# AGS Math — Frontend Reference

Single-file reference for the Flutter app at `C:\allgifted\flutter_demo`.
Aimed at a new dev or future Claude session walking into this codebase.

**Companion documents** (read these for adjacent concerns, not duplicated here):
- `CLAUDE.md` — git workflow, brand canon, project conventions.
- `AUDIT.md` — broader codebase audit (security, build, infrastructure gaps).
- `CHANGES.md` — Phase 1.5 hardening change log (apiBaseUrl extraction, etc.).
- `docs/NAMING_AUDIT.md` — pre-rename snapshot of brand-name occurrences.
- `AGS_MATH_PALETTE.md` — canonical kid-facing visual identity (in-flight, awaiting Pamela's three decisions).

**Last updated:** 2026-05-21 (immediately after the AGS Math display-string
rename on branch `rename/ags-math-brand`, commit 4f57fa3).

---

## 1. Stack + structure

### Platform targets

Declared in `pubspec.yaml` and the per-platform directories:

| Target | Status | Notes |
|---|---|---|
| **Web** | ✅ Active | Primary delivery for `quiz.allgifted.com`. Stripe payments stubbed (see §5). PWA manifest minimal. |
| **Android** | ✅ Active | `com.allgifted.math` bundle ID. Release-signed when `key.properties` exists, debug-signed fallback for `flutter run --release` locally. |
| **iOS** | ⚠️ Bundle ID is template default | `com.example.agMath` — Phase 2 cleanup needed. Build otherwise works. |
| **macOS** | ⚠️ Same | `com.example.agMath`. Not a planned release target. |
| **Linux** | ⚠️ Same | `com.example.ag_math`. Not a planned release target. |
| **Windows** | ⚠️ Slug filename | `ag_math.exe` retained intentionally (binary name = slug); display title now "AGS Math". |

### Dependencies (from `pubspec.yaml`)

```yaml
dependencies:
  flutter sdk ">=3.0.0 <4.0.0"
  cupertino_icons ^1.0.2
  audioplayers ^6.0.0         # correct.mp3 / wrong.mp3 / tada.mp3 SFX
  http ^1.1.0                 # all backend calls; no dio
  shared_preferences ^2.2.2   # primary state-persistence layer
  intl ^0.20.2                # date/number formatting
  flutter_html ^3.0.0-beta.2  # HTML rendering in question stems
  url_launcher ^6.2.5         # app-download redirects on web
  package_info_plus ^8.1.0    # runtime read of version+build → X-Client-Version
  flutter_stripe ^12.6.0      # mobile payment sheet only
  video_player ^2.11.1        # underlying for chewie
  chewie ^1.7.5               # video modal player
  google_fonts ^8.1.0         # Montserrat via network
  lottie ^3.3.3               # celebration animations (success.json, etc.)
  web ^1.1.1                  # JS interop (web-only payment stub)
  flutter_secure_storage ^10.1.0  # auth_token (iOS Keychain / Android Keystore)
  flutter_math_fork ^0.7.4    # LaTeX rendering in math questions
  sentry_flutter ^8.0.0       # error tracking

dev_dependencies:
  flutter_test, flutter_lints ^4.0.0
```

**Notable absences:** no `provider`, `riverpod`, `bloc`, `get_it`, `freezed`,
or any other state-management / DI framework. State is pure `StatefulWidget`
+ `SharedPreferences`. No `dio` — `http` is the only HTTP client. No
`firebase_*` of any kind — no push notifications, no analytics, no auth via
Firebase. No `cached_network_image` — every question image re-downloads on
every render. No `revenue_cat` package despite `lib/services/revenue_cat_service.dart`
existing (see §8).

### Directory map

```
lib/
├── main.dart                  # entrypoint + MaterialApp + 6 named routes
├── config.dart                # AppConfig — single source of build-time env
├── screens/                   # 23 files — 1 screen per file (mostly)
│   ├── auth/                  # OTP login flow
│   ├── diagnostic/            # diagnostic_screen + diagnostic_result_screen
│   ├── home_screen.dart
│   ├── profile_screen.dart
│   ├── subject_select_screen.dart
│   ├── question_screen.dart
│   ├── question_state.dart    # state-holder co-located with question_screen
│   ├── … etc
├── widgets/                   # 29 files — reusable widgets + per-screen extracts
│   ├── subscription/          # paywall + buy-lives + subscription-options sheets
│   ├── lives_header.dart      # heart counter + countdown timer
│   ├── platform_network_image*.dart  # web/io/stub conditional-import trio
│   ├── … etc
├── services/                  # 10 files — API + side-effect services
│   ├── auth_service.dart      # OTP, token storage, getApiHeaders
│   ├── user_service.dart      # /api/user/* endpoints
│   ├── track_service.dart, question_service.dart, question_logic_service.dart
│   ├── diagnostic_service.dart, answer_service.dart, upgrade_service.dart
│   ├── sound_service.dart     # SFX wrapper around audioplayers
│   ├── revenue_cat_service.dart  # dead placeholder; see §8
│   └── payment/
│       ├── payment_service.dart        # dispatcher + read-only HTTP
│       ├── payment_service_stub.dart   # if neither web nor io
│       ├── payment_service_mobile.dart # dart.library.io
│       ├── payment_service_web.dart    # dart.library.js_interop
│       └── payment_models.dart
├── theme/                     # design tokens
│   ├── app_colors.dart, app_theme.dart, app_font_styles.dart
│   ├── app_button_styles.dart, app_input_styles.dart, font_helpers.dart
├── models/                    # 3 files
│   ├── diagnostic_question.dart
│   ├── subscription_tier.dart   # enum + UserSubscription class (not yet consumed)
│   └── school_customization.dart  # white-label scaffolding (not yet consumed)
└── utils/
    └── smooth_page_transitions.dart
```

Code volume per `AUDIT.md`: ~16,140 LOC across `lib/`. Test directory exists
(`test/`) but is untracked and effectively empty (single broken counter
widget test deleted in commit `cd0f685`).

---

## 2. App lifecycle

### Startup sequence (`lib/main.dart:20-44`)

1. `WidgetsFlutterBinding.ensureInitialized()`.
2. `PackageInfo.fromPlatform()` reads pubspec `version` + `buildNumber` →
   `AppConfig.clientVersion = '${info.version}+${info.buildNumber}'`. Must
   complete before any HTTP call — `X-Client-Version` depends on it.
3. Mobile-only: `Stripe.publishableKey = AppConfig.stripePublishableKey`.
   Guarded by `if (!kIsWeb)` (Stripe SDK has no web support).
4. `SentryFlutter.init` with `dsn = kSentryDsn`, `tracesSampleRate = 0.2`,
   `debug = kDebugMode` → wraps `runApp(const MyApp())` as `appRunner`.

### Routing (`lib/main.dart:46-129`)

Custom `onGenerateRoute` — **not** `go_router`. Six named routes:

| Route | Builder | Notes |
|---|---|---|
| `/` | `StartupSplashScreen` | Default. Also the catch-all for unknown routes. |
| `/home` | `HomeScreen` | Main screen post-auth. |
| `/subject-select` | `SubjectSelectScreen` | Topic/track browser. |
| `/diagnostic` | `DiagnosticScreen` (in `BackToHomeWrapper`) | Adaptive placement test. |
| `/diagnostic-result` | `DiagnosticResultScreen` (in `BackToHomeWrapper`) | Takes `result: Map` arg. |
| `/question` | `QuestionScreen` (in `BackToHomeWrapper`) | Takes `trackId, testId, trackName, questions, sessionType` args. |

**Quirk — first-load splash hijack** (`main.dart:64-71`):

```dart
onGenerateRoute: (settings) {
  if (!_hasInitialized) {
    _hasInitialized = true;
    return MaterialPageRoute(builder: (_) => const StartupSplashScreen());
  }
  // … route switch
}
```

`_hasInitialized` is an instance bool on `_MyAppState`. **Web refresh and
any deep link always land on splash**, because the first invocation of
`onGenerateRoute` ignores `settings.name`. This breaks bookmarking and
shareable URLs on web. Acceptable for now since the app is single-flow.

**`BackToHomeWrapper`** (`main.dart:135-159`): `PopScope(canPop: false)`
that intercepts the device back button and routes to `/home` via
`pushNamedAndRemoveUntil`. Applied to diagnostic + question screens so a
back-press doesn't pop mid-session.

**Most navigation is unnamed.** Only ~6 of ~23 screens are reachable via
`Navigator.pushNamed`. Profile, Upgrade, Explore, Leaderboard, Result, etc.
use `Navigator.push(MaterialPageRoute(builder: ...))` directly. There is no
single route table to grep.

### Splash → home transition

`StartupSplashScreen` (`lib/screens/startup_splash_screen.dart`) renders
`Icons.smart_toy_outlined` (stock Material robot, **not** the AGS logo
despite `assets/logo.png` existing and being bundled) and a
`CircularProgressIndicator`. It checks auth state and routes to either
`/home` or the OTP login flow. There is no animated splash, no Lottie, no
brand reveal — straight indicator → next screen.

---

## 3. State + persistence

### State-management pattern

**No state-management framework.** Every screen is a `StatefulWidget` and
synchronises state through `SharedPreferences` reads in `initState` /
`build`. There is no event bus, no global store, no DI. Cross-screen
communication: caller writes to prefs, callee reads from prefs on its
own `initState` or `loadFromStorage()`.

**Refresh pattern**: screens that need fresh data call a backend endpoint
(typically `UserService.getSubscriptionStatus()` or `getUserInfo()`),
write the response to prefs, then `setState` from prefs. The same data
ends up in two places (prefs + widget state); prefs is the source of
truth across screens, widget state is the source of truth within a build.

### `SharedPreferences` keys (comprehensive inventory)

Grouped by domain. All writes/reads against the same key are listed; "✗"
means a key is written but never read or vice versa.

#### Identity

| Key | Type | Written | Read |
|---|---|---|---|
| `user_id` | int | `auth_service.dart`, `otp_request_screen.dart` | `auth_service.dart:getUserId` |
| `first_name` | String | `home_screen.dart:61`, `profile_screen.dart:97`, `pre_user_info_screen.dart` | `home_screen.dart:91`, `question_logic_service.dart` |
| `last_name`, `contact`, `email`, `dob`, `member_since` | String | `profile_screen.dart:97-102` | `profile_screen.dart:142-146` (offline fallback only) |

#### Subscription / access

| Key | Type | Written | Read |
|---|---|---|---|
| `is_subscriber` | bool | `home_screen.dart:56`, `profile_screen.dart:103` | `home_screen.dart:97`, `profile_screen.dart:147`, `question_logic_service.dart` |
| `unlimited` | bool | `subject_select_screen.dart:140` | `question_logic_service.dart`, `question_screen.dart:128`, `lives_header.dart` |
| `can_answer` | bool | `subject_select_screen.dart:145` | (no readers found — dead) |

Two flags both gate paywalled behaviour (`is_subscriber` from
`access_type=='premium'`, `unlimited` from the track-questions response).
They are written by different code paths and can drift mid-session if the
backend updates only one. The lives header uses `unlimited`; the home
screen uses `is_subscriber`.

#### Lives

| Key | Type | Written | Read |
|---|---|---|---|
| `lives` | int | `home_screen.dart:62`, `subject_select_screen.dart`, `question_screen.dart:172` (local decrement) | `question_logic_service.dart:44`, `profile_screen.dart:149` |
| `max_lives` | int | `subject_select_screen.dart:135` | `question_logic_service.dart:45` |
| `next_life_in_seconds` | int | (response cache; assumed set by question_logic_service) | `question_logic_service.dart:47` — **no `??` default; null will crash** |

#### Progress / stats

| Key | Type | Written | Read |
|---|---|---|---|
| `kudos`, `streak`, `total_questions`, `topics_practiced`, `overall_maxile`, `maxile_level`, `correct_answers`, `accuracy`, `fields_practiced`, `tracks_completed`, `skills_mastered` | int / double | `home_screen.dart:63-67`, `profile_screen.dart:106-114` | `home_screen.dart:92-96`, `profile_screen.dart:150-158` |
| `game_level`, `maxile_level` (second use) | int | `result_screen.dart:71-72` | (no readers — appears unused) |

#### Diagnostic

| Key | Type | Written | Read |
|---|---|---|---|
| `can_take_diagnostic` | bool | `home_screen.dart:72` | `home_screen.dart:101` |
| `diagnostic_message` | String | `home_screen.dart:74` | `home_screen.dart:102` |
| `diagnostic_days_remaining` | int | `home_screen.dart:76` | `home_screen.dart:103` |
| `diagnostic_premium` | bool | `home_screen.dart:78` | `home_screen.dart:104` |
| `last_diagnostic_result` | String (JSON) | `home_screen.dart:85` | `home_screen.dart:143` |

#### Flow flags

| Key | Type | Written | Read |
|---|---|---|---|
| `kiasu_completed` | bool | `otp_request_screen.dart:260`, `pre_user_info_screen.dart:88` | (mostly checked via backend `kiasu_recommended` flag) |
| `has_started_kiasu_path` | bool | `home_screen.dart:58`, `question_service.dart:74` | `home_screen.dart:98` |

### Secure storage

`flutter_secure_storage` (iOS Keychain / Android Keystore /
EncryptedSharedPreferences) — `auth_service.dart`.

| Key | Purpose |
|---|---|
| `auth_token` | JWT bearer. Written by `saveToken`; read by `getToken`. Migrated from plaintext SharedPreferences on first read (`_readAndMigrate`). |
| `pc_token` | Legacy "payment confirmation" token. **Never written in current code**, only read as fallback when `auth_token` is missing. Vestigial. |

### Logout state-clearing quirk

`profile_screen.dart:189` calls `prefs.clear()` directly when the user
taps Logout, bypassing `AuthService.logout()`. This:
1. Skips the server-side `POST /api/auth/logout` call, so the server still
   thinks the session is active.
2. Does wipe local state correctly (lives, kudos, diagnostic cache).

`AuthService.logout()` exists and does it correctly. The profile screen
should call it but doesn't.

---

## 4. Backend API contract

Base URL: `AppConfig.apiBaseUrl` (default `https://mathapi.allgifted.com`,
overridable with `--dart-define=API_BASE_URL=…`).

### Headers (`AuthService.getApiHeaders`, `auth_service.dart:223-232`)

Every authenticated request sends:

```
Authorization: Bearer <token>           # from flutter_secure_storage
Accept: application/json
Content-Type: application/json
X-Client-Version: <semver>+<build>      # e.g. "1.4.0+2"
```

`X-Client-Version` format is `<pubspec.version>+<pubspec.buildNumber>` —
constructed in `main.dart:27`. **Backend version-gating logic must parse
only up to the `+`** (per `CLAUDE.md`).

`requireAuth=false` (passed to `getApiHeaders`) returns headers without
the bearer; used by auth-bootstrap endpoints (OTP request, OTP verify,
guest login).

### Endpoint catalogue

#### Auth + User

| Method | Path | Consumer | Notes |
|---|---|---|---|
| POST | `/api/auth/otp/request` | `AuthService.requestOTP` | `{contact}` → `{user_id?, email_hint?, phone_hint?, requires_profile_completion?}` |
| POST | `/api/auth/otp/verify` | `AuthService.verifyOTP` | `{contact, otp}` → `{token, user_id?, kiasu_recommended?}` |
| POST | `/api/auth/request-otp` | `otp_request_screen.dart:87` | **Alternative path** — screen-level direct call bypassing AuthService. |
| POST | `/api/auth/verify-otp` | `otp_request_screen.dart:200` | Same — screen calls a different path than the service. Worth reconciling. |
| POST | `/api/auth/guest` | `AuthService.guestLogin` | `{contact?}` → `{token}` |
| POST | `/api/auth/logout` | `AuthService.logout` | Best-effort; failures swallowed. |
| POST | `/api/loginInfo` | `AuthService.authenticate` | Pre-OTP contact lookup. |
| GET | `/api/user` | `AuthService` | Current user profile. |
| GET | `/api/user/profile` | `UserService` | `{user, stats, progress}` composite. |
| PUT | `/api/user/profile` | `UserService` | Profile field updates. |
| PUT | `/api/user/update` | `UserService` | Alternative profile update. |
| GET | `/api/user/subscription-status` | `UserService.getSubscriptionStatus` | The big composite — see shape below. |

#### Tracks + practice loop

| Method | Path | Consumer | Notes |
|---|---|---|---|
| GET | `/api/tracks` | `TrackService` | Topic catalogue. |
| GET | `/api/tracks/{id}` | `TrackService` | Single track metadata. |
| GET | `/api/tracks/{id}/questions` | `QuestionService` | `{questions: [...]}` batch. |
| POST | `/api/tracks/{id}/answers` | `QuestionLogicService.submitResults` | Submit answers; response code dispatches next action (see §5). |
| POST | `/api/questions/{id}/report` | `QuestionLogicService` | User reports a bad question. |

#### Kiasu Path

| Method | Path | Consumer | Notes |
|---|---|---|---|
| GET | `/api/kiasu-path/start` | `QuestionService.startKiasuPath` | Begin guided sequence. |
| GET | `/api/kiasu-path/continue` | `QuestionService` | Resume. |
| POST | `/api/kiasu-path/submit` | `QuestionLogicService` | Same shape as tracks submit. |

#### Diagnostic

| Method | Path | Consumer | Notes |
|---|---|---|---|
| GET | `/api/diagnostic/eligibility` | `DiagnosticService.checkDiagnosticEligibility` | `{can_take, days_remaining, message, is_premium}`. 403 if restricted. |
| POST | `/api/diagnostic/start` | `DiagnosticService.startDiagnostic` | Begin / resume; returns either `{questions, session_id}` or `{diagnostic_completed, results}` or `{can_take:false}`. |
| POST | `/api/diagnostic/submit` | `DiagnosticService.submitDiagnostic` | `{session_id, answers}` → next batch or final results. |
| GET | `/api/diagnostic/result` | `DiagnosticService` | Latest result. |
| GET | `/api/diagnostic/last` | `DiagnosticService` | Cached "last completed" result. |
| POST | `/api/diagnostic/abandon/{id}` | `DiagnosticService` | Cancel in-flight session. |

#### Payments

| Method | Path | Consumer | Notes |
|---|---|---|---|
| GET | `/api/subscription-plans` | `PaymentService.getPlans` | Catalogue. All platforms. |
| POST | `/api/subscription/create-premium` | `paymentServiceSubscribeToPremium` (mobile) | `{billing_period}` → `{clientSecret, type, subscriptionId}`. |
| POST | `/api/lives/purchase` | `paymentServiceBuyLives` (mobile) | `{pack_id}` → `{clientSecret, amount, transactionId}`. |
| POST | `/api/payments/verify` | `PaymentService.verifyPayment` | `{transaction_id}` → `{ok, subscription_status}`. |
| GET | `/api/payments/status/{id}` | `PaymentService.checkPaymentStatus` | Polled up to 10× by `PaymentConfirmationDialog`. |

### `/api/user/subscription-status` response shape (inferred from `home_screen.dart:51-87`)

```
{
  ok: bool,
  access_type: 'premium' | 'free' | …,
  lives: int,
  kudos: int,
  streak: int,
  total_questions: int,
  topics_practiced: int,
  overall_maxile: int,
  first_name: string,
  has_started_kiasu_path: bool | int (1/0),
  diagnostic: {
    can_take: bool,
    days_remaining: int,
    message: string,
    is_premium: bool
  },
  last_diagnostic_result: { … } | null
}
```

### `/api/tracks/{id}/answers` response codes (inferred from `question_screen.dart:292-338`)

| Code | Meaning | Client action |
|---|---|---|
| 200 | Session complete | Navigate to `TestResultScreen` with kudos / maxile / percentage. |
| 201 | More questions queued | Re-render `QuestionScreen` with new batch. |
| 204 | Track completed (all topics) | Celebration → home. |
| 205 | Out of lives | Show `OutOfLivesModal` with buy / wait / subscribe. |
| 206 | (alt complete) | Navigate to result. |
| 403 | Premium required | Show `PremiumRequiredDialog`. |

---

## 5. Key flows

### 5.1 Auth (OTP-based)

1. `OTPRequestScreen` (`lib/screens/auth/otp_request_screen.dart`) — user
   enters email or phone, taps Send. Screen-level POST to
   `/api/auth/request-otp`. Caches `contact` (and `email` if it was an
   email) to prefs.
2. Server returns `{email_hint, phone_hint}` if existing user, or
   `{requires_profile_completion: true}` if new.
3. User enters 6-digit OTP → screen-level POST to `/api/auth/verify-otp`
   with `{contact, otp_code}`.
4. Response: `{token, kiasu_recommended}`. `AuthService.saveToken(token)`
   writes to `flutter_secure_storage` (key `auth_token`).
5. Branch:
   - `kiasu_recommended == true` → `PreUserInfoScreen` (Kiasu intake form).
   - Else → mark `kiasu_completed=true` → `BottomNavScreen` (home shell).

**Token attachment:** `AuthService.getApiHeaders()` (line 223) reads the
token from secure storage and stamps `Authorization: Bearer <token>` on
every authenticated call. Returns `null` if no token, which lets callers
treat unauthenticated state as a return rather than an exception.

**Endpoint mismatch:** `AuthService.requestOTP` and `verifyOTP` use
`/api/auth/otp/request` and `/api/auth/otp/verify`. The OTP screen calls
`/api/auth/request-otp` and `/api/auth/verify-otp` directly. Both work
today (backend accepts both); if the backend prunes one variant, half
the flow breaks. Worth consolidating.

**Logout:** `AuthService.logout` POSTs to `/api/auth/logout` then clears
secure storage + prefs. `profile_screen.dart:189` bypasses this and
calls `prefs.clear()` directly — server-side session is never invalidated.

### 5.2 Question / practice loop

Entry: `SubjectSelectScreen` → user taps a track → `Navigator.pushNamed('/question', arguments: {trackId, testId, trackName, questions, sessionType})`.

**Per-question cycle** (`lib/screens/question_screen.dart` +
`lib/screens/question_state.dart` + `lib/services/question_logic_service.dart`):

1. Render `widget.questions[state.currentIndex]` based on `type_id`:
   1 = multiple-choice, 2 = fill-in-blank, 3 = true/false. The
   `correct_answer` (int 0–3) **is shipped to the client** inside the
   question object — see §8 quirk #1.
2. User selects / inputs answer → `_submitAnswer()`.
3. `QuestionLogicService.validateAnswer` compares locally to
   `correct_answer`. Mark `state.isCorrect`. Play sound via
   `SoundService.playCorrect/Wrong`.
4. If wrong AND not `unlimited` → `_reduceLivesLocally` (decrement
   `lives` pref immediately).
5. Advance `state.currentIndex`. If batch exhausted or out of lives →
   `_sendResults` posts the whole batch to `/api/tracks/{id}/answers`
   (or `/api/kiasu-path/submit`).
6. Dispatch on response code (see table in §4).

**Hint flow** (`question_screen.dart:499-517`): if the question has a
`hints` array and isn't yet answered, show a lightbulb. Tap opens
`HintsBottomSheet`. No scoring penalty observed for hint usage.

**Video flow** (`question_screen.dart:365-387`): if the question has
`video_link` or `video_title`, "Watch Video" → Chewie player in a modal.
Relative URLs are prefixed with `${AppConfig.apiBaseUrl}/`.

**Exit:** back button → `QuestionDialogs.showExitDialog` → confirm pops
to home. **Session state is not persisted on abort** — partial progress
is lost.

**End of session:** `TestResultScreen` shows kudos earned (tween
animation), final maxile, percentage. Continue → `BottomNavScreen`.

### 5.3 Diagnostic

Gated behind `can_take_diagnostic` cached at `home_screen.dart:101`.
30-day cooldown enforced server-side; cooldown duration cached as
`diagnostic_days_remaining`. If `is_premium` is true the cooldown is
waived.

**Session** (`lib/screens/diagnostic/diagnostic_screen.dart`):

1. `DiagnosticService.startDiagnostic` → POST `/api/diagnostic/start`.
2. Response is one of:
   - `{session_id, questions: [...]}` — render the batch.
   - `{diagnostic_completed: true, results: {...}}` — go straight to results.
   - `{can_take: false, days_remaining}` — show cooldown dialog.
   - `{ok: false, can_answer: false}` — out of lives.
3. Per question: user taps option → client computes
   `isCorrect = currentQuestion.correctOptionId == _selectedOptionId`
   (line 144). Append `{question_id, selected_option_id, is_correct, answered_at}`
   to `_answers`.
4. End of batch → `DiagnosticService.submitDiagnostic(sessionId, answers)`
   → POST `/api/diagnostic/submit`. Response is either the next adaptive
   batch or the final result.
5. `DiagnosticResultScreen` (named route `/diagnostic-result`) renders the
   maxile gauge (0–700), level name, correct/incorrect breakdown.

**Difference from track practice:** no hints, no videos, no attempt
limits, fully server-adaptive (each batch comes from server based on
prior answers). The model class for diagnostic questions is the only
formal model in `lib/models/` that's actively consumed.

### 5.4 Subscription + payment

**Platform split** (`lib/services/payment/payment_service.dart:11-13`):

```dart
import 'payment_service_stub.dart'
    if (dart.library.js_interop) 'payment_service_web.dart'
    if (dart.library.io)         'payment_service_mobile.dart';
```

Dart conditional imports pick the right implementation at compile time.
This is the cleanest pattern in the codebase.

**Mobile subscribe** (`payment_service_mobile.dart:23-90`):

1. `PaymentService.subscribeToPremium('monthly' | 'annual')` →
   `paymentServiceSubscribeToPremium`.
2. POST `/api/subscription/create-premium` → server returns
   `{clientSecret, type: 'payment_intent' | 'setup_intent', subscriptionId}`.
3. `Stripe.instance.initPaymentSheet(SetupPaymentSheetParameters(
   merchantDisplayName: 'AGS Math', clientSecret: …))`.
4. `Stripe.instance.presentPaymentSheet()` — native sheet appears.
5. On dismiss: `PaymentService.showPaymentConfirmationDialog` polls
   `GET /api/payments/status/{id}` up to 10× (2 s between attempts).
6. On success: navigate home; `home_screen.loadFromStorage()` re-reads
   `is_subscriber` from `/api/user/subscription-status`.

**Mobile buy-lives** (`payment_service_mobile.dart:92-148`): same shape
but POSTs `/api/lives/purchase` with `{pack_id}`. Response includes
`amount` and `transactionId` for the post-payment poll.

**Web fallback** (`payment_service_web.dart`): both `subscribeToPremium`
and `buyLives` throw `PaymentException('…available in the AGS Math
mobile app. Download it to continue.', code: 'web_unsupported')`. The
calling UI must catch and display this — e.g., `GetTheAppPanel` shows a
panel directing the user to `AppConfig.appDownloadUrl`. The buy-lives
bottom sheet does not currently catch this gracefully on web — tapping
"Buy Lives" on web will surface the exception.

**Read endpoints** (`getPlans`, `verifyPayment`, `checkPaymentStatus`)
live in `payment_service.dart` directly and work on all platforms — they
are pure HTTP.

**Models** (`payment_models.dart`, `subscription_tier.dart`):
- `SubscriptionPlan`, `PaymentResult`, `PaymentVerificationResult`,
  `PaymentStatus`, plus a hierarchy of `PaymentException`
  subclasses (`PaymentAuthenticationException`, `PaymentCancelledException`,
  `PaymentPlatformException`).
- `SubscriptionTier` enum: `free, simba, student, family, teacher, school`.
- `UserSubscription` class with `tier, subscriptionEnd, consumableLives,
  hasUnlimitedLives, childProfiles, schoolId`. **Not currently consumed**
  by any screen — scaffolding for the family/school tier rollout.

### 5.5 Lives mechanic

**UI** (`lib/widgets/lives_header.dart`): row of hearts rendered by
`DoodleHeartPainter` (custom `CustomPainter`, hand-wobbly heart shape,
filled with `#960000` Crimson). Generates `widget.maxLives` hearts; fills
first `widget.lives` of them.

**Countdown timer** (`lives_header.dart:58-80`): `Timer.periodic`
decrements `_remainingSeconds` once per second. Initial value comes
from `widget.nextLifeInSeconds` (passed by parent, sourced from server
in the questions/answers response). Renders "+1 in 12m 34s" next to the
hearts.

**Unlimited bypass** (`lives_header.dart:99`):

```dart
if (widget.unlimited) {
  return const SizedBox.shrink();
}
```

Premium and SIMBA users have `unlimited == true` → the header doesn't
render at all.

**Refill correctness caveat:** the timer is a relative countdown, not
anchored to a server timestamp. iOS pauses `Timer` when the app is
backgrounded; on resume the countdown shows stale time. Also vulnerable
to device clock skew. Architecturally the server should return an
absolute "next life at" ISO timestamp and the client should render
`max(target - now, 0)` — that change is non-trivial.

**Out-of-lives flow** (`lib/widgets/out_of_lives_modal.dart`): modal with
three actions: Buy Lives (opens `BuyLivesBottomSheet` → mobile-only Stripe
purchase), Unlimited Lives (opens `SubscriptionOptionsSheet` → upgrade
flow), Wait (dismisses; user must wait for the countdown).

### 5.6 Streak (display-only metric)

Backend computes the streak. Client only reads it.

| Where | Code |
|---|---|
| Read from `/api/user/subscription-status` | `home_screen.dart:64` → `await prefs.setInt('streak', response['streak'] ?? 0);` |
| Read from `/api/user/profile` | `profile_screen.dart:86` → `final parsedStreak = parseInt(stats['streak']);` |
| Cached as `prefs.getInt('streak')` | `home_screen.dart:92`, `profile_screen.dart:151` |
| Displayed (home) | `home_screen.dart:581-598` — `Icons.local_fire_department` (orange flame) + `"$_streak ${_streak < 2 ? 'day' : 'days'} streak"`; hidden when streak is 0 |
| Displayed (profile) | `profile_screen.dart:339` — `_buildStatItem('Streak', '$streak days', …)` |

**What does NOT exist on the client:**
- No `last_practice_date` storage.
- No daily-check or "did the user practice today" logic.
- No client-side increment on activity.
- No "streak about to break" notification (the app has no notification
  package installed at all — see §1).
- No "freeze streak" mechanic.
- No streak-broken UI flow.

If the backend's "streak" is genuinely a consecutive-days metric, the
client will render it correctly; if it's labelled "streak" but actually
counts something else (correct-answer streak, session streak), the
client will still render it identically because it doesn't inspect what
the number means.

---

## 6. Theme system

### Current implementation (`lib/theme/`)

`AppTheme.lightTheme` (`app_theme.dart`) is the single themed entrypoint
passed to `MaterialApp`. It composes:

- `colorScheme`: `primary: AppColors.darkRed`,
  `secondary: AppColors.pink`, `surface: AppColors.tileGrey`,
  `onPrimary: AppColors.white`, `onSecondary: AppColors.black`.
- `textTheme`: `GoogleFonts.montserratTextTheme().copyWith(...)` — every
  Material text role mapped to an `AppFontStyles` getter.

### `AppColors` tokens (current values, `app_colors.dart`)

| Token | Hex | Notes |
|---|---|---|
| `darkRed` | `#960000` | Primary. AGS Crimson. |
| `maroon` | `#853030` | Unused outside its own definition. |
| `pink` | `#D0ACAC` | Secondary. Brand Dusty Rose. |
| `yellow`, `gold` | `#FFBF66` | **Not the brand Gold** (`#BF9237`). |
| `lightGreyBackground` | `#EEEEEE` | Scaffold background on subject-select etc. |
| `tileGrey` | `#D9D9D9` | Card surfaces. |
| `lightGrey`, `mediumGrey`, `darkGrey`, `darkGreyText`, `darkText`, `black`, `white` | various | Greyscale. |
| `success` | `#50D200` | Brighter than brand Lime. |
| `error` | `#D80000` | Harder than the new palette's `error-gentle`. |
| `inputActive`, `inputInactive`, `inputBackground` | `#960000`, `#E2E8F0`, `#F8FAFC` | Input field states. |
| `progressComplete`, `progressActive`, `progressInactive` | `#2E7D32`, `#960000`, `#D1D5DB` | Progress UI. |
| `levelBeginner`, `levelBuilding`, `levelGrowing`, `levelAdvanced` | `#2196F3, #FF9800, #4CAF50, #9C27B0` | Diagnostic-result level pills. Material defaults; not on-brand. |
| `speedBlue`, `accuracyGreen` | `#2196F3, #50D200` | Stat-tile colours. |

### `AppFontStyles` tokens (selected — full list in `app_font_styles.dart`)

All Montserrat via `google_fonts`. Sample:

| Token | Size | Weight | Colour | Use |
|---|---|---|---|---|
| `heading1` | 28 | 700 | Black | Page titles |
| `heading2` | 18 | 700 | DarkRed | Subheadings |
| `headingLarge` | 32 | 700 | Black | Hero |
| `bodyLarge`, `bodyMedium` | 18 / 16 | 400 | Black | Body text |
| `questionText` | 18 | 400 | Black | Question stems |
| `caption`, `headingSubtitle` | 12 | 400 / 500 | DarkGreyText / Black | Small text |
| `greeting`, `name` | 25 | 500 / 700 | Black | Home-screen welcome |
| `buttonPrimary`, `buttonSecondary` | 20 | 700 | White / Black | Button labels |
| `tileText` | 14 | 700 | DarkGreyText | Tile labels |

`AppButtonStyles` and `AppInputStyles` follow the same token pattern;
each exposes a handful of pre-baked `ButtonStyle` / `InputDecoration`
instances. See `app_button_styles.dart` / `app_input_styles.dart` for
the full enumeration.

### ⚠️ Theme is now off-spec — `AGS_MATH_PALETTE.md` supersedes

The canonical AGS Math palette was newly defined today
(`AGS_MATH_PALETTE.md`, 2026-05-21) and the current theme implementation
no longer matches the spec on multiple fronts:

| Spec token | Spec hex | Current theme | Status |
|---|---|---|---|
| `brand-crimson` | `#960000` | `darkRed = #960000` | ✅ Match |
| `brand-gold` (reward) | `#BF9237` | `gold = #FFBF66` | ❌ Wrong hex |
| `ags-lime` (kid-facing primary) | `#88C808` | (missing) | ❌ Not defined |
| `success` | `#4CAF50` | `success = #50D200` | ❌ Wrong hex |
| `error-gentle` | `#E57373` | `error = #D80000` | ❌ Too harsh per spec |
| `info-blue` | `#3BA9F4` | (missing) | ❌ Not defined |
| `bg-warm` | `#FBF9F4` | `lightGreyBackground = #EEEEEE` | ❌ Different value, different intent (warm vs neutral grey) |
| `surface` | `#FFFFFF` | `white = #FEFEFE` | ⚠️ Close enough |
| `ink` (body / mascot) | `#2B2B2B` | `black = #282828` | ⚠️ Close enough |
| `ink-soft` | `#6B6B6B` | `darkGreyText = #6D6D6D` | ⚠️ Close enough |

The spec also says **Lime should be the kid-facing primary** (action
buttons, hero CTAs) with Crimson reserved as the institutional anchor
"used sparingly." The current theme uses Crimson as `colorScheme.primary`
and across the entire button system. That's a substantive rework, not a
hex swap.

**Three pending Pamela decisions** block the theme rework:
1. Mascot colour (charcoal-ink vs ags-lime).
2. Brightness direction (Lime-led vs brighter / blue co-primary).
3. Field colour-coding per subject (palette TBD when field list is confirmed).

Until those land, leave `lib/theme/app_colors.dart` as-is and treat
`AGS_MATH_PALETTE.md` as the design source of truth.

### Fonts: cross-repo mismatch

`google_fonts` Montserrat in this app; **Raleway** in the All Gifted
institutional site (`c:\allgifted\allgifted-web\CLAUDE-brand.md:20`).
The product and the company site use different display faces. This is
either intentional (kid-facing app deserves its own face) or a drift
that needs resolution — call the design team's intent.

### Splash + launcher icons

- Splash text uses `Icons.smart_toy_outlined` (stock Material robot)
  in `#960000` at 100px. **Not** an AGS-branded logo, despite
  `assets/logo.png` being declared in `pubspec.yaml:40`.
- Android launch background: plain white (`launch_background.xml`
  has no bitmap).
- Android launcher icons (`mipmap-*/ic_launcher.png`): present in 5
  densities but appear to be Flutter template defaults rather than
  the AGS logo. Visual inspection recommended.
- iOS `LaunchScreen` storyboard: not audited.

---

## 7. Build + environment

### `AppConfig` (`lib/config.dart`)

```dart
class AppConfig {
  // --dart-define=API_BASE_URL=…
  static const String apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://mathapi.allgifted.com',
  );

  // --dart-define=STRIPE_PUBLISHABLE_KEY=…
  static const String stripePublishableKey = String.fromEnvironment(
    'STRIPE_PUBLISHABLE_KEY',
    defaultValue: 'pk_test_YOUR_KEY_HERE',
  );

  static const String currency = 'sgd';

  // Set at startup in main.dart from package_info_plus.
  // Sent as X-Client-Version on every API call. Format: "<semver>+<build>".
  static late final String clientVersion;

  // Web subscribe/buy-lives buttons redirect here.
  // TODO(Phase 1.5): replace with real App Store / Play Store smart link.
  static const String appDownloadUrl = 'https://allgifted.com/app';

  static const bool isProduction = false;  // unused — see below
}
```

**`isProduction` is not used anywhere.** Grep confirms zero references
outside the definition itself, `AUDIT.md`, and `CHANGES.md` commentary.
Either wire it (Sentry environment tag, feature flags, release logging)
or remove it.

### Dev-environment chip (`lib/widgets/dev_environment_chip.dart`)

Renders a lime-coloured chip in the app bar of `RegistrationScreen` and
the OTP screen when `AppConfig.apiBaseUrl != 'https://mathapi.allgifted.com'`,
showing the dev host (e.g. `DEV: localhost:8000`). Prevents the
recurring "wait, am I in prod?" confusion when running against a local
backend.

### Sentry (`lib/main.dart:17-43`)

- DSN: hardcoded as `kSentryDsn`. Public DSNs are not secrets — Flutter
  apps ship the DSN in the binary by design.
- `tracesSampleRate: 0.2` (20 % of transactions). No prod/dev sampling
  split.
- `debug: kDebugMode`.
- **No `Sentry.setUser` or `configureScope` calls anywhere** — crash
  reports cannot be attributed to a specific user account.
- **No `Sentry.captureException` manual calls** — only the framework's
  auto-capture handler.
- **No `beforeSend` filter** — anything captured is sent.
- `lib/widgets/sentry_test_button.dart` exists for manual error
  injection during QA.

### Android signing (`android/app/build.gradle.kts`)

```kotlin
signingConfigs {
  create("release") { /* reads key.properties */ }
}
buildTypes {
  release {
    signingConfig = if (keystorePropertiesFile.exists()) {
      signingConfigs.getByName("release")
    } else {
      signingConfigs.getByName("debug")
    }
  }
}
```

Release builds use the proper keystore when `key.properties` is present
(production), and gracefully fall back to debug signing for local
`flutter run --release` smoke tests. `android/key.properties.example` is
checked in as a template.

### Build commands (representative)

Local Android emulator against a local backend:

```
flutter run \
  --dart-define=API_BASE_URL=http://10.0.2.2:8000 \
  --dart-define=STRIPE_PUBLISHABLE_KEY=pk_test_…
```

Local iOS simulator: same but `localhost:8000` instead of `10.0.2.2`.
Production release: no overrides — defaults are production.

`CHANGES.md` has the full invocation matrix.

---

## 8. Notable quirks, known gaps, cross-references

### Quirks worth flagging to anyone editing this code

1. **`correct_answer` is shipped to the client** in every question
   payload (`diagnostic_question.dart:64-68`, track question objects via
   `QuestionLogicService.validateAnswer`). A user with browser devtools
   open can see the answer before submitting. The server presumably
   re-grades on submit, but the client-visible kudos / maxile / progress
   are inflatable in the moment. Real fix: stop sending `correct_answer`
   client-side; submit user choice, let server adjudicate, return
   correctness in the response.

2. **First-load routing hijack** (`main.dart:64-71`): web refresh and
   deep links always land on splash because the first invocation of
   `onGenerateRoute` short-circuits to `StartupSplashScreen`. Breaks
   URL-shareability on web.

3. **OTP endpoint duality** — `AuthService` calls `/api/auth/otp/{request,verify}`;
   `otp_request_screen.dart` calls `/api/auth/{request-otp,verify-otp}`.
   Backend currently accepts both. Risk if backend prunes one path.

4. **Lives double-decrement risk** (`question_screen.dart:165-182` +
   `question_logic_service.dart`): the client decrements `lives` in
   prefs immediately on a wrong answer; on session submit the server
   response includes a fresh count and overwrites the local value.
   If the server response fails mid-session, local decrement persists
   even though the server may not have charged. Server-supplied state
   wins on the next successful sync, so the gap is bounded but real.

5. **`profile_screen.dart:189` calls `prefs.clear()` directly** on
   logout, bypassing `AuthService.logout()`. Server session is not
   invalidated. Use `AuthService.logout()` instead.

6. **`is_subscriber` vs `unlimited`** — two flags both gate paywalled
   behaviour, written by different code paths, can drift mid-session.
   The home screen gates on one, the lives header on the other.

7. **Timer-based lives refill** (`lives_header.dart`) drifts when the
   app is backgrounded on iOS and is vulnerable to device clock skew.
   Architectural fix is server-supplied absolute timestamp.

8. **`next_life_in_seconds` no default** (`question_logic_service.dart:47`)
   — reads `prefs.getInt('next_life_in_seconds')` without `?? fallback`.
   Will return `null` if the key was never written; downstream code
   must null-check or crash.

9. **`isProduction` flag is dead** (`config.dart:33`) — defined but
   never referenced. Either wire it or remove it.

10. **`revenue_cat_service.dart` is a 17-line dead stub** with three
    `// TODO` method bodies. No imports anywhere, no
    `flutter_revenue_cat` package in `pubspec`. Either implement (Phase
    2 IAP) or delete.

11. **`UserSubscription` / `SchoolCustomization` models are scaffolding**
    — defined in `lib/models/` but not consumed by any screen. Saved
    forward for family/school tier rollout.

12. **Web payment failure mode is unhelpful** — `buyLives` /
    `subscribeToPremium` on web throw `PaymentException('web_unsupported')`.
    Some call sites (`GetTheAppPanel`) catch and redirect to the app
    download page; others let the exception bubble. Audit the buy-lives
    flow on web before any web user is allowed to reach it.

13. **No `cached_network_image`** — every question / option image
    re-downloads from `mathapi.allgifted.com/media/...` on every render
    (back-stack push, scroll-off-and-back, hot reload). Network-dependent
    quiz UX. AUDIT.md flags this.

14. **Web favicon is hot-linked from the backend**
    (`web/index.html:10` → `mathapi.allgifted.com/.../favicon.ico`). If
    the backend is unreachable the favicon breaks. Local
    `web/favicon.ico` exists but is unused.

15. **~100 `print()` statements with emoji prefixes** survive into
    release builds (they're `print`, not `debugPrint`). Will spam
    release logs. Cosmetic but trivial to clean.

### Cross-references — do not duplicate these here

- **Security / build / infra audit** → `AUDIT.md` (root). Larger, more
  exhaustive sweep including `applicationId`, signing, web caching,
  network resilience.
- **Phase 1.5 hardening history** → `CHANGES.md` (root). Full record of
  the `apiBaseUrl` extraction, build-flag rollout, version-header wiring.
- **Brand rename pre-state** → `docs/NAMING_AUDIT.md`. Frozen snapshot of
  every "All Gifted Math" / "AllGifted Math" / "Ag Math" occurrence
  before the rename on `rename/ags-math-brand`.
- **Visual identity (kid-facing)** → `AGS_MATH_PALETTE.md` (root,
  untracked, in-flight). The canonical palette. Theme implementation
  follows once the three Pamela decisions land.
- **Project conventions / git workflow** → `CLAUDE.md` (root). Brand
  canon, multi-machine git rules, never-commit-on-prod rule, etc.

### Open Phase-2 work surface

Items pending dedicated branches:

- **Bundle IDs**: iOS/macOS still `com.example.agMath`, Linux
  `com.example.ag_math`. Needs provisioning-profile work for iOS;
  trivial elsewhere. Don't change `com.allgifted.math` on Android
  without confirming whether the Play Store listing exists under
  that ID first.
- **Visual rework**: apply `AGS_MATH_PALETTE.md` after Pamela's
  three decisions land. Includes Lime as primary, error-gentle
  swap, surfacing Gold as reward-only, custom splash logo, real
  launcher icons. Likely a `theme/ags-math-palette` branch.
- **Web payment**: replace web-side `web_unsupported` exception
  with either a graceful redirect (current behaviour in
  `GetTheAppPanel`) sitewide or a real Stripe Checkout integration.
- **Frontend tests**: zero tests right now. Even a smoke test that
  starts the app and verifies the splash → login transition would
  catch regressions in the routing-hijack code path.
