skills/flutter-it/flutter_it/command-it-expert

command-it-expert

SKILL.md

command_it Expert - Command Pattern with Reactive States

What: Wrap functions as command objects with automatic loading/error/result states. Built on listen_it.

CRITICAL RULES

  • Use run() to execute commands, NOT execute() (deprecated)
  • Sync commands ASSERT on isRunning access - use async commands for loading states
  • restriction.value == true means command is DISABLED (cannot run)
  • Factory constructors with TResult require initialValue parameter
  • Error filters return ErrorReaction enum, NOT bool

Factory Constructors

Choose the right one based on parameter/result combinations:

// ASYNC - Most common
Command.createAsyncNoParamNoResult(() async { ... });
Command.createAsyncNoResult<TParam>((param) async { ... });
Command.createAsyncNoParam<TResult>(() async { ... }, initialValue: defaultValue);
Command.createAsync<TParam, TResult>((param) async { ... }, initialValue: defaultValue);

// SYNC - No isRunning support
Command.createSyncNoParamNoResult(() { ... });
Command.createSyncNoResult<TParam>((param) { ... });
Command.createSyncNoParam<TResult>(() { ... }, initialValue: defaultValue);
Command.createSync<TParam, TResult>((param) { ... }, initialValue: defaultValue);

// UNDOABLE - With undo stack
Command.createUndoableNoResult<TParam, TUndoState>(
  (param, undoStack) async { undoStack.push(currentState); ... },
  undo: (undoStack, error) async { final prev = undoStack.pop(); restore(prev); },
);

// WITH PROGRESS - Progress tracking
Command.createAsyncNoParamWithProgress<TResult>(
  (handle) async {
    handle.updateProgress(0.5);
    handle.updateStatusMessage('Loading...');
    if (handle.isCanceled.value) return defaultValue;
    ...
  },
  initialValue: defaultValue,
);

Execution

command.run();                    // Fire and forget (returns void)
command.run(param);               // With parameter
command(param);                   // Callable class syntax (alias for run)
final result = await command.runAsync(param);  // Await result (async commands only)

Observable Properties (all ValueListenable)

command.isRunning        // ValueListenable<bool> - ASYNC ONLY (asserts on sync), use for UI
command.isRunningSync    // ValueListenable<bool> - ONLY for restrictions, NOT for UI updates
command.canRun           // ValueListenable<bool> - !restriction && !isRunning
command.errors           // ValueListenable<CommandError<TParam>?>
command.errorsDynamic    // ValueListenable<CommandError<dynamic>?>
command.results          // ValueListenable<CommandResult<TParam?, TResult>>

// WithProgress commands only:
command.progress         // ValueListenable<double> - 0.0 to 1.0
command.statusMessage    // ValueListenable<String?>
command.isCanceled       // ValueListenable<bool>

CommandResult Properties

final result = command.results.value;
result.data          // TResult? - the return value
result.error         // Object? - exception if failed
result.isRunning     // bool
result.paramData     // TParam? - parameter passed to command
result.hasError      // bool
result.hasData       // bool
result.isSuccess     // bool
result.stackTrace    // StackTrace?

Restrictions

restriction takes ValueListenable<bool> - when true, command CANNOT run:

// Disable command while another is running
final saveCommand = Command.createAsyncNoParamNoResult(
  () async { ... },
  restriction: loadCommand.isRunning,  // Can't save while loading
);

// Custom restriction
final isOffline = ValueNotifier<bool>(false);
final fetchCommand = Command.createAsyncNoParamNoResult(
  () async { ... },
  restriction: isOffline,  // Can't fetch when offline
);

// ifRestrictedRunInstead - alternative action when restricted
final cmd = Command.createAsyncNoParamNoResult(
  () async { ... },
  restriction: someCondition,
  ifRestrictedRunInstead: () => showToast('Cannot run now'),
);

Error Handling

ErrorFilter returns ErrorReaction enum:

enum ErrorReaction {
  none,                        // Swallow error
  throwException,              // Rethrow
  globalHandler,               // Only global handler
  localHandler,                // Only local listeners
  localAndGlobalHandler,       // Both
  firstLocalThenGlobalHandler, // Local first, global if no local (DEFAULT)
  noHandlersThrowException,    // Throw if no handlers at all
  throwIfNoLocalHandler,       // Throw if no local handler
}

Built-in filters:

// Default - local first, global as fallback
errorFilter: const GlobalIfNoLocalErrorFilter(),

// Local listeners only, no Sentry/global
errorFilter: const LocalErrorFilter(),

// Both local and global always
errorFilter: const LocalAndGlobalErrorFilter(),

// Global only (e.g., Sentry logging, no UI)
errorFilter: const GlobalErrorFilter(),

Custom filter function:

errorFilterFn: (Object error, StackTrace stackTrace) {
  if (error is ApiException && error.code == 404) {
    return ErrorReaction.localHandler;  // UI handles 404
  }
  return ErrorReaction.firstLocalThenGlobalHandler;  // Default for rest
},

Custom filter class:

class MyErrorFilter implements ErrorFilter {
  
  ErrorReaction filter(Object error, StackTrace stackTrace) {
    if (error is ApiException && error.code == 404) {
      return ErrorReaction.localAndGlobalHandler;
    }
    return ErrorReaction.globalHandler;
  }
}

Global exception handler (static property, not method):

Command.globalExceptionHandler = (CommandError error, StackTrace stackTrace) {
  Sentry.captureException(error.error, stackTrace: stackTrace);
};

Global error stream - Command.globalErrors is a Stream<CommandError> of all globally-handled errors. Use registerStreamHandler in your root widget to show toasts for errors not handled locally:

// In root widget (e.g. MyApp)
registerStreamHandler(
  target: Command.globalErrors,
  handler: (context, snapshot, cancel) {
    if (snapshot.hasData) showErrorToast(context, snapshot.data!.error);
  },
);

Listening to errors: .errors is ValueListenable<CommandError?> — the static type is nullable. At runtime, handlers only fire with actual CommandError objects (null resets don't trigger handlers). Use error! to promote — no null check needed (unless you call clearErrors()).

// With listen_it
command.errors.listen((error, subscription) {
  showErrorDialog(error.error);  // listen_it skips null emissions
});

// With watch_it registerHandler — use error! to promote (handler never called with null)
registerHandler(
  select: (MyManager m) => m.deleteCommand.errors,
  handler: (context, error, cancel) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Delete failed: ${error!.error}')),
    );
  },
);

Static Configuration

Command.globalExceptionHandler = ...;          // Global error callback
Command.errorFilterDefault = LocalErrorFilter(); // Change default filter
Command.globalErrors;                          // Stream<CommandError> of all errors
Command.loggingHandler = (name, result) { };   // Log all command executions
Command.assertionsAlwaysThrow = true;          // Default: true
Command.reportAllExceptions = false;           // Default: false
Command.detailedStackTraces = true;            // Default: true

Production Patterns

Async command with error filter:

late final getListingPreviewCommand =
    Command.createAsync<GetListingPreviewRequest, SellerFeesDto?>(
  (request) async {
    final api = MarketplaceApi(di<ApiClient>());
    return await api.getListingPreview(request);
  },
  debugName: 'getListingPreview',
  initialValue: null,
  errorFilter: const GlobalIfNoLocalErrorFilter(),
);

Undoable delete with recovery:

deletePostCommand = Command.createUndoableNoResult<PostProxy, PostProxy>(
  (post, undoStack) async {
    undoStack.push(post);
    await PostApi(di<ApiClient>()).deletePost(post.id);
  },
  undo: (stack, error) {
    final post = stack.pop();
    di<EventBus>().sendUndoDeletePostEvent(post);
  },
  errorFilter: const GlobalIfNoLocalErrorFilter(),
);

Restriction chaining:

late final updateAvatarCommand = Command.createAsyncNoResult<File>(
  (file) async { ... },
  restriction: updateFromBackendCommand.isRunning,
);

Custom error filter hierarchy:

class LocalOnlyErrorFilter implements ErrorFilter {
  
  ErrorReaction filter(Object error, StackTrace stackTrace) {
    return ErrorReaction.localHandler;
  }
}

class Api404ToSentry403LocalErrorFilter implements ErrorFilter {
  
  ErrorReaction filter(Object error, StackTrace stackTrace) {
    if (error is ApiException) {
      if (error.code == 404) return ErrorReaction.localAndGlobalHandler;
      if (error.code == 403) return ErrorReaction.localHandler;
    }
    return ErrorReaction.globalHandler;
  }
}

Reacting to Command Completion

A Command is itself a ValueListenable. There are three levels of observation:

// ✅ Watch the command itself — fires ONLY on successful completion
registerHandler(
  select: (MyManager m) => m.myCommand,
  handler: (context, _, __) {
    navigateAway();  // Only called on success
  },
);

// ✅ Watch .errors — fires ONLY on errors
registerHandler(
  select: (MyManager m) => m.myCommand.errors,
  handler: (context, error, _) {
    showError(error!.error.toString());
  },
);

// Watch .results — fires on EVERY state change (isRunning, success, error)
// Use result.isSuccess / result.hasError / result.isRunning to distinguish
registerHandler(
  select: (MyManager m) => m.myCommand.results,
  handler: (context, result, _) {
    if (result.isSuccess) { ... }
    if (result.hasError) { ... }
    if (result.isRunning) { ... }
  },
);

Prefer watching the command itself for success and .errors for failures. Only use .results when you need to react to all state transitions.

// ❌ DON'T use isRunning to detect success — fragile and ambiguous
registerHandler(
  select: (MyManager m) => m.myCommand.isRunning,
  handler: (context, isRunning, _) {
    if (!isRunning && noError) { ... }  // Easy to get wrong
  },
);

// ✅ DO watch the command itself
registerHandler(
  select: (MyManager m) => m.myCommand,
  handler: (context, _, __) { ... },  // Only fires on success
);

Anti-Patterns

// ❌ Using deprecated execute()
command.execute();
// ✅ Use run()
command.run();

// ❌ Accessing isRunning on sync command
final cmd = Command.createSyncNoParamNoResult(() => print('hi'));
cmd.isRunning;  // ASSERTION ERROR
// ✅ Use async command for loading states
final cmd = Command.createAsyncNoParamNoResult(() async => print('hi'));
cmd.isRunning;  // Works

// ❌ Error filter returning bool
errorFilter: (error, hasLocal) => true  // WRONG TYPE
// ✅ Return ErrorReaction enum
errorFilterFn: (error, stackTrace) => ErrorReaction.localHandler

// ❌ try/catch inside command body — commands handle errors automatically
late final saveCommand = Command.createAsyncNoParamNoResult(() async {
  try {
    await api.save();
  } catch (e) {
    cleanup();
    rethrow;
  }
});
// ✅ Use .errors.listen() for side effects on error
late final saveCommand = Command.createAsyncNoParamNoResult(
  () async => await api.save(),
)..errors.listen((_, _) => cleanup());
Weekly Installs
2
GitHub Stars
13
First Seen
2 days ago
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2