Skip to main content
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
1Transport: WSS only. No E2E encryption for now.
2Sync: online-first with a minimal outbox/retry for reliability.
3Identity: one device can have multiple workspaces; each workspace has one active identity.
4Message ordering: server timestamp is canonical; client timestamp is UI fallback only.
5Notifications: local notifications first; push is a pluggable module added later.
6Deep links: not required for production, but go_router URL structure is kept for dev/debug convenience.
7i18n: not urgent, but the architecture must not block it (no hardcoded strings in widgets).
8Local 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.
TermMeaning
WorkspaceA named connection to one Hiro League gateway instance. A device can have multiple workspaces.
IdentityThe cryptographic keypair + attestation for one workspace.
ChannelA topic or conversation thread. Channels are listed in the sidebar.
BotA personality/agent. A bot is NOT a channel — it is an entity that can be assigned to, or participate in, a channel.
SessionA conversation context within a channel. A channel can have multiple sessions (like threads).
MessageA single unit of communication in a channel/session. Has a type: text, image, video, voice, location, file.
GatewayThe WebSocket server the app connects to.

State management: Riverpod v3

Why Riverpod over Provider:
  • Provider requires BuildContext to read state — Riverpod does not. This matters for services, repositories, and background tasks.
  • Riverpod’s AsyncNotifier handles loading/error/data states cleanly without boilerplate.
  • Compile-time safety with @riverpod code 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).
Rule: One state approach across the entire app. Never mix Provider + Riverpod + Bloc.

Architecture: feature-first + 3-layer

Each feature is a vertical slice. Inside each feature, three layers exist:
Presentation  →  Application  →  Domain  ←  Data
(widgets)        (providers)     (models,    (repositories,
                 (notifiers)      contracts)  remote, local)
Dependency rule:
  • domain has zero Flutter or third-party dependencies. Pure Dart.
  • data implements domain contracts (repository interfaces).
  • application (Riverpod providers/notifiers) wires domain + data together.
  • presentation reads providers only. Never touches repositories or services directly.

Folder structure

lib/
├── main.dart                    # ProviderScope, bootstrap only
├── app.dart                     # MaterialApp.router, theme, router config

├── core/                        # Shared primitives — no feature logic here
│   ├── constants/
│   ├── errors/
│   ├── extensions/
│   ├── utils/
│   └── ui/                      # Design system / UI kit
│       ├── atoms/
│       ├── molecules/
│       └── theme/

├── config/
│   └── router/

├── platform/                    # Platform-specific adapters behind interfaces
│   ├── biometrics/
│   ├── notifications/
│   ├── media/
│   └── storage/

├── data/                        # Data layer — no UI here
│   ├── local/
│   │   ├── database/
│   │   └── cache/
│   ├── remote/
│   │   ├── gateway/
│   │   └── http/
│   └── repositories/

├── domain/                      # Pure Dart — zero Flutter dependency
│   ├── models/
│   ├── repositories/            # Contracts (abstract classes only)
│   └── services/

├── application/                 # Riverpod providers and notifiers
│   ├── providers.dart
│   ├── auth/
│   ├── gateway/
│   ├── channels/
│   ├── messages/
│   └── ...

└── features/                    # Presentation layer — screens and widgets
    ├── shell/
    ├── onboarding/
    ├── lock/
    ├── channels/
    ├── chat/
    ├── bots/
    └── settings/

Routing structure

/                                → redirect: auth guard → lock guard → /app/channels
/onboarding                      → OnboardingScreen
/lock                            → LockScreen
/workspaces                      → WorkspaceListScreen
/app  (StatefulShellRoute)       → AppShell
  /app/channels                  → ChannelListScreen
  /app/channels/:channelId       → ChatScreen
  /app/channels/:channelId/sessions/:sessionId  → SessionChatScreen
  /app/bots                      → BotListScreen
  /app/bots/:botId               → BotWebViewScreen
  /app/bots/:botId/stream        → PixelStreamScreen
  /app/settings                  → SettingsScreen
