advance_cart_stepper 2.0.2 copy "advance_cart_stepper: ^2.0.2" to clipboard
advance_cart_stepper: ^2.0.2 copied to clipboard

A customizable expandable cart quantity stepper widget for Flutter with async support, loading indicators, and theming.

Cart Stepper #

A highly customizable, animated cart quantity stepper widget for Flutter with async support, loading indicators, and theming.

pub package License: MIT Flutter Dart

Cart Stepper Demo

Table of Contents #

Features #

  • Smooth Animations -- Elegant expand/collapse transitions between add button and stepper
  • Two Widget Variants -- Simple CartStepper for sync usage, AsyncCartStepper for full async support
  • Generic Quantity Type -- Supports int, double, or any num subtype via CartStepper<T>
  • Async Support -- Built-in loading indicators for API operations with error handling
  • Optimistic Updates -- Instant UI feedback with automatic revert on errors
  • Debounce Mode -- Batch rapid changes into a single API call
  • Operation Management -- Throttling, cancellation, and pending operation tracking
  • Sync & Async Validation -- Custom validators with rejection callbacks for user feedback
  • Multiple Loading Indicators -- 15+ SpinKit animations plus Flutter's built-in indicators
  • Customizable Styling -- Full control over colors, borders, shadows, and typography
  • Size Variants -- Compact, normal, and large presets for different use cases
  • Theming -- Apply consistent styles across multiple steppers with CartStepperTheme
  • Long Press -- Hold to rapidly increment/decrement with configurable delays
  • Manual Input -- Tap to type quantities directly via keyboard
  • Auto-Collapse -- Optionally collapse to badge view after inactivity
  • Quantity Formatters -- Built-in abbreviation for large numbers (1.5k, 2M)
  • RTL Support -- Full right-to-left directionality support
  • Vertical Layout -- Horizontal or vertical stepper layout direction
  • Controller Integration -- External state management with CartStepperController
  • Reactive Streams -- Drive quantity from a Stream<T> for reactive architectures
  • Undo Support -- Configurable undo-after-delete with custom UI
  • Detailed Callbacks -- onDetailedQuantityChanged with old value, new value, and change type
  • Expand/Collapse Callbacks -- onExpanded / onCollapsed lifecycle hooks
  • Custom Builders -- expandedBuilder and transitionBuilder for fully custom UI
  • Selection Modes -- Single or multiple selection in CartStepperGroup
  • Typed Error Hierarchy -- Sealed CartStepperError classes for precise error handling
  • Extracted Widgets -- Reusable StepperButton and AnimatedCounter for custom implementations
  • State-Agnostic -- Works with any state management (Provider, Riverpod, Bloc, etc.)
  • Accessibility -- Full semantic support for screen readers

Installation #

Add to your pubspec.yaml:

dependencies:
  advance_cart_stepper: ^2.0.1

Then run:

flutter pub get

Which Widget to Use? #

Feature CartStepper AsyncCartStepper
Sync callbacks Yes Yes
Generic T extends num Yes Yes
Controller integration Yes Yes
Reactive streams Yes Yes
RTL / directionality Yes Yes
Vertical layout Yes Yes
Expand/collapse callbacks Yes Yes
Detailed quantity changed Yes Yes
Async callbacks with loading -- Yes
Optimistic updates / debounce -- Yes
Custom icons -- Yes (via iconConfig)
Long press configuration -- Yes (via longPressConfig)
Manual input -- Yes (via manualInputConfig)
Auto-collapse / badge mode -- Yes (via collapseConfig)
Async validators -- Yes
Undo after delete -- Yes (via undoConfig)
Expanded builder -- Yes
Transition builder -- Yes (via animation)
Quantity formatters -- Yes
Error handling -- Yes

Rule of thumb: Use CartStepper for simple add/remove/adjust flows. Use AsyncCartStepper when you need API calls, advanced customization, or any feature beyond the basics.

Quick Start #

Basic Usage (CartStepper) #

import 'package:advance_cart_stepper/advance_cart_stepper.dart';

CartStepper(
  quantity: quantity,
  onQuantityChanged: (qty) => setState(() => quantity = qty),
  onRemove: () => setState(() => quantity = 0),
)

Async with Loading Indicator (AsyncCartStepper) #

AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onRemoveAsync: () async {
    await api.removeFromCart(itemId);
    setState(() => quantity = 0);
  },
  onError: (error, stackTrace) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Error: $error')),
    );
  },
)

Customization #

Size Variants #

// Compact -- for dense lists (32px height)
CartStepper(quantity: quantity, size: CartStepperSize.compact, onQuantityChanged: (qty) {})

// Normal -- default size (40px height)
CartStepper(quantity: quantity, size: CartStepperSize.normal, onQuantityChanged: (qty) {})

// Large -- for accessibility or prominent CTAs (48px height)
CartStepper(quantity: quantity, size: CartStepperSize.large, onQuantityChanged: (qty) {})

Style Presets #

CartStepper(quantity: quantity, style: CartStepperStyle.defaultOrange, onQuantityChanged: (qty) {})
CartStepper(quantity: quantity, style: CartStepperStyle.dark, onQuantityChanged: (qty) {})
CartStepper(quantity: quantity, style: CartStepperStyle.light, onQuantityChanged: (qty) {})

Custom Styling #

CartStepper(
  quantity: quantity,
  style: CartStepperStyle(
    backgroundColor: Colors.blue,
    foregroundColor: Colors.white,
    borderColor: Colors.blue,
    borderWidth: 2.0,
    elevation: 4.0,
    borderRadius: BorderRadius.circular(8),
    fontWeight: FontWeight.bold,
    iconScale: 1.2,
    textStyle: TextStyle(fontSize: 16, letterSpacing: 1.2),
  ),
  onQuantityChanged: (qty) {},
)

Style from ColorScheme #

Automatically match your app's theme:

CartStepper(
  quantity: quantity,
  style: CartStepperStyle.fromColorScheme(Theme.of(context).colorScheme),
  onQuantityChanged: (qty) {},
)

Add Button Styles #

