leulit_flutter_actionmanager 5.7.0 copy "leulit_flutter_actionmanager: ^5.7.0" to clipboard
leulit_flutter_actionmanager: ^5.7.0 copied to clipboard

A lightweight, type-safe action dispatcher for Flutter applications. Works seamlessly across all layers - UI, domain, data, and services.

Leulit Flutter Action Manager #

A lightweight, type-safe action dispatcher for Flutter applications. Works seamlessly across all layers: UI, domain, data, and services.

pub package License: MIT

✨ Features #

  • Type-safe with Dart enums (no string typos!)
  • TypedAction<T>: compile-time type validation — mismatches are caught by the compiler, not at runtime
  • Zero external dependencies (only Flutter/Dart)
  • Universal: Works in widgets, controllers, services, repositories
  • Reactive: StreamSubscription support for easy cleanup
  • Debuggable: Built-in introspection and statistics
  • Memory-safe: Proper handler cleanup
  • Incremental migration: TypedAction<T> coexists with existing enum-based code

🚀 Installation #

Add to your pubspec.yaml:

dependencies:
  leulit_flutter_actionmanager: ^5.0.0

Then run:

flutter pub get

📚 Basic Usage #

1. Define your actions #

enum AppAction {
  userLoggedIn,
  userLoggedOut,
  dataLoaded,
  networkError,
  zoomIn,
  zoomOut,
}

2. Dispatch actions #

⚠️ Important: All handlers execute asynchronously (microtask queue) after dispatch returns. This means handlers run after the current synchronous code completes.

// Without data
ActionManager.dispatch(AppAction.zoomIn);

// With data
ActionManager.dispatch(AppAction.userLoggedIn, data: user);

// With additional context
ActionManager.dispatch(
  AppAction.networkError,
  data: errorMessage,
  context: {'retry': true, 'endpoint': '/api/users'},
);

3. Register handlers #

✅ Best Practice: Use in StatefulWidget with cleanup

class MyWidget extends StatefulWidget {
  @override
  _MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final _subscriptions = <StreamSubscription>[];

  @override
  void initState() {
    super.initState();
    
    // ✅ RECOMMENDED: Use listen() with cleanup
    _subscriptions.add(
      ActionManager.listen<User>(AppAction.userLoggedIn, (event) {
        if (mounted) { // Always check mounted before setState
          final user = event.data;
          setState(() {
            // Update state with user
          });
        }
      }),
    );
  }

