update cart
This commit is contained in:
434
CART_DEBOUNCE.md
Normal file
434
CART_DEBOUNCE.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Cart Quantity Update Debounce Implementation
|
||||
|
||||
## Overview
|
||||
Implemented a 3-second debounce for cart quantity updates to prevent excessive API calls. UI updates happen instantly, but API sync is delayed until the user stops changing quantities.
|
||||
|
||||
## Problem Solved
|
||||
**Before**: Every increment/decrement button press triggered an immediate API call
|
||||
- Multiple rapid clicks = multiple API calls
|
||||
- Poor performance and UX
|
||||
- Unnecessary server load
|
||||
- Potential rate limiting issues
|
||||
|
||||
**After**: UI updates instantly, API syncs after 3 seconds of inactivity
|
||||
- User can rapidly change quantities
|
||||
- Only one API call after user stops
|
||||
- Smooth, responsive UI
|
||||
- Reduced server load
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Debounce Timer in Cart Provider
|
||||
**File**: `lib/features/cart/presentation/providers/cart_provider.dart`
|
||||
|
||||
```dart
|
||||
@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() {
|
||||
// Cancel debounce timer when provider is disposed
|
||||
ref.onDispose(() {
|
||||
_debounceTimer?.cancel();
|
||||
});
|
||||
|
||||
return CartState.initial().copyWith(
|
||||
memberTier: 'Diamond',
|
||||
memberDiscountPercent: 15.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Local Update Method (Instant UI Update)
|
||||
|
||||
```dart
|
||||
/// 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) {
|
||||
removeFromCart(productId);
|
||||
return;
|
||||
}
|
||||
|
||||
final currentState = state;
|
||||
final itemIndex = currentState.items.indexWhere(
|
||||
(item) => item.product.productId == productId,
|
||||
);
|
||||
|
||||
if (itemIndex == -1) return;
|
||||
final item = currentState.items[itemIndex];
|
||||
|
||||
// Update local state immediately (instant UI update)
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Debounce Scheduling
|
||||
|
||||
```dart
|
||||
/// Schedule debounced sync to API (3 seconds after last change)
|
||||
void _scheduleDebouncedSync() {
|
||||
// Cancel existing timer (restarts the 3s countdown)
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
// Start new timer (3 seconds debounce)
|
||||
_debounceTimer = Timer(const Duration(seconds: 3), () {
|
||||
_syncPendingQuantityUpdates();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Background API Sync
|
||||
|
||||
```dart
|
||||
/// 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 (background, no loading state)
|
||||
for (final entry in updates.entries) {
|
||||
final productId = entry.key;
|
||||
final quantity = entry.value;
|
||||
|
||||
final item = currentState.items.firstWhere(
|
||||
(item) => item.product.productId == productId,
|
||||
orElse: () => throw Exception('Item not found'),
|
||||
);
|
||||
|
||||
try {
|
||||
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
|
||||
print('[Cart] Failed to sync quantity for $productId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Updated Increment/Decrement Methods
|
||||
|
||||
```dart
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Force Sync on Navigation & Checkout
|
||||
|
||||
**File**: `lib/features/cart/presentation/pages/cart_page.dart`
|
||||
|
||||
#### A. Force Sync on Page Disposal
|
||||
```dart
|
||||
class _CartPageState extends ConsumerState<CartPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
// Force sync any pending quantity updates before leaving cart page
|
||||
ref.read(cartProvider.notifier).forceSyncPendingUpdates();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Force Sync on Checkout Button (Skip Debounce) ⚡
|
||||
```dart
|
||||
class _CartPageState extends ConsumerState<CartPage> {
|
||||
bool _isSyncing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: hasSelection && !_isSyncing
|
||||
? () async {
|
||||
// Set syncing state (show loading)
|
||||
setState(() {
|
||||
_isSyncing = true;
|
||||
});
|
||||
|
||||
// Force sync immediately - NO WAITING for debounce!
|
||||
await ref
|
||||
.read(cartProvider.notifier)
|
||||
.forceSyncPendingUpdates();
|
||||
|
||||
// Reset syncing state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSyncing = false;
|
||||
});
|
||||
|
||||
// Navigate to checkout with synced data
|
||||
context.push(RouteNames.checkout);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: _isSyncing
|
||||
? CircularProgressIndicator() // Show loading while syncing
|
||||
: Text('Tiến hành đặt hàng'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Provider Method**:
|
||||
```dart
|
||||
/// Force sync all pending quantity updates immediately
|
||||
///
|
||||
/// Useful when:
|
||||
/// - User taps checkout button (skip 3s debounce)
|
||||
/// - User navigates away or closes cart
|
||||
/// - Need to ensure data is synced before critical operations
|
||||
Future<void> forceSyncPendingUpdates() async {
|
||||
_debounceTimer?.cancel();
|
||||
await _syncPendingQuantityUpdates();
|
||||
}
|
||||
```
|
||||
|
||||
## User Flow
|
||||
|
||||
### Scenario 1: Rapid Clicks (Debounced)
|
||||
```
|
||||
User clicks +5 times rapidly (within 3 seconds)
|
||||
↓
|
||||
Each click: UI updates instantly (1→2→3→4→5)
|
||||
↓
|
||||
Timer restarts on each click
|
||||
↓
|
||||
User stops clicking
|
||||
↓
|
||||
3 seconds pass
|
||||
↓
|
||||
Single API call: updateQuantity(productId, 5)
|
||||
```
|
||||
|
||||
### Scenario 2: Manual Text Input (Immediate)
|
||||
```
|
||||
User types "10" in quantity field
|
||||
↓
|
||||
User presses Enter
|
||||
↓
|
||||
Immediate API call: updateQuantity(productId, 10)
|
||||
↓
|
||||
No debounce (direct input needs immediate sync)
|
||||
```
|
||||
|
||||
### Scenario 3: Navigate Away (Force Sync)
|
||||
```
|
||||
User clicks + button 3 times
|
||||
↓
|
||||
UI updates: 1→2→3
|
||||
↓
|
||||
Timer is running (1 second passed)
|
||||
↓
|
||||
User navigates back
|
||||
↓
|
||||
dispose() called
|
||||
↓
|
||||
forceSyncPendingUpdates() executes
|
||||
↓
|
||||
Immediate API call: updateQuantity(productId, 3)
|
||||
```
|
||||
|
||||
### Scenario 4: Checkout Button (Force Sync - Skip Debounce) ⚡ NEW
|
||||
```
|
||||
User clicks + button 5 times
|
||||
↓
|
||||
UI updates: 1→2→3→4→5
|
||||
↓
|
||||
Timer is running (1 second passed, would wait 2 more seconds)
|
||||
↓
|
||||
User clicks "Tiến hành đặt hàng" (Checkout)
|
||||
↓
|
||||
Button shows loading spinner
|
||||
↓
|
||||
forceSyncPendingUpdates() called IMMEDIATELY
|
||||
↓
|
||||
Debounce timer cancelled
|
||||
↓
|
||||
API call: updateQuantity(productId, 5) - NO WAITING!
|
||||
↓
|
||||
Navigate to checkout page with synced data ✅
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Instant UI feedback** - No waiting for API responses
|
||||
✅ **Reduced API calls** - Only 1 call per product after changes stop
|
||||
✅ **Better UX** - Smooth, responsive interface
|
||||
✅ **Server-friendly** - Minimizes unnecessary requests
|
||||
✅ **Offline-ready** - Local state updates work offline
|
||||
✅ **Force sync on exit** - Ensures changes are saved
|
||||
✅ **Skip debounce on checkout** - Immediate sync when user clicks checkout ⚡ NEW
|
||||
|
||||
## Configuration
|
||||
|
||||
### Debounce Duration
|
||||
Default: **3 seconds** ✅
|
||||
|
||||
To change:
|
||||
```dart
|
||||
_debounceTimer = Timer(const Duration(seconds: 3), () {
|
||||
_syncPendingQuantityUpdates();
|
||||
});
|
||||
```
|
||||
|
||||
Recommended values:
|
||||
- **2-3 seconds**: Responsive, good balance (current setting) ✅
|
||||
- **5 seconds**: More conservative (fewer API calls)
|
||||
- **1 second**: Very aggressive (more API calls, but faster sync)
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
1. **Test rapid clicks**:
|
||||
- Open cart
|
||||
- Click + button 10 times rapidly
|
||||
- Watch console: Should see only 1 API call after 3s
|
||||
|
||||
2. **Test text input**:
|
||||
- Type quantity directly
|
||||
- Press Enter
|
||||
- Should see immediate API call
|
||||
|
||||
3. **Test navigation sync**:
|
||||
- Click + button 3 times
|
||||
- Immediately navigate back
|
||||
- Should see API call before page closes
|
||||
|
||||
4. **Test multiple products**:
|
||||
- Change quantity on product A
|
||||
- Change quantity on product B
|
||||
- Wait 3 seconds
|
||||
- Should batch update both products
|
||||
|
||||
5. **Test checkout force sync** ⚡ NEW:
|
||||
- Click + button 5 times rapidly
|
||||
- Immediately click "Tiến hành đặt hàng" (within 3s)
|
||||
- Button should show loading spinner
|
||||
- API call should happen immediately (skip debounce)
|
||||
- Should navigate to checkout with synced data
|
||||
|
||||
### Expected Behavior
|
||||
```
|
||||
// Rapid increments (debounced)
|
||||
Click +1 → UI: 2, API: none
|
||||
Click +1 → UI: 3, API: none
|
||||
Click +1 → UI: 4, API: none
|
||||
Wait 3s → UI: 4, API: updateQuantity(4) ✅
|
||||
|
||||
// Direct input (immediate)
|
||||
Type "10" → UI: 10, API: none
|
||||
Press Enter → UI: 10, API: updateQuantity(10) ✅
|
||||
|
||||
// Navigate away (force sync)
|
||||
Click +1 → UI: 2, API: none
|
||||
Navigate back → UI: 2, API: updateQuantity(2) ✅
|
||||
|
||||
// Checkout button (force sync - skip debounce) ⚡ NEW
|
||||
Click +5 times → UI: 1→2→3→4→5, API: none
|
||||
Click checkout (after 1s) → Loading spinner shown
|
||||
→ API: updateQuantity(5) IMMEDIATELY (skip remaining 2s debounce)
|
||||
→ Navigate to checkout ✅
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Sync Failure
|
||||
- Local state is preserved
|
||||
- User sees correct quantity in UI
|
||||
- Error is logged silently
|
||||
- User can retry by refreshing cart
|
||||
|
||||
### Offline Behavior
|
||||
- All updates work in local state
|
||||
- API calls fail silently
|
||||
- TODO: Add to offline queue for retry when online
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Before Debounce
|
||||
- 10 rapid clicks = 10 API calls
|
||||
- Each call takes ~200-500ms
|
||||
- Total time: 2-5 seconds of loading
|
||||
- Poor UX, server strain
|
||||
|
||||
### After Debounce
|
||||
- 10 rapid clicks = 1 API call (after 3s)
|
||||
- UI updates are instant (<16ms per frame)
|
||||
- Total time: 3 seconds wait + 1 API call
|
||||
- Great UX, minimal server load
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Batch Updates**: Combine multiple product updates into single API call
|
||||
2. **Offline Queue**: Persist pending updates to Hive for offline resilience
|
||||
3. **Visual Indicator**: Show "syncing..." badge when pending updates exist
|
||||
4. **Configurable Timeout**: Allow users to adjust debounce duration
|
||||
5. **Smart Sync**: Sync immediately before checkout/payment
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Cart Provider**: `lib/features/cart/presentation/providers/cart_provider.dart`
|
||||
- **Cart Page**: `lib/features/cart/presentation/pages/cart_page.dart`
|
||||
- **Cart Item Widget**: `lib/features/cart/presentation/widgets/cart_item_widget.dart`
|
||||
- **Cart Repository**: `lib/features/cart/data/repositories/cart_repository_impl.dart`
|
||||
|
||||
## Summary
|
||||
|
||||
The debounce implementation provides a smooth, responsive cart experience while minimizing server load. Users get instant feedback, and the app intelligently batches API calls. This is a best practice for any real-time data synchronization scenario! 🎉
|
||||
Reference in New Issue
Block a user