Skip to content

Flutter Integration

Plugin Kit’s core package is pure Dart, with no flutter SDK dependency and no widget imports, so the same runtime drops into a CLI, a server, or a Flutter app unchanged. This page is about wiring that runtime into a widget tree, either directly or through whatever state library you already use.

The reference integration in this repo is example/code_editor/ — try it at plugin-kit.saad-ardati.dev/code-editor — a full Flutter app built entirely on StatefulWidget and setState with no DI or state-management package. Everything below mirrors that pattern.

Plugin Kit can sit alongside your state management or carry a fair amount of state-management work itself. The bus, registry, and session lifecycle overlap with what most state libraries do, so the choice is about how much overlap you want to pay for, not about which tool is allowed to do which job.

ToolGood at
Provider / Riverpod / Bloc / GetItexposing values to widgets, scheduling rebuilds, mature widget integrations
Plugin Kitlifecycle-aware feature composition, replaceable services, isolated sessions, runtime configuration, and (incidentally) a typed event bus that can drive setState directly

If you keep both, the usual split is: state management exposes values to widgets, Plugin Kit decides which features exist and how they participate, and a small bridge connects the two. If you drop the state library, the session bus drives setState straight from the shell. The reference example takes that path.

For plugin-provided UI, that bridge often resolves widget factories from the registry. For app behavior, prefer event contracts: widgets and state holders emit typed events, plugins react, and the Flutter layer does not need to know which service did the work.

The nicest Flutter surfaces for Plugin Kit are the ones users can see:

  • a model selector whose options follow enabled plugins
  • a customization dialog that edits runtime settings live
  • a chat timeline that grows richer blocks as plugins stream progress and results

Those are all the same integration story. The widget tree provides the shell. The runtime decides which features participate.

The cleanest integration, used by the code_editor example, looks like this.

  1. A stateful shell widget holds a PluginRuntime and the current PluginSession as fields.
  2. The shell subscribes to session-bus events it cares about and calls setState in those handlers.
  3. Plugins contribute widgets to the shell by registering factories in the registry.
  4. Plugins contribute descriptors (what should appear in the toolbar, panel list, status bar) by mutating collection events the shell emits.
  5. When the user toggles a feature, the shell calls updateSessionSettings(...) with serialization to prevent races.
  6. The shell’s dispose() disposes the runtime, which tears down sessions cleanly.

No extra package. Just Flutter, with the shell owning every dispose call. (For a version that hides the bookkeeping behind a scope widget and a mixin, see flutter_plugin_kit.)

class EditorScreen extends StatefulWidget {
/// Creates an [EditorScreen].
const EditorScreen({super.key});
@override
State<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends State<EditorScreen> {
late final PluginRuntime _runtime;
PluginSession? _session;
List<PanelDescriptor> _panels = [];
@override
void initState() {
super.initState();
_runtime = PluginRuntime(plugins: [TerminalPlugin(), MinimapPlugin()]);
_runtime.init();
_createSession();
}
Future<void> _createSession() async {
_session = await _runtime.createSession();
_session!.on<UIRefreshRequest>((_) async {
if (!mounted) return;
await _collectPanels();
});
await _collectPanels();
}
Future<void> _collectPanels() async {
final collect = CollectPanels();
await _session!.emit(collect);
if (!mounted) return;
setState(() => _panels = collect.panels);
}
@override
void dispose() {
// runtime.dispose() iterates and disposes active sessions. Do NOT call
// session.dispose() separately. Doing both races on stateful service
// detach and can throw ConcurrentModificationError.
_runtime.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(children: [for (final panel in _panels) _resolvePanel(panel)]),
);
}
Widget _resolvePanel(PanelDescriptor panel) {
const ns = Namespace('panel');
final factory = _session?.context.maybeResolve<PanelWidgetFactory>(
ns(panel.id),
);
return factory?.build(context) ?? const SizedBox.shrink();
}
}

That is the whole bridge. Session events trigger setState. Plugins contribute via the registry and via events. The widget tree never imports the ServiceRegistry or subscribes directly to plugin internals.

