diff --git a/ecommerce_app/integration_test/purchase_flow_test.dart b/ecommerce_app/integration_test/purchase_flow_test.dart index c8513642..1082a743 100644 --- a/ecommerce_app/integration_test/purchase_flow_test.dart +++ b/ecommerce_app/integration_test/purchase_flow_test.dart @@ -27,6 +27,12 @@ void main() { await r.cart.openCart(); r.cart.expectFindZeroCartItems(); await r.closePage(); + // reviews flow + // await r.products.selectProduct(); + // r.reviews.expectFindLeaveReview(); + // await r.reviews.tapLeaveReviewButton(); + // await r.reviews.createAndSubmitReview(); + // r.reviews.expectFindOneReview(); // sign out await r.openPopupMenu(); await r.auth.openAccountScreen(); diff --git a/ecommerce_app/lib/src/common_widgets/async_value_widget.dart b/ecommerce_app/lib/src/common_widgets/async_value_widget.dart index 4e7492ce..49644f24 100644 --- a/ecommerce_app/lib/src/common_widgets/async_value_widget.dart +++ b/ecommerce_app/lib/src/common_widgets/async_value_widget.dart @@ -16,3 +16,23 @@ class AsyncValueWidget extends StatelessWidget { ); } } + +/// Sliver equivalent of [AsyncValueWidget] +class AsyncValueSliverWidget extends StatelessWidget { + const AsyncValueSliverWidget( + {super.key, required this.value, required this.data}); + final AsyncValue value; + final Widget Function(T) data; + + @override + Widget build(BuildContext context) { + return value.when( + data: data, + loading: () => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator())), + error: (e, st) => SliverToBoxAdapter( + child: Center(child: ErrorMessageWidget(e.toString())), + ), + ); + } +} diff --git a/ecommerce_app/lib/src/constants/test_products.dart b/ecommerce_app/lib/src/constants/test_products.dart index b4c00f37..adbfa1ce 100644 --- a/ecommerce_app/lib/src/constants/test_products.dart +++ b/ecommerce_app/lib/src/constants/test_products.dart @@ -9,8 +9,6 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 15, availableQuantity: 5, - avgRating: 4.5, - numRatings: 2, ), Product( id: '2', @@ -19,8 +17,6 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 13, availableQuantity: 5, - avgRating: 4, - numRatings: 2, ), Product( id: '3', @@ -29,8 +25,6 @@ const kTestProducts = [ description: 'Lorem ipsum', price: 17, availableQuantity: 5, - avgRating: 5, - numRatings: 2, ), Product( id: '4', diff --git a/ecommerce_app/lib/src/features/checkout/application/fake_checkout_service.dart b/ecommerce_app/lib/src/features/checkout/application/fake_checkout_service.dart index e4f9f121..db4720db 100644 --- a/ecommerce_app/lib/src/features/checkout/application/fake_checkout_service.dart +++ b/ecommerce_app/lib/src/features/checkout/application/fake_checkout_service.dart @@ -5,6 +5,7 @@ import 'package:ecommerce_app/src/features/orders/data/fake_orders_repository.da import 'package:ecommerce_app/src/features/orders/domain/order.dart'; import 'package:ecommerce_app/src/features/products/data/fake_products_repository.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/utils/current_date_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// A fake checkout service that doesn't process real payments. @@ -22,6 +23,7 @@ class FakeCheckoutService { final authRepository = ref.read(authRepositoryProvider); final remoteCartRepository = ref.read(remoteCartRepositoryProvider); final ordersRepository = ref.read(ordersRepositoryProvider); + final currentDateBuilder = ref.read(currentDateBuilderProvider); // * Assertion operator is ok here since this method is only called from // * a place where the user is signed in final uid = authRepository.currentUser!.uid; @@ -29,9 +31,7 @@ class FakeCheckoutService { final cart = await remoteCartRepository.fetchCart(uid); if (cart.items.isNotEmpty) { final total = _totalPrice(cart); - // * If we want to make this code more testable, a DateTime builder - // * should be injected as a dependency - final orderDate = DateTime.now(); + final orderDate = currentDateBuilder(); // * The orderId is a unique string that could be generated with the UUID // * package. Since this is a fake service, we just derive it from the date. final orderId = orderDate.toIso8601String(); diff --git a/ecommerce_app/lib/src/features/orders/application/user_orders_provider.dart b/ecommerce_app/lib/src/features/orders/application/user_orders_provider.dart index 877bb99c..fa2d057d 100644 --- a/ecommerce_app/lib/src/features/orders/application/user_orders_provider.dart +++ b/ecommerce_app/lib/src/features/orders/application/user_orders_provider.dart @@ -1,6 +1,7 @@ import 'package:ecommerce_app/src/features/authentication/data/fake_auth_repository.dart'; import 'package:ecommerce_app/src/features/orders/data/fake_orders_repository.dart'; import 'package:ecommerce_app/src/features/orders/domain/order.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Watch the list of user orders @@ -8,10 +9,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final userOrdersProvider = StreamProvider.autoDispose>((ref) { final user = ref.watch(authStateChangesProvider).value; if (user != null) { - final ordersRepository = ref.watch(ordersRepositoryProvider); - return ordersRepository.watchUserOrders(user.uid); + return ref.watch(ordersRepositoryProvider).watchUserOrders(user.uid); } else { - // If the user is null, just return an empty screen. - return const Stream.empty(); + // If the user is null, return an empty list (no orders) + return Stream.value([]); + } +}); + +/// Check if a product was previously purchased by the user +final matchingUserOrdersProvider = + StreamProvider.autoDispose.family, ProductID>((ref, productId) { + final user = ref.watch(authStateChangesProvider).value; + if (user != null) { + return ref + .watch(ordersRepositoryProvider) + .watchUserOrders(user.uid, productId: productId); + } else { + // If the user is null, return an empty list (no orders) + return Stream.value([]); } }); diff --git a/ecommerce_app/lib/src/features/orders/data/fake_orders_repository.dart b/ecommerce_app/lib/src/features/orders/data/fake_orders_repository.dart index 1d63a509..c1fe12d1 100644 --- a/ecommerce_app/lib/src/features/orders/data/fake_orders_repository.dart +++ b/ecommerce_app/lib/src/features/orders/data/fake_orders_repository.dart @@ -1,4 +1,5 @@ import 'package:ecommerce_app/src/features/orders/domain/order.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:ecommerce_app/src/utils/delay.dart'; import 'package:ecommerce_app/src/utils/in_memory_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,14 +13,22 @@ class FakeOrdersRepository { /// - value: list of orders for that user final _orders = InMemoryStore>>({}); - // A stream that returns all the orders for a given user, ordered by date - Stream> watchUserOrders(String uid) { + /// A stream that returns all the orders for a given user, ordered by date + /// Only user orders that match the given productId will be returned. + /// If a productId is not passed, all user orders will be returned. + Stream> watchUserOrders(String uid, {ProductID? productId}) { return _orders.stream.map((ordersData) { final ordersList = ordersData[uid] ?? []; ordersList.sort( (lhs, rhs) => rhs.orderDate.compareTo(lhs.orderDate), ); - return ordersList; + if (productId != null) { + return ordersList + .where((order) => order.items.keys.contains(productId)) + .toList(); + } else { + return ordersList; + } }); } diff --git a/ecommerce_app/lib/src/features/orders/domain/order.dart b/ecommerce_app/lib/src/features/orders/domain/order.dart index 65de68a6..4bf857ec 100644 --- a/ecommerce_app/lib/src/features/orders/domain/order.dart +++ b/ecommerce_app/lib/src/features/orders/domain/order.dart @@ -1,4 +1,5 @@ import 'package:ecommerce_app/src/exceptions/app_exception.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; /// Order status enum OrderStatus { confirmed, shipped, delivered } @@ -34,7 +35,7 @@ class Order { final String userId; /// List of items in that order - final Map items; + final Map items; final OrderStatus orderStatus; final DateTime orderDate; final double total; diff --git a/ecommerce_app/lib/src/features/orders/domain/purchase.dart b/ecommerce_app/lib/src/features/orders/domain/purchase.dart deleted file mode 100644 index 980161f3..00000000 --- a/ecommerce_app/lib/src/features/orders/domain/purchase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:ecommerce_app/src/features/orders/domain/order.dart'; - -/// Model class containing order details that need to be shown in the product -/// page if the product was purchased by the current user. -class Purchase { - const Purchase({ - required this.orderId, - required this.orderDate, - }); - final OrderID orderId; - final DateTime orderDate; -} diff --git a/ecommerce_app/lib/src/features/products/data/fake_products_repository.dart b/ecommerce_app/lib/src/features/products/data/fake_products_repository.dart index 1e10eed3..8c5dc3cb 100644 --- a/ecommerce_app/lib/src/features/products/data/fake_products_repository.dart +++ b/ecommerce_app/lib/src/features/products/data/fake_products_repository.dart @@ -1,35 +1,54 @@ +import 'dart:async'; + import 'package:ecommerce_app/src/constants/test_products.dart'; import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:ecommerce_app/src/utils/delay.dart'; +import 'package:ecommerce_app/src/utils/in_memory_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class FakeProductsRepository { FakeProductsRepository({this.addDelay = true}); final bool addDelay; - final List _products = kTestProducts; + + /// Preload with the default list of products when the app starts + final _products = InMemoryStore>(List.from(kTestProducts)); List getProductsList() { - return _products; + return _products.value; } Product? getProduct(String id) { - return _getProduct(_products, id); + return _getProduct(_products.value, id); } Future> fetchProductsList() async { await delay(addDelay); - return Future.value(_products); + return Future.value(_products.value); } - Stream> watchProductsList() async* { - await delay(addDelay); - yield _products; + Stream> watchProductsList() { + return _products.stream; } Stream watchProduct(String id) { return watchProductsList().map((products) => _getProduct(products, id)); } + /// Update product or add a new one + Future setProduct(Product product) async { + await delay(addDelay); + final products = _products.value; + final index = products.indexWhere((p) => p.id == product.id); + if (index == -1) { + // if not found, add as a new product + products.add(product); + } else { + // else, overwrite previous product + products[index] = product; + } + _products.value = products; + } + static Product? _getProduct(List products, String id) { try { return products.firstWhere((product) => product.id == id); diff --git a/ecommerce_app/lib/src/features/products/domain/product.dart b/ecommerce_app/lib/src/features/products/domain/product.dart index a0987bd6..3368b170 100644 --- a/ecommerce_app/lib/src/features/products/domain/product.dart +++ b/ecommerce_app/lib/src/features/products/domain/product.dart @@ -55,4 +55,26 @@ class Product { avgRating.hashCode ^ numRatings.hashCode; } + + Product copyWith({ + ProductID? id, + String? imageUrl, + String? title, + String? description, + double? price, + int? availableQuantity, + double? avgRating, + int? numRatings, + }) { + return Product( + id: id ?? this.id, + imageUrl: imageUrl ?? this.imageUrl, + title: title ?? this.title, + description: description ?? this.description, + price: price ?? this.price, + availableQuantity: availableQuantity ?? this.availableQuantity, + avgRating: avgRating ?? this.avgRating, + numRatings: numRatings ?? this.numRatings, + ); + } } diff --git a/ecommerce_app/lib/src/features/products/presentation/product_screen/leave_review_action.dart b/ecommerce_app/lib/src/features/products/presentation/product_screen/leave_review_action.dart index 4db54cd5..715e10c2 100644 --- a/ecommerce_app/lib/src/features/products/presentation/product_screen/leave_review_action.dart +++ b/ecommerce_app/lib/src/features/products/presentation/product_screen/leave_review_action.dart @@ -1,4 +1,5 @@ -import 'package:ecommerce_app/src/features/orders/domain/purchase.dart'; +import 'package:ecommerce_app/src/features/orders/domain/order.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/routing/app_router.dart'; import 'package:ecommerce_app/src/utils/date_formatter.dart'; @@ -13,16 +14,24 @@ import 'package:go_router/go_router.dart'; /// leave a review. class LeaveReviewAction extends ConsumerWidget { const LeaveReviewAction({super.key, required this.productId}); - final String productId; + final ProductID productId; @override Widget build(BuildContext context, WidgetRef ref) { // TODO: Read from data source - final purchase = Purchase(orderId: 'abc', orderDate: DateTime.now()); - // ignore: unnecessary_null_comparison - if (purchase != null) { + final orders = [ + Order( + id: 'abc', + userId: '123', + items: {productId: 1}, + orderStatus: OrderStatus.confirmed, + orderDate: DateTime.now(), + total: 15.0, + ) + ]; + if (orders.isNotEmpty) { final dateFormatted = - ref.watch(dateFormatterProvider).format(purchase.orderDate); + ref.watch(dateFormatterProvider).format(orders.first.orderDate); return Column( children: [ const Divider(), @@ -52,7 +61,7 @@ class LeaveReviewAction extends ConsumerWidget { ], ); } else { - return const SizedBox(); + return const SizedBox.shrink(); } } } diff --git a/ecommerce_app/lib/src/features/products/presentation/product_screen/product_screen.dart b/ecommerce_app/lib/src/features/products/presentation/product_screen/product_screen.dart index 5bea4236..4a592de7 100644 --- a/ecommerce_app/lib/src/features/products/presentation/product_screen/product_screen.dart +++ b/ecommerce_app/lib/src/features/products/presentation/product_screen/product_screen.dart @@ -19,7 +19,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Shows the product page for a given product ID. class ProductScreen extends StatelessWidget { const ProductScreen({super.key, required this.productId}); - final String productId; + final ProductID productId; @override Widget build(BuildContext context) { diff --git a/ecommerce_app/lib/src/features/reviews/application/reviews_service.dart b/ecommerce_app/lib/src/features/reviews/application/reviews_service.dart new file mode 100644 index 00000000..0fddb359 --- /dev/null +++ b/ecommerce_app/lib/src/features/reviews/application/reviews_service.dart @@ -0,0 +1,59 @@ +import 'package:ecommerce_app/src/features/authentication/data/fake_auth_repository.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; +import 'package:ecommerce_app/src/features/reviews/data/fake_reviews_repository.dart'; +import 'package:ecommerce_app/src/features/reviews/domain/review.dart'; +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ReviewsService { + ReviewsService(this.ref); + final Ref ref; + + Future submitReview({ + required ProductID productId, + required Review review, + }) async { + final user = ref.read(authRepositoryProvider).currentUser; + // * we should only call this method when the user is signed in + assert(user != null); + if (user == null) { + throw AssertionError( + 'Can\'t submit a review if the user is not signed in'.hardcoded); + } + await ref.read(reviewsRepositoryProvider).setReview( + productId: productId, + uid: user.uid, + review: review, + ); + } +} + +final reviewsServiceProvider = Provider((ref) { + return ReviewsService(ref); +}); + +/// Check if a product was previously reviewed by the user +final userReviewFutureProvider = + FutureProvider.autoDispose.family((ref, productId) { + final user = ref.watch(authStateChangesProvider).value; + if (user != null) { + return ref + .watch(reviewsRepositoryProvider) + .fetchUserReview(productId, user.uid); + } else { + return Future.value(null); + } +}); + +/// Check if a product was previously reviewed by the user +final userReviewStreamProvider = + StreamProvider.autoDispose.family((ref, productId) { + final user = ref.watch(authStateChangesProvider).value; + if (user != null) { + return ref + .watch(reviewsRepositoryProvider) + .watchUserReview(productId, user.uid); + } else { + return Stream.value(null); + } +}); diff --git a/ecommerce_app/lib/src/features/reviews/data/fake_reviews_repository.dart b/ecommerce_app/lib/src/features/reviews/data/fake_reviews_repository.dart new file mode 100644 index 00000000..d4fa0acf --- /dev/null +++ b/ecommerce_app/lib/src/features/reviews/data/fake_reviews_repository.dart @@ -0,0 +1,88 @@ +import 'package:ecommerce_app/src/features/products/domain/product.dart'; +import 'package:ecommerce_app/src/features/reviews/domain/review.dart'; +import 'package:ecommerce_app/src/utils/delay.dart'; +import 'package:ecommerce_app/src/utils/in_memory_store.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A repository used to store all user reviews for all products +class FakeReviewsRepository { + FakeReviewsRepository({this.addDelay = true}); + final bool addDelay; + + /// Reviews Store + /// key: [ProductID] + /// value: map of [Review] values for each user ID + final _reviews = InMemoryStore>>({}); + + /// Single review for a given product given by a specific user + /// Emits non-null values if the user has reviewed the product + Stream watchUserReview(ProductID id, String uid) { + return _reviews.stream.map((reviewsData) { + // access nested maps by productId, then uid + return reviewsData[id]?[uid]; + }); + } + + /// Single review for a given product given by a specific user + /// Returns a non-null value if the user has reviewed the product + Future fetchUserReview(ProductID id, String uid) async { + await delay(addDelay); + // access nested maps by productId, then uid + return Future.value(_reviews.value[id]?[uid]); + } + + /// All reviews for a given product from all users + Stream> watchReviews(ProductID id) { + return _reviews.stream.map((reviewsData) { + // access nested maps by productId, then uid + final reviews = reviewsData[id]; + if (reviews == null) { + return []; + } else { + return reviews.values.toList(); + } + }); + } + + /// All reviews for a given product from all users + Future> fetchReviews(ProductID id) { + // access nested maps by productId, then uid + final reviews = _reviews.value[id]; + if (reviews == null) { + return Future.value([]); + } else { + return Future.value(reviews.values.toList()); + } + } + + /// Submit a new review or update an existing review for a given product + /// @param productId the product identifier + /// @param uid the identifier of the user who is leaving the review + /// @param review a [Review] object with the review information + Future setReview({ + required ProductID productId, + required String uid, + required Review review, + }) async { + await delay(addDelay); + final allReviews = _reviews.value; + final reviews = allReviews[productId]; + if (reviews != null) { + // reviews already exist: set the new review for the given uid + reviews[uid] = review; + } else { + // reviews do not exist: create a new map with the new review + allReviews[productId] = {uid: review}; + } + _reviews.value = allReviews; + } +} + +final reviewsRepositoryProvider = Provider((ref) { + return FakeReviewsRepository(); +}); + +final productReviewsProvider = StreamProvider.autoDispose + .family, ProductID>((ref, productId) { + return ref.watch(reviewsRepositoryProvider).watchReviews(productId); +}); diff --git a/ecommerce_app/lib/src/features/reviews/domain/review.dart b/ecommerce_app/lib/src/features/reviews/domain/review.dart index 531b8de5..dacb09cd 100644 --- a/ecommerce_app/lib/src/features/reviews/domain/review.dart +++ b/ecommerce_app/lib/src/features/reviews/domain/review.dart @@ -1,11 +1,28 @@ /// Product review data class Review { const Review({ - required this.score, + required this.rating, required this.comment, required this.date, }); - final double score; // from 1 to 5 + final double rating; // from 1 to 5 final String comment; final DateTime date; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Review && + other.rating == rating && + other.comment == comment && + other.date == date; + } + + @override + int get hashCode => rating.hashCode ^ comment.hashCode ^ date.hashCode; + + @override + String toString() => + 'Review(rating: $rating, comment: $comment, date: $date)'; } diff --git a/ecommerce_app/lib/src/features/reviews/presentation/leave_review_screen/leave_review_screen.dart b/ecommerce_app/lib/src/features/reviews/presentation/leave_review_screen/leave_review_screen.dart index 3cbea320..691d40c6 100644 --- a/ecommerce_app/lib/src/features/reviews/presentation/leave_review_screen/leave_review_screen.dart +++ b/ecommerce_app/lib/src/features/reviews/presentation/leave_review_screen/leave_review_screen.dart @@ -1,5 +1,6 @@ import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/constants/breakpoints.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:ecommerce_app/src/features/reviews/presentation/product_reviews/product_rating_bar.dart'; import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:flutter/material.dart'; @@ -7,10 +8,11 @@ import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; import 'package:ecommerce_app/src/common_widgets/primary_button.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; import 'package:ecommerce_app/src/features/reviews/domain/review.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; class LeaveReviewScreen extends StatelessWidget { const LeaveReviewScreen({super.key, required this.productId}); - final String productId; + final ProductID productId; @override Widget build(BuildContext context) { @@ -29,19 +31,19 @@ class LeaveReviewScreen extends StatelessWidget { } } -class LeaveReviewForm extends StatefulWidget { +class LeaveReviewForm extends ConsumerStatefulWidget { const LeaveReviewForm({super.key, required this.productId, this.review}); - final String productId; + final ProductID productId; final Review? review; // * Keys for testing using find.byKey() static const reviewCommentKey = Key('reviewComment'); @override - State createState() => _LeaveReviewFormState(); + ConsumerState createState() => _LeaveReviewFormState(); } -class _LeaveReviewFormState extends State { +class _LeaveReviewFormState extends ConsumerState { final _controller = TextEditingController(); double _rating = 0; @@ -49,11 +51,7 @@ class _LeaveReviewFormState extends State { @override void initState() { super.initState(); - final review = widget.review; - if (review != null) { - _controller.text = review.comment; - _rating = review.score; - } + // TODO: Initialize state } @override @@ -63,18 +61,6 @@ class _LeaveReviewFormState extends State { super.dispose(); } - Future _submitReview() async { - await showNotImplementedAlertDialog(context: context); - // only submit if new rating or different from before - // final previousReview = widget.review; - // if (previousReview == null || - // _rating != previousReview.score || - // _controller.text != previousReview.comment) { - // // TODO: Submit review - // } - // Navigator.of(context).pop(); - } - @override Widget build(BuildContext context) { return Column( @@ -109,7 +95,10 @@ class _LeaveReviewFormState extends State { text: 'Submit'.hardcoded, // TODO: Loading state isLoading: false, - onPressed: _rating == 0 ? null : _submitReview, + onPressed: _rating == 0 + ? null + // TODO: submit review + : () => showNotImplementedAlertDialog(context: context), ) ], ); diff --git a/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_review_card.dart b/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_review_card.dart index 8c6e2507..2d4e10eb 100644 --- a/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_review_card.dart +++ b/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_review_card.dart @@ -1,4 +1,3 @@ -import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart'; import 'package:ecommerce_app/src/features/reviews/presentation/product_reviews/product_rating_bar.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; @@ -23,13 +22,11 @@ class ProductReviewCard extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ProductRatingBar( - initialRating: review.score, + initialRating: review.rating, ignoreGestures: true, itemSize: 20, - // TODO: Implement onRatingUpdate - onRatingUpdate: (value) { - showNotImplementedAlertDialog(context: context); - }, + // * ok to use an empty callback here since we're ignoring gestures + onRatingUpdate: (value) {}, ), Text(dateFormatted, style: Theme.of(context).textTheme.bodySmall), ], diff --git a/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_reviews_list.dart b/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_reviews_list.dart index 1c5212fd..f5e5dcd0 100644 --- a/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_reviews_list.dart +++ b/ecommerce_app/lib/src/features/reviews/presentation/product_reviews/product_reviews_list.dart @@ -1,28 +1,30 @@ import 'package:ecommerce_app/src/constants/breakpoints.dart'; +import 'package:ecommerce_app/src/features/products/domain/product.dart'; import 'package:ecommerce_app/src/features/reviews/presentation/product_reviews/product_review_card.dart'; import 'package:flutter/material.dart'; import 'package:ecommerce_app/src/common_widgets/responsive_center.dart'; import 'package:ecommerce_app/src/constants/app_sizes.dart'; import 'package:ecommerce_app/src/features/reviews/domain/review.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Shows the list of reviews for a given product ID -class ProductReviewsList extends StatelessWidget { +class ProductReviewsList extends ConsumerWidget { const ProductReviewsList({super.key, required this.productId}); - final String productId; + final ProductID productId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { // TODO: Read from data source final reviews = [ - Review( - date: DateTime(2022, 2, 12), - score: 4.5, - comment: 'Great product, would buy again!', - ), - Review( - date: DateTime(2022, 2, 10), - score: 4.0, - comment: 'Looks great but the packaging was damaged.', - ), + // Review( + // date: DateTime(2022, 2, 12), + // rating: 4.5, + // comment: 'Great product, would buy again!', + // ), + // Review( + // date: DateTime(2022, 2, 10), + // rating: 4.0, + // comment: 'Looks great but the packaging was damaged.', + // ), ]; return SliverList( delegate: SliverChildBuilderDelegate( diff --git a/ecommerce_app/lib/src/utils/current_date_provider.dart b/ecommerce_app/lib/src/utils/current_date_provider.dart new file mode 100644 index 00000000..f71030a9 --- /dev/null +++ b/ecommerce_app/lib/src/utils/current_date_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A provider that returns a function that returns the current date. +/// This makes it easy to mock the current date in tests. +final currentDateBuilderProvider = Provider((ref) { + return () => DateTime.now(); +}); diff --git a/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart b/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart index 74e07f98..2ee839ee 100644 --- a/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart +++ b/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart @@ -5,6 +5,7 @@ import 'package:ecommerce_app/src/features/cart/domain/cart.dart'; import 'package:ecommerce_app/src/features/checkout/application/fake_checkout_service.dart'; import 'package:ecommerce_app/src/features/orders/data/fake_orders_repository.dart'; import 'package:ecommerce_app/src/features/orders/domain/order.dart'; +import 'package:ecommerce_app/src/utils/current_date_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -13,6 +14,7 @@ import '../../../mocks.dart'; void main() { const testUser = AppUser(uid: 'abc', email: 'abc@test.com'); + final testDate = DateTime(2022, 7, 13); setUpAll(() { // needed for MockOrdersRepository registerFallbackValue(Order( @@ -20,7 +22,7 @@ void main() { userId: testUser.uid, items: {'1': 1}, orderStatus: OrderStatus.confirmed, - orderDate: DateTime(2022, 7, 13), + orderDate: testDate, total: 15, )); // needed for MockRemoteCartRepository @@ -42,6 +44,7 @@ void main() { authRepositoryProvider.overrideWithValue(authRepository), remoteCartRepositoryProvider.overrideWithValue(remoteCartRepository), ordersRepositoryProvider.overrideWithValue(ordersRepository), + currentDateBuilderProvider.overrideWithValue(() => testDate), ], ); addTearDown(container.dispose); @@ -68,7 +71,7 @@ void main() { expect(checkoutService.placeOrder, throwsStateError); }); - test('non-empty cart, creates order', () async { + test('non-empty cart, creates order and purchase, empties cart', () async { // setup when(() => authRepository.currentUser).thenReturn(testUser); when(() => remoteCartRepository.fetchCart(testUser.uid)).thenAnswer( @@ -86,7 +89,8 @@ void main() { await checkoutService.placeOrder(); // verify verify(() => ordersRepository.addOrder(testUser.uid, any())).called(1); - verify(() => remoteCartRepository.setCart(testUser.uid, const Cart())); + verify(() => remoteCartRepository.setCart(testUser.uid, const Cart())) + .called(1); }); }); } diff --git a/ecommerce_app/test/src/features/purchase_flow_test.dart b/ecommerce_app/test/src/features/purchase_flow_test.dart index 3f7cca40..5abde031 100644 --- a/ecommerce_app/test/src/features/purchase_flow_test.dart +++ b/ecommerce_app/test/src/features/purchase_flow_test.dart @@ -25,6 +25,12 @@ void main() { await r.cart.openCart(); r.cart.expectFindZeroCartItems(); await r.closePage(); + // reviews flow + // await r.products.selectProduct(); + // r.reviews.expectFindLeaveReview(); + // await r.reviews.tapLeaveReviewButton(); + // await r.reviews.createAndSubmitReview(); + // r.reviews.expectFindOneReview(); // sign out await r.openPopupMenu(); await r.auth.openAccountScreen(); diff --git a/ecommerce_app/test/src/features/reviews/application/reviews_service_test.dart b/ecommerce_app/test/src/features/reviews/application/reviews_service_test.dart new file mode 100644 index 00000000..b095fb8b --- /dev/null +++ b/ecommerce_app/test/src/features/reviews/application/reviews_service_test.dart @@ -0,0 +1,69 @@ +import 'package:ecommerce_app/src/features/authentication/data/fake_auth_repository.dart'; +import 'package:ecommerce_app/src/features/authentication/domain/app_user.dart'; +import 'package:ecommerce_app/src/features/reviews/application/reviews_service.dart'; +import 'package:ecommerce_app/src/features/reviews/data/fake_reviews_repository.dart'; +import 'package:ecommerce_app/src/features/reviews/domain/review.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../mocks.dart'; + +void main() { + const testUser = AppUser(uid: 'abc', email: 'abc@test.com'); + const testProductId = '1'; + final testReview = + Review(rating: 5, comment: '', date: DateTime(2022, 7, 31)); + late MockAuthRepository authRepository; + late MockReviewsRepository reviewsRepository; + setUp(() { + authRepository = MockAuthRepository(); + reviewsRepository = MockReviewsRepository(); + }); + + ReviewsService makeReviewsService() { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(authRepository), + reviewsRepositoryProvider.overrideWithValue(reviewsRepository), + ], + ); + addTearDown(container.dispose); + return container.read(reviewsServiceProvider); + } + + group('submitReview', () { + test('null user, throws', () { + // setup + when(() => authRepository.currentUser).thenReturn(null); + final reviewsService = makeReviewsService(); + // run + expect( + () => reviewsService.submitReview( + productId: testProductId, + review: testReview, + ), + throwsAssertionError, + ); + }); + test('non null user, sets review', () async { + // setup + when(() => authRepository.currentUser).thenReturn(testUser); + when(() => reviewsRepository.setReview( + productId: testProductId, + uid: testUser.uid, + review: testReview, + )).thenAnswer((_) => Future.value()); + final reviewsService = makeReviewsService(); + // run + await reviewsService.submitReview(productId: '1', review: testReview); + // verify + verify(() => authRepository.currentUser).called(1); + verify(() => reviewsRepository.setReview( + productId: testProductId, + uid: testUser.uid, + review: testReview, + )).called(1); + }); + }); +} diff --git a/ecommerce_app/test/src/features/reviews/reviews_robot.dart b/ecommerce_app/test/src/features/reviews/reviews_robot.dart new file mode 100644 index 00000000..75062cf1 --- /dev/null +++ b/ecommerce_app/test/src/features/reviews/reviews_robot.dart @@ -0,0 +1,54 @@ +import 'package:ecommerce_app/src/features/reviews/presentation/leave_review_screen/leave_review_screen.dart'; +import 'package:ecommerce_app/src/features/reviews/presentation/product_reviews/product_review_card.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class ReviewsRobot { + ReviewsRobot(this.tester); + final WidgetTester tester; + + void expectFindLeaveReview() { + final finder = find.text('Leave a review'); + expect(finder, findsOneWidget); + } + + Future tapLeaveReviewButton() async { + final finder = find.text('Leave a review'); + expect(finder, findsOneWidget); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + void expectFindOneReview() { + // don't skip offstage widgets as the product reviews may appear below + // the visible area on screen + final finder = find.byType(ProductReviewCard, skipOffstage: false); + expect(finder, findsOneWidget); + } + + // leave a review + Future selectReviewScore() async { + final finder = find.byKey(const Key('stars-4')); + expect(finder, findsOneWidget); + await tester.tap(finder); + } + + Future enterReviewComment() async { + final finder = find.byKey(LeaveReviewForm.reviewCommentKey); + expect(finder, findsOneWidget); + await tester.enterText(finder, 'Love it!'); + } + + Future submitReview() async { + final finder = find.text('Submit'); + expect(finder, findsOneWidget); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + Future createAndSubmitReview() async { + await selectReviewScore(); + await enterReviewComment(); + await submitReview(); + } +} diff --git a/ecommerce_app/test/src/mocks.dart b/ecommerce_app/test/src/mocks.dart index 84fca9da..4024e8f5 100644 --- a/ecommerce_app/test/src/mocks.dart +++ b/ecommerce_app/test/src/mocks.dart @@ -5,6 +5,8 @@ import 'package:ecommerce_app/src/features/cart/data/remote/remote_cart_reposito import 'package:ecommerce_app/src/features/checkout/application/fake_checkout_service.dart'; import 'package:ecommerce_app/src/features/orders/data/fake_orders_repository.dart'; import 'package:ecommerce_app/src/features/products/data/fake_products_repository.dart'; +import 'package:ecommerce_app/src/features/reviews/application/reviews_service.dart'; +import 'package:ecommerce_app/src/features/reviews/data/fake_reviews_repository.dart'; import 'package:mocktail/mocktail.dart'; class MockAuthRepository extends Mock implements FakeAuthRepository {} @@ -20,3 +22,7 @@ class MockProductsRepository extends Mock implements FakeProductsRepository {} class MockOrdersRepository extends Mock implements FakeOrdersRepository {} class MockCheckoutService extends Mock implements FakeCheckoutService {} + +class MockReviewsRepository extends Mock implements FakeReviewsRepository {} + +class MockReviewsService extends Mock implements ReviewsService {} diff --git a/ecommerce_app/test/src/robot.dart b/ecommerce_app/test/src/robot.dart index 4fd8a1a1..2ad1cea6 100644 --- a/ecommerce_app/test/src/robot.dart +++ b/ecommerce_app/test/src/robot.dart @@ -5,8 +5,10 @@ import 'package:ecommerce_app/src/features/cart/data/local/fake_local_cart_repos import 'package:ecommerce_app/src/features/cart/data/local/local_cart_repository.dart'; import 'package:ecommerce_app/src/features/cart/data/remote/fake_remote_cart_repository.dart'; import 'package:ecommerce_app/src/features/cart/data/remote/remote_cart_repository.dart'; +import 'package:ecommerce_app/src/features/orders/data/fake_orders_repository.dart'; import 'package:ecommerce_app/src/features/products/data/fake_products_repository.dart'; import 'package:ecommerce_app/src/features/products/presentation/home_app_bar/more_menu_button.dart'; +import 'package:ecommerce_app/src/features/reviews/data/fake_reviews_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,6 +17,7 @@ import 'features/cart/cart_robot.dart'; import 'features/checkout/checkout_robot.dart'; import 'features/orders/orders_robot.dart'; import 'features/products/products_robot.dart'; +import 'features/reviews/reviews_robot.dart'; import 'goldens/golden_robot.dart'; class Robot { @@ -24,6 +27,7 @@ class Robot { cart = CartRobot(tester), checkout = CheckoutRobot(tester), orders = OrdersRobot(tester), + reviews = ReviewsRobot(tester), golden = GoldenRobot(tester); final WidgetTester tester; final AuthRobot auth; @@ -31,6 +35,7 @@ class Robot { final CartRobot cart; final CheckoutRobot checkout; final OrdersRobot orders; + final ReviewsRobot reviews; final GoldenRobot golden; Future pumpMyApp() async { @@ -39,6 +44,8 @@ class Robot { final authRepository = FakeAuthRepository(addDelay: false); final localCartRepository = FakeLocalCartRepository(addDelay: false); final remoteCartRepository = FakeRemoteCartRepository(addDelay: false); + final ordersRepository = FakeOrdersRepository(addDelay: false); + final reviewsRepository = FakeReviewsRepository(addDelay: false); // * Create ProviderContainer with any required overrides final container = ProviderContainer( overrides: [ @@ -46,6 +53,8 @@ class Robot { authRepositoryProvider.overrideWithValue(authRepository), localCartRepositoryProvider.overrideWithValue(localCartRepository), remoteCartRepositoryProvider.overrideWithValue(remoteCartRepository), + ordersRepositoryProvider.overrideWithValue(ordersRepository), + reviewsRepositoryProvider.overrideWithValue(reviewsRepository), ], ); // * Initialize CartSyncService to start the listener