Skip to content

Progress Control

Commands support built-in progress tracking, status messages, and cooperative cancellation through the ProgressHandle class. This enables you to provide rich feedback to users during long-running operations like file uploads, data synchronization, or batch processing.

Overview

Progress Control provides three key capabilities:

  • Progress tracking - Report operation progress from 0.0 (0%) to 1.0 (100%)
  • Status messages - Provide human-readable status updates during execution
  • Cooperative cancellation - Allow operations to be canceled gracefully

Key benefits:

  • Zero overhead - Commands without progress use static default notifiers (no memory cost)
  • Non-nullable API - All progress properties available on every command
  • Type-safe - Full type inference and compile-time checking
  • Reactive - All properties are ValueListenable for UI observation
  • Test-friendly - MockCommand supports full progress simulation

Quick Example

dart
final uploadCommand = Command.createAsyncWithProgress<File, String>(
  (file, handle) async {
    for (int i = 0; i <= 100; i += 10) {
      if (handle.isCanceled.value) return 'Canceled';

      await uploadChunk(file, i);
      handle.updateProgress(i / 100.0);
      handle.updateStatusMessage('Uploading: $i%');
    }
    return 'Complete';
  },
  initialValue: '',
);
dart
// In UI (with watch_it):
final progress = watchValue((MyService s) => s.uploadCommand.progress);
final status = watchValue((MyService s) => s.uploadCommand.statusMessage);

LinearProgressIndicator(value: progress)  // 0.0 to 1.0
Text(status ?? '')  // 'Uploading: 50%'
IconButton(
  onPressed: uploadCommand.cancel,  // Request cancellation
  icon: Icon(Icons.cancel),
)

Creating Commands with Progress

Use the WithProgress factory variants to create commands that receive a ProgressHandle:

Async Commands with Progress

dart
// Full signature: parameter + result
final processCommand = Command.createAsyncWithProgress<int, String>(
  (count, handle) async {
    for (int i = 0; i < count; i++) {
      if (handle.isCanceled.value) return 'Canceled';

      await processItem(Item());
      handle.updateProgress((i + 1) / count);
      handle.updateStatusMessage('Processing item ${i + 1} of $count');
    }
    return 'Processed $count items';
  },
  initialValue: '',
);

All four async variants are available:

Factory MethodFunction Signature
createAsyncWithProgress(param, handle) async => TResult
createAsyncNoParamWithProgress(handle) async => TResult
createAsyncNoResultWithProgress(param, handle) async => void
createAsyncNoParamNoResultWithProgress(handle) async => void

Undoable Commands with Progress

Combine undo capability with progress tracking:

dart
final uploadCommand =
    Command.createUndoableWithProgress<File, String, UploadState>(
  (file, handle, undoStack) async {
    handle.updateStatusMessage('Starting upload...');
    final uploadId = await startUpload(file);
    undoStack.push(UploadState(uploadId));

    final chunks = calculateChunks(file);
    for (int i = 0; i < chunks; i++) {
      if (handle.isCanceled.value) {
        await cancelUpload(uploadId);
        return 'Canceled';
      }

      await uploadChunk(file, i);
      handle.updateProgress((i + 1) / chunks);
      handle.updateStatusMessage('Uploaded ${i + 1}/$chunks chunks');
    }

    return 'Upload complete';
  },
  undo: (undoStack, reason) async {
    final state = undoStack.pop();
    await deleteUpload(state.uploadId);
    return 'Upload deleted';
  },
  initialValue: '',
);

All four undoable variants are available:

  • createUndoableWithProgress<TParam, TResult, TUndoState>()
  • createUndoableNoParamWithProgress<TResult, TUndoState>()
  • createUndoableNoResultWithProgress<TParam, TUndoState>()
  • createUndoableNoParamNoResultWithProgress<TUndoState>()

Progress Properties

All commands (even those without progress) expose these properties:

progress

Observable progress value from 0.0 (0%) to 1.0 (100%):

dart
final command = Command.createAsyncWithProgress<void, String>(
  (_, handle) async {
    handle.updateProgress(0.0);   // Start
    await step1();
    handle.updateProgress(0.33);  // 33%
    await step2();
    handle.updateProgress(0.66);  // 66%
    await step3();
    handle.updateProgress(1.0);   // Complete
    return 'Done';
  },
  initialValue: '',
);

// In UI:
final progress = watchValue((MyService s) => s.command.progress);
LinearProgressIndicator(value: progress)  // Flutter progress bar

Type: ValueListenable<double>Range: 0.0 to 1.0 (inclusive) Default: 0.0 for commands without ProgressHandle