CartStepper(quantity: 0, addToCartConfig: AddToCartButtonConfig.circleIcon, onQuantityChanged: (qty) {})
CartStepper(quantity: 0, addToCartConfig: AddToCartButtonConfig.addButton, onQuantityChanged: (qty) {})
CartStepper(quantity: 0, addToCartConfig: AddToCartButtonConfig.addToCartButton, onQuantityChanged: (qty) {})
CartStepper(quantity: 0, addToCartConfig: AddToCartButtonConfig.iconOnlyButton, onQuantityChanged: (qty) {})

// Custom button
CartStepper(
  quantity: 0,
  addToCartConfig: AddToCartButtonConfig(
    style: AddToCartButtonStyle.button,
    buttonText: 'Buy Now',
    icon: Icons.shopping_bag,
    iconLeading: false,
    buttonWidth: 100,
  ),
  onQuantityChanged: (qty) {},
)

Animation Configuration #

CartStepper(quantity: quantity, animation: CartStepperAnimation.fast, onQuantityChanged: (qty) {})
CartStepper(quantity: quantity, animation: CartStepperAnimation.smooth, onQuantityChanged: (qty) {})

// Custom
CartStepper(
  quantity: quantity,
  animation: CartStepperAnimation(
    expandDuration: Duration(milliseconds: 300),
    countChangeDuration: Duration(milliseconds: 150),
    expandCurve: Curves.easeOutBack,
    collapseCurve: Curves.easeInCubic,
    countChangeCurve: Curves.easeInOut,
    enableHaptics: true,
  ),
  onQuantityChanged: (qty) {},
)

Generic Quantity Type #

Use double or any num subtype for fractional quantities:

// Double quantities (e.g., weight in kg)
CartStepper<double>(
  quantity: 1.5,
  minQuantity: 0.0,
  maxQuantity: 10.0,
  step: 0.5,
  onQuantityChanged: (qty) => setState(() => weight = qty),
)

// Default is int -- no type parameter needed
CartStepper(
  quantity: 3,
  onQuantityChanged: (qty) => setState(() => count = qty),
)

Vertical Layout #

Stack controls vertically:

CartStepper(
  quantity: quantity,
  direction: CartStepperDirection.vertical,
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

RTL / Directionality #

Explicit text direction for RTL languages:

CartStepper(
  quantity: quantity,
  textDirection: TextDirection.rtl,
  onQuantityChanged: (qty) {},
)

Detailed Quantity Changed Callback #

Get old value, new value, and change type:

CartStepper(
  quantity: quantity,
  onDetailedQuantityChanged: (newQty, oldQty, changeType) {
    print('Changed from $oldQty to $newQty via $changeType');
    // changeType: increment, decrement, add, remove, longPressIncrement,
    //             longPressDecrement, manualInput, programmatic, stream
  },
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Expand / Collapse Callbacks #

React to stepper expand/collapse transitions:

CartStepper(
  quantity: quantity,
  onExpanded: () => print('Stepper expanded'),
  onCollapsed: () => print('Stepper collapsed'),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

AsyncCartStepper Features #

All of the following features require AsyncCartStepper.

Loading Indicators #

// SpinKit animations
AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig(
    type: CartStepperLoadingType.fadingCircle,
    minimumDuration: Duration(milliseconds: 500),
    sizeMultiplier: 0.8,
    showDelay: Duration.zero,
    disableButtonsDuringLoading: true,
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(qty);
  },
)

// Built-in Flutter indicator (no SpinKit dependency)
AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig.builtIn,
  onQuantityChangedAsync: (qty) async { await api.updateCart(qty); },
)

Loading config presets:

Preset Type Min Duration Size
CartStepperLoadingConfig() threeBounce 300ms 0.8x
CartStepperLoadingConfig.fast pulse 150ms 0.7x
CartStepperLoadingConfig.subtle fadingFour 200ms 0.6x
CartStepperLoadingConfig.builtIn circular 200ms 0.7x

Available loading types: threeBounce (default), fadingCircle, pulse, dualRing, spinningCircle, wave, chasingDots, threeInOut, ring, ripple, fadingFour, pianoWave, dancingSquare, cubeGrid, circular (Flutter built-in), linear (Flutter built-in)

Optimistic Updates #

Update the UI immediately while the API call happens in background:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: true,
    revertOnError: true,
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stack) {
    showErrorSnackBar(error.toString());
  },
)

Debounce Mode #

Batch rapid changes into a single API call:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    // Only called after user stops interacting for 500ms
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

With debounce:

  • User sees immediate UI feedback
  • User can rapidly adjust quantity without waiting
  • Only one API call is made after user stops interacting
  • Long press works smoothly without blocking

Error Handling #

Error Callback

AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stackTrace) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Failed: $error')),
    );
  },
)

Error Builder

Display inline error UI with retry functionality:

AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  errorBuilder: (context, error, retry) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Failed to update', style: TextStyle(color: Colors.red, fontSize: 12)),
        TextButton(onPressed: retry, child: Text('Retry')),
      ],
    );
  },
)

Custom Icons (CartStepperIconConfig) #

AsyncCartStepper(
  quantity: quantity,
  iconConfig: CartStepperIconConfig(
    addIcon: Icons.add_circle,
    incrementIcon: Icons.arrow_upward,
    decrementIcon: Icons.arrow_downward,
    deleteIcon: Icons.cancel,
    collapsedBadgeIcon: Icons.shopping_cart,
  ),
  onQuantityChanged: (qty) {},
)

Long Press Configuration (CartStepperLongPressConfig) #

// Fast repeat
AsyncCartStepper(
  quantity: quantity,
  longPressConfig: CartStepperLongPressConfig.fast,
  onQuantityChanged: (qty) {},
)

// Slow repeat
AsyncCartStepper(
  quantity: quantity,
  longPressConfig: CartStepperLongPressConfig.slow,
  onQuantityChanged: (qty) {},
)

// Disabled
AsyncCartStepper(
  quantity: quantity,
  longPressConfig: CartStepperLongPressConfig.disabled,
  onQuantityChanged: (qty) {},
)

