Versions Covered: v8.0.3 (patch), v8.1.0 (minor), v9.0.0 (major) Total Estimated Time: 17.5-19.5 hours Status: v8.0.3 complete, v8.1.0 pending
This plan covers two releases incorporating both new features and critical fixes from the November 2025 code review:
- Fix unsafe type casts
- Improve documentation of core methods
- Improve error handling documentation
- Hybrid Error Filtering - Function-based error filters with type safety
- Test Coverage - Comprehensive tests for UndoableCommand, CommandBuilder, disposal
- Performance - Optimize error handling and stack trace capture
- API Enhancements - ErrorFilter composition utilities and common filters
See COMMAND_EXTENSIONS_DESIGN.md and API_PROPOSAL_v8.1_v9.0.md for lifecycle hooks, ErrorHandlerRegistry, and RetryableCommand.
Estimated Time: 1.5 hours Complexity: Low Breaking Changes: None
File: lib/command_it.dart
Lines: 232, 236
Time: 5 minutes
Severity: HIGH - Runtime crash risk
Current Code:
_canExecute = (_restriction == null)
? _isExecuting.map((val) => !val) as ValueNotifier<bool>
: _restriction.combineLatest<bool, bool>(
_isExecuting,
(restriction, isExecuting) => !restriction && !isExecuting,
) as ValueNotifier<bool>;Fix: Change internal field type
// Line 555 - Change field type
late ValueListenable<bool> _canExecute; // Was: ValueNotifier<bool>
// Lines 232, 236 - Remove casts
_canExecute = (_restriction == null)
? _isExecuting.map((val) => !val)
: _restriction.combineLatest<bool, bool>(
_isExecuting,
(restriction, isExecuting) => !restriction && !isExecuting,
);Impact: None - getters already return ValueListenable
Status: ✅ COMPLETED
Note on Future Completion Safety: This fix was initially planned but rejected after analysis. The success and error paths are mutually exclusive (try/catch), and there's no code path that could complete the same future twice. Adding isCompleted checks would mask bugs (double disposal, reentrancy issues) that should fail loudly during development. The dispose path (line 538) legitimately has the check because it's cleanup code.
Note on Print Statements: MockCommand print statements were also considered for removal but rejected. They serve legitimate debugging purposes (execution confirmation + misconfiguration warnings) and are already marked as intentional with // ignore: avoid_print. Not worth the breaking change to MockCommand API.
File: lib/command_it.dart
Line: 245
Time: 30 minutes
Severity: MEDIUM - Critical method lacks docs
Status: ✅ COMPLETED
Add comprehensive doc comment:
/// Executes the wrapped command function with optional [param].
///
/// The execution follows this flow:
/// 1. Checks if command is disposed
/// 2. Validates restriction (if any)
/// 3. Ensures not already executing (async commands only)
/// 4. Executes wrapped function
/// 5. Updates all ValueListenables with results
/// 6. Handles any errors via ErrorFilter system
///
/// For async commands, this method:
/// - Sets [isExecuting] to true before execution
/// - Updates [results] with loading state
/// - Sets [isExecuting] to false after completion
///
/// For sync commands:
/// - Executes immediately
/// - No [isExecuting] updates (throws assertion if accessed)
///
/// Errors are handled according to [errorFilter] configuration.
/// See [ErrorReaction] for available error handling strategies.
///
/// Use [executeWithFuture] if you need to await the result.
void execute([TParam? param]) async {
// ...
}Also document: _handleErrorFiltered, _mandatoryErrorHandling, _improveStacktrace
File: README.md
Time: 1 hour
Severity: MEDIUM - Complex flow undocumented
Status: ✅ COMPLETED
Add section after line ~264 (after Error Handling section):
### Error Handling Flow
When a command throws an error, it flows through multiple stages:
Error Occurs in Command ↓
- Mandatory Error Handling ├─ AssertionError? → Throw (if assertionsAlwaysThrow) ├─ reportAllExceptions? → Call globalExceptionHandler └─ Continue to filtering ↓
- Error Filter Evaluation ├─ Call command's errorFilter ├─ If returns defaulErrorFilter → Use errorFilterDefault └─ Get ErrorReaction ↓
- React Based on ErrorReaction ├─ none → Swallow error ├─ throwException → Rethrow ├─ localHandler → Notify .errors listeners ├─ globalHandler → Call globalExceptionHandler ├─ localAndGlobalHandler → Both ├─ firstLocalThenGlobalHandler → Local, fallback to global └─ ... (see ErrorReaction enum) ↓
- Update State ├─ Push to .results (if configured) └─ Emit to .errors (if local handling)
**Key points:**
- Errors always update `.results.value.hasError`
- Local handlers subscribe to `.errors` ValueListenable
- Global handler is the static `Command.globalExceptionHandler`
- `ErrorFilter` controls routing, not handling
Files Modified: 2
lib/command_it.dart(2 changes: unsafe casts, execute() documentation)README.md(2 changes: error handling config section, image URL fix)
Breaking Changes: None
Testing:
- All existing tests must pass
- No new tests required (fixes only)
Estimated Time: 16-20 hours Complexity: Medium Breaking Changes: None
This release contains three major areas:
- Hybrid Error Filtering (6.5 hours) - Function-based error filters
- Test Coverage Improvements (6-7 hours) - Fill gaps from code review
- Performance & API Enhancements (3-4 hours) - Optimizations and utilities
Files: lib/command_it.dart
Time: 30 minutes
Status: Not started
Location: lib/command_it.dart (after imports, before ErrorReaction enum)
// Add after imports, around line 13
typedef ErrorFilterFn = ErrorReaction? Function(
Object error,
StackTrace stackTrace,
);Rationale: Define function signature explicitly for type safety.
Location: lib/command_it.dart:169 (Command constructor)
Current code:
Command({
required TResult initialValue,
required ValueListenable<bool>? restriction,
required ExecuteInsteadHandler<TParam>? ifRestrictedExecuteInstead,
required bool includeLastResultInCommandResults,
required bool noReturnValue,
required bool notifyOnlyWhenValueChanges,
ErrorFilter? errorFilter, // ← Current parameter
required String? name,
required bool noParamValue,
}) : _errorFilter = errorFilter ?? errorFilterDefault,
// ...New code:
Command({
required TResult initialValue,
required ValueListenable<bool>? restriction,
required ExecuteInsteadHandler<TParam>? ifRestrictedExecuteInstead,
required bool includeLastResultInCommandResults,
required bool noReturnValue,
required bool notifyOnlyWhenValueChanges,
ErrorFilter? errorFilter, // ← Keep for backward compatibility
ErrorFilterFn? errorFilterFn, // ← Add new parameter
required String? name,
required bool noParamValue,
}) : _errorFilterFn = _resolveErrorFilter(errorFilter, errorFilterFn),
_restriction = restriction,
_ifRestrictedExecuteInstead = ifRestrictedExecuteInstead,
_noReturnValue = noReturnValue,
_noParamValue = noParamValue,
_includeLastResultInCommandResults = includeLastResultInCommandResults,
_name = name,
super(
initialValue,
mode: notifyOnlyWhenValueChanges
? CustomNotifierMode.normal
: CustomNotifierMode.always,
) {
assert(
errorFilter == null || errorFilterFn == null,
'Cannot provide both errorFilter and errorFilterFn. '
'Use errorFilter for objects (e.g., RetryErrorFilter) or '
'errorFilterFn for functions.',
);
// ... rest of constructor body (lines 193-243 unchanged)
}Location: lib/command_it.dart:555
Current code:
final ErrorFilter _errorFilter;New code:
final ErrorFilterFn _errorFilterFn;Location: lib/command_it.dart (after constructor, before execute method)
Insert after line ~243, before void execute([TParam? param]):
/// Converts either ErrorFilter object or ErrorFilterFn function to
/// internal function representation.
///
/// Priority:
/// 1. If [objectFilter] provided, convert it to function
/// 2. Else if [functionFilter] provided, use it directly
/// 3. Else use global default filter
static ErrorFilterFn _resolveErrorFilter(
ErrorFilter? objectFilter,
ErrorFilterFn? functionFilter,
) {
if (objectFilter != null) {
// Convert object to function
return (error, stackTrace) {
final reaction = objectFilter.filter(error, stackTrace);
// Convert defaulErrorFilter to null for consistency
return reaction == ErrorReaction.defaulErrorFilter ? null : reaction;
};
}
if (functionFilter != null) {
// Use function directly
return functionFilter;
}
// Use global default
return (error, stackTrace) {
final reaction = errorFilterDefault.filter(error, stackTrace);
return reaction == ErrorReaction.defaulErrorFilter ? null : reaction;
};
}Location: lib/command_it.dart:608
Current code:
void _handleErrorFiltered(
TParam? param,
Object error,
StackTrace stackTrace,
) {
var errorReaction = _errorFilter.filter(error, stackTrace);
if (errorReaction == ErrorReaction.defaulErrorFilter) {
errorReaction = errorFilterDefault.filter(error, stackTrace);
}
// ... rest of method
}New code:
void _handleErrorFiltered(
TParam? param,
Object error,
StackTrace stackTrace,
) {
// Call filter function (works for both converted objects and raw functions)
var errorReaction = _errorFilterFn(error, stackTrace);
// null means no match, apply default filter
if (errorReaction == null) {
final defaultReaction = errorFilterDefault.filter(error, stackTrace);
// Default filter should never return defaulErrorFilter, but handle it just in case
errorReaction = defaultReaction == ErrorReaction.defaulErrorFilter
? ErrorReaction.firstLocalThenGlobalHandler
: defaultReaction;
}
// ... rest of method unchanged (lines 617-705)
}Testing checkpoint: After these changes, existing tests should still pass.
Files: lib/command_it.dart
Time: 1 hour
Status: Not started
Each factory method needs:
- Add
ErrorFilterFn? errorFilterFnparameter (afterErrorFilter? errorFilter) - Pass both to constructor
Location: lib/command_it.dart:800
Current signature:
static Command<void, void> createSyncNoParamNoResult(
void Function() action, {
ValueListenable<bool>? restriction,
void Function()? ifRestrictedExecuteInstead,
ErrorFilter? errorFilter,
bool notifyOnlyWhenValueChanges = false,
String? debugName,
})New signature:
static Command<void, void> createSyncNoParamNoResult(
void Function() action, {
ValueListenable<bool>? restriction,
void Function()? ifRestrictedExecuteInstead,
ErrorFilter? errorFilter,
ErrorFilterFn? errorFilterFn, // ← Add this
bool notifyOnlyWhenValueChanges = false,
String? debugName,
})Constructor call update:
return CommandSync<void, void>(
funcNoParam: action,
initialValue: null,
restriction: restriction,
ifRestrictedExecuteInstead: ifRestrictedExecuteInstead != null
? (_) => ifRestrictedExecuteInstead()
: null,
includeLastResultInCommandResults: false,
noReturnValue: true,
errorFilter: errorFilter,
errorFilterFn: errorFilterFn, // ← Add this
notifyOnlyWhenValueChanges: notifyOnlyWhenValueChanges,
name: debugName,
noParamValue: true,
);Apply same pattern to:
- ✅
createSyncNoParamNoResult(line 800) createSyncNoResult<TParam>(line 849)createSyncNoParam<TResult>(line 898)createSync<TParam, TResult>(line 952)createAsyncNoParamNoResult(line 1000)createAsyncNoResult<TParam>(line 1046)createAsyncNoParam<TResult>(line 1092)createAsync<TParam, TResult>(line 1142)createUndoableNoParamNoResult<TUndoState>(line 1191)createUndoableNoResult<TParam, TUndoState>(line 1244)createUndoableNoParam<TResult, TUndoState>(line 1296)createUndoable<TParam, TResult, TUndoState>(line 1352)
Script to help (can be run manually):
# Search for all factory methods
cd /home/escamoteur/dev/flutter_it/command_it
grep -n "static Command.*create" lib/command_it.dartFiles: lib/sync_command.dart, lib/async_command.dart
Time: 20 minutes
Status: Not started
File: lib/sync_command.dart:13
Current code:
CommandSync({
TResult Function(TParam)? func,
TResult Function()? funcNoParam,
required super.initialValue,
required super.restriction,
required super.ifRestrictedExecuteInstead,
required super.includeLastResultInCommandResults,
required super.noReturnValue,
required super.errorFilter,
required super.notifyOnlyWhenValueChanges,
required super.name,
required super.noParamValue,
}) : _func = func,
_funcNoParam = funcNoParam;New code:
CommandSync({
TResult Function(TParam)? func,
TResult Function()? funcNoParam,
required super.initialValue,
required super.restriction,
required super.ifRestrictedExecuteInstead,
required super.includeLastResultInCommandResults,
required super.noReturnValue,
required super.errorFilter,
required super.errorFilterFn, // ← Add this
required super.notifyOnlyWhenValueChanges,
required super.name,
required super.noParamValue,
}) : _func = func,
_funcNoParam = funcNoParam;File: lib/async_command.dart:7
Apply same change as CommandSync.
Files: lib/undoable_command.dart
Time: 15 minutes
Status: Not started
Location: lib/undoable_command.dart:54
Current constructor parameters:
UndoableCommand({
// ... other params
required super.errorFilter,
// ... other params
})New constructor parameters:
UndoableCommand({
// ... other params
required super.errorFilter,
required super.errorFilterFn, // ← Add this
// ... other params
})Files: lib/mock_command.dart
Time: 10 minutes
Status: Not started
Location: lib/mock_command.dart:15
Add super.errorFilterFn parameter similar to other subclasses.
Files: test/error_filter_function_test.dart (new file)
Time: 1.5 hours
Status: Not started
File: test/error_filter_function_test.dart
// ignore_for_file: avoid_print
import 'package:command_it/command_it.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ErrorFilterFn - Basic Functionality', () {
test('Function filter works with simple lambda', () async {
int executionCount = 0;
final command = Command.createAsync<void, int>(
() async {
executionCount++;
throw Exception('Test error');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) {
return error is Exception
? ErrorReaction.localHandler
: null;
},
);
bool errorHandlerCalled = false;
command.errors.listen((error, _) {
if (error != null) {
errorHandlerCalled = true;
}
});
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(errorHandlerCalled, true);
expect(executionCount, 1);
});
test('Function filter returns null for no match', () async {
final command = Command.createAsync<void, int>(
() async {
throw ArgumentError('Test error');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) {
// Only handle Exception, not ArgumentError
return error is Exception && error is! ArgumentError
? ErrorReaction.localHandler
: null;
},
);
// Should fall back to default filter
command.execute();
await Future.delayed(Duration(milliseconds: 100));
// Default behavior should apply
expect(command.results.value.hasError, true);
});
test('Named function works as filter', () async {
ErrorReaction? myFilter(Object error, StackTrace stackTrace) {
if (error is TimeoutException) {
return ErrorReaction.globalHandler;
}
return null;
}
bool globalHandlerCalled = false;
Command.globalExceptionHandler = (error, stackTrace) {
globalHandlerCalled = true;
};
final command = Command.createAsync<void, int>(
() async {
throw TimeoutException('Timeout');
},
initialValue: 0,
errorFilterFn: myFilter,
);
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(globalHandlerCalled, true);
Command.globalExceptionHandler = null; // Cleanup
});
});
group('ErrorFilterFn - Type Safety', () {
test('Correct function signature compiles', () {
// This should compile
final command = Command.createAsync<void, int>(
() async => 42,
initialValue: 0,
errorFilterFn: (Object error, StackTrace stackTrace) {
return ErrorReaction.localHandler;
},
);
expect(command, isNotNull);
});
test('Dynamic parameters work (covariant)', () {
// dynamic is supertype of Object, should work
final command = Command.createAsync<void, int>(
() async => 42,
initialValue: 0,
errorFilterFn: (dynamic error, dynamic stackTrace) {
return ErrorReaction.localHandler;
},
);
expect(command, isNotNull);
});
// Note: Wrong signatures will cause compile errors, not runtime errors
// These are tested by attempting to compile and verifying errors
});
group('ErrorFilterFn - Integration with ErrorFilter', () {
test('Cannot provide both errorFilter and errorFilterFn', () {
expect(
() => Command.createAsync<void, int>(
() async => 42,
initialValue: 0,
errorFilter: const ErrorHandlerLocal(),
errorFilterFn: (e, s) => ErrorReaction.global,
),
throwsAssertionError,
);
});
test('errorFilter still works (backward compatibility)', () async {
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilter: const ErrorHandlerLocal(),
);
bool errorHandlerCalled = false;
command.errors.listen((error, _) {
if (error != null) {
errorHandlerCalled = true;
}
});
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(errorHandlerCalled, true);
});
test('errorFilterFn takes precedence when only it is provided', () async {
bool functionFilterUsed = false;
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) {
functionFilterUsed = true;
return ErrorReaction.localHandler;
},
);
command.errors.listen((error, _) {});
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(functionFilterUsed, true);
});
});
group('ErrorFilterFn - All ErrorReaction Types', () {
test('ErrorReaction.none works', () async {
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) => ErrorReaction.none,
);
command.execute();
await Future.delayed(Duration(milliseconds: 100));
// Error should be swallowed
expect(command.results.value.hasError, false);
});
test('ErrorReaction.throwException works', () async {
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) => ErrorReaction.throwException,
);
// Should rethrow
expect(
() => command.execute(),
throwsA(isA<Exception>()),
);
});
test('ErrorReaction.globalHandler works', () async {
bool globalCalled = false;
Command.globalExceptionHandler = (error, stackTrace) {
globalCalled = true;
};
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) => ErrorReaction.globalHandler,
);
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(globalCalled, true);
Command.globalExceptionHandler = null; // Cleanup
});
test('ErrorReaction.localHandler works', () async {
bool localCalled = false;
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) => ErrorReaction.localHandler,
);
command.errors.listen((error, _) {
if (error != null) localCalled = true;
});
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(localCalled, true);
});
});
group('ErrorFilterFn - Composition Patterns', () {
test('Can compose multiple filter functions', () async {
ErrorFilterFn filter1 = (e, s) =>
e is TimeoutException ? ErrorReaction.global : null;
ErrorFilterFn filter2 = (e, s) =>
e is ArgumentError ? ErrorReaction.local : null;
ErrorFilterFn combined = (e, s) => filter1(e, s) ?? filter2(e, s);
final command = Command.createAsync<void, int>(
() async {
throw TimeoutException('Test');
},
initialValue: 0,
errorFilterFn: combined,
);
bool globalCalled = false;
Command.globalExceptionHandler = (error, stackTrace) {
globalCalled = true;
};
command.execute();
await Future.delayed(Duration(milliseconds: 100));
expect(globalCalled, true);
Command.globalExceptionHandler = null;
});
});
group('ErrorFilterFn - Edge Cases', () {
test('Null return falls back to default filter', () async {
final command = Command.createAsync<void, int>(
() async {
throw Exception('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) => null, // Always return null
);
command.errors.listen((error, _) {});
command.execute();
await Future.delayed(Duration(milliseconds: 100));
// Default filter should handle it
expect(command.results.value.hasError, true);
});
test('Function filter works with all command types', () {
// Sync
expect(
() => Command.createSync<void, int>(
(x) => 42,
initialValue: 0,
errorFilterFn: (e, s) => ErrorReaction.local,
),
returnsNormally,
);
// Async
expect(
() => Command.createAsync<void, int>(
(x) async => 42,
initialValue: 0,
errorFilterFn: (e, s) => ErrorReaction.local,
),
returnsNormally,
);
// Undoable
expect(
() => Command.createUndoable<void, int, String>(
(x, stack) async => 42,
initialValue: 0,
undo: (stack, result) async {},
errorFilterFn: (e, s) => ErrorReaction.local,
),
returnsNormally,
);
});
});
}File: test/flutter_command_test.dart
Add section at end:
group('ErrorFilterFn Integration Tests', () {
test('Function filter integrates with existing error test infrastructure', () {
// Use existing Collector pattern
final Collector<CommandError> errorCollector = Collector<CommandError>();
final command = Command.createAsync<void, int>(
() async {
throw CustomException('Test');
},
initialValue: 0,
errorFilterFn: (error, stackTrace) {
return error is CustomException
? ErrorReaction.localHandler
: null;
},
);
command.errors.listen((error, _) => errorCollector(error!));
command.execute();
// Test continues...
});
});Files: lib/error_filters.dart
Time: 45 minutes
Status: Not started
Location: End of lib/error_filters.dart
/// Combines multiple [ErrorFilterFn] functions into one.
///
/// Returns the first non-null [ErrorReaction] from the list of filters.
/// If all filters return null, returns null (falls back to default).
///
/// Example:
/// ```dart
/// errorFilterFn: combine([
/// (e, s) => e is NetworkException ? ErrorReaction.global : null,
/// (e, s) => e is TimeoutException ? ErrorReaction.local : null,
/// (e, s) => ErrorReaction.firstLocalThenGlobalHandler, // Fallback
/// ])
/// ```
ErrorFilterFn combine(List<ErrorFilterFn> filters) {
return (error, stackTrace) {
for (final filter in filters) {
final reaction = filter(error, stackTrace);
if (reaction != null) return reaction;
}
return null;
};
}
/// Converts an [ErrorFilter] object to [ErrorFilterFn] function.
///
/// Useful for mixing objects and functions in compositions.
///
/// Example:
/// ```dart
/// errorFilterFn: combine([
/// toFunction(const ErrorHandlerLocal()),
/// (e, s) => e is NetworkException ? ErrorReaction.global : null,
/// ])
/// ```
ErrorFilterFn toFunction(ErrorFilter filter) {
return (error, stackTrace) {
final reaction = filter.filter(error, stackTrace);
return reaction == ErrorReaction.defaulErrorFilter ? null : reaction;
};
}
/// Converts an [ErrorFilterFn] function to [ErrorFilter] object.
///
/// Useful if you need an object but have a function.
ErrorFilter toObject(ErrorFilterFn fn) {
return _FunctionErrorFilter(fn);
}
class _FunctionErrorFilter implements ErrorFilter {
final ErrorFilterFn fn;
const _FunctionErrorFilter(this.fn);
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
return fn(error, stackTrace) ?? ErrorReaction.defaulErrorFilter;
}
}
/// Creates a filter that only handles errors of type [T].
///
/// Example:
/// ```dart
/// errorFilterFn: typeFilter<NetworkException>(ErrorReaction.global)
/// ```
ErrorFilterFn typeFilter<T>(ErrorReaction reaction) {
return (error, stackTrace) {
return error is T ? reaction : null;
};
}
/// Creates a filter that handles errors matching a predicate.
///
/// Example:
/// ```dart
/// errorFilterFn: predicateFilter(
/// (e) => e is NetworkException && e.statusCode == 401,
/// ErrorReaction.localHandler,
/// )
/// ```
ErrorFilterFn predicateFilter(
bool Function(Object error) predicate,
ErrorReaction reaction,
) {
return (error, stackTrace) {
return predicate(error) ? reaction : null;
};
}File: lib/command_it.dart
Ensure these are exported:
export 'package:command_it/error_filters.dart';Already exported, so new functions will be available automatically.
Files: README.md, CHANGELOG.md, inline docs
Time: 1 hour
Status: Not started
Location: Top of CHANGELOG.md
## [8.1.0] - 2025-11-XX
### Added
- **Function-based error filters**: You can now pass error filter functions directly via the new `errorFilterFn` parameter
```dart
// New: Function filter
errorFilterFn: (error, stackTrace) =>
error is NetworkException ? ErrorReaction.global : null
// Still supported: Object filter
errorFilter: const RetryErrorFilter(maxRetries: 3)- New helper functions for filter composition:
combine(List<ErrorFilterFn>)- Combine multiple filterstoFunction(ErrorFilter)- Convert object to functiontoObject(ErrorFilterFn)- Convert function to objecttypeFilter<T>(ErrorReaction)- Type-based filteringpredicateFilter(predicate, ErrorReaction)- Predicate-based filtering
- Comprehensive compile-time type checking for function filters
- Function filters return
nullfor "no match" instead ofErrorReaction.defaulErrorFilter
- Internal error handling now uses function representation (transparent to users)
- Error filter resolution now supports both objects and functions
- Nothing deprecated in this release (fully backward compatible)
- None
- Added section on function-based error filters to README
- Added examples showing both object and function approaches
- Updated API documentation with function filter signatures
### Step 8.2: Update README.md
**Location**: After "Error Handling" section (around line 264)
```markdown
### Error Filtering with Functions (New in v8.1.0)
In addition to error filter objects, you can now use functions for more concise error handling:
```dart
// Function filter - inline logic
final command = Command.createAsync<String, List<Data>>(
fetchData,
[],
errorFilterFn: (error, stackTrace) {
if (error is NetworkException) {
if (error.statusCode == 401) {
showLoginDialog();
return ErrorReaction.localHandler;
}
return ErrorReaction.globalHandler;
}
return null; // No match, use default filter
},
);
When to use objects vs functions:
Use errorFilter (objects) when:
- Filter has configuration parameters (e.g.,
RetryErrorFilter(maxRetries: 3)) - Filter is reused across multiple commands
- You want const optimization
Use errorFilterFn (functions) when:
- One-off custom logic specific to this command
- Simple inline filtering is sufficient
- You prefer functional style
Composition helpers:
// Combine multiple filters
errorFilterFn: combine([
typeFilter<NetworkException>(ErrorReaction.global),
typeFilter<TimeoutException>(ErrorReaction.local),
(e, s) => ErrorReaction.firstLocalThenGlobalHandler, // Fallback
])
// Mix objects and functions
errorFilterFn: combine([
toFunction(const ErrorHandlerLocal()),
(e, s) => e is NetworkException ? ErrorReaction.global : null,
])For more details, see the error filtering documentation.
### Step 8.3: Update factory method documentation
**Location**: Each factory method doc comment
Add to each factory method's documentation (template):
```dart
/// [errorFilter] : ErrorFilter object for reusable error handling logic.
/// [errorFilterFn] : Function for inline error handling logic.
/// You can provide either [errorFilter] OR [errorFilterFn], not both.
///
/// The function signature is:
/// ```dart
/// ErrorReaction? Function(Object error, StackTrace stackTrace)
/// ```
/// Return [ErrorReaction] to handle the error, or `null` to delegate to default filter.
Time: 1 hour Status: Not started
cd /home/escamoteur/dev/flutter_it/command_it
flutter testExpected: All existing tests should pass (backward compatible change).
flutter test test/error_filter_function_test.dartExpected: All new tests should pass.
flutter analyzeExpected: No errors or warnings.
dart format lib/ test/flutter test --coverage
genhtml coverage/lcov.info -o coverage/htmlGoal: Maintain or improve existing coverage (~80%+).
Time: 30 minutes Status: Not started
File: pubspec.yaml
version: 8.1.0cd example
flutter analyze
flutter run --no-pubFile: example/lib/function_filter_example.dart (new)
import 'package:command_it/command_it.dart';
void main() {
// Example 1: Simple type-based filtering
final simpleCommand = Command.createAsync<String, String>(
(param) async {
// Simulate network call that might fail
throw NetworkException('Connection failed');
},
initialValue: '',
errorFilterFn: (error, stackTrace) {
return error is NetworkException
? ErrorReaction.globalHandler
: null;
},
);
// Example 2: Complex custom logic
final complexCommand = Command.createAsync<String, String>(
(param) async {
throw CustomException(401, 'Unauthorized');
},
initialValue: '',
errorFilterFn: (error, stackTrace) {
if (error is CustomException) {
if (error.statusCode == 401) {
// Show login dialog
return ErrorReaction.localHandler;
}
if (error.statusCode >= 500) {
// Server error - log it
return ErrorReaction.globalHandler;
}
}
return null; // Use default handling
},
);
// Example 3: Composition
final composedCommand = Command.createAsync<String, String>(
(param) async => 'Success',
initialValue: '',
errorFilterFn: combine([
typeFilter<NetworkException>(ErrorReaction.global),
typeFilter<TimeoutException>(ErrorReaction.local),
predicateFilter(
(e) => e.toString().contains('auth'),
ErrorReaction.localHandler,
),
(e, s) => ErrorReaction.firstLocalThenGlobalHandler, // Fallback
]),
);
}
class NetworkException implements Exception {
final String message;
NetworkException(this.message);
}
class CustomException implements Exception {
final int statusCode;
final String message;
CustomException(this.statusCode, this.message);
}git add .
git commit -m "Add function-based error filter support
- Add ErrorFilterFn typedef for type-safe function filters
- Add errorFilterFn parameter to all factory methods
- Add composition helpers (combine, typeFilter, predicateFilter)
- Add comprehensive tests for function filters
- Update documentation and examples
- Fully backward compatible (non-breaking change)
Closes #XXX"If in separate branch:
git push origin feature/function-error-filters
# Create PROr if ready to publish:
flutter pub publish --dry-run
# Review
flutter pub publishBefore marking complete:
- All existing tests pass
- New function filter tests pass
- Analyzer shows no errors/warnings
- Code is formatted
- Documentation updated
- CHANGELOG updated
- Examples compile and run
- Manual testing done:
- Object filter still works
- Function filter works
- Compilation error for wrong signature
- Both parameters assertion fires
- Composition helpers work
- Default filter fallback works
If issues are discovered after deployment:
- Revert the commit if breaking issues found
- Hotfix version: If minor issues, create 8.1.1 patch
- Documentation only: If just doc issues, update without version bump
| Phase | Time | Complexity |
|---|---|---|
| Phase 1: Base class | 30 min | Medium |
| Phase 2: Factory methods | 60 min | Low (repetitive) |
| Phase 3: Subclasses | 20 min | Low |
| Phase 4: UndoableCommand | 15 min | Low |
| Phase 5: MockCommand | 10 min | Low |
| Phase 6: Tests | 90 min | Medium |
| Phase 7: Helpers | 45 min | Medium |
| Phase 8: Documentation | 60 min | Low |
| Phase 9: Testing & fixes | 60 min | Variable |
| Phase 10: Final steps | 30 min | Low |
| Total | 6.5 hours | Medium |
Goal: Address critical test gaps identified in code review
File: test/undoable_command_test.dart (new file)
Time: 3-4 hours
Coverage Target: 90%+
Tests to Add:
-
Undo operation error handling
test('Undo operation that throws exception', () async { // Verify error from undo operation is handled properly // Should it call error filter? Should it rollback? });
-
Multiple undo operations
test('Multiple consecutive undo operations', () async { // Execute → undo → execute → undo → execute // Verify undo stack state at each step }); test('Undo all operations until stack empty', () async { // Perform 5 executions, then 5 undos // Verify final state matches initial state });
-
Undo stack limits
test('Undo stack overflow behavior', () async { // Execute 1000+ operations // Verify memory doesn't explode // Check oldest entries are removed }); test('Undo stack with max size parameter', () async { // If we add maxUndoStackSize parameter // Verify circular buffer behavior });
-
undoOnExecutionFailure variations
test('undoOnExecutionFailure = false, error occurs', () async { // Verify undo is NOT called }); test('undoOnExecutionFailure = true, error occurs', () async { // Verify undo IS called // Verify undo state is pushed });
-
Concurrent execution attempts
test('Attempt execution while previous execution pending', () async { // Should be blocked by isExecuting }); test('Attempt undo while execution pending', () async { // What should happen? Error? Queue? Block? });
-
Undo with complex state
test('Undo with large/complex TUndoState objects', () async { // Test serialization if needed }); test('Undo callback receives correct state snapshot', () async { // Verify state passed to undo callback matches execution snapshot });
Estimated Breakdown:
- Test file setup: 30 min
- 6 test categories × 30 min each: 3 hours
- Edge case coverage: 30 min
- Total: 4 hours
File: test/command_builder_test.dart (expand existing)
Time: 2 hours
Coverage Target: 90%+
Current State: Only 1 minimal test exists (line 1162)
Tests to Add:
-
State transition tests
testWidgets('Builder shows whileExecuting during execution', (tester) async { // Verify loading indicator appears }); testWidgets('Builder shows onData after success', (tester) async { // Verify data widget appears with correct value }); testWidgets('Builder shows onError after error', (tester) async { // Verify error widget appears with error object }); testWidgets('Builder shows onSuccess for void commands', (tester) async { // Verify success widget for commands with no return value });
-
Error cases
testWidgets('Builder with no onData or onSuccess throws', (tester) async { // Currently assertion-only, should be runtime error }); testWidgets('Builder handles null lastValue correctly', (tester) async { // First execution, no previous value });
-
includeLastResultInCommandResults
testWidgets('Builder retains last value during error', (tester) async { // Show stale data with error indicator }); testWidgets('Builder retains last value during loading', (tester) async { // Show stale data with loading indicator });
-
Rebuild optimization
testWidgets('Builder only rebuilds when command state changes', (tester) async { // Verify no unnecessary rebuilds });
Estimated Breakdown:
- Expand test file: 15 min
- 4 test categories × 30 min each: 2 hours
- Total: 2 hours
File: test/disposal_test.dart (new file)
Time: 1 hour
Coverage Target: 95%+
Tests to Add:
-
Dispose during execution
test('Dispose async command while executing', () async { // Start long-running command // Dispose command // Verify: isDisposing flag prevents notifications // Verify: Future completion handled gracefully }); test('Dispose sync command (immediate)', () { // Verify disposal completes immediately });
-
Double disposal
test('Call dispose() twice', () { // Should not throw // Should not dispose twice });
-
Access after disposal
test('Execute after disposal throws', () { // Should throw clear error }); test('Read properties after disposal', () { // What happens? Throw? Return stale? Define behavior });
-
Listener memory leaks
test('Listeners cleaned up after disposal', () async { // Add 100 listeners // Dispose command // Verify all listeners removed (no memory leak) // Use package:leak_tracker if available });
Estimated Breakdown:
- Test file setup: 15 min
- 4 test categories × 15 min each: 45 min
- Total: 1 hour
File: test/performance_test.dart (new file)
Time: 1 hour (if included)
Coverage Target: N/A (performance benchmarks)
Tests to Add:
-
Rapid execution
test('1000 rapid executions complete successfully', () async { // Stress test ValueListenable notifications });
-
Many listeners
test('100 concurrent listeners on one command', () async { // Verify no performance degradation });
-
Large parameters/results
test('Command with 10MB parameter object', () async { // Verify memory handling });
-
Memory leak detection
test('No memory leaks after 1000 command create/dispose cycles', () async { // Create command, use it, dispose, repeat // Monitor memory usage });
Note: These tests require performance monitoring infrastructure. Consider optional for v8.1.0.
| Test Suite | Time | Priority | New Tests |
|---|---|---|---|
| UndoableCommand | 4 hours | High | ~15 tests |
| CommandBuilder | 2 hours | High | ~8 tests |
| Disposal | 1 hour | High | ~6 tests |
| Performance | 1 hour | Low (optional) | ~4 tests |
| Total | 6-7 hours | - | ~30 tests |
Goal: Optimize hot paths and add missing ErrorFilter utilities
File: lib/error_filters.dart
Line: 117
Time: 15 minutes
Impact: Eliminates object allocation on every error
Current Code (creates Exception on every call):
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error.runtimeType == Exception().runtimeType) { // ← Bad!
return _table[Exception] ?? ErrorReaction.firstLocalThenGlobalHandler;
}
return _table[error.runtimeType] ?? ErrorReaction.defaulErrorFilter;
}Optimized Code:
class TableErrorFilter implements ErrorFilter {
static final _exceptionRuntimeType = Exception().runtimeType; // ← Cached
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error.runtimeType == _exceptionRuntimeType) {
return _table[Exception] ?? ErrorReaction.firstLocalThenGlobalHandler;
}
return _table[error.runtimeType] ?? ErrorReaction.defaulErrorFilter;
}
}File: lib/command_it.dart
Line: 253
Time: 1 hour
Impact: Reduces overhead when detailed traces not needed
Current Code (always captures if enabled):
if (Command.detailedStackTraces) {
_traceBeforeExecute = Trace.current(); // ← Always happens
}Options:
Option A: Lazy capture
// Don't capture unless error occurs
Trace? _getCapturedTrace() {
if (Command.detailedStackTraces && _traceBeforeExecute != null) {
return _traceBeforeExecute;
} else if (Command.detailedStackTraces) {
// Capture now (late, but better than nothing)
return Trace.current();
}
return null;
}Option B: Lite mode
static bool detailedStackTraces = true;
static bool liteStackTraces = false; // NEW: Capture only on error
// In execute():
if (Command.detailedStackTraces && !Command.liteStackTraces) {
_traceBeforeExecute = Trace.current();
}Recommendation: Option B (lite mode) - opt-in performance boost
Testing: Measure before/after performance in tight loop
File: lib/error_filters.dart
Time: 1.5 hours
Impact: Make complex filters easier to build
Add composition helpers (already partially in plan Phase 7):
/// Combines multiple ErrorFilterFn functions with OR logic.
/// Returns first non-null reaction, or null if all return null.
ErrorFilterFn combineOr(List<ErrorFilterFn> filters) {
return (error, stackTrace) {
for (final filter in filters) {
final reaction = filter(error, stackTrace);
if (reaction != null) return reaction;
}
return null;
};
}
/// Combines multiple ErrorFilterFn functions with AND logic.
/// All filters must agree (return same reaction) to match.
ErrorFilterFn combineAnd(List<ErrorFilterFn> filters) {
return (error, stackTrace) {
ErrorReaction? agreed;
for (final filter in filters) {
final reaction = filter(error, stackTrace);
if (reaction == null) return null; // One said no
if (agreed == null) {
agreed = reaction;
} else if (agreed != reaction) {
return null; // Disagreement
}
}
return agreed;
};
}
/// Inverts a filter (NOT logic).
ErrorFilterFn not(ErrorFilterFn filter, ErrorReaction elseReaction) {
return (error, stackTrace) {
final reaction = filter(error, stackTrace);
return reaction == null ? elseReaction : null;
};
}Add tests for composition logic (30 min)
File: lib/error_filters.dart
Time: 45 minutes
Impact: Reduce boilerplate for common cases
Add built-in filters for common scenarios:
/// Filter for network-related errors.
class NetworkErrorFilter implements ErrorFilter {
final ErrorReaction reaction;
const NetworkErrorFilter({
this.reaction = ErrorReaction.globalHandler,
});
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error is SocketException ||
error is HttpException ||
error.runtimeType.toString().contains('Network')) {
return reaction;
}
return ErrorReaction.defaulErrorFilter;
}
}
/// Filter for timeout errors.
class TimeoutErrorFilter implements ErrorFilter {
final ErrorReaction reaction;
const TimeoutErrorFilter({
this.reaction = ErrorReaction.localHandler,
});
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error is TimeoutException) {
return reaction;
}
return ErrorReaction.defaulErrorFilter;
}
}
/// Filter for validation/argument errors.
class ValidationErrorFilter implements ErrorFilter {
final ErrorReaction reaction;
const ValidationErrorFilter({
this.reaction = ErrorReaction.localHandler,
});
@override
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error is ArgumentError ||
error is FormatException ||
error is RangeError) {
return reaction;
}
return ErrorReaction.defaulErrorFilter;
}
}Add tests for each filter (15 min)
| Enhancement | Time | Impact |
|---|---|---|
| Exception type caching | 15 min | Low |
| Lazy stack traces | 1 hour | Medium |
| Composition utilities | 1.5 hours | High (usability) |
| Common error filters | 45 min | Medium (usability) |
| Total | 3-4 hours | - |
No new dependencies required. Uses existing:
flutterSDKlisten_it(already a dependency)test(dev dependency)
| Task | Time |
|---|---|
| Fix unsafe casts | 5 min |
| Fix Future completion | 10 min |
| Remove print statements | 20 min |
| Document execute() | 30 min |
| Document error flow (README) | 1 hour |
| Remove deprecated code | 10 min |
| Total | ~2-3 hours |
| Part | Time |
|---|---|
| Part A: Hybrid Error Filtering | 6.5 hours |
| Part B: Test Coverage | 6-7 hours |
| Part C: Performance & Enhancements | 3-4 hours |
| Total | 16-20 hours |
✅ Patch complete when:
- All 6 critical fixes implemented
- All existing tests pass
- Documentation improved
- Published to pub.dev
✅ Minor release complete when:
- All factory methods accept
errorFilterFnparameter - Type checking works at compile time
- All existing tests pass
- 30+ new tests added with >90% coverage
- UndoableCommand, CommandBuilder, disposal fully tested
- Performance optimizations implemented
- ErrorFilter composition utilities available
- Common error filters implemented
- Documentation includes examples for all new features
- Helper functions available and documented
- Backward compatible (no breaking changes)
- Published to pub.dev as v8.1.0
Recommended sequence:
-
v8.0.3 first (2-3 hours) - Critical fixes
- Get clean foundation
- Improve documentation
- Remove technical debt
-
v8.1.0 Part A (6.5 hours) - Hybrid error filtering
- Core feature implementation
- Enables function-based filters
-
v8.1.0 Part C (3-4 hours) - Performance & utilities
- While hybrid filtering fresh in mind
- Common filters use new errorFilterFn
-
v8.1.0 Part B (6-7 hours) - Test coverage
- Test everything together
- Catch integration issues
- Stress test the complete package
Total timeline: Can be done in 2-3 dedicated days or 1-2 weeks part-time.
- Non-breaking except for deprecated code removal
- MockCommand signature change only affects test code
- Safe to deploy immediately
- Non-breaking - all features are additive
- Function filters complement objects, don't replace them
- No deprecations in this version
- Maintains 100% backward compatibility
- Sets foundation for v9.0.0 breaking changes
- See
COMMAND_EXTENSIONS_DESIGN.mdfor lifecycle hooks - See
API_PROPOSAL_v8.1_v9.0.mdfor complete API - Will include ErrorHandlerRegistry, RetryableCommand, and lifecycle hooks
- Can remove typos (
defaulErrorFilter→defaultErrorFilter) - Can simplify factory method proliferation
- Remove deprecated
debugErrorsThrowAlways(deprecated since v8.0.0 - July 2025, ~4 months)- Lines 369-371: Remove usage in error handling
- Lines 471-474: Remove field declaration
- Migration: Use
reportAllExceptionsinstead