/// Cart Provider /// /// 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/presentation/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 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 _pendingQuantityUpdates = {}; @override CartState build() { // 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, ); } /// Initialize cart by loading from API /// /// Call this from UI on mount to load cart items from backend. Future initialize() async { final repository = await ref.read(cartRepositoryProvider.future); // 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 = []; final selectedItems = {}; // 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, ); // 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()}', ); } } /// Add product to cart (API + Local) Future 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.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()}', ); } } /// 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 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.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 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.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 _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.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'); } } } /// 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 updateQuantity(String productId, double newQuantity) async { if (newQuantity <= 0) { await removeFromCart(productId); return; } 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 forceSyncPendingUpdates() async { _debounceTimer?.cancel(); await _syncPendingQuantityUpdates(); } /// Clear entire cart (API + Local) Future 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.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 = {}; for (final item in currentState.items) { updatedSelection[item.product.productId] = !allSelected; } state = _recalculateTotal(currentState.copyWith(selectedItems: updatedSelection)); } /// Delete selected items (API + Local) Future 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.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 * (currentState.memberDiscountPercent / 100); // Calculate shipping (free for now) const shippingFee = 0.0; // Calculate total final total = subtotal - memberDiscount + shippingFee; return currentState.copyWith( subtotal: subtotal, memberDiscount: memberDiscount, shippingFee: shippingFee, total: total, ); } /// Get total quantity of all items double get totalQuantity { return state.items.fold(0.0, (sum, item) => sum + item.quantity); } } /// Cart item count provider /// keepAlive: true to persist with cart provider @Riverpod(keepAlive: true) int cartItemCount(Ref ref) { final cartState = ref.watch(cartProvider); return cartState.items.length; } /// Cart total provider /// keepAlive: true to persist with cart provider @Riverpod(keepAlive: true) double cartTotal(Ref ref) { final cartState = ref.watch(cartProvider); return cartState.total; }