update cart

This commit is contained in:
Phuoc Nguyen
2025-11-14 16:19:25 +07:00
parent 4738553d2e
commit aae3c9d080
30 changed files with 5954 additions and 758 deletions

View File

@@ -0,0 +1,415 @@
# Cart API Integration - Complete Implementation
## Overview
This document describes the complete cart API integration following clean architecture principles for the Worker Flutter app.
## Architecture
```
Presentation Layer (UI)
Domain Layer (Business Logic)
Data Layer (API + Local Storage)
```
## Files Created
### 1. API Constants
**File**: `/Users/ssg/project/worker/lib/core/constants/api_constants.dart`
**Added Endpoints**:
- `addToCart` - POST /api/method/building_material.building_material.api.user_cart.add_to_cart
- `removeFromCart` - POST /api/method/building_material.building_material.api.user_cart.remove_from_cart
- `getUserCart` - POST /api/method/building_material.building_material.api.user_cart.get_user_cart
### 2. Domain Repository Interface
**File**: `/Users/ssg/project/worker/lib/features/cart/domain/repositories/cart_repository.dart`
**Methods**:
- `addToCart()` - Add items to cart
- `removeFromCart()` - Remove items from cart
- `getCartItems()` - Get all cart items
- `updateQuantity()` - Update item quantity
- `clearCart()` - Clear all cart items
- `getCartTotal()` - Get total cart value
- `getCartItemCount()` - Get total item count
**Returns**: Domain entities (`CartItem`), not models.
### 3. Remote Data Source
**File**: `/Users/ssg/project/worker/lib/features/cart/data/datasources/cart_remote_datasource.dart`
**Class**: `CartRemoteDataSourceImpl`
**Features**:
- Uses `DioClient` for HTTP requests
- Proper error handling with custom exceptions
- Converts Dio exceptions to app-specific exceptions
- Maps API response to `CartItemModel`
**API Request/Response Mapping**:
#### Add to Cart
```dart
// Request
{
"items": [
{
"item_id": "Gạch ốp Signature SIG.P-8806",
"amount": 4000000,
"quantity": 33
}
]
}
// Response
{
"message": [
{
"item_id": "Gạch ốp Signature SIG.P-8806",
"success": true,
"message": "Updated quantity in cart"
}
]
}
```
#### Get Cart Items
```dart
// Request
{
"limit_start": 0,
"limit_page_length": 0
}
// Response
{
"message": [
{
"name": "rfsbgqusrj",
"item": "Gạch ốp Signature SIG.P-8806",
"quantity": 33.0,
"amount": 4000000.0,
"item_code": "Gạch ốp Signature SIG.P-8806",
"item_name": "Gạch ốp Signature SIG.P-8806",
"image": null,
"conversion_of_sm": 0.0
}
]
}
```
**Error Handling**:
- `TimeoutException` - Connection/send/receive timeout
- `NoInternetException` - No network connection
- `UnauthorizedException` - 401 errors
- `ForbiddenException` - 403 errors
- `NotFoundException` - 404 errors
- `RateLimitException` - 429 errors
- `ServerException` - 5xx errors
- `NetworkException` - Other network errors
- `ParseException` - Invalid response format
### 4. Local Data Source
**File**: `/Users/ssg/project/worker/lib/features/cart/data/datasources/cart_local_datasource.dart`
**Class**: `CartLocalDataSourceImpl`
**Features**:
- Uses Hive for local storage
- Box name: `cartBox` (from `HiveBoxNames.cartBox`)
- Uses `Box<dynamic>` with `.whereType<CartItemModel>()` (best practice)
- Stores items with `productId` as key
**Methods**:
- `saveCartItems()` - Replace all cart items
- `getCartItems()` - Get all cart items
- `addCartItem()` - Add single item (merges if exists)
- `updateCartItem()` - Update single item
- `removeCartItems()` - Remove items by productId
- `clearCart()` - Clear all items
- `getCartItemCount()` - Sum of all quantities
- `getCartTotal()` - Sum of all subtotals
**Best Practice**:
```dart
Box<dynamic> get _cartBox => _hiveService.getBox<dynamic>(HiveBoxNames.cartBox);
Future<List<CartItemModel>> getCartItems() async {
final items = _cartBox.values
.whereType<CartItemModel>()
.toList();
return items;
}
```
### 5. Repository Implementation
**File**: `/Users/ssg/project/worker/lib/features/cart/data/repositories/cart_repository_impl.dart`
**Class**: `CartRepositoryImpl`
**Strategy**: API-first with local fallback
#### Workflow:
1. **Add to Cart**:
- Try API request first
- On success: Sync to local storage
- On network error: Add to local only + queue for sync
- Convert models to entities before returning
2. **Remove from Cart**:
- Try API request first
- On success: Remove from local storage
- On network error: Remove from local only + queue for sync
3. **Get Cart Items**:
- Try API request first
- On success: Sync to local storage
- On network error: Return local data (offline support)
- Convert models to entities before returning
4. **Update Quantity**:
- Uses `addToCart` with new quantity (replaces existing)
5. **Clear Cart**:
- Gets all items and removes them via API
**Error Propagation**:
- Rethrows: `StorageException`, `NetworkException`, `ServerException`, `ValidationException`
- Wraps unknown errors in `UnknownException`
**Helper Methods**:
- `_modelToEntity()` - Convert `CartItemModel` to `CartItem` entity
- `_createCartItemModel()` - Create new model from parameters
### 6. Riverpod Providers
**Remote Data Source Provider**:
```dart
@riverpod
CartRemoteDataSource cartRemoteDataSource(CartRemoteDataSourceRef ref) {
final dioClient = ref.watch(dioClientProvider).requireValue;
return CartRemoteDataSourceImpl(dioClient);
}
```
**Local Data Source Provider**:
```dart
@riverpod
CartLocalDataSource cartLocalDataSource(CartLocalDataSourceRef ref) {
final hiveService = HiveService();
return CartLocalDataSourceImpl(hiveService);
}
```
**Repository Provider**:
```dart
@riverpod
CartRepository cartRepository(CartRepositoryRef ref) {
final remoteDataSource = ref.watch(cartRemoteDataSourceProvider);
final localDataSource = ref.watch(cartLocalDataSourceProvider);
return CartRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource,
);
}
```
## Usage Example
### In Cart Provider (Presentation Layer)
```dart
@riverpod
class Cart extends _$Cart {
CartRepository get _repository => ref.read(cartRepositoryProvider);
// Add product to cart via API
Future<void> addProductToCart(Product product, double quantity) async {
try {
// Call repository
final items = await _repository.addToCart(
itemIds: [product.erpnextItemCode ?? product.productId],
quantities: [quantity],
prices: [product.basePrice],
);
// Update UI state
state = state.copyWith(items: _convertToCartItemData(items));
} catch (e) {
// Handle error (show snackbar, etc.)
throw e;
}
}
// Get cart items from API
Future<void> loadCartItems() async {
try {
final items = await _repository.getCartItems();
state = state.copyWith(items: _convertToCartItemData(items));
} catch (e) {
// Handle error
throw e;
}
}
// Remove from cart via API
Future<void> removeProductFromCart(String productId) async {
try {
await _repository.removeFromCart(itemIds: [productId]);
// Update UI state
final updatedItems = state.items
.where((item) => item.product.productId != productId)
.toList();
state = state.copyWith(items: updatedItems);
} catch (e) {
// Handle error
throw e;
}
}
}
```
## API-First Strategy Details
### Online Scenario:
1. User adds item to cart
2. API request sent to backend
3. Backend returns updated cart
4. Local storage synced with API response
5. UI updated with latest data
### Offline Scenario:
1. User adds item to cart
2. API request fails (no internet)
3. Item added to local storage only
4. Request queued for later sync (TODO)
5. UI updated from local data
### Sync on Reconnection:
When internet connection restored:
1. Process offline queue
2. Send queued requests to API
3. Sync local storage with API responses
4. Clear queue on success
## Important Notes
### Product ID Mapping
- **Frontend**: Uses `product.productId` (UUID)
- **API**: Expects `item_id` (ERPNext item code)
- **Mapping**: Use `product.erpnextItemCode` when calling API
- **Fallback**: If `erpnextItemCode` is null, use `productId`
### API Response Fields
- `name` - Cart item ID (ERPNext internal ID)
- `item` or `item_code` - Product ERPNext code
- `quantity` - Item quantity
- `amount` - Unit price
- `conversion_of_sm` - Conversion factor (if applicable)
### Local Storage
- Box: `HiveBoxNames.cartBox`
- Key: `productId` (for easy lookup and updates)
- Type: `CartItemModel` (Hive type ID: 5)
- Strategy: `Box<dynamic>` with `.whereType<CartItemModel>()`
### Error Handling Best Practices
1. Always catch specific exceptions first
2. Rethrow domain-specific exceptions
3. Wrap unknown errors in `UnknownException`
4. Provide user-friendly error messages
5. Log errors for debugging
## Testing Checklist
- [ ] Add single item to cart
- [ ] Add multiple items at once
- [ ] Update item quantity
- [ ] Remove single item
- [ ] Remove multiple items
- [ ] Clear entire cart
- [ ] Get cart items
- [ ] Calculate cart total
- [ ] Calculate item count
- [ ] Offline add (no internet)
- [ ] Offline remove (no internet)
- [ ] Sync after reconnection
- [ ] Handle API errors gracefully
- [ ] Handle timeout errors
- [ ] Handle unauthorized errors
- [ ] Local storage persistence
## Future Enhancements
1. **Offline Queue System**:
- Implement request queue for offline operations
- Auto-sync when connection restored
- Conflict resolution for concurrent edits
2. **Optimistic Updates**:
- Update UI immediately
- Sync with backend in background
- Rollback on failure
3. **Cart Sync Status**:
- Track sync state per item
- Show sync indicators in UI
- Manual sync trigger
4. **Multi-cart Support**:
- Named carts (e.g., "Project A", "Project B")
- Switch between carts
- Merge carts
5. **Cart Analytics**:
- Track add/remove events
- Cart abandonment tracking
- Conversion metrics
## Related Files
- Domain Entity: `/Users/ssg/project/worker/lib/features/cart/domain/entities/cart_item.dart`
- Data Model: `/Users/ssg/project/worker/lib/features/cart/data/models/cart_item_model.dart`
- UI State: `/Users/ssg/project/worker/lib/features/cart/presentation/providers/cart_state.dart`
- Cart Page: `/Users/ssg/project/worker/lib/features/cart/presentation/pages/cart_page.dart`
## Dependencies
- `dio` - HTTP client
- `hive_ce` - Local database
- `riverpod` - State management
- `riverpod_annotation` - Code generation
## Code Generation
To generate Riverpod providers (`.g.dart` files):
```bash
dart run build_runner build --delete-conflicting-outputs
```
Or for watch mode:
```bash
dart run build_runner watch --delete-conflicting-outputs
```
## Summary
This implementation provides:
- ✅ Clean architecture separation
- ✅ API-first with local fallback
- ✅ Offline support
- ✅ Proper error handling
- ✅ Type-safe operations
- ✅ Hive best practices
- ✅ Riverpod integration
- ✅ Scalable and maintainable code
All files follow the existing codebase patterns and are ready for integration with the UI layer.

