flutter_plugin_kit
flutter_plugin_kit is the optional Flutter companion to plugin_kit. It packages the patterns a Flutter shell uses around a PluginRuntime (carrying it through the tree, subscribing to session events from a State, exposing the latest event to a state-management library) into a small set of types you opt into where they pay for themselves.
It pulls in only flutter and plugin_kit. Everything it exposes uses standard Flutter shapes (InheritedWidget, ChangeNotifier, ValueListenable), so it drops into provider, flutter_bloc, riverpod, signals, MobX, etc. as ordinary values. It does not replace your state library.
Install
Section titled “Install”dependencies: plugin_kit: ^PUBVER_plugin_kit flutter_plugin_kit: ^PUBVER_flutter_plugin_kitWhat’s in the box
Section titled “What’s in the box”PluginRuntimeScope:StatefulWidgetthat installs an internal inherited scope carrying aPluginRuntime. Either pass an externally-owned runtime via.value, or pass a list of plugins and let the scope construct, init, and dispose one for you.PluginSessionScope:StatefulWidgetthat installs an internal inherited scope carrying aPluginSession. Three modes: explicit session, runtime + auto-create session, or derive both from an ambientPluginRuntimeScope. Async session creation is handled with optionalloadinganderrorbuilders.PluginSessionStateListener<W>: mixin onState<W>.listen<E>(handler)andrebuildOn<E>([when])register subscriptions that auto-cancel on dispose and re-attach automatically across session swaps. Both are callable frominitState.PluginEventNotifier<E>:ChangeNotifier/ValueListenable<E?>. Subscribes to a session and exposes the latest event of typeEas.value. Drops intoChangeNotifierProvider,ValueListenableProvider,ValueListenableBuilder, and any other foundation-listenable consumer.BuildContext.watchEvent<E>()/readEvent<E>(): extensions onBuildContext.watchEventsubscribes the calling element to rebuilds on the nextE;readEventreturns the latest without subscribing.
Quick tour
Section titled “Quick tour”Two plugins, one screen. The scope owns the runtime and the session; the screen mixes in the listener and calls listen from initState.
void exampleAppRoot() { runApp( MaterialApp( home: PluginRuntimeScope( plugins: [ChatPlugin(), AssistantPlugin()], child: const PluginSessionScope(child: ChatScreen()), ), ), );}
class ChatScreen extends StatefulWidget { /// Creates a [ChatScreen]. const ChatScreen({super.key});
@override State<ChatScreen> createState() => _ChatScreenState();}
class _ChatScreenState extends State<ChatScreen> with PluginSessionStateListener<ChatScreen> { String? _last;
@override void initState() { super.initState(); listen<ChatMessageReceived>((envelope) { setState(() => _last = envelope.event.text); }); }
@override Widget build(BuildContext context) => Text(_last ?? 'idle');}PluginRuntimeScope constructed and init-ed the runtime; it disposes it when the scope leaves the tree. PluginSessionScope saw the ambient runtime, called createSession, and exposed the result. The mixin saw the session in didChangeDependencies, attached the ChatMessageReceived handler, and will cancel it on dispose.
The Builder-only variant skips the State class entirely:
Widget buildWatchEventExample() { return Builder( builder: (context) { final last = context.watchEvent<ChatMessageReceived>(); return Text(last?.text ?? 'idle'); }, );}watchEvent<E> reads the ambient session from PluginSessionScope and subscribes the calling element to rebuilds when new E events land. The shared event-bus subscription is owned per event type by PluginSessionEvents, then canceled when that scope is disposed or when the session changes.
Scope widgets
Section titled “Scope widgets”PluginRuntimeScope
Section titled “PluginRuntimeScope”Two construction shapes. Pick by who owns the runtime’s lifetime.
/// Demonstrates the auto-create form of PluginRuntimeScope.Widget buildRuntimeScopeAutoCreate() { return PluginRuntimeScope( plugins: [ChatPlugin(), AssistantPlugin()], initialSettings: const RuntimeSettings(), child: const ChatScreen(), );}The scope constructs a PluginRuntime from the plugin list, calls init, holds the reference, and disposes it when the scope leaves the tree. Convenient for per-route runtimes whose lifetime really does match the widget that hosts them.
final runtime = PluginRuntime(plugins: [...])..init();
// Somewhere up the tree:PluginRuntimeScope.value( runtime: runtime, child: const ChatScreen(),);
// Caller disposes when truly done:await runtime.dispose();The scope holds a reference but does not own the runtime’s lifetime. Same contract as Provider.value: pass it in, take it out, the widget never disposes what it didn’t construct.
PluginSessionScope
Section titled “PluginSessionScope”Three modes. Pick by where the session comes from.
/// Demonstrates PluginSessionScope reading from an ambient PluginRuntimeScope.Widget buildSessionScopeAmbient() { return PluginRuntimeScope( plugins: [ChatPlugin(), AssistantPlugin()], child: const PluginSessionScope( loading: _circularProgress, child: ChatScreen(), ), );}
Widget _circularProgress(BuildContext context) => const Center(child: CircularProgressIndicator());No runtime: or session: argument. The scope reads the ambient PluginRuntimeScope, calls createSession, and exposes the result. While the create future is pending, the optional loading builder paints; on error, the optional error builder paints. The scope owns the session and disposes it on swap or unmount.
/// Demonstrates PluginSessionScope with an explicit runtime.Widget buildSessionScopeExplicitRuntime(PluginRuntime someRuntime) { return PluginSessionScope(runtime: someRuntime, child: const ChatScreen());}Like the ambient case, but with an explicit runtime. The scope still owns the created session.
/// Demonstrates PluginSessionScope with an externally-owned session.Widget buildSessionScopeExternalSession(PluginSession existingSession) { return PluginSessionScope( session: existingSession, child: const ChatScreen(), );}The scope just carries an externally-owned session. Caller manages its lifetime. Useful when the session lives in a service locator, a Riverpod provider, or any other long-lived holder.
The scope reacts correctly when you swap arguments mid-tree: switching from auto-create to an explicit session, swapping the ambient runtime, or replacing one explicit session with another all trigger a clean dispose-and-recreate of any owned session, without leaking the previous one.
PluginSessionStateListener
Section titled “PluginSessionStateListener”A mixin on State<W>. Two methods:
listen<E>(void Function(EventEnvelope<E> envelope) handler): register a handler for events of typeE. Returns nothing; cancellation is automatic ondispose. The handler receives the envelope, soenvelope.identifier,envelope.event, andenvelope.stoppedstay reachable.rebuildOn<E>([bool Function(EventEnvelope<E> envelope)? when]): register a setState-trigger handler for events of typeE, optionally filtered bywhen. The predicate receives the envelope too, so filtering can gate onidentifier, payload, or stop state.
Both are callable from initState (and any later lifecycle callback). Behind the scenes the mixin records each listen / rebuildOn call as a descriptor and attaches descriptors against the active session as soon as one becomes available, typically the next didChangeDependencies after initState. There is no _wired flag, no “subscribe in didChangeDependencies instead” caveat.
class FullChatScreen extends StatefulWidget { /// Creates a [FullChatScreen]. const FullChatScreen({super.key});
@override State<FullChatScreen> createState() => _FullChatScreenState();}
class _FullChatScreenState extends State<FullChatScreen> with PluginSessionStateListener<FullChatScreen> { String? _last;
@override void initState() { super.initState(); listen<ChatMessageReceived>((envelope) { if (!mounted) return; setState(() => _last = envelope.event.text); }); }
@override Widget build(BuildContext context) => Text(_last ?? 'idle');}The mixin defaults to reading the active session from the ambient PluginSessionScope. Override PluginSession? get session only when the session lives elsewhere, typically => widget.session for a screen that takes one as a parameter.
/// Pane that explicitly overrides [session] from a widget parameter.class Pane extends StatefulWidget { /// The session to listen to. final PluginSession session;
/// Creates a [Pane] bound to [session]. const Pane({super.key, required this.session});
@override State<Pane> createState() => _PaneState();}
class _PaneState extends State<Pane> with PluginSessionStateListener<Pane> { @override PluginSession? get session => widget.session;
@override Widget build(BuildContext context) => const SizedBox.shrink();}The mixin re-attaches descriptors automatically when the session changes (a PluginSessionScope swap, widget.session swap, or ambient PluginRuntimeScope swap). Each swap disposes the prior batch of subscriptions and re-attaches a fresh batch against the new session.
BuildContext extensions
Section titled “BuildContext extensions”watchEvent<E> and readEvent<E> are the recommended path for reading events. If you have a PluginSessionScope ancestor, they cover ~90% of the “show the latest event of type E” use case in one line each, with no state holder to construct or dispose.
/// Demonstrates watchEvent and readEvent extensions.Widget buildWatchReadEventExample() { return Builder( builder: (context) { final last = context.watchEvent<ChatMessageReceived>(); final maybe = context.readEvent<ChatMessageReceived>(); return Text('watch=${last?.text} read=${maybe?.text}'); }, );}watchEvent<E> reads the ambient PluginSessionScope, subscribes the calling element to events of type E, and rebuilds the element when one arrives. Returns the latest E? (initially null). The event-bus subscription is tracked per event type on the surrounding PluginSessionEvents scope and is canceled when that scope is disposed or switches to a different session.
readEvent<E> reads the latest E? without subscribing the calling element to rebuilds. It still ensures a scope-level subscription for E exists. Useful inside event handlers, button callbacks, and other one-shot reads where you don’t want a rebuild.
PluginEventNotifier
Section titled “PluginEventNotifier”PluginEventNotifier<E> is the integration shim for state-management libraries that consume a ChangeNotifier / ValueListenable<E?> rather than a BuildContext. Reach for it when:
- Your
Provider/ChangeNotifierProviderboundary needs a realListenableto compose withListenable.merge, signals, or other listenables. - You’re writing a state holder (Cubit, controller, AsyncNotifier) that wants to expose “the latest event of type
E” through its own surface, and the host doesn’t have access to aBuildContext. - The session lives outside
PluginSessionScope(e.g., in a service locator) andwatchEventcan’t find it. - You need
priorityoridentifieron the subscription.watchEventregisters as a default-priority, no-identifier handler;PluginEventNotifier’s constructor forwards both toEventBus.onfor callers who need scoped delivery or a non-default cascade position.
/// Example Bloc-style cubit that bridges session events.class PluginEventCubit<E> { /// The current event value. E? value;
late final EventSubscription _sub;
/// Creates a cubit listening to [session] for events of type [E]. PluginEventCubit(PluginSession session) { _sub = session.on<E>((envelope) { value = envelope.event; }); }
/// Cancels the subscription. void close() { _sub.cancel(); }}It also implements ValueListenable<E?>, so ValueListenableProvider, ValueListenableBuilder, and Listenable.merge consume it without ceremony.
Integrating with state-management libraries
Section titled “Integrating with state-management libraries”flutter_plugin_kit does not depend on provider, flutter_bloc, or any other state library. It exposes standard Flutter shapes those libraries already consume.
provider
Section titled “provider”PluginEventNotifier<E> is a ChangeNotifier, so it drops into ChangeNotifierProvider directly:
ChangeNotifierProvider( create: (context) => PluginEventNotifier<ChatMessageReceived>( PluginSessionScope.of(context), ), child: const ChatBody(),);
// In ChatBody:final last = context.watch<PluginEventNotifier<ChatMessageReceived>>().value;flutter_bloc
Section titled “flutter_bloc”No Cubit adapter is bundled. Create one by subscribing to session.on<E>, parameterised by whatever Bloc shape your app prefers:
class PluginEventCubit<E> extends Cubit<E?> { PluginEventCubit(PluginSession session) : super(null) { _sub = session.on<E>((envelope) { if (!isClosed) emit(envelope.event); }); } late final EventSubscription _sub;
@override Future<void> close() async { _sub.cancel(); return super.close(); }}riverpod / signals / mobx
Section titled “riverpod / signals / mobx”Same shape. Subscribe in a notifier or store, expose the latest event, dispose cancels. Each library’s recipe lives in example/state_garden/ (live demo) and is shown side-by-side in State Management Bridges.
Pure-Dart sibling
Section titled “Pure-Dart sibling”If you have a host that is not a State<W> (a Cubit, a ChangeNotifier, a controller class), plugin_kit itself ships a PluginSessionListener mixin and a shared EventBinding descriptor type. Same declarative subscription shape, no Flutter dependency.
/// Pure-Dart controller that uses [PluginSessionListener] to subscribe to/// [ChatMessageReceived] events without depending on Flutter.class ChatController with PluginSessionListener { /// Creates a [ChatController] bound to [session] and attaches subscriptions. ChatController(this.session) { attachSubscriptions(); }
@override final PluginSession session;
@override List<EventBinding> get subscriptions => [ EventBinding.on<ChatMessageReceived>(_onReceived), ];
void _onReceived(EventEnvelope<ChatMessageReceived> envelope) { // React to the incoming chat message. print('received: ${envelope.event.text}'); }
/// Cancels all active subscriptions. void dispose() => detachSubscriptions();}EventBinding.on<E>(handler) is a static factory rather than a top-level function so it does not shadow the existing PluginHelper.on<E> extension method.
Lifecycle notes
Section titled “Lifecycle notes”- The runtime outlives the widget tree. Hot restart, route stack resets, and deep navigation throw away widgets but not your
PluginRuntime. For long-lived runtimes, hold the runtime outside the tree (top-level final, GetIt singleton, Riverpod provider with one-shot create) and pass it intoPluginRuntimeScope.value/PluginSessionScope(session: ...). The auto-create variants are convenient when the scope’s lifetime really does match the runtime’s, e.g. inside a per-routeStatefulWidget. - Disposal happens when the scope owns the resource. A scope only owns what it constructed itself: a
plugins:-formPluginRuntimeScopeowns the runtime, an ambient-or-runtime-formPluginSessionScopeowns the session. The.valueand explicit-session forms never dispose the resource on the caller’s behalf. - Async dispose errors are reported, not swallowed. Both scopes wrap their owned
dispose()calls so that any error a plugin raises during detach is routed throughFlutterError.reportErrorinstead of escaping as an uncaught zone error. In tests, the error surfaces viatester.takeException().