diff --git a/src/backend/functions/src/system/fakeDataPopulator.ts b/src/backend/functions/src/system/fakeDataPopulator.ts index 2c3f478..2c6ab7d 100644 --- a/src/backend/functions/src/system/fakeDataPopulator.ts +++ b/src/backend/functions/src/system/fakeDataPopulator.ts @@ -4,7 +4,12 @@ import * as faker from 'faker'; // enable short hand for console.log() function log(message: string) { console.log(`FakeDataPopulator | ${message}`); } - +const FAKE_REGION_NAME = 'cape-town' +const NUMBER_OF_FAKE_MERCHANTS = 10 +const NUMBER_OF_FAKE_PRODUCTS_PER_MERCHANTS = 30 +const MERCHANTS_COLLECTION = 'merchants' +const REGIONS_COLLECTION = 'regions' +const PRODUCTS_COLLECTION = 'products' /** * A class that helps with populating a local firestore database */ @@ -35,33 +40,42 @@ export class FakeDataPopulator { private async generateRegions() { log('generateRegions'); - await this.firestoreDatabase.collection('regions').doc('cape-town').set({}); + await this.firestoreDatabase.collection(REGIONS_COLLECTION).doc(FAKE_REGION_NAME).set({}); } private async generateMerchants() { log('generateMerchants'); - for (let index = 0; index < 30; index++) { + for (let index = 0; index < NUMBER_OF_FAKE_MERCHANTS; index++) { let merchant = { 'name': faker.commerce.productName(), - 'image': faker.image.imageUrl(640, 640, 'food'), + 'images': [ + faker.image.imageUrl(1024, 640, 'food', true), + faker.image.imageUrl(1024, 640, 'food', true), + faker.image.imageUrl(1024, 640, 'food', true), + ], 'categories': [ faker.commerce.department(), faker.commerce.department() ], - 'rating': faker.datatype.float(2), + 'rating': faker.datatype.float({ + min: 0, + max: 5, + precision: 2 + }), 'numberOfRatings': faker.datatype.number(200), }; - let merchantId = await this.createMerchantDocument(merchant); + let merchantId = + await this.createMerchantDocumentForSpecificRegion(merchant, FAKE_REGION_NAME); await this.generateMerchantsProducts(merchantId); } } - private async generateMerchantsProducts(merchatId: string) { - log(`generateMerchantsProducts merchatId:${merchatId}`); + private async generateMerchantsProducts(merchantId: string) { + log(`generateMerchantsProducts merchatId:${merchantId}`); - for (let index = 0; index < 30; index++) { + for (let index = 0; index < NUMBER_OF_FAKE_PRODUCTS_PER_MERCHANTS; index++) { let product = { 'name': faker.commerce.productName(), 'description': faker.lorem.paragraph(2), @@ -70,17 +84,16 @@ export class FakeDataPopulator { 'price': faker.datatype.number(8999), }; - await this.createMerchantProduct(merchatId, product); + await this.createMerchantProduct(merchantId, product); } } - - private async createMerchantProduct(merchantId: string, product: any) { - await this.firestoreDatabase.collection('merchants').doc(merchantId).collection('products').add(product); + private async createMerchantDocumentForSpecificRegion(merchant: any, regionId: string): Promise { + let documentReference = await this.firestoreDatabase.collection(REGIONS_COLLECTION).doc(regionId).collection(MERCHANTS_COLLECTION).add(merchant); + return documentReference.id; } - private async createMerchantDocument(merchant: any): Promise { - let documentReference = await this.firestoreDatabase.collection('merchants').add(merchant); - return documentReference.id; + private async createMerchantProduct(merchantId: string, product: any) { + await this.firestoreDatabase.collection(REGIONS_COLLECTION).doc(FAKE_REGION_NAME).collection(MERCHANTS_COLLECTION).doc(merchantId).collection(PRODUCTS_COLLECTION).add(product) } private async createGenerateDocument(): Promise { @@ -91,4 +104,5 @@ export class FakeDataPopulator { private getGenerateDocument(): firestore.DocumentReference { return this.firestoreDatabase.collection('data').doc('generate'); } + } \ No newline at end of file diff --git a/src/clients/box_ui/example/lib/example_view.dart b/src/clients/box_ui/example/lib/example_view.dart index b9e9fc8..00f9e94 100644 --- a/src/clients/box_ui/example/lib/example_view.dart +++ b/src/clients/box_ui/example/lib/example_view.dart @@ -17,11 +17,46 @@ class ExampleView extends StatelessWidget { ...buttonWidgets, ...textWidgets, ...inputFields, + ...largeRestaurantItems, ], ), ); } + List get largeRestaurantItems => [ + verticalSpaceLarge, + BoxText.headline('LargeMerchantItem'), + verticalSpaceMedium, + LargeMerchantItem( + name: 'McDonald', + images: [ + 'https://baconmockup.com/640/360', + 'https://baconmockup.com/641/360', + 'https://baconmockup.com/639/360', + 'https://baconmockup.com/638/360' + ], + categories: ['Arabic', 'Turkish', 'Chinese'], + deliveryCost: 4.2, + deliveryInMinutes: 26, + rating: 3.4, + numberOfRatings: 41, + ), + verticalSpaceMedium, + LargeMerchantItem( + name: 'McDonald', + images: [ + 'https://baconmockup.com/640/360', + 'https://baconmockup.com/641/360', + 'https://baconmockup.com/639/360', + 'https://baconmockup.com/638/360' + ], + categories: ['Arabic', 'Turkish', 'Chinese'], + deliveryCost: 0, + deliveryInMinutes: 26, + rating: 3.4, + numberOfRatings: 41, + ) + ]; List get textWidgets => [ BoxText.headline('Text Styles'), verticalSpaceMedium, diff --git a/src/clients/box_ui/lib/box_ui.dart b/src/clients/box_ui/lib/box_ui.dart index daab696..b9f56ae 100644 --- a/src/clients/box_ui/lib/box_ui.dart +++ b/src/clients/box_ui/lib/box_ui.dart @@ -5,6 +5,7 @@ export 'src/widgets/box_text.dart'; export 'src/widgets/box_button.dart'; export 'src/widgets/box_input_field.dart'; export 'src/widgets/autocomplete_listItem.dart'; +export 'src/widgets/large_merchants_item.dart'; // Colors Export export 'src/shared/app_colors.dart'; diff --git a/src/clients/box_ui/lib/src/shared/app_colors.dart b/src/clients/box_ui/lib/src/shared/app_colors.dart index 88ebf43..fb2ce1a 100644 --- a/src/clients/box_ui/lib/src/shared/app_colors.dart +++ b/src/clients/box_ui/lib/src/shared/app_colors.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; const Color kcPrimaryColor = Color(0xff22A45D); +const Color kcDeepGreyColor = Color(0xff010F07); const Color kcMediumGreyColor = Color(0xff868686); +const Color kcSemiLightColor = Color(0xffD8D8D8); const Color kcLightGreyColor = Color(0xffe5e5e5); const Color kcVeryLightGreyColor = Color(0xfff2f2f2); diff --git a/src/clients/box_ui/lib/src/shared/ui_helpers.dart b/src/clients/box_ui/lib/src/shared/ui_helpers.dart index 1dcbe36..ef95be2 100644 --- a/src/clients/box_ui/lib/src/shared/ui_helpers.dart +++ b/src/clients/box_ui/lib/src/shared/ui_helpers.dart @@ -1,4 +1,3 @@ -// Horizontal Spacing import 'package:flutter/material.dart'; const Widget horizontalSpaceTiny = SizedBox(width: 5.0); @@ -23,3 +22,5 @@ double screenHeightPercentage(BuildContext context, {double percentage = 1}) => double screenWidthPercentage(BuildContext context, {double percentage = 1}) => screenWidth(context) * percentage; + +const double screenHorizontalPadding = 16; diff --git a/src/clients/box_ui/lib/src/widgets/large_merchants_item.dart b/src/clients/box_ui/lib/src/widgets/large_merchants_item.dart new file mode 100644 index 0000000..88439ae --- /dev/null +++ b/src/clients/box_ui/lib/src/widgets/large_merchants_item.dart @@ -0,0 +1,96 @@ +import 'package:box_ui/box_ui.dart'; +import 'package:box_ui/src/shared/styles.dart'; +import 'package:flutter/material.dart'; + +import 'large_merchants_item_images_carousel.dart'; + +class LargeMerchantItem extends StatelessWidget { + final List images; + final String name; + final List categories; + final double? rating; + final int? numberOfRatings; + final int? deliveryInMinutes; + final double? deliveryCost; + final bool isClosed; + + const LargeMerchantItem( + {Key? key, + required this.images, + required this.name, + required this.categories, + this.deliveryInMinutes, + this.deliveryCost, + this.rating, + this.numberOfRatings, + this.isClosed = false}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LargeMerchantsItemImagesCarousel( + images: images, + ), + verticalSpaceSmall, + BoxText.subheading(name), + verticalSpaceTiny, + BoxText.body( + categories.join(' • '), + color: kcMediumGreyColor, + ), + verticalSpaceSmall, + Row( + children: [ + if (rating != null) ...[ + BoxText.caption( + rating.toString(), + ), + horizontalSpaceTiny, + Icon( + Icons.star_rounded, + color: kcPrimaryColor, + size: 15, + ), + horizontalSpaceTiny, + BoxText.caption( + numberOfRatings.toString() + ' Ratings', + ), + horizontalSpaceTiny, + ], + if (deliveryInMinutes != null) ...[ + Icon( + Icons.watch_later_rounded, + color: Colors.black.withOpacity(0.6), + size: 15, + ), + horizontalSpaceTiny, + BoxText.caption( + deliveryInMinutes.toString() + ' Min', + ), + horizontalSpaceTiny, + ], + if (deliveryCost != null) ...[ + BoxText.body( + '•', + color: kcMediumGreyColor.withOpacity(0.5), + ), + horizontalSpaceTiny, + Icon( + Icons.attach_money_rounded, + color: kcMediumGreyColor, + size: 15, + ), + horizontalSpaceTiny, + BoxText.caption( + deliveryCost == 0.0 ? 'Free' : deliveryCost.toString(), + ), + ] + ], + ) + ], + ); + } +} diff --git a/src/clients/box_ui/lib/src/widgets/large_merchants_item_images_carousel.dart b/src/clients/box_ui/lib/src/widgets/large_merchants_item_images_carousel.dart new file mode 100644 index 0000000..6f66a93 --- /dev/null +++ b/src/clients/box_ui/lib/src/widgets/large_merchants_item_images_carousel.dart @@ -0,0 +1,80 @@ +import 'package:box_ui/src/shared/app_colors.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; + +class LargeMerchantsItemImagesCarousel extends StatefulWidget { + final List images; + const LargeMerchantsItemImagesCarousel({Key? key, required this.images}) + : super(key: key); + + @override + _LargeMerchantsItemImagesCarouselState createState() => + _LargeMerchantsItemImagesCarouselState(); +} + +class _LargeMerchantsItemImagesCarouselState + extends State { + int _currentIndex = 0; + @override + Widget build(BuildContext context) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CarouselSlider( + items: widget.images + .map((image) => Container( + width: double.infinity, + color: kcLightGreyColor, + child: Image.network( + image, + fit: BoxFit.cover, + ), + )) + .toList(), + options: CarouselOptions( + aspectRatio: 1.8, + viewportFraction: 1, + onPageChanged: (currentPageIndex, _) { + setState(() { + _currentIndex = currentPageIndex; + }); + })), + ), + _CarouselCustomIndexWidget( + currentIndex: _currentIndex, + images: widget.images, + ) + ], + ); + } +} + +class _CarouselCustomIndexWidget extends StatelessWidget { + final List images; + final int currentIndex; + const _CarouselCustomIndexWidget( + {Key? key, required this.currentIndex, required this.images}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 20, + right: 20, + child: Row( + children: [ + ...images.asMap().entries.map((map) => Container( + margin: const EdgeInsets.only(right: 8), + width: 8, + height: 5, + decoration: BoxDecoration( + color: Colors.white + .withOpacity(currentIndex == map.key ? 1 : 0.3), + borderRadius: BorderRadius.circular(32)), + )) + ], + ), + ); + } +} diff --git a/src/clients/box_ui/pubspec.yaml b/src/clients/box_ui/pubspec.yaml index 1524fcd..c6ed7e8 100644 --- a/src/clients/box_ui/pubspec.yaml +++ b/src/clients/box_ui/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: flutter: sdk: flutter - + carousel_slider: ^4.0.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/src/clients/customer/lib/api/firestore_api.dart b/src/clients/customer/lib/api/firestore_api.dart index 3bd7359..a7d19f4 100644 --- a/src/clients/customer/lib/api/firestore_api.dart +++ b/src/clients/customer/lib/api/firestore_api.dart @@ -4,6 +4,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:customer/constants/app_keys.dart'; import 'package:customer/exceptions/firestore_api_exception.dart'; import 'package:customer/models/application_models.dart'; +import 'package:customer/extensions/string_extensions.dart'; class FirestoreApi { final log = getLogger('FirestoreApi'); @@ -61,7 +62,7 @@ class FirestoreApi { log.i('address:$address'); try { - final addressDoc = getAddressCollectionForUser(user.id).doc(); + final addressDoc = _getAddressCollectionForUser(user.id).doc(); final newAddressId = addressDoc.id; log.v('Address will be stored with id: $newAddressId'); @@ -84,8 +85,8 @@ class FirestoreApi { } return true; - } on Exception catch (e) { - log.e('we could not save the users address. $e'); + } on Exception catch (error) { + log.e('we could not save the users address. $error'); return false; } } @@ -96,7 +97,76 @@ class FirestoreApi { return cityDocument.exists; } - CollectionReference getAddressCollectionForUser(String userId) { + CollectionReference _getAddressCollectionForUser(String userId) { return usersCollection.doc(userId).collection(AddressesFirestoreKey); } + + Future> getAddressListForUser(String userId) async { + log.i('userId:$userId'); + try { + final addressCollection = + await _getAddressCollectionForUser(userId).get(); + log.v('addressCollection: ${addressCollection.toString()}'); + + List
addresses = addressCollection.docs.map((address) { + return Address.fromJson(address.data()); + }).toList(); + return addresses; + } catch (error) { + throw FirestoreApiException( + devDetails: error.toString(), + message: "getAddressListForUser() failed,", + ); + } + } + + String extractRegionIdFromUserAddresses( + {required List
addresses, + required String userDefaultAddressId}) { + log.i('addresses:$addresses, userDefaultAddressId:$userDefaultAddressId'); + try { + return addresses + .firstWhere( + (address) => address.id == userDefaultAddressId, + ) + .city! + .toCityDocument; + } on StateError catch (error) { + throw FirestoreApiException( + devDetails: error.toString(), + message: + "we couldn't found the default address of the user in our address collection", + ); + } + } + + Future> getMerchantsCollectionForRegion( + {required String regionId}) async { + log.i('regionId:$regionId'); + try { + final regionCollections = await regionsCollection + .doc(regionId) + .collection(MerchantsFirestoreKey) + .get(); + if (regionCollections.docs.isEmpty) { + log.w('We have no merchants in this region'); + return []; + } + + final regionCollectionsDocuments = regionCollections.docs; + log.v( + 'for regionId: $regionId, Merchants fetched: $regionCollectionsDocuments'); + List merchants = regionCollectionsDocuments.map((merchant) { + var data = merchant.data(); + data.putIfAbsent('id', () => merchant.id); + return Merchant.fromJson(data); + }).toList(); + return merchants; + } catch (error) { + throw FirestoreApiException( + devDetails: error.toString(), + message: + 'An error ocurred while calling getMerchantsCollectionForRegion()'); + } + } } diff --git a/src/clients/customer/lib/constants/app_keys.dart b/src/clients/customer/lib/constants/app_keys.dart index fc4c125..d9d04be 100644 --- a/src/clients/customer/lib/constants/app_keys.dart +++ b/src/clients/customer/lib/constants/app_keys.dart @@ -5,3 +5,4 @@ const String NoKey = 'NO_KEY'; const String UsersFirestoreKey = 'users'; const String AddressesFirestoreKey = 'addresses'; const String RegionsFirestoreKey = 'regions'; +const String MerchantsFirestoreKey = 'merchants'; diff --git a/src/clients/customer/lib/models/application_models.dart b/src/clients/customer/lib/models/application_models.dart index 1a0625e..e72d7ef 100644 --- a/src/clients/customer/lib/models/application_models.dart +++ b/src/clients/customer/lib/models/application_models.dart @@ -19,7 +19,7 @@ class User with _$User { } @freezed -abstract class Address with _$Address { +class Address with _$Address { factory Address({ String? id, required String placeId, @@ -34,3 +34,17 @@ abstract class Address with _$Address { factory Address.fromJson(Map json) => _$AddressFromJson(json); } + +@freezed +class Merchant with _$Merchant { + factory Merchant( + {required String id, + List? categories, + List? images, + String? name, + int? numberOfRatings, + double? rating}) = _Merchant; + + factory Merchant.fromJson(Map json) => + _$MerchantFromJson(json); +} diff --git a/src/clients/customer/lib/models/application_models.freezed.dart b/src/clients/customer/lib/models/application_models.freezed.dart index 6a88349..ca0c3d2 100644 --- a/src/clients/customer/lib/models/application_models.freezed.dart +++ b/src/clients/customer/lib/models/application_models.freezed.dart @@ -512,3 +512,273 @@ abstract class _Address implements Address { _$AddressCopyWith<_Address> get copyWith => throw _privateConstructorUsedError; } + +Merchant _$MerchantFromJson(Map json) { + return _Merchant.fromJson(json); +} + +/// @nodoc +class _$MerchantTearOff { + const _$MerchantTearOff(); + + _Merchant call( + {required String id, + List? categories, + List? images, + String? name, + int? numberOfRatings, + double? rating}) { + return _Merchant( + id: id, + categories: categories, + images: images, + name: name, + numberOfRatings: numberOfRatings, + rating: rating, + ); + } + + Merchant fromJson(Map json) { + return Merchant.fromJson(json); + } +} + +/// @nodoc +const $Merchant = _$MerchantTearOff(); + +/// @nodoc +mixin _$Merchant { + String get id => throw _privateConstructorUsedError; + List? get categories => throw _privateConstructorUsedError; + List? get images => throw _privateConstructorUsedError; + String? get name => throw _privateConstructorUsedError; + int? get numberOfRatings => throw _privateConstructorUsedError; + double? get rating => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $MerchantCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MerchantCopyWith<$Res> { + factory $MerchantCopyWith(Merchant value, $Res Function(Merchant) then) = + _$MerchantCopyWithImpl<$Res>; + $Res call( + {String id, + List? categories, + List? images, + String? name, + int? numberOfRatings, + double? rating}); +} + +/// @nodoc +class _$MerchantCopyWithImpl<$Res> implements $MerchantCopyWith<$Res> { + _$MerchantCopyWithImpl(this._value, this._then); + + final Merchant _value; + // ignore: unused_field + final $Res Function(Merchant) _then; + + @override + $Res call({ + Object? id = freezed, + Object? categories = freezed, + Object? images = freezed, + Object? name = freezed, + Object? numberOfRatings = freezed, + Object? rating = freezed, + }) { + return _then(_value.copyWith( + id: id == freezed + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + categories: categories == freezed + ? _value.categories + : categories // ignore: cast_nullable_to_non_nullable + as List?, + images: images == freezed + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List?, + name: name == freezed + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + numberOfRatings: numberOfRatings == freezed + ? _value.numberOfRatings + : numberOfRatings // ignore: cast_nullable_to_non_nullable + as int?, + rating: rating == freezed + ? _value.rating + : rating // ignore: cast_nullable_to_non_nullable + as double?, + )); + } +} + +/// @nodoc +abstract class _$MerchantCopyWith<$Res> implements $MerchantCopyWith<$Res> { + factory _$MerchantCopyWith(_Merchant value, $Res Function(_Merchant) then) = + __$MerchantCopyWithImpl<$Res>; + @override + $Res call( + {String id, + List? categories, + List? images, + String? name, + int? numberOfRatings, + double? rating}); +} + +/// @nodoc +class __$MerchantCopyWithImpl<$Res> extends _$MerchantCopyWithImpl<$Res> + implements _$MerchantCopyWith<$Res> { + __$MerchantCopyWithImpl(_Merchant _value, $Res Function(_Merchant) _then) + : super(_value, (v) => _then(v as _Merchant)); + + @override + _Merchant get _value => super._value as _Merchant; + + @override + $Res call({ + Object? id = freezed, + Object? categories = freezed, + Object? images = freezed, + Object? name = freezed, + Object? numberOfRatings = freezed, + Object? rating = freezed, + }) { + return _then(_Merchant( + id: id == freezed + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + categories: categories == freezed + ? _value.categories + : categories // ignore: cast_nullable_to_non_nullable + as List?, + images: images == freezed + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List?, + name: name == freezed + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + numberOfRatings: numberOfRatings == freezed + ? _value.numberOfRatings + : numberOfRatings // ignore: cast_nullable_to_non_nullable + as int?, + rating: rating == freezed + ? _value.rating + : rating // ignore: cast_nullable_to_non_nullable + as double?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$_Merchant implements _Merchant { + _$_Merchant( + {required this.id, + this.categories, + this.images, + this.name, + this.numberOfRatings, + this.rating}); + + factory _$_Merchant.fromJson(Map json) => + _$_$_MerchantFromJson(json); + + @override + final String id; + @override + final List? categories; + @override + final List? images; + @override + final String? name; + @override + final int? numberOfRatings; + @override + final double? rating; + + @override + String toString() { + return 'Merchant(id: $id, categories: $categories, images: $images, name: $name, numberOfRatings: $numberOfRatings, rating: $rating)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other is _Merchant && + (identical(other.id, id) || + const DeepCollectionEquality().equals(other.id, id)) && + (identical(other.categories, categories) || + const DeepCollectionEquality() + .equals(other.categories, categories)) && + (identical(other.images, images) || + const DeepCollectionEquality().equals(other.images, images)) && + (identical(other.name, name) || + const DeepCollectionEquality().equals(other.name, name)) && + (identical(other.numberOfRatings, numberOfRatings) || + const DeepCollectionEquality() + .equals(other.numberOfRatings, numberOfRatings)) && + (identical(other.rating, rating) || + const DeepCollectionEquality().equals(other.rating, rating))); + } + + @override + int get hashCode => + runtimeType.hashCode ^ + const DeepCollectionEquality().hash(id) ^ + const DeepCollectionEquality().hash(categories) ^ + const DeepCollectionEquality().hash(images) ^ + const DeepCollectionEquality().hash(name) ^ + const DeepCollectionEquality().hash(numberOfRatings) ^ + const DeepCollectionEquality().hash(rating); + + @JsonKey(ignore: true) + @override + _$MerchantCopyWith<_Merchant> get copyWith => + __$MerchantCopyWithImpl<_Merchant>(this, _$identity); + + @override + Map toJson() { + return _$_$_MerchantToJson(this); + } +} + +abstract class _Merchant implements Merchant { + factory _Merchant( + {required String id, + List? categories, + List? images, + String? name, + int? numberOfRatings, + double? rating}) = _$_Merchant; + + factory _Merchant.fromJson(Map json) = _$_Merchant.fromJson; + + @override + String get id => throw _privateConstructorUsedError; + @override + List? get categories => throw _privateConstructorUsedError; + @override + List? get images => throw _privateConstructorUsedError; + @override + String? get name => throw _privateConstructorUsedError; + @override + int? get numberOfRatings => throw _privateConstructorUsedError; + @override + double? get rating => throw _privateConstructorUsedError; + @override + @JsonKey(ignore: true) + _$MerchantCopyWith<_Merchant> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/src/clients/customer/lib/models/application_models.g.dart b/src/clients/customer/lib/models/application_models.g.dart index f42969c..0fb2fea 100644 --- a/src/clients/customer/lib/models/application_models.g.dart +++ b/src/clients/customer/lib/models/application_models.g.dart @@ -44,3 +44,27 @@ Map _$_$_AddressToJson(_$_Address instance) => 'state': instance.state, 'postalCode': instance.postalCode, }; + +_$_Merchant _$_$_MerchantFromJson(Map json) { + return _$_Merchant( + id: json['id'] as String, + categories: (json['categories'] as List?) + ?.map((e) => e as String) + .toList(), + images: + (json['images'] as List?)?.map((e) => e as String).toList(), + name: json['name'] as String?, + numberOfRatings: json['numberOfRatings'] as int?, + rating: (json['rating'] as num?)?.toDouble(), + ); +} + +Map _$_$_MerchantToJson(_$_Merchant instance) => + { + 'id': instance.id, + 'categories': instance.categories, + 'images': instance.images, + 'name': instance.name, + 'numberOfRatings': instance.numberOfRatings, + 'rating': instance.rating, + }; diff --git a/src/clients/customer/lib/ui/home/home_view.dart b/src/clients/customer/lib/ui/home/home_view.dart index af95df6..b4f8fd4 100644 --- a/src/clients/customer/lib/ui/home/home_view.dart +++ b/src/clients/customer/lib/ui/home/home_view.dart @@ -1,3 +1,4 @@ +import 'package:box_ui/box_ui.dart'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; @@ -9,7 +10,44 @@ class HomeView extends StatelessWidget { @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( - builder: (context, model, child) => Scaffold(), + builder: (context, model, child) => Scaffold( + body: model.isBusy + ? Center( + child: CircularProgressIndicator(), + ) + : model.hasError + ? Center( + child: BoxText.headingThree( + 'An error has occered while running the future', + align: TextAlign.center, + ), + ) + : model.data!.isEmpty + ? Center( + child: BoxText.headingThree( + 'There is currently no merchants for this region', + align: TextAlign.center, + ), + ) + : ListView.builder( + padding: EdgeInsets.symmetric( + vertical: screenHeightPercentage(context, + percentage: 0.1), + horizontal: screenHorizontalPadding), + itemCount: model.data!.length, + itemBuilder: (context, index) { + final merchantItem = model.data![index]; + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: LargeMerchantItem( + images: merchantItem.images ?? [], + categories: merchantItem.categories ?? [], + name: merchantItem.name ?? '', + rating: merchantItem.rating, + numberOfRatings: merchantItem.numberOfRatings, + ), + ); + })), viewModelBuilder: () => HomeViewModel(), ); } diff --git a/src/clients/customer/lib/ui/home/home_viewmodel.dart b/src/clients/customer/lib/ui/home/home_viewmodel.dart index cc90252..40732eb 100644 --- a/src/clients/customer/lib/ui/home/home_viewmodel.dart +++ b/src/clients/customer/lib/ui/home/home_viewmodel.dart @@ -1,3 +1,39 @@ +import 'package:customer/api/firestore_api.dart'; +import 'package:customer/app/app.locator.dart'; +import 'package:customer/exceptions/firestore_api_exception.dart'; +import 'package:customer/models/application_models.dart'; +import 'package:customer/services/user_service.dart'; import 'package:stacked/stacked.dart'; +import 'package:customer/app/app.logger.dart'; -class HomeViewModel extends BaseViewModel {} \ No newline at end of file +class HomeViewModel extends FutureViewModel> { + final log = getLogger('HomeViewModel'); + + final _fireStoreApi = locator(); + final _userService = locator(); + + Future> getMerchantsForRegion() async { + try { + log.i( + "fetch merchints from firestore where user: ${_userService.currentUser}"); + + final userAddresses = await _fireStoreApi + .getAddressListForUser(_userService.currentUser.id); + final regionId = _fireStoreApi.extractRegionIdFromUserAddresses( + addresses: userAddresses, + userDefaultAddressId: _userService.currentUser.defaultAddress!); + + final merchants = await _fireStoreApi.getMerchantsCollectionForRegion( + regionId: regionId); + + log.v('List of merchants: ${merchants.toString()}'); + return merchants; + } on FirestoreApiException catch (error) { + log.e(error.toString()); + throw Exception('An error happened while fetching merchints'); + } + } + + @override + Future> futureToRun() => getMerchantsForRegion(); +} diff --git a/src/clients/customer/lib/ui/startup/startup_viewmodel.dart b/src/clients/customer/lib/ui/startup/startup_viewmodel.dart index 65db851..686e3d7 100644 --- a/src/clients/customer/lib/ui/startup/startup_viewmodel.dart +++ b/src/clients/customer/lib/ui/startup/startup_viewmodel.dart @@ -1,3 +1,4 @@ +import 'package:customer/api/firestore_api.dart'; import 'package:customer/app/app.locator.dart'; import 'package:customer/app/app.logger.dart'; import 'package:customer/app/app.router.dart'; diff --git a/src/clients/customer/pubspec.lock b/src/clients/customer/pubspec.lock index 1b547bc..16104d7 100644 --- a/src/clients/customer/pubspec.lock +++ b/src/clients/customer/pubspec.lock @@ -106,6 +106,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "8.0.5" + carousel_slider: + dependency: transitive + description: + name: carousel_slider + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" characters: dependency: transitive description: diff --git a/src/clients/customer/test/helpers/test_helpers.dart b/src/clients/customer/test/helpers/test_helpers.dart index 5434eb4..76d43c4 100644 --- a/src/clients/customer/test/helpers/test_helpers.dart +++ b/src/clients/customer/test/helpers/test_helpers.dart @@ -91,16 +91,24 @@ MockDialogService getAndRegisterDialogService() { return service; } -MockFirestoreApi getAndRegisterFirestoreApi({ - bool saveAddressSuccess = true, - bool isCityServiced = true, -}) { +MockFirestoreApi getAndRegisterFirestoreApi( + {bool saveAddressSuccess = true, + bool isCityServiced = true, + List
? userAdresses, + String? regionId}) { _removeRegistrationIfExists(); final service = MockFirestoreApi(); when(service.isCityServiced(city: anyNamed('city'))) .thenAnswer((realInvocation) => Future.value(isCityServiced)); - + when(service.getAddressListForUser(any)) + .thenAnswer((realInvocation) => Future.value(userAdresses ?? [])); + when(service.getMerchantsCollectionForRegion(regionId: anyNamed('regionId'))) + .thenAnswer((realInvocation) => Future.value([])); + when(service.extractRegionIdFromUserAddresses( + addresses: userAdresses ?? anyNamed('addresses'), + userDefaultAddressId: anyNamed('userDefaultAddressId'))) + .thenReturn(regionId ?? 'RegionId'); when(service.saveAddress( address: anyNamed('address'), user: anyNamed('user'), diff --git a/src/clients/customer/test/helpers/test_helpers.mocks.dart b/src/clients/customer/test/helpers/test_helpers.mocks.dart index 75e2a17..09be73c 100644 --- a/src/clients/customer/test/helpers/test_helpers.mocks.dart +++ b/src/clients/customer/test/helpers/test_helpers.mocks.dart @@ -385,6 +385,10 @@ class MockFirestoreApi extends _i1.Mock implements _i16.FirestoreApi { (super.noSuchMethod(Invocation.getter(#usersCollection), returnValue: _FakeCollectionReference()) as _i5.CollectionReference); @override + _i5.CollectionReference get regionsCollection => + (super.noSuchMethod(Invocation.getter(#regionsCollection), + returnValue: _FakeCollectionReference()) as _i5.CollectionReference); + @override _i7.Future createUser({_i2.User? user}) => (super.noSuchMethod(Invocation.method(#createUser, [], {#user: user}), returnValue: Future.value(null), @@ -404,7 +408,25 @@ class MockFirestoreApi extends _i1.Mock implements _i16.FirestoreApi { (super.noSuchMethod(Invocation.method(#isCityServiced, [], {#city: city}), returnValue: Future.value(false)) as _i7.Future); @override - _i5.CollectionReference getAddressCollectionForUser(String? userId) => (super - .noSuchMethod(Invocation.method(#getAddressCollectionForUser, [userId]), - returnValue: _FakeCollectionReference()) as _i5.CollectionReference); + _i7.Future> getAddressListForUser(String? userId) => + (super.noSuchMethod(Invocation.method(#getAddressListForUser, [userId]), + returnValue: Future>.value(<_i2.Address>[])) + as _i7.Future>); + @override + String extractRegionIdFromUserAddresses( + {List<_i2.Address>? addresses, String? userDefaultAddressId}) => + (super.noSuchMethod( + Invocation.method(#extractRegionIdFromUserAddresses, [], { + #addresses: addresses, + #userDefaultAddressId: userDefaultAddressId + }), + returnValue: '') as String); + @override + _i7.Future> getMerchantsCollectionForRegion( + {String? regionId}) => + (super.noSuchMethod( + Invocation.method( + #getMerchantsCollectionForRegion, [], {#regionId: regionId}), + returnValue: Future>.value(<_i2.Merchant>[])) + as _i7.Future>); } diff --git a/src/clients/customer/test/viewmodel_tests/home_viewmodel_test.dart b/src/clients/customer/test/viewmodel_tests/home_viewmodel_test.dart new file mode 100644 index 0000000..5dedd03 --- /dev/null +++ b/src/clients/customer/test/viewmodel_tests/home_viewmodel_test.dart @@ -0,0 +1,65 @@ +import 'package:customer/models/application_models.dart'; +import 'package:customer/ui/home/home_viewmodel.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../helpers/test_helpers.dart'; + +HomeViewModel _getModel() => HomeViewModel(); + +void main() { + group('HomeViewModelTest -', () { + setUp(() => registerServices()); + tearDown(() => unregisterServices()); + group('getMerchantsForRegion -', () { + test('When called, should call getAddressListForUser from firestoreApi', + () async { + final userService = getAndRegisterUserService( + currentUser: User(id: 'id', defaultAddress: 'i-am-here')); + final firestoreApi = getAndRegisterFirestoreApi(); + + final model = _getModel(); + await model.getMerchantsForRegion(); + final userId = userService.currentUser.id; + verify(firestoreApi.getAddressListForUser(userId)); + }); + test( + 'When called, should call extractRegionIdFromUserAddresses using addresses from getAddressListForUser', + () async { + final userAdresses = [ + Address( + id: 'i-am-here', + placeId: 'placeId', + city: 'cape-town', + lattitude: 0, + longitute: 0) + ]; + final userService = getAndRegisterUserService( + currentUser: User(id: 'id', defaultAddress: 'i-am-here')); + final firestoreApi = + getAndRegisterFirestoreApi(userAdresses: userAdresses); + + final model = _getModel(); + await model.getMerchantsForRegion(); + final userDefaultAddress = userService.currentUser.defaultAddress; + + verify(firestoreApi.extractRegionIdFromUserAddresses( + addresses: userAdresses, + userDefaultAddressId: userDefaultAddress!)); + }); + test( + 'When called, should call getMerchantsCollectionForRegion using addressRegionId from extractRegionIdFromUserAddresses', + () async { + const RegionId = 'id'; + getAndRegisterUserService( + currentUser: User(id: 'id', defaultAddress: 'i-am-here')); + final firestoreApi = getAndRegisterFirestoreApi(regionId: RegionId); + + final model = _getModel(); + await model.getMerchantsForRegion(); + verify( + firestoreApi.getMerchantsCollectionForRegion(regionId: RegionId)); + }); + }); + }); +}