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
ValueListenablefor UI observation - Test-friendly - MockCommand supports full progress simulation
Quick Example
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: '',
);// 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
// 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 Method | Function 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:
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%):
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 barType: 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:
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:
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
isCanceledtotrue - Clears
progressto0.0 - Clears
statusMessagetonull
This immediately clears progress state from the UI, providing instant visual feedback that the operation was canceled.
// 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:
// 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.0statusMessage- 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
// 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:
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:
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:
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 updatesupdateMockStatusMessage(String? message)- Simulate status updatesmockCancel()- Simulate cancellation
All require withProgressHandle: true in the constructor.
See Testing for more details.
Best Practices
DO: Check cancellation frequently
// ✅ 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
// ❌ 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:
// ❌ 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
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
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:
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
- Command Basics - All command factory methods
- Command Properties - Other observable properties
- Testing - Testing commands with MockCommand
- Command Builders - UI integration patterns