Files
worker/docs/md/CART_DEBOUNCE.md
Phuoc Nguyen 65f6f825a6 update md
2025-11-28 15:16:40 +07:00

12 KiB

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

@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)

/// 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

/// 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

/// 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

/// 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

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)

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:

/// 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:

_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
  • 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! 🎉