View File

@@ -0,0 +1,179 @@
/// Local Data Source: Cart Storage
///
/// Handles local storage of cart items using Hive for offline access.
/// Supports offline-first functionality and cart persistence.
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/hive_service.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/cart/data/models/cart_item_model.dart';
/// Cart Local Data Source Interface
abstract class CartLocalDataSource {
/// Save cart items to local storage
///
/// [items] - List of cart items to save
Future<void> saveCartItems(List<CartItemModel> items);
/// Get cart items from local storage
///
/// Returns list of cart items
Future<List<CartItemModel>> getCartItems();
/// Add item to local cart
///
/// [item] - Cart item to add
Future<void> addCartItem(CartItemModel item);
/// Update item in local cart
///
/// [item] - Cart item to update
Future<void> updateCartItem(CartItemModel item);
/// Remove items from local cart
///
/// [itemIds] - Product IDs to remove
Future<void> removeCartItems(List<String> itemIds);
/// Clear all items from local cart
Future<void> clearCart();
/// Get cart item count
///
/// Returns total number of items
Future<int> getCartItemCount();
/// Get cart total
///
/// Returns total amount
Future<double> getCartTotal();
}
/// Cart Local Data Source Implementation
class CartLocalDataSourceImpl implements CartLocalDataSource {
CartLocalDataSourceImpl(this._hiveService);
final HiveService _hiveService;
/// Get cart box as Box<dynamic> (following best practices)
Box<dynamic> get _cartBox => _hiveService.getBox<dynamic>(HiveBoxNames.cartBox);
@override
Future<void> saveCartItems(List<CartItemModel> items) async {
try {
// Clear existing items
await _cartBox.clear();
// Save new items with productId as key
for (final item in items) {
await _cartBox.put(item.productId, item);
}
} catch (e) {
throw StorageException('Failed to save cart items: $e');
}
}
@override
Future<List<CartItemModel>> getCartItems() async {
try {
// Get all cart items from box using .whereType() for type safety
final items = _cartBox.values
.whereType<CartItemModel>()
.toList();
return items;
} catch (e) {
throw StorageException('Failed to get cart items: $e');
}
}
@override
Future<void> addCartItem(CartItemModel item) async {
try {
// Check if item already exists
final existingItem = _cartBox.get(item.productId);
if (existingItem != null && existingItem is CartItemModel) {
// Update quantity if item exists
final updatedItem = existingItem.copyWith(
quantity: existingItem.quantity + item.quantity,
subtotal: (existingItem.quantity + item.quantity) * existingItem.unitPrice,
);
await _cartBox.put(item.productId, updatedItem);
} else {
// Add new item
await _cartBox.put(item.productId, item);
}
} catch (e) {
throw StorageException('Failed to add cart item: $e');
}
}
@override
Future<void> updateCartItem(CartItemModel item) async {
try {
// Update or add item
await _cartBox.put(item.productId, item);
} catch (e) {
throw StorageException('Failed to update cart item: $e');
}
}
@override
Future<void> removeCartItems(List<String> itemIds) async {
try {
// Remove items by productId keys
for (final itemId in itemIds) {
await _cartBox.delete(itemId);
}
} catch (e) {
throw StorageException('Failed to remove cart items: $e');
}
}
@override
Future<void> clearCart() async {
try {
await _cartBox.clear();
} catch (e) {
throw StorageException('Failed to clear cart: $e');
}
}
@override
Future<int> getCartItemCount() async {
try {
final items = await getCartItems();
// Sum up all quantities
final totalQuantity = items.fold<double>(
0.0,
(sum, item) => sum + item.quantity,
);
return totalQuantity.toInt();
} catch (e) {
throw StorageException('Failed to get cart item count: $e');
}
}
@override
Future<double> getCartTotal() async {
try {
final items = await getCartItems();
// Sum up all subtotals
final total = items.fold<double>(
0.0,
(sum, item) => sum + item.subtotal,
);
return total;
} catch (e) {
throw StorageException('Failed to get cart total: $e');
}
}
}

View File

@@ -0,0 +1,269 @@
/// Remote Data Source: Cart API
///
/// Handles all cart-related API requests to the backend.
/// Uses Frappe/ERPNext API endpoints for cart operations.
library;
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/cart/data/models/cart_item_model.dart';
/// Cart Remote Data Source Interface
abstract class CartRemoteDataSource {
/// Add items to cart
///
/// [items] - List of items with item_id, quantity, and amount
/// Returns list of cart items from API
Future<List<CartItemModel>> addToCart({
required List<Map<String, dynamic>> items,
});
/// Remove items from cart
///
/// [itemIds] - List of product ERPNext item codes to remove
/// Returns true if successful
Future<bool> removeFromCart({
required List<String> itemIds,
});
/// Get user's cart items
///
/// [limitStart] - Pagination offset (default: 0)
/// [limitPageLength] - Page size (default: 0 for all)
/// Returns list of cart items
Future<List<CartItemModel>> getUserCart({
int limitStart = 0,
int limitPageLength = 0,
});
}
/// Cart Remote Data Source Implementation
class CartRemoteDataSourceImpl implements CartRemoteDataSource {
CartRemoteDataSourceImpl(this._dioClient);
final DioClient _dioClient;
@override
Future<List<CartItemModel>> addToCart({
required List<Map<String, dynamic>> items,
}) async {
try {
// Build request body
final requestBody = {
'items': items,
};
// Make API request
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.addToCart}',
data: requestBody,
);
// Check response status
if (response.statusCode != 200 && response.statusCode != 201) {
throw ServerException(
'Failed to add items to cart',
response.statusCode,
);
}
// Parse response
// Expected format: { "message": [{ "item_id": "...", "success": true, "message": "..." }] }
final responseData = response.data;
if (responseData == null) {
throw const ParseException('Invalid response format from add to cart API');
}
// After adding, fetch updated cart
return await getUserCart();
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
if (e is NetworkException ||
e is ServerException ||
e is ParseException) {
rethrow;
}
throw UnknownException('Failed to add items to cart', e);
}
}
@override
Future<bool> removeFromCart({
required List<String> itemIds,
}) async {
try {
// Build request body
final requestBody = {
'item_ids': itemIds,
};
// Make API request
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.removeFromCart}',
data: requestBody,
);
// Check response status
if (response.statusCode != 200) {
throw ServerException(
'Failed to remove items from cart',
response.statusCode,
);
}
// Parse response
// Expected format: { "message": [{ "item_id": "...", "success": true, "message": "..." }] }
final responseData = response.data;
if (responseData == null) {
throw const ParseException('Invalid response format from remove from cart API');
}
final message = responseData['message'];
if (message is List && message.isNotEmpty) {
// Check if all items were removed successfully
final allSuccess = message.every((item) => item['success'] == true);
return allSuccess;
}
return true;
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
if (e is NetworkException ||
e is ServerException ||
e is ParseException) {
rethrow;
}
throw UnknownException('Failed to remove items from cart', e);
}
}
@override
Future<List<CartItemModel>> getUserCart({
int limitStart = 0,
int limitPageLength = 0,
}) async {
try {
// Build request body
final requestBody = {
'limit_start': limitStart,
'limit_page_length': limitPageLength,
};
// Make API request
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getUserCart}',
data: requestBody,
);
// Check response status
if (response.statusCode != 200) {
throw ServerException(
'Failed to get cart items',
response.statusCode,
);
}
// Parse response
// Expected format: { "message": [{ "name": "...", "item": "...", "quantity": 0, "amount": 0, ... }] }
final responseData = response.data;
if (responseData == null) {
throw const ParseException('Invalid response format from get user cart API');
}
final message = responseData['message'];
if (message == null || message is! List) {
throw const ParseException('Invalid message format in get user cart response');
}
// Convert to CartItemModel list
final cartItems = <CartItemModel>[];
for (final item in message) {
if (item is! Map<String, dynamic>) continue;
try {
// Map API response to CartItemModel
// API fields: name, item, quantity, amount, item_code, item_name, image, conversion_of_sm
final cartItem = CartItemModel(
cartItemId: item['name'] as String? ?? '',
cartId: 'user_cart', // Fixed cart ID for user's cart
productId: item['item_code'] as String? ?? item['item'] as String? ?? '',
quantity: (item['quantity'] as num?)?.toDouble() ?? 0.0,
unitPrice: (item['amount'] as num?)?.toDouble() ?? 0.0,
subtotal: ((item['quantity'] as num?)?.toDouble() ?? 0.0) *
((item['amount'] as num?)?.toDouble() ?? 0.0),
addedAt: DateTime.now(), // API doesn't provide timestamp
);
cartItems.add(cartItem);
} catch (e) {
// Skip invalid items but don't fail the whole request
continue;
}
}
return cartItems;
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
if (e is NetworkException ||
e is ServerException ||
e is ParseException) {
rethrow;
}
throw UnknownException('Failed to get cart items', e);
}
}
/// Handle Dio exceptions and convert to custom exceptions
Exception _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const TimeoutException();
case DioExceptionType.connectionError:
return const NoInternetException();
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
if (statusCode != null) {
if (statusCode == 401) {
return const UnauthorizedException();
} else if (statusCode == 403) {
return const ForbiddenException();
} else if (statusCode == 404) {
return const NotFoundException('Cart not found');
} else if (statusCode == 429) {
return const RateLimitException();
} else if (statusCode >= 500) {
return ServerException(
'Server error: ${e.response?.statusMessage ?? "Unknown error"}',
statusCode,
);
}
}
return NetworkException(
e.response?.statusMessage ?? 'Network error',
statusCode: statusCode,
);
case DioExceptionType.cancel:
return const NetworkException('Request cancelled');
case DioExceptionType.badCertificate:
return const NetworkException('Invalid SSL certificate');
case DioExceptionType.unknown:
return const NoInternetException();
}
}
}

View File

