Skip to content

Commit

Permalink
Starter project for section 8 (#14)
Browse files Browse the repository at this point in the history
* Update MutableCart extension, add corresponding tests

* Use dateFormatterProvider and currencyFormatterProvider rather than global formatters

* Set addDelay to false for faster loading

* Add FakeLocalCartRepository and FakeRemoteCartRepository

* Cleanup LocalCartRepository, RemoteCartRepository

* Update MutableCart extension and tests

* Updated to latest package versions

* Improved documentation

# Conflicts:
#	ecommerce_app/pubspec.lock
#	ecommerce_app/pubspec.yaml
  • Loading branch information
bizz84 committed Nov 16, 2023
1 parent 8e0cdff commit e59f92d
Show file tree
Hide file tree
Showing 19 changed files with 332 additions and 84 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:ecommerce_app/src/features/cart/data/local/local_cart_repository.dart';
import 'package:ecommerce_app/src/features/cart/domain/cart.dart';
import 'package:ecommerce_app/src/utils/delay.dart';
import 'package:ecommerce_app/src/utils/in_memory_store.dart';

class FakeLocalCartRepository implements LocalCartRepository {
FakeLocalCartRepository({this.addDelay = true});
final bool addDelay;

final _cart = InMemoryStore<Cart>(const Cart());

@override
Future<Cart> fetchCart() => Future.value(_cart.value);

@override
Stream<Cart> watchCart() => _cart.stream;

@override
Future<void> setCart(Cart cart) async {
await delay(addDelay);
_cart.value = cart;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ecommerce_app/src/features/cart/domain/cart.dart';

/// API for reading, watching and writing local cart data (guest user)
abstract class LocalCartRepository {
Future<Cart> fetchCart();

Stream<Cart> watchCart();

Future<void> setCart(Cart cart);
}

final localCartRepositoryProvider = Provider<LocalCartRepository>((ref) {
// * Override this in the main method
throw UnimplementedError();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:ecommerce_app/src/features/cart/data/remote/remote_cart_repository.dart';
import 'package:ecommerce_app/src/features/cart/domain/cart.dart';
import 'package:ecommerce_app/src/utils/delay.dart';
import 'package:ecommerce_app/src/utils/in_memory_store.dart';

class FakeRemoteCartRepository implements RemoteCartRepository {
FakeRemoteCartRepository({this.addDelay = true});
final bool addDelay;

/// An InMemoryStore containing the shopping cart data for all users, where:
/// key: uid of the user
/// value: Cart of that user
final _carts = InMemoryStore<Map<String, Cart>>({});

@override
Future<Cart> fetchCart(String uid) {
return Future.value(_carts.value[uid] ?? const Cart());
}

@override
Stream<Cart> watchCart(String uid) {
return _carts.stream.map((cartData) => cartData[uid] ?? const Cart());
}

@override
Future<void> setCart(String uid, Cart cart) async {
await delay(addDelay);
// First, get the current carts data for all users
final carts = _carts.value;
// Then, set the cart for the given uid
carts[uid] = cart;
// Finally, update the carts data (will emit a new value)
_carts.value = carts;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:ecommerce_app/src/features/cart/data/remote/fake_remote_cart_repository.dart';
import 'package:ecommerce_app/src/features/cart/domain/cart.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// API for reading, watching and writing cart data for a specific user ID
abstract class RemoteCartRepository {
Future<Cart> fetchCart(String uid);

Stream<Cart> watchCart(String uid);

Future<void> setCart(String uid, Cart cart);
}

final remoteCartRepositoryProvider = Provider<RemoteCartRepository>((ref) {
// TODO: replace with "real" remote cart repository
return FakeRemoteCartRepository();
});
51 changes: 27 additions & 24 deletions ecommerce_app/lib/src/features/cart/domain/mutable_cart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,48 @@ import 'package:ecommerce_app/src/features/products/domain/product.dart';

/// Helper extension used to mutate the items in the shopping cart.
extension MutableCart on Cart {
/// add an item to the cart by *overriding* the quantity if it already exists
Cart setItem(Item item) {
final copy = Map<ProductID, int>.from(items);
copy[item.productId] = item.quantity;
return Cart(copy);
}

/// add an item to the cart by *updating* the quantity if it already exists
Cart addItem(Item item) {
final copy = Map<ProductID, int>.from(items);
if (copy.containsKey(item.productId)) {
copy[item.productId] = item.quantity + copy[item.productId]!;
} else {
copy[item.productId] = item.quantity;
}
// * update item quantity. Read this for more details:
// * https://codewithandrea.com/tips/dart-map-update-method/
copy.update(
item.productId,
// if there is already a value, update it by adding the item quantity
(value) => item.quantity + value,
// otherwise, add the item with the given quantity
ifAbsent: () => item.quantity,
);
return Cart(copy);
}

/// add a list of items to the cart by *updating* the quantities of items that
/// already exist
Cart addItems(List<Item> itemsToAdd) {
final copy = Map<ProductID, int>.from(items);
for (var item in itemsToAdd) {
if (copy.containsKey(item.productId)) {
copy[item.productId] = item.quantity + copy[item.productId]!;
} else {
copy[item.productId] = item.quantity;
}
copy.update(
item.productId,
// if there is already a value, update it by adding the item quantity
(value) => item.quantity + value,
// otherwise, add the item with the given quantity
ifAbsent: () => item.quantity,
);
}
return Cart(copy);
}

/// if an item with the given productId is found, remove it
Cart removeItemById(ProductID productId) {
final copy = Map<ProductID, int>.from(items);
copy.remove(productId);
return Cart(copy);
}

Cart updateItemIfExists(Item item) {
if (items.containsKey(item.productId)) {
final copy = Map<ProductID, int>.from(items);
copy[item.productId] = item.quantity;
return Cart(copy);
} else {
return this;
}
}

Cart clear() {
return const Cart();
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import 'package:ecommerce_app/src/utils/currency_formatter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Text widget for showing the total price of the cart
class CartTotalText extends StatelessWidget {
class CartTotalText extends ConsumerWidget {
const CartTotalText({super.key});

@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Read from data source
const cartTotal = 104.0;
// TODO: Inject formatter
final totalFormatted = kCurrencyFormatter.format(cartTotal);
final totalFormatted =
ref.watch(currencyFormatterProvider).format(cartTotal);
return Text(
'Total: $totalFormatted',
style: Theme.of(context).textTheme.headlineSmall,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:ecommerce_app/src/common_widgets/alert_dialogs.dart';
import 'package:ecommerce_app/src/common_widgets/async_value_widget.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/currency_formatter.dart';
import 'package:flutter/material.dart';
import 'package:ecommerce_app/src/common_widgets/custom_image.dart';
import 'package:ecommerce_app/src/common_widgets/item_quantity_selector.dart';
Expand All @@ -12,7 +13,6 @@ import 'package:ecommerce_app/src/constants/app_sizes.dart';
import 'package:ecommerce_app/src/features/cart/domain/item.dart';
import 'package:ecommerce_app/src/features/products/domain/product.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';

/// Shows a shopping cart item (or loading/error UI if needed)
class ShoppingCartItem extends ConsumerWidget {
Expand Down Expand Up @@ -54,7 +54,7 @@ class ShoppingCartItem extends ConsumerWidget {
}

/// Shows a shopping cart item for a given product
class ShoppingCartItemContents extends StatelessWidget {
class ShoppingCartItemContents extends ConsumerWidget {
const ShoppingCartItemContents({
super.key,
required this.product,
Expand All @@ -71,10 +71,9 @@ class ShoppingCartItemContents extends StatelessWidget {
static Key deleteKey(int index) => Key('delete-$index');

@override
Widget build(BuildContext context) {
// TODO: error handling
// TODO: Inject formatter
final priceFormatted = NumberFormat.simpleCurrency().format(product.price);
Widget build(BuildContext context, WidgetRef ref) {
final priceFormatted =
ref.watch(currencyFormatterProvider).format(product.price);
return ResponsiveTwoColumnLayout(
startFlex: 1,
endFlex: 2,
Expand All @@ -90,28 +89,10 @@ class ShoppingCartItemContents extends StatelessWidget {
gapH24,
isEditable
// show the quantity selector and a delete button
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ItemQuantitySelector(
quantity: item.quantity,
maxQuantity: min(product.availableQuantity, 10),
itemIndex: itemIndex,
// TODO: Implement onChanged
onChanged: (value) {
showNotImplementedAlertDialog(context: context);
},
),
IconButton(
key: deleteKey(itemIndex),
icon: Icon(Icons.delete, color: Colors.red[700]),
// TODO: Implement onPressed
onPressed: () {
showNotImplementedAlertDialog(context: context);
},
),
const Spacer(),
],
? EditOrRemoveItemWidget(
product: product,
item: item,
itemIndex: itemIndex,
)
// else, show the quantity as a read-only label
: Padding(
Expand All @@ -125,3 +106,46 @@ class ShoppingCartItemContents extends StatelessWidget {
);
}
}

// custom widget to show the quantity selector and a delete button
class EditOrRemoveItemWidget extends ConsumerWidget {
const EditOrRemoveItemWidget({
super.key,
required this.product,
required this.item,
required this.itemIndex,
});
final Product product;
final Item item;
final int itemIndex;

// * Keys for testing using find.byKey()
static Key deleteKey(int index) => Key('delete-$index');

@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ItemQuantitySelector(
quantity: item.quantity,
maxQuantity: min(product.availableQuantity, 10),
itemIndex: itemIndex,
// TODO: Implement onChanged
onChanged: (value) {
showNotImplementedAlertDialog(context: context);
},
),
IconButton(
key: deleteKey(itemIndex),
icon: Icon(Icons.delete, color: Colors.red[700]),
// TODO: Implement onPressed
onPressed: () {
showNotImplementedAlertDialog(context: context);
},
),
const Spacer(),
],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ShoppingCartScreen extends StatelessWidget {

@override
Widget build(BuildContext context) {
// TODO: error handling
// TODO: Read from data source
const cartItemsList = [
Item(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:ecommerce_app/src/features/cart/domain/item.dart';
import 'package:ecommerce_app/src/features/orders/domain/order.dart';
import 'package:ecommerce_app/src/utils/currency_formatter.dart';
import 'package:ecommerce_app/src/utils/date_formatter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Shows all the details for a given order
class OrderCard extends StatelessWidget {
Expand Down Expand Up @@ -35,16 +36,16 @@ class OrderCard extends StatelessWidget {
/// Order header showing the following:
/// - Total order amount
/// - Order date
class OrderHeader extends StatelessWidget {
class OrderHeader extends ConsumerWidget {
const OrderHeader({super.key, required this.order});
final Order order;

@override
Widget build(BuildContext context) {
// TODO: Inject currency formatter
final totalFormatted = kCurrencyFormatter.format(order.total);
// TODO: Inject date formatter
final dateFormatted = kDateFormatter.format(order.orderDate);
Widget build(BuildContext context, WidgetRef ref) {
final totalFormatted =
ref.watch(currencyFormatterProvider).format(order.total);
final dateFormatted =
ref.watch(dateFormatterProvider).format(order.orderDate);
return Container(
color: Colors.grey[200],
padding: const EdgeInsets.all(Sizes.p16),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class FakeProductsRepository {
}

final productsRepositoryProvider = Provider<FakeProductsRepository>((ref) {
return FakeProductsRepository();
// * Set addDelay to false for faster loading
return FakeProductsRepository(addDelay: false);
});

final productsListStreamProvider =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import 'package:ecommerce_app/src/routing/app_router.dart';
import 'package:flutter/material.dart';
import 'package:ecommerce_app/src/constants/app_sizes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

/// Shopping cart icon with items count badge
class ShoppingCartIcon extends StatelessWidget {
class ShoppingCartIcon extends ConsumerWidget {
const ShoppingCartIcon({super.key});

// * Keys for testing using find.byKey()
static const shoppingCartIconKey = Key('shopping-cart');

@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Read from data source
const cartItemsCount = 3;
return Stack(
Expand Down
Loading

0 comments on commit e59f92d

Please sign in to comment.