No backward compatibility is required — this is a clean-slate build. The existing
device_apps demo code is a reference only; the new app replaces it entirely.Locked decisions
| # | Decision |
|---|---|
| 1 | Transport: WSS only. No E2E encryption for now. |
| 2 | Sync: online-first with a minimal outbox/retry for reliability. |
| 3 | Identity: one device can have multiple workspaces; each workspace has one active identity. |
| 4 | Message ordering: server timestamp is canonical; client timestamp is UI fallback only. |
| 5 | Notifications: local notifications first; push is a pluggable module added later. |
| 6 | Deep links: not required for production, but go_router URL structure is kept for dev/debug convenience. |
| 7 | i18n: not urgent, but the architecture must not block it (no hardcoded strings in widgets). |
| 8 | Local DB: messages and channels are cached locally in the app (Drift/SQLite). Server is the source of truth on refresh. |
Core concepts (domain language)
These terms must be used consistently everywhere — in code, comments, and AI prompts.| Term | Meaning |
|---|---|
| Workspace | A named connection to one Hiro League gateway instance. A device can have multiple workspaces. |
| Identity | The cryptographic keypair + attestation for one workspace. |
| Channel | A topic or conversation thread. Channels are listed in the sidebar. |
| Bot | A personality/agent. A bot is NOT a channel — it is an entity that can be assigned to, or participate in, a channel. |
| Session | A conversation context within a channel. A channel can have multiple sessions (like threads). |
| Message | A single unit of communication in a channel/session. Has a type: text, image, video, voice, location, file. |
| Gateway | The WebSocket server the app connects to. |
State management: Riverpod v3
Why Riverpod over Provider:- Provider requires
BuildContextto read state — Riverpod does not. This matters for services, repositories, and background tasks. - Riverpod’s
AsyncNotifierhandles loading/error/data states cleanly without boilerplate. - Compile-time safety with
@riverpodcode generation — no string keys, no runtime errors. - Providers are composable and testable in isolation.
- Scales to the complexity of this app (multiple workspaces, per-channel message streams, gateway lifecycle).
Architecture: feature-first + 3-layer
Each feature is a vertical slice. Inside each feature, three layers exist:domainhas zero Flutter or third-party dependencies. Pure Dart.dataimplementsdomaincontracts (repository interfaces).application(Riverpod providers/notifiers) wires domain + data together.presentationreads providers only. Never touches repositories or services directly.
Folder structure
Routing structure
StatefulShellRoute renders both panels side-by-side in a Row inside AppShell.
Key implementation rules
Models
- Every domain model uses
freezed. No mutable model classes anywhere. - JSON serialization lives in
data/DTOs, not domain models. A mapper converts DTO → domain model. - Never pass raw
Map<String, dynamic>outside thedata/layer.
Widgets
- Screens are thin: they read providers and pass data down to child widgets.
- No business logic in widgets. No repository calls from widgets.
- No
if/elsechains dispatching message types in one widget — useswitchin a dispatcher widget that delegates to typed sub-widgets. - Strings are never hardcoded in widgets. Use a constants file or ARB files (i18n-ready from day one).
- Split any widget that exceeds ~80 lines into smaller named widgets.
Gateway / WebSocket
GatewayClientowns the raw connection, reconnect loop, and heartbeat. It knows nothing about domain models.GatewayProtocolhandles frame parsing. It converts raw JSON to typed DTOs.GatewayAuthHandlerhandles the challenge/response and pairing flow.GatewayNotifier(Riverpod) owns the connection lifecycle and exposes state to the UI.MessageRepositorysubscribes to the gateway stream and writes to the local Drift DB.- The UI reads from the local DB stream — it never reads directly from the WebSocket.
Outbox pattern
When a message send fails (no connection), it goes into a local outbox table.OutboxService retries on reconnect. Message status in the UI reflects: sending → sent → delivered → read or failed.
Platform abstraction
Anything that behaves differently on Android vs web vs iOS vs desktop lives inplatform/ behind an abstract interface. Features never import from platform/ directly — they go through the provider.
Adaptive layout
AppShelldetects screen width and renders either aNavigationRail(wide) orNavigationBar(narrow).- The split-pane (channel list + chat) is implemented inside
StatefulShellRoute. - A single
AdaptiveLayout.isWide(context)utility (based onMediaQuery.sizeOf) is the single source of truth for breakpoints.
Theming
AppThemeprovides both light and darkThemeDatausingflex_color_scheme.- No hardcoded colors or text styles in widgets — always use
Theme.of(context)or named tokens fromapp_colors.dart. - Theme mode is persisted in
SettingsRepositoryand restored on startup.
Packages
Build order
| Phase | Features |
|---|---|
| 1 — Foundation | Folder structure, theme, router, AppShell (adaptive layout), core utilities |
| 2 — Identity | Onboarding (QR + pairing code), crypto, identity storage, workspace model |
| 3 — Lock | Biometric/PIN lock screen, lock guard in router |
| 4 — Gateway | GatewayClient, protocol, auth handler, reconnect, GatewayNotifier |
| 5 — Channels | Channel list, local DB, channel repository, channel list screen |
| 6 — Chat | Message model, MessageRepository (DB + gateway sync), chat screen, text bubble, input bar |
| 7 — Rich messages | Image, voice, video, location, file bubbles; attachment picker |
| 8 — Reply/Forward | Reply preview, forward action, message context menu |
| 9 — Bots | Bot model, bot list, bot assignment to channel, bot WebView screen |
| 10 — Sessions | Session model, session list, session chat |
| 11 — Pixel streaming | PixelStreamScreen with WebView + JS bridge |
| 12 — Notifications | Local notification service, notification on incoming message |
| 13 — Settings | Theme toggle, security settings, gateway settings |
| 14 — Multi-workspace | Workspace switcher, per-workspace identity, workspace-scoped providers |
| 15 — i18n | ARB files, flutter_localizations, string extraction |
AI coding guardrails
Use these verbatim in prompts when working with an AI coding assistant:- “Implement by feature slice. Each feature has its own folder under
features/for presentation andapplication/for state.” - “Respect the 3-layer boundary:
features/→application/→domain/←data/. No layer may skip a level.” - “Do not place business logic in widgets. Widgets read providers and render data.”
- “Do not access the gateway, database, or any service directly from a widget or screen.”
- “No raw
Map<String, dynamic>outsidedata/. All data crossing the data/domain boundary must be a typed, freezed model.” - “Every new feature requires: a freezed domain model, a repository contract in
domain/repositories/, an implementation indata/repositories/, and a Riverpod notifier inapplication/.” - “All platform-specific behavior (biometrics, notifications, file system, WebView) must be behind an abstract interface in
platform/.” - “No hardcoded strings in widgets. Use constants or ARB keys.”
- “No hardcoded colors or text styles in widgets. Use
Theme.of(context)tokens.” - “The gateway connection lifecycle lives in
GatewayNotifier. The UI reads message data from the local DB stream viaMessageRepository, not from the WebSocket directly.” - “Adaptive layout breakpoints use
AdaptiveLayout.isWide(context)only. No inlineMediaQuerywidth checks scattered across widgets.” - “A Bot is not a Channel. They are separate domain models with separate providers and separate screens.”
- “Message bubble rendering uses a dispatcher widget that switches on
MessageTypeand delegates to a typed sub-widget. No monolithic bubble widget.” - “State management is Riverpod v3 with code generation (
@riverpod). Do not introduce Provider, Bloc, or GetX anywhere.”
Troubleshooting
Issues discovered during the Riverpod v2 → v3 / freezed v2 → v3 upgrade.version solving failed: source_gen conflict between riverpod_generator and freezed
version solving failed: source_gen conflict between riverpod_generator and freezed
riverpod_generator ^4 requires source_gen ^3, but freezed ^2 requires source_gen ^2. They cannot coexist.Fix: upgrade freezed and freezed_annotation to ^3.x at the same time as riverpod_generator ^4.x.version solving failed: analyzer conflict between riverpod_generator ^4 and drift_dev ^2.32+
version solving failed: analyzer conflict between riverpod_generator ^4 and drift_dev ^2.32+
riverpod_generator ^4 requires analyzer ^9, but drift_dev >=2.32.0 bumped to analyzer ^10.Fix: pin drift_dev (and drift) below 2.32.0 until riverpod_generator releases support for analyzer ^10:Riverpod v3: undefined_identifier errors for authNotifierProvider, gatewayNotifierProvider, etc.
Riverpod v3: undefined_identifier errors for authNotifierProvider, gatewayNotifierProvider, etc.
In Riverpod v3 /
Function-based providers (lowercase function names) are unchanged.Fix: global search-and-replace across the codebase for each renamed provider after running
riverpod_generator ^4, class-based providers drop the Notifier suffix from their generated variable name.| Class | v2 provider name | v3 provider name |
|---|---|---|
AuthNotifier | authNotifierProvider | authProvider |
GatewayNotifier | gatewayNotifierProvider | gatewayProvider |
RouterNotifier | routerNotifierProvider | routerProvider |
MessageSendNotifier | messageSendNotifierProvider | messageSendProvider |
build_runner.Riverpod v3: 'valueOrNull' isn't defined for the type 'AsyncValue'
Riverpod v3: 'valueOrNull' isn't defined for the type 'AsyncValue'
The old Fix: global search-and-replace
AsyncValue.value (which threw on error/loading) was removed. valueOrNull was renamed to value..valueOrNull → .value for all Riverpod AsyncValue usages. Do not rename valueOrNull on custom non-Riverpod types (e.g. a Result<T> class with its own valueOrNull getter).Freezed v3: non_abstract_class_inherits_abstract_member on @freezed data classes
Freezed v3: non_abstract_class_inherits_abstract_member on @freezed data classes
In freezed v3, the generated Not affected: multi-variant union types (e.g.
_$Foo mixin declares abstract field getters. A plain class Foo with _$Foo is a concrete class with unimplemented abstract members — this is a compile error.Affected pattern: any @freezed class Foo with _$Foo that has a single factory constructor (= _Foo).AuthState with unauthenticated / pairing / authenticated / error) — the mixin for unions has no abstract field getters, so class continues to work.Freezed v3: 'when' / 'map' isn't defined for the type after upgrading
Freezed v3: 'when' / 'map' isn't defined for the type after upgrading
In freezed v3, pattern-matching methods (
when, map, maybeWhen, etc.) were moved from instance methods on the class to an extension (e.g. extension AuthStatePatterns on AuthState).Extension methods require a direct import of the library that defines them. Importing a file that imports the freezed file is not sufficient.Riverpod v3 runtime: 'Cannot use the Ref after it has been disposed' mid-async operation
Riverpod v3 runtime: 'Cannot use the Ref after it has been disposed' mid-async operation
@riverpod (auto-dispose) providers can be paused or disposed between await points. Any ref.read() call after an await will throw this error at runtime.Two rules to follow:-
Action/service notifiers that span async work must be
keepAlive: true.MessageSendNotifier,GatewayNotifier, andRouterNotifierare examples — they are not tied to a single widget’s lifecycle. -
Capture all
ref.read()results before the firstawait.
Riverpod v3: unnecessary_import warning for flutter_riverpod
Riverpod v3: unnecessary_import warning for flutter_riverpod
In Riverpod v3,
package:riverpod_annotation/riverpod_annotation.dart re-exports everything from flutter_riverpod. Files that import both will get an unnecessary_import lint warning.Fix: remove import 'package:flutter_riverpod/flutter_riverpod.dart' from any file that already imports riverpod_annotation.