// Custom
AsyncCartStepper(
  quantity: quantity,
  longPressConfig: CartStepperLongPressConfig(
    enabled: true,
    interval: Duration(milliseconds: 50),
    initialDelay: Duration(milliseconds: 200),
  ),
  onQuantityChanged: (qty) {},
)

Long press presets:

Preset Interval Initial Delay
CartStepperLongPressConfig() 100ms 400ms
CartStepperLongPressConfig.fast 50ms 200ms
CartStepperLongPressConfig.slow 200ms 600ms
CartStepperLongPressConfig.disabled -- --

Manual Input (CartStepperManualInputConfig) #

Allow users to type quantities directly:

AsyncCartStepper(
  quantity: quantity,
  manualInputConfig: CartStepperManualInputConfig(
    enabled: true,
    onSubmitted: (value) => print('User entered: $value'),
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Custom input builder:

AsyncCartStepper(
  quantity: quantity,
  manualInputConfig: CartStepperManualInputConfig(
    enabled: true,
    builder: (context, currentValue, onSubmit, onCancel) {
      return MyCustomNumberPicker(
        value: currentValue,
        onConfirm: (value) => onSubmit(value.toString()),
        onCancel: onCancel,
      );
    },
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Auto-Collapse with Badge (CartStepperCollapseConfig) #

AsyncCartStepper(
  quantity: quantity,
  collapseConfig: CartStepperCollapseConfig(
    autoCollapseDelay: Duration(seconds: 3),
    initiallyExpanded: false,
  ),
  iconConfig: CartStepperIconConfig(
    collapsedBadgeIcon: Icons.shopping_cart,
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

// Or use the badge() preset
AsyncCartStepper(
  quantity: quantity,
  collapseConfig: CartStepperCollapseConfig.badge(
    delay: Duration(seconds: 3),
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Custom collapsed button:

AsyncCartStepper(
  quantity: quantity,
  collapseConfig: CartStepperCollapseConfig(
    collapsedWidth: 110,
    collapsedHeight: 36,
    collapsedBuilder: (context, qty, isLoading, onTap) {
      return GestureDetector(
        onTap: onTap,
        child: Container(
          padding: const EdgeInsets.all(8),
          child: isLoading
              ? const CircularProgressIndicator(strokeWidth: 2)
              : const Text('Add to Cart'),
        ),
      );
    },
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Validation with Feedback #

AsyncCartStepper(
  quantity: quantity,
  maxQuantity: 100,
  validator: (current, newQty) => newQty <= availableStock,
  onValidationRejected: (current, attempted) {
    showSnackBar('Only $availableStock items in stock');
  },
  onMaxReached: () => showSnackBar('Maximum quantity reached'),
  onMinReached: () => showSnackBar('Minimum quantity reached'),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Quantity Formatting #

// Abbreviate large numbers (1500 -> "1.5k")
AsyncCartStepper(
  quantity: 1500,
  maxQuantity: 9999999,
  quantityFormatter: QuantityFormatters.abbreviated,
  onQuantityChanged: (qty) {},
)

// Show max indicator (99 -> "99+")
AsyncCartStepper(
  quantity: 99,
  quantityFormatter: QuantityFormatters.abbreviatedWithMax(99),
  onQuantityChanged: (qty) {},
)

// Simple display (integers without decimal point)
AsyncCartStepper(
  quantity: 5,
  quantityFormatter: QuantityFormatters.simple,
  onQuantityChanged: (qty) {},
)

Async Behavior Configuration (CartStepperAsyncBehavior) #

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: true,       // Show new value immediately
    revertOnError: true,          // Revert if operation fails
    allowLongPressForAsync: false, // Disable rapid fire for async
    throttleInterval: Duration(milliseconds: 100),
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
  },
)

Async behavior presets:

Preset Description
CartStepperAsyncBehavior() Default -- no optimistic updates, 80ms throttle
CartStepperAsyncBehavior.optimistic Optimistic updates with revert on error
CartStepperAsyncBehavior.debounced() Debounced with configurable delay (default 500ms)

Deep Dive: Throttle vs Debounce #

Understanding the two execution strategies is key to choosing the right asyncBehavior for your use case.

Throttle Mode (Default)

When debounceDelay is not set (the default), each tap triggers an API call immediately, but rapid taps are throttled so no more than one call fires per throttleInterval (default 80ms). If a tap arrives while the throttle window is active, the latest value is queued and fires when the window elapses.

User taps: +  +  +  (rapid taps)
           |  |  |
API calls: [call 1]  (throttled → queued) → [call 2 with latest value]
// Default throttle mode — each tap triggers an API call (throttled at 80ms)
AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Debounce Mode

When debounceDelay is set, the widget switches to a completely different strategy:

  1. UI updates instantly — the quantity counter changes immediately on each tap
  2. A timer starts — if the user taps again before the timer fires, the timer resets
  3. One API call fires — only after the user has stopped interacting for the debounceDelay duration, a single API call is made with the final accumulated value
User taps:  +    +    +         (rapid taps, then pause)
            |    |    |
UI display: 1    2    3         (updates instantly)
            [reset] [reset]     (timer keeps resetting)
                          |--- 500ms idle ---|
API call:                                   [call with qty=3]
// Debounce mode — user taps +++ rapidly, UI shows 3 instantly,
// then ONE API call fires 500ms after the last tap
AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Key Differences

Aspect Throttle (default) Debounce
API call timing Immediately (throttled) After user stops interacting
Number of API calls One per tap (deduped by throttle window) One total for all rapid taps
UI update Waits for API (unless optimisticUpdate) Instant local update
Long press for async Blocked by default Allowed automatically
Best for Single item adjustments Shopping carts, bulk edits

Tip: Debounce mode is ideal for shopping cart APIs where you want responsive UI without hammering the server. The user can tap 5 times rapidly and only one API call is made.

Debounce + Long Press

In throttle mode, long-press rapid-fire is blocked for async operations by default (to prevent queuing many concurrent API calls). But in debounce mode, long-press is automatically allowed because there is no risk — only one API call fires at the end regardless of how many taps occurred:

// Long press works smoothly in debounce mode — no extra config needed
AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  longPressConfig: CartStepperLongPressConfig.fast,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Debounce + Error Revert

When revertOnError is true (the default), if the debounced API call fails, the displayed quantity reverts to the value before the debounce accumulation started — not just the last tap:

User taps:  1 → 2 → 3 → 4    (debounce accumulates)
API call:   updateCart(4)      (fires after delay)
API fails:  ✗ error
UI reverts: 1                  (back to the starting value, not 3)
AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
    revertOnError: true, // default
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stack) {
    showErrorSnackBar('Failed to update cart');
  },
)

isLoading and Async Modes #

The isLoading parameter lets you externally control the loading state. However, it interacts with debounce and optimistic updates in important ways.

How Loading State Works Internally

There are two sources of loading state:

Source Description
isLoading (external) Set by you via the widget parameter — overrides internal state
Internal loading Managed automatically by the widget during async operations

When isLoading is null (the default), the widget manages its own loading state:

  • In throttle mode: loading is true while each API call is in flight
  • In debounce mode: loading stays false during the debounce "waiting" phase and only becomes true when the API call actually fires

When isLoading is explicitly set, it always takes priority over the internal state.

Where Loading Blocks Interaction

When loading is active (isLoading == true or internal loading is true):

Action Blocked? Override
Increment / decrement Yes Unless optimisticUpdate: true
Add button Yes Unless optimisticUpdate: true
Manual input Always blocked No override available

The Debounce Gotcha

Debounce mode relies on the user being able to tap multiple times freely during the debounce accumulation window. If you externally set isLoading: true too broadly, it blocks all taps and defeats the purpose of debouncing:

// BAD — Don't do this with debounce mode
AsyncCartStepper(
  quantity: quantity,
  isLoading: myGlobalLoadingFlag, // blocks taps during debounce window!
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async { ... },
)

// GOOD — Let the widget manage its own loading state
AsyncCartStepper(
  quantity: quantity,
  // isLoading: not set (null) — internal state manages it correctly
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async { ... },
)
Scenario isLoading Why
Debounce mode Don't set (leave null) Let internal state handle it
Throttle + optimistic updates Optional Safe because optimisticUpdate bypasses the loading check
Throttle (default, no optimistic) Optional Can use for external control
External loading (e.g., page-level refresh) true during refresh Useful to block all interaction during unrelated loading

Best Practices & Common Patterns #

Debounce with error handling — responsive UI, minimal API calls:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stack) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Failed to update cart')),
    );
  },
)

Pattern 2: Instant Feedback with Safety Net

Optimistic updates + revert on error — feels instant, auto-recovers:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior.optimistic,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stack) {
    showErrorSnackBar('Update failed — reverted');
  },
)

Pattern 3: Maximum Responsiveness (Optimistic + Debounce)

Combines both strategies — UI updates instantly, one batched API call, reverts on failure:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: true,
    revertOnError: true,
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stack) {
    showErrorSnackBar('Update failed — reverted');
  },
)