On narrow screens (mobile): channels list and chat are stacked (push navigation). On wide screens (tablet/desktop/web): 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 the data/ 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/else chains dispatching message types in one widget — use switch in 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

  • GatewayClient owns the raw connection, reconnect loop, and heartbeat. It knows nothing about domain models.
  • GatewayProtocol handles frame parsing. It converts raw JSON to typed DTOs.
  • GatewayAuthHandler handles the challenge/response and pairing flow.
  • GatewayNotifier (Riverpod) owns the connection lifecycle and exposes state to the UI.
  • MessageRepository subscribes 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 in platform/ behind an abstract interface. Features never import from platform/ directly — they go through the provider.

Adaptive layout

  • AppShell detects screen width and renders either a NavigationRail (wide) or NavigationBar (narrow).
  • The split-pane (channel list + chat) is implemented inside StatefulShellRoute.
  • A single AdaptiveLayout.isWide(context) utility (based on MediaQuery.sizeOf) is the single source of truth for breakpoints.

Theming

  • AppTheme provides both light and dark ThemeData using flex_color_scheme.
  • No hardcoded colors or text styles in widgets — always use Theme.of(context) or named tokens from app_colors.dart.
  • Theme mode is persisted in SettingsRepository and restored on startup.

Packages

dependencies:
  # State
  flutter_riverpod: ^3.x
  riverpod_annotation: ^4.x

  # Navigation
  go_router: ^14.x

  # Models
  freezed_annotation: ^3.x
  json_annotation: ^4.x

  # Local DB
  drift: ^2.x
  drift_flutter: ^0.x  # bundles sqlite3 3.x natively; sqlite3_flutter_libs is no longer needed

  # Theming
  flex_color_scheme: ^8.x

  # Secure storage
  flutter_secure_storage: ^9.x

  # WebSocket
  web_socket_channel: ^3.x

  # Crypto
  cryptography: ^2.x

  # Biometrics
  local_auth: ^2.x

  # QR scanning
  mobile_scanner: ^7.x

  # Media
  image_picker: ^1.x
  file_picker: ^8.x

  # Notifications
  flutter_local_notifications: ^18.x

  # WebView
  webview_flutter: ^4.x

dev_dependencies:
  build_runner: ^2.x
  freezed: ^3.x
  json_serializable: ^6.x
  riverpod_generator: ^4.x
  drift_dev: ^2.x

Build order

PhaseFeatures
1 — FoundationFolder structure, theme, router, AppShell (adaptive layout), core utilities
2 — IdentityOnboarding (QR + pairing code), crypto, identity storage, workspace model
3 — LockBiometric/PIN lock screen, lock guard in router
4 — GatewayGatewayClient, protocol, auth handler, reconnect, GatewayNotifier
5 — ChannelsChannel list, local DB, channel repository, channel list screen
6 — ChatMessage model, MessageRepository (DB + gateway sync), chat screen, text bubble, input bar
7 — Rich messagesImage, voice, video, location, file bubbles; attachment picker
8 — Reply/ForwardReply preview, forward action, message context menu
9 — BotsBot model, bot list, bot assignment to channel, bot WebView screen
10 — SessionsSession model, session list, session chat
11 — Pixel streamingPixelStreamScreen with WebView + JS bridge
12 — NotificationsLocal notification service, notification on incoming message
13 — SettingsTheme toggle, security settings, gateway settings
14 — Multi-workspaceWorkspace switcher, per-workspace identity, workspace-scoped providers
15 — i18nARB files, flutter_localizations, string extraction

AI coding guardrails

