This commit is contained in:
Phuoc Nguyen
2025-10-24 15:41:20 +07:00
parent c225144ad3
commit eaaa9921f5
9 changed files with 1173 additions and 21 deletions

View File

@@ -6,6 +6,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/main/presentation/pages/main_scaffold.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_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 // TODO: Add more routes as features are implemented
], ],

View File

@@ -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<CartPage> createState() => _CartPageState();
}
class _CartPageState extends ConsumerState<CartPage> {
final TextEditingController _discountController = TextEditingController();
@override
void dispose() {
_discountController.dispose();
super.dispose();
}
void _clearCart() {
showDialog<void>(
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<String>(
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 ?? ''})',
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,
),
),
),
),
);
}
}

View File

@@ -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<double>(
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<double>(
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;
}

View File

@@ -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, CartState> {
/// 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<CartState>(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> {
CartState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<CartState, CartState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<CartState, CartState>,
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<int, int, int>
with $Provider<int> {
/// 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<int> $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<int>(value),
);
}
}
String _$cartItemCountHash() => r'4ddc2979030a4470b2fa1de4832a84313e98e259';
/// Cart total provider
@ProviderFor(cartTotal)
const cartTotalProvider = CartTotalProvider._();
/// Cart total provider
final class CartTotalProvider
extends $FunctionalProvider<double, double, double>
with $Provider<double> {
/// 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<double> $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<double>(value),
);
}
}
String _$cartTotalHash() => r'48460600487e734788e6d6cf1e4f7e13d21f21a4';

View File

@@ -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<CartItemData> 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<double>(0.0, (sum, item) => sum + item.quantity);
}
CartState copyWith({
List<CartItemData>? 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,
);
}
}

View File

@@ -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 ?? ''}',
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 ?? '',
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,
),
),
);
}
}

View File

@@ -137,7 +137,7 @@ class HomePage extends ConsumerWidget {
icon: Icons.shopping_cart, icon: Icons.shopping_cart,
label: 'Giỏ hàng', label: 'Giỏ hàng',
badge: '3', badge: '3',
onTap: () => _showComingSoon(context, 'Giỏ hàng', l10n), onTap: () => context.push('/cart'),
), ),
QuickAction( QuickAction(
icon: Icons.favorite, icon: Icons.favorite,

View File

@@ -7,7 +7,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.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/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/categories_provider.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart'; import 'package:worker/features/products/presentation/providers/products_provider.dart';
import 'package:worker/features/products/presentation/widgets/category_filter_chips.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 l10n = AppLocalizations.of(context)!;
final categoriesAsync = ref.watch(categoriesProvider); final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final cartItemCount = ref.watch(cartItemCountProvider);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), // Match HTML background backgroundColor: const Color(0xFFF4F6F8), // Match HTML background
@@ -47,21 +50,14 @@ class ProductsPage extends ConsumerWidget {
actions: [ actions: [
// Cart Icon with Badge // Cart Icon with Badge
IconButton( IconButton(
icon: const Badge( icon: Badge(
label: Text('3'), label: Text('$cartItemCount'),
backgroundColor: AppColors.danger, backgroundColor: AppColors.danger,
textColor: AppColors.white, 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: () { onPressed: () => context.go(RouteNames.cart),
// TODO: Navigate to cart page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.cart),
duration: const Duration(seconds: 1),
),
);
},
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
], ],
@@ -102,16 +98,16 @@ class ProductsPage extends ConsumerWidget {
context.push('/products/${product.productId}'); context.push('/products/${product.productId}');
}, },
onAddToCart: (product) { onAddToCart: (product) {
// TODO: Add to cart logic // Add to cart
ref.read(cartProvider.notifier).addToCart(product);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${product.name} đã thêm vào giỏ hàng'), content: Text('${product.name} đã thêm vào giỏ hàng'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
action: SnackBarAction( action: SnackBarAction(
label: 'Xem', label: 'Xem',
onPressed: () { onPressed: () => context.go(RouteNames.cart),
// Navigate to cart
},
), ),
), ),
); );

View File

@@ -417,14 +417,14 @@ class _ReviewItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
review['name'], review['name']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.grey900, color: AppColors.grey900,
), ),
), ),
Text( Text(
review['date'], review['date']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.grey500, color: AppColors.grey500,
@@ -443,7 +443,7 @@ class _ReviewItem extends StatelessWidget {
children: List.generate( children: List.generate(
5, 5,
(index) => Icon( (index) => Icon(
index < review['rating'] index < (review['rating'] as num? ?? 0).toInt()
? Icons.star ? Icons.star
: Icons.star_border, : Icons.star_border,
color: const Color(0xFFffc107), color: const Color(0xFFffc107),
@@ -456,7 +456,7 @@ class _ReviewItem extends StatelessWidget {
// Review Text // Review Text
Text( Text(
review['text'], review['text']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
height: 1.5, height: 1.5,