@@ -0,0 +1,50 @@
/// Cart Data Providers
///
/// State management for cart data layer using Riverpod.
/// Provides access to datasources and repositories.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/database/hive_service.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/cart/data/datasources/cart_local_datasource.dart';
import 'package:worker/features/cart/data/datasources/cart_remote_datasource.dart';
import 'package:worker/features/cart/data/repositories/cart_repository_impl.dart';
import 'package:worker/features/cart/domain/repositories/cart_repository.dart';
part 'cart_data_providers.g.dart';
/// Cart Local DataSource Provider
///
/// Provides instance of CartLocalDataSource.
/// keepAlive: true to persist with cart provider.
@Riverpod(keepAlive: true)
CartLocalDataSource cartLocalDataSource(Ref ref) {
final hiveService = HiveService();
return CartLocalDataSourceImpl(hiveService);
}
/// Cart Remote DataSource Provider
///
/// Provides instance of CartRemoteDataSource with DioClient.
/// keepAlive: true to persist with cart provider.
@Riverpod(keepAlive: true)
Future<CartRemoteDataSource> cartRemoteDataSource(Ref ref) async {
final dioClient = await ref.watch(dioClientProvider.future);
return CartRemoteDataSourceImpl(dioClient);
}
/// Cart Repository Provider
///
/// Provides instance of CartRepository implementation.
/// keepAlive: true to persist with cart provider.
@Riverpod(keepAlive: true)
Future<CartRepository> cartRepository(Ref ref) async {
final remoteDataSource = await ref.watch(cartRemoteDataSourceProvider.future);
final localDataSource = ref.watch(cartLocalDataSourceProvider);
return CartRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource,
);
}

View File

@@ -0,0 +1,180 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_data_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Cart Local DataSource Provider
///
/// Provides instance of CartLocalDataSource.
/// keepAlive: true to persist with cart provider.
@ProviderFor(cartLocalDataSource)
const cartLocalDataSourceProvider = CartLocalDataSourceProvider._();
/// Cart Local DataSource Provider
///
/// Provides instance of CartLocalDataSource.
/// keepAlive: true to persist with cart provider.
final class CartLocalDataSourceProvider
extends
$FunctionalProvider<
CartLocalDataSource,
CartLocalDataSource,
CartLocalDataSource
>
with $Provider<CartLocalDataSource> {
/// Cart Local DataSource Provider
///
/// Provides instance of CartLocalDataSource.
/// keepAlive: true to persist with cart provider.
const CartLocalDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartLocalDataSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartLocalDataSourceHash();
@$internal
@override
$ProviderElement<CartLocalDataSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
CartLocalDataSource create(Ref ref) {
return cartLocalDataSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CartLocalDataSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CartLocalDataSource>(value),
);
}
}
String _$cartLocalDataSourceHash() =>
r'81a8c1688dff786d4ecebbd8239ae1c8174008c0';
/// Cart Remote DataSource Provider
///
/// Provides instance of CartRemoteDataSource with DioClient.
/// keepAlive: true to persist with cart provider.
@ProviderFor(cartRemoteDataSource)
const cartRemoteDataSourceProvider = CartRemoteDataSourceProvider._();
/// Cart Remote DataSource Provider
///
/// Provides instance of CartRemoteDataSource with DioClient.
/// keepAlive: true to persist with cart provider.
final class CartRemoteDataSourceProvider
extends
$FunctionalProvider<
AsyncValue<CartRemoteDataSource>,
CartRemoteDataSource,
FutureOr<CartRemoteDataSource>
>
with
$FutureModifier<CartRemoteDataSource>,
$FutureProvider<CartRemoteDataSource> {
/// Cart Remote DataSource Provider
///
/// Provides instance of CartRemoteDataSource with DioClient.
/// keepAlive: true to persist with cart provider.
const CartRemoteDataSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartRemoteDataSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartRemoteDataSourceHash();
@$internal
@override
$FutureProviderElement<CartRemoteDataSource> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<CartRemoteDataSource> create(Ref ref) {
return cartRemoteDataSource(ref);
}
}
String _$cartRemoteDataSourceHash() =>
r'758905224c472f1e088c3be7c7451c2321959bd8';
/// Cart Repository Provider
///
/// Provides instance of CartRepository implementation.
/// keepAlive: true to persist with cart provider.
@ProviderFor(cartRepository)
const cartRepositoryProvider = CartRepositoryProvider._();
/// Cart Repository Provider
///
/// Provides instance of CartRepository implementation.
/// keepAlive: true to persist with cart provider.
final class CartRepositoryProvider
extends
$FunctionalProvider<
AsyncValue<CartRepository>,
CartRepository,
FutureOr<CartRepository>
>
with $FutureModifier<CartRepository>, $FutureProvider<CartRepository> {
/// Cart Repository Provider
///
/// Provides instance of CartRepository implementation.
/// keepAlive: true to persist with cart provider.
const CartRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartRepositoryHash();
@$internal
@override
$FutureProviderElement<CartRepository> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<CartRepository> create(Ref ref) {
return cartRepository(ref);
}
}
String _$cartRepositoryHash() => r'f6bbe5ab247737887e6b51f7ca8050bb6898ac2a';

View File

@@ -0,0 +1,285 @@
/// Repository Implementation: Cart Repository
///
/// Implements the cart repository interface with:
/// - API-first strategy with local fallback
/// - Automatic sync between API and local storage
/// - Offline queue support
/// - Error handling and recovery
library;
import 'package:worker/core/errors/exceptions.dart';
import 'package:worker/features/cart/data/datasources/cart_local_datasource.dart';
import 'package:worker/features/cart/data/datasources/cart_remote_datasource.dart';
import 'package:worker/features/cart/data/models/cart_item_model.dart';
import 'package:worker/features/cart/domain/entities/cart_item.dart';
import 'package:worker/features/cart/domain/repositories/cart_repository.dart';
/// Cart Repository Implementation
///
/// Strategy: API-first with local fallback
/// 1. Try API request first
/// 2. On success, sync to local storage
/// 3. On failure, fallback to local data (for reads)
/// 4. Queue failed writes for later sync
class CartRepositoryImpl implements CartRepository {
CartRepositoryImpl({
required CartRemoteDataSource remoteDataSource,
required CartLocalDataSource localDataSource,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource;
final CartRemoteDataSource _remoteDataSource;
final CartLocalDataSource _localDataSource;
@override
Future<List<CartItem>> addToCart({
required List<String> itemIds,
required List<double> quantities,
required List<double> prices,
}) async {
try {
// Validate input
if (itemIds.length != quantities.length || itemIds.length != prices.length) {
throw const ValidationException(
'Item IDs, quantities, and prices must have the same length',
);
}
// Build API request items
final items = <Map<String, dynamic>>[];
for (int i = 0; i < itemIds.length; i++) {
items.add({
'item_id': itemIds[i],
'quantity': quantities[i],
'amount': prices[i],
});
}
// Try API first
try {
final cartItemModels = await _remoteDataSource.addToCart(items: items);
// Sync to local storage
await _localDataSource.saveCartItems(cartItemModels);
// Convert to domain entities
return cartItemModels.map(_modelToEntity).toList();
} on NetworkException catch (e) {
// If no internet, add to local cart only
if (e is NoInternetException || e is TimeoutException) {
// Add items to local cart
for (int i = 0; i < itemIds.length; i++) {
final cartItemModel = _createCartItemModel(
productId: itemIds[i],
quantity: quantities[i],
unitPrice: prices[i],
);
await _localDataSource.addCartItem(cartItemModel);
}
// TODO: Queue for sync when online
// Return local cart items
final localItems = await _localDataSource.getCartItems();
return localItems.map(_modelToEntity).toList();
}
rethrow;
}
} on StorageException {
rethrow;
} on ValidationException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw UnknownException('Failed to add items to cart', e);
}
}
@override
Future<bool> removeFromCart({
required List<String> itemIds,
}) async {
try {
// Try API first
try {
final success = await _remoteDataSource.removeFromCart(itemIds: itemIds);
if (success) {
// Sync to local storage
await _localDataSource.removeCartItems(itemIds);
}
return success;
} on NetworkException catch (e) {
// If no internet, remove from local cart only
if (e is NoInternetException || e is TimeoutException) {
await _localDataSource.removeCartItems(itemIds);
// TODO: Queue for sync when online
return true;
}
rethrow;
}
} on StorageException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw UnknownException('Failed to remove items from cart', e);
}
}
@override
Future<List<CartItem>> getCartItems() async {
try {
// Try API first
try {
final cartItemModels = await _remoteDataSource.getUserCart();
// Sync to local storage
await _localDataSource.saveCartItems(cartItemModels);
// Convert to domain entities
return cartItemModels.map(_modelToEntity).toList();
} on NetworkException catch (e) {
// If no internet, fallback to local storage
if (e is NoInternetException || e is TimeoutException) {
final localItems = await _localDataSource.getCartItems();
return localItems.map(_modelToEntity).toList();
}
rethrow;
}
} on StorageException {
rethrow;
} on ServerException {
rethrow;
} on NetworkException {
rethrow;
} catch (e) {
throw UnknownException('Failed to get cart items', e);
}
}
@override
Future<List<CartItem>> updateQuantity({
required String itemId,
required double quantity,
required double price,
}) async {
try {
// API doesn't have update endpoint, use add with new quantity
// This will replace the existing quantity
return await addToCart(
itemIds: [itemId],
quantities: [quantity],
prices: [price],
);
} catch (e) {
throw UnknownException('Failed to update cart item quantity', e);
}
}
@override
Future<bool> clearCart() async {
try {
// Get all cart items
final items = await getCartItems();
if (items.isEmpty) {
return true;
}
// Extract item IDs
final itemIds = items.map((item) => item.productId).toList();
// Remove all items
return await removeFromCart(itemIds: itemIds);
} catch (e) {
throw UnknownException('Failed to clear cart', e);
}
}
@override
Future<double> getCartTotal() async {
try {
// Try to calculate from API data first
try {
final items = await getCartItems();
return items.fold<double>(
0.0,
(sum, item) => sum + item.subtotal,
);
} on NetworkException catch (e) {
// If no internet, use local calculation
if (e is NoInternetException || e is TimeoutException) {
return await _localDataSource.getCartTotal();
}
rethrow;
}
} catch (e) {
throw UnknownException('Failed to get cart total', e);
}
}
@override
Future<int> getCartItemCount() async {
try {
// Try to calculate from API data first
try {
final items = await getCartItems();
final totalQuantity = items.fold<double>(
0.0,
(sum, item) => sum + item.quantity,
);
return totalQuantity.toInt();
} on NetworkException catch (e) {
// If no internet, use local calculation
if (e is NoInternetException || e is TimeoutException) {
return await _localDataSource.getCartItemCount();
}
rethrow;
}
} catch (e) {
throw UnknownException('Failed to get cart item count', e);
}
}
// ============================================================================
// Helper Methods
// ============================================================================
/// Convert CartItemModel to CartItem entity
CartItem _modelToEntity(CartItemModel model) {
return CartItem(
cartItemId: model.cartItemId,
cartId: model.cartId,
productId: model.productId,
quantity: model.quantity,
unitPrice: model.unitPrice,
subtotal: model.subtotal,
addedAt: model.addedAt,
);
}
/// Create CartItemModel from parameters
CartItemModel _createCartItemModel({
required String productId,
required double quantity,
required double unitPrice,
}) {
return CartItemModel(
cartItemId: DateTime.now().millisecondsSinceEpoch.toString(),
cartId: 'user_cart',
productId: productId,
quantity: quantity,
unitPrice: unitPrice,
subtotal: quantity * unitPrice,
addedAt: DateTime.now(),
);
}
}