Widgets that plugins contribute live in the registry, behind an interface the shell knows about.

Define an interface in the shell:

abstract class PanelWidgetFactory {
Widget build(BuildContext context);
}

Implement it in the plugin. The factory typically extends StatefulPluginService so it inherits session lifecycle and automatic subscription tracking.

class TerminalPlugin extends SessionPlugin {
@override
PluginId get pluginId => const PluginId('terminal');
@override
void register(ScopedServiceRegistry registry) {
const panel = Namespace('panel');
registry.registerSingleton<PanelWidgetFactory>(
panel('terminal'), // ServiceId('panel.terminal')
() => TerminalPanelFactory(),
);
}
}

The factory keeps its own state (_history). To trigger a rebuild, it emits UIRefreshRequest on the session bus. The shell hears it, calls setState, and the widget’s build(context) runs as part of that rebuild.

Because the factory is a StatefulPluginService, the runtime manages its lifecycle automatically. It attaches when the plugin enables, detaches when the plugin disables, and its tracked subscriptions are cancelled without manual cleanup.

For things like toolbar items, status bar entries, or panel lists, the shell emits a “collection” event. Plugins listen for it and append to the mutable list inside.

class CollectPanels {
final List<PanelDescriptor> panels = [];
}

Because every enabled plugin’s handler runs in priority order on the same mutable event object, one emit produces the final list in-place. No separate collect-then-fold step.

When a user enables or disables a plugin mid-session, the shell calls updateSessionSettings(...) on the runtime.

Future<void> updateSessionPlugin(
PluginRuntime runtime,
PluginSession session,
) async {
final next = RuntimeSettings(
plugins: {
...session.settings.plugins,
const PluginId('experimental_feature'): const PluginConfig(enabled: true),
},
);
await runtime.updateSessionSettings(session, newSettings: next);
}

Dispose each session exactly once. PluginRuntime.dispose() iterates the active sessions it still owns and disposes them. If you plan to call runtime.dispose(), do not also call session.dispose() for those same sessions.

register and attach do not re-run on hot reload. Plugins are constructed once, and hot reload does not re-run main or re-execute initState. If you change code inside a plugin’s register or attach, use hot restart. Pure widget code inside factories still hot-reloads fine, because that code runs on rebuild.

Use mounted checks after async work. Session event handlers are async. If a handler awaits something and then calls setState, check mounted first. The widget may have been disposed in the meantime.

If you already use Riverpod, Provider, or Bloc

Section titled “If you already use Riverpod, Provider, or Bloc”

The shape of the integration above is the same. Hold the runtime where your package expects long-lived services, and expose the current session (or services resolved from it) through whatever mechanism you already use.

For the library-specific bridge code (Provider/ChangeNotifier, Riverpod AsyncNotifier, flutter_bloc Cubit, signals_flutter, MobX, GetIt) see State Management Bridges. Every recipe there is implemented in example/state_garden/ and run by flutter test, so the citations are checked code rather than prose.

Testing Flutter widgets that use Plugin Kit

Section titled “Testing Flutter widgets that use Plugin Kit”

Plugin Kit works with flutter_test like any other Dart dependency. The pattern:

/// Demonstrates the pattern for testing a Flutter widget backed by a runtime.
Future<void> pumpEditorShell(
PluginRuntime runtime,
Future<void> Function(Widget) pump,
) async {
runtime.init();
await pump(const MaterialApp(home: EditorScreen()));
}

For widgets that resolve services on build, register stubs or fakes in a test-only plugin. The registry does not distinguish “real” from “fake” implementations. Higher priority wins, same as production.

class FakeSearchPlugin extends SessionPlugin {
@override
PluginId get pluginId => const PluginId('fake_search');
@override
void register(ScopedServiceRegistry registry) {
registry.registerSingleton<SearchService>(
const ServiceId('search'),
() => FakeSearch(),
priority: Priority.system, // beat any other registrant
);
}
}

See Testing for non-widget plugin tests.