Use these verbatim in prompts when working with an AI coding assistant:
  1. “Implement by feature slice. Each feature has its own folder under features/ for presentation and application/ for state.”
  2. “Respect the 3-layer boundary: features/application/domain/data/. No layer may skip a level.”
  3. “Do not place business logic in widgets. Widgets read providers and render data.”
  4. “Do not access the gateway, database, or any service directly from a widget or screen.”
  5. “No raw Map<String, dynamic> outside data/. All data crossing the data/domain boundary must be a typed, freezed model.”
  6. “Every new feature requires: a freezed domain model, a repository contract in domain/repositories/, an implementation in data/repositories/, and a Riverpod notifier in application/.”
  7. “All platform-specific behavior (biometrics, notifications, file system, WebView) must be behind an abstract interface in platform/.”
  8. “No hardcoded strings in widgets. Use constants or ARB keys.”
  9. “No hardcoded colors or text styles in widgets. Use Theme.of(context) tokens.”
  10. “The gateway connection lifecycle lives in GatewayNotifier. The UI reads message data from the local DB stream via MessageRepository, not from the WebSocket directly.”
  11. “Adaptive layout breakpoints use AdaptiveLayout.isWide(context) only. No inline MediaQuery width checks scattered across widgets.”
  12. “A Bot is not a Channel. They are separate domain models with separate providers and separate screens.”
  13. “Message bubble rendering uses a dispatcher widget that switches on MessageType and delegates to a typed sub-widget. No monolithic bubble widget.”
  14. “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.
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.
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:
drift: ">=2.28.1 <2.32.0"
drift_dev: ">=2.28.1 <2.32.0"
In Riverpod v3 / riverpod_generator ^4, class-based providers drop the Notifier suffix from their generated variable name.
Classv2 provider namev3 provider name
AuthNotifierauthNotifierProviderauthProvider
GatewayNotifiergatewayNotifierProvidergatewayProvider
RouterNotifierrouterNotifierProviderrouterProvider
MessageSendNotifiermessageSendNotifierProvidermessageSendProvider
Function-based providers (lowercase function names) are unchanged.Fix: global search-and-replace across the codebase for each renamed provider after running build_runner.
The old AsyncValue.value (which threw on error/loading) was removed. valueOrNull was renamed to value.
// v2
final state = ref.watch(authProvider).valueOrNull;

// v3
final state = ref.watch(authProvider).value;
Fix: global search-and-replace .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).
In freezed v3, the generated _$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).
// v2 — no longer compiles with freezed v3
@freezed
class Channel with _$Channel {
  const factory Channel({...}) = _Channel;
}

// v3 fix
@freezed
abstract class Channel with _$Channel {
  const factory Channel({...}) = _Channel;
}
Not affected: multi-variant union types (e.g. AuthState with unauthenticated / pairing / authenticated / error) — the mixin for unions has no abstract field getters, so class continues to work.
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.
// router_notifier.dart only imported auth_notifier.dart, not auth_state.dart directly.
// auth_notifier.dart uses `import` (not `export`) for auth_state.dart, so the
// AuthStatePatterns extension was not in scope.
// Error: "The method 'when' isn't defined for the type 'AuthState'"

// Fix: add a direct import to bring the extension into scope
import 'auth/auth_state.dart';
@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:
  1. Action/service notifiers that span async work must be keepAlive: true. MessageSendNotifier, GatewayNotifier, and RouterNotifier are examples — they are not tied to a single widget’s lifecycle.
  2. Capture all ref.read() results before the first await.
// ❌ BAD — ref.read after await will throw if provider was rebuilt/disposed
Future<void> sendText(...) async {
  final repo = ref.read(messageRepositoryProvider);
  await repo.insertOutbound(...);
  ref.read(gatewayProvider.notifier).send({...}); // 💥 ref may be dead here
}

// ✅ GOOD — all refs resolved before any await
Future<void> sendText(...) async {
  final repo = ref.read(messageRepositoryProvider);
  final gateway = ref.read(gatewayProvider.notifier); // captured early
  await repo.insertOutbound(...);
  gateway.send({...}); // safe — local variable, not going through ref
}
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.