diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 55a4223..1c2f647 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/main/presentation/pages/main_scaffold.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart'; @@ -73,6 +74,16 @@ class AppRouter { }, ), + // Cart Route + GoRoute( + path: RouteNames.cart, + name: RouteNames.cart, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: const CartPage(), + ), + ), + // TODO: Add more routes as features are implemented ], diff --git a/lib/features/cart/presentation/pages/cart_page.dart b/lib/features/cart/presentation/pages/cart_page.dart new file mode 100644 index 0000000..6717d06 --- /dev/null +++ b/lib/features/cart/presentation/pages/cart_page.dart @@ -0,0 +1,459 @@ +/// Cart Page +/// +/// Shopping cart screen with items, warehouse selection, discount code, +/// and order summary matching the HTML design. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:worker/core/router/app_router.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/core/theme/typography.dart'; +import 'package:worker/features/cart/presentation/providers/cart_provider.dart'; +import 'package:worker/features/cart/presentation/providers/cart_state.dart'; +import 'package:worker/features/cart/presentation/widgets/cart_item_widget.dart'; + +/// Cart Page +/// +/// Features: +/// - AppBar with back, title (with count), and clear cart button +/// - Warehouse selection dropdown +/// - Cart items list +/// - Discount code input with apply button +/// - Order summary with breakdown +/// - Checkout button +class CartPage extends ConsumerStatefulWidget { + const CartPage({super.key}); + + @override + ConsumerState createState() => _CartPageState(); +} + +class _CartPageState extends ConsumerState { + final TextEditingController _discountController = TextEditingController(); + + @override + void dispose() { + _discountController.dispose(); + super.dispose(); + } + + void _clearCart() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Xóa giỏ hàng'), + content: const Text('Bạn có chắc chắn muốn xóa toàn bộ giỏ hàng?'), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: const Text('Hủy'), + ), + ElevatedButton( + onPressed: () { + ref.read(cartProvider.notifier).clearCart(); + context.pop(); + context.pop(); // Also go back from cart page + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.danger, + ), + child: const Text('Xóa'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final cartState = ref.watch(cartProvider); + final itemCount = cartState.itemCount; + + final currencyFormatter = NumberFormat.currency( + locale: 'vi_VN', + symbol: 'đ', + decimalDigits: 0, + ); + + return Scaffold( + backgroundColor: AppColors.grey50, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + title: Text('Giỏ hàng ($itemCount)'), + actions: [ + if (cartState.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _clearCart, + tooltip: 'Xóa giỏ hàng', + ), + ], + ), + body: cartState.isEmpty + ? _buildEmptyCart() + : SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 8), + + // Warehouse Selection + _buildWarehouseSelection(cartState.selectedWarehouse), + + // Cart Items + ...cartState.items.map((item) => CartItemWidget(item: item)), + + const SizedBox(height: 8), + + // Discount Code + _buildDiscountCodeSection(cartState), + + // Order Summary + _buildOrderSummary(cartState, currencyFormatter), + + const SizedBox(height: 16), + + // Checkout Button + _buildCheckoutButton(cartState), + + const SizedBox(height: 24), + ], + ), + ), + ); + } + + /// Build empty cart state + Widget _buildEmptyCart() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.shopping_cart_outlined, + size: 80, + color: AppColors.grey500.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'Giỏ hàng trống', + style: AppTypography.headlineMedium.copyWith( + color: AppColors.grey500, + ), + ), + const SizedBox(height: 8), + Text( + 'Hãy thêm sản phẩm vào giỏ hàng', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.grey500, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context.go(RouteNames.products), + icon: const Icon(Icons.shopping_bag_outlined), + label: const Text('Xem sản phẩm'), + ), + ], + ), + ); + } + + /// Build warehouse selection card + Widget _buildWarehouseSelection(String selectedWarehouse) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kho xuất hàng', + style: AppTypography.labelLarge.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selectedWarehouse, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.grey100), + ), + ), + items: const [ + DropdownMenuItem( + value: 'Kho Hà Nội - Nguyễn Trãi', + child: Text('Kho Hà Nội - Nguyễn Trãi'), + ), + DropdownMenuItem( + value: 'Kho TP.HCM - Quận 7', + child: Text('Kho TP.HCM - Quận 7'), + ), + DropdownMenuItem( + value: 'Kho Đà Nẵng - Sơn Trà', + child: Text('Kho Đà Nẵng - Sơn Trà'), + ), + ], + onChanged: (value) { + if (value != null) { + ref.read(cartProvider.notifier).selectWarehouse(value); + } + }, + ), + ], + ), + ); + } + + /// Build discount code section + Widget _buildDiscountCodeSection(CartState cartState) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mã giảm giá', + style: AppTypography.labelLarge.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _discountController, + decoration: InputDecoration( + hintText: 'Nhập mã giảm giá', + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.grey100), + ), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + if (_discountController.text.isNotEmpty) { + ref.read(cartProvider.notifier).applyDiscountCode( + _discountController.text, + ); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text('Áp dụng'), + ), + ], + ), + + // Success message for member discount + if (cartState.memberTier.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const Icon( + Icons.check_circle, + color: AppColors.success, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Bạn được giảm ${cartState.memberDiscountPercent.toStringAsFixed(0)}% (hạng ${cartState.memberTier})', + style: AppTypography.bodySmall.copyWith( + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Build order summary section + Widget _buildOrderSummary(CartState cartState, NumberFormat currencyFormatter) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thông tin đơn hàng', + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // Subtotal + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tạm tính (${cartState.totalQuantity.toStringAsFixed(0)} ${cartState.items.firstOrNull?.product.unit ?? 'm²'})', + style: AppTypography.bodyMedium, + ), + Text( + currencyFormatter.format(cartState.subtotal), + style: AppTypography.bodyMedium, + ), + ], + ), + + const SizedBox(height: 12), + + // Member Discount + if (cartState.memberDiscount > 0) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Giảm giá ${cartState.memberTier} (-${cartState.memberDiscountPercent.toStringAsFixed(0)}%)', + style: AppTypography.bodyMedium, + ), + Text( + '-${currencyFormatter.format(cartState.memberDiscount)}', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.success, + ), + ), + ], + ), + ), + + // Shipping Fee + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Phí vận chuyển', + style: AppTypography.bodyMedium, + ), + Text( + cartState.shippingFee > 0 + ? currencyFormatter.format(cartState.shippingFee) + : 'Miễn phí', + style: AppTypography.bodyMedium, + ), + ], + ), + + const Divider(height: 24), + + // Total + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tổng cộng', + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + currencyFormatter.format(cartState.total), + style: AppTypography.headlineSmall.copyWith( + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + } + + /// Build checkout button + Widget _buildCheckoutButton(CartState cartState) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: cartState.isNotEmpty + ? () { + // TODO: Navigate to checkout page when implemented + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Checkout page chưa được triển khai'), + ), + ); + } + : null, + child: const Text( + 'Tiến hành đặt hàng', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/cart/presentation/providers/cart_provider.dart b/lib/features/cart/presentation/providers/cart_provider.dart new file mode 100644 index 0000000..165210b --- /dev/null +++ b/lib/features/cart/presentation/providers/cart_provider.dart @@ -0,0 +1,177 @@ +/// Cart Provider +/// +/// State management for shopping cart using Riverpod. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/cart/presentation/providers/cart_state.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; + +part 'cart_provider.g.dart'; + +/// Cart Notifier +/// +/// Manages cart state including: +/// - Adding/removing items +/// - Updating quantities +/// - Warehouse selection +/// - Discount code application +/// - Cart summary calculations +@riverpod +class Cart extends _$Cart { + @override + CartState build() { + final initialState = CartState.initial(); + // Initialize with Diamond tier discount (15%) + // TODO: Get actual tier from user profile + return initialState.copyWith( + memberTier: 'Diamond', + memberDiscountPercent: 15.0, + ); + } + + /// Add product to cart + void addToCart(Product product, {double quantity = 1.0}) { + final existingItemIndex = state.items.indexWhere( + (item) => item.product.productId == product.productId, + ); + + if (existingItemIndex >= 0) { + // Update quantity if item already exists + updateQuantity( + product.productId, + state.items[existingItemIndex].quantity + quantity, + ); + } else { + // Add new item + final newItem = CartItemData( + product: product, + quantity: quantity, + ); + + state = state.copyWith( + items: [...state.items, newItem], + ); + _recalculateTotal(); + } + } + + /// Remove product from cart + void removeFromCart(String productId) { + state = state.copyWith( + items: state.items.where((item) => item.product.productId != productId).toList(), + ); + _recalculateTotal(); + } + + /// Update item quantity + void updateQuantity(String productId, double newQuantity) { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } + + final updatedItems = state.items.map((item) { + if (item.product.productId == productId) { + return item.copyWith(quantity: newQuantity); + } + return item; + }).toList(); + + state = state.copyWith(items: updatedItems); + _recalculateTotal(); + } + + /// Increment quantity + void incrementQuantity(String productId) { + final item = state.items.firstWhere( + (item) => item.product.productId == productId, + ); + updateQuantity(productId, item.quantity + 1); + } + + /// Decrement quantity + void decrementQuantity(String productId) { + final item = state.items.firstWhere( + (item) => item.product.productId == productId, + ); + updateQuantity(productId, item.quantity - 1); + } + + /// Clear entire cart + void clearCart() { + state = CartState.initial(); + } + + /// Select warehouse + void selectWarehouse(String warehouse) { + state = state.copyWith(selectedWarehouse: warehouse); + } + + /// Apply discount code + void applyDiscountCode(String code) { + // TODO: Validate with backend + // For now, simulate discount application + if (code.isNotEmpty) { + state = state.copyWith( + discountCode: code, + discountCodeApplied: true, + ); + _recalculateTotal(); + } + } + + /// Remove discount code + void removeDiscountCode() { + state = state.copyWith( + discountCode: null, + discountCodeApplied: false, + ); + _recalculateTotal(); + } + + /// Recalculate cart totals + void _recalculateTotal() { + // Calculate subtotal + final subtotal = state.items.fold( + 0.0, + (sum, item) => sum + (item.product.basePrice * item.quantity), + ); + + // Calculate member tier discount + final memberDiscount = subtotal * (state.memberDiscountPercent / 100); + + // Calculate shipping (free for now) + const shippingFee = 0.0; + + // Calculate total + final total = subtotal - memberDiscount + shippingFee; + + state = state.copyWith( + subtotal: subtotal, + memberDiscount: memberDiscount, + shippingFee: shippingFee, + total: total, + ); + } + + /// Get total quantity of all items + double get totalQuantity { + return state.items.fold( + 0.0, + (sum, item) => sum + item.quantity, + ); + } +} + +/// Cart item count provider +@riverpod +int cartItemCount(Ref ref) { + return ref.watch(cartProvider).items.length; +} + +/// Cart total provider +@riverpod +double cartTotal(Ref ref) { + return ref.watch(cartProvider).total; +} diff --git a/lib/features/cart/presentation/providers/cart_provider.g.dart b/lib/features/cart/presentation/providers/cart_provider.g.dart new file mode 100644 index 0000000..c1239c4 --- /dev/null +++ b/lib/features/cart/presentation/providers/cart_provider.g.dart @@ -0,0 +1,186 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cart_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Cart Notifier +/// +/// Manages cart state including: +/// - Adding/removing items +/// - Updating quantities +/// - Warehouse selection +/// - Discount code application +/// - Cart summary calculations + +@ProviderFor(Cart) +const cartProvider = CartProvider._(); + +/// Cart Notifier +/// +/// Manages cart state including: +/// - Adding/removing items +/// - Updating quantities +/// - Warehouse selection +/// - Discount code application +/// - Cart summary calculations +final class CartProvider extends $NotifierProvider { + /// Cart Notifier + /// + /// Manages cart state including: + /// - Adding/removing items + /// - Updating quantities + /// - Warehouse selection + /// - Discount code application + /// - Cart summary calculations + const CartProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'cartProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cartHash(); + + @$internal + @override + Cart create() => Cart(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CartState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$cartHash() => r'fa4c957f9cd7e54000e035b0934ad2bd08ba2786'; + +/// Cart Notifier +/// +/// Manages cart state including: +/// - Adding/removing items +/// - Updating quantities +/// - Warehouse selection +/// - Discount code application +/// - Cart summary calculations + +abstract class _$Cart extends $Notifier { + CartState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + CartState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Cart item count provider + +@ProviderFor(cartItemCount) +const cartItemCountProvider = CartItemCountProvider._(); + +/// Cart item count provider + +final class CartItemCountProvider extends $FunctionalProvider + with $Provider { + /// Cart item count provider + const CartItemCountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'cartItemCountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cartItemCountHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + int create(Ref ref) { + return cartItemCount(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(int value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$cartItemCountHash() => r'4ddc2979030a4470b2fa1de4832a84313e98e259'; + +/// Cart total provider + +@ProviderFor(cartTotal) +const cartTotalProvider = CartTotalProvider._(); + +/// Cart total provider + +final class CartTotalProvider + extends $FunctionalProvider + with $Provider { + /// Cart total provider + const CartTotalProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'cartTotalProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$cartTotalHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + double create(Ref ref) { + return cartTotal(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(double value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$cartTotalHash() => r'48460600487e734788e6d6cf1e4f7e13d21f21a4'; diff --git a/lib/features/cart/presentation/providers/cart_state.dart b/lib/features/cart/presentation/providers/cart_state.dart new file mode 100644 index 0000000..c9f24b4 --- /dev/null +++ b/lib/features/cart/presentation/providers/cart_state.dart @@ -0,0 +1,111 @@ +/// Cart State +/// +/// Immutable state class for cart management. +library; + +import 'package:worker/features/products/domain/entities/product.dart'; + +/// Cart Item Data +/// +/// Represents a product in the cart with quantity. +class CartItemData { + final Product product; + final double quantity; + + const CartItemData({ + required this.product, + required this.quantity, + }); + + /// Calculate line total + double get lineTotal => product.basePrice * quantity; + + CartItemData copyWith({ + Product? product, + double? quantity, + }) { + return CartItemData( + product: product ?? this.product, + quantity: quantity ?? this.quantity, + ); + } +} + +/// Cart State +/// +/// Represents the complete state of the shopping cart. +class CartState { + final List items; + final String selectedWarehouse; + final String? discountCode; + final bool discountCodeApplied; + final String memberTier; + final double memberDiscountPercent; + final double subtotal; + final double memberDiscount; + final double shippingFee; + final double total; + + const CartState({ + required this.items, + required this.selectedWarehouse, + this.discountCode, + required this.discountCodeApplied, + required this.memberTier, + required this.memberDiscountPercent, + required this.subtotal, + required this.memberDiscount, + required this.shippingFee, + required this.total, + }); + + factory CartState.initial() { + return const CartState( + items: [], + selectedWarehouse: 'Kho Hà Nội - Nguyễn Trãi', + discountCode: null, + discountCodeApplied: false, + memberTier: '', + memberDiscountPercent: 0.0, + subtotal: 0.0, + memberDiscount: 0.0, + shippingFee: 0.0, + total: 0.0, + ); + } + + bool get isEmpty => items.isEmpty; + bool get isNotEmpty => items.isNotEmpty; + int get itemCount => items.length; + + /// Get total quantity across all items + double get totalQuantity { + return items.fold(0.0, (sum, item) => sum + item.quantity); + } + + CartState copyWith({ + List? items, + String? selectedWarehouse, + String? discountCode, + bool? discountCodeApplied, + String? memberTier, + double? memberDiscountPercent, + double? subtotal, + double? memberDiscount, + double? shippingFee, + double? total, + }) { + return CartState( + items: items ?? this.items, + selectedWarehouse: selectedWarehouse ?? this.selectedWarehouse, + discountCode: discountCode ?? this.discountCode, + discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied, + memberTier: memberTier ?? this.memberTier, + memberDiscountPercent: memberDiscountPercent ?? this.memberDiscountPercent, + subtotal: subtotal ?? this.subtotal, + memberDiscount: memberDiscount ?? this.memberDiscount, + shippingFee: shippingFee ?? this.shippingFee, + total: total ?? this.total, + ); + } +} diff --git a/lib/features/cart/presentation/widgets/cart_item_widget.dart b/lib/features/cart/presentation/widgets/cart_item_widget.dart new file mode 100644 index 0000000..8d30eaf --- /dev/null +++ b/lib/features/cart/presentation/widgets/cart_item_widget.dart @@ -0,0 +1,212 @@ +/// Cart Item Widget +/// +/// Displays a single item in the cart with image, details, and quantity controls. +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/core/theme/typography.dart'; +import 'package:worker/features/cart/presentation/providers/cart_provider.dart'; +import 'package:worker/features/cart/presentation/providers/cart_state.dart'; + +/// Cart Item Widget +/// +/// Displays: +/// - Product image (80x80, rounded) +/// - Product name and SKU +/// - Price per unit +/// - Quantity controls (-, value, +, unit label) +class CartItemWidget extends ConsumerWidget { + final CartItemData item; + + const CartItemWidget({ + super.key, + required this.item, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currencyFormatter = NumberFormat.currency( + locale: 'vi_VN', + symbol: 'đ', + decimalDigits: 0, + ); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Image + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: item.product.imageUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 80, + color: AppColors.grey100, + child: const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 80, + color: AppColors.grey100, + child: const Icon( + Icons.image_not_supported, + color: AppColors.grey500, + ), + ), + ), + ), + + const SizedBox(width: 12), + + // Product Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Name + Text( + item.product.name, + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // SKU + Text( + 'Mã: ${item.product.erpnextItemCode ?? item.product.productId}', + style: AppTypography.bodySmall.copyWith( + color: AppColors.grey500, + ), + ), + + const SizedBox(height: 8), + + // Price + Text( + '${currencyFormatter.format(item.product.basePrice)}/${item.product.unit ?? 'm²'}', + style: AppTypography.titleMedium.copyWith( + color: AppColors.primaryBlue, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 12), + + // Quantity Controls + Row( + children: [ + // Decrease button + _QuantityButton( + icon: Icons.remove, + onPressed: () { + ref.read(cartProvider.notifier).decrementQuantity( + item.product.productId, + ); + }, + ), + + const SizedBox(width: 12), + + // Quantity value + Text( + item.quantity.toStringAsFixed(0), + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(width: 12), + + // Increase button + _QuantityButton( + icon: Icons.add, + onPressed: () { + ref.read(cartProvider.notifier).incrementQuantity( + item.product.productId, + ); + }, + ), + + const SizedBox(width: 8), + + // Unit label + Text( + item.product.unit ?? 'm²', + style: AppTypography.bodySmall.copyWith( + color: AppColors.grey500, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Quantity Button +/// +/// Small circular button for incrementing/decrementing quantity. +class _QuantityButton extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + + const _QuantityButton({ + required this.icon, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(20), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + icon, + size: 18, + color: AppColors.grey900, + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index f8f62e2..a316024 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -137,7 +137,7 @@ class HomePage extends ConsumerWidget { icon: Icons.shopping_cart, label: 'Giỏ hàng', badge: '3', - onTap: () => _showComingSoon(context, 'Giỏ hàng', l10n), + onTap: () => context.push('/cart'), ), QuickAction( icon: Icons.favorite, diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 30271e1..c8b98c9 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -7,7 +7,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/cart/presentation/providers/cart_provider.dart'; import 'package:worker/features/products/presentation/providers/categories_provider.dart'; import 'package:worker/features/products/presentation/providers/products_provider.dart'; import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart'; @@ -31,6 +33,7 @@ class ProductsPage extends ConsumerWidget { final l10n = AppLocalizations.of(context)!; final categoriesAsync = ref.watch(categoriesProvider); final productsAsync = ref.watch(productsProvider); + final cartItemCount = ref.watch(cartItemCountProvider); return Scaffold( backgroundColor: const Color(0xFFF4F6F8), // Match HTML background @@ -47,21 +50,14 @@ class ProductsPage extends ConsumerWidget { actions: [ // Cart Icon with Badge IconButton( - icon: const Badge( - label: Text('3'), + icon: Badge( + label: Text('$cartItemCount'), backgroundColor: AppColors.danger, textColor: AppColors.white, - child: Icon(Icons.shopping_cart_outlined, color: Colors.black,), + isLabelVisible: cartItemCount > 0, + child: const Icon(Icons.shopping_cart_outlined, color: Colors.black), ), - onPressed: () { - // TODO: Navigate to cart page - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.cart), - duration: const Duration(seconds: 1), - ), - ); - }, + onPressed: () => context.go(RouteNames.cart), ), const SizedBox(width: AppSpacing.sm), ], @@ -102,16 +98,16 @@ class ProductsPage extends ConsumerWidget { context.push('/products/${product.productId}'); }, onAddToCart: (product) { - // TODO: Add to cart logic + // Add to cart + ref.read(cartProvider.notifier).addToCart(product); + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${product.name} đã thêm vào giỏ hàng'), duration: const Duration(seconds: 2), action: SnackBarAction( label: 'Xem', - onPressed: () { - // Navigate to cart - }, + onPressed: () => context.go(RouteNames.cart), ), ), ); diff --git a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart index 502dc05..c9f8b1b 100644 --- a/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart +++ b/lib/features/products/presentation/widgets/product_detail/product_tabs_section.dart @@ -417,14 +417,14 @@ class _ReviewItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - review['name'], + review['name']?.toString() ?? '', style: const TextStyle( fontWeight: FontWeight.w600, color: AppColors.grey900, ), ), Text( - review['date'], + review['date']?.toString() ?? '', style: const TextStyle( fontSize: 12, color: AppColors.grey500, @@ -443,7 +443,7 @@ class _ReviewItem extends StatelessWidget { children: List.generate( 5, (index) => Icon( - index < review['rating'] + index < (review['rating'] as num? ?? 0).toInt() ? Icons.star : Icons.star_border, color: const Color(0xFFffc107), @@ -456,7 +456,7 @@ class _ReviewItem extends StatelessWidget { // Review Text Text( - review['text'], + review['text']?.toString() ?? '', style: const TextStyle( fontSize: 14, height: 1.5,