Pattern 4: Strict Server-Authoritative

No optimistic updates, no debounce — wait for the server on every change:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: false,
    throttleInterval: Duration(milliseconds: 100),
  ),
  loadingConfig: CartStepperLoadingConfig.fast,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Pattern 5: Debounce with Long Press for Bulk Adjustments

Fast long-press + debounce — great for adjusting large quantities quickly:

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 800),
  ),
  longPressConfig: CartStepperLongPressConfig.fast,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Reactive Streams #

Drive quantity from a Stream:

AsyncCartStepper(
  quantity: quantity,
  quantityStream: myQuantityStream,
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Undo After Delete #

Allow users to undo item removal:

AsyncCartStepper(
  quantity: quantity,
  undoConfig: const CartStepperUndoConfig(
    enabled: true,
    duration: Duration(seconds: 3),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onRemoveAsync: () async {
    await api.removeFromCart(itemId);
    setState(() => quantity = 0);
  },
)

Custom undo UI:

AsyncCartStepper(
  quantity: quantity,
  undoConfig: CartStepperUndoConfig(
    enabled: true,
    duration: Duration(seconds: 5),
    builder: (context, undo) => TextButton(
      onPressed: undo,
      child: Text('Undo'),
    ),
  ),
  onQuantityChangedAsync: (qty) async { await api.updateCart(itemId, qty); },
)

Async Validator #

Validate quantity changes asynchronously (e.g., check stock):

AsyncCartStepper(
  quantity: quantity,
  asyncValidator: (current, next) async {
    final available = await api.checkStock(itemId, next);
    return available;
  },
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

Custom Expanded Builder #

Replace the default expanded controls with a custom builder:

AsyncCartStepper(
  quantity: quantity,
  expandedBuilder: (context, qty, increment, decrement, isLoading) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        IconButton(onPressed: decrement, icon: Icon(Icons.remove)),
        Text('$qty'),
        IconButton(onPressed: increment, icon: Icon(Icons.add)),
      ],
    );
  },
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Custom Transition Builder #

Customize the expand/collapse animation:

CartStepper(
  quantity: quantity,
  animation: CartStepperAnimation(
    transitionBuilder: (context, animation, child) {
      return ScaleTransition(scale: animation, child: child);
    },
  ),
  onQuantityChanged: (qty) => setState(() => quantity = qty),
)

Controller #

For external state management with CartStepperController:

class _MyWidgetState extends State<MyWidget> {
  late final CartStepperController controller;

  @override
  void initState() {
    super.initState();
    controller = CartStepperController(
      initialQuantity: 0,
      minQuantity: 0,
      maxQuantity: 10,
      step: 1,
      onMaxReached: () => print('Max reached'),
      onMinReached: () => print('Min reached'),
    );
    controller.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Manual wiring
        CartStepper(
          quantity: controller.quantity,
          maxQuantity: controller.maxQuantity,
          onQuantityChanged: controller.setQuantity,
          onRemove: controller.reset,
        ),

        // Or pass controller directly for auto-wiring
        CartStepper(
          quantity: 0, // ignored when controller is provided
          controller: controller,
        ),

        // Async with controller
        AsyncCartStepper(
          quantity: controller.quantity,
          isLoading: controller.isLoading,
          onQuantityChangedAsync: (qty) => controller.setQuantityAsync(
            qty, () => api.updateCart(itemId, qty),
          ),
        ),
      ],
    );
  }
}