View File

@@ -0,0 +1,83 @@
/// Domain Repository Interface: Cart Repository
///
/// Defines the contract for cart data operations.
/// Implementation in data layer handles API and local storage.
library;
import 'package:worker/features/cart/domain/entities/cart_item.dart';
/// Cart Repository Interface
///
/// Provides methods for managing shopping cart items:
/// - Add items to cart
/// - Remove items from cart
/// - Get cart items
/// - Update item quantities
/// - Clear cart
///
/// Implementations should handle:
/// - API-first strategy with local fallback
/// - Offline queue for failed requests
/// - Automatic sync when connection restored
abstract class CartRepository {
/// Add items to cart
///
/// [items] - List of cart items to add
/// [itemIds] - Product ERPNext item codes
/// [quantities] - Quantities for each item
/// [prices] - Unit prices for each item
///
/// Returns list of cart items on success.
/// Throws exceptions on failure.
Future<List<CartItem>> addToCart({
required List<String> itemIds,
required List<double> quantities,
required List<double> prices,
});
/// Remove items from cart
///
/// [itemIds] - Product ERPNext item codes to remove
///
/// Returns true if successful, false otherwise.
/// Throws exceptions on failure.
Future<bool> removeFromCart({
required List<String> itemIds,
});
/// Get all cart items
///
/// Returns list of cart items.
/// Throws exceptions on failure.
Future<List<CartItem>> getCartItems();
/// Update item quantity
///
/// [itemId] - Product ERPNext item code
/// [quantity] - New quantity
/// [price] - Unit price
///
/// Returns updated cart item list.
/// Throws exceptions on failure.
Future<List<CartItem>> updateQuantity({
required String itemId,
required double quantity,
required double price,
});
/// Clear all items from cart
///
/// Returns true if successful, false otherwise.
/// Throws exceptions on failure.
Future<bool> clearCart();
/// Get total cart value
///
/// Returns total amount of all items in cart.
Future<double> getCartTotal();
/// Get total cart item count
///
/// Returns total number of items (sum of quantities).
Future<int> getCartItemCount();
}

View File

@@ -1,7 +1,7 @@
/// Cart Page
///
/// Shopping cart screen with items, warehouse selection, discount code,
/// and order summary matching the HTML design.
/// Shopping cart screen with selection and checkout.
/// Features expanded item list with total price at bottom.
library;
import 'package:flutter/material.dart';
@@ -19,12 +19,10 @@ 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
/// - AppBar with back, title (with count), and delete button
/// - Select all section with count display
/// - Expanded cart items list with checkboxes
/// - Total price and checkout button at bottom
class CartPage extends ConsumerStatefulWidget {
const CartPage({super.key});
@@ -33,40 +31,27 @@ class CartPage extends ConsumerStatefulWidget {
}
class _CartPageState extends ConsumerState<CartPage> {
final TextEditingController _discountController = TextEditingController();
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Initialize cart from API on mount
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(cartProvider.notifier).initialize();
});
}
@override
void dispose() {
_discountController.dispose();
// Force sync any pending quantity updates before leaving cart page
ref.read(cartProvider.notifier).forceSyncPendingUpdates();
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',
@@ -74,6 +59,9 @@ class _CartPageState extends ConsumerState<CartPage> {
decimalDigits: 0,
);
final itemCount = cartState.itemCount;
final hasSelection = cartState.selectedCount > 0;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar(
@@ -92,48 +80,301 @@ class _CartPageState extends ConsumerState<CartPage> {
actions: [
if (cartState.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.black),
onPressed: _clearCart,
tooltip: 'Xóa giỏ hàng',
icon: Icon(
Icons.delete_outline,
color: hasSelection ? AppColors.danger : AppColors.grey500,
),
onPressed: hasSelection
? () {
_showDeleteConfirmation(context, ref, cartState);
}
: null,
tooltip: 'Xóa sản phẩm đã chọn',
),
const SizedBox(width: AppSpacing.sm),
],
),
body: cartState.isEmpty
? _buildEmptyCart()
: SingleChildScrollView(
child: Column(
body: cartState.isLoading && cartState.isEmpty
? const Center(child: CircularProgressIndicator())
: cartState.errorMessage != null && cartState.isEmpty
? _buildErrorState(context, cartState.errorMessage!)
: cartState.isEmpty
? _buildEmptyCart(context)
: Column(
children: [
// Error banner if there's an error
if (cartState.errorMessage != null)
_buildErrorBanner(cartState.errorMessage!),
// Select All Section
const SizedBox(height: 8),
_buildSelectAllSection(cartState, ref),
const SizedBox(height: 8),
// Expanded Cart Items List
Expanded(
child: Stack(
children: [
ListView.builder(
itemCount: cartState.items.length,
itemBuilder: (context, index) {
return CartItemWidget(item: cartState.items[index]);
},
),
// Loading overlay
if (cartState.isLoading)
Container(
color: Colors.black.withValues(alpha: 0.1),
child: const Center(
child: CircularProgressIndicator(),
),
),
],
),
),
// Total and Checkout at Bottom
_buildBottomSection(
context,
cartState,
ref,
currencyFormatter,
hasSelection,
),
],
),
);
}
/// Build select all section
Widget _buildSelectAllSection(CartState cartState, WidgetRef ref) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 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: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Checkbox with label
GestureDetector(
onTap: () {
ref.read(cartProvider.notifier).toggleSelectAll();
},
child: Row(
children: [
_CustomCheckbox(
value: cartState.isAllSelected,
onChanged: (value) {
ref.read(cartProvider.notifier).toggleSelectAll();
},
),
const SizedBox(width: 12),
Text(
'Chọn tất cả',
style: AppTypography.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
// Selected count
Text(
'Đã chọn: ${cartState.selectedCount}/${cartState.itemCount}',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.primaryBlue,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
);
}
/// Build bottom section with total price and checkout button
Widget _buildBottomSection(
BuildContext context,
CartState cartState,
WidgetRef ref,
NumberFormat currencyFormatter,
bool hasSelection,
) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
border: const Border(
top: BorderSide(color: Color(0xFFF0F0F0), width: 2),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Total Price Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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),
Text(
'Tổng tạm tính (${cartState.selectedCount} sản phẩm)',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
fontSize: 14,
),
),
Text(
currencyFormatter.format(cartState.selectedTotal),
style: AppTypography.headlineSmall.copyWith(
color: AppColors.primaryBlue,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
],
),
const SizedBox(height: 16),
// Checkout Button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: hasSelection && !_isSyncing
? () async {
// Set syncing state
setState(() {
_isSyncing = true;
});
// Force sync any pending quantity updates before checkout
await ref
.read(cartProvider.notifier)
.forceSyncPendingUpdates();
// Reset syncing state
if (mounted) {
setState(() {
_isSyncing = false;
});
// Navigate to checkout
context.push(RouteNames.checkout);
}
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
disabledBackgroundColor: AppColors.grey100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 0,
),
child: _isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.white),
),
)
: Text(
'Tiến hành đặt hàng',
style: AppTypography.labelLarge.copyWith(
color: AppColors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
],
),
),
),
);
}
/// Build error banner (shown at top when there's an error but cart has items)
Widget _buildErrorBanner(String errorMessage) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: AppColors.danger.withValues(alpha: 0.1),
child: Row(
children: [
const Icon(Icons.error_outline, color: AppColors.danger, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
errorMessage,
style: AppTypography.bodySmall.copyWith(color: AppColors.danger),
),
),
],
),
);
}
/// Build error state (shown when cart fails to load and is empty)
Widget _buildErrorState(BuildContext context, String errorMessage) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: AppColors.danger),
const SizedBox(height: 16),
const Text(
'Không thể tải giỏ hàng',
style: AppTypography.headlineMedium,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
errorMessage,
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
ref.read(cartProvider.notifier).initialize();
},
icon: const Icon(Icons.refresh),
label: const Text('Thử lại'),
),
],
),
);
}
/// Build empty cart state
Widget _buildEmptyCart() {
Widget _buildEmptyCart(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -166,287 +407,76 @@ class _CartPageState extends ConsumerState<CartPage> {
);
}
/// 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(
/// Show delete confirmation dialog
void _showDeleteConfirmation(
BuildContext context,
WidgetRef ref,
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
? () {
context.push(RouteNames.checkout);
}
: null,
child: const Text(
'Tiến hành đặt hàng',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Xóa sản phẩm'),
content: Text(
'Bạn có chắc muốn xóa ${cartState.selectedCount} sản phẩm đã chọn?',
),
actions: [
TextButton(
onPressed: () => context.pop(),
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () {
ref.read(cartProvider.notifier).deleteSelected();
context.pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Đã xóa sản phẩm khỏi giỏ hàng'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
),
);
},
style: ElevatedButton.styleFrom(backgroundColor: AppColors.danger),
child: const Text('Xóa'),
),
],
),
);
}
}
/// Custom Checkbox Widget
///
/// Matches HTML design with 22px size, 6px radius, blue when checked.
class _CustomCheckbox extends StatelessWidget {
const _CustomCheckbox({required this.value, this.onChanged});
final bool value;
final ValueChanged<bool?>? onChanged;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged?.call(!value),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: value ? AppColors.primaryBlue : AppColors.white,
border: Border.all(
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
width: 2,
),
borderRadius: BorderRadius.circular(6),
),
child: value
? const Icon(
Icons.check,
size: 16,
color: AppColors.white,
)
: null,
),
);
}

View File

