Skip to content

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.

dependencies:
plugin_kit: ^PUBVER_plugin_kit
flutter_plugin_kit: ^PUBVER_flutter_plugin_kit
  • PluginRuntimeScope: StatefulWidget that installs an internal inherited scope carrying a PluginRuntime. 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: StatefulWidget that installs an internal inherited scope carrying a PluginSession. Three modes: explicit session, runtime + auto-create session, or derive both from an ambient PluginRuntimeScope. Async session creation is handled with optional loading and error builders.
  • PluginSessionStateListener<W>: mixin on State<W>. listen<E>(handler) and rebuildOn<E>([when]) register subscriptions that auto-cancel on dispose and re-attach automatically across session swaps. Both are callable from initState.
  • PluginEventNotifier<E>: ChangeNotifier / ValueListenable<E?>. Subscribes to a session and exposes the latest event of type E as .value. Drops into ChangeNotifierProvider, ValueListenableProvider, ValueListenableBuilder, and any other foundation-listenable consumer.
  • BuildContext.watchEvent<E>() / readEvent<E>(): extensions on BuildContext. watchEvent subscribes the calling element to rebuilds on the next E; readEvent returns the latest without subscribing.

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.

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.

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.

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.

A mixin on State<W>. Two methods:

  • listen<E>(void Function(EventEnvelope<E> envelope) handler): register a handler for events of type E. Returns nothing; cancellation is automatic on dispose. The handler receives the envelope, so envelope.identifier, envelope.event, and envelope.stopped stay reachable.
  • rebuildOn<E>([bool Function(EventEnvelope<E> envelope)? when]): register a setState-trigger handler for events of type E, optionally filtered by when. The predicate receives the envelope too, so filtering can gate on identifier, 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.

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<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/ChangeNotifierProvider boundary needs a real Listenable to compose with Listenable.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 a BuildContext.
  • The session lives outside PluginSessionScope (e.g., in a service locator) and watchEvent can’t find it.
  • You need priority or identifier on the subscription. watchEvent registers as a default-priority, no-identifier handler; PluginEventNotifier’s constructor forwards both to EventBus.on for 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.

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;

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();
}
}

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.

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.

  • 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 into PluginRuntimeScope.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-route StatefulWidget.
  • Disposal happens when the scope owns the resource. A scope only owns what it constructed itself: a plugins:-form PluginRuntimeScope owns the runtime, an ambient-or-runtime-form PluginSessionScope owns the session. The .value and 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 through FlutterError.reportError instead of escaping as an uncaught zone error. In tests, the error surfaces via tester.takeException().