Controller API #

The controller exposes a rich API for programmatic control:

final controller = CartStepperController<int>(initialQuantity: 0);

// Properties
controller.quantity;          // Current quantity
controller.displayQuantity;  // Effective quantity (pending or actual)
controller.isExpanded;       // Whether stepper is expanded
controller.isLoading;        // Whether async operation is in progress
controller.canIncrement;     // Whether increment is possible
controller.canDecrement;     // Whether decrement is possible
controller.isAtMin;          // Whether quantity is at minimum
controller.isAtMax;          // Whether quantity is at maximum
controller.hasPendingOperation; // Whether there's a pending operation

// Sync methods
controller.setQuantity(5);
controller.increment();
controller.decrement();
controller.reset();
controller.expand();
controller.collapse();
controller.setToMax();
controller.setToMin();
controller.cancelOperation();

// Async methods
await controller.setQuantityAsync(5, () => api.updateCart(5), optimistic: true);
await controller.incrementAsync((newQty) => api.updateCart(newQty));
await controller.decrementAsync((newQty) => api.updateCart(newQty));
await controller.resetAsync(() => api.removeFromCart());

// Serialization
final json = controller.toJson();
final restored = json.toCartStepperController();

Controller with State Management #

// With Provider
ChangeNotifierProvider(
  create: (_) => CartStepperController(initialQuantity: 0),
)

// With Riverpod
final cartItemProvider = ChangeNotifierProvider(
  (ref) => CartStepperController(initialQuantity: 0),
);

Theming #

Apply consistent styling across multiple steppers:

CartStepperTheme(
  data: CartStepperThemeData(
    style: CartStepperStyle(
      backgroundColor: Colors.purple,
      foregroundColor: Colors.white,
    ),
    size: CartStepperSize.normal,
    animation: CartStepperAnimation.smooth,
    addToCartConfig: AddToCartButtonConfig.addButton,
    longPressConfig: CartStepperLongPressConfig(enabled: true),
    collapseConfig: CartStepperCollapseConfig(
      autoCollapseDelay: Duration(seconds: 5),
    ),
    iconConfig: CartStepperIconConfig(
      collapsedBadgeIcon: Icons.shopping_cart,
    ),
    manualInputConfig: CartStepperManualInputConfig(enabled: true),
    loadingConfig: CartStepperLoadingConfig.fast,
  ),
  child: Column(
    children: [
      ThemedCartStepper(quantity: 1, onQuantityChanged: (qty) {}),
      ThemedCartStepper(quantity: 2, onQuantityChanged: (qty) {}),
      ThemedCartStepper(
        quantity: 3,
        size: CartStepperSize.compact, // Override theme size
        onQuantityChanged: (qty) {},
      ),
    ],
  ),
)

ThemedCartStepper supports both sync and async callbacks. It looks up the nearest CartStepperTheme and applies its settings, with local overrides taking precedence.

Composite Widgets #

CartProductTile #

A complete product tile with integrated stepper:

CartProductTile(
  leading: Image.network(product.imageUrl),
  title: product.name,
  subtitle: 'In stock',
  price: '\$${product.price}',
  quantity: quantity,
  onQuantityChanged: (qty) => updateCart(product.id, qty),
  onRemove: () => removeFromCart(product.id),
  stepperSize: CartStepperSize.compact,
  stepperStyle: CartStepperStyle.defaultOrange,
  onTap: () => navigateToProduct(product.id),
  borderRadius: 12,
)

CartStepperGroup #

For variant selection (sizes, colors):

CartStepperGroup(
  items: [
    CartStepperGroupItem(id: 'small', quantity: 0, label: 'S'),
    CartStepperGroupItem(id: 'medium', quantity: 1, label: 'M'),
    CartStepperGroupItem(id: 'large', quantity: 0, label: 'L'),
  ],
  onQuantityChanged: (index, qty) {
    setState(() => sizes[index] = qty);
  },
  maxTotalQuantity: 10,
  onTotalChanged: (total) => print('Total: $total'),
)

Selection modes:

// Single selection (radio-style: only one item can have quantity > 0)
CartStepperGroup(
  items: colorVariants,
  selectionMode: CartStepperSelectionMode.single,
  onQuantityChanged: (index, qty) { ... },
  onSelectionChanged: (selectedIndices) { ... },
)

// Async group with loading indicators
CartStepperGroup(
  items: variants,
  onQuantityChangedAsync: (index, qty) async {
    await api.updateVariant(index, qty);
  },
  onError: (error, stack) => showError(error),
)

// Themed group (picks up CartStepperTheme from context)
CartStepperGroup(
  items: variants,
  themed: true,
  onQuantityChanged: (index, qty) { ... },
)

Group items support typed associated data:

CartStepperGroupItem<ProductVariant>(
  id: 'red-m',
  quantity: 0,
  label: 'Red - M',
  data: ProductVariant(color: 'red', size: 'm'),
  minQuantity: 0,
  maxQuantity: 10,
)

CartBadge #

Display cart count on icons with animated transitions:

CartBadge(
  count: totalItems,
  child: Icon(Icons.shopping_cart),
)

With customization:

CartBadge(
  count: totalItems,
  badgeColor: Colors.red,
  textColor: Colors.white,
  size: 20,
  maxCount: 99,          // Shows "99+" for higher counts
  showZero: false,       // Hide badge when count is 0
  alignment: Alignment.topRight,
  offset: EdgeInsets.only(top: -4, right: -4),
  child: Icon(Icons.shopping_cart, size: 28),
)

Extracted Widgets #

These standalone widgets are exported for use in custom implementations.

StepperButton #

A reusable button with the same styling used internally by the stepper:

StepperButton(
  icon: Icons.add,
  onPressed: () => handleAdd(),
)

AnimatedCounter #

An animated number display with slide transitions, used by the stepper for quantity changes:

AnimatedCounter(
  count: currentCount,
)

Error Types #

The package provides a sealed error hierarchy for precise error handling in async operations:

Error Class When Thrown
CartStepperValidationError Quantity validation fails
CartStepperOperationError Async operation (API call) fails
CartStepperTimeoutError Async operation times out
CartStepperCancellationError Operation cancelled (new operation started or widget disposed)
CartStepperBusyError Operation attempted while another is in progress

All error types extend the sealed CartStepperError base class, enabling exhaustive switch expressions:

AsyncCartStepper(
  quantity: quantity,
  errorBuilder: (context, error, retry) {
    return switch (error) {
      CartStepperValidationError(:final attemptedQuantity) =>
        Text('Cannot set to $attemptedQuantity'),
      CartStepperOperationError(:final operationType) =>
        TextButton(onPressed: retry, child: Text('Retry $operationType')),
      CartStepperTimeoutError() =>
        Text('Request timed out'),
      CartStepperCancellationError() =>
        SizedBox.shrink(),
      CartStepperBusyError() =>
        Text('Please wait...'),
    };
  },
  onQuantityChangedAsync: (qty) async { ... },
)

Helper types for operation results:

// OperationResult is a sealed type with two subtypes:
// - OperationSuccess
// - OperationFailure

Tips, Gotchas #

This section documents non-obvious behaviors, subtle edge cases, and practical tips that go beyond the basic API documentation. Read this before shipping to production.

minQuantity Must Be > 0 #

Despite the doc comments saying "Defaults to 0", the actual default is 1 and the widget enforces minQuantity > 0 via assertion. Passing minQuantity: 0 will crash in debug mode.

// BAD — crashes in debug mode
CartStepper(quantity: 1, minQuantity: 0, onQuantityChanged: (qty) {})

// GOOD — minimum is 1 or higher
CartStepper(quantity: 1, minQuantity: 1, onQuantityChanged: (qty) {})

This also means the "Add" button always sets quantity to minQuantity (not 0), and controller.reset() resets to minQuantity (not 0). There is no concept of "quantity = 0" in the widget -- quantity 0 is the collapsed/empty state where the "Add to Cart" button is shown.

Always Provide onError for Async Operations #

When using AsyncCartStepper, if no onError callback is provided and an async operation fails, the error is silently swallowed in release builds. You won't see any crash, log, or trace -- the stepper simply gets stuck in a failed state.

// BAD — errors vanish silently in production
AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty); // if this throws... silence
  },
)

// GOOD — always handle errors
AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
  onError: (error, stackTrace) {
    log('Cart update failed', error: error, stackTrace: stackTrace);
    showErrorSnackBar('Failed to update cart');
  },
)

For visual error feedback, also provide an errorBuilder:

AsyncCartStepper(
  quantity: quantity,
  onQuantityChangedAsync: (qty) async { ... },
  onError: (error, stack) => log('Error', error: error),
  errorBuilder: (context, error, retry) {
    return TextButton(onPressed: retry, child: Text('Retry'));
  },
)

Long Press Is Silently Disabled for Async #

When you use onQuantityChangedAsync, long-press rapid-fire is disabled by default with no visual indication. The user holds the button and nothing happens beyond the first tap.

This is intentional to prevent queuing many concurrent API calls, but it can confuse users. You have three options:

// Option 1: Explicitly allow long press for async (careful — can queue many API calls)
AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(allowLongPressForAsync: true),
  onQuantityChangedAsync: (qty) async { ... },
)

// Option 2: Use debounce mode — long press is automatically allowed
// (only one API call fires at the end, so it's safe)
AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async { ... },
)

// Option 3: Disable long press entirely to make it explicit
AsyncCartStepper(
  quantity: quantity,
  longPressConfig: CartStepperLongPressConfig.disabled,
  onQuantityChangedAsync: (qty) async { ... },
)

Long Press Never Triggers Removal #

Even during a long-press decrement hold, the stepper will stop at minQuantity and never trigger deletion. Only a single tap on the decrement/delete button when at minQuantity triggers onRemove / onRemoveAsync. This is intentional to prevent accidental deletions during rapid adjustment.

Optimistic Updates: Server Must Confirm Exact Value #

When using optimisticUpdate: true, the widget shows a pending value immediately and waits for the server to "confirm" it by matching widget.quantity to the pending value. If the server returns a different quantity (e.g., you sent 10 but server capped it to 8), the optimistic value persists until the async operation completes.

// If the server might return a different quantity than requested,
// make sure to update widget.quantity from the server response:
AsyncCartStepper(
  quantity: serverQuantity, // always use the server-confirmed value
  asyncBehavior: CartStepperAsyncBehavior.optimistic,
  onQuantityChangedAsync: (qty) async {
    final confirmedQty = await api.updateCart(itemId, qty);
    setState(() => serverQuantity = confirmedQty); // use server's response
  },
)

revertOnError: false Can Cause Stuck State #

When optimisticUpdate: true and revertOnError: false, if the async operation fails, the pending optimistic value is never cleared. The display stays stuck on the pending value forever.

// DANGEROUS — stuck pending value on error
asyncBehavior: CartStepperAsyncBehavior(
  optimisticUpdate: true,
  revertOnError: false, // pending value stays if API fails
)

// SAFE — revert on error is the default
asyncBehavior: CartStepperAsyncBehavior(
  optimisticUpdate: true,
  revertOnError: true, // default — reverts to pre-operation value
)

Loading Has a Minimum Duration of 300ms #

Even if your async operation completes in 10ms, the loading spinner shows for at least 300ms by default. This prevents visual "flicker" but can feel slow for fast operations.

// Use the fast preset to reduce minimum loading to 150ms
AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig.fast,
  onQuantityChangedAsync: (qty) async { ... },
)

// Or customize it directly
AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig(
    minimumDuration: Duration(milliseconds: 100), // shorter
    showDelay: Duration(milliseconds: 200), // don't show spinner for fast ops
  ),
  onQuantityChangedAsync: (qty) async { ... },
)

The showDelay parameter delays showing the spinner. Combined with a fast API, you can avoid showing the spinner entirely for quick operations while still showing it for slow ones.

autoCollapseDelay Changes Initial Expand State #

Setting autoCollapseDelay has a surprising side effect: the stepper starts collapsed even when quantity > 0, unless you also explicitly set initiallyExpanded: true.