@@ -53,7 +53,7 @@ class CheckoutPage extends HookConsumerWidget {
final companyEmailController = useTextEditingController();
// Payment method
final paymentMethod = useState<String>('bank_transfer');
final paymentMethod = useState<String>('full_payment');
// Price negotiation
final needsNegotiation = useState<bool>(false);

View File

@@ -1,136 +1,571 @@
/// Cart Provider
///
/// State management for shopping cart using Riverpod.
/// State management for shopping cart using Riverpod with API integration.
library;
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/cart/data/providers/cart_data_providers.dart';
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/providers/products_provider.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
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 3s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@Riverpod(keepAlive: true)
class Cart extends _$Cart {
/// Debounce timer for quantity updates (3 seconds)
Timer? _debounceTimer;
/// Map to track pending quantity updates (productId -> quantity)
final Map<String, double> _pendingQuantityUpdates = {};
@override
CartState build() {
final initialState = CartState.initial();
// Initialize with Diamond tier discount (15%)
// TODO: Get actual tier from user profile
return initialState.copyWith(
// Cancel debounce timer when provider is disposed
ref.onDispose(() {
_debounceTimer?.cancel();
});
// Start with initial state
// Call initialize() from UI to load from API (from HomePage)
return CartState.initial().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,
);
/// Initialize cart by loading from API
///
/// Call this from UI on mount to load cart items from backend.
Future<void> initialize() async {
final repository = await ref.read(cartRepositoryProvider.future);
if (existingItemIndex >= 0) {
// Update quantity if item already exists
updateQuantity(
product.productId,
state.items[existingItemIndex].quantity + quantity,
// Set loading state
state = state.copyWith(isLoading: true, errorMessage: null);
try {
// Load cart items from API (with Hive fallback)
final cartItems = await repository.getCartItems();
// Get member tier from user profile
// TODO: Replace with actual user tier from auth
const memberTier = 'Diamond';
const memberDiscountPercent = 15.0;
// Convert CartItem entities to CartItemData for UI
final items = <CartItemData>[];
final selectedItems = <String, bool>{};
// Fetch product details for each cart item
final productsRepository = await ref.read(productsRepositoryProvider.future);
for (final cartItem in cartItems) {
try {
// Fetch full product entity from products repository
final product = await productsRepository.getProductById(cartItem.productId);
// Calculate conversion for this item
final converted = _calculateConversion(
cartItem.quantity,
product.conversionOfSm,
);
// Create CartItemData with full product info
items.add(
CartItemData(
product: product,
quantity: cartItem.quantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
),
);
// Initialize as not selected by default
selectedItems[product.productId] = false;
} catch (productError) {
// Skip this item if product can't be fetched
// In production, use a proper logging framework
// ignore: avoid_print
print('[CartProvider] Failed to load product ${cartItem.productId}: $productError');
}
}
final newState = CartState(
items: items,
selectedItems: selectedItems,
selectedWarehouse: 'Kho Hà Nội - Nguyễn Trãi',
discountCode: null,
discountCodeApplied: false,
memberTier: memberTier,
memberDiscountPercent: memberDiscountPercent,
subtotal: 0.0,
memberDiscount: 0.0,
shippingFee: 0.0,
total: 0.0,
isLoading: false,
);
} else {
// Add new item
final newItem = CartItemData(product: product, quantity: quantity);
state = state.copyWith(items: [...state.items, newItem]);
_recalculateTotal();
// Recalculate totals
state = _recalculateTotal(newState);
} catch (e) {
// If loading fails, keep current state but show error
state = state.copyWith(
isLoading: false,
errorMessage: 'Failed to load cart: ${e.toString()}',
);
}
}
/// Remove product from cart
void removeFromCart(String productId) {
state = state.copyWith(
items: state.items
.where((item) => item.product.productId != productId)
.toList(),
);
_recalculateTotal();
/// Add product to cart (API + Local)
Future<void> addToCart(Product product, {double quantity = 1.0}) async {
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
// Set loading state
state = currentState.copyWith(isLoading: true, errorMessage: null);
try {
// Check if item exists
final existingItemIndex = currentState.items.indexWhere(
(item) => item.product.productId == product.productId,
);
if (existingItemIndex >= 0) {
// Update quantity if item already exists
final newQuantity = currentState.items[existingItemIndex].quantity + quantity;
await updateQuantity(product.productId, newQuantity);
} else {
// Add new item via API
await repository.addToCart(
itemIds: [product.erpnextItemCode ?? product.productId],
quantities: [quantity],
prices: [product.basePrice],
);
// Calculate conversion
final converted = _calculateConversion(quantity, product.conversionOfSm);
final newItem = CartItemData(
product: product,
quantity: quantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
);
// Add item and mark as NOT selected by default
final updatedItems = [...currentState.items, newItem];
final updatedSelection = Map<String, bool>.from(currentState.selectedItems);
updatedSelection[product.productId] = false;
final newState = currentState.copyWith(
items: updatedItems,
selectedItems: updatedSelection,
isLoading: false,
);
state = _recalculateTotal(newState);
}
} catch (e) {
// Show error but keep current state
state = currentState.copyWith(
isLoading: false,
errorMessage: 'Failed to add item: ${e.toString()}',
);
}
}
/// Update item quantity
void updateQuantity(String productId, double newQuantity) {
/// Calculate conversion based on business rules
///
/// Business Rules:
/// 1. Số viên (boxes/tiles) = Số lượng x Conversion Factor - Round UP
/// Example: 6.43 → 7 viên
/// 2. Số m² (converted) = Số viên / Conversion Factor - Round to 2 decimals
/// 3. Tạm tính = Số m² (converted) x Đơn giá
({double convertedQuantity, int boxes}) _calculateConversion(
double quantity,
double? conversionFactor,
) {
// Use product's conversion factor, default to 1.0 if not set
final factor = conversionFactor ?? 1.0;
// Step 1: Calculate number of tiles/boxes needed (ROUND UP)
final exactBoxes = quantity * factor;
final boxes = exactBoxes.ceil(); // Round up: 6.43 → 7
// Step 2: Calculate converted m² from rounded boxes (2 decimal places)
final convertedM2 = boxes / factor;
final converted = (convertedM2 * 100).roundToDouble() / 100; // Round to 2 decimals
return (convertedQuantity: converted, boxes: boxes);
}
/// Remove product from cart (API + Local)
Future<void> removeFromCart(String productId) async {
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
// Find the item to get its ERPNext item code
final item = currentState.items.firstWhere(
(item) => item.product.productId == productId,
orElse: () => throw Exception('Item not found'),
);
// Set loading state
state = currentState.copyWith(isLoading: true, errorMessage: null);
try {
// Remove from API
await repository.removeFromCart(
itemIds: [item.product.erpnextItemCode ?? productId],
);
// Remove from local state
final updatedSelection = Map<String, bool>.from(currentState.selectedItems)
..remove(productId);
final newState = currentState.copyWith(
items: currentState.items
.where((item) => item.product.productId != productId)
.toList(),
selectedItems: updatedSelection,
isLoading: false,
);
state = _recalculateTotal(newState);
} catch (e) {
state = currentState.copyWith(
isLoading: false,
errorMessage: 'Failed to remove item: ${e.toString()}',
);
}
}
/// Update item quantity immediately (local only, no API call)
///
/// Used for instant UI updates. Actual API sync happens after debounce.
void updateQuantityLocal(String productId, double newQuantity) {
if (newQuantity <= 0) {
// For zero quantity, remove immediately
removeFromCart(productId);
return;
}
final updatedItems = state.items.map((item) {
if (item.product.productId == productId) {
return item.copyWith(quantity: newQuantity);
final currentState = state;
// Find the item
final itemIndex = currentState.items.indexWhere(
(item) => item.product.productId == productId,
);
if (itemIndex == -1) return;
final item = currentState.items[itemIndex];
// Update local state immediately
final converted = _calculateConversion(
newQuantity,
item.product.conversionOfSm,
);
final updatedItems = List<CartItemData>.from(currentState.items);
updatedItems[itemIndex] = item.copyWith(
quantity: newQuantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
);
final newState = currentState.copyWith(items: updatedItems);
state = _recalculateTotal(newState);
// Track pending update for API sync
_pendingQuantityUpdates[productId] = newQuantity;
// Schedule debounced API sync
_scheduleDebouncedSync();
}
/// Schedule debounced sync to API (3 seconds after last change)
void _scheduleDebouncedSync() {
// Cancel existing timer
_debounceTimer?.cancel();
// Start new timer (3 seconds debounce)
_debounceTimer = Timer(const Duration(seconds: 3), () {
_syncPendingQuantityUpdates();
});
}
/// Sync all pending quantity updates to API
Future<void> _syncPendingQuantityUpdates() async {
if (_pendingQuantityUpdates.isEmpty) return;
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
// Create a copy of pending updates
final updates = Map<String, double>.from(_pendingQuantityUpdates);
_pendingQuantityUpdates.clear();
// Sync each update to API
for (final entry in updates.entries) {
final productId = entry.key;
final quantity = entry.value;
// Find the item
final item = currentState.items.firstWhere(
(item) => item.product.productId == productId,
orElse: () => throw Exception('Item not found'),
);
try {
// Update via API (no loading state, happens in background)
await repository.updateQuantity(
itemId: item.product.erpnextItemCode ?? productId,
quantity: quantity,
price: item.product.basePrice,
);
} catch (e) {
// Silent fail - keep local state, user can retry later
// TODO: Add to offline queue for retry
// ignore: avoid_print
print('[Cart] Failed to sync quantity for $productId: $e');
}
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();
}
/// Update item quantity with API sync (immediate, no debounce)
///
/// Use this for direct updates (not from increment/decrement buttons).
/// For increment/decrement, use updateQuantityLocal instead.
Future<void> updateQuantity(String productId, double newQuantity) async {
if (newQuantity <= 0) {
await removeFromCart(productId);
return;
}
/// Recalculate cart totals
void _recalculateTotal() {
// Calculate subtotal
final subtotal = state.items.fold<double>(
0.0,
(sum, item) => sum + (item.product.basePrice * item.quantity),
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
// Find the item
final item = currentState.items.firstWhere(
(item) => item.product.productId == productId,
orElse: () => throw Exception('Item not found'),
);
// Set loading state
state = currentState.copyWith(isLoading: true, errorMessage: null);
try {
// Update via API
await repository.updateQuantity(
itemId: item.product.erpnextItemCode ?? productId,
quantity: newQuantity,
price: item.product.basePrice,
);
// Update local state
final converted = _calculateConversion(
newQuantity,
item.product.conversionOfSm,
);
final updatedItems = currentState.items.map((currentItem) {
if (currentItem.product.productId == productId) {
return currentItem.copyWith(
quantity: newQuantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
);
}
return currentItem;
}).toList();
final newState = currentState.copyWith(
items: updatedItems,
isLoading: false,
);
state = _recalculateTotal(newState);
} catch (e) {
state = currentState.copyWith(
isLoading: false,
errorMessage: 'Failed to update quantity: ${e.toString()}',
);
}
}
/// Increment quantity (with debounce)
///
/// Updates UI immediately, syncs to API after 3s of no changes.
void incrementQuantity(String productId) {
final currentState = state;
final item = currentState.items.firstWhere(
(item) => item.product.productId == productId,
);
updateQuantityLocal(productId, item.quantity + 1);
}
/// Decrement quantity (minimum 1, with debounce)
///
/// Updates UI immediately, syncs to API after 3s of no changes.
void decrementQuantity(String productId) {
final currentState = state;
final item = currentState.items.firstWhere(
(item) => item.product.productId == productId,
);
// Keep minimum quantity at 1, don't go to 0
if (item.quantity > 1) {
updateQuantityLocal(productId, item.quantity - 1);
}
}
/// Force sync all pending quantity updates immediately
///
/// Useful when user navigates away or closes cart.
Future<void> forceSyncPendingUpdates() async {
_debounceTimer?.cancel();
await _syncPendingQuantityUpdates();
}
/// Clear entire cart (API + Local)
Future<void> clearCart() async {
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
state = currentState.copyWith(isLoading: true, errorMessage: null);
try {
await repository.clearCart();
state = CartState.initial().copyWith(
memberTier: currentState.memberTier,
memberDiscountPercent: currentState.memberDiscountPercent,
);
} catch (e) {
state = currentState.copyWith(
isLoading: false,
errorMessage: 'Failed to clear cart: ${e.toString()}',
);
}
}
/// Toggle item selection (Local only)
void toggleSelection(String productId) {
final currentState = state;
final updatedSelection = Map<String, bool>.from(currentState.selectedItems);
updatedSelection[productId] = !(updatedSelection[productId] ?? false);
state = _recalculateTotal(currentState.copyWith(selectedItems: updatedSelection));
}
/// Toggle select all (Local only)
void toggleSelectAll() {
final currentState = state;
final allSelected = currentState.isAllSelected;
final updatedSelection = <String, bool>{};
for (final item in currentState.items) {
updatedSelection[item.product.productId] = !allSelected;
}
state = _recalculateTotal(currentState.copyWith(selectedItems: updatedSelection));
}
/// Delete selected items (API + Local)
Future<void> deleteSelected() async {
final repository = await ref.read(cartRepositoryProvider.future);
final currentState = state;
final selectedIds = currentState.selectedItems.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toSet();
if (selectedIds.isEmpty) return;
state = currentState.copyWith(isLoading: true, errorMessage: null);
try {
// Get ERPNext item codes for selected items
final itemCodesToRemove = currentState.items
.where((item) => selectedIds.contains(item.product.productId))
.map((item) => item.product.erpnextItemCode ?? item.product.productId)
.toList();
// Remove from API
await repository.removeFromCart(itemIds: itemCodesToRemove);
// Remove from local state
final remainingItems = currentState.items
.where((item) => !selectedIds.contains(item.product.productId))
.toList();
final updatedSelection = Map<String, bool>.from(currentState.selectedItems);
for (final id in selectedIds) {
updatedSelection.remove(id);
}
final newState = currentState.copyWith(
items: remainingItems,
selectedItems: updatedSelection,
isLoading: false,
);
state = _recalculateTotal(newState);
} catch (e) {
state = currentState.copyWith(
isLoading: false,
errorMessage: 'Failed to delete items: ${e.toString()}',
);
}
}
/// Select warehouse (Local only)
void selectWarehouse(String warehouse) {
final currentState = state;
state = currentState.copyWith(selectedWarehouse: warehouse);
}
/// Apply discount code (Local only for now)
void applyDiscountCode(String code) {
// TODO: Validate with backend API
final currentState = state;
if (code.isNotEmpty) {
final newState = currentState.copyWith(
discountCode: code,
discountCodeApplied: true,
);
state = _recalculateTotal(newState);
}
}
/// Remove discount code (Local only)
void removeDiscountCode() {
final currentState = state;
final newState = currentState.copyWith(
discountCode: null,
discountCodeApplied: false,
);
state = _recalculateTotal(newState);
}
/// Recalculate cart totals (Local calculation)
CartState _recalculateTotal(CartState currentState) {
// Calculate subtotal using CONVERTED quantities for selected items only
double subtotal = 0.0;
for (final item in currentState.items) {
if (currentState.selectedItems[item.product.productId] == true) {
subtotal += item.lineTotal; // Uses quantityConverted
}
}
// Calculate member tier discount
final memberDiscount = subtotal * (state.memberDiscountPercent / 100);
final memberDiscount = subtotal * (currentState.memberDiscountPercent / 100);
// Calculate shipping (free for now)
const shippingFee = 0.0;
@@ -138,7 +573,7 @@ class Cart extends _$Cart {
// Calculate total
final total = subtotal - memberDiscount + shippingFee;
state = state.copyWith(
return currentState.copyWith(
subtotal: subtotal,
memberDiscount: memberDiscount,
shippingFee: shippingFee,
@@ -153,13 +588,17 @@ class Cart extends _$Cart {
}
/// Cart item count provider
@riverpod
/// keepAlive: true to persist with cart provider
@Riverpod(keepAlive: true)
int cartItemCount(Ref ref) {
return ref.watch(cartProvider).items.length;
final cartState = ref.watch(cartProvider);
return cartState.items.length;
}
/// Cart total provider
@riverpod
/// keepAlive: true to persist with cart provider
@Riverpod(keepAlive: true)
double cartTotal(Ref ref) {
return ref.watch(cartProvider).total;
final cartState = ref.watch(cartProvider);
return cartState.total;
}

View File

@@ -10,40 +10,40 @@ part of 'cart_provider.dart';
// 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
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
@ProviderFor(Cart)
const cartProvider = CartProvider._();
/// Cart Notifier
///
/// Manages cart state including:
/// - Adding/removing items
/// - Updating quantities
/// - Warehouse selection
/// - Discount code application
/// - Cart summary calculations
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
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
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
const CartProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartProvider',
isAutoDispose: true,
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@@ -64,16 +64,16 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
}
}
String _$cartHash() => r'fa4c957f9cd7e54000e035b0934ad2bd08ba2786';
String _$cartHash() => r'3bb1372a0e87268e35c7c8d424d2d8315b4d09b2';
/// Cart Notifier
///
/// Manages cart state including:
/// - Adding/removing items
/// - Updating quantities
/// - Warehouse selection
/// - Discount code application
/// - Cart summary calculations
/// Manages cart state with API integration:
/// - Adding/removing items (syncs with API)
/// - Updating quantities (syncs with API with 5s debounce)
/// - Loading cart from API via initialize()
/// - Local-only operations: selection, warehouse, calculations
/// - keepAlive: true to maintain cart state across navigation
abstract class _$Cart extends $Notifier<CartState> {
CartState build();
@@ -95,22 +95,25 @@ abstract class _$Cart extends $Notifier<CartState> {
}
/// Cart item count provider
/// keepAlive: true to persist with cart provider
@ProviderFor(cartItemCount)
const cartItemCountProvider = CartItemCountProvider._();
/// Cart item count provider
/// keepAlive: true to persist with cart provider
final class CartItemCountProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Cart item count provider
/// keepAlive: true to persist with cart provider
const CartItemCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartItemCountProvider',
isAutoDispose: true,
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@@ -137,26 +140,29 @@ final class CartItemCountProvider extends $FunctionalProvider<int, int, int>
}
}
String _$cartItemCountHash() => r'4ddc2979030a4470b2fa1de4832a84313e98e259';
String _$cartItemCountHash() => r'35385f5445be6bf66faf58cbbb450cf6196ee4a8';
/// Cart total provider
/// keepAlive: true to persist with cart provider
@ProviderFor(cartTotal)
const cartTotalProvider = CartTotalProvider._();
/// Cart total provider
/// keepAlive: true to persist with cart provider
final class CartTotalProvider
extends $FunctionalProvider<double, double, double>
with $Provider<double> {
/// Cart total provider
/// keepAlive: true to persist with cart provider
const CartTotalProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartTotalProvider',
isAutoDispose: true,
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@@ -183,4 +189,4 @@ final class CartTotalProvider
}
}
String _$cartTotalHash() => r'48460600487e734788e6d6cf1e4f7e13d21f21a4';
String _$cartTotalHash() => r'027326bae4554031852eaa1348cbc900089f6ec1';

View File

@@ -11,16 +11,30 @@ import 'package:worker/features/products/domain/entities/product.dart';
class CartItemData {
final Product product;
final double quantity;
final double quantityConverted; // Rounded-up quantity for actual billing
final int boxes; // Number of tiles/boxes needed
const CartItemData({required this.product, required this.quantity});
const CartItemData({
required this.product,
required this.quantity,
required this.quantityConverted,
required this.boxes,
});
/// Calculate line total
double get lineTotal => product.basePrice * quantity;
/// Calculate line total using CONVERTED quantity (important for accurate billing)
double get lineTotal => product.basePrice * quantityConverted;
CartItemData copyWith({Product? product, double? quantity}) {
CartItemData copyWith({
Product? product,
double? quantity,
double? quantityConverted,
int? boxes,
}) {
return CartItemData(
product: product ?? this.product,
quantity: quantity ?? this.quantity,
quantityConverted: quantityConverted ?? this.quantityConverted,
boxes: boxes ?? this.boxes,
);
}
}
@@ -30,6 +44,7 @@ class CartItemData {
/// Represents the complete state of the shopping cart.
class CartState {
final List<CartItemData> items;
final Map<String, bool> selectedItems; // productId -> isSelected
final String selectedWarehouse;
final String? discountCode;
final bool discountCodeApplied;
@@ -39,9 +54,12 @@ class CartState {
final double memberDiscount;
final double shippingFee;
final double total;
final bool isLoading;
final String? errorMessage;
const CartState({
required this.items,
required this.selectedItems,
required this.selectedWarehouse,
this.discountCode,
required this.discountCodeApplied,
@@ -51,11 +69,14 @@ class CartState {
required this.memberDiscount,
required this.shippingFee,
required this.total,
this.isLoading = false,
this.errorMessage,
});
factory CartState.initial() {
return const CartState(
items: [],
selectedItems: {},
selectedWarehouse: 'Kho Hà Nội - Nguyễn Trãi',
discountCode: null,
discountCodeApplied: false,
@@ -77,8 +98,30 @@ class CartState {
return items.fold<double>(0.0, (sum, item) => sum + item.quantity);
}
/// Get number of selected items
int get selectedCount {
return selectedItems.values.where((selected) => selected).length;
}
/// Check if all items are selected
bool get isAllSelected {
return items.isNotEmpty && selectedCount == items.length;
}
/// Get total of selected items only
double get selectedTotal {
double total = 0.0;
for (final item in items) {
if (selectedItems[item.product.productId] == true) {
total += item.lineTotal;
}
}
return total;
}
CartState copyWith({
List<CartItemData>? items,
Map<String, bool>? selectedItems,
String? selectedWarehouse,
String? discountCode,
bool? discountCodeApplied,
@@ -88,9 +131,12 @@ class CartState {
double? memberDiscount,
double? shippingFee,
double? total,
bool? isLoading,
String? errorMessage,
}) {
return CartState(
items: items ?? this.items,
selectedItems: selectedItems ?? this.selectedItems,
selectedWarehouse: selectedWarehouse ?? this.selectedWarehouse,
discountCode: discountCode ?? this.discountCode,
discountCodeApplied: discountCodeApplied ?? this.discountCodeApplied,
@@ -101,6 +147,8 @@ class CartState {
memberDiscount: memberDiscount ?? this.memberDiscount,
shippingFee: shippingFee ?? this.shippingFee,
total: total ?? this.total,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
}

View File

@@ -1,6 +1,6 @@
/// Cart Item Widget
///
/// Displays a single item in the cart with image, details, and quantity controls.
/// Displays a single item in the cart with checkbox, image, details, and quantity controls.
library;
import 'package:cached_network_image/cached_network_image.dart';
@@ -15,17 +15,68 @@ 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 {
/// - Checkbox for selection (left side, aligned to top)
/// - Product image (100x100, rounded)
/// - Product name and price
/// - Quantity controls (-, text field for input, +, unit label)
/// - Converted quantity display: "(Quy đổi: X.XX m² = Y viên)"
class CartItemWidget extends ConsumerStatefulWidget {
final CartItemData item;
const CartItemWidget({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<CartItemWidget> createState() => _CartItemWidgetState();
}
class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
late TextEditingController _quantityController;
late FocusNode _quantityFocusNode;
@override
void initState() {
super.initState();
_quantityController = TextEditingController(
text: widget.item.quantity.toStringAsFixed(0),
);
_quantityFocusNode = FocusNode();
}
@override
void didUpdateWidget(CartItemWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Update text field when quantity changes from outside (increment/decrement buttons)
if (widget.item.quantity != oldWidget.item.quantity) {
_quantityController.text = widget.item.quantity.toStringAsFixed(0);
}
}
@override
void dispose() {
_quantityController.dispose();
_quantityFocusNode.dispose();
super.dispose();
}
void _handleQuantitySubmit(String value) {
final newQuantity = double.tryParse(value);
if (newQuantity != null && newQuantity >= 1) {
ref
.read(cartProvider.notifier)
.updateQuantity(widget.item.product.productId, newQuantity);
} else {
// Invalid input, reset to current quantity
_quantityController.text = widget.item.quantity.toStringAsFixed(0);
}
_quantityFocusNode.unfocus();
}
@override
Widget build(BuildContext context) {
final cartState = ref.watch(cartProvider);
final isSelected =
cartState.selectedItems[widget.item.product.productId] ?? false;
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
@@ -33,8 +84,8 @@ class CartItemWidget extends ConsumerWidget {
);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(12),
@@ -49,29 +100,45 @@ class CartItemWidget extends ConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Image
// Checkbox (aligned to top, ~50px from top to match HTML)
Padding(
padding: const EdgeInsets.only(top: 34),
child: _CustomCheckbox(
value: isSelected,
onChanged: (value) {
ref
.read(cartProvider.notifier)
.toggleSelection(widget.item.product.productId);
},
),
),
const SizedBox(width: 12),
// Product Image (bigger: 100x100)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.product.imageUrl,
width: 80,
height: 80,
imageUrl: widget.item.product.thumbnail,
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 80,
height: 80,
width: 100,
height: 100,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 80,
width: 100,
height: 100,
color: AppColors.grey100,
child: const Icon(
Icons.image_not_supported,
color: AppColors.grey500,
size: 32,
),
),
),
@@ -86,9 +153,10 @@ class CartItemWidget extends ConsumerWidget {
children: [
// Product Name
Text(
item.product.name,
widget.item.product.name,
style: AppTypography.titleMedium.copyWith(
fontWeight: FontWeight.w600,
fontSize: 15,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -96,27 +164,18 @@ class CartItemWidget extends ConsumerWidget {
const SizedBox(height: 4),
// SKU
// Price
Text(
'Mã: ${item.product.erpnextItemCode ?? item.product.productId}',
style: AppTypography.bodySmall.copyWith(
color: AppColors.grey500,
'${currencyFormatter.format(widget.item.product.basePrice)}/${widget.item.product.unit ?? ''}',
style: AppTypography.titleMedium.copyWith(
color: AppColors.primaryBlue,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
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: [
@@ -126,21 +185,57 @@ class CartItemWidget extends ConsumerWidget {
onPressed: () {
ref
.read(cartProvider.notifier)
.decrementQuantity(item.product.productId);
.decrementQuantity(widget.item.product.productId);
},
),
const SizedBox(width: 12),
const SizedBox(width: 8),
// Quantity value
Text(
item.quantity.toStringAsFixed(0),
style: AppTypography.titleMedium.copyWith(
fontWeight: FontWeight.w600,
// Quantity TextField
SizedBox(
width: 50,
height: 32,
child: TextField(
controller: _quantityController,
focusNode: _quantityFocusNode,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
style: AppTypography.titleMedium.copyWith(
fontWeight: FontWeight.w600,
fontSize: 16,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
),
onSubmitted: _handleQuantitySubmit,
onEditingComplete: () {
_handleQuantitySubmit(_quantityController.text);
},
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
// Increase button
_QuantityButton(
@@ -148,7 +243,7 @@ class CartItemWidget extends ConsumerWidget {
onPressed: () {
ref
.read(cartProvider.notifier)
.incrementQuantity(item.product.productId);
.incrementQuantity(widget.item.product.productId);
},
),
@@ -156,13 +251,39 @@ class CartItemWidget extends ConsumerWidget {
// Unit label
Text(
item.product.unit ?? '',
widget.item.product.unit ?? '',
style: AppTypography.bodySmall.copyWith(
color: AppColors.grey500,
),
),
],
),
const SizedBox(height: 4),
// Converted Quantity Display
RichText(
text: TextSpan(
style: AppTypography.bodySmall.copyWith(
color: AppColors.grey500,
fontSize: 13,
),
children: [
const TextSpan(text: '(Quy đổi: '),
TextSpan(
text:
'${widget.item.quantityConverted.toStringAsFixed(2)} ${widget.item.product.unit ?? ''}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: ' = '),
TextSpan(
text: '${widget.item.boxes} viên',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: ')'),
],
),
),
],
),
),
@@ -172,9 +293,45 @@ class CartItemWidget extends ConsumerWidget {
}
}
/// Custom Checkbox Widget
///
/// Matches HTML design with 20px size, 6px radius, blue when checked.
class _CustomCheckbox extends StatelessWidget {
final bool value;
final ValueChanged<bool?>? onChanged;
const _CustomCheckbox({required this.value, this.onChanged});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged?.call(!value),
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: value ? AppColors.primaryBlue : AppColors.white,
border: Border.all(
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
width: 2,
),
borderRadius: BorderRadius.circular(6),
),
child: value
? const Icon(
Icons.check,
size: 14,
color: AppColors.white,
)
: null,
),
);
}
}
/// Quantity Button
///
/// Small circular button for incrementing/decrementing quantity.
/// Small button for incrementing/decrementing quantity.
class _QuantityButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
@@ -185,15 +342,16 @@ class _QuantityButton extends StatelessWidget {
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(6),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE0E0E0), width: 2),
borderRadius: BorderRadius.circular(6),
color: AppColors.white,
),
child: Icon(icon, size: 18, color: AppColors.grey900),
child: Icon(icon, size: 16, color: AppColors.grey900),
),
);
}

View File

@@ -1,6 +1,7 @@
/// Order Summary Section Widget
///
/// Displays cart items and price breakdown.
/// Displays cart items with conversion details and price breakdown.
/// Matches checkout.html design with product name on line 1, conversion on line 2.
library;
import 'package:flutter/material.dart';
@@ -9,7 +10,7 @@ import 'package:worker/core/theme/colors.dart';
/// Order Summary Section
///
/// Shows order items, subtotal, discount, shipping, and total.
/// Shows order items with conversion details, subtotal, discount, shipping, and total.
class OrderSummarySection extends StatelessWidget {
final List<Map<String, dynamic>> cartItems;
final double subtotal;
@@ -57,8 +58,8 @@ class OrderSummarySection extends StatelessWidget {
const SizedBox(height: AppSpacing.md),
// Cart Items
...cartItems.map((item) => _buildCartItem(item)),
// Cart Items with conversion details
...cartItems.map((item) => _buildCartItemWithConversion(item)),
const Divider(height: 32),
@@ -66,12 +67,12 @@ class OrderSummarySection extends StatelessWidget {
_buildSummaryRow('Tạm tính', subtotal),
const SizedBox(height: 8),
// Discount
_buildSummaryRow('Giảm giá (5%)', -discount, isDiscount: true),
// Member Tier Discount (Diamond 15%)
_buildSummaryRow('Giảm giá Diamond', -discount, isDiscount: true),
const SizedBox(height: 8),
// Shipping
_buildSummaryRow('Phí vận chuyển', shipping),
_buildSummaryRow('Phí vận chuyển', shipping, isFree: shipping == 0),
const Divider(height: 24),
@@ -80,9 +81,9 @@ class OrderSummarySection extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Tổng cộng',
'Tổng thanh toán',
style: TextStyle(
fontSize: 18,
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
@@ -90,7 +91,7 @@ class OrderSummarySection extends StatelessWidget {
Text(
_formatCurrency(total),
style: const TextStyle(
fontSize: 20,
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
),
@@ -102,39 +103,26 @@ class OrderSummarySection extends StatelessWidget {
);
}
/// Build cart item row
Widget _buildCartItem(Map<String, dynamic> item) {
/// Build cart item with conversion details on two lines
Widget _buildCartItemWithConversion(Map<String, dynamic> item) {
// Mock conversion data (in real app, this comes from CartItemData)
final quantity = item['quantity'] as int;
final quantityM2 = quantity.toDouble(); // User input
final quantityConverted = (quantityM2 * 1.008 * 100).ceil() / 100; // Rounded up
final boxes = (quantityM2 * 2.8).ceil(); // Tiles count
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Image
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: AppColors.grey100,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
item['image'] as String,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.image, color: AppColors.grey500);
},
),
),
),
const SizedBox(width: 12),
// Product Info
// Product info (left side)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Line 1: Product name
Text(
item['name'] as String,
style: const TextStyle(
@@ -142,14 +130,15 @@ class OrderSummarySection extends StatelessWidget {
fontWeight: FontWeight.w500,
color: Color(0xFF212121),
),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Line 2: Conversion details (muted text)
Text(
'Mã: ${item['sku']}',
'$quantityM2 m² ($boxes viên / ${quantityConverted.toStringAsFixed(2)} m²)',
style: const TextStyle(
fontSize: 12,
fontSize: 13,
color: AppColors.grey500,
),
),
@@ -159,27 +148,16 @@ class OrderSummarySection extends StatelessWidget {
const SizedBox(width: 12),
// Quantity and Price
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'SL: ${item['quantity']}',
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
),
const SizedBox(height: 4),
Text(
_formatCurrency(
((item['price'] as int) * (item['quantity'] as int))
.toDouble(),
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
),
),
],
// Price (right side)
Text(
_formatCurrency(
((item['price'] as int) * quantityConverted).toDouble(),
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
),
),
],
),
@@ -191,6 +169,7 @@ class OrderSummarySection extends StatelessWidget {
String label,
double amount, {
bool isDiscount = false,
bool isFree = false,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -200,11 +179,11 @@ class OrderSummarySection extends StatelessWidget {
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
),
Text(
_formatCurrency(amount),
isFree ? 'Miễn phí' : _formatCurrency(amount),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDiscount ? AppColors.danger : const Color(0xFF212121),
color: isDiscount ? AppColors.success : const Color(0xFF212121),
),
),
],
@@ -213,6 +192,6 @@ class OrderSummarySection extends StatelessWidget {
/// Format currency
String _formatCurrency(double amount) {
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
return '${amount.abs().toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}đ';
}
}

View File

@@ -1,6 +1,8 @@
/// Payment Method Section Widget
///
/// Payment method selection (Bank Transfer or COD).
/// Payment method selection with two options:
/// 1. Full payment via bank transfer
/// 2. Partial payment (>=20%, 30 day terms)
library;
import 'package:flutter/material.dart';
@@ -10,7 +12,7 @@ import 'package:worker/core/theme/colors.dart';
/// Payment Method Section
///
/// Allows user to select payment method between bank transfer and COD.
/// Two payment options matching checkout.html design.
class PaymentMethodSection extends HookWidget {
final ValueNotifier<String> paymentMethod;
@@ -47,27 +49,34 @@ class PaymentMethodSection extends HookWidget {
const SizedBox(height: AppSpacing.md),
// Bank Transfer Option
// Full Payment Option
InkWell(
onTap: () => paymentMethod.value = 'bank_transfer',
onTap: () => paymentMethod.value = 'full_payment',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Radio<String>(
value: 'bank_transfer',
value: 'full_payment',
groupValue: paymentMethod.value,
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
),
const SizedBox(width: 12),
const Icon(
Icons.account_balance_outlined,
color: AppColors.grey500,
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Chuyển khoản ngân hàng',
'Thanh toán hoàn toàn',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
@@ -75,7 +84,7 @@ class PaymentMethodSection extends HookWidget {
),
SizedBox(height: 4),
Text(
'Thanh toán qua chuyển khoản',
'Thanh toán qua tài khoản ngân hàng',
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
@@ -91,27 +100,34 @@ class PaymentMethodSection extends HookWidget {
const Divider(height: 1),
// COD Option
// Partial Payment Option
InkWell(
onTap: () => paymentMethod.value = 'cod',
onTap: () => paymentMethod.value = 'partial_payment',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Radio<String>(
value: 'cod',
value: 'partial_payment',
groupValue: paymentMethod.value,
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
),
const SizedBox(width: 12),
const Icon(
Icons.payments_outlined,
color: AppColors.grey500,
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Thanh toán khi nhận hàng (COD)',
'Thanh toán một phần',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
@@ -119,7 +135,7 @@ class PaymentMethodSection extends HookWidget {
),
SizedBox(height: 4),
Text(
'Thanh toán bằng tiền mặt khi nhận hàng',
'Trả trước(≥20%), còn lại thanh toán trong vòng 30 ngày',
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,

View File

@@ -26,11 +26,27 @@ import 'package:worker/generated/l10n/app_localizations.dart';
/// - Quick action sections
/// - Bottom navigation
/// - Floating action button (Chat)
class HomePage extends ConsumerWidget {
///
/// Initializes cart on mount to load items from API.
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
// Initialize cart from API on app startup
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(cartProvider.notifier).initialize();
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// Watch member card state

View File

@@ -22,13 +22,14 @@ class ProductModel extends HiveObject {
this.description,
required this.basePrice,
this.images,
this.thumbnail,
required this.thumbnail,
this.imageCaptions,
this.customLink360,
this.specifications,
this.category,
this.brand,
this.unit,
this.conversionOfSm,
required this.isActive,
required this.isFeatured,
this.erpnextItemCode,
@@ -58,7 +59,7 @@ class ProductModel extends HiveObject {
/// Thumbnail image URL
@HiveField(5)
final String? thumbnail;
final String thumbnail;
/// Image captions (JSON encoded map of image_url -> caption)
@HiveField(6)
@@ -85,6 +86,11 @@ class ProductModel extends HiveObject {
@HiveField(11)
final String? unit;
/// Conversion factor for Square Meter UOM (tiles per m²)
/// Used to calculate: Số viên = Số lượng × conversionOfSm
@HiveField(17)
final double? conversionOfSm;
/// Whether product is active
@HiveField(12)
final bool isActive;
@@ -117,7 +123,7 @@ class ProductModel extends HiveObject {
description: json['description'] as String?,
basePrice: (json['base_price'] as num).toDouble(),
images: json['images'] != null ? jsonEncode(json['images']) : null,
thumbnail: json['thumbnail'] as String?,
thumbnail: json['thumbnail'] as String,
imageCaptions: json['image_captions'] != null
? jsonEncode(json['image_captions'])
: null,
@@ -128,6 +134,9 @@ class ProductModel extends HiveObject {
category: json['category'] as String?,
brand: json['brand'] as String?,
unit: json['unit'] as String?,
conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble()
: null,
isActive: json['is_active'] as bool? ?? true,
isFeatured: json['is_featured'] as bool? ?? false,
erpnextItemCode: json['erpnext_item_code'] as String?,
@@ -227,7 +236,7 @@ class ProductModel extends HiveObject {
description: json['description'] as String?,
basePrice: price,
images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null,
thumbnail: thumbnailUrl,
thumbnail: thumbnailUrl ?? '',
imageCaptions: imageCaptionsMap.isNotEmpty
? jsonEncode(imageCaptionsMap)
: null,
@@ -239,6 +248,9 @@ class ProductModel extends HiveObject {
json['item_group'] as String?, // Try item_group_name first, fallback to item_group
brand: json['brand'] as String?,
unit: json['stock_uom'] as String? ?? '',
conversionOfSm: json['conversion_of_sm'] != null
? (json['conversion_of_sm'] as num).toDouble()
: null,
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
isFeatured: false, // Not provided by API, default to false
erpnextItemCode: json['name'] as String, // Store item code for reference
@@ -270,6 +282,7 @@ class ProductModel extends HiveObject {
'category': category,
'brand': brand,
'unit': unit,
'conversion_of_sm': conversionOfSm,
'is_active': isActive,
'is_featured': isFeatured,
'erpnext_item_code': erpnextItemCode,
@@ -349,6 +362,7 @@ class ProductModel extends HiveObject {
String? category,
String? brand,
String? unit,
double? conversionOfSm,
bool? isActive,
bool? isFeatured,
String? erpnextItemCode,
@@ -368,6 +382,7 @@ class ProductModel extends HiveObject {
category: category ?? this.category,
brand: brand ?? this.brand,
unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,
@@ -410,6 +425,7 @@ class ProductModel extends HiveObject {
category: category,
brand: brand,
unit: unit,
conversionOfSm: conversionOfSm,
isActive: isActive,
isFeatured: isFeatured,
erpnextItemCode: erpnextItemCode,

View File

@@ -22,13 +22,14 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
description: fields[2] as String?,
basePrice: (fields[3] as num).toDouble(),
images: fields[4] as String?,
thumbnail: fields[5] as String?,
thumbnail: fields[5] as String,
imageCaptions: fields[6] as String?,
customLink360: fields[7] as String?,
specifications: fields[8] as String?,
category: fields[9] as String?,
brand: fields[10] as String?,
unit: fields[11] as String?,
conversionOfSm: (fields[17] as num?)?.toDouble(),
isActive: fields[12] as bool,
isFeatured: fields[13] as bool,
erpnextItemCode: fields[14] as String?,
@@ -40,7 +41,7 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
@override
void write(BinaryWriter writer, ProductModel obj) {
writer
..writeByte(17)
..writeByte(18)
..writeByte(0)
..write(obj.productId)
..writeByte(1)
@@ -74,7 +75,9 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
..writeByte(15)
..write(obj.createdAt)
..writeByte(16)
..write(obj.updatedAt);
..write(obj.updatedAt)
..writeByte(17)
..write(obj.conversionOfSm);
}
@override

View File

@@ -9,6 +9,27 @@ library;
/// Represents a tile/construction product in the application.
/// Used across all layers but originates in the domain layer.
class Product {
const Product({
required this.productId,
required this.name,
this.description,
required this.basePrice,
required this.images,
required this.thumbnail,
required this.imageCaptions,
this.customLink360,
required this.specifications,
this.category,
this.brand,
this.unit,
this.conversionOfSm,
required this.isActive,
required this.isFeatured,
this.erpnextItemCode,
required this.createdAt,
required this.updatedAt,
});
/// Unique identifier
final String productId;
@@ -25,7 +46,7 @@ class Product {
final List<String> images;
/// Thumbnail image URL
final String? thumbnail;
final String thumbnail;
/// Image captions
final Map<String, String> imageCaptions;
@@ -45,6 +66,10 @@ class Product {
/// Unit of measurement (e.g., "m²", "viên", "hộp")
final String? unit;
/// Conversion factor for Square Meter UOM (tiles per m²)
/// Used to calculate: Số viên = Số lượng × conversionOfSm
final double? conversionOfSm;
/// Product is active
final bool isActive;
@@ -60,26 +85,6 @@ class Product {
/// Last updated date
final DateTime updatedAt;
const Product({
required this.productId,
required this.name,
this.description,
required this.basePrice,
required this.images,
this.thumbnail,
required this.imageCaptions,
this.customLink360,
required this.specifications,
this.category,
this.brand,
this.unit,
required this.isActive,
required this.isFeatured,
this.erpnextItemCode,
required this.createdAt,
required this.updatedAt,
});
/// Get primary image URL
String? get primaryImage => images.isNotEmpty ? images.first : null;
@@ -134,6 +139,7 @@ class Product {
String? category,
String? brand,
String? unit,
double? conversionOfSm,
bool? isActive,
bool? isFeatured,
String? erpnextItemCode,
@@ -153,6 +159,7 @@ class Product {
category: category ?? this.category,
brand: brand ?? this.brand,
unit: unit ?? this.unit,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
isActive: isActive ?? this.isActive,
isFeatured: isFeatured ?? this.isFeatured,
erpnextItemCode: erpnextItemCode ?? this.erpnextItemCode,

View File

@@ -238,7 +238,7 @@ class ProductCard extends ConsumerWidget {
width: double.infinity,
height: 36.0,
child: ElevatedButton.icon(
onPressed: product.inStock ? onAddToCart : null,
onPressed: !product.inStock ? onAddToCart : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
@@ -256,7 +256,7 @@ class ProductCard extends ConsumerWidget {
),
icon: const Icon(Icons.shopping_cart, size: 16.0),
label: Text(
product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock,
!product.inStock ? 'Thêm vào giỏ' : l10n.outOfStock,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.w600,