Control de Progreso
Los Commands soportan tracking de progreso integrado, mensajes de estado, y cancelación cooperativa a través de la clase ProgressHandle. Esto te permite proporcionar feedback rico a los usuarios durante operaciones de larga duración como subida de archivos, sincronización de datos, o procesamiento por lotes.
Resumen
El Control de Progreso proporciona tres capacidades clave:
- ✅ Tracking de progreso - Reporta progreso de operación desde 0.0 (0%) hasta 1.0 (100%)
- ✅ Mensajes de estado - Proporciona actualizaciones de estado legibles durante la ejecución
- ✅ Cancelación cooperativa - Permite que las operaciones sean canceladas elegantemente
Beneficios clave:
- Cero overhead - Commands sin progreso usan notifiers estáticos por defecto (sin costo de memoria)
- API no-nullable - Todas las propiedades de progreso disponibles en cada command
- Type-safe - Inferencia de tipos completa y verificación en tiempo de compilación
- Reactivo - Todas las propiedades son
ValueListenablepara observación de UI
Ejemplo Rápido
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: '',
);// En UI (con watch_it):
final progress = watchValue((MyService s) => s.uploadCommand.progress);
final status = watchValue((MyService s) => s.uploadCommand.statusMessage);
LinearProgressIndicator(value: progress) // 0.0 a 1.0
Text(status ?? '') // 'Subiendo: 50%'
IconButton(
onPressed: uploadCommand.cancel, // Solicitar cancelación
icon: Icon(Icons.cancel),
)Creando Commands con Progreso
Usa las variantes factory WithProgress para crear commands que reciben un ProgressHandle:
Commands Async con Progreso
// 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: '',
);Las cuatro variantes async están disponibles:
| Método Factory | Firma de Función |
|---|---|
createAsyncWithProgress | (param, handle) async => TResult |
createAsyncNoParamWithProgress | (handle) async => TResult |
createAsyncNoResultWithProgress | (param, handle) async => void |
createAsyncNoParamNoResultWithProgress | (handle) async => void |
Commands Undoable con Progreso
Combina capacidad de undo con tracking de progreso:
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: '',
);Las cuatro variantes undoable están disponibles:
createUndoableWithProgress<TParam, TResult, TUndoState>()createUndoableNoParamWithProgress<TResult, TUndoState>()createUndoableNoResultWithProgress<TParam, TUndoState>()createUndoableNoParamNoResultWithProgress<TUndoState>()
Propiedades de Progreso
Todos los commands (incluso aquellos sin progreso) exponen estas propiedades:
progress
Valor de progreso observable desde 0.0 (0%) hasta 1.0 (100%):
final command = Command.createAsyncWithProgress<void, String>(
(_, handle) async {
handle.updateProgress(0.0); // Inicio
await step1();
handle.updateProgress(0.33); // 33%
await step2();
handle.updateProgress(0.66); // 66%
await step3();
handle.updateProgress(1.0); // Completo
return 'Hecho';
},
initialValue: '',
);
// En UI:
final progress = watchValue((MyService s) => s.command.progress);
LinearProgressIndicator(value: progress) // Barra de progreso de FlutterTipo: ValueListenable<double>Rango: 0.0 a 1.0 (inclusivo) Por defecto: 0.0 para commands sin ProgressHandle
statusMessage
Mensaje de estado observable proporcionando estado de operación legible:
handle.updateStatusMessage('Descargando...');
handle.updateStatusMessage('Procesando...');
handle.updateStatusMessage(null); // Limpiar mensaje
// En UI:
final status = watchValue((MyService s) => s.command.statusMessage);
Text(status ?? 'Inactivo')Tipo: ValueListenable<String?>Por defecto: null para commands sin ProgressHandle
isCanceled
Flag de cancelación observable. La función envuelta debe verificar esto periódicamente y manejar la cancelación cooperativamente:
final command = Command.createAsyncWithProgress<void, String>(
(_, handle) async {
for (int i = 0; i < 100; i++) {
// Verificar cancelación antes de cada iteración
if (handle.isCanceled.value) {
return 'Cancelado en paso $i';
}
await processStep(i);
handle.updateProgress((i + 1) / 100);
}
return 'Completo';
},
initialValue: '',
);
// En UI:
final isCanceled = watchValue((MyService s) => s.command.isCanceled);
if (isCanceled) Text('Operación cancelada')Tipo: ValueListenable<bool>Por defecto: false para commands sin ProgressHandle
cancel()
Solicita cancelación cooperativa de la operación. Este método:
- Establece
isCanceledatrue - Limpia
progressa0.0 - Limpia
statusMessageanull
Esto inmediatamente limpia el estado de progreso de la UI, proporcionando feedback visual instantáneo de que la operación fue cancelada.
// En UI:
IconButton(
onPressed: command.cancel,
icon: Icon(Icons.cancel),
)
// O programáticamente:
if (userNavigatedAway) {
command.cancel();
}Importante: Esto no detiene forzadamente la ejecución. La función envuelta debe verificar isCanceled.value y responder apropiadamente (ej., retornar temprano, lanzar excepción, limpiar recursos).
resetProgress()
Resetea o inicializa manualmente el estado de progreso:
// Resetear a valores por defecto (0.0, null, false)
command.resetProgress();
// Inicializar a valores específicos (ej., resumiendo una operación)
command.resetProgress(
progress: 0.5,
statusMessage: 'Resumiendo subida...',
);
// Limpiar progreso 100% después de completar
if (command.progress.value == 1.0) {
await Future.delayed(Duration(seconds: 2));
command.resetProgress();
}Parámetros:
progress- Valor inicial de progreso opcional (0.0-1.0), por defecto 0.0statusMessage- Mensaje de estado inicial opcional, por defecto null
Casos de uso:
- Limpiar progreso 100% de UI después de completación exitosa
- Inicializar commands para resumir desde un punto específico
- Resetear progreso entre ejecuciones manuales
- Preparar estado de command para testing
Nota: El progreso se resetea automáticamente al inicio de cada ejecución de run(), así que los resets manuales típicamente solo se necesitan para limpieza de UI o resumir operaciones. Adicionalmente, llamar cancel() también limpia progress y statusMessage para proporcionar feedback visual inmediato.
Patrones de Integración
Con Indicadores de Progreso de Flutter
// Barra de progreso lineal
final progress = watchValue((MyService s) => s.uploadCommand.progress);
LinearProgressIndicator(value: progress)
// Indicador de progreso circular
CircularProgressIndicator(value: progress)
// Display de progreso personalizado
Text('${(progress * 100).toInt()}% completo')Con Tokens de Cancelación Externos
La propiedad isCanceled es un ValueListenable, permitiéndote enviar cancelación a librerías externas como 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 Sin Progreso
Los commands creados con factories regulares (sin WithProgress) aún tienen propiedades de progreso, pero retornan valores por defecto:
final command = Command.createAsync<void, String>(
(_) async => 'Hecho',
initialValue: '',
);
// Estas propiedades existen pero retornan valores por defecto:
command.progress.value // Siempre 0.0
command.statusMessage.value // Siempre null
command.isCanceled.value // Siempre false
command.cancel() // Sin efecto (sin progress handle)Este diseño de cero overhead significa:
- ✅ El código de UI siempre puede acceder propiedades de progreso sin verificaciones null
- ✅ Sin costo de memoria para commands que no necesitan progreso
- ✅ Fácil agregar progreso a commands existentes después (solo cambia factory)
Testing con MockCommand
MockCommand soporta simulación completa de progreso para 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();
}Métodos de progreso de MockCommand:
updateMockProgress(double value)- Simula actualizaciones de progresoupdateMockStatusMessage(String? message)- Simula actualizaciones de estadomockCancel()- Simula cancelación
Todos requieren withProgressHandle: true en el constructor.
Ver Testing para más detalles.
Mejores Prácticas
SÍ: Verificar cancelación frecuentemente
// ✅ Bien - verifica antes de cada operación costosa
for (final item in items) {
if (handle.isCanceled.value) return 'Cancelado';
await processItem(item);
handle.updateProgress(progress);
}NO: Verificar cancelación muy infrecuentemente
// ❌ Mal - solo verifica una vez al inicio
if (handle.isCanceled.value) return 'Cancelado';
for (final item in items) {
await processItem(item); // No puede cancelar durante procesamiento
}Consideraciones de Rendimiento
Las actualizaciones de progreso son ligeras - cada actualización es solo una asignación de ValueNotifier. Sin embargo, evita actualizaciones excesivas:
// ❌ Potencialmente excesivo - actualiza cada byte
for (int i = 0; i < 1000000; i++) {
process(i);
handle.updateProgress(i / 1000000); // ¡1M actualizaciones de UI!
}
// ✅ Mejor - throttle de actualizaciones
final updateInterval = 1000000 ~/ 100; // Actualizar cada 1%
for (int i = 0; i < 1000000; i++) {
process(i);
if (i % updateInterval == 0) {
handle.updateProgress(i / 1000000); // 100 actualizaciones de UI
}
}Para operaciones de muy alta frecuencia, considera actualizar cada N iteraciones o usar un timer para throttle de actualizaciones.
Patrones Comunes
Operaciones Multi-Paso
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: '',
);Procesamiento por Lotes con Progreso
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: '',
);Progreso Indeterminado
Para operaciones donde el progreso no puede calcularse:
final command = Command.createAsyncWithProgress<void, String>(
(_, handle) async {
handle.updateStatusMessage('Conectando al servidor...');
await connect();
handle.updateStatusMessage('Autenticando...');
await authenticate();
handle.updateStatusMessage('Cargando datos...');
await loadData();
// No actualiza progreso - UI puede mostrar indicador indeterminado
return 'Completo';
},
initialValue: '',
);
// En UI:
final status = watchValue((MyService s) => s.command.statusMessage);
Column(
children: [
CircularProgressIndicator(), // Indeterminado (sin valor)
Text(status ?? ''),
],
)Ver También
- Fundamentos de Command - Todos los métodos factory de command
- Propiedades del Command - Otras propiedades observables
- Testing - Testing de commands con MockCommand
- Command Builders - Patrones de integración de UI