statusMessage

Observable status message providing human-readable operation status:

dart
handle.updateStatusMessage('Downloading...');
handle.updateStatusMessage('Processing...');
handle.updateStatusMessage(null);  // Clear message

// In UI:
final status = watchValue((MyService s) => s.command.statusMessage);
Text(status ?? 'Idle')

Type: ValueListenable<String?>Default: null for commands without ProgressHandle

isCanceled

Observable cancellation flag. The wrapped function should check this periodically and handle cancellation cooperatively:

dart
final command = Command.createAsyncWithProgress<void, String>(
  (_, handle) async {
    for (int i = 0; i < 100; i++) {
      // Check cancellation before each iteration
      if (handle.isCanceled.value) {
        return 'Canceled at step $i';
      }

      await processStep(i);
      handle.updateProgress((i + 1) / 100);
    }
    return 'Complete';
  },
  initialValue: '',
);

// In UI:
final isCanceled = watchValue((MyService s) => s.command.isCanceled);
if (isCanceled) Text('Operation canceled')

Type: ValueListenable<bool>Default: false for commands without ProgressHandle

cancel()

Request cooperative cancellation of the operation. This method:

  • Sets isCanceled to true
  • Clears progress to 0.0
  • Clears statusMessage to null

This immediately clears progress state from the UI, providing instant visual feedback that the operation was canceled.

dart
// In UI:
IconButton(
  onPressed: command.cancel,
  icon: Icon(Icons.cancel),
)

// Or programmatically:
if (userNavigatedAway) {
  command.cancel();
}

Important: This does not forcibly stop execution. The wrapped function must check isCanceled.value and respond appropriately (e.g., return early, throw exception, clean up resources).

resetProgress()

Manually reset or initialize progress state:

dart
// Reset to defaults (0.0, null, false)
command.resetProgress();

// Initialize to specific values (e.g., resuming an operation)
command.resetProgress(
  progress: 0.5,
  statusMessage: 'Resuming upload...',
);

// Clear 100% progress after completion
if (command.progress.value == 1.0) {
  await Future.delayed(Duration(seconds: 2));
  command.resetProgress();
}

Parameters:

  • progress - Optional initial progress value (0.0-1.0), defaults to 0.0
  • statusMessage - Optional initial status message, defaults to null

Use cases:

  • Clear 100% progress from UI after successful completion
  • Initialize commands to resume from a specific point
  • Reset progress between manual executions
  • Prepare command state for testing

Note: Progress is automatically reset at the start of each run() execution, so manual resets are typically only needed for UI cleanup or resuming operations. Additionally, calling cancel() also clears progress and statusMessage to provide immediate visual feedback.

Integration Patterns

With Flutter Progress Indicators

dart
// Linear progress bar
final progress = watchValue((MyService s) => s.uploadCommand.progress);
LinearProgressIndicator(value: progress)

// Circular progress indicator
CircularProgressIndicator(value: progress)

// Custom progress display
Text('${(progress * 100).toInt()}% complete')

With External Cancellation Tokens

The isCanceled property is a ValueListenable, allowing you to forward cancellation to external libraries like Dio:

dart
final downloadCommand = Command.createAsyncWithProgress<String, File>(
  (url, handle) async {
    final dio = Dio();
    final cancelToken = CancelToken();

    // Forward command cancellation to Dio
    late final subscription;
    subscription = handle.isCanceled.listen(
      (canceled, _) {
        if (canceled) {
          cancelToken.cancel('User canceled');
          subscription.cancel();
        }
      },
    );

    try {
      await dio.download(
        url,
        '/downloads/file.zip',
        cancelToken: cancelToken,
        onReceiveProgress: (received, total) {
          if (total != -1) {
            handle.updateProgress(received / total);
            handle.updateStatusMessage(
              'Downloaded ${(received / 1024 / 1024).toStringAsFixed(1)} MB '
              'of ${(total / 1024 / 1024).toStringAsFixed(1)} MB',
            );
          }
        },
      );
      return File('/downloads/file.zip');
    } finally {
      subscription.cancel();
    }
  },
  initialValue: File(''),
);

Commands Without Progress

Commands created with regular factories (without WithProgress) still have progress properties, but they return default values:

dart
final command = Command.createAsync<void, String>(
  (_) async => 'Done',
  initialValue: '',
);

// These properties exist but return defaults:
command.progress.value        // Always 0.0
command.statusMessage.value   // Always null
command.isCanceled.value      // Always false
command.cancel()              // No effect (no progress handle)