// SURPRISE — stepper starts collapsed even though quantity is 5
AsyncCartStepper(
  quantity: 5,
  collapseConfig: CartStepperCollapseConfig(
    autoCollapseDelay: Duration(seconds: 3),
  ),
  onQuantityChanged: (qty) {},
)

// FIX — explicitly start expanded
AsyncCartStepper(
  quantity: 5,
  collapseConfig: CartStepperCollapseConfig(
    autoCollapseDelay: Duration(seconds: 3),
    initiallyExpanded: true, // now it starts expanded, then auto-collapses
  ),
  onQuantityChanged: (qty) {},
)

Async Operations Are Not Cancelled Server-Side #

When a new operation supersedes an old one (or the widget is disposed), the old API call keeps running on the server. Only the UI update from the stale result is suppressed. If your API calls have side effects (charging a card, sending emails), ensure your server handles duplicate/overlapping requests gracefully (idempotency).

Controller reset() and collapse() Don't Remove Items #

Both controller.reset() and controller.collapse() set quantity to minQuantity (default 1), not 0. There is no controller method to "remove from cart" (set quantity below minQuantity). Use onRemove / onRemoveAsync callbacks for item removal.

Controller setQuantity() Skips Validation #

Calling controller.setQuantity(value) bypasses the validator callback. Only increment(), decrement(), incrementAsync(), and decrementAsync() call the validator. If you need to enforce validation on direct quantity changes, validate before calling setQuantity().

// Validator is NOT called here
controller.setQuantity(50);

// Validator IS called here
controller.increment(); // calls validator(current, current + step)

Controller setQuantity() Silently Cancels In-Flight Async #

Calling controller.setQuantity() while an async operation is in progress silently increments the internal operation ID and clears the loading state. The running API call completes on the server but its result is ignored, and onOperationCancelled is not called.

Priority Order: Controller > Stream > Widget Quantity #

When multiple quantity sources are provided, the priority is:

  1. controller.quantity (highest)
  2. quantityStream latest value
  3. widget.quantity (lowest)

If you provide both a controller and a quantityStream, the stream is completely ignored with no warning.

ThemedCartStepper Drops Config in Sync Mode #

When ThemedCartStepper is used without any async callbacks, it falls back to the sync CartStepper which does not support longPressConfig, collapseConfig, iconConfig, manualInputConfig, quantityFormatter, or deleteViaQuantityChange. These theme properties are silently dropped. To use these features, provide at least one async callback (even if you don't need async behavior).

CartStepperGroup Ignores Theme by Default #

Unlike ThemedCartStepper, CartStepperGroup does not pick up CartStepperTheme from context by default. You must explicitly opt in:

// Theme is IGNORED
CartStepperGroup(items: items, onQuantityChanged: (i, qty) {})

// Theme is applied
CartStepperGroup(items: items, themed: true, onQuantityChanged: (i, qty) {})

CartStepperGroup Uses compact Size by Default #

CartStepperGroup defaults to CartStepperSize.compact, while standalone steppers default to CartStepperSize.normal. This can cause visual inconsistency if you use both without explicit sizing.

CartProductTile Is Sync-Only #

CartProductTile internally uses CartStepper (sync), not AsyncCartStepper. There is no way to use async callbacks, loading indicators, or advanced features through CartProductTile. Build a custom tile layout if you need async support.

Manual Input Allows Decimals for int Steppers #

The manual input field allows decimal points (e.g., "5.5") even when the stepper type is int. The value is parsed and silently truncated (not rounded). Consider providing a custom manualInputConfig.builder if you need stricter input validation.

Manual Input Submits on Focus Loss #

When manual input is active and the field loses focus (tapping elsewhere, keyboard dismiss), the current value is automatically submitted -- not cancelled. If you want cancel-on-blur behavior, use a custom manualInputConfig.builder.

Undo State Shows minQuantity, Not Zero #

During the undo-after-delete countdown, the displayed quantity shows minQuantity (e.g., 1), not 0. This is because the widget doesn't have a concept of "zero quantity display" during active operation. Consider providing a custom undoConfig.builder if you want different undo visuals.

toCartStepperController() JSON Extension Has Wrong Defaults #

The Map<String, dynamic>.toCartStepperController() extension defaults minQuantity to 0, which violates the minQuantity > 0 assertion and will crash in debug mode. Always provide explicit values when using JSON deserialization:

// BAD — crashes if 'minQuantity' key is missing
final controller = jsonMap.toCartStepperController();

// GOOD — provide the values explicitly
final controller = CartStepperController<int>(
  initialQuantity: jsonMap['quantity'] as int? ?? 1,
  minQuantity: jsonMap['minQuantity'] as int? ?? 1,
  maxQuantity: jsonMap['maxQuantity'] as int? ?? 99,
);

Large step Values Can Disable Increment #

If step is larger than maxQuantity - currentQuantity, the increment button appears disabled even though the clamped result would be valid. For example, with step: 5, currentQuantity: 1, maxQuantity: 3, the button is disabled even though incrementing to 3 would be a valid clamped result.

// Be careful with large step values relative to your range
CartStepper(
  quantity: 1,
  step: 5,
  maxQuantity: 3, // increment button will be disabled!
  onQuantityChanged: (qty) {},
)

Non-Blocking UI: Keeping the Stepper Responsive #

By default, when an async operation is in flight, the stepper blocks all interaction -- buttons are disabled and a loading spinner replaces the quantity. This is the safest behavior, but it can feel sluggish. Here's how each mechanism blocks (or doesn't block) the UI, and how to keep things feeling instant.

What Blocks What

State +/- Buttons Add Button Manual Input Quantity Display
_isLoading (no optimistic) Disabled Disabled Disabled Shows spinner
_isLoading + optimisticUpdate Enabled Enabled Disabled Shows pending qty
_isLoading + disableButtonsDuringLoading: false Enabled Disabled Disabled Shows spinner
Debounce waiting (before API fires) Enabled Enabled Enabled Shows debounced qty
enabled: false Disabled Disabled Disabled Normal display
External isLoading: true Disabled Disabled Disabled Shows spinner

