Behavioral annotation client for the Seen backend. The app renders a hidden-object scene from a passive-data context, lets the user pick clues that felt familiar, asks an AI (or fixed fallback) to clarify what each clue meant, and then surfaces transparent 14-day patterns.
The point of this codebase's architecture is that UI changes and logic changes never have to touch the same file. See "Where to change what" below.
The backend URL and function key are injected at build time via
--dart-define so the key is never committed to source:
flutter run \
--dart-define=SEEN_API_BASE_URL=https://seen-backend-func-32653.azurewebsites.net/api \
--dart-define=SEEN_API_FUNCTION_KEY=<your-function-key>Get the key from Azure:
az functionapp keys list --name seen-backend-func-32653 \
--resource-group seen-backend-rg --query "functionKeys.default" -o tsvLeave both blank and the app still works — every backend call has a deterministic local fallback so the demo never breaks because the network broke.
lib/
├── main.dart # ProviderScope + MaterialApp entry
│
├── core/ # Foundation, no imports from below
│ ├── config/api_config.dart # baseUrl + functionKey from --dart-define
│ ├── errors/failures.dart # sealed Failure hierarchy
│ └── theme/app_theme.dart # AppColors + GlassContainer + ThemeData
│
├── data/ # External world
│ ├── models/ # DTOs matching backend contract 1:1
│ │ ├── daily_context.dart
│ │ ├── interpreted_signal.dart
│ │ ├── clue.dart # backend fields + UI-only extras (icon/x/y)
│ │ ├── clue_selection.dart
│ │ ├── daily_entry.dart
│ │ ├── follow_up_question.dart
│ │ ├── pattern_observation.dart
│ │ ├── scene_composition.dart
│ │ └── demo_profile.dart
│ ├── local/ # Static/seed data
│ │ ├── clue_catalog.dart # the 12 clues + visual metadata
│ │ ├── demo_profiles.dart # profile A/B/C
│ │ └── historical_dataset.dart # 14-day synthetic generator
│ ├── remote/
│ │ ├── api_client.dart # Dio + function-key interceptor
│ │ └── seen_api.dart # typed endpoints, throws Failure
│ └── repositories/seen_repository_impl.dart
│
├── domain/ # Pure business logic
│ ├── repositories/seen_repository.dart # abstract interface
│ └── engine/ # deterministic engines (also offline fallbacks)
│ ├── signal_engine.dart
│ ├── scoring_engine.dart
│ ├── pattern_engine.dart
│ ├── summary_engine.dart
│ └── fallback_questions.dart
│
└── presentation/ # UI + state controllers
├── providers/api_providers.dart # config → client → api → repository wiring
├── controllers/ # Riverpod notifiers
│ ├── app_navigation_controller.dart # mode / step / walkthrough
│ ├── profile_controller.dart # active demo profile + ad-hoc tweaks
│ ├── day_flow_controller.dart # signals + scene + selections + hint
│ ├── follow_up_controller.dart # AsyncValue<FollowUpQuestion> per clue
│ ├── summary_controller.dart # AsyncValue<DailyEntry> for commit
│ └── historical_entries_controller.dart
├── screens/
│ ├── main_layout.dart # header + stepper + mode switch
│ ├── walkthrough_screen.dart # 4-page intro
│ ├── context_preview_screen.dart # screen 1
│ ├── scene_screen.dart # screen 2
│ ├── daily_summary_screen.dart # screen 3
│ ├── patterns_screen.dart # screen 4
│ └── therapist_portal.dart
└── widgets/
├── scene_canvas.dart # the hidden-object canvas + painters
├── question_sheet.dart # the AI/fallback follow-up sheet
└── simulator.dart # telemetry sliders (optional dev tool)
Each layer only imports from itself or from layers below it:
presentation → domain → data → core
↘ ↑
core
No widget calls the API directly. No controller instantiates Dio. Screens read from providers; controllers read from the repository; the repository mediates the local engine and the HTTP API.
| I want to change… | Files to touch |
|---|---|
| Colors, typography, spacing | core/theme/app_theme.dart |
| A screen's look (any of the four patient screens) | presentation/screens/<screen>.dart only |
| The hidden-object canvas visuals | presentation/widgets/scene_canvas.dart |
| The follow-up modal sheet look | presentation/widgets/question_sheet.dart |
| The walkthrough intro pages | presentation/screens/walkthrough_screen.dart |
| Add/remove a clue | data/local/clue_catalog.dart |
| Change the 3 demo profiles | data/local/demo_profiles.dart |
| Change signal interpretation rules | domain/engine/signal_engine.dart |
| Change clue scoring / scene composition | domain/engine/scoring_engine.dart |
| Change how patterns are calculated | domain/engine/pattern_engine.dart |
| Change the offline fallback question by category | domain/engine/fallback_questions.dart |
| Change what the backend gets sent | data/remote/seen_api.dart |
| Swap the backend entirely (mock server, offline demo…) | provide a new SeenRepository and override seenRepositoryProvider |
| Add a new endpoint | add a method in seen_api.dart + a method in the repo interface + impl |
| Add a new screen | new file under presentation/screens/, wire into main_layout.dart |
| Change the max clues per day | kMaxSelectionsPerDay in day_flow_controller.dart (server also enforces) |
- Compile-time safe DI — the only way to get a repository into a widget
is via
ref.watch(seenRepositoryProvider); you can't accidentally pass the wrong shape or forget to construct one. AsyncValue<T>for the AI calls — widgets pattern-match loading/data/error rather than jugglingFutureBuilder+ local state.overrideWith(...)in tests — every provider is swappable, so widget tests never need real HTTP.- Auto-dispose semantics — the follow-up question per clue lives only while its sheet is open.
DayFlowController is the single controller responsible for "what does
today look like right now". It watches the active profile and re-derives
signals + scene deterministically. Selections and hint level are the only
mutable state; everything else is a pure function of the current context.
activeProfileProvider ──▶ DayFlowController.build()
│
├─▶ interpretSignals(context)
├─▶ composeScene(signals)
└─▶ selections=[], hintLevel=0
user taps clue ──▶ SceneCanvas.onClueTapped
└─▶ ClueQuestionSheet
└─▶ ref.watch(followUpQuestionProvider(clue))
└─▶ SeenRepository.followUpQuestion(...)
├─▶ SeenApi.followUpQuestion (backend)
└─▶ FallbackQuestions.forCategory (offline)
└─▶ ref.read(dayFlow…).addSelection(...)
user taps "Complete Daily Entry" ──▶ SceneScreen
└─▶ ref.read(dailySummary…).commit()
└─▶ SeenRepository.completeDay(...)
All models mirror the backend TypeScript types exactly. See
seen-backend/README.md for the source of truth. The frontend adds four
UI-only fields to Clue (icon, x, y, question, options) which
Clue.toJson() deliberately omits — the backend AI never sees them (this
respects the "AI receives only the minimum structured context" rule from
the requirements guide).
flutter testThe included smoke test overrides walkthroughDoneProvider to skip the
intro and asserts the main layout renders. Add more tests by overriding
the relevant providers with mocks — no need to stub the whole world.