  @override
  void dispose() {
    // ✅ CRITICAL: Cancel all subscriptions
    for (final sub in _subscriptions) {
      sub.cancel();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

⚠️ Alternative: Use on() with manual cleanup

class _MyWidgetState extends State<MyWidget> {
  String? _handlerId;

  @override
  void initState() {
    super.initState();
    
    // ⚠️ If using on(), you MUST cleanup manually
    _handlerId = ActionManager.on<User>(AppAction.userLoggedIn, (event) {
      if (mounted) setState(() { /* Update with event.data */ });
    });
  }

  @override
  void dispose() {
    // ✅ CRITICAL: Remove handler
    if (_handlerId != null) {
      ActionManager.off(AppAction.userLoggedIn, _handlerId!);
    }
    super.dispose();
  }
}

🎯 Best: Use ActionManagerWidget (auto cleanup)

// ✅ BEST PRACTICE: Automatic cleanup, no manual subscription management
ActionManagerWidget<User>(
  action: AppAction.userLoggedIn,
  builder: (context, event) {
    final user = event.data;
    return Text('Welcome ${user?.name ?? "Guest"}');
  },
)

Other handler types

// Simple handler (no data)
ActionManager.onVoid(AppAction.zoomIn, (event) {
  print('Zooming in at ${event.timestamp}!');
});

// Handler with data and ActionEvent metadata
ActionManager.on<String>(AppAction.networkError, (event) {
  print('Error at ${event.timestamp}: ${event.data}');
  print('Context: ${event.context}');
});

4. Reactive Widgets (NEW in 4.1.0) #

The easiest way to build reactive UIs - widgets that automatically rebuild when actions are dispatched:

// Simple reactive widget
ActionManagerWidget<String>(
  action: AppAction.statusUpdated,
  builder: (context, event) {
    return Text('Status: ${event.data ?? "idle"}');
  },
)

// With placeholder before first event
ActionManagerWidget<User>(
  action: AppAction.userLoggedIn,
  placeholder: CircularProgressIndicator(),
  builder: (context, event) {
    final user = event.data;
    return Text('Welcome ${user?.name ?? "Guest"}!');
  },
)

// Debugging: access full event metadata
ActionManagerWidget<bool>(
  action: AppAction.displayForm,
  builder: (context, event) {
    debugPrint('Action: ${event.action}, Display: ${event.data}');
    debugPrint('Source: ${event.context?["source"]}');
    if (event.data == true) return MyForm();
    return const SizedBox.shrink();
  },
)

// React to multiple actions — event.data is dynamic, cast manually per action
ActionManagerMultiWidget(
  actions: [AppAction.refresh, AppAction.dataUpdated],
  builder: (context, event) {
    return Text('Last update: ${event.timestamp}');
  },
)

Benefits:

  • ✅ Auto-subscription and cleanup (no dispose needed)
  • ✅ Automatic setState() when action is dispatched
  • ✅ Type-safe with generics
  • ✅ Optional placeholder widget

🔒 TypedAction<T> — Compile-time Type Safety #

The standard enum-based API works well, but type mismatches between dispatch<T> and on<T> are only caught at runtime (silently swallowed by default). TypedAction<T> solves this by encoding the data type directly in the action constant.

Setup (no changes to existing code) #

// Keep your enum as-is
enum AppActions { userLoggedIn, issuesLoaded, refreshUI }

// Add a typed-constants class alongside it
class AppActionsTyped {
  static const userLoggedIn  = TypedAction<UserModel>  (AppActions.userLoggedIn);
  static const issuesLoaded  = TypedAction<List<Issue>>(AppActions.issuesLoaded);
  static const refreshUI     = TypedAction<void>       (AppActions.refreshUI);
}

Dispatch — compiler validates data type #

AppActionsTyped.issuesLoaded.dispatch(data: list);   // ✅
AppActionsTyped.issuesLoaded.dispatch(data: true);   // ❌ compile error
AppActionsTyped.refreshUI.dispatch();                // ✅ void, no data needed

Handler — T inferred, no explicit annotation #

// T is inferred as List<Issue> from TypedAction — no <T> needed
AppActionsTyped.issuesLoaded.on((event) {
  final items = event.data!; // List<Issue>, fully typed
});

// Async handler
AppActionsTyped.issuesLoaded.on((event) async {
  await repository.save(event.data!);
});

Introspection #

AppActionsTyped.issuesLoaded.hasHandlers;  // bool
AppActionsTyped.issuesLoaded.handlerCount; // int
AppActionsTyped.issuesLoaded.metadata;     // ActionMetadata

Incremental migration #

Old code continues to work — migrate file by file at your own pace:

// Old — still works
ActionManager.dispatch<List<Issue>>(AppActions.issuesLoaded, data: list);

// New — type-safe, T inferred
AppActionsTyped.issuesLoaded.dispatch(data: list);

🎯 Use Cases #

In Widgets (Manual Subscription) #

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  StreamSubscription? _sub;
  
  @override
  void initState() {
    super.initState();
    
    // Listen and rebuild
    _sub = ActionManager.listen(AppAction.dataLoaded, (_) {
      setState(() {});
    });
  }
  
  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => ActionManager.dispatch(AppAction.zoomIn),
      child: Text('Zoom +'),
    );
  }
}

In Controllers (GetX example) #

