# flutter_plugin_kit

<PubVersion />

`flutter_plugin_kit` is the optional Flutter companion to [`plugin_kit`](https://github.com/SaadArdati/plugin_kit/tree/main/packages/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.
**When to use it vs the raw runtime:** The raw `plugin_kit` setup ([Flutter Integration](https://plugin-kit.saad-ardati.dev/guides/flutter-integration/)) is what you reach for when the shell already owns its own `PluginRuntime` field, its own `_createSession` flow, and its own `setState` triggers. `flutter_plugin_kit` is what you reach for when that boilerplate is paying no rent: you want a scope widget that constructs and disposes the runtime for you, a mixin that handles bus subscriptions, and one `BuildContext.watchEvent<T>()` line where the manual subscription used to be.

Both ship in this monorepo. Use whichever is cheaper for the shape of your app.

## Install

```yaml
dependencies:
  plugin_kit: ^PUBVER_plugin_kit
  flutter_plugin_kit: ^PUBVER_flutter_plugin_kit
```

## What's in the box

- **`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.

## 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`.

```dart
extractRegion(flutterIntegrationSource, 'flutter-runtime-scope-in-app')
```

`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:

```dart
extractRegion(flutterIntegrationSource, 'flutter-builder-watch-event')
```

`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

### PluginRuntimeScope

Two construction shapes. Pick by who owns the runtime's lifetime.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-runtime-scope')
```

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.

```dart
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

Three modes. Pick by where the session comes from.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-session-scope-ambient')
```

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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-session-scope-runtime')
```

Like the ambient case, but with an explicit runtime. The scope still owns the created session.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-session-scope-external')
```

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

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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-state-listener-full')
```

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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-state-listener-session-override')
```

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

`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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-kit-watch-read-event')
```

`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

`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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-plugin-event-notifier')
```

It also implements `ValueListenable<E?>`, so `ValueListenableProvider`, `ValueListenableBuilder`, and `Listenable.merge` consume it without ceremony.

## 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

`PluginEventNotifier<E>` is a `ChangeNotifier`, so it drops into `ChangeNotifierProvider` directly:

```dart
ChangeNotifierProvider(
  create: (context) => PluginEventNotifier<ChatMessageReceived>(
    PluginSessionScope.of(context),
  ),
  child: const ChatBody(),
);

// In ChatBody:
final last = context.watch<PluginEventNotifier<ChatMessageReceived>>().value;
```

### flutter_bloc

No Cubit adapter is bundled. Create one by subscribing to `session.on<E>`, parameterised by whatever `Bloc` shape your app prefers:

```dart
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

Same shape. Subscribe in a notifier or store, expose the latest event, dispose cancels. Each library's recipe lives in [`example/state_garden/`](https://github.com/SaadArdati/plugin_kit/tree/main/example/state_garden) ([live demo](https://plugin-kit.saad-ardati.dev/state-garden)) and is shown side-by-side in [State Management Bridges](https://plugin-kit.saad-ardati.dev/reference/state-management-bridges/).

## 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.

```dart
extractRegion(flutterIntegrationSource, 'flutter-chat-controller')
```

`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

- **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()`.

## Related reading

[Flutter Integration](https://plugin-kit.saad-ardati.dev/guides/flutter-integration/)
  [State Management Bridges](https://plugin-kit.saad-ardati.dev/reference/state-management-bridges/)
  [Sessions](https://plugin-kit.saad-ardati.dev/concepts/sessions/)
  [Plugin Kit Dialog](https://plugin-kit.saad-ardati.dev/guides/plugin-kit-dialog/)