diff --git a/lib/src/command.dart b/lib/src/command.dart index ec3b0e6..37ee50a 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -137,6 +137,68 @@ sealed class Command extends ChangeNotifier metadata: metadata ?? {'reason': 'Command reset'}); } + /// Adds a listener that executes specific callbacks based on command state changes. + /// + /// This method provides a convenient way to listen to command state changes and react + /// with appropriate callbacks. Each state has its corresponding optional callback, and if no + /// callback is provided for the current state, the [orElse] callback will be executed if provided. + /// + /// The listener will be triggered immediately with the current state, and then every time + /// the command state changes. + /// + /// Returns a [VoidCallback] that can be called to remove the listener. + /// + /// Example: + /// ```dart + /// final command = Command0(() async { + /// return Success('Hello, World!'); + /// }); + /// + /// final removeListener = command.addWhenListener( + /// onIdle: () => print('Command is ready'), + /// onRunning: () => print('Command is executing'), + /// onSuccess: (value) => print('Success: $value'), + /// onFailure: (error) => print('Error: $error'), + /// onCancelled: () => print('Command was cancelled'), + /// orElse: () => print('Unknown state'), + /// ); + /// + /// // Later, remove the listener + /// removeListener(); + /// ``` + VoidCallback addWhenListener({ + void Function()? onIdle, + void Function()? onRunning, + void Function(T value)? onSuccess, + void Function(Exception? exception)? onFailure, + void Function()? onCancelled, + void Function()? orElse, + }) { + void listener() { + switch (value) { + case IdleCommand(): + (onIdle ?? orElse)?.call(); + case CancelledCommand(): + (onCancelled ?? orElse)?.call(); + case RunningCommand(): + (onRunning ?? orElse)?.call(); + case FailureCommand(:final error): + onFailure != null ? onFailure(error) : orElse?.call(); + case SuccessCommand(:final value): + onSuccess != null ? onSuccess(value) : orElse?.call(); + } + } + + // Execute immediately with current state + listener(); + + // Add listener for future state changes + addListener(listener); + + // Return a function to remove the listener + return () => removeListener(listener); + } + /// Executes the given [action] and updates the command state accordingly. /// /// The state transitions to [RunningCommand] during execution, diff --git a/test/src/command_test.dart b/test/src/command_test.dart index ca14ff6..3fcf52b 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -523,6 +523,179 @@ void main() { command1.execute().then((_) => command2.execute()); }); }); + + group('Command addWhenListener tests', () { + setUp(() { + // Reset the global observer to avoid interference with other tests + Command.setObserverListener((state) {}); + }); + + test('addWhenListener executes immediately with current state', () { + final command = Command0(() async => const Success('test')); + var idleCalled = false; + + command.addWhenListener(onIdle: () => idleCalled = true); + + expect(idleCalled, isTrue); + }); + + test('addWhenListener calls onSuccess when command succeeds', () async { + final command = Command0(() async => const Success('test value')); + var successCalled = false; + String? receivedValue; + + command.addWhenListener( + onSuccess: (value) { + successCalled = true; + receivedValue = value; + }, + ); + + await command.execute(); + + expect(successCalled, isTrue); + expect(receivedValue, equals('test value')); + }); + + test('addWhenListener calls onFailure when command fails', () async { + final testException = Exception('test error'); + final command = Command0(() async => Failure(testException)); + var failureCalled = false; + Exception? receivedException; + + command.addWhenListener( + onFailure: (exception) { + failureCalled = true; + receivedException = exception; + }, + ); + + await command.execute(); + + expect(failureCalled, isTrue); + expect(receivedException, equals(testException)); + }); + + test('addWhenListener calls onRunning during command execution', () async { + final command = Command0(() async { + await Future.delayed(const Duration(milliseconds: 50)); + return const Success('test'); + }); + var runningSeen = false; + + command.addWhenListener(onRunning: () => runningSeen = true); + + await command.execute(); + + expect(runningSeen, isTrue); + }); + + test('addWhenListener calls onCancelled when command is cancelled', () async { + final command = Command0(() async { + await Future.delayed(const Duration(seconds: 2)); + return const Success('test'); + }); + var cancelledCalled = false; + + command.addWhenListener(onCancelled: () => cancelledCalled = true); + + command.execute(); + command.cancel(); + + expect(cancelledCalled, isTrue); + }); + + test('addWhenListener calls orElse as fallback', () async { + final command = Command0(() async => const Success('test')); + var elseCalled = false; + + command.addWhenListener( + onFailure: (error) => {}, + orElse: () => elseCalled = true, + ); + + await command.execute(); + + expect(elseCalled, isTrue); + }); + + test('addWhenListener removes listener correctly', () async { + final command = Command0(() async => const Success('test')); + var callCount = 0; + + final removeListener = command.addWhenListener( + onSuccess: (value) => callCount++, + ); + + await command.execute(); + expect(callCount, equals(1)); + + removeListener(); + command.reset(); + await command.execute(); + + expect(callCount, equals(1)); // Should still be 1 because listener was removed + }); + + test('addWhenListener supports multiple independent listeners', () async { + final command = Command0(() async => const Success('test')); + var listener1Called = false; + var listener2Called = false; + + command.addWhenListener(onSuccess: (value) => listener1Called = true); + final removeListener2 = command.addWhenListener(onSuccess: (value) => listener2Called = true); + + await command.execute(); + + expect(listener1Called, isTrue); + expect(listener2Called, isTrue); + + // Reset and remove one listener + command.reset(); + removeListener2(); + listener1Called = false; + listener2Called = false; + + await command.execute(); + + expect(listener1Called, isTrue); + expect(listener2Called, isFalse); + }); + + test('addWhenListener works with Command1', () async { + final command = Command1((value) async => Success('Result: $value')); + var successCalled = false; + String? receivedValue; + + command.addWhenListener( + onSuccess: (value) { + successCalled = true; + receivedValue = value; + }, + ); + + await command.execute(42); + + expect(successCalled, isTrue); + expect(receivedValue, equals('Result: 42')); + }); + + test('addWhenListener handles listener exceptions gracefully', () async { + final command = Command0(() async => const Success('test')); + var commandCompleted = false; + + command.addWhenListener( + onSuccess: (value) => throw Exception('Listener error'), + ); + + // Command should complete normally despite listener exception + await command.execute(); + commandCompleted = true; + + expect(commandCompleted, isTrue); + expect(command.value, isA>()); + }); + }); } class AppException implements Exception {