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,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(),
);
}
}