Skip to content

Commit

Permalink
Starter project for section 11
Browse files Browse the repository at this point in the history
# Conflicts:
#	ecommerce_app/lib/src/app.dart
#	ecommerce_app/pubspec.lock
#	ecommerce_app/pubspec.yaml

# Conflicts:
#	ecommerce_app/lib/src/features/products/presentation/product_screen/leave_review_action.dart
  • Loading branch information
bizz84 committed Nov 6, 2024
1 parent 5cb77bc commit f514627
Show file tree
Hide file tree
Showing 25 changed files with 480 additions and 91 deletions.
6 changes: 6 additions & 0 deletions ecommerce_app/integration_test/purchase_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions ecommerce_app/lib/src/common_widgets/async_value_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,23 @@ class AsyncValueWidget<T> extends StatelessWidget {
);
}
}

/// Sliver equivalent of [AsyncValueWidget]
class AsyncValueSliverWidget<T> extends StatelessWidget {
const AsyncValueSliverWidget(
{super.key, required this.value, required this.data});
final AsyncValue<T> 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())),
),
);
}
}
6 changes: 0 additions & 6 deletions ecommerce_app/lib/src/constants/test_products.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ const kTestProducts = [
description: 'Lorem ipsum',
price: 15,
availableQuantity: 5,
avgRating: 4.5,
numRatings: 2,
),
Product(
id: '2',
Expand All @@ -19,8 +17,6 @@ const kTestProducts = [
description: 'Lorem ipsum',
price: 13,
availableQuantity: 5,
avgRating: 4,
numRatings: 2,
),
Product(
id: '3',
Expand All @@ -29,8 +25,6 @@ const kTestProducts = [
description: 'Lorem ipsum',
price: 17,
availableQuantity: 5,
avgRating: 5,
numRatings: 2,
),
Product(
id: '4',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,16 +23,15 @@ 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;
// 1. Get the cart object
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
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
/// NOTE: Only watch this provider if the user is signed in.
final userOrdersProvider = StreamProvider.autoDispose<List<Order>>((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<List<Order>, 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([]);
}
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,14 +13,22 @@ class FakeOrdersRepository {
/// - value: list of orders for that user
final _orders = InMemoryStore<Map<String, List<Order>>>({});

// A stream that returns all the orders for a given user, ordered by date
Stream<List<Order>> 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<List<Order>> 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;
}
});
}

Expand Down
3 changes: 2 additions & 1 deletion ecommerce_app/lib/src/features/orders/domain/order.dart
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -34,7 +35,7 @@ class Order {
final String userId;

/// List of items in that order
final Map<String, int> items;
final Map<ProductID, int> items;
final OrderStatus orderStatus;
final DateTime orderDate;
final double total;
Expand Down
12 changes: 0 additions & 12 deletions ecommerce_app/lib/src/features/orders/domain/purchase.dart

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<Product> _products = kTestProducts;

/// Preload with the default list of products when the app starts
final _products = InMemoryStore<List<Product>>(List.from(kTestProducts));

List<Product> getProductsList() {
return _products;
return _products.value;
}

Product? getProduct(String id) {
return _getProduct(_products, id);
return _getProduct(_products.value, id);
}

Future<List<Product>> fetchProductsList() async {
await delay(addDelay);
return Future.value(_products);
return Future.value(_products.value);
}

Stream<List<Product>> watchProductsList() async* {
await delay(addDelay);
yield _products;
Stream<List<Product>> watchProductsList() {
return _products.stream;
}

Stream<Product?> watchProduct(String id) {
return watchProductsList().map((products) => _getProduct(products, id));
}

/// Update product or add a new one
Future<void> 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<Product> products, String id) {
try {
return products.firstWhere((product) => product.id == id);
Expand Down
22 changes: 22 additions & 0 deletions ecommerce_app/lib/src/features/products/domain/product.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand Down Expand Up @@ -52,7 +61,7 @@ class LeaveReviewAction extends ConsumerWidget {
],
);
} else {
return const SizedBox();
return const SizedBox.shrink();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> 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<ReviewsService>((ref) {
return ReviewsService(ref);
});

/// Check if a product was previously reviewed by the user
final userReviewFutureProvider =
FutureProvider.autoDispose.family<Review?, ProductID>((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<Review?, ProductID>((ref, productId) {
final user = ref.watch(authStateChangesProvider).value;
if (user != null) {
return ref
.watch(reviewsRepositoryProvider)
.watchUserReview(productId, user.uid);
} else {
return Stream.value(null);
}
});
Loading

0 comments on commit f514627

Please sign in to comment.