This zero-overhead design means:

  • ✅ UI code can always access progress properties without null checks
  • ✅ No memory cost for commands that don't need progress
  • ✅ Easy to add progress to existing commands later (just change factory)

Testing with MockCommand

MockCommand supports full progress simulation for testing:

dart
void testProgressUpdates() {
  final mockCommand = MockCommand<File, String>(
    initialValue: '',
    withProgressHandle: true, // Enable progress simulation
  );

  // Simulate progress updates
  mockCommand.updateMockProgress(0.0);
  mockCommand.updateMockStatusMessage('Starting upload...');
  assert(mockCommand.progress.value == 0.0);
  assert(mockCommand.statusMessage.value == 'Starting upload...');

  mockCommand.updateMockProgress(0.5);
  mockCommand.updateMockStatusMessage('Uploading...');
  assert(mockCommand.progress.value == 0.5);

  mockCommand.mockCancel();
  assert(mockCommand.isCanceled.value == true);

  mockCommand.dispose();
}

MockCommand progress methods:

  • updateMockProgress(double value) - Simulate progress updates
  • updateMockStatusMessage(String? message) - Simulate status updates
  • mockCancel() - Simulate cancellation

All require withProgressHandle: true in the constructor.

See Testing for more details.

Best Practices

DO: Check cancellation frequently

dart
// ✅ Good - check before each expensive operation
for (final item in items) {
  if (handle.isCanceled.value) return 'Canceled';
  await processItem(item);
  handle.updateProgress(progress);
}

DON'T: Check cancellation too infrequently

dart
// ❌ Bad - only checks once at start
if (handle.isCanceled.value) return 'Canceled';
for (final item in items) {
  await processItem(item);  // Can't cancel during processing
}

Performance Considerations

Progress updates are lightweight - each update is just a ValueNotifier assignment. However, avoid excessive updates:

dart
// ❌ Potentially excessive - updates every byte
for (int i = 0; i < 1000000; i++) {
  process(i);
  handle.updateProgress(i / 1000000);  // 1M UI updates!
}

// ✅ Better - throttle updates
final updateInterval = 1000000 ~/ 100;  // Update every 1%
for (int i = 0; i < 1000000; i++) {
  process(i);
  if (i % updateInterval == 0) {
    handle.updateProgress(i / 1000000);  // 100 UI updates
  }
}

For very high-frequency operations, consider updating every N iterations or using a timer to throttle updates.

Common Patterns

Multi-Step Operations

dart
final multiStepCommand = Command.createAsyncWithProgress<void, String>(
  (_, handle) async {
    // Step 1: Download (0-40%)
    handle.updateStatusMessage('Downloading data...');
    await downloadData();
    handle.updateProgress(0.4);

    // Step 2: Process (40-80%)
    handle.updateStatusMessage('Processing data...');
    await processData();
    handle.updateProgress(0.8);

    // Step 3: Save (80-100%)
    handle.updateStatusMessage('Saving results...');
    await saveResults();
    handle.updateProgress(1.0);

    return 'Complete';
  },
  initialValue: '',
);

Batch Processing with Progress

dart
final batchCommand = Command.createAsyncWithProgress<List<Item>, String>(
  (items, handle) async {
    final total = items.length;
    int current = 0;

    for (final item in items) {
      if (handle.isCanceled.value) {
        return 'Canceled ($current/$total processed)';
      }

      current++;
      handle.updateStatusMessage('Processing item $current of $total');

      // Process item with per-item progress
      const steps = 10;
      for (int step = 0; step <= steps; step++) {
        if (handle.isCanceled.value) {
          return 'Canceled ($current/$total processed)';
        }

        handle.updateProgress(step / steps);
        await simulateDelay(50); // Simulate work step
      }
    }

    return 'Processed $total items';
  },
  initialValue: '',
);

Indeterminate Progress

For operations where progress can't be calculated:

dart
final command = Command.createAsyncWithProgress<void, String>(
  (_, handle) async {
    handle.updateStatusMessage('Connecting to server...');
    await connect();

    handle.updateStatusMessage('Authenticating...');
    await authenticate();

    handle.updateStatusMessage('Loading data...');
    await loadData();

    // Don't update progress - UI can show indeterminate indicator
    return 'Complete';
  },
  initialValue: '',
);

// In UI:
final status = watchValue((MyService s) => s.command.statusMessage);
Column(
  children: [
    CircularProgressIndicator(),  // Indeterminate (no value)
    Text(status ?? ''),
  ],
)

See Also

Released under the MIT License.