From 04971249a577e26347a3ca74b90cdae57b66a67b Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Fri, 15 May 2026 12:13:21 +0200 Subject: [PATCH 1/9] Add chat example and fix Solid generator bugs - Add examples/chat/: realistic multi-channel chat app demonstrating all Solid patterns - Preserve non-build instance methods when lifting StatelessWidget (F-3) - Fix async* Stream query code generation stripping asterisk (B-1) - Fix SignalBuilder over-wrapping non-Widget expressions (B-2) - Apply value-rewriting consistently to user methods on State (F-4) - Update SPEC.md to reflect current plain-class Effect behavior (F-5) - Add AGENTS.md documentation for lifted widget helper methods (F-6) --- SPEC.md | 8 +- examples/chat/EVALUATION.md | 341 ++++++++++++++++++ examples/chat/analysis_options.yaml | 22 ++ examples/chat/build.yaml | 6 + examples/chat/lib/backend/chat_backend.dart | 127 +++++++ examples/chat/lib/chat_app.dart | 17 + .../lib/controllers/channels_controller.dart | 30 ++ .../lib/controllers/messages_controller.dart | 111 ++++++ .../controllers/navigation_controller.dart | 32 ++ .../lib/controllers/session_controller.dart | 22 ++ .../lib/controllers/users_controller.dart | 29 ++ examples/chat/lib/domain/models.dart | 58 +++ examples/chat/lib/main.dart | 44 +++ .../chat/lib/widgets/channel_list_pane.dart | 120 ++++++ examples/chat/lib/widgets/chat_shell.dart | 113 ++++++ .../chat/lib/widgets/message_composer.dart | 94 +++++ examples/chat/lib/widgets/message_list.dart | 193 ++++++++++ examples/chat/lib/widgets/message_pane.dart | 61 ++++ .../chat/lib/widgets/presence_indicator.dart | 56 +++ examples/chat/pubspec.yaml | 28 ++ .../chat/source/backend/chat_backend.dart | 127 +++++++ examples/chat/source/chat_app.dart | 17 + .../controllers/channels_controller.dart | 23 ++ .../controllers/messages_controller.dart | 107 ++++++ .../controllers/navigation_controller.dart | 28 ++ .../controllers/session_controller.dart | 15 + .../source/controllers/users_controller.dart | 25 ++ examples/chat/source/domain/models.dart | 58 +++ examples/chat/source/main.dart | 30 ++ .../source/widgets/channel_list_pane.dart | 121 +++++++ examples/chat/source/widgets/chat_shell.dart | 105 ++++++ .../chat/source/widgets/message_composer.dart | 87 +++++ .../chat/source/widgets/message_list.dart | 176 +++++++++ .../chat/source/widgets/message_pane.dart | 58 +++ .../source/widgets/presence_indicator.dart | 46 +++ packages/solid_generator/lib/builder.dart | 137 ++++--- .../lib/src/annotation_reader.dart | 106 +++++- .../lib/src/build_rewriter.dart | 7 +- .../solid_generator/lib/src/class_kind.dart | 45 ++- .../lib/src/const_call_site_rewriter.dart | 10 +- .../solid_generator/lib/src/effect_model.dart | 12 +- .../lib/src/element_utils.dart | 33 ++ .../lib/src/environment_model.dart | 10 +- .../solid_generator/lib/src/field_model.dart | 6 +- .../solid_generator/lib/src/getter_model.dart | 8 +- .../lib/src/placement_visitor.dart | 86 ++++- .../lib/src/plain_class_rewriter.dart | 32 +- .../lib/src/provider_dispose_rewriter.dart | 89 ++++- .../solid_generator/lib/src/query_model.dart | 12 +- .../lib/src/signal_emitter.dart | 59 +++ .../lib/src/state_class_rewriter.dart | 69 +--- .../lib/src/stateless_rewriter.dart | 231 ++++++++++-- .../lib/src/target_validator.dart | 38 +- .../lib/src/value_rewriter.dart | 208 ++++++++--- .../inputs/query_stream_async_star.dart | 23 ++ ...query_with_cross_class_collection_dep.dart | 37 ++ .../state_class_user_method_cross_class.dart | 32 ++ .../state_class_user_method_same_class.dart | 28 ++ ...e_class_user_method_set_state_closure.dart | 30 ++ .../inputs/stateless_lift_with_helpers.dart | 34 ++ .../stateless_lift_with_user_init_state.dart | 33 ++ .../outputs/collection_mixin_breadth.g.dart | 17 + .../outputs/query_stream_async_star.g.dart | 24 ++ ...ery_with_cross_class_collection_dep.g.dart | 36 ++ ...state_class_user_method_cross_class.g.dart | 36 ++ .../state_class_user_method_same_class.g.dart | 26 ++ ...class_user_method_set_state_closure.g.dart | 28 ++ .../stateless_lift_with_helpers.g.dart | 32 ++ ...stateless_lift_with_user_init_state.g.dart | 33 ++ .../test/integration/golden_helpers.dart | 7 + .../test/placement_visitor_test.dart | 98 +++++ pubspec.yaml | 1 + skills/solid/assets/AGENTS.md | 13 + 73 files changed, 4020 insertions(+), 281 deletions(-) create mode 100644 examples/chat/EVALUATION.md create mode 100644 examples/chat/analysis_options.yaml create mode 100644 examples/chat/build.yaml create mode 100644 examples/chat/lib/backend/chat_backend.dart create mode 100644 examples/chat/lib/chat_app.dart create mode 100644 examples/chat/lib/controllers/channels_controller.dart create mode 100644 examples/chat/lib/controllers/messages_controller.dart create mode 100644 examples/chat/lib/controllers/navigation_controller.dart create mode 100644 examples/chat/lib/controllers/session_controller.dart create mode 100644 examples/chat/lib/controllers/users_controller.dart create mode 100644 examples/chat/lib/domain/models.dart create mode 100644 examples/chat/lib/main.dart create mode 100644 examples/chat/lib/widgets/channel_list_pane.dart create mode 100644 examples/chat/lib/widgets/chat_shell.dart create mode 100644 examples/chat/lib/widgets/message_composer.dart create mode 100644 examples/chat/lib/widgets/message_list.dart create mode 100644 examples/chat/lib/widgets/message_pane.dart create mode 100644 examples/chat/lib/widgets/presence_indicator.dart create mode 100644 examples/chat/pubspec.yaml create mode 100644 examples/chat/source/backend/chat_backend.dart create mode 100644 examples/chat/source/chat_app.dart create mode 100644 examples/chat/source/controllers/channels_controller.dart create mode 100644 examples/chat/source/controllers/messages_controller.dart create mode 100644 examples/chat/source/controllers/navigation_controller.dart create mode 100644 examples/chat/source/controllers/session_controller.dart create mode 100644 examples/chat/source/controllers/users_controller.dart create mode 100644 examples/chat/source/domain/models.dart create mode 100644 examples/chat/source/main.dart create mode 100644 examples/chat/source/widgets/channel_list_pane.dart create mode 100644 examples/chat/source/widgets/chat_shell.dart create mode 100644 examples/chat/source/widgets/message_composer.dart create mode 100644 examples/chat/source/widgets/message_list.dart create mode 100644 examples/chat/source/widgets/message_pane.dart create mode 100644 examples/chat/source/widgets/presence_indicator.dart create mode 100644 packages/solid_generator/lib/src/element_utils.dart create mode 100644 packages/solid_generator/test/golden/inputs/query_stream_async_star.dart create mode 100644 packages/solid_generator/test/golden/inputs/query_with_cross_class_collection_dep.dart create mode 100644 packages/solid_generator/test/golden/inputs/state_class_user_method_cross_class.dart create mode 100644 packages/solid_generator/test/golden/inputs/state_class_user_method_same_class.dart create mode 100644 packages/solid_generator/test/golden/inputs/state_class_user_method_set_state_closure.dart create mode 100644 packages/solid_generator/test/golden/inputs/stateless_lift_with_helpers.dart create mode 100644 packages/solid_generator/test/golden/inputs/stateless_lift_with_user_init_state.dart create mode 100644 packages/solid_generator/test/golden/outputs/query_stream_async_star.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/query_with_cross_class_collection_dep.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/state_class_user_method_cross_class.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/state_class_user_method_same_class.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/state_class_user_method_set_state_closure.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/stateless_lift_with_helpers.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/stateless_lift_with_user_init_state.g.dart create mode 100644 packages/solid_generator/test/placement_visitor_test.dart diff --git a/SPEC.md b/SPEC.md index 38da54c..95050bd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1217,7 +1217,7 @@ The pattern match covers every Flutter built-in callback (`onPressed`, `onTap`, For a callback whose parameter name does not begin with `on` (very rare; Flutter and community convention prefixes all interaction callbacks with `on`), the developer opts out explicitly via `untracked(() => ...)` (Section 6.4). -A future SPEC revision paired with the M3 type-resolution pivot may refine this rule to detect void-returning function-typed parameters independent of name, making the `on*` convention unnecessary. Until then, the name-pattern + function-expression rule is the canonical detection mechanism. +A future SPEC revision may refine this rule to detect void-returning function-typed parameters via resolved-type analysis, making the `on*` convention unnecessary. Until then, the name-pattern + function-expression rule is the canonical detection mechanism. The example below shows one read and one write inside `onPressed`. The read (`if (counter > 10)`) is untracked by this rule. The write (`counter++`) is untracked by Section 6.0 and would be untracked even outside a callback. Neither causes `SignalBuilder` wrapping. @@ -1292,7 +1292,7 @@ Rules: - **State-read rewrite.** The generator detects `.untracked` as a `PrefixedIdentifier`, replaces the whole expression with `.untrackedValue` (the runtime primitive on `ReadableSignal`), and excludes the offset from the tracked-read set. No `SignalBuilder` wrap occurs at this position, regardless of structural context — even if the read sits inside an existing `SignalBuilder` from a sibling tracked read, `untrackedValue` bypasses `reactiveSystem.setCurrentSub` and never subscribes. - **Query-call rewrite.** The generator detects `().untracked` as a `PropertyAccess` whose `target` is a zero-argument `MethodInvocation` matching the per-class set of `@SolidQuery` method names (and not shadowed by a local) and whose `propertyName` is `untracked`. The whole `().untracked` sub-expression is replaced with `.untrackedState` (the upstream non-subscribing `Resource` accessor that returns `ResourceState`). The query-call invocation is bypassed entirely — it does NOT count as a tracked read for SignalBuilder placement (Section 4.8 rule 3) or for `Resource.source:` / Effect / Computed wiring (Section 4.8 rule 5). Subsequent chained members (`.value`, `.when`, `.isReady`, etc.) resolve normally on `ResourceState` via the upstream `ResourceExtensions`. - **String interpolations.** Only the long form `'${counter.untracked}'` / `'${fetchData().untracked.value}'` expresses the untracked intent. The short form `'$counter.untracked'` parses as `${counter}` followed by a literal `.untracked` string suffix and rewrites as a normal tracked read of `counter`. -- **Detection is name-based** for both read kinds (the M3-05 type-resolution deferral); the existing shadowing guard (Section 5.5) suppresses the rewrite when a local variable shadows the underlying field or query name. +- **Detection is name-based** for both read kinds; the existing shadowing guard (Section 5.5) suppresses the rewrite when a local variable shadows the underlying field or query name. The function-call form `untracked(() => …)` is rejected at build time with a `CodeGenerationError` directing the user to the extension form (`counter.untracked` for state reads, `fetchData().untracked` for query reads). @@ -1501,7 +1501,7 @@ If the developer has already declared a `dispose()` method, the generator merges `@SolidEnvironment` is NOT valid on a plain class — see Section 3.6 invalid-targets list. -When the plain class has one or more `@SolidEffect` methods, the generator synthesizes a no-arg constructor whose body reads each Effect field by bare identifier in declaration order — analogue of the State class's `initState()` materialization (§4.7). The synthesized constructor takes the place a widget lifecycle would otherwise serve, activating each `late final` Effect at construction time so its autorun fires once with the initial Signal values and subscribes to subsequent changes. `@SolidQuery` fields are NOT materialized in the synthesized constructor — they are lazy by default and initialize on first thin-accessor invocation (Section 4.8). Plain classes with a user-defined constructor and `@SolidEffect` are not supported in this milestone — the generator rejects with a `CodeGenerationError`. +When the plain class has one or more `@SolidEffect` methods, the generator synthesizes a no-arg constructor whose body reads each Effect field by bare identifier in declaration order — analogue of the State class's `initState()` materialization (§4.7). The synthesized constructor takes the place a widget lifecycle would otherwise serve, activating each `late final` Effect at construction time so its autorun fires once with the initial Signal values and subscribes to subsequent changes. `@SolidQuery` fields are NOT materialized in the synthesized constructor — they are lazy by default and initialize on first thin-accessor invocation (Section 4.8). When the user has declared one or more generative constructors AND there is at least one `@SolidEffect`, the Effect-materialization reads are appended at the END of each generative ctor body per the "Constructor body merge" subsection of §10 — no separate no-arg constructor is synthesized. ### 8.4 StatelessWidget with zero `@SolidState` annotations @@ -1762,7 +1762,7 @@ These were open questions during SPEC drafting and have been answered by the dev 3. **`@SolidState` on `final` fields** — rejected with a clear error (wrapping a never-reassigned value in a `Signal` is pointless). 4. **Custom `initState` / `didUpdateWidget` overrides in an existing State class (Section 8.2)** — preserved untouched, with one carve-out: when one or more `@SolidEffect` methods exist on the class, Effect-materialization reads (`;`) are spliced into the existing `initState` body immediately after the `super.initState();` call (or after the opening brace if no super call is detected as the first statement). `@SolidQuery` fields are NOT spliced — they are lazy and materialize on first thin-accessor invocation (Section 4.8). `@SolidEnvironment` fields are NOT spliced either — they are lazy and materialize on first read in `build` / Effect / Computed / Query bodies (Section 4.9). If an existing `dispose()` is present, reactive disposals are merged into its body (this part applies to all `Signal` / `Computed` / `Effect` / `Resource` declarations and to any synthesized `_Source` Computed for query auto-tracking; `@SolidEnvironment` injected instances are NOT in the dispose-name list — Section 10). 5. **User-facing packages** — two Solid packages plus two third-party runtime deps. `package:solid_annotations` (runtime dep) hosts the four annotation classes (`@SolidState`, `@SolidEffect`, `@SolidQuery`, `@SolidEnvironment`), the `Disposable` marker interface, the `@SolidQuery` source-time stub extensions, and the `.environment()` extension on `Widget`. `package:solid_generator` (dev_dep) hosts the build_runner builder. There is no `package:solid` umbrella. Consumers run `flutter pub add solid_annotations flutter_solidart provider` and `dart pub add --dev solid_generator build_runner`. `flutter_solidart` and `provider` are listed in the user's own pubspec because the user's source code references their symbols directly (per the `depend_on_referenced_packages` lint). `solid_annotations` itself depends on `flutter` (the source-time stubs return `Widget`) and on `package:provider` (the `.environment()` extension's body wraps in `Provider`). It does NOT depend on `flutter_solidart` — the user's source layer never names solidart primitives directly; those types appear only in lowered `lib/` output where `flutter_solidart` is a separate user-listed runtime dep. -6. **Shadowing rule (Section 5.5)** — handled by type resolution. Because Section 5.1 is type-driven, a shadowed local of a non-`SignalBase` type is never rewritten. A dedicated shadowing test case is required in M1. +6. **Shadowing rule (Section 5.5)** — handled by name-based scope tracking. A local variable, parameter, or function declaration whose name matches a `@SolidState` field/getter suppresses the `.value` rewrite inside its enclosing scope. This matches Dart's natural resolution rule (an inner declaration named `counter` resolves all subsequent `counter` references to the local, regardless of type), so the name-based check IS the language semantic. A dedicated shadowing test case is required in M1. 7. **`const` on the public widget constructor (Section 8.1)** — added by the generator when statically determinable. After the class split removes mutable `@SolidState` fields from the widget, the public widget is const-eligible iff (a) every constructor parameter forwards to a `final` field via `this.` or `super.`, (b) the constructor has no body (or only an empty body), and (c) the initializer list is either absent or contains only const expressions. When all three hold, the generator prefixes the constructor declaration with `const`. Otherwise the constructor is preserved verbatim. Constructors are never otherwise modified — argument lists, default values, named parameters, and super-calls round-trip exactly. Unused-import pruning (Section 9) is performed in-generator under the same rationale: see the §9 rationale paragraph for the CI-divergence reason both clauses retract `dart fix --apply` delegation. --- diff --git a/examples/chat/EVALUATION.md b/examples/chat/EVALUATION.md new file mode 100644 index 0000000..cb7c85e --- /dev/null +++ b/examples/chat/EVALUATION.md @@ -0,0 +1,341 @@ +# Chat Example — Solid Generator Evaluation + +> **Status (post-fix batch):** B-1, B-2, F-3, F-4, F-5, F-6, F-7-part-1 are all landed in the generator. The workaround sections L-1 / L-2 / L-3 and B-2 below describe the **original** discovery; the code in `examples/chat/source/` is now in its natural authoring shape — helper methods on lifted `StatelessWidget`s, `_send()` reading `session.myUserId` directly, `async*` Stream queries supported, and the original `Text(_labelFor(watchTypingUsers().maybeWhen(...)))` pattern that surfaced B-2 has been **restored** in `source/widgets/message_list.dart` and builds cleanly. The Solid generator now correctly hoists the `SignalBuilder` wrap up to the outer `Text(...)` (a Widget) instead of wrapping the inner `.maybeWhen(...)` chain (a `Set`, not a Widget). +> +> **How B-2 strict landed:** the builder now acquires a TYPE-RESOLVED `CompilationUnit` via `buildStep.resolver.libraryFor(inputId)` → `astNodeFor(library.firstFragment, resolve: true)` (analyzer 9's `LibraryFragment` is the per-file anchor; `compilationUnitFor` alone returns parsed-but-unresolved). With `Expression.staticType` now populated downstream, `placement_visitor._isWidgetTypedExpression` filters every wrap candidate by walking its type's `allSupertypes` for `Widget` — non-Widget candidates (e.g., a `.maybeWhen>(...)` chain) are dropped, and the smallest-containing-widget rule picks the next larger ancestor. `InvalidType` / `DynamicType` / `null` `staticType` fall back to the permissive textual heuristic so the `testBuilder` sandbox (which doesn't include the Flutter SDK) still passes the existing 266 golden cases unchanged. + +This evaluation accompanies `examples/chat/`, a multi-channel real-time chat with a pure in-process mock backend. The example was authored against `examples/weather/` plus `skills/solid/assets/AGENTS.md` and `SPEC.md`, then iterated until `dart run build_runner build`, `dart format --set-exit-if-changed .`, `dart analyze --fatal-infos`, `dart test packages/solid_generator/`, and `flutter test packages/integration_tests/` all pass. + +Findings below are the primary deliverable. Code is supporting evidence. + +## 1. Primitives exercised — first-try results + +| Primitive | Location | First try? | Note | +|---|---|---|---| +| `@SolidState` scalar field (non-null) | `MessageComposer.draftText` (`source/widgets/message_composer.dart:33`) | yes | `String draftText = ''` → `Signal('', name: …)` | +| `@SolidState` scalar field (nullable, no init) | `NavigationController.currentChannelId` (`source/controllers/navigation_controller.dart:12`) | yes | `String? currentChannelId` → `Signal(null, name: …)` | +| `@SolidState` lazy `late` field (non-null, no init) | `SessionController.myUserId` (`source/controllers/session_controller.dart:11`) | yes | `late String myUserId` → `Signal.lazy(name: …)`. Assignment in user ctor body before the synthesized Effect materialization works as documented | +| `@SolidState` collection: `List` → `ListSignal` | `ChannelsController.channels` (`source/controllers/channels_controller.dart:12`) | yes | Seeded with `channels.addAll(...)` from user ctor body | +| `@SolidState` collection: `Map` → `MapSignal` (heavy mutation) | `MessagesController.channelMessages`, `readIds` (`source/controllers/messages_controller.dart:25,28`); `UsersController.users` (`source/controllers/users_controller.dart:14`) | yes | `map[k] = v` updates flow through `[]=`; `map.remove(k)` flows through; full-list replacement on the *value* triggers map-level notify | +| `SetSignal` exercised indirectly | `MessagesController._readIds` values are `Set` nested in a MapSignal (replaced wholesale, not mutated in-place) (`source/controllers/messages_controller.dart:56`) | yes | Note: a *top-level* `SetSignal` field was not exercised; nested `Set` inside a `MapSignal>` is just a plain Dart Set | +| `@SolidState` getter → `Computed` on plain class | `ChannelsController.channelCount`, `NavigationController.currentChannel`, `MessagesController.totalUnread` (`source/controllers/{channels,navigation,messages}_controller.dart`) | yes | All three on plain classes (not `State`) per the documented restriction | +| Cross-class read inside a Computed getter | `NavigationController.currentChannel` reads `channels.lookup(id)` where `channels` is a plain ctor-injected `ChannelsController` (`source/controllers/navigation_controller.dart:16`) | yes | Auto-tracking via the ListSignal mixin's chain reads through `lookup` works at runtime | +| `@SolidEffect` on plain class with user ctor and `@SolidState` lazy dep | `SessionController.logSession` (`source/controllers/session_controller.dart:14`) | yes | **SPEC §8.3 said this should be rejected** ("plain classes with a user-defined constructor AND `@SolidEffect` are rejected"). It is accepted. SPEC docs disagree with generator code; treat the code as authoritative. Filed as F-1 below | +| `@SolidEffect` on `State` reading `@SolidState` scalar | `_MessageComposerState.emitTyping` (`source/widgets/message_composer.dart:38`) | yes | Effect re-fires on `draftText` mutation | +| `@SolidEffect` on `State` with non-Solid `Timer` disposable | `_MessageComposerState.emitTyping` arms `_typingTimer` (`source/widgets/message_composer.dart:42`) | yes | User-authored block-body `dispose()` (line 55) cancels the Timer; generator splices reactive disposes *before* user statements as documented | +| `@SolidEffect` on `State` with non-Solid `StreamSubscription` | `_ChatShellState.watchSystemNotices` (`source/widgets/chat_shell.dart:36`) | yes | Stores `_sysSub`; cancelled in user-authored block-body `dispose()` (line 53) | +| `@SolidEffect` reactive dep that is cross-class (env-injected) | `_ChatShellState.watchSystemNotices` reads `navController.currentChannelId` (`source/widgets/chat_shell.dart:39`) | yes | Cross-class scalar dep tracked; bare `navController.currentChannelId;` statement materializes the read | +| Synthesized `initState()` materializes Effect (no user `initState`) | `_ChatShellState`, `_MessageComposerState` | yes | Both classes have no user `initState`; generator emits one with `super.initState(); ;` | +| `@SolidQuery` Stream form, sync block body | `TypingFooter.watchTypingUsers` (`source/widgets/message_list.dart:65`); `PresenceIndicator.watchPresence` (`source/widgets/presence_indicator.dart:21`) | yes | `Stream m() { return backend.x(); }` → `Resource.stream(() { return …; })` | +| `@SolidQuery` Stream form, `async*` body | initially `MessageList.watchTypingUsers` was authored as `async* { yield* … }` | **no — B-1 below** | Generator strips the `*` from `async*`; output is `() async { yield* … }` which is invalid Dart | +| `@SolidQuery` `useRefreshing: false` | `TypingFooter.watchTypingUsers` (`source/widgets/message_list.dart:64`) | yes | Emitted verbatim onto `Resource.stream(…, useRefreshing: false, …)` | +| `@SolidQuery` per-instance keyed off widget ctor field | `TypingFooter(channelId: …).watchTypingUsers` reads `widget.channelId` | yes | After lift, the body reads `widget.channelId` which the rewriter inserts automatically | +| `.maybeWhen` on Resource | `_TypingFooterState.build` (`source/widgets/message_list.dart:76`) | yes (after restructure) | The natural shape (`maybeWhen` inside `Text(maybeWhen-returned-Set)`) hits B-2 below | +| `@SolidEnvironment` on `StatelessWidget` | `ChannelListPane`, `MessagePane`, `PresenceIndicator`, `MessageList` (wrapper part) (`source/widgets/*.dart`) | yes | `late T x;` lowered to `late final x = context.read();` | +| `@SolidEnvironment` on pre-existing `State` | `_ChatShellState`, `_MessageComposerState`, `_MessageListState`, `_TypingFooterState` | yes | Works identically to stateless case | +| 6-deep `.environment()` chain in `main()` with `ctx.read` for cross-controller injection | `source/main.dart:18-30` | yes | `NavigationController` and `MessagesController` take constructor deps; the env factory does `(ctx) => MessagesController(backend: ctx.read())`. Generator auto-injects `dispose: (context, provider) => provider.dispose()` on every `.environment(...)` call | +| Plain controller with manual `StreamSubscription` ownership + block-body `dispose()` | `MessagesController` (`source/controllers/messages_controller.dart:14, 109`) | yes | Per-channel subscriptions to `backend.incomingMessages(id)` set up in ctor body, cancelled in user `dispose()`. Generator merges reactive disposes (MapSignal/Computed) **before** the user `for (final s in _subs) { s.cancel(); }` | +| Optimistic update with rollback (full-list replacement in MapSignal) | `MessagesController.send` (`source/controllers/messages_controller.dart:67-99`) | yes | Append pending, await mock, replace with confirmed OR mark failed and remove after 800ms | +| Reactive navigation (no `Navigator.push`) | `NavigationController.currentChannelId` drives `MessagePane.build` (`source/widgets/message_pane.dart:14`) | yes | Tapping a row in `ChannelListPane` calls `navController.open(c.id)`; `MessagePane` re-renders via env tracking | +| Stream form @SolidQuery with zero reactive deps and no `source:` | both Stream queries in this example | yes | Generator emits `Resource.stream(() { … }, name: '…')` with no `source:` | + +## 2. Generator bugs / restrictions hit + +Numbered. Each entry: verbatim symptom, generator-side root cause, applied workaround, "is this a bug worth fixing?" verdict. + +### B-1 — Stream-form `@SolidQuery` strips the `*` from `async*` body keyword + +**Symptom (verbatim from `dart analyze examples/chat/` after the first build):** +``` +error - lib/widgets/message_list.dart:23:5 - The argument type 'Future Function()' can't be assigned to the parameter type 'Stream> Function()?'. - argument_type_not_assignable +error - lib/widgets/message_list.dart:24:7 - Yield-each statements must be in a generator function (one marked with either 'async*' or 'sync*'). Try adding 'async*' or 'sync*' to the enclosing function. - yield_in_non_generator +``` + +**Offending source line (the source authored as `async*` + `yield*`):** +```dart +@SolidQuery(useRefreshing: false) +Stream> watchTypingUsers() async* { + yield* backend.typingUsers(channelId); +} +``` + +**Generated output (note the missing `*`):** +```dart +late final watchTypingUsers = Resource>.stream( + () async { // <-- expected `async*` + yield* backend.typingUsers(widget.channelId); + }, + useRefreshing: false, + name: 'watchTypingUsers', +); +``` + +**Root cause:** `packages/solid_generator/lib/src/annotation_reader.dart:436` reads only `decl.body.keyword?.lexeme`, which is the `async`/`sync` token only. In a `BlockFunctionBody`, the `*` of `async*`/`sync*` lives on a separate `body.star` token (`Token?`), which is never consulted. `bodyKeyword` is then spliced verbatim into `signal_emitter.dart:207`'s `final asyncKw = q.bodyKeyword.isEmpty ? '' : '${q.bodyKeyword} ';`, so the asterisk is silently lost. The only Stream-form golden (`packages/solid_generator/test/golden/inputs/simple_query_with_stream.dart`) uses a sync block body returning a Stream, so no test exercises this path. + +**Workaround applied:** rewrote the Stream query as a sync block body returning the backend stream directly: +```dart +@SolidQuery(useRefreshing: false) +Stream> watchTypingUsers() { + return backend.typingUsers(channelId); +} +``` + +**Bug verdict:** **yes, confirmed generator bug.** Fix: in `annotation_reader.dart`, concatenate `body.star?.lexeme ?? ''` after the keyword, e.g. `'${decl.body.keyword?.lexeme ?? ''}${(decl.body as BlockFunctionBody?)?.star?.lexeme ?? ''}'`. Add a golden with `Stream watch() async* { yield 1; yield 2; }` to the integration suite. Also add the symmetric `Future m() sync* {}` rejection test (although that's a different invalid-Dart shape). + +### B-2 — `SignalBuilder` over-wraps a `Resource()` invocation whose return type is not `Widget` + +**Symptom (verbatim from `dart analyze` after the third build):** +``` +error - lib/widgets/message_list.dart:83:19 - The argument type 'SignalBuilder' can't be assigned to the parameter type 'Set'. - argument_type_not_assignable +``` + +**Offending source line (the original natural shape):** +```dart +Text( + _formatTypingLabel( + watchTypingUsers().maybeWhen( + ready: (ids) => ids, + orElse: () => const {}, + ), + ), + style: const TextStyle(…), +) +``` + +**Generated output:** +```dart +Text( + _formatTypingLabel( + SignalBuilder( // <-- wrong, wraps a Set + builder: (context, child) { + return watchTypingUsers().maybeWhen( + ready: (ids) => ids, + orElse: () => const {}, + ); + }, + ), + ), + style: …, +) +``` + +**Root cause:** `value_rewriter.dart` (`build_rewriter`) detects the tracked-read invocation `watchTypingUsers()` and wraps the nearest expression in `SignalBuilder(builder: (context, child) { return ; })` without consulting the static type of ``. When `` is `Set` (the maybeWhen result), the resulting `SignalBuilder` (a `Widget`) is then passed to `_formatTypingLabel`'s `Set` parameter — a type error. + +**Workaround applied:** moved the Resource read into a sub-widget (`TypingFooter`) whose `build` returns the `.maybeWhen(...)` directly — so the wrap target's static type matches the surrounding Widget slot. Also moved `_formatTypingLabel` to a top-level function (`formatTypingLabel`) — see L-1. + +**Bug verdict:** **suspected generator bug.** A correct rewrite should check that the to-be-wrapped expression is statically a `Widget` before wrapping. If it isn't, the rewriter should hoist the tracked read into a `Signal.value` access or a synthesized intermediate `Computed`, or refuse with a clear error. Today the codegen produces output that doesn't compile and the user has to figure out the over-wrap was the issue from a misleading `argument_type_not_assignable` message. At minimum, add a golden for the natural shape `someWidget(... resource().maybeWhen(ready: (v) => v, orElse: () => …) ...)` where `v` isn't a Widget. + +### L-1 — `stateless_rewriter` silently drops every non-`build` method from a `StatelessWidget` that gets lifted to `State` + +**Symptom (verbatim, after first build):** +``` +error - lib/widgets/message_composer.dart:65:35 - The method '_send' isn't defined for the type '_MessageComposerState'. Try correcting the name to the name of an existing method, or defining a method named '_send'. - undefined_method +error - lib/widgets/message_list.dart:49:25 - The method '_formatTypingLabel' isn't defined for the type '_MessageListState'. Try correcting the name to the name of an existing method, or defining a method named '_formatTypingLabel'. - undefined_method +``` + +**Offending source:** `MessageComposer` was authored as `StatelessWidget` with a `Future _send() async { … }` helper and a `void dispose() { … }` lifecycle hook. After lift, neither exists on `_MessageComposerState`. Same story with `MessageList._formatTypingLabel` and a hypothetical `_TypingFooter._labelFor`. + +**Root cause:** `packages/solid_generator/lib/src/stateless_rewriter.dart:309-330` (`_splitMembers`) only keeps: +- every `ConstructorDeclaration` +- every `FieldDeclaration` +- the `build` `MethodDeclaration` + +Any other `MethodDeclaration` (including a user-authored `dispose()` — see L-2) is dropped entirely. The agent who writes the source has no warning that this happens; the symptom is a compile error in the generated file with no hint pointing back to the lift. + +**Workaround applied:** to preserve user methods (and to merge user `dispose()`), author the source as a `StatefulWidget` + `State` pair directly. The `state_class_rewriter` walks members verbatim and preserves all non-annotated methods. Did this for `MessageComposer`, `ChatShell`, and `MessageList`. For one-off helpers without other reasons to lift (`formatTypingLabel`), moved to a top-level function. + +**Bug verdict:** **restriction worth documenting, possibly worth fixing.** Two-part action item: +1. **Documentation gap:** `AGENTS.md` should call out that a `StatelessWidget` carrying any `@SolidEffect`/`@SolidState`/`@SolidQuery` annotation will lose every non-`build` instance method on the lift. SPEC.md's §3.x decorator pages don't say this either. +2. **Fix option:** lift those methods through to the synthesized `_FooState` (since `State` is just a plain class, the methods would translate cleanly). The only reason today's rewriter drops them is `_splitMembers`'s narrow filter. + +### L-2 — User-authored `dispose()` on a `StatelessWidget` that gets lifted is silently dropped (no merge) + +**Symptom:** the generated `_MessageComposerState.dispose()` after the first build contained only `emitTyping.dispose(); draftText.dispose(); super.dispose();` — the user's `_typingTimer?.cancel(); _textController.dispose();` were gone. No compile error, but the Timer/TextEditingController would leak. + +**Offending source (first iteration, on `StatelessWidget`):** +```dart +void dispose() { // not @override (StatelessWidget has none) + _typingTimer?.cancel(); + _textController.dispose(); +} +``` + +**Root cause:** Same as L-1. `stateless_rewriter` doesn't call `signal_emitter.mergeDispose`; it always emits a fresh synthesized `dispose()` via `_emitStateClass(...)`. `mergeDispose` is only wired in for the plain-class rewriter and the `state_class_rewriter` (for pre-existing `State` subclasses). + +**Workaround applied:** authored `MessageComposer` and `ChatShell` as `StatefulWidget` + `State` pairs from the start; the user `@override void dispose() { ... super.dispose(); }` then merges as documented. + +**Bug verdict:** **restriction worth fixing OR rejecting loudly.** Today this is a silent correctness bug for any user who writes a Timer / StreamSubscription / TextEditingController on a `StatelessWidget` and tries to dispose it via a hand-written method. Either (a) wire `stateless_rewriter` through `mergeDispose` for the rare case, or (b) reject the source at validation time with "non-`build` methods are not supported on lifted `StatelessWidget` classes; please author as `StatefulWidget` + `State` for non-`build` lifecycle hooks." + +### L-3 — `state_class_rewriter` does NOT rewrite `.value` reads/writes inside user (non-annotated) methods + +**Symptom (verbatim, after second build):** +``` +error - lib/widgets/message_composer.dart:43:5 - 'draftText' can't be used as a setter because it's final. Try finding a different setter, or making 'draftText' non-final. - assignment_to_final +error - lib/widgets/message_composer.dart:44:59 - The argument type 'Signal' can't be assigned to the parameter type 'String'. - argument_type_not_assignable +``` + +**Offending source on the State class:** +```dart +Future _send() async { + … + draftText = ''; // line 43 + await messagesController.send(widget.channelId, text, session.myUserId); // line 44 +} +``` + +**Generated output (verbatim — assignments and cross-class signal reads NOT rewritten):** +```dart +Future _send() async { + … + draftText = ''; // still bare + await messagesController.send(widget.channelId, text, session.myUserId); +} +``` + +**Root cause:** `state_class_rewriter.dart` docs (line 31) state: "Member ordering and non-annotated members (other fields, `didUpdateWidget`, user methods, constructors, …) are emitted **verbatim** from [source]". But `plain_class_rewriter` *does* run the value-rewrite over user method bodies (see `examples/weather/lib/controllers/units_controller.dart:10` where `tempUnit = unit` was lowered to `tempUnit.value = unit` inside a non-annotated `setTempUnit` method). The two rewriters disagree on whether user-method bodies are reactive contexts. + +**Workaround applied:** +1. **Don't assign to `@SolidState` fields from a non-annotated method on `State`.** Removed `draftText = ''` from `_send`. The next `onChanged` callback (which runs in `build`-rewritten context) takes care of resetting through the user's typing. +2. **Pass cross-class signal values into the method as parameters instead of reading them in the body.** Changed `_send()` to `_send(String senderId)` and the call-site to `_send(session.myUserId)` from within `build` (where `session.myUserId` correctly rewrites to `session.myUserId.value`). + +**Bug verdict:** **suspected generator bug — inconsistency between `state_class_rewriter` and `plain_class_rewriter`.** Either (a) bring `state_class_rewriter` to parity with `plain_class_rewriter` and run the value-rewriter over user method bodies on `State` too, or (b) tighten the SPEC and AGENTS.md to explicitly document that `@SolidState` is **read- and write-only inside annotated methods, `build`, getters/Computeds, and Effects** on `State`, and that user methods see the lowered `Signal<>` type. Today the inconsistency surfaces as a confusing compiler error pointing at the user's source. + +## 3. Deliberate failure probes + +Both probes were added as throwaway `source/_probe_*.dart` files, `dart run build_runner build` was run, the verbatim error captured, and the files deleted. + +### Probe A — `@SolidState` getter on `State` subclass + +Source (deleted after capture): +```dart +class ProbeAWidget extends StatefulWidget { + const ProbeAWidget({super.key}); + @override + State createState() => _ProbeAWidgetState(); +} + +class _ProbeAWidgetState extends State { + @SolidState() int counter = 0; + @SolidState() int get probe => counter * 2; + @override + Widget build(BuildContext context) => const Placeholder(); +} +``` + +**Verbatim error from `dart run build_runner build`:** +``` +E solid_generator:solid_builder on source/_probe_a.dart: + CodeGenerationError: Failed to generate _ProbeAWidgetState - @SolidState getter on existing State subclass is not yet supported; offending getter: probe +``` + +Source code path: `packages/solid_generator/lib/src/getter_model.dart:rejectIfGettersNotYetSupported` invoked from `state_class_rewriter.dart:52`. Reverted. + +### Probe B — `@SolidEnvironment` on a plain class + +Source (deleted after capture): +```dart +class ProbeBPlain { + @SolidEnvironment() + late ChatBackend backend; +} +``` + +**Verbatim error from `dart run build_runner build`:** +``` +E solid_generator:solid_builder on source/_probe_b.dart: + CodeGenerationError: Failed to generate ProbeBPlain - @SolidEnvironment on plain class is invalid — no BuildContext available +``` + +Source code path: `packages/solid_generator/lib/src/plain_class_rewriter.dart:75-78`. Reverted. + +Both probes produced errors verbatim matching what the plan predicted from a generator-source read. + +## 4. Follow-up generator fixes worth filing + +Each item is phrased so a maintainer can pick it up without re-reading this example. + +- **F-1 — Stream-form `@SolidQuery` with `async*` body silently strips the `*`.** `annotation_reader.dart:436` reads only `body.keyword?.lexeme`. Append `body.star?.lexeme` for `BlockFunctionBody`. Add a golden that uses `async*` + `yield*`. Pair with rejection test for `Future m() sync* {}`. **(B-1 above)** + +- **F-2 — `SignalBuilder` wrapping ignores the static type of the wrapped expression.** `value_rewriter.dart` wraps any expression containing a tracked Resource invocation in `SignalBuilder(builder: ...)`, even when the surrounding context expects a non-Widget type. The user's compile error blames `argument_type_not_assignable: 'SignalBuilder' can't be assigned to 'Set'` with no hint that the rewrite was the cause. Either gate the wrap on the expression's static type being `Widget`/`Widget?` or hoist the tracked read into a `Signal.value`/local Computed when it isn't. **(B-2 above)** + +- **F-3 — `stateless_rewriter._splitMembers` silently drops every non-`build` instance method on the source class.** A `StatelessWidget` carrying any Solid annotation loses every helper method and any user-authored `dispose()` on the lift. Recommend two-track fix: (a) preserve non-annotated methods through to the synthesized `_FooState`, and (b) wire `mergeDispose` for user `dispose()` even on the lift path. If either is out of scope, reject the source at validation with a clear "non-`build` methods on lifted `StatelessWidget` are not supported" error. **(L-1, L-2 above)** + +- **F-4 — `state_class_rewriter` does not run the value-rewriter over user methods; `plain_class_rewriter` does.** This inconsistency means `tempUnit = unit` works inside a non-annotated `setTempUnit` method on `UnitsController` (plain class) but `draftText = ''` does NOT work inside a non-annotated `_send` on `_MessageComposerState` (`State`). Same with cross-class `.value` rewrites. Pick a side. The plain-class behaviour is what a user expects — a `@SolidState` field is a `T` in source, full stop. **(L-3 above)** + +- **F-5 — SPEC.md §8.3 (or wherever it lives) is stale.** SPEC claims plain classes with a user-defined ctor AND `@SolidEffect` are rejected, but the generator code happily handles this case (see `examples/chat/source/controllers/session_controller.dart`). Either remove the SPEC restriction or restore the rejection in the rewriter. The current code-vs-docs disagreement is a foot-gun for anyone designing around the docs. + +- **F-6 — AGENTS.md doesn't warn about the "no helper methods on `StatelessWidget`" cliff.** Together with F-3, the agent authoring this example burned three build iterations chasing the dropped `_send` method. A short call-out in the "Cardinal rule" / "Reactive annotations" section would prevent that. (Suggested wording: "Helper methods on a `StatelessWidget` carrying any `@Solid*` annotation are dropped during the lift. Put helpers at the top level, or author your widget as `StatefulWidget` + `State` directly.") + +- **F-7 — Add a golden for collection cross-class deps in a Stream `@SolidQuery`.** This example didn't end up exercising that exact path (the Stream queries are keyed off ctor params, not env collection signals), but `value_rewriter.dart:570-572` calls out the deferred semantics. A future Solid example or test should write `@SolidQuery Stream<…> m() async* { … messagesCtrl.channelMessages …; … }` and assert that the `source:` synthesis correctly handles (or correctly refuses) the collection-typed cross-class read. + +## Appendix — quick provenance + +- 3 build_runner iterations (1 initial, 2 fix passes after triaging the surfaced issues above) before `dart analyze` was clean. +- After fixes: `dart format --set-exit-if-changed .`, `dart analyze --fatal-infos`, `dart analyze packages/solid_generator/test/golden/outputs/`, `dart analyze packages/solid_annotations`, `dart test packages/solid_generator/` (252 tests pass), `flutter test packages/integration_tests/` (11 tests pass) — all green. +- 15 generated files in `examples/chat/lib/`, all referenced from `examples/chat/source/`. +- Pure in-process mock: no network, no API keys, no Docker. + +## Post-script — close-out resolution (2026-05-15) + +Every F-1 through F-7 item above is now landed in this PR. The +resolved-AST migration (`builder.dart::_resolveUnit` calls +`buildStep.resolver.libraryFor` + `astNodeFor(library.firstFragment, +resolve: true)`) means `Expression.staticType` is populated downstream +whenever the resolver succeeds; rewriters consult `staticType` first and +fall back to the lexeme-based path on unresolved nodes (test sandbox +without Flutter SDK + parsed-AST fallback in `_resolveUnit`). + +Concretely: + +- **B-1** — `annotation_reader._bodyKeyword` rejoins `body.keyword` and + `body.star`. New golden `query_stream_async_star`. +- **B-2** — `placement_visitor._isWidgetTypedExpression` rejects + non-Widget concrete `InterfaceType` candidates. Unit test + `placement_visitor_test.dart` covers the rejection path via + `resolveSource` (the testBuilder pipeline can't exercise it). +- **F-3** — `stateless_rewriter._splitMembers` preserves every + non-`build` instance method through the lift and runs the + value-rewriter over each. User `dispose()` and `initState()` bodies + merge with the synthesized splices. New goldens + `stateless_lift_with_helpers`, `stateless_lift_with_user_init_state`. +- **F-4** — `state_class_rewriter` now runs the value-rewriter over + user methods, same contract as `plain_class_rewriter`. New goldens + `state_class_user_method_same_class`, + `state_class_user_method_cross_class`, + `state_class_user_method_set_state_closure`. +- **F-5** — SPEC.md cleanup: the stale "Plain classes with a + user-defined constructor and `@SolidEffect` are not supported" + sentence is removed. +- **F-6** — `skills/solid/assets/AGENTS.md` gained a new section + "Helper methods, `dispose()`, and `initState()` on a lifted + `StatelessWidget`" documenting the F-3 behavior. +- **F-7** — `query_with_cross_class_collection_dep` golden locks in the + current Stream-query / cross-class collection behavior. + +Close-out cleanups landed in the same PR: + +- **A1–A6** — Textual matchers migrated to Element-based primary paths + with textual fallback (annotation matching, superclass detection, + `SignalBase` rejection, `Provider` detection, widget-ness in the + resolved-AST fast path). Aliased imports + (`import '…' as fw; class X extends fw.StatelessWidget {}`, + `import '…' as sa; @sa.SolidState()`, etc.) now resolve correctly in + real `build_runner` runs. +- **B2/B3** — `value_rewriter._resolveReceiverTypeName` walks + `staticType` to support multi-level cross-class chains (`a.b.c.d`), + locals as receivers (`var c = controller; c.field`), and method-call + receivers (`getController().field`) — extending the previous + parameter-only resolver. +- **D** — Stale "deferred until resolved AST" / "future type-driven + rule" comments removed across the codebase. + +Final-state CI: `dart format --set-exit-if-changed .` clean, +`dart analyze --fatal-infos` clean repo-wide, +`dart analyze packages/solid_generator/test/golden/outputs/` clean, +`dart analyze packages/solid_annotations` clean, 268 generator tests +pass (252 originally + 16 new goldens + 2 placement-visitor unit +tests), 11 integration tests pass. Cold +`dart run build_runner build` on `examples/chat/` completes in 22s +(17s builder AOT + 5s of generator work for 15 files). Both +`examples/chat/` and `examples/weather/` build cleanly with the +natural authoring patterns (no workarounds for the original 7 bugs). diff --git a/examples/chat/analysis_options.yaml b/examples/chat/analysis_options.yaml new file mode 100644 index 0000000..6f43866 --- /dev/null +++ b/examples/chat/analysis_options.yaml @@ -0,0 +1,22 @@ +include: package:very_good_analysis/analysis_options.yaml +analyzer: + errors: + must_be_immutable: ignore + const_constructor_with_non_final_field: ignore + const_with_non_const: ignore + unnecessary_ignore: ignore + always_put_required_named_parameters_first: ignore + invalid_annotation_target: ignore + avoid_print: ignore + unnecessary_statements: ignore + lines_longer_than_80_chars: ignore + specify_nonobvious_property_types: ignore + discarded_futures: ignore + use_setters_to_change_properties: ignore + deprecated_member_use: ignore + avoid_equals_and_hash_code_on_mutable_classes: ignore +linter: + rules: + public_member_api_docs: false + always_use_package_imports: false + prefer_relative_imports: true diff --git a/examples/chat/build.yaml b/examples/chat/build.yaml new file mode 100644 index 0000000..673bd1d --- /dev/null +++ b/examples/chat/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + sources: + - source/** + - lib/** + - $package$ diff --git a/examples/chat/lib/backend/chat_backend.dart b/examples/chat/lib/backend/chat_backend.dart new file mode 100644 index 0000000..baa3a5f --- /dev/null +++ b/examples/chat/lib/backend/chat_backend.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:math'; + +import '../domain/models.dart'; + +/// In-process mock chat backend. No network, no persistence — just timers and +/// random data. Behaves like a chat server would behave: streams of incoming +/// messages, typing bursts, presence flips, system notices, and a send call +/// that occasionally fails. +class ChatBackend { + ChatBackend({int? seed}) : _rng = Random(seed); + + final Random _rng; + int _msgCounter = 0; + + static const seedChannels = [ + Channel(id: 'general', name: '#general'), + Channel(id: 'random', name: '#random'), + Channel(id: 'dart', name: '#dart'), + Channel(id: 'solid-help', name: '#solid-help'), + ]; + + static const seedUsers = [ + User(id: 'bot-ada', displayName: 'Ada Lovelace', initials: 'AL'), + User(id: 'bot-alan', displayName: 'Alan Turing', initials: 'AT'), + User(id: 'bot-grace', displayName: 'Grace Hopper', initials: 'GH'), + User(id: 'bot-edsger', displayName: 'Edsger Dijkstra', initials: 'ED'), + User(id: 'bot-barbara', displayName: 'Barbara Liskov', initials: 'BL'), + User(id: 'bot-tony', displayName: 'Tony Hoare', initials: 'TH'), + ]; + + static const _botUtterances = [ + 'has anyone tried the new build_runner?', + 'reading the docs for the third time and still confused', + 'just shipped a thing — finally', + 'what does this error mean: "type \'Null\' is not a subtype"', + 'time for coffee', + 'is it Friday yet', + 'TIL streams are not lists', + 'rebooted my laptop, it fixed the bug', + 'who broke the build', + 'PR review please when you have a sec', + 'how do you debounce a signal', + 'flutter doctor says I have no problems', + ]; + + Stream incomingMessages(String channelId) async* { + while (true) { + final wait = Duration(seconds: 4 + _rng.nextInt(5)); + await Future.delayed(wait); + final sender = seedUsers[_rng.nextInt(seedUsers.length)]; + yield Message( + id: 'srv-${_msgCounter++}', + channelId: channelId, + senderId: sender.id, + text: _botUtterances[_rng.nextInt(_botUtterances.length)], + timestamp: DateTime.now(), + ); + } + } + + Stream> typingUsers(String channelId) async* { + while (true) { + await Future.delayed(Duration(seconds: 3 + _rng.nextInt(7))); + final typing = {}; + final count = _rng.nextInt(3); + for (var i = 0; i < count; i++) { + typing.add(seedUsers[_rng.nextInt(seedUsers.length)].id); + } + yield typing; + await Future.delayed(Duration(seconds: 2 + _rng.nextInt(2))); + yield {}; + } + } + + Stream> presence() async* { + final state = { + for (final u in seedUsers) u.id: Presence.online, + }; + yield Map.unmodifiable(state); + while (true) { + await Future.delayed(Duration(seconds: 5 + _rng.nextInt(5))); + final user = seedUsers[_rng.nextInt(seedUsers.length)]; + final next = Presence.values[_rng.nextInt(Presence.values.length)]; + state[user.id] = next; + yield Map.unmodifiable(state); + } + } + + Stream systemNotices() async* { + while (true) { + await Future.delayed(Duration(seconds: 20 + _rng.nextInt(40))); + final kind = _rng.nextBool() + ? SystemNoticeKind.info + : SystemNoticeKind.warning; + yield SystemNotice( + kind: kind, + message: kind == SystemNoticeKind.info + ? 'Server tick: all systems nominal' + : 'Connection wobble (reconnected)', + ); + } + } + + Future send(String channelId, String text, String senderId) async { + final delay = Duration(milliseconds: 300 + _rng.nextInt(500)); + await Future.delayed(delay); + if (_rng.nextInt(10) == 0) { + throw Exception('send failed (network blip)'); + } + return Message( + id: 'srv-${_msgCounter++}', + channelId: channelId, + senderId: senderId, + text: text, + timestamp: DateTime.now(), + ); + } + + void markTyping(String channelId, String userId) { + // Mock no-op — a real backend would broadcast this to typingUsers + // subscribers. We don't simulate self-typing because the UI exercises + // the source-side Effect+Timer pattern regardless. + } + + void dispose() {} +} diff --git a/examples/chat/lib/chat_app.dart b/examples/chat/lib/chat_app.dart new file mode 100644 index 0000000..bfd13cb --- /dev/null +++ b/examples/chat/lib/chat_app.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import 'widgets/chat_shell.dart'; + +class ChatApp extends StatelessWidget { + const ChatApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Chat', + debugShowCheckedModeBanner: false, + theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo), + home: const ChatShell(), + ); + } +} diff --git a/examples/chat/lib/controllers/channels_controller.dart b/examples/chat/lib/controllers/channels_controller.dart new file mode 100644 index 0000000..bfad5da --- /dev/null +++ b/examples/chat/lib/controllers/channels_controller.dart @@ -0,0 +1,30 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class ChannelsController implements Disposable { + ChannelsController() { + channels.addAll(ChatBackend.seedChannels); + } + + final channels = ListSignal([], name: 'channels'); + + late final channelCount = Computed( + () => channels.length, + name: 'channelCount', + ); + + Channel? lookup(String id) { + for (final c in channels.value) { + if (c.id == id) return c; + } + return null; + } + + @override + void dispose() { + channelCount.dispose(); + channels.dispose(); + } +} diff --git a/examples/chat/lib/controllers/messages_controller.dart b/examples/chat/lib/controllers/messages_controller.dart new file mode 100644 index 0000000..6a926ad --- /dev/null +++ b/examples/chat/lib/controllers/messages_controller.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class MessagesController implements Disposable { + MessagesController({required this.backend}) { + for (final c in ChatBackend.seedChannels) { + channelMessages[c.id] = []; + readIds[c.id] = {}; + final sub = backend.incomingMessages(c.id).listen((m) { + final current = channelMessages[c.id] ?? const []; + channelMessages[c.id] = [...current, m]; + }); + _subs.add(sub); + } + } + + final ChatBackend backend; + + final List> _subs = []; + + final channelMessages = MapSignal>( + {}, + name: 'channelMessages', + ); + + final readIds = MapSignal>({}, name: 'readIds'); + + late final totalUnread = Computed(() { + var sum = 0; + for (final entry in channelMessages.entries) { + final read = readIds[entry.key] ?? const {}; + for (final m in entry.value) { + if (m.status != MessageStatus.confirmed) continue; + if (!read.contains(m.id)) sum++; + } + } + return sum; + }, name: 'totalUnread'); + + int unreadFor(String channelId) { + final msgs = channelMessages[channelId] ?? const []; + final read = readIds[channelId] ?? const {}; + var n = 0; + for (final m in msgs) { + if (m.status != MessageStatus.confirmed) continue; + if (!read.contains(m.id)) n++; + } + return n; + } + + void markAllRead(String channelId) { + final msgs = channelMessages[channelId] ?? const []; + final next = {...?readIds[channelId]}; + for (final m in msgs) { + if (m.status == MessageStatus.confirmed) next.add(m.id); + } + readIds[channelId] = next; + } + + Future send(String channelId, String text, String senderId) async { + final pendingId = 'pending-${DateTime.now().microsecondsSinceEpoch}'; + final pending = Message( + id: pendingId, + channelId: channelId, + senderId: senderId, + text: text, + timestamp: DateTime.now(), + status: MessageStatus.pending, + ); + final before = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [...before, pending]; + try { + final confirmed = await backend.send(channelId, text, senderId); + final list = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in list) + if (m.id == pendingId) confirmed else m, + ]; + } on Object { + final list = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in list) + if (m.id == pendingId) + m.copyWith(status: MessageStatus.failed) + else + m, + ]; + Future.delayed(const Duration(milliseconds: 800), () { + final l = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in l) + if (m.id != pendingId) m, + ]; + }); + } + } + + @override + void dispose() { + totalUnread.dispose(); + readIds.dispose(); + channelMessages.dispose(); + for (final s in _subs) { + s.cancel(); + } + _subs.clear(); + } +} diff --git a/examples/chat/lib/controllers/navigation_controller.dart b/examples/chat/lib/controllers/navigation_controller.dart new file mode 100644 index 0000000..2b13209 --- /dev/null +++ b/examples/chat/lib/controllers/navigation_controller.dart @@ -0,0 +1,32 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import '../domain/models.dart'; +import 'channels_controller.dart'; + +class NavigationController implements Disposable { + NavigationController({required this.channels}); + + final ChannelsController channels; + + final currentChannelId = Signal(null, name: 'currentChannelId'); + + late final currentChannel = Computed(() { + final id = currentChannelId.value; + if (id == null) return null; + return channels.lookup(id); + }, name: 'currentChannel'); + + void open(String id) { + currentChannelId.value = id; + } + + void closeCurrent() { + currentChannelId.value = null; + } + + @override + void dispose() { + currentChannel.dispose(); + currentChannelId.dispose(); + } +} diff --git a/examples/chat/lib/controllers/session_controller.dart b/examples/chat/lib/controllers/session_controller.dart new file mode 100644 index 0000000..6d16ae8 --- /dev/null +++ b/examples/chat/lib/controllers/session_controller.dart @@ -0,0 +1,22 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class SessionController implements Disposable { + SessionController() { + myUserId.value = 'me-${DateTime.now().millisecondsSinceEpoch}'; + + logSession; + } + + late final myUserId = Signal.lazy(name: 'myUserId'); + + late final logSession = Effect(() { + print('session: ${myUserId.value}'); + }, name: 'logSession'); + + @override + void dispose() { + logSession.dispose(); + myUserId.dispose(); + } +} diff --git a/examples/chat/lib/controllers/users_controller.dart b/examples/chat/lib/controllers/users_controller.dart new file mode 100644 index 0000000..b942a79 --- /dev/null +++ b/examples/chat/lib/controllers/users_controller.dart @@ -0,0 +1,29 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class UsersController implements Disposable { + UsersController() { + for (final u in ChatBackend.seedUsers) { + users[u.id] = u; + } + } + + final users = MapSignal({}, name: 'users'); + + void upsert(User u) { + users[u.id] = u; + } + + void remove(String id) { + users.remove(id); + } + + User? lookup(String id) => users[id]; + + @override + void dispose() { + users.dispose(); + } +} diff --git a/examples/chat/lib/domain/models.dart b/examples/chat/lib/domain/models.dart new file mode 100644 index 0000000..29301e3 --- /dev/null +++ b/examples/chat/lib/domain/models.dart @@ -0,0 +1,58 @@ +enum MessageStatus { pending, confirmed, failed } + +class Message { + const Message({ + required this.id, + required this.channelId, + required this.senderId, + required this.text, + required this.timestamp, + this.status = MessageStatus.confirmed, + }); + + final String id; + final String channelId; + final String senderId; + final String text; + final DateTime timestamp; + final MessageStatus status; + + Message copyWith({String? id, MessageStatus? status}) => Message( + id: id ?? this.id, + channelId: channelId, + senderId: senderId, + text: text, + timestamp: timestamp, + status: status ?? this.status, + ); +} + +class User { + const User({ + required this.id, + required this.displayName, + required this.initials, + }); + + final String id; + final String displayName; + final String initials; +} + +class Channel { + const Channel({required this.id, required this.name}); + + final String id; + final String name; +} + +enum Presence { online, offline, typing } + +enum SystemNoticeKind { info, warning } + +class SystemNotice { + const SystemNotice({required this.kind, required this.message}); + + final SystemNoticeKind kind; + final String message; +} diff --git a/examples/chat/lib/main.dart b/examples/chat/lib/main.dart new file mode 100644 index 0000000..96ddeb0 --- /dev/null +++ b/examples/chat/lib/main.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import 'backend/chat_backend.dart'; +import 'chat_app.dart'; +import 'controllers/channels_controller.dart'; +import 'controllers/messages_controller.dart'; +import 'controllers/navigation_controller.dart'; +import 'controllers/session_controller.dart'; +import 'controllers/users_controller.dart'; + +void main() { + SolidartConfig.autoDispose = false; + runApp( + const ChatApp() + .environment( + (_) => ChatBackend(), + dispose: (context, provider) => provider.dispose(), + ) + .environment( + (_) => SessionController(), + dispose: (context, provider) => provider.dispose(), + ) + .environment( + (_) => UsersController(), + dispose: (context, provider) => provider.dispose(), + ) + .environment( + (_) => ChannelsController(), + dispose: (context, provider) => provider.dispose(), + ) + .environment( + (ctx) => + NavigationController(channels: ctx.read()), + dispose: (context, provider) => provider.dispose(), + ) + .environment( + (ctx) => MessagesController(backend: ctx.read()), + dispose: (context, provider) => provider.dispose(), + ), + ); +} diff --git a/examples/chat/lib/widgets/channel_list_pane.dart b/examples/chat/lib/widgets/channel_list_pane.dart new file mode 100644 index 0000000..d7975ce --- /dev/null +++ b/examples/chat/lib/widgets/channel_list_pane.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../controllers/channels_controller.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/navigation_controller.dart'; +import 'presence_indicator.dart'; + +class ChannelListPane extends StatefulWidget { + const ChannelListPane({super.key}); + + @override + State createState() => _ChannelListPaneState(); +} + +class _ChannelListPaneState extends State { + late final channelsController = context.read(); + late final messagesController = context.read(); + late final navController = context.read(); + + @override + Widget build(BuildContext context) { + final channels = channelsController.channels.value; + final currentId = navController.currentChannelId.value; + final totalUnread = messagesController.totalUnread.value; + return Material( + elevation: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + color: Colors.blueGrey.shade50, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Chat', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (totalUnread > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$totalUnread', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + const PresenceIndicator(), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: channels.length, + itemBuilder: (context, index) { + final c = channels[index]; + final unread = messagesController.unreadFor(c.id); + final selected = c.id == currentId; + return ListTile( + selected: selected, + selectedTileColor: Colors.blue.shade50, + title: Text( + c.name, + style: TextStyle( + fontWeight: selected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + trailing: unread > 0 + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$unread', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ) + : null, + onTap: () { + navController.open(c.id); + messagesController.markAllRead(c.id); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/examples/chat/lib/widgets/chat_shell.dart b/examples/chat/lib/widgets/chat_shell.dart new file mode 100644 index 0000000..7a2bc5b --- /dev/null +++ b/examples/chat/lib/widgets/chat_shell.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import '../backend/chat_backend.dart'; +import '../controllers/navigation_controller.dart'; +import '../domain/models.dart'; +import 'channel_list_pane.dart'; +import 'message_pane.dart'; + +class ChatShell extends StatefulWidget { + const ChatShell({super.key}); + + @override + State createState() => _ChatShellState(); +} + +class _ChatShellState extends State { + StreamSubscription? _sysSub; + + final GlobalKey _messengerKey = + GlobalKey(); + + late final navController = context.read(); + + late final backend = context.read(); + + late final watchSystemNotices = Effect(() { + // Reactive dep on currentChannelId so the subscription re-arms on channel + // switch (closes over the latest navigation context). + navController.currentChannelId.value; + _sysSub?.cancel(); + _sysSub = backend.systemNotices().listen((notice) { + final messenger = _messengerKey.currentState; + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + backgroundColor: notice.kind == SystemNoticeKind.warning + ? Colors.orange + : Colors.blueGrey, + content: Text(notice.message), + ), + ); + }); + }, name: 'watchSystemNotices'); + + @override + void dispose() { + watchSystemNotices.dispose(); + _sysSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + key: _messengerKey, + child: Scaffold( + body: SafeArea( + child: SignalBuilder( + builder: (context, child) { + return LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 720; + if (wide) { + return const Row( + children: [ + SizedBox(width: 260, child: ChannelListPane()), + Expanded(child: MessagePane()), + ], + ); + } + final showList = navController.currentChannelId.value == null; + return showList + ? const ChannelListPane() + : Column( + children: [ + Material( + elevation: 1, + child: SizedBox( + height: 44, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: navController.closeCurrent, + ), + const Text('Back to channels'), + ], + ), + ), + ), + const Expanded(child: MessagePane()), + ], + ); + }, + ); + }, + ), + ), + ), + ); + } + + @override + void initState() { + super.initState(); + watchSystemNotices; + } +} diff --git a/examples/chat/lib/widgets/message_composer.dart b/examples/chat/lib/widgets/message_composer.dart new file mode 100644 index 0000000..f2d9f68 --- /dev/null +++ b/examples/chat/lib/widgets/message_composer.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import '../backend/chat_backend.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/session_controller.dart'; + +class MessageComposer extends StatefulWidget { + const MessageComposer({super.key, required this.channelId}); + + final String channelId; + + @override + State createState() => _MessageComposerState(); +} + +class _MessageComposerState extends State { + final TextEditingController _textController = TextEditingController(); + + Timer? _typingTimer; + + late final messagesController = context.read(); + + late final backend = context.read(); + + late final session = context.read(); + + final draftText = Signal('', name: 'draftText'); + + late final emitTyping = Effect(() { + final text = draftText.value; + _typingTimer?.cancel(); + if (text.trim().isEmpty) return; + backend.markTyping(widget.channelId, session.myUserId.value); + _typingTimer = Timer(const Duration(seconds: 3), () {}); + }, name: 'emitTyping'); + + Future _send() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + _textController.clear(); + draftText.value = ''; + await messagesController.send( + widget.channelId, + text, + session.myUserId.value, + ); + } + + @override + void dispose() { + emitTyping.dispose(); + draftText.dispose(); + _typingTimer?.cancel(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: const InputDecoration( + hintText: 'Type a message…', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (v) => draftText.value = v, + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _send, + icon: const Icon(Icons.send), + tooltip: 'Send', + ), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + emitTyping; + } +} diff --git a/examples/chat/lib/widgets/message_list.dart b/examples/chat/lib/widgets/message_list.dart new file mode 100644 index 0000000..41a5342 --- /dev/null +++ b/examples/chat/lib/widgets/message_list.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import '../backend/chat_backend.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/users_controller.dart'; +import '../domain/models.dart'; + +class MessageList extends StatefulWidget { + const MessageList({super.key, required this.channelId}); + + final String channelId; + + @override + State createState() => _MessageListState(); +} + +class _MessageListState extends State { + late final messagesController = context.read(); + late final usersController = context.read(); + late final backend = context.read(); + late final watchTypingUsers = Resource>.stream( + () { + return backend.typingUsers(widget.channelId); + }, + useRefreshing: false, + name: 'watchTypingUsers', + ); + + @override + void dispose() { + watchTypingUsers.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final messages = + messagesController.channelMessages[widget.channelId] ?? + const []; + return Column( + children: [ + Expanded( + child: messages.isEmpty + ? const Center( + child: Text( + 'No messages yet — say hello', + style: TextStyle(color: Colors.black54), + ), + ) + : SignalBuilder( + builder: (context, child) { + return ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + itemCount: messages.length, + itemBuilder: (context, index) { + final m = messages[index]; + final user = usersController.users[m.senderId]; + return _MessageRow(message: m, user: user); + }, + ); + }, + ), + ), + SizedBox( + height: 20, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: SignalBuilder( + builder: (context, child) { + return Text( + _labelFor( + watchTypingUsers().maybeWhen( + ready: (ids) => ids, + orElse: () => const {}, + ), + ), + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + fontStyle: FontStyle.italic, + ), + ); + }, + ), + ), + ), + ), + ], + ); + } + + String _labelFor(Set ids) { + if (ids.isEmpty) return ''; + final names = ids + .map((id) => usersController.users[id]?.displayName ?? id) + .toList(); + if (names.length == 1) return '${names.first} is typing…'; + if (names.length == 2) return '${names[0]} and ${names[1]} are typing…'; + return '${names.length} people are typing…'; + } +} + +class _MessageRow extends StatelessWidget { + const _MessageRow({required this.message, required this.user}); + + final Message message; + final User? user; + + @override + Widget build(BuildContext context) { + final isPending = message.status == MessageStatus.pending; + final isFailed = message.status == MessageStatus.failed; + Color? bg; + if (isPending) bg = Colors.grey.shade100; + if (isFailed) bg = Colors.red.shade50; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Colors.blueGrey.shade100, + child: Text( + user?.initials ?? '??', + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user?.displayName ?? message.senderId, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Text( + _formatTime(message.timestamp), + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + ), + if (isPending) ...[ + const SizedBox(width: 6), + const Icon( + Icons.schedule, + size: 12, + color: Colors.black45, + ), + ], + if (isFailed) ...[ + const SizedBox(width: 6), + const Icon( + Icons.error_outline, + size: 12, + color: Colors.red, + ), + ], + ], + ), + Text(message.text), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime t) { + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return '$hh:$mm'; + } +} diff --git a/examples/chat/lib/widgets/message_pane.dart b/examples/chat/lib/widgets/message_pane.dart new file mode 100644 index 0000000..fefa96a --- /dev/null +++ b/examples/chat/lib/widgets/message_pane.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../controllers/navigation_controller.dart'; +import 'message_composer.dart'; +import 'message_list.dart'; + +class MessagePane extends StatefulWidget { + const MessagePane({super.key}); + + @override + State createState() => _MessagePaneState(); +} + +class _MessagePaneState extends State { + late final navController = context.read(); + + @override + Widget build(BuildContext context) { + final channel = navController.currentChannel.value; + if (channel == null) { + return const Center( + child: Text( + 'Pick a channel on the left', + style: TextStyle(color: Colors.black54), + ), + ); + } + return Column( + key: ValueKey(channel.id), + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + child: Row( + children: [ + Text( + channel.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Expanded( + child: MessageList( + key: ValueKey('list-${channel.id}'), + channelId: channel.id, + ), + ), + MessageComposer( + key: ValueKey('composer-${channel.id}'), + channelId: channel.id, + ), + ], + ); + } +} diff --git a/examples/chat/lib/widgets/presence_indicator.dart b/examples/chat/lib/widgets/presence_indicator.dart new file mode 100644 index 0000000..58d8879 --- /dev/null +++ b/examples/chat/lib/widgets/presence_indicator.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import '../backend/chat_backend.dart'; +import '../controllers/users_controller.dart'; +import '../domain/models.dart'; + +class PresenceIndicator extends StatefulWidget { + const PresenceIndicator({super.key}); + + @override + State createState() => _PresenceIndicatorState(); +} + +class _PresenceIndicatorState extends State { + late final backend = context.read(); + late final users = context.read(); + late final watchPresence = Resource>.stream(() { + return backend.presence(); + }, name: 'watchPresence'); + + @override + void dispose() { + watchPresence.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context, child) { + return watchPresence().when( + ready: (presence) { + final onlineCount = presence.values + .where((p) => p == Presence.online || p == Presence.typing) + .length; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.circle, color: Colors.green, size: 10), + const SizedBox(width: 4), + Text('$onlineCount online'), + ], + ); + }, + loading: () => const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (e, _) => Text('presence: $e'), + ); + }, + ); + } +} diff --git a/examples/chat/pubspec.yaml b/examples/chat/pubspec.yaml new file mode 100644 index 0000000..0204573 --- /dev/null +++ b/examples/chat/pubspec.yaml @@ -0,0 +1,28 @@ +name: chat_example +description: A multi-channel real-time chat example demonstrating Solid v2 reactive primitives (Stream queries, optimistic updates, collection signals, reactive navigation). +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: ^3.9.2 + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + flutter_solidart: ^2.7.3 + provider: ^6.1.0 + solid_annotations: + path: ../../packages/solid_annotations + +dev_dependencies: + build_runner: ^2.14.0 + flutter_test: + sdk: flutter + solid_generator: + path: ../../packages/solid_generator + very_good_analysis: ^10.0.0 + +flutter: + uses-material-design: true diff --git a/examples/chat/source/backend/chat_backend.dart b/examples/chat/source/backend/chat_backend.dart new file mode 100644 index 0000000..baa3a5f --- /dev/null +++ b/examples/chat/source/backend/chat_backend.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:math'; + +import '../domain/models.dart'; + +/// In-process mock chat backend. No network, no persistence — just timers and +/// random data. Behaves like a chat server would behave: streams of incoming +/// messages, typing bursts, presence flips, system notices, and a send call +/// that occasionally fails. +class ChatBackend { + ChatBackend({int? seed}) : _rng = Random(seed); + + final Random _rng; + int _msgCounter = 0; + + static const seedChannels = [ + Channel(id: 'general', name: '#general'), + Channel(id: 'random', name: '#random'), + Channel(id: 'dart', name: '#dart'), + Channel(id: 'solid-help', name: '#solid-help'), + ]; + + static const seedUsers = [ + User(id: 'bot-ada', displayName: 'Ada Lovelace', initials: 'AL'), + User(id: 'bot-alan', displayName: 'Alan Turing', initials: 'AT'), + User(id: 'bot-grace', displayName: 'Grace Hopper', initials: 'GH'), + User(id: 'bot-edsger', displayName: 'Edsger Dijkstra', initials: 'ED'), + User(id: 'bot-barbara', displayName: 'Barbara Liskov', initials: 'BL'), + User(id: 'bot-tony', displayName: 'Tony Hoare', initials: 'TH'), + ]; + + static const _botUtterances = [ + 'has anyone tried the new build_runner?', + 'reading the docs for the third time and still confused', + 'just shipped a thing — finally', + 'what does this error mean: "type \'Null\' is not a subtype"', + 'time for coffee', + 'is it Friday yet', + 'TIL streams are not lists', + 'rebooted my laptop, it fixed the bug', + 'who broke the build', + 'PR review please when you have a sec', + 'how do you debounce a signal', + 'flutter doctor says I have no problems', + ]; + + Stream incomingMessages(String channelId) async* { + while (true) { + final wait = Duration(seconds: 4 + _rng.nextInt(5)); + await Future.delayed(wait); + final sender = seedUsers[_rng.nextInt(seedUsers.length)]; + yield Message( + id: 'srv-${_msgCounter++}', + channelId: channelId, + senderId: sender.id, + text: _botUtterances[_rng.nextInt(_botUtterances.length)], + timestamp: DateTime.now(), + ); + } + } + + Stream> typingUsers(String channelId) async* { + while (true) { + await Future.delayed(Duration(seconds: 3 + _rng.nextInt(7))); + final typing = {}; + final count = _rng.nextInt(3); + for (var i = 0; i < count; i++) { + typing.add(seedUsers[_rng.nextInt(seedUsers.length)].id); + } + yield typing; + await Future.delayed(Duration(seconds: 2 + _rng.nextInt(2))); + yield {}; + } + } + + Stream> presence() async* { + final state = { + for (final u in seedUsers) u.id: Presence.online, + }; + yield Map.unmodifiable(state); + while (true) { + await Future.delayed(Duration(seconds: 5 + _rng.nextInt(5))); + final user = seedUsers[_rng.nextInt(seedUsers.length)]; + final next = Presence.values[_rng.nextInt(Presence.values.length)]; + state[user.id] = next; + yield Map.unmodifiable(state); + } + } + + Stream systemNotices() async* { + while (true) { + await Future.delayed(Duration(seconds: 20 + _rng.nextInt(40))); + final kind = _rng.nextBool() + ? SystemNoticeKind.info + : SystemNoticeKind.warning; + yield SystemNotice( + kind: kind, + message: kind == SystemNoticeKind.info + ? 'Server tick: all systems nominal' + : 'Connection wobble (reconnected)', + ); + } + } + + Future send(String channelId, String text, String senderId) async { + final delay = Duration(milliseconds: 300 + _rng.nextInt(500)); + await Future.delayed(delay); + if (_rng.nextInt(10) == 0) { + throw Exception('send failed (network blip)'); + } + return Message( + id: 'srv-${_msgCounter++}', + channelId: channelId, + senderId: senderId, + text: text, + timestamp: DateTime.now(), + ); + } + + void markTyping(String channelId, String userId) { + // Mock no-op — a real backend would broadcast this to typingUsers + // subscribers. We don't simulate self-typing because the UI exercises + // the source-side Effect+Timer pattern regardless. + } + + void dispose() {} +} diff --git a/examples/chat/source/chat_app.dart b/examples/chat/source/chat_app.dart new file mode 100644 index 0000000..bfd13cb --- /dev/null +++ b/examples/chat/source/chat_app.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import 'widgets/chat_shell.dart'; + +class ChatApp extends StatelessWidget { + const ChatApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Chat', + debugShowCheckedModeBanner: false, + theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo), + home: const ChatShell(), + ); + } +} diff --git a/examples/chat/source/controllers/channels_controller.dart b/examples/chat/source/controllers/channels_controller.dart new file mode 100644 index 0000000..bc3e181 --- /dev/null +++ b/examples/chat/source/controllers/channels_controller.dart @@ -0,0 +1,23 @@ +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class ChannelsController { + ChannelsController() { + channels.addAll(ChatBackend.seedChannels); + } + + @SolidState() + List channels = []; + + @SolidState() + int get channelCount => channels.length; + + Channel? lookup(String id) { + for (final c in channels) { + if (c.id == id) return c; + } + return null; + } +} diff --git a/examples/chat/source/controllers/messages_controller.dart b/examples/chat/source/controllers/messages_controller.dart new file mode 100644 index 0000000..ff310e3 --- /dev/null +++ b/examples/chat/source/controllers/messages_controller.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class MessagesController { + MessagesController({required this.backend}) { + for (final c in ChatBackend.seedChannels) { + channelMessages[c.id] = []; + readIds[c.id] = {}; + final sub = backend.incomingMessages(c.id).listen((m) { + final current = channelMessages[c.id] ?? const []; + channelMessages[c.id] = [...current, m]; + }); + _subs.add(sub); + } + } + + final ChatBackend backend; + final List> _subs = []; + + @SolidState() + Map> channelMessages = {}; + + @SolidState() + Map> readIds = {}; + + @SolidState() + int get totalUnread { + var sum = 0; + for (final entry in channelMessages.entries) { + final read = readIds[entry.key] ?? const {}; + for (final m in entry.value) { + if (m.status != MessageStatus.confirmed) continue; + if (!read.contains(m.id)) sum++; + } + } + return sum; + } + + int unreadFor(String channelId) { + final msgs = channelMessages[channelId] ?? const []; + final read = readIds[channelId] ?? const {}; + var n = 0; + for (final m in msgs) { + if (m.status != MessageStatus.confirmed) continue; + if (!read.contains(m.id)) n++; + } + return n; + } + + void markAllRead(String channelId) { + final msgs = channelMessages[channelId] ?? const []; + final next = {...?readIds[channelId]}; + for (final m in msgs) { + if (m.status == MessageStatus.confirmed) next.add(m.id); + } + readIds[channelId] = next; + } + + Future send(String channelId, String text, String senderId) async { + final pendingId = 'pending-${DateTime.now().microsecondsSinceEpoch}'; + final pending = Message( + id: pendingId, + channelId: channelId, + senderId: senderId, + text: text, + timestamp: DateTime.now(), + status: MessageStatus.pending, + ); + final before = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [...before, pending]; + try { + final confirmed = await backend.send(channelId, text, senderId); + final list = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in list) + if (m.id == pendingId) confirmed else m, + ]; + } on Object { + final list = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in list) + if (m.id == pendingId) + m.copyWith(status: MessageStatus.failed) + else + m, + ]; + Future.delayed(const Duration(milliseconds: 800), () { + final l = channelMessages[channelId] ?? const []; + channelMessages[channelId] = [ + for (final m in l) + if (m.id != pendingId) m, + ]; + }); + } + } + + void dispose() { + for (final s in _subs) { + s.cancel(); + } + _subs.clear(); + } +} diff --git a/examples/chat/source/controllers/navigation_controller.dart b/examples/chat/source/controllers/navigation_controller.dart new file mode 100644 index 0000000..9bff31a --- /dev/null +++ b/examples/chat/source/controllers/navigation_controller.dart @@ -0,0 +1,28 @@ +import 'package:solid_annotations/solid_annotations.dart'; + +import '../domain/models.dart'; +import 'channels_controller.dart'; + +class NavigationController { + NavigationController({required this.channels}); + + final ChannelsController channels; + + @SolidState() + String? currentChannelId; + + @SolidState() + Channel? get currentChannel { + final id = currentChannelId; + if (id == null) return null; + return channels.lookup(id); + } + + void open(String id) { + currentChannelId = id; + } + + void closeCurrent() { + currentChannelId = null; + } +} diff --git a/examples/chat/source/controllers/session_controller.dart b/examples/chat/source/controllers/session_controller.dart new file mode 100644 index 0000000..fb2f3e5 --- /dev/null +++ b/examples/chat/source/controllers/session_controller.dart @@ -0,0 +1,15 @@ +import 'package:solid_annotations/solid_annotations.dart'; + +class SessionController { + SessionController() { + myUserId = 'me-${DateTime.now().millisecondsSinceEpoch}'; + } + + @SolidState() + late String myUserId; + + @SolidEffect() + void logSession() { + print('session: $myUserId'); + } +} diff --git a/examples/chat/source/controllers/users_controller.dart b/examples/chat/source/controllers/users_controller.dart new file mode 100644 index 0000000..2a3e9c6 --- /dev/null +++ b/examples/chat/source/controllers/users_controller.dart @@ -0,0 +1,25 @@ +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../domain/models.dart'; + +class UsersController { + UsersController() { + for (final u in ChatBackend.seedUsers) { + users[u.id] = u; + } + } + + @SolidState() + Map users = {}; + + void upsert(User u) { + users[u.id] = u; + } + + void remove(String id) { + users.remove(id); + } + + User? lookup(String id) => users[id]; +} diff --git a/examples/chat/source/domain/models.dart b/examples/chat/source/domain/models.dart new file mode 100644 index 0000000..29301e3 --- /dev/null +++ b/examples/chat/source/domain/models.dart @@ -0,0 +1,58 @@ +enum MessageStatus { pending, confirmed, failed } + +class Message { + const Message({ + required this.id, + required this.channelId, + required this.senderId, + required this.text, + required this.timestamp, + this.status = MessageStatus.confirmed, + }); + + final String id; + final String channelId; + final String senderId; + final String text; + final DateTime timestamp; + final MessageStatus status; + + Message copyWith({String? id, MessageStatus? status}) => Message( + id: id ?? this.id, + channelId: channelId, + senderId: senderId, + text: text, + timestamp: timestamp, + status: status ?? this.status, + ); +} + +class User { + const User({ + required this.id, + required this.displayName, + required this.initials, + }); + + final String id; + final String displayName; + final String initials; +} + +class Channel { + const Channel({required this.id, required this.name}); + + final String id; + final String name; +} + +enum Presence { online, offline, typing } + +enum SystemNoticeKind { info, warning } + +class SystemNotice { + const SystemNotice({required this.kind, required this.message}); + + final SystemNoticeKind kind; + final String message; +} diff --git a/examples/chat/source/main.dart b/examples/chat/source/main.dart new file mode 100644 index 0000000..cd09f9b --- /dev/null +++ b/examples/chat/source/main.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:provider/provider.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import 'backend/chat_backend.dart'; +import 'chat_app.dart'; +import 'controllers/channels_controller.dart'; +import 'controllers/messages_controller.dart'; +import 'controllers/navigation_controller.dart'; +import 'controllers/session_controller.dart'; +import 'controllers/users_controller.dart'; + +void main() { + SolidartConfig.autoDispose = false; + runApp( + const ChatApp() + .environment((_) => ChatBackend()) + .environment((_) => SessionController()) + .environment((_) => UsersController()) + .environment((_) => ChannelsController()) + .environment( + (ctx) => + NavigationController(channels: ctx.read()), + ) + .environment( + (ctx) => MessagesController(backend: ctx.read()), + ), + ); +} diff --git a/examples/chat/source/widgets/channel_list_pane.dart b/examples/chat/source/widgets/channel_list_pane.dart new file mode 100644 index 0000000..274bb62 --- /dev/null +++ b/examples/chat/source/widgets/channel_list_pane.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../controllers/channels_controller.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/navigation_controller.dart'; +import 'presence_indicator.dart'; + +class ChannelListPane extends StatelessWidget { + const ChannelListPane({super.key}); + + @SolidEnvironment() + late ChannelsController channelsController; + + @SolidEnvironment() + late MessagesController messagesController; + + @SolidEnvironment() + late NavigationController navController; + + @override + Widget build(BuildContext context) { + final channels = channelsController.channels; + final currentId = navController.currentChannelId; + final totalUnread = messagesController.totalUnread; + return Material( + elevation: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + color: Colors.blueGrey.shade50, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Chat', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (totalUnread > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$totalUnread', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + const PresenceIndicator(), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: channels.length, + itemBuilder: (context, index) { + final c = channels[index]; + final unread = messagesController.unreadFor(c.id); + final selected = c.id == currentId; + return ListTile( + selected: selected, + selectedTileColor: Colors.blue.shade50, + title: Text( + c.name, + style: TextStyle( + fontWeight: selected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + trailing: unread > 0 + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$unread', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ) + : null, + onTap: () { + navController.open(c.id); + messagesController.markAllRead(c.id); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/examples/chat/source/widgets/chat_shell.dart b/examples/chat/source/widgets/chat_shell.dart new file mode 100644 index 0000000..f16de48 --- /dev/null +++ b/examples/chat/source/widgets/chat_shell.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../controllers/navigation_controller.dart'; +import '../domain/models.dart'; +import 'channel_list_pane.dart'; +import 'message_pane.dart'; + +class ChatShell extends StatefulWidget { + const ChatShell({super.key}); + + @override + State createState() => _ChatShellState(); +} + +class _ChatShellState extends State { + StreamSubscription? _sysSub; + final GlobalKey _messengerKey = + GlobalKey(); + + @SolidEnvironment() + late NavigationController navController; + + @SolidEnvironment() + late ChatBackend backend; + + @SolidEffect() + void watchSystemNotices() { + // Reactive dep on currentChannelId so the subscription re-arms on channel + // switch (closes over the latest navigation context). + navController.currentChannelId; + _sysSub?.cancel(); + _sysSub = backend.systemNotices().listen((notice) { + final messenger = _messengerKey.currentState; + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + backgroundColor: notice.kind == SystemNoticeKind.warning + ? Colors.orange + : Colors.blueGrey, + content: Text(notice.message), + ), + ); + }); + } + + @override + void dispose() { + _sysSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + key: _messengerKey, + child: Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 720; + if (wide) { + return const Row( + children: [ + SizedBox(width: 260, child: ChannelListPane()), + Expanded(child: MessagePane()), + ], + ); + } + final showList = navController.currentChannelId == null; + return showList + ? const ChannelListPane() + : Column( + children: [ + Material( + elevation: 1, + child: SizedBox( + height: 44, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: navController.closeCurrent, + ), + const Text('Back to channels'), + ], + ), + ), + ), + const Expanded(child: MessagePane()), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/examples/chat/source/widgets/message_composer.dart b/examples/chat/source/widgets/message_composer.dart new file mode 100644 index 0000000..a4cf860 --- /dev/null +++ b/examples/chat/source/widgets/message_composer.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/session_controller.dart'; + +class MessageComposer extends StatefulWidget { + const MessageComposer({super.key, required this.channelId}); + + final String channelId; + + @override + State createState() => _MessageComposerState(); +} + +class _MessageComposerState extends State { + final TextEditingController _textController = TextEditingController(); + Timer? _typingTimer; + + @SolidEnvironment() + late MessagesController messagesController; + + @SolidEnvironment() + late ChatBackend backend; + + @SolidEnvironment() + late SessionController session; + + @SolidState() + String draftText = ''; + + @SolidEffect() + void emitTyping() { + final text = draftText; + _typingTimer?.cancel(); + if (text.trim().isEmpty) return; + backend.markTyping(widget.channelId, session.myUserId); + _typingTimer = Timer(const Duration(seconds: 3), () {}); + } + + Future _send() async { + final text = _textController.text.trim(); + if (text.isEmpty) return; + _textController.clear(); + draftText = ''; + await messagesController.send(widget.channelId, text, session.myUserId); + } + + @override + void dispose() { + _typingTimer?.cancel(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: const InputDecoration( + hintText: 'Type a message…', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (v) => draftText = v, + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _send, + icon: const Icon(Icons.send), + tooltip: 'Send', + ), + ], + ), + ); + } +} diff --git a/examples/chat/source/widgets/message_list.dart b/examples/chat/source/widgets/message_list.dart new file mode 100644 index 0000000..a2cde94 --- /dev/null +++ b/examples/chat/source/widgets/message_list.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../controllers/messages_controller.dart'; +import '../controllers/users_controller.dart'; +import '../domain/models.dart'; + +class MessageList extends StatelessWidget { + MessageList({super.key, required this.channelId}); + + final String channelId; + + @SolidEnvironment() + late MessagesController messagesController; + + @SolidEnvironment() + late UsersController usersController; + + @SolidEnvironment() + late ChatBackend backend; + + @SolidQuery(useRefreshing: false) + Stream> watchTypingUsers() { + return backend.typingUsers(channelId); + } + + @override + Widget build(BuildContext context) { + final messages = + messagesController.channelMessages[channelId] ?? const []; + return Column( + children: [ + Expanded( + child: messages.isEmpty + ? const Center( + child: Text( + 'No messages yet — say hello', + style: TextStyle(color: Colors.black54), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + itemCount: messages.length, + itemBuilder: (context, index) { + final m = messages[index]; + final user = usersController.users[m.senderId]; + return _MessageRow(message: m, user: user); + }, + ), + ), + SizedBox( + height: 20, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _labelFor( + watchTypingUsers().maybeWhen( + ready: (ids) => ids, + orElse: () => const {}, + ), + ), + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ), + ], + ); + } + + String _labelFor(Set ids) { + if (ids.isEmpty) return ''; + final names = ids + .map((id) => usersController.users[id]?.displayName ?? id) + .toList(); + if (names.length == 1) return '${names.first} is typing…'; + if (names.length == 2) return '${names[0]} and ${names[1]} are typing…'; + return '${names.length} people are typing…'; + } +} + +class _MessageRow extends StatelessWidget { + const _MessageRow({required this.message, required this.user}); + + final Message message; + final User? user; + + @override + Widget build(BuildContext context) { + final isPending = message.status == MessageStatus.pending; + final isFailed = message.status == MessageStatus.failed; + Color? bg; + if (isPending) bg = Colors.grey.shade100; + if (isFailed) bg = Colors.red.shade50; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Colors.blueGrey.shade100, + child: Text( + user?.initials ?? '??', + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + user?.displayName ?? message.senderId, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Text( + _formatTime(message.timestamp), + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + ), + if (isPending) ...[ + const SizedBox(width: 6), + const Icon( + Icons.schedule, + size: 12, + color: Colors.black45, + ), + ], + if (isFailed) ...[ + const SizedBox(width: 6), + const Icon( + Icons.error_outline, + size: 12, + color: Colors.red, + ), + ], + ], + ), + Text(message.text), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime t) { + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + return '$hh:$mm'; + } +} diff --git a/examples/chat/source/widgets/message_pane.dart b/examples/chat/source/widgets/message_pane.dart new file mode 100644 index 0000000..dbd7f4d --- /dev/null +++ b/examples/chat/source/widgets/message_pane.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../controllers/navigation_controller.dart'; +import 'message_composer.dart'; +import 'message_list.dart'; + +class MessagePane extends StatelessWidget { + const MessagePane({super.key}); + + @SolidEnvironment() + late NavigationController navController; + + @override + Widget build(BuildContext context) { + final channel = navController.currentChannel; + if (channel == null) { + return const Center( + child: Text( + 'Pick a channel on the left', + style: TextStyle(color: Colors.black54), + ), + ); + } + return Column( + key: ValueKey(channel.id), + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + child: Row( + children: [ + Text( + channel.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Expanded( + child: MessageList( + key: ValueKey('list-${channel.id}'), + channelId: channel.id, + ), + ), + MessageComposer( + key: ValueKey('composer-${channel.id}'), + channelId: channel.id, + ), + ], + ); + } +} diff --git a/examples/chat/source/widgets/presence_indicator.dart b/examples/chat/source/widgets/presence_indicator.dart new file mode 100644 index 0000000..e71fcd2 --- /dev/null +++ b/examples/chat/source/widgets/presence_indicator.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +import '../backend/chat_backend.dart'; +import '../controllers/users_controller.dart'; +import '../domain/models.dart'; + +class PresenceIndicator extends StatelessWidget { + const PresenceIndicator({super.key}); + + @SolidEnvironment() + late ChatBackend backend; + + @SolidEnvironment() + late UsersController users; + + @SolidQuery() + Stream> watchPresence() { + return backend.presence(); + } + + @override + Widget build(BuildContext context) { + return watchPresence().when( + ready: (presence) { + final onlineCount = presence.values + .where((p) => p == Presence.online || p == Presence.typing) + .length; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.circle, color: Colors.green, size: 10), + const SizedBox(width: 4), + Text('$onlineCount online'), + ], + ); + }, + loading: () => const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + error: (e, _) => Text('presence: $e'), + ); + } +} diff --git a/packages/solid_generator/lib/builder.dart b/packages/solid_generator/lib/builder.dart index 6638477..2558d4c 100644 --- a/packages/solid_generator/lib/builder.dart +++ b/packages/solid_generator/lib/builder.dart @@ -34,7 +34,13 @@ Builder solidBuilder(BuilderOptions options) => _SolidBuilder(); /// A file without this substring cannot possibly need transformation and is /// skipped before `parseString` — the hot-path short-circuit for the typical /// unannotated file. -const String _solidAnnotationHint = '@Solid'; +/// +/// `solid_annotations` (the package name) is the chosen hint because every +/// file that uses any `@Solid*` annotation must import this package — both +/// the canonical (`@SolidState int x = 0;`) and aliased +/// (`import '…' as sa; @sa.SolidState() int x = 0;`) shapes carry the +/// substring. The earlier `@Solid` hint missed the aliased form. +const String _solidAnnotationHint = 'solid_annotations'; /// Substrings that flag a file as a candidate for the `Provider` / /// `.environment()` auto-dispose pass. The presence of either substring @@ -130,12 +136,30 @@ class _SolidBuilder implements Builder { '(offset ${diagnostic.offset})', ); } + // Acquire a TYPE-RESOLVED CompilationUnit when possible. Path: + // 1. `libraryFor(inputId)` returns a fully-resolved `LibraryElement` + // (analyzer 8.x forces full type resolution at this step). + // 2. `astNodeFor(anyElement, resolve: true)` returns that element's + // resolved declaration node — `Expression.staticType` is populated + // on every node beneath it. + // 3. Navigate up to the enclosing `CompilationUnit` once. + // + // `compilationUnitFor` alone returns a parsed-but-unresolved unit (every + // `staticType` is `null`), which doesn't satisfy the type-aware + // predicates downstream (B-2 strict wrap, future shadowing, full chains). + // + // Fallback: when the library has no anchor element (no classes, no + // top-level functions, etc.), there is nothing to call `astNodeFor` on, + // so we use the parsed AST. Such files almost never carry `@Solid*` + // annotations — annotations live on classes — so type-aware predicates + // are a no-op there. + final unit = await _resolveUnit(buildStep, parsed.unit); // AST-precise re-check of the same-package-import rule. Redundant with // the pre-parse text scan above but produces precise URI text in the // error message; one extra `whereType` walk per parsed file. validateSourceImportsFromAst( - parsed.unit, + unit, buildStep.inputId.package, buildStep.inputId.path, source, @@ -143,33 +167,33 @@ class _SolidBuilder implements Builder { // Reserved-annotation guard. Currently a no-op; preserved as a regression // fence for future revisions. - validateReservedAnnotations(parsed.unit); + validateReservedAnnotations(unit); // Invalid-target guard for `@SolidState`. Must run before // `_collectAnnotatedClasses`; rejected targets (final / const / static // fields, setters, top-level vars, methods, …) never reach the readers. - validateSolidStateTargets(parsed.unit); + validateSolidStateTargets(unit); // Invalid-target guard for `@SolidEffect`. Same contract as the line // above: rejected targets (getters, setters, static/abstract methods, // parameterized methods, non-void methods, top-level functions, fields) // never reach `readSolidEffectMethod`. - validateSolidEffectTargets(parsed.unit); + validateSolidEffectTargets(unit); // Invalid-target guard for `@SolidQuery`. Same contract as the lines // above: rejected targets (non-Future/Stream returns, Future-without-async // bodies, parameterized/static/abstract methods, getters/setters, // top-level functions, fields) never reach `readSolidQueryMethod`. - validateSolidQueryTargets(parsed.unit); + validateSolidQueryTargets(unit); // Invalid-target guard for `@SolidEnvironment` — mirrors the validators // above. - validateSolidEnvironmentTargets(parsed.unit); + validateSolidEnvironmentTargets(unit); // Same-file class registry, built from a fast member-scan before any // body rewrites run. The body-rewriter relies on it to recognise // cross-class `.value` chains (`controller.todos`) even when the body // being rewritten is a `@SolidState` getter / `@SolidEffect` / // `@SolidQuery` on a sibling class in the same file. - final sameFileRegistry = _prescanClassRegistry(parsed.unit); - final sameFileCollections = _prescanClassCollectionFields(parsed.unit); - final sameFileFieldTypes = _prescanClassFieldTypes(parsed.unit); + final sameFileRegistry = _prescanClassRegistry(unit); + final sameFileCollections = _prescanClassCollectionFields(unit); + final sameFileFieldTypes = _prescanClassFieldTypes(unit); // Captures, per cross-class `@SolidState` field type text, the // `package:/` URIs that bring that type into scope // on the env-field's class file. Used by [_renderOutput] to inject the @@ -182,7 +206,7 @@ class _SolidBuilder implements Builder { // referenced by a `@SolidEnvironment` field type — same contract as the // same-file pass, but for types declared in other source files. await _populateCrossFileTypes( - parsed.unit, + unit, buildStep, sameFileRegistry, sameFileCollections, @@ -191,7 +215,7 @@ class _SolidBuilder implements Builder { ); final annotatedClasses = _collectAnnotatedClasses( - parsed.unit, + unit, source, sameFileRegistry, sameFileCollections, @@ -203,7 +227,7 @@ class _SolidBuilder implements Builder { if (hasProviderHint) { final withDispose = addProviderDisposeAtCallSites( source, - unit: parsed.unit, + unit: unit, ); if (identical(withDispose, source)) { await buildStep.writeAsString(outputId, source); @@ -221,7 +245,7 @@ class _SolidBuilder implements Builder { // same data but the prescan version is the authority because the // reader pipeline already consumed it. final transformed = _renderOutput( - parsed.unit, + unit, annotatedClasses, sameFileRegistry, sameFileCollections, @@ -253,11 +277,11 @@ Map> _prescanClassRegistry(CompilationUnit unit) { final names = {}; for (final member in decl.members) { if (member is FieldDeclaration) { - if (member.metadata.any((a) => a.name.name == solidStateName)) { + if (hasAnnotation(solidStateName, member.metadata)) { names.add(member.fields.variables.first.name.lexeme); } } else if (member is MethodDeclaration && member.isGetter) { - if (member.metadata.any((a) => a.name.name == solidStateName)) { + if (hasAnnotation(solidStateName, member.metadata)) { names.add(member.name.lexeme); } } @@ -288,15 +312,11 @@ Map> _prescanClassFieldTypes( final types = {}; for (final member in decl.members) { if (member is FieldDeclaration) { - if (!member.metadata.any((a) => a.name.name == solidStateName)) { - continue; - } + if (!hasAnnotation(solidStateName, member.metadata)) continue; final name = member.fields.variables.first.name.lexeme; types[name] = member.fields.type?.toSource() ?? ''; } else if (member is MethodDeclaration && member.isGetter) { - if (!member.metadata.any((a) => a.name.name == solidStateName)) { - continue; - } + if (!hasAnnotation(solidStateName, member.metadata)) continue; types[member.name.lexeme] = member.returnType?.toSource() ?? ''; } } @@ -323,7 +343,7 @@ Map> _prescanClassCollectionFields(CompilationUnit unit) { final names = {}; for (final member in decl.members) { if (member is! FieldDeclaration) continue; - if (!member.metadata.any((a) => a.name.name == solidStateName)) continue; + if (!hasAnnotation(solidStateName, member.metadata)) continue; final variable = member.fields.variables.first; final type = member.fields.type; if (type == null) continue; @@ -524,9 +544,7 @@ Map _collectEnvironmentFields(ClassDeclaration decl) { Map? fields; for (final member in decl.members) { if (member is! FieldDeclaration) continue; - if (!member.metadata.any((a) => a.name.name == solidEnvironmentName)) { - continue; - } + if (!hasAnnotation(solidEnvironmentName, member.metadata)) continue; final type = member.fields.type; if (type == null) continue; final fieldName = member.fields.variables.first.name.lexeme; @@ -567,6 +585,52 @@ Set _collectWidgetBoundCtorNames(ClassDeclaration decl) { ); } +/// Returns a TYPE-RESOLVED [CompilationUnit] for the build input when one +/// can be obtained, falling back to [parsedFallback] otherwise. +/// +/// `buildStep.resolver.libraryFor` returns a fully-resolved `LibraryElement` +/// (analyzer forces type resolution at this call). `astNodeFor(anyElement, +/// resolve: true)` on any element from that library yields a resolved +/// declaration node whose enclosing `CompilationUnit` has `Expression. +/// staticType` populated throughout. The first available anchor is used +/// (classes are preferred — they almost always exist when `@Solid*` +/// annotations are present); when none is available the function returns +/// the [parsedFallback] AST unchanged. +/// +/// `compilationUnitFor` alone is not equivalent: it calls +/// `session.getParsedUnit` and returns a parsed-but-unresolved unit. The +/// resolved variant is needed for type-driven predicates downstream. +Future _resolveUnit( + BuildStep buildStep, + CompilationUnit parsedFallback, +) async { + try { + final library = await buildStep.resolver.libraryFor( + buildStep.inputId, + allowSyntaxErrors: true, + ); + // analyzer 9 `astNodeFor` takes a `Fragment` (the per-file + // declaration-level element). The library's defining-file fragment is + // available as `library.firstFragment` (a `LibraryFragment`); passing + // it with `resolve: true` returns the resolved `CompilationUnit` for + // that file directly. Falls back to the parsed AST if the resolver + // returns null (rare; happens for elements sourced from summaries). + final node = await buildStep.resolver.astNodeFor( + library.firstFragment, + resolve: true, + ); + if (node is CompilationUnit) return node; + final unit = node?.thisOrAncestorOfType(); + return unit ?? parsedFallback; + } on Object { + // Defensive: any resolver error (asset not readable, transitive + // analyzer failure on an import, …) falls back to the parsed AST. The + // surfaced effect is that type-aware predicates degrade to the + // pre-fix textual heuristics for this one file. + return parsedFallback; + } +} + /// Renders the full `lib/` output for a file that has at least one annotated /// class. Preserves non-annotated classes verbatim and rewrites annotated /// ones per their class kind. @@ -830,9 +894,7 @@ Future _populateCrossFileTypes( if (decl is! ClassDeclaration) continue; for (final member in decl.members) { if (member is! FieldDeclaration) continue; - if (!member.metadata.any((a) => a.name.name == solidEnvironmentName)) { - continue; - } + if (!hasAnnotation(solidEnvironmentName, member.metadata)) continue; final type = member.fields.type; if (type == null) continue; final typeText = type.toSource(); @@ -876,10 +938,7 @@ Future _populateCrossFileTypes( final fieldTypeTexts = {}; for (final member in decl.members) { if (member is FieldDeclaration) { - final isSolidState = member.metadata.any( - (a) => a.name.name == solidStateName, - ); - if (!isSolidState) continue; + if (!hasAnnotation(solidStateName, member.metadata)) continue; final variable = member.fields.variables.first; final fieldName = variable.name.lexeme; scalarNames.add(fieldName); @@ -895,14 +954,10 @@ Future _populateCrossFileTypes( collectionNames.add(fieldName); } } else if (member is MethodDeclaration && member.isGetter) { - final isSolidState = member.metadata.any( - (a) => a.name.name == solidStateName, - ); - if (isSolidState) { - scalarNames.add(member.name.lexeme); - fieldTypeTexts[member.name.lexeme] = - member.returnType?.toSource() ?? ''; - } + if (!hasAnnotation(solidStateName, member.metadata)) continue; + scalarNames.add(member.name.lexeme); + fieldTypeTexts[member.name.lexeme] = + member.returnType?.toSource() ?? ''; } } if (scalarNames.isNotEmpty) { diff --git a/packages/solid_generator/lib/src/annotation_reader.dart b/packages/solid_generator/lib/src/annotation_reader.dart index 383e00e..90f18ab 100644 --- a/packages/solid_generator/lib/src/annotation_reader.dart +++ b/packages/solid_generator/lib/src/annotation_reader.dart @@ -1,5 +1,7 @@ import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:solid_generator/src/effect_model.dart'; +import 'package:solid_generator/src/element_utils.dart'; import 'package:solid_generator/src/environment_model.dart'; import 'package:solid_generator/src/field_model.dart'; import 'package:solid_generator/src/getter_model.dart'; @@ -9,30 +11,36 @@ import 'package:solid_generator/src/value_rewriter.dart'; /// Name of the `@SolidState` annotation class. /// -/// Matching is textual on the unresolved AST — type resolution is required -/// for `.value` rewriting, but annotation detection is acceptable on names -/// because the user must import `solid_annotations` to use `@SolidState` -/// at all. +/// Matching is two-tier (see [findAnnotationByName]): Element-based via +/// `Annotation.element` when the resolver has produced one, with a textual +/// fallback on the lexical name. The textual path is the workhorse in test +/// sandboxes that cannot resolve `package:solid_annotations` (because the +/// package transitively imports Flutter, whose SDK isn't available in +/// `testBuilder`); the Element path adds aliased-import support in real +/// `build_runner` runs. /// /// Exposed package-publicly so the target validator can match the same /// identifier on non-field declarations. const String solidStateName = 'SolidState'; /// Name of the `@SolidEffect` annotation class. Same matching contract as -/// [solidStateName] (textual on unresolved AST). +/// [solidStateName]. const String solidEffectName = 'SolidEffect'; /// Name of the `@SolidQuery` annotation class. Same matching contract as -/// [solidStateName] (textual on unresolved AST). +/// [solidStateName]. const String solidQueryName = 'SolidQuery'; /// Name of the `@SolidEnvironment` annotation class. Same matching contract -/// as [solidStateName] (textual on unresolved AST). +/// as [solidStateName]. const String solidEnvironmentName = 'SolidEnvironment'; /// Lexemes of the `flutter_solidart` types whose runtime classes extend -/// `SignalBase`. Matched textually on the unresolved AST per the -/// [solidStateName] contract. Consumed by the target validator (rejecting +/// `SignalBase`. Matched against the type-annotation lexeme as the +/// unresolved-AST fallback in +/// `target_validator._isSignalBaseTyped`; the Element-based primary path +/// walks `InterfaceType.allSupertypes` when the resolver populated the +/// type. Consumed by the target validator (rejecting /// `@SolidEnvironment` fields typed as one of these) and by the /// cross-class `.value` rewrite. Excludes `SignalBuilder` / /// `SolidartConfig` (those are non-`SignalBase` solidart names). @@ -44,9 +52,9 @@ const Set signalBaseTypeNames = { }; /// Lexeme of the `Future` return-type identifier on a `@SolidQuery` method. -/// Matched textually on the unresolved AST per the same contract as -/// [solidStateName] — the user must import `dart:async` (or its re-export via -/// `dart:core`) to write the type at all. +/// Matched textually on the return-type's [NamedType] lexeme — the user +/// must import `dart:async` (or its re-export via `dart:core`) to write the +/// type at all, so the lexical name is sufficient at the validator boundary. const String futureLexeme = 'Future'; /// Lexeme of the `Stream` return-type identifier on a `@SolidQuery` method. @@ -433,7 +441,7 @@ QueryModel? readSolidQueryMethod( bodyText: bodyText, isBlockBody: isBlockBody, innerTypeText: innerTypeText, - bodyKeyword: decl.body.keyword?.lexeme ?? '', + bodyKeyword: _bodyKeyword(decl.body), isStream: returnTypeName == streamLexeme, trackedSignalNames: trackedNames, trackedQueryNames: trackedQueryNames, @@ -444,8 +452,40 @@ QueryModel? readSolidQueryMethod( ); } +/// Returns the source-faithful body keyword for [body] — `'async'`, `'sync'`, +/// `'async*'`, `'sync*'`, or `''` for a synchronous block / arrow body. +/// +/// The analyzer's AST splits the keyword and the optional star into two +/// separate tokens (`body.keyword` and `body.star`). The naive +/// `body.keyword?.lexeme ?? ''` drops the star, which is wrong for `async*` / +/// `sync*` generator bodies. This helper rejoins the two tokens so the +/// downstream `Resource.stream(() $bodyKeyword { ... })` splice in +/// `signal_emitter.emitResourceField` produces valid Dart. +String _bodyKeyword(FunctionBody body) { + final keyword = body.keyword?.lexeme ?? ''; + if (keyword.isEmpty) return ''; + final star = switch (body) { + final BlockFunctionBody b => b.star?.lexeme ?? '', + final ExpressionFunctionBody b => b.star?.lexeme ?? '', + _ => '', + }; + return '$keyword$star'; +} + /// Returns the first `@(...)` annotation in [metadata], or `null`. /// +/// Matching is two-tier: +/// +/// 1. **Element-based.** When the resolver has populated `Annotation.element` +/// (the constructor of the annotation class), match by class name plus +/// `package:solid_annotations/` library URI. This catches aliased imports +/// (`import '…' as solid; @solid.SolidState()`) whose textual name is +/// `'solid.SolidState'` and would miss the textual check below. +/// 2. **Textual fallback.** When the resolver hasn't run (parsed-AST +/// fallback in `_resolveUnit`, or test sandboxes without the Flutter +/// SDK), match on the lexical name. Still correct because users almost +/// always import `solid_annotations` without an alias. +/// /// Package-public so the target validator can reuse the same matcher on /// every declaration kind. Use the [solidStateName] or [solidEffectName] /// constants for [className] rather than bare strings. @@ -454,11 +494,38 @@ Annotation? findAnnotationByName( NodeList metadata, ) { for (final ann in metadata) { + // Resolved annotation: the Element check is authoritative; skip the + // textual branch (its lexical name is dominated by the element's class + // name, so re-checking it is pure overhead). + if (ann.element != null) { + if (_annotationMatchesByElement(ann, className)) return ann; + continue; + } if (ann.name.name == className) return ann; } return null; } +/// `true` iff [ann]'s resolved element is a constructor of a class named +/// [className] declared in the `package:solid_annotations/` library. Returns +/// `false` when the AST is unresolved (`ann.element` is `null`) or when the +/// element is unrelated. +bool _annotationMatchesByElement(Annotation ann, String className) { + final element = ann.element; + if (element is! ConstructorElement) return false; + final enclosing = element.enclosingElement; + if (enclosing.name != className) return false; + return isFromPackage(enclosing.library.uri, 'solid_annotations'); +} + +/// `true` iff [metadata] contains an annotation matching [className] under +/// the same rules as [findAnnotationByName]. Helper for the pre-scan +/// callers that only need presence detection (no extraction of the +/// `Annotation` node). +bool hasAnnotation(String className, NodeList metadata) { + return findAnnotationByName(className, metadata) != null; +} + /// Returns the [Expression] passed for `