# AllGifted Math — Flutter Codebase Audit

_Read-only audit. All file references use `lib/...` paths relative to the repo root._

Scope: ~16,140 LOC across `lib/` (22 screens, 23 widgets, 11 services, 3 models). Targets: Android, iOS, macOS, Linux, Windows, Web. Backend: `https://mathapi.allgifted.com` (Laravel). Single environment hardcoded — no prod/staging switching.

> **Severity legend:** `[CRITICAL]` blocks build/release or causes data loss/security incident. `[HIGH]` user-visible breakage or revenue/integrity risk. `[MEDIUM]` correctness/maintainability. `[LOW]` polish/tech debt.

---

## 1. Architecture

**State management:** None. No Provider, Riverpod, Bloc, GetX, or InheritedWidget pattern. Cross-screen state is shared via `SharedPreferences` reads/writes and constructor arguments. The same flag (`is_subscriber`, `lives`, `unlimited`, `kudos`, `first_name`) is read in ≥5 different `StatefulWidget`s, each independently calling `SharedPreferences.getInstance()`.

**Folder structure:** Flat `lib/{screens,services,widgets,models,theme,utils}` — reasonable for the size, but with anomalies:
- `lib/screens/subscriber/` exists and is empty.
- Three orphan entrypoints: `lib/main.dart`, `lib/main-hello world.dart` (note the space), `lib/main-testrive.dart`.
- `lib/screens/pre_subscriber_home_screen-old.dart` is dead code with stale TODOs.
- `lib/web_backup/index.html` is checked-in old bootstrap.

**Navigation:**
- `MaterialApp.onGenerateRoute` registers only 5 named routes (`/`, `/home`, `/subject-select`, `/diagnostic`, `/diagnostic-result`, `/question`).
- The remaining ~17 screens are pushed via `Navigator.push(MaterialPageRoute(...))` directly — 43 raw Navigator calls in `lib/screens/`.
- `MyApp._hasInitialized` is a mutable instance bool used inside `onGenerateRoute` to force the first route to splash. This is fragile and **breaks web refresh / deep links** — every page reload routes back to splash regardless of the URL.
- `BackToHomeWrapper` uses `onPopInvoked` (deprecated in Flutter 3.22+; use `onPopInvokedWithResult`).
- No `go_router` / `auto_route`. No deep linking. No URL strategy for web (`PathUrlStrategy`) — URLs use the hash strategy by default.

### Findings
- `[CRITICAL]` `lib/main.dart:43` — `_hasInitialized` flag swallows the requested route on first load. On web, every refresh, every shared URL, every Stripe redirect-back lands on splash. Combined with no deep-link routing, this makes refunds/payments/email links unreliable.
- `[HIGH]` No state-management abstraction. `is_subscriber` and `lives` are duplicated in 5+ screens; mutating them in one screen requires the next screen to re-read prefs — race conditions when API responses arrive after the next screen has already read.
- `[MEDIUM]` `lib/screens/subscriber/` empty folder, `pre_subscriber_home_screen-old.dart`, `main-hello world.dart`, `main-testrive.dart`, `web_backup/` — dead code in version control.
- `[MEDIUM]` 17/22 screens push raw `MaterialPageRoute`s, bypassing the route table — unnamed, untestable navigation, no analytics hookpoints.

---

## 2. Pubspec & Dependencies

**SDK:** `sdk: ">=3.0.0 <4.0.0"` (overly wide; should pin a minimum like `>=3.5.0`).

**Direct dependencies declared:** `cupertino_icons`, `audioplayers ^5.2.1`, `http ^1.1.0`, `intl_phone_field ^3.2.0`, `shared_preferences ^2.2.2`, `intl ^0.20.2`, `flutter_html ^3.0.0-beta.2`, `url_launcher ^6.2.5`, `package_info_plus ^4.0.0`.

**Commented out** in `pubspec.yaml`: `flutter_stripe`, `in_app_purchase`, `video_player`, `chewie`, `flutter_html_math`, `flutter_math_fork`, `flutter_spinbox`, `google_fonts`.

**`pubspec.lock`** confirms no `flutter_stripe`, `chewie`, `video_player`, `google_fonts`, `lottie`, `rive`, `web`, `flutter_secure_storage` installed.

**Imports referencing missing packages** (build will fail on `flutter pub get && flutter run`):

| Package | Files importing it |
|---|---|
| `package:flutter_stripe/flutter_stripe.dart` | `lib/main.dart`, `lib/services/payment/payment_service.dart`, `stripe_mobile_wrapper.dart`, `stripe_service_mobile.dart` |
| `package:video_player/video_player.dart` | `lib/screens/question_screen.dart`, `lib/widgets/video_modal.dart` |
| `package:chewie/chewie.dart` | same |
| `package:google_fonts/google_fonts.dart` | `lib/theme/app_theme.dart`, `lib/theme/app_font_styles.dart` |
| `package:lottie/lottie.dart` | `diagnostic_result_screen.dart`, `test_result_screen.dart`, `quick_motivation_overlay.dart` |
| `package:rive/rive.dart` | `lib/main-testrive.dart` (orphan) |
| `package:web/web.dart` | `lib/screens/auth/otp_request_screen.dart` |

`dart:html` and `dart:ui_web` are imported unconditionally in `lib/widgets/platform_network_image.dart` and `lib/screens/auth/otp_request_screen.dart` — these will not compile for mobile/desktop.

**Unused dependencies:** `intl_phone_field` is declared but I find no import in `lib/`. `cupertino_icons` is fine (Material default). `flutter_html` is used.

**Dev/prod separation:** `dev_dependencies` only contains `flutter_test` + `flutter_lints ^3.0.0` (one major behind 4.x). No `mocktail`, `bloc_test`, `integration_test`, `golden_toolkit`, `build_runner`. No `--dart-define` plumbing. `AppConfig.isProduction` is a `const bool` literal toggled by hand-editing source.

### Findings
- `[CRITICAL]` `flutter pub get && flutter run` fails: 7 third-party packages imported by `lib/` are absent from `pubspec.yaml` and `pubspec.lock`. The repo is non-buildable as committed.
- `[CRITICAL]` `lib/widgets/platform_network_image.dart:6-7` and `lib/screens/auth/otp_request_screen.dart:16-17` import `dart:html` / `dart:ui_web` without conditional imports — Android/iOS/macOS builds will fail compilation.
- `[HIGH]` `flutter_html ^3.0.0-beta.2` — beta dependency in production app; LaTeX/HTML is on the critical path of every question.
- `[MEDIUM]` `audioplayers ^5.2.1` — current major is 6.x; `flutter_lints ^3` — current major is 4.x; `package_info_plus ^4.0.0` — current is 8.x.
- `[MEDIUM]` SDK constraint too wide; pin a minimum that matches the Flutter version actually used.
- `[LOW]` `intl_phone_field` declared, never imported.

---

## 3. API Integration & Auth

**HTTP client:** Plain `package:http`. No `Dio`, no interceptors, no centralized client. Each service file rebuilds a `http.post/get` call from scratch with hand-rolled headers.

**Base URL:** Hardcoded in `lib/config.dart:3` to `https://mathapi.allgifted.com`. Local override is commented out. `isProduction` flag exists but is unused — switching environments requires editing source. No `--dart-define`, no `.env`.

**Auth token storage:** **`SharedPreferences` (plaintext key `auth_token`)**. `flutter_secure_storage` is not in pubspec at all. iOS Keychain / Android Keystore are not used. Tokens are also fetched directly via `prefs.getString('auth_token')` in 8+ places (`question_service.dart`, `user_service.dart`, `question_logic_service.dart`) bypassing the `AuthService.getToken()` abstraction (which has fallback logic for `pc_token`).

**Error handling:** Per-service try/catch returning `null` or empty-list. No error type hierarchy crosses service boundaries — the call sites have to inspect map keys (`response['ok'] == false`, `response['code'] == 205`) to distinguish "out of lives" from "premium required" from "network error".

**Status code dispatch is inconsistent:**
- `diagnostic_service.dart` handles 200, 401, 403, 404 → `null`/data.
- `question_service.dart` handles 200, 205 (out of lives), 403 (premium).
- `payment_service.dart` only checks `200`.
- `track_service.dart` no 401 handling at all.

**No retry, no backoff, no offline queue.** Timeouts hardcoded inline (5–30s, varies per call site).

**Logout flow** (`auth_service.dart:289`) clears prefs but `profile_screen.dart:189` calls `prefs.clear()` directly — bypassing `AuthService.logout()` which also tries to call `/api/auth/logout`.

### Findings
- `[CRITICAL]` Auth tokens in `SharedPreferences` plaintext. On rooted Android / jailbroken iOS, or any backup/restore scenario, tokens leak. **Must move to `flutter_secure_storage` (or platform keychain).**
- `[CRITICAL]` `lib/services/user_service.dart:133` — `Uri.parse('$AppConfig.apibaseURL/api/user/update')` is broken interpolation: `$AppConfig` resolves to the class symbol, then `.apibaseURL` is appended as a literal string. Field is also misspelled (`apibaseURL` vs `apiBaseUrl`). `UserService.updateUserInfo()` will hit a non-existent host and silently fail.
- `[HIGH]` No environment switching. `AppConfig.isProduction` exists but is never read. Devs must edit `config.dart` to point at staging — risk of shipping the wrong URL.
- `[HIGH]` `Stripe.publishableKey` is hardcoded inline in `lib/main.dart:17` (a real-looking `pk_test_51NehC6...` key, not `AppConfig.stripePublishableKey`). This bypasses the config layer and bakes the key into binary; if it gets swapped to a `pk_live` value, rotation requires a redeploy.
- `[HIGH]` Three different paths to `getToken`: `AuthService.getToken()` (handles fallback `pc_token`), `prefs.getString('auth_token')` (direct), and `userData['token']` (loaded via `QuestionLogicService.loadUserData`). Token-refresh logic in any one place will not cover the other two.
- `[MEDIUM]` `lib/services/profile_screen.dart:189` calls `prefs.clear()` instead of `AuthService.logout()` — wipes diagnostic cache, kiasu state, etc. and skips the server-side logout call.
- `[MEDIUM]` No retry/backoff on transient 5xx; no offline queue for answer submission. A flaky network can cause `submitResults` to silently `null` and lose the user's session.
- `[MEDIUM]` `lib/services/payment/payment_service.dart` calls `/payments/verify` and `/payments/status/...` (no `/api/` prefix), while every other call uses `/api/...` — likely a 404 in production.
- `[LOW]` 100+ `print()` statements with emoji prefixes will spam release logs. Switch to `debugPrint` (compiled out in release) or a logger.

---

## 4. Stripe / Payment Flow

**SDK used:** `flutter_stripe` (mobile native), but **the package is not in `pubspec.yaml`** (see §2). Three parallel implementations exist:

1. `lib/services/payment/payment_service.dart` — primary entry. `startCheckout()` calls `/api/subscription/create-premium` regardless of whether the user is buying lives or a subscription.
2. `lib/services/payment/stripe_service_mobile.dart` — `buyLives()` (calls `/lives/purchase`) and `subscribeToPremium()` (calls `/subscription/create-premium`, no `/api/` prefix).
3. `lib/services/payment/stripe_service_stub.dart` — web stub with a different `getPlans()` that hits `/subscription-plans`.

**PCI surface:** Native `flutter_stripe` Payment Sheet on mobile is correct (card data never touches the app). Web has no payment implementation — `payment_service.dart:60` throws `'Web payments not yet implemented'`. The `BuyLivesBottomSheet` and `SubscriptionOptionsSheet` UI run on web and will throw on tap.

**Server-driven vs client-driven:** Mostly server-driven (backend issues `clientSecret`), but **prices are hardcoded on the client**:
- `subscription_options_sheet.dart` line 100: `amount: 3500` for monthly $35.
- `stripe_service_mobile.dart:146`: `amount: billingPeriod == 'annual' ? 9999 : 999` (claims $99.99 / $9.99 — **inconsistent with the UI's $35**).
- `buy_lives_bottom_sheet.dart` lines 122–151: `0.99 / 1.99 / 2.99 / 3.99` hardcoded.
- `payment_selection_screen.dart:53`: `amount: 9900` (yet another price).

These client amounts are passed to `PaymentResult` but the actual charge is whatever the server-issued `clientSecret` says — so the client-side amount is purely cosmetic. Still, **UI and backend prices can diverge silently**.

**Plan IDs hardcoded:** `_getPlanIdForLives` returns `10/11/12/13`; `subscribeToPremium` returns `planId: 3`. Backend coupling is implicit.

**Post-purchase reconciliation:** `payment_service.dart:241` `PaymentConfirmationDialog` polls `/payments/status/{txId}` up to 10 times every 2s after returning from browser. There is **no webhook-driven server reconciliation visible from the client**. If the user closes the app between Stripe success and poll, entitlement may not propagate. `home_screen.loadFromStorage()` re-reads `is_subscriber` only on `initState` — the home screen does not re-fetch after returning from a payment sheet.

**Currency:** Hardcoded `'sgd'` in 5 places. No locale switching for VN/CN later.

### Findings
- `[CRITICAL]` Web payments are unimplemented but the Buy Lives / Subscribe sheets render on web and throw `PaymentException('Web payments not yet implemented')` on tap. **Web users see "Out of Lives" and have no way to recover.** Either hide the buy buttons on web or implement Stripe Checkout redirect.
- `[CRITICAL]` `flutter_stripe` is imported but missing from `pubspec.yaml` — see §2; this alone breaks mobile builds.
- `[HIGH]` `BuyLivesBottomSheet._handlePurchase` calls `PaymentService.startCheckout` which hits `/api/subscription/create-premium`. Buying 5 lives currently routes through the **subscription** endpoint. This is the wrong endpoint and will either fail or accidentally create a subscription for lives plans.
- `[HIGH]` Three different Stripe service files (`payment_service`, `stripe_service_mobile`, `stripe_service_stub`) — duplicated `getPlans`, conflicting URL prefixes (`/api/...` vs `/...`), divergent error handling. `payment_selection_screen.dart` uses a fourth pattern (`deferred as` import on `StripeService`, which doesn't exist on `StripeServiceMobile`). One source of truth needed.
- `[HIGH]` Hardcoded prices in 4 different places, with values that contradict each other ($35 monthly in UI vs $9.99 in `stripe_service_mobile.subscribeToPremium`). Even though the server controls actual charge, displayed UX may not match the receipt.
- `[HIGH]` No re-fetch of subscription state after payment success. After Stripe sheet returns, the user is shown a snackbar and the previous screen — but `is_subscriber` in `SharedPreferences` and home-screen state are not refreshed until `loadFromStorage()` is called again. Premium feature unlocks may lag a session.
- `[MEDIUM]` `verifyPayment` and `checkPaymentStatus` use `${AppConfig.apiBaseUrl}/payments/...` (no `/api/`) — likely 404.
- `[MEDIUM]` `revenue_cat_service.dart` is a 17-line stub of TODOs. Either remove or implement; in the current state it is misleading.

---

## 5. Lives / Hearts UI

**Display widget:** `lib/widgets/lives_header.dart` (224 lines) draws a custom doodle heart via `CustomPaint`. The countdown timer is a local `Timer.periodic(1s)` that decrements `_remainingSeconds` from `widget.nextLifeInSeconds`.

**Backend sync:**
- After every track-question response, `subject_select_screen.dart:131-147` writes `lives`, `max_lives`, `unlimited`, `can_answer` from server JSON back into `SharedPreferences`.
- After every wrong answer, `question_screen.dart:164` `_reduceLivesLocally()` decrements the local `lives` count without telling the server. The server presumably also decrements when the answer is submitted in `submitResults`.
- This means **lives are decremented twice on every wrong answer** (once locally, once on server submit) unless the next server response overwrites the local value — which it does, but only after `_sendResults` runs at end-of-batch.
- Within a batch, the local count diverges from the server count for several minutes.

**Offline behaviour:** None. If the API call fails (`null` returned), `_handleKiasuPathTap` shows "Failed to start". No queue, no retry.

**Optimistic updates:** Yes (decrement before server confirms). No rollback on server failure.

**Time-skew handling:** None. The `Timer.periodic` uses the wall clock; if the user backgrounds the app, suspends, or changes device time, the countdown drifts. `next_life_in_seconds` is a delta, so there is no absolute timestamp to recover from. `_calculateNextLifeTime()` returns hardcoded `1800` seconds when the server doesn't supply one.

### Findings
- `[HIGH]` Local lives are decremented in `_reduceLivesLocally` *before* the server submits, then overwritten by server response at end of batch. If the user kills the app mid-batch, they lose lives that the server may not have charged. Conversely, if the server has a different `max_lives` (e.g., user upgraded), the optimistic local decrement may take the user below the server's true count.
- `[HIGH]` No time-skew protection. Backgrounding the app (especially on iOS) pauses `Timer.periodic` — when the app resumes, the displayed "next life in 23m" may be wildly off from the real refill time. Recommend storing the absolute `next_life_at` ISO timestamp from the server and rendering `nextLifeAt - DateTime.now()`.
- `[HIGH]` `home_screen.dart:97` reads `is_subscriber` and `unlimited` from prefs and gates UI on it. A user who toggles `unlimited=true` in `SharedPreferences` (rooted device, web devtools, etc.) **bypasses the lives system locally**. Server is still authoritative for unlocking premium content, but the client UI is fully bypassable.
- `[MEDIUM]` `QuestionLogicService.reduceLives` (lines 19–36) is a confused function: redeclares `isUnlimited` shadowing the parameter, never updates `nextLifeInSeconds`, and the closing brace nests `loadUserData` inside an unrelated comment. Currently unused but will mislead anyone refactoring lives.
- `[MEDIUM]` `OutOfLivesModal` is shown via `showDialog` but `home_screen._handleKiasuPathTap` calls `Navigator.pop(context)` to close a previously-pushed loading spinner — if the dialog stack is interrupted (back button, system dialog), the "Failed to start Kiasu Path" snackbar fires while the spinner is still on screen.

---

## 6. Kiasu Path Adaptive Learning & Premium Gating

**Logic location:** Server. Client calls `GET /api/kiasu-path/start`, `GET /api/kiasu-path/continue`, and `POST /api/kiasu-path/submit` and renders the questions returned. There is no adaptive difficulty algorithm in `lib/`.

**Premium gating on client:**
- `home_screen.dart:193`: `if (!_isSubscriber) { _showPremiumFeatureDialog() }` — gates the "Start Kiasu Path" tap.
- `_isSubscriber` is loaded from `SharedPreferences` (`prefs.getBool('is_subscriber')`).
- `is_subscriber` is set in `loadFromStorage` from `response['access_type'] == "premium"` — so it tracks the server, but only when `loadFromStorage()` runs (initState + return-from-screen).

**Bypass risk:**
- A user with rooted Android, jailbroken iOS, or browser DevTools can flip `is_subscriber=true` in `SharedPreferences` and **the client will fully unlock the Kiasu Path UI**. The server returns 403 in `question_service.startKiasuPath` (status code → `{'error': 'premium_required', 'code': 403}`), so the **content** is safe — but the user sees the unlock UX and a confusing failure when the request fires.
- More subtly: `home_screen._handleKiasuPathTap` first checks the local `_isSubscriber`, then on server 403 calls `_showPremiumFeatureDialog()` again. So the server enforcement is reached, but the gate is **defense-in-depth on the client only**, with a cosmetically correct error path.

**Diagnostic gating:** Same shape — `can_take_diagnostic` is loaded from server into prefs and re-read locally. `home_screen.dart:112` also calls a local-only `_showDiagnosticRestrictionDialog`. The server returns 403 with `days_remaining`, so the eligibility check is duplicated client+server.

### Findings
- `[HIGH]` Premium UI gating depends on `SharedPreferences.is_subscriber`, which is freely modifiable on the device. If any premium **content** is ever cached client-side (e.g., a Kiasu Path response stored locally), this becomes a content-leak vector. Today it's "only" a cosmetic bypass — the server still returns 403 — but treat the client gate as decoration only and document this in code.
- `[MEDIUM]` Two sources of truth for "is the user premium": `prefs.is_subscriber` (set from `access_type == "premium"`) and `prefs.unlimited` (set from server's `unlimited` flag in track responses). They drift if a user upgrades mid-session. The lives-header gate is on `unlimited`; the Kiasu gate is on `is_subscriber`. If the backend changes one without the other, UI is inconsistent.
- `[MEDIUM]` The eligibility check is requested twice: once via `loadFromStorage` (subscription-status endpoint includes `diagnostic.can_take`) and once via `DiagnosticService.checkDiagnosticEligibility` (`/api/diagnostic/eligibility`). Pick one.

---

## 7. Diagnostic Assessment & Maxile Display

**Client-side correctness check:** **Yes — and the correct answer is sent to the client.**
- `lib/models/diagnostic_question.dart:64-67` reads `correct_answer` (an int 0–3) from the JSON the server returns and stores it as `correctOptionId`.
- `lib/screens/diagnostic/diagnostic_screen.dart:143` computes `isCorrect = currentQuestion.correctOptionId == _selectedOptionId` **on the client** and adds it to the `DiagnosticAnswer` payload.
- `lib/screens/diagnostic/diagnostic_screen.dart:178` sends those answers (including the client-computed `is_correct`) to `/api/diagnostic/submit`.

The same pattern holds for tracks/Kiasu (`lib/services/answer_service.dart:5` `int correctIndex = question['correct_answer']`; `lib/widgets/answer_input_widgets.dart:29`; `lib/widgets/question_feedback_area.dart:219`; `lib/services/question_logic_service.dart:99`).

**Maxile rendering:**
- `home_screen.dart:67` reads `overall_maxile` from `/api/user/subscription-status`.
- `test_result_screen.dart` receives `maxile`, `maxileLevelName` directly from the result-submit response and animates a number tween (`_animatedKudos`, `_animatedMaxile`).
- `diagnostic_result_screen.dart:64` reads `summary['average_maxile']` and animates a gauge from 0 to `targetMaxile/700`.
- `lib/screens/test_result_screen.dart:128-141` `_getMaxileTooltip` has hardcoded thresholds (100/300/500/700) for tooltip text, but the comment on line 126 says "REMOVED `_getMaxileLevelName()` - now from backend" — so the level *name* comes from server but the *tooltip range* is still client-decided.

**Accuracy of client rendering:** Maxile comes from the server. The client doesn't compute it. The level **name** and **score** are server truth; only the tooltip thresholds and the `0..700` gauge ceiling are client constants.

### Findings
- `[CRITICAL]` `correct_answer` is shipped to the client for every question (diagnostic, track, Kiasu). Anyone with browser DevTools or a network proxy can read the answer before submitting. **All three quiz modes are open-book.** This invalidates Kudos, Maxile, leaderboard, and diagnostic placement integrity. Fix: server should return only the question + options; submit endpoint should grade.
- `[HIGH]` Diagnostic submit payload includes a client-computed `is_correct` boolean. If the backend trusts this field, the user can mark every answer correct. If the backend recomputes (correct), the field is dead weight that telegraphs the design intent.
- `[MEDIUM]` Maxile tooltip thresholds are hardcoded on the client. If the server's level system changes (and the level *name* comes from the server), the client tooltip text will drift out of sync.
- `[LOW]` `diagnostic_result_screen.dart` divides by literal `700` for the gauge. If the maxile ceiling changes server-side, the gauge will saturate.

---

## 8. PWA Configuration

**Manifest** (`web/manifest.json`):
- `start_url`: `"."` — should be `"/"` to land on the SPA root after install.
- `display`: `"standalone"` ✅
- `theme_color`/`background_color`: `#960000` ✅
- **Icons reference `icons/ag.png`** — but the `web/icons/` directory contains `Icon-192.png`, `Icon-192.pn.png`, `Icon-512.png`, `Icon-152.png`, `Icon-maskable-152.png`, `Icon-maskable-192.png`, `Icon-maskable-512.png`. **There is no `ag.png`.** All four icon entries point to a missing file.
- One existing file is named `Icon-192.pn.png` — likely a typo of `.png`.

**index.html** (`web/index.html`):
- Loads `flutter_bootstrap.js` (Flutter's auto-init). No explicit service-worker registration.
- Favicon points to `https://mathapi.allgifted.com/media/storage/favicons/favicon.ico` — an external URL. Browsers may not cache it; if backend goes down, favicon breaks. There is also a local `web/favicon.ico` that's unused.
- No `<meta name="apple-mobile-web-app-capable" content="yes">`, no `<link rel="apple-touch-icon">`, no `<meta name="viewport">` with `viewport-fit=cover`. **iOS install ("Add to Home Screen") will produce a default-styled web-clip with no icon and a black status bar.**
- No `noscript` styling, no install-prompt logic, no offline fallback page.

**Service worker:** Flutter's `build web` generates `flutter_service_worker.js` automatically when not disabled, but this repo does not register it manually and has no app-level caching strategy. Question images and audio assets re-download every session.

**`web_backup/index.html`** is a different bootstrap (CanvasKit with `flutter-web-renderer: html` meta). Not referenced — dead.

### Findings
- `[CRITICAL]` PWA manifest icons reference `icons/ag.png` which does not exist; installs from Chrome / Edge / Android will silently fall back to a generic icon.
- `[HIGH]` Zero iOS PWA configuration. No `apple-touch-icon`, no `apple-mobile-web-app-*` meta tags, no viewport meta. Add-to-Home-Screen on iOS will look broken.
- `[HIGH]` No viewport meta — text scaling and safe-area on iPhone notch will be wrong; status bar overlaps content in standalone mode.
- `[MEDIUM]` `start_url: "."` — install behavior depends on the page the user installed from. Use `"/"`.
- `[MEDIUM]` Favicon hosted on backend means a backend outage → broken favicons cached in browser tabs.
- `[MEDIUM]` No app-level offline strategy. Question assets and audio re-fetched every session.
- `[LOW]` `Icon-192.pn.png` typo. `web_backup/` should be deleted.

---

## 9. Performance

**Build modes:** No release-specific config visible. `android/app/build.gradle.kts:34` ships **release builds signed with the debug keystore** (`signingConfig = signingConfigs.getByName("debug")`) — Play Store will reject. `applicationId = "com.example.ag_math"` is the create-template default, also a Play Store blocker.

**`const` constructors:** Flutter-lints catches the easy ones, but I see many places without `const` that could have it (e.g., `_screens` getter in `bottom_nav_screen.dart:18` rebuilds 4 child widgets every `setState`; should be `final` or `const`). `HomeScreen()` and `SubjectSelectScreen()` are called without `const` despite the constructor being `const`-eligible.

**ListView vs ListView.builder:**
- `subject_select_screen.dart:244` and `:272` use `ListView` (not `.builder`) wrapping a `GridView.builder` per field/level — small lists, OK.
- `profile_screen.dart:891` uses `ListView` directly — a single screen-length scroll, OK.
- `question_dialogs.dart:260` uses `ListView.builder` ✅.
- No long lists currently use the wrong constructor, but the leaderboard screen is empty (53 lines) and will need `.builder` when populated.

**Image caching:** Default `Image.network`. No `cached_network_image` package. Question and option images **re-download from `mathapi.allgifted.com/media/...` every render** (back-stack push, hot reload, scroll-off-screen and back). Network-dependent quiz UX.

**Rebuilds & jank candidates:**
- `LivesHeader._startCountdown` calls `setState` every 1s while the timer runs — rebuilds the entire header (which contains `CustomPaint` heart drawings that are cheap, but still).
- `home_screen` calls `loadFromStorage` on every `Navigator.pushNamed(...).then(...)` — the home re-renders the full stats column on every return.
- `question_screen.dart` has 3 `AnimationController`s + `setState` on every keypad input.
- `_screens` getter in `bottom_nav_screen` rebuilds all 4 tabs every time `_currentIndex` changes (no `IndexedStack`).
- 60+ `withOpacity(...)` calls — deprecated in Flutter 3.27 (replace with `.withValues(alpha:)`); the deprecation note also notes the new API avoids unnecessary `Color` allocations.

**Asset bundle:** 12 Lottie JSONs + 3 audio files + several PNGs/SVGs declared. Reasonable size. Confetti animations are randomly chosen on every result screen — fine.

### Findings
- `[CRITICAL]` `android/app/build.gradle.kts:25` `applicationId = "com.example.ag_math"` and `:34` debug-signing in release — both must be fixed before any Play Store upload.
- `[HIGH]` No `cached_network_image`. Question/option/track images redownload on every screen mount over a Singapore mobile network. User-perceived latency.
- `[MEDIUM]` `BottomNavScreen._screens` getter rebuilds all 4 tabs per `setState`; should use `IndexedStack` (or precompute list once) and not lose tab scroll position.
- `[MEDIUM]` 60+ deprecated `withOpacity` calls — will warn when bumping Flutter.
- `[MEDIUM]` `LivesHeader` rebuilds every second — fine on its own, but its parent (`question_screen` / `home_screen`) shouldn't be rebuilt with it. Currently `LivesHeader` is a leaf so OK, but verify when integrating.
- `[LOW]` Missing `const` on widgets like `HomeScreen()`, `SubjectSelectScreen()`, several theme-styled `Text` widgets.

---

## 10. Tests

**Single test file:** `test/widget_test.dart` — 30 lines, the **boilerplate `flutter create` counter test**. It pumps `MyApp()` and expects `'0'`/`'1'` text and an `Icons.add`. The actual `MyApp` has none of these. **The test does not compile against the current codebase**, much less pass.

- Widget tests: 0 (the one file is broken).
- Integration tests: 0. `integration_test` is not in `dev_dependencies`.
- Golden tests: 0.

**Critical untested screens:**

| Surface | Risk if untested |
|---|---|
| Diagnostic flow | Silent client-side correctness checking + server submission |
| Question grading (`AnswerService`, `QuestionLogicService.validateAnswer`) | Off-by-one in `correct_answer` index, FIB number-vs-string parsing edge cases |
| Lives decrement / refill timer | Time-skew, app suspend, optimistic update rollback |
| Stripe payment paths | Three parallel implementations, web stub throws |
| Auth/OTP | 422 vs 200 vs `requires_profile_completion` branches |
| `loadFromStorage` ↔ server response merge | The "save half the response into prefs" pattern is brittle |
| Maxile gauge clamp at 700 | Cap may move server-side |

### Findings
- `[CRITICAL]` There are **no working tests**. The single committed test does not compile. CI cannot block regressions.
- `[HIGH]` No tests for grading, lives, payment, or auth — every one of these has bugs identified in this audit, and there is no harness to catch new ones.
- `[MEDIUM]` `integration_test`, `mocktail`, `golden_toolkit` not in dev deps.

---

## 11. Tech Debt

**TODO/FIXME:** 7 TODOs (4 in `revenue_cat_service.dart`, 2 in `pre_subscriber_home_screen-old.dart`, 1 in `subscription_renew_screen.dart:25`).

**Deprecated APIs in use:**
- `withOpacity(...)` — 60+ occurrences. Replace with `.withValues(alpha:)`.
- `onPopInvoked` (`lib/main.dart:124`) — deprecated 3.22+. Replace with `onPopInvokedWithResult`.
- `MaterialStateProperty` not seen, but `WidgetStateProperty.all` is used in `question_screen.dart:663` — that's the new API ✅.
- `dart:html` (`platform_network_image.dart:6`) — deprecated; use `package:web` (which is also unconditionally imported in `otp_request_screen.dart`, also a problem).
- `prefer_related_applications: false` in manifest is fine.

**Commented-out code blocks:**
- `pubspec.yaml`: 8 dependencies commented out (see §2).
- `lib/screens/question_screen.dart:1-2`: imports `video_player` and `chewie` (which aren't in pubspec).
- `lib/widgets/lives_header.dart` etc.: scattered `// ✅` decoration comments, harmless.
- `pre_subscriber_home_screen-old.dart` (153 lines, dead).
- `main-hello world.dart`, `main-testrive.dart` (orphan main files).
- `web_backup/index.html` (orphan PWA bootstrap).

**Hardcoded strings (i18n readiness for ZH/VI):**
- **No `flutter_localizations`, no `intl_translation`, no ARB files, no `lib/l10n/`.** Every user-facing string is a Dart string literal: "Welcome back", "Test Your Skills", "Browse Topics", "Out of Lives!", "Premium Member", "Maybe Later", "Verify & Continue", "Diagnostic Available Soon", and so on across 22 screens.
- Currency hardcoded to `'sgd'` in 5 places; price strings like `'\$0.99'`, `'\$35'` use literal `$`.
- Date formatting in `diagnostic_result_screen.dart:35` builds a manual `['Jan', 'Feb', ...]` array instead of `DateFormat`. `intl` is in pubspec but barely used.
- Phone validation uses regex; no locale-aware libphonenumber.

**Naming / structural debt:**
- `pre_subscriber_home_screen-old.dart` filename uses kebab + suffix; Dart convention is snake_case, no `-old` markers in version control.
- Service classes are entirely `static` (`AuthService`, `QuestionService`, `UserService`, ...) — untestable, can't mock without code generation. Convert to instance classes + DI.
- `QuestionLogicService.reduceLives` (`question_logic_service.dart:19-36`) has a brace-nesting bug: the closing `}` on line 36 ends `reduceLives` but the opening `///` doc comment on line 36 attaches to nothing. The next method `loadUserData` is technically inside the previous method's closing area. This compiles but is unreadable.
- `analysis_options.yaml` uses default `flutter_lints` only — no project-specific rules (e.g., `prefer_const_constructors`, `avoid_print`, `unawaited_futures`).

### Findings
- `[HIGH]` Zero i18n scaffolding for ZH/VI. Every string is hardcoded English; you'll need a full extraction pass before localization. Set up `flutter_localizations` + ARB now while screen count is manageable (22 screens).
- `[MEDIUM]` 60+ `withOpacity` calls; 1 `onPopInvoked`; 2 `dart:html` imports. Will become deprecation warnings/errors when Flutter is bumped.
- `[MEDIUM]` Orphan files: `main-hello world.dart`, `main-testrive.dart`, `pre_subscriber_home_screen-old.dart`, `web_backup/`, empty `lib/screens/subscriber/`.
- `[MEDIUM]` All-static services prevent mocking; tests cannot intercept HTTP without `http.Client` injection or `mocktail` overrides on top-level functions.
- `[LOW]` `analysis_options.yaml` uses defaults only; no `prefer_const_constructors`, no `avoid_print`, no `unawaited_futures`.
- `[LOW]` `RevenueCatService` and `subscription_renew_screen` are stub TODOs — either implement or delete.

---

## Top 10 Risks — Ranked

| # | Risk | Severity | Where | Why it ranks here |
|---|---|---|---|---|
| 1 | **Repo does not build.** `flutter_stripe`, `video_player`, `chewie`, `google_fonts`, `lottie`, `web` imported but absent from `pubspec.yaml`. Plus unconditional `dart:html` imports break mobile compile. | CRITICAL | `pubspec.yaml` vs `lib/main.dart`, `lib/theme/`, `lib/screens/`, `lib/widgets/platform_network_image.dart` | Nothing else matters until `flutter run` works on mobile and web. |
| 2 | **Quiz answers leak to client.** Every question's `correct_answer` is in the JSON; client computes correctness for diagnostic, tracks, and Kiasu Path; submit payload even includes `is_correct`. | CRITICAL | `lib/models/diagnostic_question.dart:64`, `lib/services/answer_service.dart:5`, `lib/services/question_logic_service.dart:99`, `lib/screens/diagnostic/diagnostic_screen.dart:143` | Invalidates Kudos, Maxile, leaderboard, diagnostic placement integrity. Can be cheated with browser devtools. |
| 3 | **Auth tokens in plaintext SharedPreferences.** No `flutter_secure_storage`. Backups, rooted devices, debug-mode access all leak the bearer token. | CRITICAL | `lib/services/auth_service.dart:172,192`, every service file | Standard mobile-app security finding; trivially fixed but currently unaddressed. |
| 4 | **Web payment is broken UX.** `BuyLivesBottomSheet` and subscription sheet render on web; `payment_service.dart:60` throws "Web payments not yet implemented". Web users hit Out-of-Lives → tap Buy → exception. | CRITICAL | `lib/services/payment/payment_service.dart:60`, `lib/widgets/subscription/buy_lives_bottom_sheet.dart` | Direct revenue blocker for web installs. |
| 5 | **PWA manifest icons missing.** All four manifest entries point to `icons/ag.png` which doesn't exist; no iOS PWA meta tags; no viewport meta. | CRITICAL | `web/manifest.json`, `web/index.html`, `web/icons/` | Installable PWA shows generic icon; iOS Add-to-Home-Screen looks broken. |
| 6 | **Android release config is template-default.** `applicationId = "com.example.ag_math"` and release builds signed with debug keystore. | CRITICAL | `android/app/build.gradle.kts:25,34` | Play Store upload will be rejected. |
| 7 | **Lives state is split-brain.** Optimistic local decrement + server overwrite + no time-skew handling on the refill timer; `unlimited` flag bypassable in `SharedPreferences`. | HIGH | `lib/screens/question_screen.dart:164`, `lib/widgets/lives_header.dart:58`, `lib/screens/home_screen.dart:97` | Users either lose lives the server didn't charge for, or the timer drifts after backgrounding. |
| 8 | **Three Stripe service files with conflicting endpoints and prices.** `BuyLivesBottomSheet` routes lives purchases through the **subscription** endpoint. Hardcoded prices ($35 vs $9.99) contradict each other. | HIGH | `lib/services/payment/{payment_service,stripe_service_mobile,stripe_service_stub}.dart`, `lib/widgets/subscription/{buy_lives_bottom_sheet,subscription_options_sheet}.dart` | Users may be charged the wrong amount or accidentally subscribed when buying lives. |
| 9 | **No working tests, no CI guardrail.** The lone `test/widget_test.dart` is the broken `flutter create` counter test. | HIGH | `test/widget_test.dart` | Every regression in payments, grading, lives, or auth ships unnoticed. |
| 10 | **Zero i18n scaffolding** despite the stated ZH/VI roadmap, plus a fragile route table (`MyApp._hasInitialized`) that breaks web refresh and Stripe redirect-back. | HIGH | All screens; `lib/main.dart:43`; no `lib/l10n/`, no `flutter_localizations` | Both will be many-month migrations if deferred — start the localization pass and migrate to `go_router` now. |

### Quick wins (≤ 1 day each)
- Restore missing `pubspec.yaml` dependencies; `flutter pub get`; fix conditional imports for `dart:html`.
- Add real `web/icons/ag.png` (or update manifest to point at `Icon-192.png`/`Icon-512.png`).
- Fix `lib/services/user_service.dart:133` interpolation bug (`$AppConfig.apibaseURL` → `${AppConfig.apiBaseUrl}`).
- Replace `prefs.clear()` in `profile_screen._logout` with `AuthService.logout()`.
- Set `applicationId` and create a proper release keystore.
- Delete `main-hello world.dart`, `main-testrive.dart`, `pre_subscriber_home_screen-old.dart`, `web_backup/`, `lib/screens/subscriber/`.

### Multi-week initiatives
- Move `correct_answer` grading server-side; remove `correctOptionId` from the model and the answer_service.
- Migrate token storage to `flutter_secure_storage`; introduce a single `ApiClient` with interceptors, retry, and 401 refresh.
- Adopt a state-management library (Riverpod recommended) and migrate the 5-place `is_subscriber`/`lives` reads to a single source of truth.
- Replace direct `Navigator.push` with `go_router`; fix `MyApp._hasInitialized` and add deep-link / payment-redirect support.
- Set up `flutter_localizations` + ARB; extract strings as you touch each screen.
- Stand up a real test suite: widget tests for grading + lives, integration tests for the auth/payment happy paths, golden tests for the result screens.
