diff --git a/.pubignore b/.pubignore index 2ccdf6bc..8cf0f261 100644 --- a/.pubignore +++ b/.pubignore @@ -34,5 +34,11 @@ doc/ devtools_options.yaml flutter_modular.png CONTRIBUTING.md +CLAUDE.md -tool/ \ No newline at end of file +# NOTE: do NOT blanket-exclude `tool/` here. `dart pub publish` applies this +# root .pubignore to nested packages too (e.g. tool/docs_mcp, published as +# `flutter_modular_docs_mcp`), so excluding the dir would also strip the files +# that package needs to publish. Exclude only build artifacts instead. +tool/**/.dart_tool/ +tool/**/build/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fca0ae..92049450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 7.0.1 + +- **Page-scoped BLoC/Cubit support.** New `Scoped.addStreamable(ctor, + (t) => t.stream, (t) => t.close())` exposes the object itself via + `context.watch()` (read its synchronous `state`, call its methods) while + rebuilds are driven by its stream — flutter_modular keeps **no dependency on + the `bloc` package** (stream/close are caller callbacks). Companion + `addListenable(ctor, (t) => t.listenable, (t) => t.dispose())` for objects + whose reactivity is a `Listenable` property. See the docs for a suggested + `addBloc` extension covering both BLoC and Cubit. +- **`add(ctor)`** — non-reactive page-scoped object, readable via + `context.read`/`watch` and disposed on unmount when it implements + `Disposable`. **Breaking:** replaces `addDisposable`, which is removed (the + `Disposable` interface is retained). +- `addChangeNotifier` reexpressed over `addListenable`; + `watch`/`read`/`Consumer`/`Selector` now accept any `Object` (not just + `Listenable`), so a non-`Listenable` reactive object can be exposed. + ## 7.0.0-dev.1 Ground-up rewrite of flutter_modular. **Breaking:** the v6 API (`Module` with diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..25e132ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# CLAUDE.md — flutter_modular + +Guia rápido para trabalhar neste repositório. Explicações simples + o que fazer +e o que **não** fazer. Leia antes de mexer no código ou na documentação. + +## O que é este projeto + +`flutter_modular` é um pacote Flutter de **injeção de dependência + gerência de +rotas**, com **estado escopo-de-página** (page-scoped). A versão atual é a **v7**, +uma reescrita do zero (branch `v7`). + +> Era um monorepo (melos). Agora é **um único pacote** na raiz. `modular_core` foi +> achatado para dentro de `flutter_modular`, e `shelf_modular` está sendo +> descontinuado. + +## Estrutura do repositório + +``` +lib/ # o pacote flutter_modular v7 (código publicado) + flutter_modular.dart # exports públicos + src/{app,module,navigation,route,state}/ +test/ # testes do pacote (flutter test) +example/ # app de demonstração (rotas aninhadas, guards, DI, etc.) +doc/ # site de documentação (Docusaurus 3.10) — NÃO é o pacote + docs/ # markdown das docs (fonte da verdade do conteúdo) +tool/docs_mcp/ # servidor MCP em Dart que SERVE as docs (pacote pub.dev separado) +art/ # identidade visual (logo Modular) +``` + +## API v7 (use estes idiomas — não os da v6) + +```dart +// Módulo = DI + rotas, declarado de forma funcional: +final appModule = createModule(register: (c) { + c + ..addSingleton(Counter.new) + ..route('/', child: (ctx, state) => const HomePage()) + ..route('/details/:id', child: (ctx, state) => DetailsPage(id: state.params['id']!)) + ..module('/admin', module: adminModule); +}); + +// Bootstrap (ModularApp acima do MaterialApp): +ModularApp(module: appModule, child: AppRoot()); +MaterialApp.router(routerConfig: ModularApp.routerConfigOf(context)); + +// Navegação: +context.pushNamed('/details/42'); // empilha página (push NÃO entra na URL) +context.navigate('/'); // troca a stack (dona da URL, reseta histórico) +context.pop(result); // volta entregando resultado ao pushNamed + +// Estado page-scoped (criado e descartado junto com a rota): +c.route('/counter', + provide: (s) => s.addChangeNotifier(CounterVM.new), + child: (ctx, state) => const CounterPage()); +final vm = context.watch(); // rebuild quando notifica +``` + +Modelo de rotas v7: a **URL representa a base da stack** (push não aparece na +URL); rotas são **relativas** (semântica de diretório); deep-link entra via +`defaultRouteName`; `navigatorKey`/`observers` ficam no `ModularApp`. + +## Comandos comuns + +```sh +# Pacote (raiz): +flutter test # roda os testes +flutter analyze # lint (usa flutterando_analysis) + +# Exemplo: +cd example && flutter run + +# Site de docs (Docusaurus): +cd doc && yarn install && yarn start # dev em http://localhost:3000 +cd doc && yarn build # build de produção + +# Servidor MCP de docs: +cd tool/docs_mcp && dart test # testes do servidor +``` + +## ✅ Faça (DO) + +- Use a **API v7** (acima). Confira `README.md`, `lib/` e `example/` como fonte + da verdade da API. +- Rode `flutter test` e `flutter analyze` antes de concluir uma mudança no pacote. +- Siga **Conventional Commits** (`feat:`, `fix:`, `docs:`, `chore:` …) — veja + `CONTRIBUTING.md`. +- Mantenha o pacote raiz enxuto: só `lib/` (mais os metadados) vai para o pub.dev. +- Para o fluxo de release do MCP, siga o passo a passo em + [`tool/docs_mcp/CLAUDE.md`](tool/docs_mcp/CLAUDE.md). + +## ❌ Não faça (DON'T) + +- **Não** use a API da v6 (`extends Module`, `Modular.get`, `Modular.to.push`). + É a v7 agora. +- **Não** conserte os testes do `shelf_modular` — ele está sendo descontinuado. +- **Não** confie nas docs em `doc/docs/flutter_modular/**` para a API: o prosa + ainda descreve a **v6** e contradiz o README v7. Ao escrever docs novas, use a + API v7. +- **Não** edite `tool/docs_mcp/lib/src/generated/docs_data.g.dart` à mão — é + gerado (veja lembrete abaixo). +- **Não** faça blanket-exclude de `tool/` no `.pubignore` da raiz (veja gotcha). + +## ⚠️ Lembretes importantes (tipo) + +**Mexeu na documentação → rebuilde o MCP e republique.** As docs ficam +**embutidas em build time** dentro do servidor MCP. Editar `doc/docs` **não muda +nada** até regenerar o índice. Sempre que adicionar/alterar conteúdo em +`doc/docs`: + +1. Regenere o índice embutido: + ```sh + cd tool/docs_mcp && dart run bin/build_index.dart + ``` + (varre `doc/docs`: `intro.md`, `platforms.md`, `flutter_modular/**`; ignora + `legacy*/` e `shelf_modular/`). Isso reescreve `lib/src/generated/docs_data.g.dart`. +2. Verifique: `dart analyze` e `dart test` (ambos limpos). +3. **Bump de versão em DOIS lugares** (precisam bater): `pubspec.yaml` → `version:` + e `lib/src/server.dart` → `const String serverVersion`; adicione entrada no + `CHANGELOG.md`. +4. Commit (o `dart pub publish` só envia arquivos versionados no git). +5. `dart pub publish --dry-run` → depois `dart pub publish`. +6. Recompile o binário local para o Claude Code pegar o conteúdo novo: + ```sh + dart compile exe bin/server.dart -o ~/.local/bin/flutter_modular_docs_mcp + ``` + (MCP carrega no início da sessão — abra uma sessão nova do Claude Code.) + +Passo a passo completo: [`tool/docs_mcp/CLAUDE.md`](tool/docs_mcp/CLAUDE.md). + +**Gotcha do `.pubignore`.** `dart pub publish` aplica o `.pubignore` da **raiz** +também aos pacotes aninhados (`tool/docs_mcp`). Se a raiz fizer blanket-exclude de +`tool/`, o publish do `flutter_modular_docs_mcp` sai com o archive **vazio** +("the pubspec is hidden", "LICENSE missing", "bin/server.dart does not exist"). +Exclua apenas artefatos de build sob `tool/` (`tool/**/.dart_tool/`, +`tool/**/build/`), não a pasta inteira. + +**Os docs estão atrasados (v6).** A reescrita da prosa de `doc/docs` para a API +v7 é trabalho em aberto. Até lá, o MCP serve conteúdo v6 — regenerar só re-embute +o que estiver em `doc/docs`. diff --git a/doc/docs/flutter_modular/state-management.md b/doc/docs/flutter_modular/state-management.md index e50cc04a..e8af5d2c 100644 --- a/doc/docs/flutter_modular/state-management.md +++ b/doc/docs/flutter_modular/state-management.md @@ -30,7 +30,7 @@ final productsModule = createModule( '/:id', provide: (s) { s - ..addDisposable(RealtimeConnection.new) + ..add(RealtimeConnection.new) ..addChangeNotifier(ProductDetailViewModel.new) ..addStream(_viewersStream); }, @@ -40,15 +40,20 @@ final productsModule = createModule( ); ``` -The `Scoped` registrar (`s`) offers three kinds of registration: +The rule is **`addChangeNotifier`** (a reactive view model) and **`addStream`** +(stream‑backed state); **`add`** registers a plain non‑reactive object. The `Scoped` +registrar (`s`) offers: | Method | For | Reactive? | Disposed on unmount? | |---|---|---|---| | `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` | -| `addDisposable(ctor)` | a non‑reactive resource (socket, use‑case) | ❌ | ✅ `dispose()` | | `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription | +| `add(ctor)` | a non‑reactive object (socket, use‑case, config) | ❌ | ✅ if it implements `Disposable` | Reactivity and lifecycle are **independent**: a thing can have either, both, or neither. +For reactive objects that don't fit the two rules above — a **BLoC**, a **Cubit**, a +controller exposing a `Listenable` — there are two escape hatches, +[`addStreamable` and `addListenable`](#exceptions-addstreamable-and-addlistenable). ### addChangeNotifier — a reactive view model @@ -72,11 +77,12 @@ class ProductListViewModel extends ChangeNotifier { } ``` -### addDisposable — a non‑reactive resource +### add — a non‑reactive resource For something that needs lifecycle but no reactivity — a connection, a subscription manager, a use‑case holding a handle. It is built as a per‑page singleton, so a view -model can **inject the same instance**, and `dispose()`d on exit: +model can **inject the same instance**. If it implements `Disposable`, it is +`dispose()`d on exit — `add` **always** checks for `Disposable`: ```dart class RealtimeConnection implements Disposable { @@ -113,6 +119,87 @@ Stream _viewersStream() => final viewers = context.watch>().value; // latest int, or null ``` +## Exceptions: addStreamable and addListenable + +`addChangeNotifier` and `addStream` cover the common cases. When an object's reactivity +lives on a **property** — its `stream`, or a `Listenable` it exposes — and you want to +expose the **object itself** (to read its synchronous state and call its methods), reach +for these two escape hatches. Each takes a factory, a selector for the reactive source, +and a (required) dispose callback: + +- `addStreamable(ctor, (t) => t.stream, (t) => t.close())` — reactivity is a `Stream`. + `context.watch()` returns the object; rebuilds fire on each emission. +- `addListenable(ctor, (t) => t.someListenable, (t) => t.dispose())` — reactivity is a + `Listenable` property. + +```dart +// A controller that is NOT a ChangeNotifier but exposes one: +class SearchController { + final ValueNotifier query = ValueNotifier(''); + void dispose() => query.dispose(); +} + +provide: (s) => s.addListenable( + SearchController.new, + (c) => c.query, // the rebuild trigger + (c) => c.dispose(), // cleanup on unmount +); +``` + +:::note +Prefer `addChangeNotifier`/`addStream`. Use `addStreamable`/`addListenable` only when the +reactive source is a property of the object you want to expose. +::: + +## BLoC and Cubit + +A **BLoC** or **Cubit** is exactly the streamable case: it exposes a synchronous `state`, +a `stream` of changes, and an async `close()`. Register it with `addStreamable` — +`context.watch()` returns the **BLoC/Cubit** itself, so you read `state` directly and +rebuilds are driven by its stream: + +```dart +// CounterCubit is a Cubit from the `bloc` package. +route( + '/counter', + provide: (s) => s.addStreamable( + CounterCubit.new, + (c) => c.stream, + (c) => c.close(), + ), + child: (ctx, state) { + final counter = ctx.watch(); // the Cubit itself + return Text('${counter.state}'); // read its synchronous state + }, +); +``` + +flutter_modular has **no dependency on the `bloc` package** — `addStreamable` takes the +`stream` and `close` as callbacks. To make this a one‑liner, add a small extension on +`Scoped` in your app. Because both **BLoC** and **Cubit** extend `BlocBase` (which has +`.stream` and `.close()`), a single `addBloc` covers both: + +```dart +import 'package:bloc/bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +/// Registers a page-scoped BLoC or Cubit: reactive via its stream, closed on unmount. +extension BlocScoped on Scoped { + void addBloc>(B Function() create) => + addStreamable(create, (b) => b.stream, (b) => b.close()); +} +``` + +```dart +// Now registering any BLoC or Cubit is one line: +provide: (s) => s.addBloc(CounterCubit.new), +``` + +:::tip +With the extension above, `addBloc(MyBloc.new)` works for both **BLoC** and +**Cubit** — one line to get a page‑scoped, auto‑closed, reactive instance. +::: + ## Reading state: `watch` and `read` From any descendant of the page, reach a provided `Listenable`: @@ -201,7 +288,7 @@ abstract interface class Disposable { } ``` -Implement it and register with `addDisposable` (page‑scoped) — Modular builds it in the +Implement it and register with `add` (page‑scoped) — Modular builds it in the page‑local injector and calls `dispose()` on unmount. Feature‑module binds that implement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature leaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle). diff --git a/doc/publish-docker.sh b/doc/publish-docker.sh index 7d7f8bb9..75e836f3 100755 --- a/doc/publish-docker.sh +++ b/doc/publish-docker.sh @@ -6,7 +6,7 @@ # ./publish-docker.sh [TAG] # TAG defaults to "latest" # # Environment variables: -# DOCKER_USER Docker Hub user/org (default: flutterando) +# DOCKER_USER Docker Hub user/org (default: jacobmoura7) # IMAGE_NAME repository name (default: modular-docs) # PLATFORMS target platforms (default: linux/amd64,linux/arm64) # DOCKER_PASSWORD if set (with DOCKER_USER), used for a non-interactive login @@ -19,7 +19,7 @@ set -euo pipefail # Always run from the doc/ directory (where the Dockerfile lives). cd "$(dirname "$0")" -DOCKER_USER="${DOCKER_USER:-flutterando}" +DOCKER_USER="${DOCKER_USER:-jacobmoura7}" IMAGE_NAME="${IMAGE_NAME:-modular-docs}" PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" TAG="${1:-latest}" diff --git a/example/lib/app/products/data/realtime_connection.dart b/example/lib/app/products/data/realtime_connection.dart index c554e143..9be39071 100644 --- a/example/lib/app/products/data/realtime_connection.dart +++ b/example/lib/app/products/data/realtime_connection.dart @@ -1,9 +1,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_modular/flutter_modular.dart'; -/// `addDisposable` demo: a NON-reactive resource opened while the detail page is -/// alive and closed on exit. It is injected into the detail VM (the same -/// page-scoped instance), showing reactivity and lifecycle are independent. +/// `add` demo: a NON-reactive resource opened while the detail page is alive and +/// closed on exit. Registered with `add` and cleaned up because it implements +/// [Disposable]. It is injected into the detail VM (the same page-scoped +/// instance), showing reactivity and lifecycle are independent. class RealtimeConnection implements Disposable { bool isOpen = true; diff --git a/example/lib/app/products/products_module.dart b/example/lib/app/products/products_module.dart index f8b00938..e146658c 100644 --- a/example/lib/app/products/products_module.dart +++ b/example/lib/app/products/products_module.dart @@ -12,7 +12,7 @@ import 'viewmodels/product_list_view_model.dart'; /// live in `pages/` and `viewmodels/`. /// /// Demonstrates: page-scoped view models (`addChangeNotifier`), params to the -/// view (`/:id`), `addDisposable` (non-reactive resource), and `addStream`. +/// view (`/:id`), `add` (non-reactive `Disposable` resource), and `addStream`. /// --------------------------------------------------------------------------- /// `addStream` demo: a live "people viewing" ticker. @@ -36,7 +36,7 @@ final productsModule = createModule( '/:id', provide: (s) { s - ..addDisposable(RealtimeConnection.new) + ..add(RealtimeConnection.new) ..addChangeNotifier( ProductDetailViewModel.new, ) diff --git a/lib/flutter_modular.dart b/lib/flutter_modular.dart index 18dd855a..d1adf752 100644 --- a/lib/flutter_modular.dart +++ b/lib/flutter_modular.dart @@ -2,8 +2,10 @@ /// /// Navigator 2.0 (route matching + page stack + guards + transitions), the /// module system (`createModule` / `ModularContext`), page-scoped state -/// (`provide` / `Scoped` + `context.watch`/`read`, `Consumer`/`Selector`, -/// `addStream`), and nested routes (`children` + `RouterOutlet`). +/// (`provide` / `Scoped` + `context.watch`/`read`, `Consumer`/`Selector`; +/// `addChangeNotifier`/`addStream` as the rule, `addStreamable`/`addListenable` +/// for BLoC/Cubit-style objects, `add` for non-reactive resources), and nested +/// routes (`children` + `RouterOutlet`). library; export 'src/app/modular_app.dart'; diff --git a/lib/src/state/consumer.dart b/lib/src/state/consumer.dart index 9fc0cb9a..79ef5684 100644 --- a/lib/src/state/consumer.dart +++ b/lib/src/state/consumer.dart @@ -2,9 +2,11 @@ import 'package:flutter/widgets.dart'; import 'scoped.dart'; -/// Rebuilds ONLY its [builder] when [T] notifies — scopes the rebuild to a -/// sub-widget instead of the whole page (the granular alternative to `watch`). -class Consumer extends StatefulWidget { +/// Rebuilds ONLY its [builder] when [T]'s trigger notifies — scopes the rebuild +/// to a sub-widget instead of the whole page (the granular alternative to +/// `watch`). [T] is the page-scoped value (a `ChangeNotifier`, a bloc +/// registered via `addStreamable`, etc.); rebuilds are driven by its trigger. +class Consumer extends StatefulWidget { const Consumer({required this.builder, this.child, super.key}); final Widget Function(BuildContext context, T value, Widget? child) builder; @@ -14,17 +16,19 @@ class Consumer extends StatefulWidget { State> createState() => _ConsumerState(); } -class _ConsumerState extends State> { - T? _value; +class _ConsumerState extends State> { + late T _value; + Listenable? _trigger; @override void didChangeDependencies() { super.didChangeDependencies(); - final value = context.read(); - if (!identical(value, _value)) { - _value?.removeListener(_onChange); - _value = value; - _value!.addListener(_onChange); + final pair = context.scopedPair(); + _value = pair.value; + if (!identical(pair.trigger, _trigger)) { + _trigger?.removeListener(_onChange); + _trigger = pair.trigger; + _trigger?.addListener(_onChange); } } @@ -34,18 +38,18 @@ class _ConsumerState extends State> { @override void dispose() { - _value?.removeListener(_onChange); + _trigger?.removeListener(_onChange); super.dispose(); } @override Widget build(BuildContext context) => - widget.builder(context, _value!, widget.child); + widget.builder(context, _value, widget.child); } /// Rebuilds its [builder] only when the SELECTED value [R] changes — surgical -/// reactivity over a [Listenable] view model. -class Selector extends StatefulWidget { +/// reactivity over a page-scoped value [T] (a view model, a bloc, etc.). +class Selector extends StatefulWidget { const Selector({ required this.selector, required this.builder, @@ -61,20 +65,24 @@ class Selector extends StatefulWidget { State> createState() => _SelectorState(); } -class _SelectorState extends State> { +class _SelectorState extends State> { T? _source; + Listenable? _trigger; late R _selected; @override void didChangeDependencies() { super.didChangeDependencies(); - final source = context.read(); - if (!identical(source, _source)) { - _source?.removeListener(_onChange); - _source = source; - _source!.addListener(_onChange); + final pair = context.scopedPair(); + if (!identical(pair.value, _source)) { + _source = pair.value; _selected = widget.selector(context, _source!); } + if (!identical(pair.trigger, _trigger)) { + _trigger?.removeListener(_onChange); + _trigger = pair.trigger; + _trigger?.addListener(_onChange); + } } void _onChange() { @@ -86,7 +94,7 @@ class _SelectorState extends State> { @override void dispose() { - _source?.removeListener(_onChange); + _trigger?.removeListener(_onChange); super.dispose(); } diff --git a/lib/src/state/scoped.dart b/lib/src/state/scoped.dart index 965fefeb..090b06d9 100644 --- a/lib/src/state/scoped.dart +++ b/lib/src/state/scoped.dart @@ -2,12 +2,19 @@ import 'dart:async'; import 'package:auto_injector/auto_injector.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; /// Registrar for PAGE-SCOPED state, used in `route(provide: (scoped) {...})`. /// /// Each registration becomes a factory built in a page-local injector at mount /// (deps resolved from the module injector), provided reactively via /// `InheritedNotifier`, and disposed at unmount. +/// +/// The rule is [addChangeNotifier] (reactive view model) and [addStream] +/// (stream-backed value). [addStreamable] and [addListenable] are escape +/// hatches for objects whose reactivity lives on a *property* (a BLoC/Cubit's +/// `stream`, a controller's `Listenable`); [add] is the simple non-reactive +/// registration. class Scoped { final List _specs = []; @@ -18,19 +25,43 @@ class Scoped { /// unmount). The bound is [ChangeNotifier] — NOT [Listenable] — precisely so /// the dispose is guaranteed: a bare `Listenable` has no `dispose`, which /// would make resource cleanup silently best-effort. + /// + /// This is the default reactive registration; it delegates to [addListenable] + /// (the notifier is both the exposed value and the rebuild trigger). void addChangeNotifier(Function constructor) { - _specs.add(_ChangeNotifierSpec(constructor)); + addListenable(constructor, (vm) => vm, (vm) => vm.dispose()); } - /// Registers a NON-REACTIVE [Disposable], scoped to the page. It is built in - /// the page-local injector (one instance per mount, so view models can depend - /// on it) and `dispose()`d on unmount. Use this for resources that need - /// lifecycle but no reactivity — a socket, a subscription manager, a - /// use-case holding a connection. Reactivity ([ChangeNotifier]) and lifecycle - /// ([Disposable]) are thus independent: a thing can have either, both, or - /// neither. - void addDisposable(Function constructor) { - _specs.add(_DisposableSpec(constructor)); + /// Registers a reactive object whose rebuild source is a [Listenable] + /// *property* rather than the object itself — the escape hatch for reactive + /// types that are not a [ChangeNotifier]. `context.watch()` returns the + /// object; rebuilds are driven by the listenable from [select]; [dispose] is + /// called on unmount. + /// + /// Prefer [addChangeNotifier] unless your type's reactivity lives on a + /// property. + void addListenable( + Function constructor, + Listenable Function(T value) select, + FutureOr Function(T value) dispose, + ) { + _specs.add(_ListenableSpec(constructor, select, dispose)); + } + + /// Registers a reactive object whose rebuild source is a [Stream] *property* + /// — for BLoC/Cubit and the like. `context.watch()` returns the object + /// itself (read its synchronous state, e.g. `bloc.state`); rebuilds are + /// driven by the stream from [select]; [dispose] (e.g. `(b) => b.close()`) is + /// called on unmount. + /// + /// Prefer [addStream] when you only need the latest emitted value; reach for + /// this when you want to expose the object (its methods and current state). + void addStreamable( + Function constructor, + Stream Function(T value) select, + FutureOr Function(T value) dispose, + ) { + _specs.add(_StreamableSpec(constructor, select, dispose)); } /// Registers stream-backed state. The latest value is exposed via @@ -38,12 +69,28 @@ class Scoped { void addStream(Stream Function() create) { _specs.add(_StreamSpec(create)); } + + /// Registers a NON-REACTIVE object, scoped to the page. It is built in the + /// page-local injector (one instance per mount, so view models can depend on + /// it) and exposed via `context.read`/`watch` (`watch` never rebuilds — there + /// is no trigger). Use this for resources that need lifecycle but no + /// reactivity — a socket, a subscription manager, a use-case holding a + /// connection, a config object. + /// + /// If the instance implements [Disposable], its `dispose()` is **always** + /// called on unmount — the instance can be any class, including a bloc that + /// chose to implement [Disposable] (mapping `dispose` to `close`). Reactivity + /// and lifecycle are thus independent: a thing can have either, both, or + /// neither. + void add(Function constructor) { + _specs.add(_SimpleSpec(constructor)); + } } -/// A page-scoped resource that needs cleanup but is NOT reactive. Implement it -/// on any class and register it via [Scoped.addDisposable] to have it built in -/// the page-local injector and `dispose()`d when the page leaves the stack — -/// even though it is not a [Listenable]/[ChangeNotifier]. +/// A page-scoped resource that needs cleanup. Implement it on any class and +/// register it via [Scoped.add] to have it built in the page-local injector and +/// `dispose()`d when the page leaves the stack — even though it is not a +/// [Listenable]/[ChangeNotifier]. abstract interface class Disposable { void dispose(); } @@ -54,36 +101,94 @@ abstract class ScopedSpec { void register(AutoInjector injector); Object resolve(AutoInjector injector); Widget wrap(Object instance, Widget child); - void dispose(Object instance); + + /// May return a [Future] (e.g. a bloc's `close()`); the host fires it and + /// forgets, since `State.dispose` is synchronous. + FutureOr dispose(Object instance); } -class _ChangeNotifierSpec implements ScopedSpec { - _ChangeNotifierSpec(this.constructor); +/// A reactive object whose rebuild trigger is a [Listenable] — either the +/// object itself (via [Scoped.addChangeNotifier]) or one of its properties (via +/// [Scoped.addListenable]). Registered as a per-page singleton (shared with any +/// view model that injects it), provided via [_VMInherited], and disposed by +/// the caller-supplied callback on unmount. +class _ListenableSpec implements ScopedSpec { + _ListenableSpec(this.constructor, this.select, this.onDispose); final Function constructor; + final Listenable Function(T value) select; + final FutureOr Function(T value) onDispose; + + Listenable? _trigger; @override Type get type => T; @override - void register(AutoInjector injector) => injector.add(constructor); + void register(AutoInjector injector) => + injector.addLazySingleton(constructor); @override - Object resolve(AutoInjector injector) => injector.get(); + Object resolve(AutoInjector injector) { + final value = injector.get(); + _trigger = select(value); + return value; + } @override Widget wrap(Object instance, Widget child) => - _VMInherited(notifier: instance as T, child: child); + _VMInherited(value: instance as T, notifier: _trigger, child: child); @override - void dispose(Object instance) => (instance as ChangeNotifier).dispose(); + FutureOr dispose(Object instance) => onDispose(instance as T); } -/// A non-reactive, page-scoped [Disposable]: registered as a per-page singleton -/// (shared with any view model that injects it), NOT provided through an -/// `InheritedNotifier` (nothing to listen to), and `dispose()`d on unmount. -class _DisposableSpec implements ScopedSpec { - _DisposableSpec(this.constructor); +/// A reactive object whose rebuild trigger is a [Stream] property (a BLoC or +/// Cubit and the like). The stream drives an internal [_StreamTrigger]; the +/// object itself is what `watch()` returns. Disposed by the caller-supplied +/// callback (e.g. `close()`) on unmount, after the subscription is cancelled. +class _StreamableSpec implements ScopedSpec { + _StreamableSpec(this.constructor, this.select, this.onDispose); + + final Function constructor; + final Stream Function(T value) select; + final FutureOr Function(T value) onDispose; + + _StreamTrigger? _trigger; + + @override + Type get type => T; + + @override + void register(AutoInjector injector) => + injector.addLazySingleton(constructor); + + @override + Object resolve(AutoInjector injector) { + final value = injector.get(); + _trigger = _StreamTrigger(select(value)); + return value; + } + + @override + Widget wrap(Object instance, Widget child) => + _VMInherited(value: instance as T, notifier: _trigger, child: child); + + @override + Future dispose(Object instance) async { + // Stop listening BEFORE the user closes the object, so no late emission + // reaches the trigger during teardown. + _trigger?.dispose(); + await onDispose(instance as T); + } +} + +/// A non-reactive, page-scoped object: registered as a per-page singleton +/// (shared with any view model that injects it), provided via [_VMInherited] +/// with a null trigger (readable but never rebuilding), and — if it implements +/// [Disposable] — `dispose()`d on unmount. +class _SimpleSpec implements ScopedSpec { + _SimpleSpec(this.constructor); final Function constructor; @@ -98,10 +203,13 @@ class _DisposableSpec implements ScopedSpec { Object resolve(AutoInjector injector) => injector.get(); @override - Widget wrap(Object instance, Widget child) => child; + Widget wrap(Object instance, Widget child) => + _VMInherited(value: instance as T, child: child); @override - void dispose(Object instance) => (instance as Disposable).dispose(); + void dispose(Object instance) { + if (instance is Disposable) instance.dispose(); + } } /// A [ChangeNotifier] holding the latest value emitted by a [Stream]. @@ -140,17 +248,52 @@ class _StreamSpec implements ScopedSpec { Object resolve(AutoInjector injector) => StreamValue(create()); @override - Widget wrap(Object instance, Widget child) => _VMInherited>( - notifier: instance as StreamValue, - child: child, - ); + Widget wrap(Object instance, Widget child) { + final streamValue = instance as StreamValue; + return _VMInherited>( + value: streamValue, + notifier: streamValue, + child: child, + ); + } @override void dispose(Object instance) => (instance as StreamValue).dispose(); } -class _VMInherited extends InheritedNotifier { - const _VMInherited({required super.notifier, required super.child}); +/// Internal: adapts a [Stream] into a notify-only [Listenable] rebuild trigger. +/// It does NOT retain emitted values (unlike [StreamValue]) — it only notifies, +/// and cancels its subscription on dispose. +class _StreamTrigger extends ChangeNotifier { + _StreamTrigger(Stream stream) { + _sub = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _sub; + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } +} + +/// Internal: provides a page-scoped [value] of any type [T], rebuilding +/// dependents when [notifier] (the trigger) fires. +/// +/// For `ChangeNotifier`-style state, [value] and `notifier` are the same +/// object; for streamables they differ (the object vs. an internal +/// [_StreamTrigger]); for a non-reactive [Scoped.add], `notifier` is null and +/// dependents never rebuild. Decoupling the exposed value from the trigger is +/// what lets `watch()` return a non-`Listenable` (e.g. a bloc). +class _VMInherited extends InheritedNotifier { + const _VMInherited({ + required this.value, + super.notifier, + required super.child, + }); + + final T value; } /// Wraps a page subtree: builds the page-scoped instances in a page-local @@ -186,6 +329,16 @@ class _ScopedHostState extends State { widget.provide(scoped); _specs = scoped.specs; + final seen = {}; + for (final spec in _specs) { + if (!seen.add(spec.type)) { + throw FlutterError( + 'Scoped: type ${spec.type} registered more than once in the same ' + 'route. Each page-scoped type must be unique.', + ); + } + } + final injector = AutoInjector()..addInjector(widget.parent); for (final spec in _specs) { spec.register(injector); @@ -200,7 +353,10 @@ class _ScopedHostState extends State { void dispose() { for (final spec in _specs) { final instance = _instances[spec.type]; - if (instance != null) spec.dispose(instance); + if (instance != null) { + final result = spec.dispose(instance); + if (result is Future) unawaited(result); + } } super.dispose(); } @@ -217,23 +373,36 @@ class _ScopedHostState extends State { /// Page-scoped state access from any descendant of the page. extension ModularStateX on BuildContext { - /// Reactively reads a page-scoped [Listenable] (rebuilds on notify). - T watch() { + /// Reactively reads a page-scoped value of type [T] (rebuilds when its + /// trigger fires). For [Scoped.addStreamable]/[Scoped.addListenable], [T] is + /// the object itself. + T watch() { final inherited = dependOnInheritedWidgetOfExactType<_VMInherited>(); - final notifier = inherited?.notifier; - if (notifier == null) { + if (inherited == null) { throw FlutterError('context.watch<$T>(): no scoped $T provided.'); } - return notifier; + return inherited.value; } - /// Reads a page-scoped [Listenable] WITHOUT subscribing to rebuilds. - T read() { + /// Reads a page-scoped value of type [T] WITHOUT subscribing to rebuilds. + T read() { final element = getElementForInheritedWidgetOfExactType<_VMInherited>(); - final notifier = (element?.widget as _VMInherited?)?.notifier; - if (notifier == null) { + final inherited = element?.widget as _VMInherited?; + if (inherited == null) { throw FlutterError('context.read<$T>(): no scoped $T provided.'); } - return notifier; + return inherited.value; + } + + /// Internal: the (value, trigger) pair backing a page-scoped [T], used by + /// `Consumer`/`Selector` to subscribe to the trigger while exposing the value. + @internal + ({T value, Listenable? trigger}) scopedPair() { + final element = getElementForInheritedWidgetOfExactType<_VMInherited>(); + final inherited = element?.widget as _VMInherited?; + if (inherited == null) { + throw FlutterError('no scoped $T provided.'); + } + return (value: inherited.value, trigger: inherited.notifier); } } diff --git a/pubspec.yaml b/pubspec.yaml index 90795767..d0613265 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_modular description: Smart project structure with dependency injection and route management for Flutter. -version: 7.0.0 +version: 7.0.1 homepage: https://github.com/Flutterando/modular repository: https://github.com/Flutterando/modular issue_tracker: https://github.com/Flutterando/modular/issues diff --git a/test/disposable_test.dart b/test/disposable_test.dart index 4c7a9218..d4c7c115 100644 --- a/test/disposable_test.dart +++ b/test/disposable_test.dart @@ -24,7 +24,7 @@ final feedModule = createModule( c.route( '/feed', provide: (s) => s - ..addDisposable(Connection.new) + ..add(Connection.new) ..addChangeNotifier(FeedVM.new), child: (ctx, state) { captured = ctx.watch().connection; @@ -38,8 +38,8 @@ void main() { setUp(() => captured = null); testWidgets( - 'page-scoped Disposable is the same instance the VM injects (per-page ' - 'singleton) and is disposed on unmount — without being reactive', + 'page-scoped add() instance is the same the VM injects (per-page ' + 'singleton) and is disposed on unmount because it implements Disposable', (tester) async { final boot = bootstrapModule(feedModule); await tester.pumpWidget( @@ -56,10 +56,10 @@ void main() { final connection = captured!; expect(connection.closed, isFalse); - // Unmount the page. The Connection — never provided through an - // InheritedNotifier — is still cleaned up. Because the VM-injected - // instance is the one that closes, this also proves the per-page - // singleton sharing. + // Unmount the page. The Connection — registered via add(), non-reactive + // — is cleaned up because it implements Disposable. Because the + // VM-injected instance is the one that closes, this also proves the + // per-page singleton sharing. await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); diff --git a/test/listenable_test.dart b/test/listenable_test.dart new file mode 100644 index 00000000..983c7fbb --- /dev/null +++ b/test/listenable_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// An object whose reactivity lives on a [Listenable] *property* (a +/// `ValueNotifier`), not on the object itself — the case `addListenable` +/// exists for. `watch()` returns the holder; rebuilds are driven by +/// `holder.counter`. +class Holder { + final ValueNotifier counter = ValueNotifier(0); + bool disposed = false; + + void increment() => counter.value++; + + void dispose() { + disposed = true; + counter.dispose(); + } +} + +Holder? captured; + +final holderModule = createModule( + register: (c) { + c.route( + '/holder', + provide: (s) => s.addListenable( + Holder.new, + (holder) => holder.counter, + (holder) => holder.dispose(), + ), + child: (ctx, state) { + final holder = ctx.watch(); + captured = holder; + return Scaffold( + body: Column( + children: [ + Text('v:${holder.counter.value}'), + TextButton( + onPressed: () => ctx.read().increment(), + child: const Text('inc'), + ), + ], + ), + ); + }, + ); + }, +); + +void main() { + setUp(() => captured = null); + + testWidgets( + 'addListenable exposes the object; watch rebuilds when its Listenable ' + 'property notifies; dispose is called on unmount', + (tester) async { + final boot = bootstrapModule(holderModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/holder', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('v:0'), findsOneWidget); + + await tester.tap(find.text('inc')); + await tester.pumpAndSettle(); + expect(find.text('v:1'), findsOneWidget); + + final holder = captured!; + expect(holder.disposed, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + expect(holder.disposed, isTrue); + }, + ); +} diff --git a/test/simple_add_test.dart b/test/simple_add_test.dart new file mode 100644 index 00000000..51efbe7e --- /dev/null +++ b/test/simple_add_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A plain, non-reactive object with no lifecycle. +class AppConfig { + final String title = 'Modular'; +} + +/// A non-reactive object that opts into cleanup by implementing [Disposable]. +class DisposableConfig implements Disposable { + bool disposed = false; + + @override + void dispose() => disposed = true; +} + +AppConfig? capturedConfig; +DisposableConfig? capturedDisposable; + +final addModule = createModule( + register: (c) { + c.route( + '/cfg', + provide: (s) => s + ..add(AppConfig.new) + ..add(DisposableConfig.new), + child: (ctx, state) { + capturedConfig = ctx.read(); + capturedDisposable = ctx.read(); + return Text( + 'cfg:${ctx.watch().title}', + textDirection: TextDirection.ltr, + ); + }, + ); + }, +); + +final dupModule = createModule( + register: (c) { + c.route( + '/dup', + provide: (s) => s + ..add(AppConfig.new) + ..add(AppConfig.new), + child: (ctx, state) => const SizedBox(), + ); + }, +); + +void main() { + setUp(() { + capturedConfig = null; + capturedDisposable = null; + }); + + testWidgets( + 'add() exposes a non-reactive object via read/watch and disposes it on ' + 'unmount only when it implements Disposable', + (tester) async { + final boot = bootstrapModule(addModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/cfg', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('cfg:Modular'), findsOneWidget); + expect(capturedConfig, isNotNull); + final disposable = capturedDisposable!; + expect(disposable.disposed, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + + // The Disposable one is cleaned up; the plain AppConfig simply has no + // dispose to call (and nothing crashes). + expect(disposable.disposed, isTrue); + }, + ); + + testWidgets('registering the same type twice throws a FlutterError', ( + tester, + ) async { + final boot = bootstrapModule(dupModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/dup', + ), + ), + ); + + // The router builds the page (and runs ScopedHost.initState, where the + // duplicate-type guard lives) a frame after mount. Pump until it surfaces. + Object? error; + for (var i = 0; i < 5 && error == null; i++) { + await tester.pump(); + error = tester.takeException(); + } + + expect(error, isA()); + }); +} diff --git a/test/streamable_test.dart b/test/streamable_test.dart new file mode 100644 index 00000000..33e78768 --- /dev/null +++ b/test/streamable_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A minimal Cubit-like object: synchronous [state], a [stream] of changes, and +/// an async [close]. flutter_modular needs no dependency on the `bloc` +/// package — `addStreamable` takes the stream/close as callbacks. +class CounterCubit { + final StreamController _controller = StreamController.broadcast(); + int state = 0; + bool closed = false; + + Stream get stream => _controller.stream; + + void increment() { + state++; + _controller.add(state); + } + + Future close() async { + closed = true; + await _controller.close(); + } +} + +/// A two-field variant, to prove `Selector` rebuilds only when the SELECTED +/// value changes even though the trigger is a stream. +class MultiCubit { + final StreamController _controller = StreamController.broadcast(); + int a = 0; + int b = 0; + + Stream get stream => _controller.stream; + + void incA() { + a++; + _controller.add(null); + } + + void incB() { + b++; + _controller.add(null); + } + + Future close() => _controller.close(); +} + +CounterCubit? capturedCubit; +int selectorBuilds = 0; + +final counterModule = createModule( + register: (c) { + c.route( + '/counter', + provide: (s) => s.addStreamable( + CounterCubit.new, + (cubit) => cubit.stream, + (cubit) => cubit.close(), + ), + child: (ctx, state) { + final cubit = ctx.watch(); + capturedCubit = cubit; + return Scaffold( + body: Column( + children: [ + Text('count:${cubit.state}'), + TextButton( + onPressed: () => ctx.read().increment(), + child: const Text('inc'), + ), + ], + ), + ); + }, + ); + }, +); + +final multiModule = createModule( + register: (c) { + c.route( + '/multi', + provide: (s) => s.addStreamable( + MultiCubit.new, + (cubit) => cubit.stream, + (cubit) => cubit.close(), + ), + child: (ctx, state) => Scaffold( + body: Column( + children: [ + Selector( + selector: (c, cubit) => cubit.a, + builder: (c, a, _) { + selectorBuilds++; + return Text('a:$a'); + }, + ), + TextButton( + onPressed: () => ctx.read().incA(), + child: const Text('incA'), + ), + TextButton( + onPressed: () => ctx.read().incB(), + child: const Text('incB'), + ), + ], + ), + ), + ); + }, +); + +void main() { + setUp(() { + capturedCubit = null; + selectorBuilds = 0; + }); + + testWidgets( + 'addStreamable exposes the object itself; watch rebuilds on stream emit', + (tester) async { + final boot = bootstrapModule(counterModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/counter', + ), + ), + ); + await tester.pumpAndSettle(); + + // watch() returns the bloc; we read its synchronous state. + expect(find.text('count:0'), findsOneWidget); + + // read() is the same instance (the button increments it). + await tester.tap(find.text('inc')); + await tester.pumpAndSettle(); + + // The stream emission drove the rebuild, and the fact that the count + // advanced proves read() is the same instance as watch. + expect(find.text('count:1'), findsOneWidget); + }, + ); + + testWidgets('addStreamable calls the (async) dispose on unmount', ( + tester, + ) async { + final boot = bootstrapModule(counterModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/counter', + ), + ), + ); + await tester.pumpAndSettle(); + final cubit = capturedCubit!; + expect(cubit.closed, isFalse); + + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + + // close() is async but fire-and-forgotten; `closed` is set synchronously. + expect(cubit.closed, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Selector over a streamable rebuilds only when the selected value changes', + (tester) async { + final boot = bootstrapModule(multiModule); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + injector: boot.injector, + initialRoute: '/multi', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('a:0'), findsOneWidget); + final builds = selectorBuilds; + + // Emitting via incB must NOT rebuild the Selector (it selects `a`). + await tester.tap(find.text('incB')); + await tester.pumpAndSettle(); + expect(selectorBuilds, builds); + expect(find.text('a:0'), findsOneWidget); + + // incA changes the selected value → rebuild. + await tester.tap(find.text('incA')); + await tester.pumpAndSettle(); + expect(selectorBuilds, greaterThan(builds)); + expect(find.text('a:1'), findsOneWidget); + }, + ); +} diff --git a/tool/docs_mcp/CHANGELOG.md b/tool/docs_mcp/CHANGELOG.md index 1e6d1eda..432fbda5 100644 --- a/tool/docs_mcp/CHANGELOG.md +++ b/tool/docs_mcp/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.1 + +- Re-embed the refreshed state-management page: documents the page-scoped + `addStreamable`/`addListenable` escape hatches and BLoC/Cubit registration + (a `addBloc` extension over `addStreamable`). + ## 0.2.0 - Re-embed the documentation rewritten for flutter_modular v7 (the served diff --git a/tool/docs_mcp/lib/src/generated/docs_data.g.dart b/tool/docs_mcp/lib/src/generated/docs_data.g.dart index 4bfd08d5..856ff1c2 100644 --- a/tool/docs_mcp/lib/src/generated/docs_data.g.dart +++ b/tool/docs_mcp/lib/src/generated/docs_data.g.dart @@ -43,7 +43,7 @@ const List docPages = [ DocPage( path: "flutter_modular/state-management.md", title: "State management", - markdown: "# State management\n\nThis is the architecture Modular pushes. State is **scoped** and has a **deterministic\nlifecycle**: a view model is built when its page mounts and disposed when the page\nleaves the stack. You don't own globals and you don't write `dispose` calls — the\nframework does. The durable truth stays in a repository/service in\n[DI](./dependency-injection.md); view models are disposable projections over it.\n\n## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..addDisposable(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe `Scoped` registrar (`s`) offers three kinds of registration:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addDisposable(ctor)` | a non‑reactive resource (socket, use‑case) | ❌ | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.\n\n### addChangeNotifier — a reactive view model\n\nThe bound type is a `ChangeNotifier` (not a bare `Listenable`) precisely so disposal is\nguaranteed. A page‑scoped VM reads the source of truth instead of holding it:\n\n```dart\n/// Page-scoped: 1:1 with the list view. Reads the repository (SSoT), doesn't own truth.\nclass ProductListViewModel extends ChangeNotifier {\n ProductListViewModel(this._repo); // repo injected from the module graph\n final ProductRepository _repo;\n\n bool loading = true;\n List products = const [];\n\n Future load() async {\n products = await _repo.getProducts();\n loading = false;\n notifyListeners();\n }\n}\n```\n\n### addDisposable — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**, and `dispose()`d on exit:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```\n\n### addStream — stream‑backed state\n\n`addStream` exposes the latest value of a stream as a `StreamValue`:\n\n```dart\nStream _viewersStream() =>\n Stream.periodic(const Duration(seconds: 2), (i) => 40 + i);\n\n// ...provide: (s) => s.addStream(_viewersStream)...\n\n// In the page:\nfinal viewers = context.watch>().value; // latest int, or null\n```\n\n## Reading state: `watch` and `read`\n\nFrom any descendant of the page, reach a provided `Listenable`:\n\n```dart\nfinal vm = context.watch(); // rebuilds this widget on notify\ncontext.read().load(); // reads without subscribing (callbacks)\n```\n\n- `context.watch()` subscribes — the widget rebuilds when `T` notifies.\n- `context.read()` does **not** subscribe — use it in callbacks (`onPressed`) and\n one‑shot calls.\n\n## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```\n\n## App‑scoped state {#app-scoped-state}\n\nPage‑scoped state lives *below* the `Navigator`, so it cannot rebuild the `MaterialApp`\nitself. For app‑global state that drives **theme, locale, or session**, declare it on\n`ModularApp.provide` — the very same `Scoped` mechanism, only anchored **above** the\n`MaterialApp`:\n\n```dart\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n provide: (s) => s.addChangeNotifier(ThemeController.new),\n child: const AppRoot(),\n ),\n );\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n final theme = context.watch(); // above the MaterialApp\n return MaterialApp.router(\n themeMode: theme.mode,\n theme: ThemeData.light(useMaterial3: true),\n darkTheme: ThemeData.dark(useMaterial3: true),\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n```dart\nclass ThemeController extends ChangeNotifier {\n ThemeMode mode = ThemeMode.light;\n void toggle() {\n mode = mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;\n notifyListeners();\n }\n}\n```\n\nBecause it is anchored above the `Navigator`, a page deep in the tree can still reach it\nwith `context.read().toggle()` — and the whole app re‑themes.\n\n## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `addDisposable` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).\n\n## Why this is the architecture\n\n- **One home for the truth.** Repositories/services are root‑owned singletons; a view\n model reads them and never becomes a competing source of truth.\n- **No floating state.** A VM exists exactly as long as its page; leaving disposes it.\n- **State management gets lighter.** Lifecycle and provision are the framework's job, so\n whatever reactivity you choose has less to carry.", + markdown: "# State management\n\nThis is the architecture Modular pushes. State is **scoped** and has a **deterministic\nlifecycle**: a view model is built when its page mounts and disposed when the page\nleaves the stack. You don't own globals and you don't write `dispose` calls — the\nframework does. The durable truth stays in a repository/service in\n[DI](./dependency-injection.md); view models are disposable projections over it.\n\n## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..add(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe rule is **`addChangeNotifier`** (a reactive view model) and **`addStream`**\n(stream‑backed state); **`add`** registers a plain non‑reactive object. The `Scoped`\nregistrar (`s`) offers:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n| `add(ctor)` | a non‑reactive object (socket, use‑case, config) | ❌ | ✅ if it implements `Disposable` |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.\nFor reactive objects that don't fit the two rules above — a **BLoC**, a **Cubit**, a\ncontroller exposing a `Listenable` — there are two escape hatches,\n[`addStreamable` and `addListenable`](#exceptions-addstreamable-and-addlistenable).\n\n### addChangeNotifier — a reactive view model\n\nThe bound type is a `ChangeNotifier` (not a bare `Listenable`) precisely so disposal is\nguaranteed. A page‑scoped VM reads the source of truth instead of holding it:\n\n```dart\n/// Page-scoped: 1:1 with the list view. Reads the repository (SSoT), doesn't own truth.\nclass ProductListViewModel extends ChangeNotifier {\n ProductListViewModel(this._repo); // repo injected from the module graph\n final ProductRepository _repo;\n\n bool loading = true;\n List products = const [];\n\n Future load() async {\n products = await _repo.getProducts();\n loading = false;\n notifyListeners();\n }\n}\n```\n\n### add — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**. If it implements `Disposable`, it is\n`dispose()`d on exit — `add` **always** checks for `Disposable`:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```\n\n### addStream — stream‑backed state\n\n`addStream` exposes the latest value of a stream as a `StreamValue`:\n\n```dart\nStream _viewersStream() =>\n Stream.periodic(const Duration(seconds: 2), (i) => 40 + i);\n\n// ...provide: (s) => s.addStream(_viewersStream)...\n\n// In the page:\nfinal viewers = context.watch>().value; // latest int, or null\n```\n\n## Exceptions: addStreamable and addListenable\n\n`addChangeNotifier` and `addStream` cover the common cases. When an object's reactivity\nlives on a **property** — its `stream`, or a `Listenable` it exposes — and you want to\nexpose the **object itself** (to read its synchronous state and call its methods), reach\nfor these two escape hatches. Each takes a factory, a selector for the reactive source,\nand a (required) dispose callback:\n\n- `addStreamable(ctor, (t) => t.stream, (t) => t.close())` — reactivity is a `Stream`.\n `context.watch()` returns the object; rebuilds fire on each emission.\n- `addListenable(ctor, (t) => t.someListenable, (t) => t.dispose())` — reactivity is a\n `Listenable` property.\n\n```dart\n// A controller that is NOT a ChangeNotifier but exposes one:\nclass SearchController {\n final ValueNotifier query = ValueNotifier('');\n void dispose() => query.dispose();\n}\n\nprovide: (s) => s.addListenable(\n SearchController.new,\n (c) => c.query, // the rebuild trigger\n (c) => c.dispose(), // cleanup on unmount\n);\n```\n\n:::note\nPrefer `addChangeNotifier`/`addStream`. Use `addStreamable`/`addListenable` only when the\nreactive source is a property of the object you want to expose.\n:::\n\n## BLoC and Cubit\n\nA **BLoC** or **Cubit** is exactly the streamable case: it exposes a synchronous `state`,\na `stream` of changes, and an async `close()`. Register it with `addStreamable` —\n`context.watch()` returns the **BLoC/Cubit** itself, so you read `state` directly and\nrebuilds are driven by its stream:\n\n```dart\n// CounterCubit is a Cubit from the `bloc` package.\nroute(\n '/counter',\n provide: (s) => s.addStreamable(\n CounterCubit.new,\n (c) => c.stream,\n (c) => c.close(),\n ),\n child: (ctx, state) {\n final counter = ctx.watch(); // the Cubit itself\n return Text('\${counter.state}'); // read its synchronous state\n },\n);\n```\n\nflutter_modular has **no dependency on the `bloc` package** — `addStreamable` takes the\n`stream` and `close` as callbacks. To make this a one‑liner, add a small extension on\n`Scoped` in your app. Because both **BLoC** and **Cubit** extend `BlocBase` (which has\n`.stream` and `.close()`), a single `addBloc` covers both:\n\n```dart\nimport 'package:bloc/bloc.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// Registers a page-scoped BLoC or Cubit: reactive via its stream, closed on unmount.\nextension BlocScoped on Scoped {\n void addBloc>(B Function() create) =>\n addStreamable(create, (b) => b.stream, (b) => b.close());\n}\n```\n\n```dart\n// Now registering any BLoC or Cubit is one line:\nprovide: (s) => s.addBloc(CounterCubit.new),\n```\n\n:::tip\nWith the extension above, `addBloc(MyBloc.new)` works for both **BLoC** and\n**Cubit** — one line to get a page‑scoped, auto‑closed, reactive instance.\n:::\n\n## Reading state: `watch` and `read`\n\nFrom any descendant of the page, reach a provided `Listenable`:\n\n```dart\nfinal vm = context.watch(); // rebuilds this widget on notify\ncontext.read().load(); // reads without subscribing (callbacks)\n```\n\n- `context.watch()` subscribes — the widget rebuilds when `T` notifies.\n- `context.read()` does **not** subscribe — use it in callbacks (`onPressed`) and\n one‑shot calls.\n\n## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```\n\n## App‑scoped state {#app-scoped-state}\n\nPage‑scoped state lives *below* the `Navigator`, so it cannot rebuild the `MaterialApp`\nitself. For app‑global state that drives **theme, locale, or session**, declare it on\n`ModularApp.provide` — the very same `Scoped` mechanism, only anchored **above** the\n`MaterialApp`:\n\n```dart\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n provide: (s) => s.addChangeNotifier(ThemeController.new),\n child: const AppRoot(),\n ),\n );\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n final theme = context.watch(); // above the MaterialApp\n return MaterialApp.router(\n themeMode: theme.mode,\n theme: ThemeData.light(useMaterial3: true),\n darkTheme: ThemeData.dark(useMaterial3: true),\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n```dart\nclass ThemeController extends ChangeNotifier {\n ThemeMode mode = ThemeMode.light;\n void toggle() {\n mode = mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;\n notifyListeners();\n }\n}\n```\n\nBecause it is anchored above the `Navigator`, a page deep in the tree can still reach it\nwith `context.read().toggle()` — and the whole app re‑themes.\n\n## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `add` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).\n\n## Why this is the architecture\n\n- **One home for the truth.** Repositories/services are root‑owned singletons; a view\n model reads them and never becomes a competing source of truth.\n- **No floating state.** A VM exists exactly as long as its page; leaving disposes it.\n- **State management gets lighter.** Lifecycle and provision are the framework's job, so\n whatever reactivity you choose has less to carry.", ), DocPage( path: "flutter_modular/testing.md", @@ -413,7 +413,7 @@ const List docChunks = [ pageTitle: "State management", heading: "Page‑scoped state with `provide`", anchor: "page-scoped-state-with-provide", - text: "## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..addDisposable(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe `Scoped` registrar (`s`) offers three kinds of registration:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addDisposable(ctor)` | a non‑reactive resource (socket, use‑case) | ❌ | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.", + text: "## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..add(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe rule is **`addChangeNotifier`** (a reactive view model) and **`addStream`**\n(stream‑backed state); **`add`** registers a plain non‑reactive object. The `Scoped`\nregistrar (`s`) offers:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n| `add(ctor)` | a non‑reactive object (socket, use‑case, config) | ❌ | ✅ if it implements `Disposable` |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.\nFor reactive objects that don't fit the two rules above — a **BLoC**, a **Cubit**, a\ncontroller exposing a `Listenable` — there are two escape hatches,\n[`addStreamable` and `addListenable`](#exceptions-addstreamable-and-addlistenable).", ), DocChunk( pagePath: "flutter_modular/state-management.md", @@ -425,9 +425,9 @@ const List docChunks = [ DocChunk( pagePath: "flutter_modular/state-management.md", pageTitle: "State management", - heading: "addDisposable — a non‑reactive resource", - anchor: "adddisposable-a-non-reactive-resource", - text: "### addDisposable — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**, and `dispose()`d on exit:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```", + heading: "add — a non‑reactive resource", + anchor: "add-a-non-reactive-resource", + text: "### add — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**. If it implements `Disposable`, it is\n`dispose()`d on exit — `add` **always** checks for `Disposable`:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```", ), DocChunk( pagePath: "flutter_modular/state-management.md", @@ -436,6 +436,20 @@ const List docChunks = [ anchor: "addstream-stream-backed-state", text: "### addStream — stream‑backed state\n\n`addStream` exposes the latest value of a stream as a `StreamValue`:\n\n```dart\nStream _viewersStream() =>\n Stream.periodic(const Duration(seconds: 2), (i) => 40 + i);\n\n// ...provide: (s) => s.addStream(_viewersStream)...\n\n// In the page:\nfinal viewers = context.watch>().value; // latest int, or null\n```", ), + DocChunk( + pagePath: "flutter_modular/state-management.md", + pageTitle: "State management", + heading: "Exceptions: addStreamable and addListenable", + anchor: "exceptions-addstreamable-and-addlistenable", + text: "## Exceptions: addStreamable and addListenable\n\n`addChangeNotifier` and `addStream` cover the common cases. When an object's reactivity\nlives on a **property** — its `stream`, or a `Listenable` it exposes — and you want to\nexpose the **object itself** (to read its synchronous state and call its methods), reach\nfor these two escape hatches. Each takes a factory, a selector for the reactive source,\nand a (required) dispose callback:\n\n- `addStreamable(ctor, (t) => t.stream, (t) => t.close())` — reactivity is a `Stream`.\n `context.watch()` returns the object; rebuilds fire on each emission.\n- `addListenable(ctor, (t) => t.someListenable, (t) => t.dispose())` — reactivity is a\n `Listenable` property.\n\n```dart\n// A controller that is NOT a ChangeNotifier but exposes one:\nclass SearchController {\n final ValueNotifier query = ValueNotifier('');\n void dispose() => query.dispose();\n}\n\nprovide: (s) => s.addListenable(\n SearchController.new,\n (c) => c.query, // the rebuild trigger\n (c) => c.dispose(), // cleanup on unmount\n);\n```\n\n:::note\nPrefer `addChangeNotifier`/`addStream`. Use `addStreamable`/`addListenable` only when the\nreactive source is a property of the object you want to expose.\n:::", + ), + DocChunk( + pagePath: "flutter_modular/state-management.md", + pageTitle: "State management", + heading: "BLoC and Cubit", + anchor: "bloc-and-cubit", + text: "## BLoC and Cubit\n\nA **BLoC** or **Cubit** is exactly the streamable case: it exposes a synchronous `state`,\na `stream` of changes, and an async `close()`. Register it with `addStreamable` —\n`context.watch()` returns the **BLoC/Cubit** itself, so you read `state` directly and\nrebuilds are driven by its stream:\n\n```dart\n// CounterCubit is a Cubit from the `bloc` package.\nroute(\n '/counter',\n provide: (s) => s.addStreamable(\n CounterCubit.new,\n (c) => c.stream,\n (c) => c.close(),\n ),\n child: (ctx, state) {\n final counter = ctx.watch(); // the Cubit itself\n return Text('\${counter.state}'); // read its synchronous state\n },\n);\n```\n\nflutter_modular has **no dependency on the `bloc` package** — `addStreamable` takes the\n`stream` and `close` as callbacks. To make this a one‑liner, add a small extension on\n`Scoped` in your app. Because both **BLoC** and **Cubit** extend `BlocBase` (which has\n`.stream` and `.close()`), a single `addBloc` covers both:\n\n```dart\nimport 'package:bloc/bloc.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// Registers a page-scoped BLoC or Cubit: reactive via its stream, closed on unmount.\nextension BlocScoped on Scoped {\n void addBloc>(B Function() create) =>\n addStreamable(create, (b) => b.stream, (b) => b.close());\n}\n```\n\n```dart\n// Now registering any BLoC or Cubit is one line:\nprovide: (s) => s.addBloc(CounterCubit.new),\n```\n\n:::tip\nWith the extension above, `addBloc(MyBloc.new)` works for both **BLoC** and\n**Cubit** — one line to get a page‑scoped, auto‑closed, reactive instance.\n:::", + ), DocChunk( pagePath: "flutter_modular/state-management.md", pageTitle: "State management", @@ -462,7 +476,7 @@ const List docChunks = [ pageTitle: "State management", heading: "Disposable {#disposable}", anchor: "disposable-disposable", - text: "## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `addDisposable` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).", + text: "## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `add` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).", ), DocChunk( pagePath: "flutter_modular/state-management.md", diff --git a/tool/docs_mcp/lib/src/server.dart b/tool/docs_mcp/lib/src/server.dart index e74c0b12..349ea95a 100644 --- a/tool/docs_mcp/lib/src/server.dart +++ b/tool/docs_mcp/lib/src/server.dart @@ -14,7 +14,7 @@ import 'generated/docs_data.g.dart'; import 'search_index.dart'; /// Version reported to clients in the MCP `initialize` handshake. -const String serverVersion = '0.2.0'; +const String serverVersion = '0.2.1'; /// URI scheme/prefix under which each doc page is exposed as a resource. const String _uriPrefix = 'modular-docs:///'; diff --git a/tool/docs_mcp/pubspec.yaml b/tool/docs_mcp/pubspec.yaml index 840216db..abbff712 100644 --- a/tool/docs_mcp/pubspec.yaml +++ b/tool/docs_mcp/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_modular_docs_mcp description: >- MCP server that serves the flutter_modular documentation to AI coding clients as searchable resources plus a keyword search tool. -version: 0.2.0 +version: 0.2.1 repository: https://github.com/Flutterando/modular homepage: https://modular.flutterando.com.br topics: