435 lines
12 KiB
Markdown
435 lines
12 KiB
Markdown
# 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
|
|
? const CustomLoadingIndicator() // 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! 🎉
|