Key insight: Manual input is always blocked during loading, regardless of optimisticUpdate. There is no override for this.

Strategy 1: Optimistic Updates (Instant Feedback, Spinner in Background)

The quantity updates instantly. Buttons stay enabled. The spinner does not appear -- instead, the new quantity is shown immediately. If the API fails, it reverts.

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: true,
    revertOnError: true,
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

UI behavior: User taps + -> display shows new quantity instantly -> API runs in background -> if error, display reverts. No spinner, no disabled state. The user can keep tapping freely.

Strategy 2: Debounce (Accumulate Taps, One API Call)

The UI updates locally on every tap. No API call fires during tapping. One call fires after the user pauses. Loading spinner only appears during the actual API call.

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

UI behavior: User taps +++ -> display shows 1, 2, 3 locally -> 500ms idle -> loading spinner appears briefly -> API call completes. The stepper is fully interactive during the debounce wait, including long press.

Strategy 3: Debounce + Optimistic (Maximum Responsiveness)

Combines both: instant local updates, batched API call, no spinner during accumulation, and buttons stay enabled even during the API call.

AsyncCartStepper(
  quantity: quantity,
  asyncBehavior: CartStepperAsyncBehavior(
    optimisticUpdate: true,
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

UI behavior: Fully non-blocking. User can tap freely at all times. One API call fires after pausing. On error, reverts to the value before the accumulation started.

Strategy 4: Keep Buttons Enabled During Loading (Without Optimistic)

If you don't want optimistic updates but still want buttons enabled during loading (so the user can queue the next change), disable the button-blocking:

AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig(
    disableButtonsDuringLoading: false, // buttons stay enabled
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

UI behavior: Spinner appears in the quantity area, but +/- buttons remain tappable. The new operation supersedes the old one (the old API call still runs server-side but its result is ignored).

Note: The Add button (collapsed state) is always disabled during loading regardless of disableButtonsDuringLoading. Only the expanded +/- buttons respect this setting.

Strategy 5: Hide the Spinner for Fast Operations

Use showDelay to only show the spinner if the operation takes longer than expected:

AsyncCartStepper(
  quantity: quantity,
  loadingConfig: CartStepperLoadingConfig(
    showDelay: Duration(milliseconds: 300), // wait 300ms before showing spinner
    minimumDuration: Duration(milliseconds: 150), // if shown, show for at least 150ms
  ),
  onQuantityChangedAsync: (qty) async {
    await api.updateCart(itemId, qty);
    setState(() => quantity = qty);
  },
)

UI behavior: If the API responds within 300ms, the user never sees a spinner -- it feels instant. If it takes longer, the spinner appears and stays for at least 150ms to avoid flicker.

Avoid: External isLoading with Debounce

Setting isLoading externally during debounce mode blocks all taps and defeats the purpose of debouncing:

// BAD — blocks interaction during the debounce window
AsyncCartStepper(
  quantity: quantity,
  isLoading: _myLoadingFlag, // overrides internal state, blocks taps
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async { ... },
)

// GOOD — let the widget manage loading internally
AsyncCartStepper(
  quantity: quantity,
  // isLoading: don't set it
  asyncBehavior: CartStepperAsyncBehavior(
    debounceDelay: Duration(milliseconds: 500),
  ),
  onQuantityChangedAsync: (qty) async { ... },
)

Quick Reference: Which Strategy to Use

Scenario Strategy Config
Fast API (< 200ms) Show delay loadingConfig: CartStepperLoadingConfig(showDelay: Duration(milliseconds: 200))
Shopping cart (frequent updates) Debounce asyncBehavior: CartStepperAsyncBehavior(debounceDelay: Duration(milliseconds: 500))
Instant feel, safe revert Optimistic asyncBehavior: CartStepperAsyncBehavior.optimistic
Maximum responsiveness Debounce + Optimistic asyncBehavior: CartStepperAsyncBehavior(optimisticUpdate: true, debounceDelay: Duration(milliseconds: 500))
Slow API, allow queuing Keep buttons enabled loadingConfig: CartStepperLoadingConfig(disableButtonsDuringLoading: false)
Server-authoritative, strict Default No config needed (default behavior)

Assertions Only Run in Debug Mode #

All parameter validation (minQuantity > 0, maxQuantity > minQuantity, step > 0, controller exclusivity) is done via Dart assert(), which is stripped in release builds. Invalid parameters won't crash in production but may cause unexpected behavior. Always test thoroughly in debug mode first.

Migration Guide #

See MIGRATION.md for a detailed guide on migrating from v1.x to v2.0.0, including the new widget split, config objects, generic type support, and all new features.

Example App #

See the example folder for a complete demo showcasing all features.

cd example
flutter create .
flutter run

Contributing #

Contributions are welcome! Here's how to get started:

  1. Fork the repository on GitHub
  2. Clone your fork locally:
git clone https://github.com/your-username/advance_cart_stepper.git
cd advance_cart_stepper
  1. Install dependencies:
flutter pub get
  1. Create a branch for your change:
git checkout -b feature/my-feature
  1. Make your changes and ensure everything passes:
dart analyze lib/ example/
dart format lib/ example/ --set-exit-if-changed
flutter test
  1. Run the example app to verify your changes visually:
cd example
flutter create .
flutter run
  1. Commit your changes with a clear message:
git commit -m "feat: add my new feature"
  1. Push to your fork and open a Pull Request against the main branch

Guidelines #

  • Follow the existing code style and conventions
  • Add documentation comments for any new public API
  • Update the example app if adding new features
  • Update README.md and MIGRATION.md if the change affects the public API
  • Keep PRs focused -- one feature or fix per PR

License #

This project is licensed under the MIT License - see the LICENSE file for details.

1
likes
150
points
474
downloads

Publisher

unverified uploader

Weekly Downloads

A customizable expandable cart quantity stepper widget for Flutter with async support, loading indicators, and theming.

Repository (GitHub)
View/report issues

Topics

#cart #stepper #quantity #e-commerce #widget

Documentation

API reference

License

MIT (license)

Dependencies

flutter, flutter_spinkit

More

Packages that depend on advance_cart_stepper