class IssueController extends GetxController {
  @override
  void onInit() {
    super.onInit();
    
    ActionManager.on<String>(AppAction.deleteIssue, (event) {
      final id = event.data;
      if (id != null) _deleteIssue(id);
    });
    
    ActionManager.on<List<Issue>>(AppAction.issuesLoaded, (event) {
      this.issues = event.data ?? [];
      update(); // Trigger UI rebuild
    });
  }
  
  void deleteIssue(String id) {
    ActionManager.dispatch(AppAction.deleteIssue, data: id);
  }
}

In Services/Repositories #

class IssueRepository {
  IssueRepository() {
    // Listen to UI commands
    ActionManager.on<String>(AppAction.deleteIssue, (event) async {
      final id = event.data;
      if (id != null) {
        await _api.deleteIssue(id);
        await loadIssues(); // Reload data
      }
    });
  }
  
  Future<void> loadIssues() async {
    try {
      final issues = await _api.getIssues();
      
      // Notify entire app
      ActionManager.dispatch(AppAction.issuesLoaded, data: issues);
    } catch (e) {
      ActionManager.dispatch(AppAction.networkError, data: e.toString());
    }
  }
}

🔍 Debugging & Introspection #

// Check if action has handlers
if (ActionManager.hasHandlers(AppAction.test)) {
  print('Someone is listening!');
}

// Get handler count
final count = ActionManager.handlerCount(AppAction.test);
print('Handlers: $count');

// Get detailed metadata
final metadata = ActionManager.getMetadata(AppAction.test);
print(metadata); // Shows count, dispatches, last time, etc.

// Get global statistics
final stats = ActionManager.getStats();
print(stats); // Shows all actions, handlers, dispatches

// Print visual summary
ActionManager.printSummary();

Output example:

📊 ═══════════════════════════════════════════
📊 Action Dispatcher Summary
📊 ═══════════════════════════════════════════

🎯 userLoggedIn
   ├─ Handlers: 3
   ├─ Dispatched: 15 times
   └─ Last: 2026-02-09 10:30:45.123

🎯 dataLoaded
   ├─ Handlers: 2
   ├─ Dispatched: 42 times
   └─ Last: 2026-02-09 10:32:10.456

📊 ═══════════════════════════════════════════
📊 Total: 2 actions, 5 handlers, 57 dispatches
📊 ═══════════════════════════════════════════

⚙️ Configuration #

// Configure logger
ActionManager.configureLogger(
  enabled: true,           // Enable/disable logging
  showTimestamp: true,     // Include timestamp in logs
);

// Clear all handlers (useful for tests)
ActionManager.clear();

🧪 Testing #

void main() {
  setUp(() {
    ActionManager.clear(); // Clean slate for each test
  });

  test('should handle action', () {
    bool called = false;
    
    ActionManager.onVoid(AppAction.test, (event) {
      called = true;
    });
    
    ActionManager.dispatch(AppAction.test);
    
    expect(called, isTrue);
  });
}

🤔 Why ActionManager? #

Before (Multiple paradigms) #

// UI: GetX reactivity
Obx(() => Text(controller.data.value))

// Cross-controller: EventBus
eventBus.fire(MyEvent(data));

// Service → UI: Callbacks
service.onData = (data) => setState(() {});

// Repository: Streams
repo.dataStream.listen((data) => ...);

After (One system) #

// Everywhere:
ActionManager.dispatch(AppAction.dataLoaded, data: data);
ActionManager.on<Data>(AppAction.dataLoaded, (event) => ...);

Benefits:

  • One pattern to learn
  • Consistent throughout the codebase
  • Easy to debug (see all actions in one place)
  • No confusion about which system to use

📄 License #

MIT License - see LICENSE file for details.

🤝 Contributing #

Contributions are welcome! Please feel free to submit a Pull Request.

📬 Support #

0
likes
150
points
70
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lightweight, type-safe action dispatcher for Flutter applications. Works seamlessly across all layers - UI, domain, data, and services.

Repository (GitHub)
View/report issues

Topics

#state-management #architecture #events #actions #dispatcher

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on leulit_flutter_actionmanager