diff --git a/.env.example b/.env.example index 960a9fc..15027d2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_ANON_KEY=your-anon-key-here -GEMINI_API_KEY=your-gemini-api-key-here +# GEMINI_API_KEY is intentionally NOT a client variable. It lives only in the +# `gemini-proxy` Supabase Edge Function — set it via +# `supabase secrets set GEMINI_API_KEY=...`. See +# supabase/functions/gemini-proxy/README.md. +HUGGINGFACE_TOKEN=your-huggingface-read-token-here diff --git a/docs/audit/2026-05-14/00_index.md b/docs/audit/2026-05-14/00_index.md new file mode 100644 index 0000000..4b0dba1 --- /dev/null +++ b/docs/audit/2026-05-14/00_index.md @@ -0,0 +1,36 @@ +# Kudlit Full Audit — 2026-05-14 (Index) + +Multi-agent orchestrated audit produced via `docs/kudlit_full_audit_prompt.md`. Seven specialist lanes ran in parallel; this index links to each lane's findings, the synthesis, and the executive summary. + +## Start Here + +- **[EXECUTIVE_SUMMARY.md](./EXECUTIVE_SUMMARY.md)** — one-page verdict, top strengths/risks, readiness call. +- **[99_top10_improvements.md](./99_top10_improvements.md)** — the prioritized ship list, severity-then-effort. + +## Lane Reports + +| # | Lane | File | Headline finding | +|---|---|---|---| +| 01 | UX/UI per-screen, mobile-first | [`01_ux_screens.md`](./01_ux_screens.md) | 29 screens audited (11 P0 / 34 P1 / 27 P2). Welcome card still says "Authentication is UI-only for now." despite working Supabase auth. | +| 02 | Multiplatform parity (Android / iOS / Web) | [`02_multiplatform.md`](./02_multiplatform.md) | Every `sqflite` datasource imports without a `kIsWeb` guard — web crashes on first cache hit. Live YOLO is mobile-only; web is single-frame capture. | +| 03 | Architecture & code quality | [`03_architecture.md`](./03_architecture.md) | 24 sites import `data/` from `presentation/` — clean-architecture inward-only rule systemically broken. Scanner domain has no `Either` and no use cases. | +| 04 | Performance, offline-first, integrations | [`04_performance_offline.md`](./04_performance_offline.md) | `ScanTab` never disposes — PageView keeps all four tabs alive, so YOLO inference runs for the app's lifetime. The recent "pause on result" commit only gated the dispatch, not the native model. | +| 05 | Security & privacy | [`05_security_privacy.md`](./05_security_privacy.md) | `GEMINI_API_KEY` ships in the client bundle; phone OTP has no client-side rate limit; password recovery deep link has no `AuthChangeEvent.passwordRecovery` handler. | +| 06 | Accessibility | [`06_accessibility.md`](./06_accessibility.md) | 3 P0 WCAG AA contrast failures; primary profile/mic buttons have zero semantic labels; zero widgets honor `MediaQuery.disableAnimations`. | +| 07 | Navigation, IA & visual language | [`07_nav_ia_visual.md`](./07_nav_ia_visual.md) | Two 4-tab navs coexist — one live (`FloatingTabNav`), one orphaned (`AppBottomNav` + `HomeTab` + `ProfileTab`). `/admin/stroke-recorder` is reachable by any signed-in user. | + +## How this audit was produced + +The orchestrator ran in three phases per `docs/kudlit_full_audit_prompt.md`: + +1. **Phase A (parallel)** — 3 Explore subagents mapped surface, data/integrations, and design system in one fan-out. +2. **Phase B (parallel)** — 7 lane auditors ran in a single fan-out, each writing its own markdown. +3. **Phase C (synthesis)** — a Plan subagent drafted the Top-10; the lead wrote this index and the executive summary. + +Every finding cites `file_path:line`. No fabricated metrics. Prior audits in `docs/` were reconciled per each lane's Methods footer. + +## What's NOT included + +- No performance numbers (frame rate, bundle size, memory) — would require a profiling run. +- No browser-specific manual QA — findings come from code review and platform-branch tracing. +- No live Supabase RLS policy verification — code-side assumptions are flagged for backend review. diff --git a/docs/audit/2026-05-14/01_ux_screens.md b/docs/audit/2026-05-14/01_ux_screens.md new file mode 100644 index 0000000..37f167d --- /dev/null +++ b/docs/audit/2026-05-14/01_ux_screens.md @@ -0,0 +1,478 @@ +# 01 — UX/UI per-screen, mobile-first +**Auditor:** general-purpose (UX lane) · **Skill invoked:** ui-ux-pro-max (deferred — not loaded in this run, applying heuristics manually) · **Date:** 2026-05-14 + +## Summary +- P0 count: 11 · P1 count: 34 · P2 count: 27 +- Single biggest risk: `auth_welcome_screen.dart` and `sign_up_screen.dart` flagship sign-up flow shows "Authentication is UI-only for now." on the welcome card (auth_welcome_screen.dart:64) — this contradicts a working Supabase auth backend and will undermine user trust at the most fragile moment of the funnel. + +## Findings + +### lib/features/home/presentation/screens/splash_screen.dart — route `/splash` +- **Purpose:** Holding screen that pre-warms the YOLO detector while the router resolves auth/preferences. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Clean fade+scale entry; brand mark fallback if asset missing (splash_screen.dart:139, splash_screen.dart:144). + - Loader has copy ("Starting Kudlit…") instead of a bare spinner (splash_screen.dart:192). +- **Cons:** + - No timeout/escape hatch — if router redirect stalls, the splash never surfaces a retry or error (splash_screen.dart:14). + - Subtitle "Baybayin · Learn · Translate" uses `letterSpacing: 0.8` at 13 px on a dark gradient, contrast borderline (splash_screen.dart:123). + - `_kBackground` gradient ends at 0.85 stop, leaving a flat black band at the bottom that fights the otherwise soft transition (splash_screen.dart:86). +- **Improvements:** + - **P1** — add a "Taking longer than usual…" affordance after ~6 s with a retry/diagnostics button (splash_screen.dart:42). + - **P2** — extend gradient to `stops: [0.0, 1.0]` or pad subtitle contrast (splash_screen.dart:86). +- **Multiplatform notes:** Web skips detector pre-warm (splash_screen.dart:19) — fine, but the spinner copy is identical so users on web won't know the wait is shorter. Consider a web-specific status string. + +### lib/features/home/presentation/screens/model_setup_screen.dart — route `/setup` +- **Purpose:** First-launch download gate for on-device LLM + YOLO models. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Strong friendly error mapper covers offline, cancel, missing-models, generic technical strings (model_setup_screen.dart:52). + - Layout adapts to desktop/landscape/short-portrait variants without breaking thumb reach (model_setup_screen.dart:170-220). + - Primary CTA min-height 52 px, secondary 48 px — both above 44 px target (model_setup_screen.dart:741, model_setup_screen.dart:758). + - Continue button has full Semantics(label/hint) (model_setup_screen.dart:725). +- **Cons:** + - Error banner uses `color: KudlitColors.grey500` on a faint red wash — `danger400.withAlpha(18)` plus grey500 text reads as muted/disabled rather than as an error (model_setup_screen.dart:798, model_setup_screen.dart:805). + - "Not now - stay on internet mode" uses an ASCII hyphen-minus with surrounding spaces — preferable to use en-dash or rewrite for typographic consistency (model_setup_screen.dart:764). + - `_DownloadNotice` icon size 12–13 px and 10–11 px copy is below the 12 px legibility floor at smaller scales (model_setup_screen.dart:683-693). + - Compact-portrait variant ditches `Spacer` rhythm so headline → panel → CTA touch when keyboard absent (model_setup_screen.dart:478). +- **Improvements:** + - **P1** — bump error text contrast to `KudlitColors.danger400` or `cs.onErrorContainer` so the banner reads as an alert (model_setup_screen.dart:805). + - **P2** — increase `_DownloadNotice` text to 11.5–12 px and icon to 14 px (model_setup_screen.dart:683). + - **P2** — replace " - " with " — " or remove dash in CTA copy (model_setup_screen.dart:764). +- **Multiplatform notes:** Web variant rewrites notice to "first setup happens in this browser" (model_setup_screen.dart:691); good. Web wraps content in scroll view, mobile uses fixed Spacer layout (model_setup_screen.dart:206). + +### lib/features/auth/presentation/screens/auth_welcome_screen.dart — route `/welcome` +- **Purpose:** Entry choice between "Create account" and "Sign in". +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Hero/sheet split with drag handle reads as a familiar mobile sheet (auth_welcome_screen.dart:41). + - Clear primary vs. secondary button hierarchy (auth_welcome_screen.dart:50-60). +- **Cons:** + - "Authentication is UI-only for now." caption directly contradicts the working Supabase flow attached to these buttons (auth_welcome_screen.dart:62-66). This is the single biggest credibility risk in the funnel. + - Hero takes 52% of the screen on every device (auth_welcome_screen.dart:35); on short Android phones the actual auth choices end up below the fold. +- **Improvements:** + - **P0** — delete or replace the "UI-only" disclaimer with a real reassurance line (e.g., "We never share your email.") (auth_welcome_screen.dart:62). + - **P1** — drop `heroFraction` to ≤0.42 on heights <700 so both CTAs render above the fold without scrolling (auth_welcome_screen.dart:35). +- **Multiplatform notes:** Web inherits the same sheet metaphor; consider a centered card layout for desktop widths via `AuthScreenShell`. Not screen-local. + +### lib/features/auth/presentation/screens/sign_in_screen.dart — pushed from welcome / login +- **Purpose:** Email+password sign-in with phone alternative and forgot-password link. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Error messages run through a typed `_mapFailure` so users see plain English ("Incorrect email or password.") (sign_in_screen.dart:51). + - Hero bubble personalisation ("Great to see you again!") matches the design polish noted in `docs/auth_polish_updates.md` (sign_in_screen.dart:112). +- **Cons:** + - On success, `Navigator.canPop` loop (sign_in_screen.dart:82) silently pops every page; if the user navigated in from a deep link with extra routes, they lose context with no transition cue. + - No keyboard-aware scroll fallback when the sheet content + keyboard exceed viewport (relies on shell). Form field overflow on small screens not screen-tested here. +- **Improvements:** + - **P1** — show a brief success snackbar/checkmark before popping so the abrupt nav doesn't feel like a crash (sign_in_screen.dart:81). + - **P2** — surface caps-lock indicator inside the password field on web (sign_in_screen.dart:127). +- **Multiplatform notes:** Phone sign-in pathway is mobile-centric but reachable on web; no degradation message if OTP send fails for region. + +### lib/features/auth/presentation/screens/login_screen.dart — route `/login` +- **Purpose:** Alternate login entry with Google + email + guest path. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Landscape branch swaps to side-by-side hero/sheet (login_screen.dart:81). + - Google loading is gated against double-tap (login_screen.dart:47). +- **Cons:** + - Two parallel screens (LoginScreen + AuthWelcomeScreen) coexist with overlapping responsibilities and inconsistent copy ("Continue with Email" vs "Create account") (login_screen.dart:38 vs auth_welcome_screen.dart:51). This creates branching mental models. + - Google failure surfaces as raw SnackBar — no retry CTA, no "use email instead" link (login_screen.dart:54). + - "Continue as Guest" is silently equivalent to skipping account creation; no explanation of what guest mode loses (login_screen.dart:65). +- **Improvements:** + - **P0** — consolidate `LoginScreen` and `AuthWelcomeScreen` to one entry; the duplication is a UX hazard and design-debt cause (login_screen.dart:1, auth_welcome_screen.dart:1). + - **P1** — surface a "What you'll miss" tooltip or microcopy under "Continue as Guest" (login_screen.dart:65). +- **Multiplatform notes:** Same sheet copy on web — Google sign-in popup behaviour differs between platforms; no platform-specific hint shown. + +### lib/features/auth/presentation/screens/sign_up_screen.dart — pushed from welcome +- **Purpose:** Email/password account creation with confirmation-pending state. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Inline validators for email/password/confirm with localized constants (sign_up_screen.dart:46-72). + - Confirmation-sent state branches to a dedicated view (sign_up_screen.dart:116). + - Hero bubble "Let's get you set up!" matches brand voice (sign_up_screen.dart:122). +- **Cons:** + - Password requirement floor is 6 chars (sign_up_screen.dart:61) — under modern recommendation of 8+; copy gives no strength feedback. + - No "show password" toggle exposed at the screen level (lives in form widget — not visible from this screen's code). + - Error banner shown only as a single string with no field-level highlight when Supabase reports field-specific failures. +- **Improvements:** + - **P0** — raise password minimum to 8 and add a basic strength indicator (sign_up_screen.dart:61). + - **P1** — when `_mapFailure` returns "Email already in use," offer a one-tap "Sign in instead" inline action (sign_up_screen.dart:77). +- **Multiplatform notes:** Web autofill behaviour fine because `Form` is standard, but no explicit `autofillHints` configured at this layer. + +### lib/features/auth/presentation/screens/phone_sign_in_screen.dart — pushed from sign-in +- **Purpose:** Phone-number entry feeding into OTP send. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Defaults to +63 PH; country picker via bottom sheet (phone_sign_in_screen.dart:35, phone_sign_in_screen.dart:72). + - `_normalizePhone` strips leading 0 — friendly for PH user habits (phone_sign_in_screen.dart:52). + - "Prefer email?" escape hatch (phone_sign_in_screen.dart:180). +- **Cons:** + - Validation message "Enter a valid phone number." returned on length<7 (phone_sign_in_screen.dart:48) — no example of expected format. + - Error text is small (12 px) and centered, not associated with the field (phone_sign_in_screen.dart:160). +- **Improvements:** + - **P1** — show placeholder/example "9XX XXX XXXX" within `PhoneField` for the selected country, and surface errors as field-level helper text (phone_sign_in_screen.dart:144). +- **Multiplatform notes:** Country picker is a bottom-sheet, which works well on mobile; on web it's still a sheet — okay but a select dropdown would feel more native there. + +### lib/features/auth/presentation/screens/phone_otp_screen.dart — pushed from phone sign-in +- **Purpose:** Six-digit OTP entry with resend. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Live regions for resend and error messages (phone_otp_screen.dart:253, phone_otp_screen.dart:273). + - Auto-advance focus + auto-submit on completion (phone_otp_screen.dart:90-95). + - Masked phone display preserves last 4 digits (phone_otp_screen.dart:196). +- **Cons:** + - `_resendCooldown` is hard-coded to 0 (phone_otp_screen.dart:46) so the cooldown branch never fires — users can spam resend and get rate-limited server-side without UI feedback. + - Boxes are 44×54 (phone_otp_screen.dart:353) — width 44 meets minimum but with `spacing: 6` and 6 boxes, the row needs ≥300 px; on 320-px screens it wraps awkwardly via Wrap (phone_otp_screen.dart:311). + - No autofill / `TextInputType.numberWithOptions(signed:false)` or `autofillHints: [oneTimeCode]` plumbed at this screen (phone_otp_screen.dart:358-363). +- **Improvements:** + - **P0** — implement the resend cooldown timer to back the existing UI branch (phone_otp_screen.dart:46, phone_otp_screen.dart:435). + - **P1** — add `autofillHints: [AutofillHints.oneTimeCode]` so iOS/Android can suggest the SMS code (phone_otp_screen.dart:355). + - **P2** — collapse to a single 6-char hidden field with painted boxes to fix narrow-viewport wrap (phone_otp_screen.dart:311). +- **Multiplatform notes:** OTP autofill is mobile-only — web users must paste; ensure paste-across-boxes works (currently `maxLength: 1` per box blocks paste). + +### lib/features/auth/presentation/screens/forgot_password_screen.dart — pushed from sign-in +- **Purpose:** Send password-reset email. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Success state replaces form with a friendly "Email sent!" headline and a Back-to-login CTA (forgot_password_screen.dart:99, forgot_password_screen.dart:135). +- **Cons:** + - Error messages render in a thin 12 px red caption (forgot_password_screen.dart:121) with no icon, easy to miss. + - No "Resend" affordance from success state, despite reset emails commonly going to spam. +- **Improvements:** + - **P1** — add a "Didn't get it? Send again" link on the success view (forgot_password_screen.dart:135). + - **P2** — wrap error in an icon+text banner consistent with `_SetupErrorBanner` style (forgot_password_screen.dart:120). +- **Multiplatform notes:** No notable platform divergence. + +### lib/features/auth/presentation/screens/reset_password_screen.dart — separate route +- **Purpose:** Stub reset-password form (separate from `ForgotPasswordScreen`). +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Minimal, consistent shell with hero/sheet style (reset_password_screen.dart:48). +- **Cons:** + - **Functionally empty** — `_submit` just flips `_hasSent` to `true` with no provider call (reset_password_screen.dart:29-37). Live users tapping this think a reset has been sent when it has not. + - Overlapping with `ForgotPasswordScreen` — UX has two screens with similar names and different (one fake) behaviours. +- **Improvements:** + - **P0** — either wire `_submit` to `authNotifierProvider.resetPassword` or delete the route in favour of `ForgotPasswordScreen` (reset_password_screen.dart:29). +- **Multiplatform notes:** N/A — screen is dead UI. + +### lib/features/auth/presentation/screens/terms_screen.dart — route `/terms` +- **Purpose:** Static Terms-of-Service content. +- **Platforms reviewed:** Android · iOS · Web +- No notable UX issues (delegated to `LegalDocumentScreen` widget; copy is well-structured and dated). One nit: "Last updated" date is hard-coded to "May 6, 2026" (terms_screen.dart:14) — would benefit from a build-time constant to avoid drift. + +### lib/features/auth/presentation/screens/privacy_policy_screen.dart — route `/privacy` +- **Purpose:** Static Privacy Policy content. +- **Platforms reviewed:** Android · iOS · Web +- No notable UX issues — same `LegalDocumentScreen` pattern, well-scoped content. Same hard-coded date caveat (privacy_policy_screen.dart:14). + +### lib/features/auth/presentation/screens/home_screen.dart — route `/home` shell +- **Purpose:** Tabbed shell for Scan/Translate/Learn/Butty with floating navigation. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - `PageView` swipe disabled (home_screen.dart:127) — prevents accidental tab change in scanner/learn flows. + - Route-driven tab switching honours `?tab=` deep links (home_screen.dart:38-53). + - Floating nav offset clears safe area + 56 (home_screen.dart:68-70). +- **Cons:** + - No tab label/Indicator hint when the bar is collapsed — relies entirely on icons; new users on first launch land on Scan with the camera permission prompt, no orientation toward the tab bar. + - `MediaQuery.removePadding(removeTop: true)` (home_screen.dart:77) discards top padding for the entire body — fine for camera tab, but for translate/learn this clips header spacing. + - `AppHeader` is shown for every tab but only "translate" toggles `showTranslateControls` (home_screen.dart:75); other tabs render a header that may not be needed (e.g., Butty has its own `ButtyHeader` resulting in a double-stack). +- **Improvements:** + - **P1** — remove top header for `butty` and `scan` tabs (or hide it when those tabs are active) to fix the double-header on Butty (home_screen.dart:75; cf butty_chat_screen.dart:122). + - **P1** — add a first-launch coachmark on the floating nav before user reaches camera permission (home_screen.dart:135). +- **Multiplatform notes:** Web touch targets are fine; floating nav `navRight: 18` is small for desktop pointer use — consider centering on desktop. + +### lib/features/home/presentation/screens/home_tab.dart — embedded in HomeScreen (legacy "home" landing) +- **Purpose:** Marketing-style landing tab with welcome banner, tool shortcuts, lesson grid. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Background asset opacity at 12% (home_tab.dart:55) keeps reading surface usable. + - Tools row uses `IntrinsicHeight` so both cards match heights (home_tab.dart:107). +- **Cons:** + - "Coming Soon" tile is a dead tile that occupies grid space with no disclosure (home_tab.dart:161). It looks tappable. + - Lesson grid `childAspectRatio: 0.85` (home_tab.dart:177) yields cramped images on 320 px width. + - "See all" action declared (home_tab.dart:86) but no navigation wired here — easy to miss because section header widget is external. + - This tab is not visible in the `HomeScreen` PageView (only Scan/Translate/Learn/Butty are present home_screen.dart:128) — appears orphaned/dead code. Confirm in routes. +- **Improvements:** + - **P0** — verify whether `home_tab.dart` is actually mounted; if not, delete (home_tab.dart:1). Dead UI competes with the live `LearnTab` for the "home" mental model. + - **P1** — make Coming-Soon tile non-tappable with an explicit "Locked" affordance (home_tab.dart:163). +- **Multiplatform notes:** N/A — likely unreachable. + +### lib/features/home/presentation/screens/scan_tab.dart — `/home?tab=scan` +- **Purpose:** Live YOLO scanner with capture, gallery, flash, retake, permutation cycling, save/share. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Shutter haptic + 200 ms white flash gives strong capture feedback (scan_tab.dart:228, scan_tab.dart:319). + - Status chip has Semantics label and auto-hide after 6 s (scan_tab.dart:97, scan_tab.dart:874). + - Tiny-viewport branch reduces icon and font sizes to keep controls usable on <340 px screens (scan_tab.dart:625). + - Retry CTA in the notice panel calls a haptic-backed snackbar so users feel the action (scan_tab.dart:111). + - Permutation cycler exposes "Reading N of M" with min-44-px chevrons (scan_tab.dart:1460). +- **Cons:** + - First-launch onboarding completely absent — users see a camera with no "Frame a glyph" coachmark. + - `_AggregatedWinnerBanner` (scan_tab.dart:473) renders without a dismiss; if the panel competes with the result panel, both can stack visually. + - Result-panel typography mixes 28 px Baybayin display with 18 px Latin and 12 px tokens — readable but `tokenPreview` joined with " · " (scan_tab.dart:1197) is decorative rather than informative for screen readers. + - "Tell me more" pill is left-aligned at 12 px (scan_tab.dart:1633) — touch target 48 px height is fine but the visual weight competes with Butty bubble. + - Status chip and YOLO model dropdown both pinned to top-right at slightly different `top` offsets (scan_tab.dart:357, scan_tab.dart:389) — risk of overlap on small screens; relies on hand-tuned offsets. + - The "Aggregated winner banner" carries `_settledReading` semantics but no announcement (no Semantics liveRegion) (scan_tab.dart:480-528). +- **Improvements:** + - **P0** — add a first-run onboarding overlay (single tap-to-dismiss card) explaining capture/gallery/flash; the camera tab is the app's hero surface and currently has no orientation (scan_tab.dart:251). + - **P1** — give `_AggregatedWinnerBanner` a Semantics(liveRegion) wrapper so VoiceOver/TalkBack announce settled readings (scan_tab.dart:480). + - **P1** — collapse the YOLO model dropdown into a settings sheet on screens <380 px; presently it overlaps the status chip vertical column (scan_tab.dart:389). + - **P2** — replace " · " token preview with comma-joined string for SR clarity (scan_tab.dart:1197). +- **Multiplatform notes:** Web has its own `WebScannerStatus` chain with `initializing|permissionNeeded|error` states (scan_tab.dart:84). Native hides gallery/flash when frozen but web hides flash always (scan_tab.dart:343). Web "Capture Webcam Frame" label is awkward — consider "Capture frame" everywhere (scan_tab.dart:419). + +### lib/features/home/presentation/screens/translate_screen.dart — `/home?tab=translate` +- **Purpose:** Text translator + sketchpad mode with AI integration; respects offline/online prefs. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - `disabledReason` propagated to children — buttons disable with explanation ("Preparing offline Gemma…") (translate_screen.dart:60-65). + - Adaptive layout collapses header when keyboard or short-landscape (translate_screen.dart:137). + - Copy/Share snackbars handle empty-state ("Nothing to copy yet.") (translate_screen.dart:181). +- **Cons:** + - `disabledReason` is rendered indirectly; not surfaced as a visible banner on this screen — user has to attempt an action to discover why it's disabled (translate_screen.dart:60). + - When keyboard opens, the header disappears entirely (translate_screen.dart:148) — workspace mode toggle becomes inaccessible mid-edit. + - Both copy and share use SnackBar with no undo/feedback consistency (translate_screen.dart:189). +- **Improvements:** + - **P1** — render `disabledReason` as a sticky helper strip at top of the panel so users see "Preparing offline Gemma…" before pressing (translate_screen.dart:60). + - **P1** — keep workspace mode pill visible above the keyboard (e.g., float a 36 px chip), or restore the header on focus-out (translate_screen.dart:148). +- **Multiplatform notes:** `view.viewInsets.bottom / devicePixelRatio` (translate_screen.dart:69) is a manual keyboard inset calc; on iOS web this can return 0 even when on-screen keyboard is up — needs platform check. + +### lib/features/home/presentation/screens/learn_tab.dart — `/home?tab=learn` +- **Purpose:** Wrapper around `LearnHomeBody` that wires up nav to lesson/gallery/quiz/butty. +- **Platforms reviewed:** Android · iOS · Web +- No notable UX issues at this thin shell. `bottomPad` correctly accounts for safe area + floating nav clearance (learn_tab.dart:19-20). See `learn_home_body.dart` for content concerns (not in scope). + +### lib/features/home/presentation/screens/butty_chat_screen.dart — `/home?tab=butty` +- **Purpose:** Conversational chat with Butty (online or offline Gemma). +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Lifecycle-aware memory flush on app pause/inactive (butty_chat_screen.dart:42-51). + - Suggested questions row appears only for the first system message (butty_chat_screen.dart:165). + - Offline-status hint banner reuses `cs.surfaceContainer` (butty_chat_screen.dart:131). +- **Cons:** + - The offline-pending banner sits *below* the message list so users scrolling messages may not notice why input is disabled (butty_chat_screen.dart:130). + - Auto-scroll triggers on every message-count change (butty_chat_screen.dart:113); if the user scrolled up to read past content, new tokens yank them back — common chat anti-pattern. + - `ChatInputBar` `enabled` flag is set but `disabledHint` may not surface as a tooltip on long-press for mobile (butty_chat_screen.dart:172). + - No "stop generating" affordance during `responding` (butty_chat_screen.dart:128). +- **Improvements:** + - **P0** — only auto-scroll if user is already at the bottom (use `_scroll.position.pixels >= maxScrollExtent - 80`) (butty_chat_screen.dart:73). + - **P1** — show a "Stop" button while responding (butty_chat_screen.dart:172). + - **P2** — move offline-pending banner above the message list so it's the first thing seen after the header (butty_chat_screen.dart:130). +- **Multiplatform notes:** Lifecycle observer flush is irrelevant on web; no harm but `paused/inactive` rarely fires there. + +### lib/features/home/presentation/screens/profile_tab.dart — `/home?tab=profile` (or settings path) +- **Purpose:** Guest sign-in prompt OR authenticated user profile shortcuts. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Guest copy "Kumusta, Bisita!" is on-brand and warm (profile_tab.dart:66). + - Profile row + history shortcuts all use 64-px min-height tappable tiles (profile_tab.dart:287, profile_tab.dart:373). + - Mascot scales between compact/regular (profile_tab.dart:42). +- **Cons:** + - No way to *reach* settings other than tapping the small edit icon at the right of the profile row — discovery is poor for an "edit profile" affordance (profile_tab.dart:337). + - Initials avatar fallback uses email[0] (profile_tab.dart:251) — looks broken if the email starts with a digit or symbol. + - Email shown in 11.5 px secondary text (profile_tab.dart:328) — borderline AA at the chosen alpha. +- **Improvements:** + - **P0** — add an explicit "Settings" icon-button at the top of the user profile state, not just the edit chevron (profile_tab.dart:280). + - **P1** — guard initials against non-letter first chars (profile_tab.dart:251). + - **P2** — raise email size to 12.5 px or alpha to 160+ (profile_tab.dart:328). +- **Multiplatform notes:** Avatar `Image.network` has no width/cache-policy hints — repeated rebuilds on web flicker. + +### lib/features/home/presentation/screens/settings_screen.dart — route `/settings` +- **Purpose:** Hosts `SettingsHeader` + `SettingsList` with sign-out. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Sign-out chains into `context.go(routeLogin)` cleanly (settings_screen.dart:33). +- **Cons:** + - Sign-out has no confirmation dialog — accidental tap signs the user out and pops them all the way back to login (settings_screen.dart:33). + - Action snackbars are generic `Text(message)` with no semantic role (settings_screen.dart:48). +- **Improvements:** + - **P0** — add an "Are you sure?" confirmation before sign-out, or at least an undo snackbar with 5-second window (settings_screen.dart:33). +- **Multiplatform notes:** N/A. + +### lib/features/home/presentation/screens/translation_history_screen.dart — route `/translation-history` +- **Purpose:** List saved translations with bookmark toggle. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Empty state has icon + descriptive copy + bounded width 360 (translation_history_screen.dart:112-156). + - Date formatter coverages today/yesterday/<7 days/other (translation_history_screen.dart:200-207). + - Bookmark IconButton enforces min 44×44 (translation_history_screen.dart:259). +- **Cons:** + - No search/filter when list grows; pure chronological list will scale poorly past ~30 items (translation_history_screen.dart:91). + - Error state displays raw exception text (translation_history_screen.dart:180) — leaks technical info. + - No "delete entry" affordance — bookmarking is the only mutation. + - Cards lack a tap target for "view details" — only the bookmark toggle is interactive (translation_history_screen.dart:215). +- **Improvements:** + - **P1** — add an `InkWell` tap to expand into a detail sheet showing full AI response (translation_history_screen.dart:215). + - **P1** — sanitise error text (translation_history_screen.dart:180); show "Couldn't load history. Pull to retry." with a retry button. + - **P2** — add filter chips for "Bookmarked" / "Latin→Baybayin" / "Baybayin→Latin" (translation_history_screen.dart:32). +- **Multiplatform notes:** N/A. + +### lib/features/home/presentation/screens/learning_progress_screen.dart — route `/learning-progress` +- **Purpose:** Ocean-themed progress hub with overall ring, per-lesson tiles, and continue-CTA. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Strong themed hero with bubbles + wave clipper + mascot (learning_progress_screen.dart:151-279). + - Animated ring (TweenAnimationBuilder) and tile press-scale feel premium (learning_progress_screen.dart:423, learning_progress_screen.dart:546). + - Status-aware borders (completed/in-progress/notStarted) read clearly (learning_progress_screen.dart:629). + - Tile message ("Magaling ka!") personalised by progress (learning_progress_screen.dart:119-128). +- **Cons:** + - "Not started" tiles are non-tappable (learning_progress_screen.dart:544) — but visually still look like cards. Users will tap, get nothing, and read it as a bug. + - Hero takes 210 px (learning_progress_screen.dart:133); on short Android (<640 px), the overall ring card is the only above-the-fold content. + - Score-only completion subtitle ("Score: 0 pts") shows for completed lessons even if score is missing (learning_progress_screen.dart:727). + - Lesson IDs and names are hard-coded twice in this file and again in `learn_home_body.dart` — content drift risk; not a UX bug per se but a copy-consistency hazard. +- **Improvements:** + - **P1** — give "Not started" tiles a tappable hint that opens an "Unlock by finishing X first" sheet, or visibly lock with a `pointerEvents: none` + tooltip (learning_progress_screen.dart:544). + - **P2** — let the SliverAppBar collapse to ~110 px so the ring is visible without scroll on small phones (learning_progress_screen.dart:133). +- **Multiplatform notes:** N/A. + +### lib/features/home/presentation/screens/butty_data_screen.dart — route `/butty-data` +- **Purpose:** Chat history + memory facts management. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Two-layer explanation upfront (butty_data_screen.dart:174-222). + - Memory entries support add/edit/delete via dialog with type dropdown (butty_data_screen.dart:839). + - Destructive actions gated with confirmation dialogs (butty_data_screen.dart:323, butty_data_screen.dart:720). + - Markdown rendering for assistant content in the full-message sheet (butty_data_screen.dart:546-595). +- **Cons:** + - "Clear all chat history" and "Clear all memory" buttons are visually identical red text buttons with subtle icon differences — high collision risk (butty_data_screen.dart:298, butty_data_screen.dart:683). + - Memory list shows only fact content + type chip — no "last referenced" timestamp surfaced in the tile (butty_data_screen.dart:799). + - `_FullMessageSheet` uses `SelectableText` for user but `MarkdownBody` for assistant (butty_data_screen.dart:544); inconsistent (user can't get markdown if they pasted code, assistant text can't be plain-copied easily). + - No empty-state for filter when memory has 1 fact and user doesn't realise more come from chat (butty_data_screen.dart:660). +- **Improvements:** + - **P1** — add an icon glyph (chat vs brain) in front of each "Clear" CTA to prevent accidental loss (butty_data_screen.dart:298, butty_data_screen.dart:683). + - **P2** — show last-referenced relative time on each fact tile (butty_data_screen.dart:799). +- **Multiplatform notes:** Web `MarkdownBody` may render code blocks wide; this screen does not constrain max width. + +### lib/features/scanner/presentation/screens/scan_history_screen.dart — route `/scan-history` +- **Purpose:** Show saved scan results. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Empty/error states match `TranslationHistoryScreen` (scan_history_screen.dart:109-198). + - Token chips give a glanceable breakdown of recognised glyphs (scan_history_screen.dart:332-365). +- **Cons:** + - Cards are not tappable — no "rescan" or "share" action from history (scan_history_screen.dart:221). + - Error state shows raw exception (scan_history_screen.dart:184), same leak as `TranslationHistoryScreen`. + - No bookmark/delete affordance — users cannot prune history. +- **Improvements:** + - **P1** — tap card → details sheet with rescan/share/delete (scan_history_screen.dart:221). + - **P1** — sanitise error text (scan_history_screen.dart:184). +- **Multiplatform notes:** N/A. + +### lib/features/learning/presentation/screens/lesson_stage_screen.dart — route `/lesson/:id` +- **Purpose:** Lesson runner with reference/draw/free-input modes and completion overlay. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - `AnimatedSwitcher` between step modes uses fade+slide for context preservation (lesson_stage_screen.dart:185-203). + - Completion overlay supports next-lesson/practice-again/back (lesson_stage_screen.dart:131-141). + - Top bar action label adapts per mode and status (lesson_stage_screen.dart:232-244). +- **Cons:** + - Help sheet is opened via a transparent modal (lesson_stage_screen.dart:48) but no Semantics indicator that one is open. + - Error view ("Back" button) does not retry the lesson load (lesson_stage_screen.dart:294). + - Step label inside progress bar concatenates with em-dash ("Step 1 of 3 — Step label") which can wrap awkwardly on tiny widths (lesson_stage_screen.dart:181). + - No keyboard handling for hardware-keyboard users on free-input mode at this screen level. +- **Improvements:** + - **P1** — add Retry button in `_ErrorView` that re-calls `loadLesson` (lesson_stage_screen.dart:294). + - **P2** — wrap step label in Tooltip + Semantics(label: step.label) and let progress text be just "Step 1 of 3" (lesson_stage_screen.dart:178). +- **Multiplatform notes:** YOLO drawing-pad model is watched on non-web (lesson_stage_screen.dart:107) — web users get free-input + reference only; no explicit message if they hit "draw" mode by accident. + +### lib/features/learning/presentation/screens/character_gallery_screen.dart — route `/character-gallery` +- **Purpose:** Browseable Baybayin glyph library with filters and search. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - ChoiceChip filters + search field combine nicely (character_gallery_screen.dart:198-220). + - `LayoutBuilder` switches to 2-column grid at ≥560 px (character_gallery_screen.dart:262). + - Rich Semantics labels on each glyph cell (character_gallery_screen.dart:323). +- **Cons:** + - Search trigger lives on every `onChanged` (character_gallery_screen.dart:56) which forces a full state rebuild and re-filter; on large libraries this will jank. + - Empty state copy is functional but cold ("No glyphs match that search.") (character_gallery_screen.dart:234) — no "clear search" CTA. + - Filter chips have no "All" feedback colour distinct from selected — selected vs unselected ChoiceChip is the default theme; on dark mode this can be flat. +- **Improvements:** + - **P1** — add a "Clear search" link inside `_EmptyGalleryMessage` (character_gallery_screen.dart:230). + - **P2** — debounce search by 120 ms to avoid rebuild thrash (character_gallery_screen.dart:56). +- **Multiplatform notes:** N/A — pure layout. + +### lib/features/learning/presentation/screens/quiz_screen.dart — route `/quiz` +- **Purpose:** Romanization quiz over previously learned glyphs. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Linear progress + glyph display + answered-section structure is clear (quiz_screen.dart:200-275). + - Result card branches cleanly (quiz_screen.dart:83-90). +- **Cons:** + - Wrong-answer feedback only shows the correct answer once — no "explain" or "see in gallery" link (quiz_screen.dart:347-364). + - No life/retry budget shown; user doesn't know how many wrong answers cost score. + - Empty body "Complete a lesson first to unlock the quiz." is a dead-end; no CTA back to Learn (quiz_screen.dart:121). + - "Check" button gets `Size.fromHeight(44)` (quiz_screen.dart:309) which meets minimum but no visual prominence — hard to find after a long romanization field. +- **Improvements:** + - **P1** — add a "See in gallery" or "Practice again" inline link on wrong-answer banner (quiz_screen.dart:347). + - **P2** — give the empty body a `FilledButton` "Go to lessons" (quiz_screen.dart:121). +- **Multiplatform notes:** N/A. + +### lib/features/admin/presentation/screens/stroke_recording_screen.dart — admin-only route +- **Purpose:** Internal stroke-pattern recording for training data. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - Clear state-machine UI (idle/saving/saved/error) (stroke_recording_screen.dart:42-57). + - Session strip surfaces "what you already saved" so the recorder doesn't lose track (stroke_recording_screen.dart:310). + - Confirmation dialog on clear-all (stroke_recording_screen.dart:666). +- **Cons:** + - Admin-only context but no badge/banner labelling it as "Admin tool — not for users" (stroke_recording_screen.dart:33). Risk if route leaks to production user. + - "Image ✓" affordance uses a checkmark in the button label (stroke_recording_screen.dart:189) — confusing as it doubles as both state and action. + - Slider thumb radius 8 + track height 3 produces a thin track that's hard to grab on mobile (stroke_recording_screen.dart:425). +- **Improvements:** + - **P1** — add a debug/admin chip in the AppBar so it's obvious this isn't user-facing (stroke_recording_screen.dart:33). + - **P2** — bump slider track height to 4–6 px and thumb to 10 (stroke_recording_screen.dart:424-426). +- **Multiplatform notes:** N/A — internal. + +## Top Recommendations (UX-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---| +| 1 | P0 | S | Remove "Authentication is UI-only for now." from welcome card | auth_welcome_screen.dart:62 | +| 2 | P0 | M | Wire `ResetPasswordScreen._submit` to provider or delete the screen | reset_password_screen.dart:29 | +| 3 | P0 | M | Consolidate `LoginScreen` and `AuthWelcomeScreen` into one entry | login_screen.dart:1; auth_welcome_screen.dart:1 | +| 4 | P0 | S | Raise password min to 8 chars + add strength feedback | sign_up_screen.dart:61 | +| 5 | P0 | M | Implement OTP resend cooldown timer | phone_otp_screen.dart:46 | +| 6 | P0 | M | Verify/delete unused `home_tab.dart` ghost screen | home_tab.dart:1 | +| 7 | P0 | M | Add scan-tab first-run coachmark | scan_tab.dart:251 | +| 8 | P0 | S | Stop auto-scroll yanking Butty chat when user scrolled up | butty_chat_screen.dart:73 | +| 9 | P0 | S | Add sign-out confirmation dialog | settings_screen.dart:33 | +| 10 | P0 | S | Add explicit Settings entry to authed Profile tab | profile_tab.dart:280 | +| 11 | P1 | S | Replace raw exception strings in error states | translation_history_screen.dart:180; scan_history_screen.dart:184 | +| 12 | P1 | M | Surface `disabledReason` as a sticky banner on Translate | translate_screen.dart:60 | +| 13 | P1 | S | Add liveRegion + sanitised SR text on aggregated winner | scan_tab.dart:480 | +| 14 | P1 | M | "Not started" lesson tiles need explicit locked affordance | learning_progress_screen.dart:544 | +| 15 | P1 | S | Add `autofillHints: [oneTimeCode]` to OTP boxes | phone_otp_screen.dart:355 | + +## Methods +- Files read: + - lib/features/home/presentation/screens/splash_screen.dart + - lib/features/home/presentation/screens/model_setup_screen.dart + - lib/features/auth/presentation/screens/auth_welcome_screen.dart + - lib/features/auth/presentation/screens/sign_in_screen.dart + - lib/features/auth/presentation/screens/login_screen.dart + - lib/features/auth/presentation/screens/sign_up_screen.dart + - lib/features/auth/presentation/screens/phone_sign_in_screen.dart + - lib/features/auth/presentation/screens/phone_otp_screen.dart + - lib/features/auth/presentation/screens/forgot_password_screen.dart + - lib/features/auth/presentation/screens/reset_password_screen.dart + - lib/features/auth/presentation/screens/terms_screen.dart + - lib/features/auth/presentation/screens/privacy_policy_screen.dart + - lib/features/auth/presentation/screens/home_screen.dart + - lib/features/home/presentation/screens/home_tab.dart + - lib/features/home/presentation/screens/scan_tab.dart + - lib/features/home/presentation/screens/translate_screen.dart + - lib/features/home/presentation/screens/learn_tab.dart + - lib/features/home/presentation/screens/butty_chat_screen.dart + - lib/features/home/presentation/screens/profile_tab.dart + - lib/features/home/presentation/screens/settings_screen.dart + - lib/features/home/presentation/screens/translation_history_screen.dart + - lib/features/home/presentation/screens/learning_progress_screen.dart + - lib/features/home/presentation/screens/butty_data_screen.dart + - lib/features/scanner/presentation/screens/scan_history_screen.dart + - lib/features/learning/presentation/screens/lesson_stage_screen.dart + - lib/features/learning/presentation/screens/character_gallery_screen.dart + - lib/features/learning/presentation/screens/quiz_screen.dart + - lib/features/admin/presentation/screens/stroke_recording_screen.dart + - lib/features/home/presentation/screens/learn_home_body.dart (skim) +- Skills invoked: ui-ux-pro-max (heuristics-only, schema not loaded in this run) +- Prior audits reconciled: docs/design-improvement-evidence-pack.md, docs/translate-page-audit.md, docs/auth_polish_updates.md, docs/kudlit_design_and_setup.md diff --git a/docs/audit/2026-05-14/02_multiplatform.md b/docs/audit/2026-05-14/02_multiplatform.md new file mode 100644 index 0000000..fe98324 --- /dev/null +++ b/docs/audit/2026-05-14/02_multiplatform.md @@ -0,0 +1,137 @@ +# 02 — Multiplatform parity (Android / iOS / Web) +**Auditor:** general-purpose (multiplatform lane) · **Skill invoked:** none · **Date:** 2026-05-14 + +## Summary +- P0 count: 3 · P1 count: 5 · P2 count: 4 +- Single biggest risk: Web build silently loses live YOLO accuracy, torch/flash, gallery picker file-system bytes, and Supabase password-reset deep linking — multiple core flows are degraded on the web build but presented to the user with the same UI affordances as mobile. + +## Parity Matrix +| Feature | Android | iOS | Web | Notes | +| --- | --- | --- | --- | --- | +| Camera live preview | ✅ Native `YOLOView` via `ultralytics_yolo` (`lib/features/scanner/presentation/widgets/scanner_camera.dart:358`) | ✅ Same `YOLOView` (`scanner_camera.dart:358`) | ⚠ `_WebCameraPreview` uses `camera` plugin (`scanner_camera.dart:320`) — different code path, no live YOLO overlay; user has to capture a frame. | +| Torch / flash | ✅ `detector.toggleTorch` → `_controller.setTorchMode` (`yolo_baybayin_detector.dart:149`) | ✅ Same | ❌ Flash toggle hidden on web (`scan_tab.dart:300`, `:343`) — no API path; getUserMedia torch unsupported. | +| Switch camera | ✅ `YOLOViewController.switchCamera` (`yolo_baybayin_detector.dart:153`) | ✅ Same | ⚠ Routed through `_webSwitchCamera` callback (`scan_tab.dart:192-198`); falls back to "Only one camera available" if browser exposes <2 devices (`scan_tab.dart:424`). | +| Local Gemma inference | ✅ `flutter_gemma` via `LocalGemmaDatasource` | ✅ Same | ❌ Image analysis explicitly throws on web (`local_gemma_datasource.dart:210`); text generate routed cloud-only (`ai_inference_repository_impl.dart:141`). | +| Cloud Gemma inference | ✅ `cloudDatasource.generate` (`ai_inference_repository_impl.dart:81-89`) | ✅ Same | ✅ Same — `kIsWeb` forces this branch (`ai_inference_repository_impl.dart:141`). | +| YOLO inference — live | ✅ `YOLOView` GPU off (`yolo_baybayin_detector.dart:120`) | ✅ Same | ⚠ `web_tflite_baybayin_detector.dart` via JS interop conditional import (`web_baybayin_detector_factory.dart:1-2`) — only runs on captured frame, not live stream. | +| YOLO inference — still image | ✅ `yolo.predict(...)` (`yolo_baybayin_detector.dart:68`) | ✅ Same | ⚠ Web TFLite path (`web_tflite_baybayin_detector.dart`, parser at `web_yolo_output_parser.dart`). | +| Vision model fetch (preflight) | ✅ `YoloModelCache.download` to filesystem (`vision_download_tile.dart:36-47`) | ✅ Same | ⚠ `createWebVisionModelPreflight().run(...)` (browser caches/IndexedDB) (`vision_download_tile.dart:33-34`, `web_vision_model_preflight_web.dart`). | +| Google OAuth deep link | ✅ `redirectTo: 'kudlit://auth/reset'` (`supabase_auth_datasource.dart:85-87`) | ✅ Same | ⚠ `${Uri.base.origin}/auth/reset` — origin-relative; depends on Site URL config and a `/auth/reset` route handler. | +| Phone OTP | ✅ `signInWithOtp(phone:)` (`supabase_auth_datasource.dart:106`) | ✅ Same | ✅ Same code path, no `kIsWeb` guard. | +| Password reset deep link | ✅ `kudlit://auth/reset` (`supabase_auth_datasource.dart:179`) | ✅ Same | ❌ `redirectTo: null` falls back to Supabase Site URL (`supabase_auth_datasource.dart:179`) — no app-side deep-link handler; user is redirected to whatever Site URL is configured. | +| Gallery picker | ✅ `image_picker` (`scan_tab_controller.dart:151-158`) | ✅ Same | ⚠ `image_picker` web returns blob; `readAsBytes()` works but no permission UX, no folder picker. | +| SQLite cache | ✅ `sqflite` (`sqlite_chat_datasource.dart`, `sqlite_lesson_progress_datasource.dart`, `sqlite_scan_history_datasource.dart`, `sqlite_chat_memory_datasource.dart`, `sqlite_translation_history_datasource.dart`, `local_profile_management_datasource.dart`) | ✅ Same | ❌ `sqflite` does not support web — any code path importing these datasources will fail at runtime on web; no `kIsWeb` guards observed around datasource construction in feature DI. | +| Hive | n/a | n/a | n/a | Not used; codebase has migrated to `sqflite`. | +| File / blob saving (YOLO model) | ✅ `path_provider` → app docs (`yolo_model_cache.dart:5`) | ✅ Same | ⚠ Web branch returns model URL directly via `web_vision_model_url_resolver.dart`; no filesystem write. | +| Share | ✅ `share_plus` `SharePlus.instance.share(...)` (`scan_tab.dart:1231`, `output_actions.dart:48`, `export_sheet.dart:87`, `stroke_export_service.dart:32`) | ✅ Same | ✅ `share_plus` falls back to Web Share API where supported; otherwise no-ops. | + +## kIsWeb Branches +| file:line | What native does | What web does | +| --- | --- | --- | +| `lib/main.dart` (no guard) | `initializeFlutterGemma(huggingFaceToken: hfToken)` runs unconditionally at line `lib/main.dart:18`. | Same call — but `flutter_gemma` on web has no vision and limited text inference. No `kIsWeb` skip. | +| `lib/features/home/presentation/screens/splash_screen.dart:19` | Pre-warms `baybayinDetectorProvider` on mobile. | Skips warm-up — web detector is created lazily by scanner screen. | +| `lib/features/home/presentation/providers/translate_sketchpad_controller.dart:100` | Runs local-first Gemma image analysis when `mode == local`. | Forces cloud analysis (local not supported on web). | +| `lib/features/home/presentation/providers/model_setup_controller.dart:105` | Reads `availableYoloModelsProvider` + downloads via `YoloModelCache`. | Refreshes `visionModelSetupStatusProvider` (web preflight via blob cache). | +| `lib/features/home/presentation/screens/model_setup_screen.dart:188 / :196 / :203 / :206` | Standard portrait/landscape/desktop layouts; no extra scroll wrapper. | Centers content and wraps in `SingleChildScrollView` to ensure scrollability in browser viewports. | +| `lib/features/home/presentation/screens/model_setup_screen.dart:639,:689` | Setup copy emphasises one-time download and Wi-Fi recommendation. | Setup copy emphasises "browser" and warns first setup may take a while. | +| `lib/features/home/presentation/screens/scan_tab.dart:85-89` | n/a | Determines whether to center the status chip on web. | +| `scan_tab.dart:192-198` | `controller.switchCamera()` (native). | Calls `_webSwitchCamera` callback registered by `_WebCameraPreview`. | +| `scan_tab.dart:206` | Try-again handler retries native scan notice. | Skips: try-again handler returns `null` until a web capture callback is registered. | +| `scan_tab.dart:280-289` | Status chip uses "Camera ready" + camera icon. | Status chip uses `_webStatus.label` + dynamic icon (initializing / permission-needed / error / ready). | +| `scan_tab.dart:298` | Pauses live YOLO inference when result panel is open. | Does not pause — `scannerPaused: scanState.resultVisible && !kIsWeb` keeps web preview running because web is non-live. | +| `scan_tab.dart:300,:343` | Flash toggle wired up. | Flash toggle disabled — `onFlashToggle: kIsWeb ? null : ...`. | +| `scan_tab.dart:411-420` | Shutter button captures native camera frame. | Shutter button calls `controller.captureWebFrame(_webCapture!)` and label changes to "Capture Webcam Frame". | +| `scan_tab.dart:424-426` | "Switch camera" tooltip always available. | "Only one camera available" tooltip when `_webSwitchCamera == null`. | +| `lib/features/home/presentation/widgets/settings/vision_download_tile.dart:33-48` | Calls `YoloModelCache.download(...)`. | Calls `createWebVisionModelPreflight().run(model.modelLink)`. | +| `vision_download_tile.dart:55-61` | Pre-fetches active YOLO path after download. | Skips — no filesystem path. | +| `vision_download_tile.dart:65-67` | Surfaces raw error text. | Routes error through `friendlyVisionModelError`. | +| `vision_download_tile.dart:126,:166,:319` | UI strings reference "download". | UI strings reference "browser" / "set up once in this browser". | +| `lib/features/auth/data/datasources/supabase_auth_datasource.dart:85-87` | `redirectTo: 'kudlit://auth/reset'`. | `redirectTo: '${Uri.base.origin}/auth/reset'`. | +| `supabase_auth_datasource.dart:179` | `redirectTo: 'kudlit://auth/reset'`. | `redirectTo: null` (falls back to Supabase Site URL). | +| `lib/features/learning/presentation/screens/lesson_stage_screen.dart:107` | Warms `yoloDrawingPadModelProvider`. | Skips warm. | +| `lib/features/learning/presentation/widgets/modes/draw_mode_body.dart:100` | Runs draw-mode local YOLO. | Skipped. | +| `lib/features/translator/data/datasources/local_gemma_datasource.dart:210` | Local Gemma vision path. | Throws `UnsupportedError('Image analysis is not supported by flutter_gemma on web yet.')`. | +| `lib/features/translator/data/repositories/ai_inference_repository_impl.dart:141` | Local-first with cloud fallback. | Forces cloud for any image analysis (`kIsWeb || _useCloud`). | +| `lib/features/scanner/presentation/providers/scan_tab_controller.dart:347` | Calls `detector.resumeInference()` after dismissing result. | Skipped (no live inference loop to resume). | +| `lib/features/scanner/presentation/providers/yolo_model_selection_provider.dart:213,:268,:269,:321,:397,:427` | Returns native filesystem paths and downloads via cache; reads dart:io. | Returns model URL directly; throws `UnsupportedError('YOLO is not available on web.')` for path-only APIs. | +| `lib/features/scanner/presentation/providers/scanner_provider.dart:19-26` | Constructs `YoloBaybayinDetector`. | Constructs `createWebBaybayinDetector(...)` via conditional import factory. | +| `lib/features/scanner/presentation/widgets/scanner_camera.dart:320` | Falls through to `YOLOView`. | Returns `_WebCameraPreview` (uses `camera` plugin). | + +## Web-Specific Files +| Path | Role | +| --- | --- | +| `lib/features/scanner/data/datasources/web_baybayin_detector_factory.dart` | Factory entry-point — conditional import dispatches between stub and real web TFLite implementation. | +| `lib/features/scanner/data/datasources/web_baybayin_detector_stub.dart` | Stub used when `dart.library.js_interop` is unavailable (mobile builds). | +| `lib/features/scanner/data/datasources/web_tflite_baybayin_detector.dart` | Real web detector implementing `BaybayinDetector` via TFLite JS runtime. | +| `lib/features/scanner/data/datasources/web_tflite_model_runtime.dart` | JS interop wrapper around the TFLite.js runtime. | +| `lib/features/scanner/data/datasources/web_yolo_output_parser.dart` | Parses raw YOLO web output tensors into `BaybayinDetection`. | +| `lib/features/scanner/data/datasources/web_vision_model_preflight.dart` | Cross-platform entry to web vision preflight (selects stub or web). | +| `lib/features/scanner/data/datasources/web_vision_model_preflight_stub.dart` | Stub preflight for native builds. | +| `lib/features/scanner/data/datasources/web_vision_model_preflight_web.dart` | Real web preflight: downloads/caches model in browser. | +| `lib/features/scanner/data/datasources/web_vision_model_url_resolver.dart` | Resolves the active web vision model URL. | +| `lib/features/translator/data/datasources/web_gemma_model_preflight.dart` | Entry point for web Gemma preflight. | +| `lib/features/translator/data/datasources/web_gemma_model_preflight_stub.dart` | Stub for native builds. | +| `lib/features/translator/data/datasources/web_gemma_model_preflight_web.dart` | Real web Gemma preflight implementation. | + +## Findings + +### Camera & vision pipeline +- **Live inference parity broken on web.** Native uses `YOLOView` to push detections in real time (`yolo_baybayin_detector.dart:44-60`, `scanner_camera.dart:358-368`). Web's `_WebCameraPreview` does not run continuous inference — the user must press a "Capture Webcam Frame" shutter (`scan_tab.dart:411-420`). The UI does not visually communicate this difference beyond the button label. +- **Result-panel pause asymmetry.** Native pauses inference while result panel is open (`scan_tab.dart:298`, `scan_tab_controller.dart:347-349`). Web skips both, which is correct (no loop) but the comment trail makes it look intentional — no inline note explaining the asymmetry. +- **Frame capture path divergent.** Native shutter uses `RenderRepaintBoundary.toImage(pixelRatio: 1.5)` (`scan_tab.dart:234-243`) to snapshot the live preview. Web shutter relies on the `WebScannerCapture` callback wired by the `camera` plugin's preview. Two distinct capture pipelines. +- **Pre-warming asymmetry.** Splash pre-warms `baybayinDetectorProvider` only on mobile (`splash_screen.dart:19-21`); the web first-detect is therefore slower because model fetching happens at the moment of capture rather than at startup. +- **TFLite model resolver tightly coupled to platform.** `_resolveWebVisionModelUrl` (`scanner_provider.dart:31-47`) only resolves the camera scope; the drawing-pad scope is never resolved for web. Combined with `draw_mode_body.dart:100`'s `!kIsWeb` guard, draw-mode practice silently has no detector on web. + +### Auth deep links +- **Password reset on web is broken if Site URL is not configured.** `resetPassword(...)` sets `redirectTo: null` for web (`supabase_auth_datasource.dart:179`). Without an explicit redirect, Supabase uses the dashboard Site URL. If the deployment's `/auth/reset` route is not registered there, users will be redirected to a 404 or the marketing page. There is no in-app fallback (e.g. instructions copy). +- **Google OAuth web origin must include `/auth/reset` route.** `signInWithGoogle()` returns to `${Uri.base.origin}/auth/reset` on web (`supabase_auth_datasource.dart:85-87`). For shareable preview/staging builds with non-canonical origins, this redirect needs the origin to be allow-listed in Supabase Auth → URL Configuration. +- **Phone OTP has no platform guard.** `sendPhoneOtp` and `verifyPhoneOtp` (`supabase_auth_datasource.dart:104-136`) run identically on all platforms. Web reCAPTCHA invocation is not handled in code; it relies on Supabase JS shim through `supabase_flutter`. Worth verifying on web build. + +### Responsive / SafeArea / keyboard +- **SafeArea coverage.** 59 hits for `SafeArea` / `MediaQuery.paddingOf` / `resizeToAvoidBottomInset` across `lib/`. The scanner overlays correctly wrap controls in `SafeArea(bottom: false, ...)` (`scan_tab.dart:332-393`) and account for `safeBottom` (`scan_tab.dart:260`). +- **Notch/landscape tuning.** `scan_tab.dart:261-269` derives `compactLandscape`, `tinyViewport`, `tinyLandscapeNotice` from `LayoutBuilder` — solid. Model setup screen has four explicit layouts (desktop, landscape, short portrait, portrait) at `model_setup_screen.dart:168-204` — good coverage. +- **Web scrolling fix.** `model_setup_screen.dart:206-213` wraps the layout in `SingleChildScrollView` only when `kIsWeb`. Native short-portrait also has a `SingleChildScrollView` (`:478`). The portrait layout uses `Spacer` + `Center` and may overflow on small browser windows without scroll — but this is hedged by the `kIsWeb` scroll wrapper. +- **Keyboard avoidance.** Auth shells set `resizeToAvoidBottomInset: true` (`auth_screen_shell.dart:31,:50`); auth_form_scaffold uses `Scaffold` defaults. No keyboard handling issues found in scanner/translate flows because those screens are camera-led. + +### Platform idiom mismatches +- **Material 3 is the single design language.** No Cupertino / `CupertinoApp` switching detected. iOS users see Material chips, sheets, and tooltips throughout (e.g. `scan_tab.dart:762-790` Material tooltips). On iOS this is a minor HIG mismatch — share sheet and toasts behave Material, not iOS native. +- **Tooltips on touch-only platforms.** Many controls wrap in `Tooltip(...)` (e.g. `scan_tab.dart:762`, `scan_tab.dart:1454`). On Android/iOS these only show on long-press; on web they show on hover — fine, but the Semantics labels are still correct. +- **No keyboard navigation polish.** No explicit `Shortcuts` / `Actions` / focus traversal customization. On web, tab-key flow follows widget tree order, which may not match visual order for stack-positioned scanner overlays. Mainly affects the scan_tab where multiple `Positioned` children share the same `Stack`. +- **`flutter_gemma` initialised unconditionally on web.** `main.dart:18` calls `initializeFlutterGemma` even when `kIsWeb` and the package's web support is limited. If the bootstrap does network/IO that fails on web, the entire app may stall at boot. +- **`sqflite` is web-incompatible.** All SQLite datasources import `package:sqflite/sqflite.dart` and have no `kIsWeb` guards (`sqlite_chat_datasource.dart`, `sqlite_lesson_progress_datasource.dart`, `sqlite_scan_history_datasource.dart`, `sqlite_chat_memory_datasource.dart`, `sqlite_translation_history_datasource.dart`, `local_profile_management_datasource.dart`). If their providers are ever read on web (chat history, scan history, lesson progress, profile cache), runtime will throw `MissingPluginException`. Need `sqflite_common_ffi_web` or web-stub datasource. + +## Top Recommendations (multiplatform-local, severity-ordered) +| # | Severity | Effort | Recommendation | Evidence | +| --- | --- | --- | --- | --- | +| 1 | P0 | M | Add web-stub or `sqflite_common_ffi_web` adapters for every `sqflite` datasource so chat history, scan history, lesson progress, and profile cache do not crash on web. Currently no `kIsWeb` guard around construction. | `lib/features/translator/data/datasources/sqlite_*.dart`, `lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart`, `lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart`, `lib/features/home/data/datasources/local_profile_management_datasource.dart` | +| 2 | P0 | S | Provide an explicit `redirectTo` for password reset on web (e.g. `'${Uri.base.origin}/auth/reset'`) instead of `null`, mirroring the OAuth web path. Add an in-app `/auth/reset` route handler. | `lib/features/auth/data/datasources/supabase_auth_datasource.dart:179`, `:85-87` | +| 3 | P0 | M | Guard `initializeFlutterGemma(...)` behind `!kIsWeb` (or a web-safe init) in `lib/main.dart:18` so the app boots on web even when the package's web side has limited support. | `lib/main.dart:18` | +| 4 | P1 | S | Surface to users that the web scanner runs single-frame capture, not live YOLO, with a clearer status chip copy and a one-time onboarding hint. Today the difference is implicit in the button label. | `scan_tab.dart:280-289`, `:411-420`, `_WebCameraPreview` in `scanner_camera.dart:320` | +| 5 | P1 | S | Disable the flash button as "Unavailable in browser" on web rather than rendering it hidden (`onFlashToggle: null`) — gives users a discoverable explanation. | `scan_tab.dart:300`, `:343` | +| 6 | P1 | M | Pre-warm the web vision detector on `SplashScreen` (currently mobile-only at `splash_screen.dart:19-21`) so the first capture on web is not slowed by lazy model fetch. | `splash_screen.dart:19-21`, `scanner_provider.dart:19-26` | +| 7 | P1 | S | Resolve the draw-mode YOLO model for web in `draw_mode_body.dart:100` and `lesson_stage_screen.dart:107` (currently `!kIsWeb` only), or provide an explicit "Draw practice requires the mobile app" notice. | `draw_mode_body.dart:100`, `lesson_stage_screen.dart:107` | +| 8 | P1 | S | Add a code comment near `scan_tab.dart:298` explaining why `scannerPaused` is forced false on web (no live loop) — easy to misread as a bug today. | `scan_tab.dart:298` | +| 9 | P2 | S | Audit Supabase Auth URL allow-list for staging/preview origins, since `Uri.base.origin` will vary per deploy. | `supabase_auth_datasource.dart:85-87` | +| 10 | P2 | M | Consider switching to `CupertinoTheme`-aware widgets (`SharePlus` is fine; share sheet, dialogs, action sheets) for iOS to meet HIG expectations, or document the Material-everywhere choice. | App-wide; `scan_tab.dart` Dialog/SnackBar usage | +| 11 | P2 | M | Add focus-traversal order to scanner stack overlays (`scan_tab.dart` `_ScanUtilityBar`, `_ScanControls`, status chip) so keyboard-only web users tab in visual order. | `scan_tab.dart:329-430` | +| 12 | P2 | S | Document phone OTP web behaviour (reCAPTCHA, allowed origins) — code path is shared with mobile (`supabase_auth_datasource.dart:104-136`) but web has additional Supabase requirements. | `supabase_auth_datasource.dart:104-136` | + +## Methods +- Files read: + - `lib/main.dart` + - `lib/features/scanner/presentation/providers/scanner_provider.dart` + - `lib/features/translator/data/repositories/ai_inference_repository_impl.dart` + - `lib/features/scanner/data/datasources/yolo_baybayin_detector.dart` + - `lib/features/scanner/data/datasources/web_baybayin_detector_factory.dart` + - `lib/features/home/presentation/screens/scan_tab.dart` + - `lib/features/home/presentation/screens/model_setup_screen.dart` + - `lib/features/auth/data/datasources/supabase_auth_datasource.dart` + - `lib/features/home/presentation/screens/splash_screen.dart` + - `lib/features/home/presentation/widgets/settings/vision_download_tile.dart` + - `lib/features/home/presentation/providers/model_setup_controller.dart` + - `lib/features/home/presentation/providers/translate_sketchpad_controller.dart` + - `lib/features/scanner/presentation/providers/scan_tab_controller.dart` + - `lib/features/scanner/presentation/widgets/scanner_camera.dart` (excerpt) + - `lib/features/translator/data/datasources/local_gemma_datasource.dart` (excerpt) +- Greps run: `kIsWeb` in `lib/`, `web_*.dart`/`*_web.dart`/`*_native.dart` file glob, `image_picker`, `sqflite|Hive|path_provider`, `share_plus`, `SafeArea|MediaQuery.paddingOf|resizeToAvoidBottomInset`. +- Prior audits reconciled: `docs/scanner_vision_model_audit.md`, `docs/gemma_offline_model_loading_audit.md`, `docs/supabase_phone_google_auth_plan.md` (referenced for prior context; not re-read in this pass). diff --git a/docs/audit/2026-05-14/03_architecture.md b/docs/audit/2026-05-14/03_architecture.md new file mode 100644 index 0000000..9104701 --- /dev/null +++ b/docs/audit/2026-05-14/03_architecture.md @@ -0,0 +1,155 @@ +# 03 — Architecture & code quality +**Auditor:** general-purpose (architecture lane) · **Skills invoked:** review (deferred — schema not loaded, applying review heuristics manually); simplify (heuristics-only) · **Date:** 2026-05-14 + +## Summary +- P0 count: 2 · P1 count: 3 · P2 count: 3 +- Single biggest risk: Presentation layer routinely reaches into `data/` (datasources + repository impls), so the Clean-Architecture inward-dependency rule from CLAUDE.md is broken in roughly two dozen call sites across scanner/translator/learning/home/admin/auth — feature swaps and unit tests can no longer rely on the domain boundary. + +## Findings + +### Clean Architecture boundary violations + +CLAUDE.md rule: *“Dependencies flow inward only: presentation → domain ← data. The domain layer has zero Flutter dependencies.”* + +1. **Domain → Flutter (rule #1):** CLEAN. `grep -rn "package:flutter" lib/features/*/domain/` returns **zero** hits. `package:flutter_riverpod` likewise absent from every `domain/` tree. Pure-Dart contract honoured across auth, home, learning, translator, scanner, admin. + +2. **Presentation → data (rule #2):** **VIOLATED in 24 import sites.** Presentation providers/widgets import concrete datasources and repository implementations directly instead of resolving them through a domain interface + DI provider. Hits: + + - `lib/features/admin/presentation/providers/stroke_pattern_providers.dart:4-5` — imports `supabase_stroke_pattern_datasource.dart` + `stroke_pattern_repository_impl.dart`. + - `lib/features/admin/presentation/providers/stroke_recording_notifier.dart:8` — imports `supabase_stroke_pattern_datasource.dart`. + - `lib/features/admin/presentation/screens/stroke_recording_screen.dart:8` — imports `stroke_export_service.dart`. + - `lib/features/auth/presentation/providers/auth_provider.dart:7-8` — imports `supabase_auth_datasource.dart` + `auth_repository_impl.dart`. + - `lib/features/home/presentation/providers/translation_history_provider.dart:7` — imports `sqlite_translation_history_datasource.dart`. + - `lib/features/home/presentation/providers/profile_management_provider.dart:10-12` — imports two profile datasources + repository impl. + - `lib/features/home/presentation/providers/translate_page_controller.dart:4` — imports `local_gemma_datasource.dart`. + - `lib/features/home/presentation/providers/model_setup_controller.dart:8` — imports `local_gemma_datasource.dart`. + - `lib/features/home/presentation/widgets/settings/vision_download_tile.dart:7` — imports `web_vision_model_preflight.dart`. + - `lib/features/home/presentation/widgets/settings/llm_download_tile.dart:5` — imports `local_gemma_datasource.dart`. + - `lib/features/home/presentation/widgets/butty_chat/butty_model_mode_selector.dart:5` — imports `local_gemma_datasource.dart`. + - `lib/features/learning/presentation/providers/lesson_repository_provider.dart:8-10` — imports `asset_lesson_data_source.dart`, `supabase_lesson_datasource.dart`, `lesson_repository_impl.dart`. + - `lib/features/learning/presentation/providers/lesson_progress_provider.dart:9` — imports `sqlite_lesson_progress_datasource.dart`. + - `lib/features/scanner/presentation/providers/scan_history_provider.dart:9` — imports `sqlite_scan_history_datasource.dart`. + - `lib/features/scanner/presentation/providers/scanner_provider.dart:6-8` — imports `device_inference_capability_checker.dart`, `web_baybayin_detector_factory.dart`, `yolo_baybayin_detector.dart`. + - `lib/features/scanner/presentation/providers/yolo_model_selection_provider.dart:10-12` — imports `web_vision_model_preflight.dart`, `yolo_model_cache.dart`, **cross-feature** `translator/data/datasources/supabase_ai_models_datasource.dart`. + - `lib/features/scanner/presentation/widgets/scanner_camera.dart:12` — imports `yolo_baybayin_detector.dart`. + - `lib/features/translator/presentation/providers/translator_providers.dart:10-17` — imports seven datasources + `ai_inference_repository_impl.dart`. + - `lib/features/translator/presentation/providers/chat_history_provider.dart:9-10` — imports `sqlite_chat_datasource.dart` + `supabase_chat_datasource.dart`. + - `lib/features/translator/presentation/providers/chat_memory_provider.dart:4-6` — imports two chat-memory datasources + `chat_memory_repository_impl.dart`. + + **Severity P0.** This is a systemic clean-architecture break. The provider files do at least keep the datasource wiring in *one place per feature* (so they function as an ad-hoc composition root), but the rule in CLAUDE.md is unambiguous; the wiring belongs in a `core/di/` root or in `data/`-owned provider files that expose only domain interfaces to presentation. + +### Riverpod convention compliance + +CLAUDE.md rule: *“Use @riverpod code generation (riverpod_annotation) for all providers.”* + +- **42 files** in `lib/features/` use `@riverpod` (`.g.dart` partners present and current). Examples: `auth_notifier.dart`, `profile_management_provider.dart`, `scanner_provider.dart`, `translator_providers.dart`, `chat_history_provider.dart`, `ai_inference_provider.dart`. +- **15 provider files** mix in raw `NotifierProvider<>`, `AsyncNotifierProvider<>`, `FutureProvider<>`, or `Provider<>` — convention violation: + - `lib/features/home/presentation/providers/translate_page_controller.dart:47-49` (`NotifierProvider`) and `:62-63` (`FutureProvider`). + - `lib/features/home/presentation/providers/translate_text_controller.dart:82-84` (`NotifierProvider`). + - `lib/features/home/presentation/providers/translate_sketchpad_controller.dart:46-48` (`NotifierProvider`). + - `lib/features/home/presentation/providers/model_setup_controller.dart:36-38` (`NotifierProvider`). + - `lib/features/home/presentation/providers/butty_chat_controller.dart:51-53` (`NotifierProvider`). + - `lib/features/home/presentation/providers/app_preferences_provider.dart:169-174` (`NotifierProvider`). + - `lib/features/home/presentation/providers/translation_history_provider.dart:11-22` (raw `Provider<>` + `AsyncNotifierProvider<>`). + - `lib/features/home/presentation/widgets/butty_chat/butty_model_mode_selector.dart:19-20` (`FutureProvider`). + - `lib/features/translator/presentation/providers/translator_providers.dart:100-101` (`FutureProvider`). + - `lib/features/translator/presentation/providers/chat_memory_provider.dart:10,17,24,32` (raw `Provider<>` × 3 + `AsyncNotifierProvider<>`). + - `lib/features/scanner/presentation/providers/scan_history_provider.dart:13,22` (raw `Provider<>` + `AsyncNotifierProvider`). + - `lib/features/scanner/presentation/providers/scanner_provider.dart:58` (`Provider` deviceInferenceCapableProvider). + - `lib/features/scanner/presentation/providers/scanner_evaluation_provider.dart:41` (`Notifier` w/ raw NotifierProvider wiring). + - `lib/features/scanner/presentation/providers/scan_tab_controller.dart:126` (`Notifier` w/ raw NotifierProvider wiring). + - `lib/features/scanner/presentation/providers/yolo_model_selection_provider.dart:78,131,142,294-295,396,426` (raw `AsyncNotifier`, `Provider<>`, `FutureProvider<>` quartet). + - `lib/features/learning/presentation/providers/lesson_progress_provider.dart:14,23` (raw `Provider<>` + `AsyncNotifierProvider<>`). + - `lib/features/admin/presentation/providers/stroke_pattern_providers.dart:10,16` (raw `Provider<>` × 2). + + Note: `app/router/router_listenable.dart:8` uses `extends ChangeNotifier` — acceptable for a `go_router` `Listenable`, but worth flagging. **Severity P1** (codebase is half-codegen, half-hand-rolled; pattern drift creates real maintenance cost). + +### Widget rule compliance (build() ≤40, no _buildX, nesting) + +build() length measurements (manual count from end-to-end reads): + +| File | Widget | `build()` lines | Verdict | +|---|---|---|---| +| `lib/features/home/presentation/screens/scan_tab.dart` | `_ScanTabState` | **~216 (252→468)** | FAIL — 5.4× budget | +| `lib/features/home/presentation/screens/translate_screen.dart` | `_TranslateScreenState` | **~140 (33→172)** | FAIL — 3.5× budget | +| `lib/features/home/presentation/screens/butty_chat_screen.dart` | `_ButtyChatScreenState` | **~97 (86→182)** | FAIL — 2.4× budget | +| `lib/features/home/presentation/screens/profile_tab.dart` | `_GuestProfile` | **~65 (35→99)** | FAIL | +| `lib/features/home/presentation/screens/profile_tab.dart` | `_UserProfile` | **~60 (146→206)** | FAIL | +| `lib/features/home/presentation/screens/profile_tab.dart` | `ProfileTab` | 10 | OK | +| `lib/features/home/presentation/screens/settings_screen.dart` | `SettingsScreen` | 31 | OK | +| `lib/features/home/presentation/screens/learn_tab.dart` | `LearnTab` | 15 | OK | +| `lib/features/learning/presentation/screens/lesson_stage_screen.dart` | `_LessonStageScreenState` | **~55 (94→148)** | FAIL | +| `lib/features/learning/presentation/screens/lesson_stage_screen.dart` | `_LessonScaffold` | **~62 (169→230)** | FAIL | +| `lib/features/learning/presentation/screens/lesson_stage_screen.dart` | `_ModeSwitcher` | 14 | OK | + +Private `_build…()` UI-builder methods: **none found in the audited screens.** All `_build…` matches surface in non-widget contexts: + +- `lib/features/home/presentation/providers/butty_chat_controller.dart:212-240` — `_buildSystemInstruction`, `_buildProfileBlock`, `_buildMemoryBlock` build *prompt strings*, not widgets. OK. +- `lib/features/learning/presentation/widgets/butty_help_sheet.dart:14` — `_buildSystemPrompt(LessonStep)` returns `String`. OK. +- `lib/features/translator/data/datasources/cloud_gemma_datasource.dart:69,217` — `_buildMessages` returns `List` for the LLM call. OK. + +Nesting depth: `scan_tab.dart` `_ScanTabState.build()` reaches 7+ levels of indentation through `LayoutBuilder → Stack → Positioned → SafeArea → Padding → AnimatedOpacity → Center → _ScanStatusChip` (lines 356–387). `translate_screen.dart` build nests `ColoredBox → SafeArea → LayoutBuilder → Column → Expanded → switch → TextModePanel`. Both exceed the “3+ levels triggers extraction” rule. + +**Severity P1** — 7 out of 11 build() methods in the requested screens exceed the 40-line budget, and the worst offender is 5× the limit. Extraction is mechanical: the scan_tab.dart `_ScanTabState.build()` already has six sub-widgets co-located in the same file (`_ScanCameraStack`, `_ScanControls`, `_ScanUtilityBar`, `_ScanNoticePanel`, `ScannerResultPanel`, `_ScanStatusChip`); the parent build is simply orchestrating them inline rather than via an extracted `_ScanTabLayout({...})` widget that owns the `Stack`/`Positioned` math. + +### Error handling (Either) coverage + +`fpdart` is imported across the codebase; `dartz` is not used. Domain `Either` coverage: + +- **Auth domain:** complete. Every method on `AuthRepository` and every use case (`sign_in_with_email`, `sign_up_with_email`, `sign_in_with_google`, `sign_out`, `send_phone_otp`, `verify_phone_otp`, `reset_password`) returns `Either`. +- **Home domain:** complete. `ProfileManagementRepository` + all four use cases (`get_profile_summary`, `get_profile_preferences`, `update_display_name`, `save_profile_preferences`) return `Either`. +- **Learning domain:** complete. `LessonRepository.loadLesson` and `LoadLesson` use case return `Either`. +- **Admin domain:** complete. `StrokePatternRepository.save` / `fetchByGlyph` return `Either`. +- **Translator domain:** **PARTIAL.** `ai_inference_repository.dart` returns `Either` for `getAvailableModels`, `isLocalModelInstalled`, `downloadLocalModel`, `generateChallenge`. But `lib/features/translator/domain/repositories/chat_memory_repository.dart:6,10,14,18,22` returns raw `Future>` / `Future` — no `Either`. The corresponding use cases (`generate_chat_response`, `analyze_baybayin_image`) also do not use `Either` consistently — `analyze_baybayin_image.dart` and `generate_chat_response.dart` need verification on a follow-up pass. +- **Scanner domain:** **MISSING.** `lib/features/scanner/domain/repositories/baybayin_detector.dart:16-32` is the sole scanner repo interface and uses raw `Future>`, `Future`, `Future` for every method. No use-case layer exists in `lib/features/scanner/domain/` (the dir has only `entities/` and `repositories/`). All detection error paths bubble through exceptions in `data/` and presentation notifiers. + +**Severity P0** for scanner (this is the feature with the most-likely failure modes — TFLite load, camera permission, web preflight) and **P1** for the partial translator coverage. + +### Style rules (no var, single quotes) + +- **`var` usage:** one isolated violation: `lib/features/home/presentation/screens/translate_screen.dart:67` — `final view = View.of(context);` (type inferred as `FlutterView` rather than declared). Rest of the audited files are clean. +- **Double-quoted strings:** every double-quote hit found in `lib/features/` is *inside* a single-quoted string literal (escaped quotes embedded in user-facing copy or prompts — e.g. `'Translate "mahal kita"'`, `'Input: "${state.inputText.trim()}"'`). No genuine `"..."` Dart string literals identified. OK. +- **Trailing commas, single-quote imports, file naming:** spot checks across the seven screens read end-to-end show full compliance. + +**Severity P2** — isolated `var` fix. + +### Test coverage map (feature × test type) + +`test/` enumeration (42 files): + +| Feature | Domain (use cases) | Data (datasources) | Presentation (widgets/providers) | Verdict | +|---|---|---|---|---| +| auth | 6 use case tests | 0 | 6 widget/screen tests | strong | +| home | 0 | 0 | 13 widget/screen + 1 utility (`safe_ai_output_test`) + 2 profile avatar contract tests | UI-heavy, no domain | +| learning | 0 | 0 | 1 widget (`learning_density_test`) | sparse | +| scanner | 0 | 3 (`yolo_baybayin_detector`, `web_yolo_output_parser`, `web_vision_model_preflight_browser`) | 1 provider (`scan_tab_controller_test`) + 4 widget tests | data-side covered, **no domain tests** (no use cases yet) | +| translator | 0 | 2 (`cloud_gemma_datasource`, `web_gemma_model_preflight_browser`) | 0 | **no presentation tests, no domain tests** | +| admin | 0 | 0 | 0 | uncovered | +| app/router | 1 (`guest_route_access_test`) | — | — | minimal | + +**Gaps:** +- Translator: no `analyze_baybayin_image`, `generate_chat_response`, `generate_baybayin_challenge`, `get_available_models`, `download_local_model`, `check_local_model_installed` use case tests. +- Home: no use case tests for `get_profile_summary`, `get_profile_preferences`, `update_display_name`, `save_profile_preferences`. No provider tests for `butty_chat_controller`, `translate_page_controller`, `translate_text_controller`, `translate_sketchpad_controller`, `model_setup_controller`, `translation_history_provider`, `profile_management_provider`. +- Learning: no use case tests for `load_lesson`. No provider tests (`lesson_controller`, `lesson_progress_provider`). +- Scanner: no use cases at all → nothing to test there; provider tests cover only `scan_tab_controller`. `scanner_provider`, `scanner_evaluation_provider`, `yolo_model_selection_provider`, `scan_history_provider` are untested. +- Admin: zero tests. + +**Severity P2** for coverage breadth — the audited feature has tests, but ML-touching presentation logic (butty chat, lesson controller, sketchpad) is unguarded. + +## Top Recommendations (architecture-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---| +| 1 | P0 | L | Add `Either` to **scanner** domain (`baybayin_detector.dart:10-32`) — wrap detect/capture/torch/switch/pause/resume in typed failures; add a `scanner/domain/usecases/` directory. | `lib/features/scanner/domain/repositories/baybayin_detector.dart:16-32` (raw Futures); the only feature whose failure surface is invisible to presentation. | +| 2 | P0 | L | Migrate `data/` imports out of `presentation/` by introducing per-feature composition-root providers (e.g. `data/di/scanner_providers.dart`) that expose only the **domain interface**; presentation should import the interface, not the datasource. | 24 import sites enumerated above (`translator_providers.dart:10-17`, `scanner_provider.dart:6-8`, `auth_provider.dart:7-8`, etc.). | +| 3 | P1 | M | Finish the `@riverpod` codegen migration across the 15 files mixing raw `NotifierProvider<>` / `AsyncNotifierProvider<>` / `FutureProvider<>` / `Provider<>`. Half the presentation layer is already on codegen — close the gap so dispose semantics and family typing are consistent. | `translate_page_controller.dart:47`, `butty_chat_controller.dart:51`, `translation_history_provider.dart:11-22`, `chat_memory_provider.dart:10-32`, etc. | +| 4 | P1 | M | Extract `_ScanTabState.build()` into a `_ScanTabLayout` widget (and similarly for `TranslateScreen`, `ButtyChatScreen`, `_GuestProfile`, `_UserProfile`, `_LessonStageScreenState`, `_LessonScaffold`). Sub-widgets already exist in-file; the rule says compose them, not inline the Stack/Positioned math. | scan_tab.dart 252→468 (216 lines); translate_screen.dart 33→172 (140 lines). | +| 5 | P1 | M | Add `Either` to translator `ChatMemoryRepository` and audit `generate_chat_response` / `analyze_baybayin_image` use cases. | `lib/features/translator/domain/repositories/chat_memory_repository.dart:6,10,14,18,22`. | +| 6 | P2 | S | Replace `final view = View.of(context);` with `final FlutterView view = View.of(context);`. | `lib/features/home/presentation/screens/translate_screen.dart:67`. | +| 7 | P2 | M | Backfill use-case unit tests for translator (`analyze_baybayin_image`, `generate_chat_response`, `generate_baybayin_challenge`), home (`update_display_name`, `save_profile_preferences`), and learning (`load_lesson`). | Test inventory above. | +| 8 | P2 | S | Add provider tests for `butty_chat_controller`, `translate_page_controller`, and `lesson_controller` — they orchestrate the AI inference path and currently have no regression net. | Test inventory above. | + +## Methods +- Files read end-to-end: `lib/features/home/presentation/screens/scan_tab.dart`, `…/translate_screen.dart`, `…/butty_chat_screen.dart`, `…/learn_tab.dart`, `…/profile_tab.dart`, `…/settings_screen.dart`, `lib/features/learning/presentation/screens/lesson_stage_screen.dart`. Spot-read: provider files in `home/presentation/providers/`, `translator/presentation/providers/`, `scanner/presentation/providers/`, `learning/presentation/providers/`, `admin/presentation/providers/`; every `domain/repositories/*.dart` under `lib/features/`. Existing audit docs reviewed for prior signal: `docs/system_audit.md`, `docs/system_audit_next_steps.md`, `docs/backend_audit_2026.md` (the first two already note `@riverpod` + `fpdart` as the intended pattern — this lane quantifies the gap between intent and the current tree). +- Grep sweeps run: `package:flutter` in `features/*/domain/` (0 hits — clean); `package:flutter_riverpod` in `features/*/domain/` (0 hits); `import …/data/` inside `features/*/presentation/` (24 hits, enumerated); `@riverpod` (42 files); raw `Provider<` / `NotifierProvider<` / `AsyncNotifierProvider<` / `FutureProvider<` outside `.g.dart` (15 files); `StateNotifierProvider` / `extends StateNotifier` (0 hits); `extends ChangeNotifier` (1 hit, router only); `_build[A-Z]` (10 hits, all returning `String`/`List`, none UI builders); `Either<` / `Failure` inside `features/*/domain/` (counted per-feature, scanner missing entirely); `var ` in audited screens (1 hit); double-quoted Dart literals (0 — all double quotes are inside single-quoted strings). +- Prior audits reconciled: `system_audit.md` claims `@riverpod` codegen and `Either` are the codebase pattern — accurate as *target state* but ~30% of presentation providers and the entire scanner domain still need migration; `system_audit_next_steps.md` priorities (scanner reliability, translator polish) align with this lane’s P0 list; `backend_audit_2026.md` is data-layer focused and does not overlap with these findings. diff --git a/docs/audit/2026-05-14/04_performance_offline.md b/docs/audit/2026-05-14/04_performance_offline.md new file mode 100644 index 0000000..1754ca5 --- /dev/null +++ b/docs/audit/2026-05-14/04_performance_offline.md @@ -0,0 +1,204 @@ +# 04 — Performance, offline-first, integrations +**Auditor:** general-purpose (performance lane) · **Skill invoked:** none · **Date:** 2026-05-14 + +## Summary +- P0 count: 2 · P1 count: 6 · P2 count: 4 +- Single biggest risk: `ScanTab` and its `YOLOView` are never disposed when the user switches tabs (PageView retains all children), so YOLO native inference continues running on every frame for the life of the app even while the user is in Translate/Learn/Butty — the `paused` flag only suppresses dispatch, not the underlying inference. + +--- + +## Findings + +### Model load lifecycle + +**flutter_gemma (LLM)** +- `lib/main.dart:18` calls `initializeFlutterGemma(huggingFaceToken: hfToken)` on `WidgetsFlutterBinding.ensureInitialized()` — this is awaited before `runApp`, so plugin init blocks first-frame paint. +- `lib/features/translator/data/datasources/flutter_gemma_bootstrap.dart:9` configures `webStorageMode: WebStorageMode.cacheApi` for web (only relevant on web). +- The actual model is loaded lazily via `LocalGemmaDatasource.probeReadiness()` (`lib/features/translator/data/datasources/local_gemma_datasource.dart:32-55`). Probe is gated by an in-flight mutex (`_probing` + `_pendingProbe`) so concurrent callers coalesce. +- Pre-warm happens inside the probe (`local_gemma_datasource.dart:78-80`): `_activeModel ??= await FlutterGemma.getActiveModel()`. So readiness probe is also the warm-up — the next `generate()` reuses `_activeModel` (`local_gemma_datasource.dart:171`). +- Probe is wired via `localModelReadinessProvider` (`translator_providers.dart:100-127`). It re-runs only when `selectedModelId` changes — by using `.select((v) => v.value?.selectedModelId)`. +- `ModelSetupController.completeSetup()` (`model_setup_controller.dart:46-83`) calls `ref.refresh(localModelReadinessProvider.future)` synchronously while the "Continue" button is in a busy state. This is the only blocking call site for warm-up. Setup UI shows a spinner via `KudlitLoadingIndicator`. +- Vision support requires recreating the engine (`local_gemma_datasource.dart:228-236`): if `_activeModel` exists without vision, it is closed and reloaded with `supportImage: true, maxNumImages: 1`. Reload is paid the first time the user runs `analyzeImage` after a text chat. +- `ensureModelLoaded()` exists (`local_gemma_datasource.dart:101-110`) but no caller invokes it — pre-warm only happens through the probe path. **P2** — dead code or missing call site for post-download warmup. + +**YOLO (vision)** +- `baybayinDetectorProvider` is `@Riverpod(keepAlive: true)` (`scanner_provider.dart:17`). The detector is instantiated when first read. On mobile this creates a `YoloBaybayinDetector` with a lazy model resolver — no native model is loaded until the live YOLOView mounts or `detectImage()` is called. +- Pre-warm: `SplashScreen.build()` (`splash_screen.dart:19-21`) calls `ref.watch(baybayinDetectorProvider)` on non-web during the splash phase. This only instantiates the Dart class, not the native model. The native load happens when `YOLOView` mounts (`scanner_camera.dart:355-368`) — first scan-tab open pays the model-load cost. +- Live and still-image inference use separate YOLO instances: + - Live: managed by `YOLOView` widget; loads native model via `modelPath` prop on mount. + - Still: `_singleImageYolo` instance (`yolo_baybayin_detector.dart:89-114`), cached by `modelPath`. Recreated only when `modelPath` changes; `loadModel()` is awaited synchronously per request that requires a new path. +- YOLO download/cache (`lib/features/scanner/data/datasources/yolo_model_cache.dart`) — keyed by Supabase model id with a `.version` sidecar file. Version check (`isUpToDate`) decides whether to redownload. Download is plain `HttpClient` streamed to disk; failure deletes the partial file (`yolo_model_cache.dart:120`). + +**UI blocking** +- `await dotenv.load()` and `await Supabase.initialize()` in `main.dart:12-16` run before `runApp`. Combined with `initializeFlutterGemma` they delay the first frame. No reports of pathological times in the codebase, but this is a single sequential await chain — moving to fire-and-forget for non-critical init is a **P1** optimization. + +--- + +### Inference cadence & camera lifecycle + +**Throttling and persistence** +- `_kDetectionInterval = 250 ms` (`scanner_camera.dart:23`) — only one dispatch per quarter second to `onDetections`. The underlying YOLO native inference still fires on every frame; the `Stopwatch` only throttles the Dart-side dispatch. +- Two-tier filtering in `_onYoloResult` (`scanner_camera.dart:256-296`): + 1. Confidence ≥ 0.8 (`_kConfidenceThreshold`), area ≥ 0.001, in-frame margin 0.02. + 2. Temporal: requires `_kRequiredConsecutiveHits = 2` consecutive non-empty intervals before surfacing — kills single-frame phantoms. + +**Paused state (commit 7f28abc)** +- `ScannerCamera.paused` (`scanner_camera.dart:208-210`, line 257): `if (widget.paused) return;` — guards `_onYoloResult` so detections are discarded while the result panel is up. This **does not** stop the native YOLO model from running; the camera and inference pipeline continue, only the dispatch is suppressed. **P1** — wasted battery while the user sits on the frozen result. The detector has `pauseInference()`/`resumeInference()` methods (`yolo_baybayin_detector.dart:155-159`) that call `_controller.stop()` / `_controller.restartCamera()`, but `ScannerCamera` does not invoke them when `paused` flips. +- Pause is wired in: `scan_tab.dart:298` (`scannerPaused: scanState.resultVisible && !kIsWeb`). Dismiss restores via `controller.resumeInference()` (`scan_tab_controller.dart:347-349`). The asymmetry is the gap — entering pause never calls `pauseInference()` on the controller, only the dispatch-suppress flag. + +**Tab switch / camera lifecycle** +- **P0** — `HomeScreen._HomeBody` (`auth/presentation/screens/home_screen.dart:122-145`) uses a `PageView` with `physics: NeverScrollableScrollPhysics()` and all four children mounted simultaneously (`ScanTab`, `TranslateScreen`, `LearnTab`, `ButtyChatScreen`). PageView keeps offscreen children **alive**, so `ScannerCamera` is never disposed when the user goes to another tab. `YOLOView` continues running native inference; the camera hardware stream is still open. +- This is consistent with the keepAlive intent of `baybayinDetectorProvider` (`scanner_provider.dart:17`), but the lack of any `pauseInference()` call on tab switch is a major battery and thermal concern. Recommend gating `ScannerCamera` mount on `_activeTab == AppTab.scan`, or invoking `pauseInference()` when the scan tab loses focus. +- Web is partially better: `_WebCameraPreview.dispose()` (`scanner_camera.dart:411-417`) disposes the `CameraController` — but only fires when ScannerCamera itself unmounts, which (due to the PageView) never happens. + +**Inference path branching** +- Live frame: YOLO native (mobile) or `_captureAndDetect` snapshot (web) — `scanner_camera.dart:552-582` calls `detectImage(bytes)` on every web capture (no live web stream inference). +- Captured shutter frame: reuses existing live detections (`scan_tab_controller.dart:171-198`), so no extra inference pass. +- Gallery: full `detectImage()` pass via the cached `_singleImageYolo` instance. +- Frame capture via `RenderRepaintBoundary.toImage(pixelRatio: 1.5)` on mobile (`scan_tab.dart:237`) — synchronous-ish render-thread work; PNG encode happens on a separate isolate via `toByteData`. **P2** — `pixelRatio: 1.5` is arbitrary; on hi-DPI Android the PNG can be large enough to noticeably stall the shutter feedback. + +**Aggregation buffer** +- `_kAggMaxBuffer = 50` frames, `_kAggIdleTimeout = 1000 ms` (`scan_tab_controller.dart:127-128`). Reset on empty detection or after 1 s idle. Reasonable bounds. Vowel ambiguity collapse (`e` → `i`) lives at `scan_tab_controller.dart:515`. + +--- + +### SQLite cache-first patterns (per repository) + +**`ProfileManagementRepositoryImpl`** (`lib/features/home/data/repositories/profile_management_repository_impl.dart`) +- `getSummary()` (lines 21-49): cache-first — returns cached value immediately if present, only hits remote on cache miss. **P1** — no TTL/staleness check. If the cache is ever populated, the read path never sees server-side changes (e.g., when avatar/displayName changes happen on another device). Mitigated only by explicit invalidation in `updateDisplayName`/`updateAvatar` on the *same* device. +- `getPreferences()` (lines 52-80): same cache-first pattern. +- Write paths (`updateDisplayName`, `updateAvatar`, `savePreferences`): remote first, then either invalidate (`clearCachedSummary`) or update cache directly. Write order is correct — server write is the source of truth for ack, cache only mirrors on success. +- `saveLessonProgress` (lines 168-185): remote-only; no cache layer. **P2** — lesson progress reads would always hit Supabase; check whether `ProfileSummary.completedLessons` is the snapshot path (it is, via the cached summary). + +**`ChatMemoryRepositoryImpl`** (`lib/features/translator/data/repositories/chat_memory_repository_impl.dart`) +- `getFacts()` (lines 27-44): SQLite first; on empty, cold-start restore from Supabase. Each remote row is `insertIfNew()` into SQLite (dedupes on `normalized` UNIQUE index). Returns the re-loaded local list. +- `addFacts()` (lines 47-58): local insert with `ConflictAlgorithm.ignore`; if persisted, fire-and-forget `_syncFact()` which captures the returned remote UUID and back-fills `remote_id` locally (lines 96-104). +- `updateFact()`/`removeFact()`: local first, then mirror to Supabase by `remote_id` if known. +- **P1** — failed Supabase inserts (returning `null`) leave the row with `remote_id IS NULL` forever. No retry queue. Cross-device sync silently breaks for those rows. (Already called out in `docs/butty_chat_memory_ai_audit.md` Gap 1.) + +**`ChatHistoryNotifier`** (`lib/features/translator/presentation/providers/chat_history_provider.dart`) — uses datasources directly, no repository indirection +- Same offline-first pattern: `build()` loads from SQLite first; on empty, calls `_remote.fetchRecent(limit: 100)` and rehydrates. +- `addMessage()` inserts locally first, updates state immediately, then `unawaited(_syncMessage(saved))` for cloud. `remote_id` back-fill mutates state (lines 73-81) to keep it consistent. +- Same retry gap as chat memory. + +**`ScanHistoryNotifier`** (`lib/features/scanner/presentation/providers/scan_history_provider.dart`) — no repository abstraction +- Direct SQLite via `SqliteScanHistoryDatasource` + inline Supabase calls (lines 79-93, 95-121). +- Cold-start restore from Supabase (lines 36-55) — inserts rows in reverse to preserve chronological order. +- Write: local-first, fire-and-forget cloud sync. No `remote_id` column in scan_history schema (`sqlite_scan_history_datasource.dart:33-43`) — there is **no idempotency key** between local and remote rows, so the cold-start restore relies on local being empty. If a user partially synced from device A and reinstalls on device B, both rows may end up in the cloud causing duplicates on next restore. **P1** — schema lacks remote_id. + +**SQLite connection management** +- All four datasources open the DB lazily, cache the `Database` in a field, and expose `dispose()`. All datasource providers wire `ref.onDispose(ds.dispose)`. Good. +- No `WAL` mode / journal-mode tuning — Flutter sqflite default is `DELETE` journal. Fine for current write volumes but **P2** — chat memory writes during streaming are sparse, scan-history is one row per save. + +--- + +### Gemma local↔cloud fallback + +**Boundary** — `lib/features/translator/data/repositories/ai_inference_repository_impl.dart:80-131` +- Router (`generateResponse`): `_useCloud` reads `preferenceResolver()` per call. Cloud path is direct; local path goes through `_localWithCloudFallback`. +- Local path uses `await for ... yield` (not `yield*`) so stream errors from `localDatasource.generate` are caught inside the try block (line 109-122). On any caught error, the `localFailed = true` branch yields from cloud (lines 123-130). +- Same pattern for `analyzeImage` (lines 160-184). Mobile-only — `kIsWeb || _useCloud` shortcircuits to cloud at line 141. +- `generateChallenge` (lines 188-215): try local, catch any, retry cloud — synchronous (not streamed). + +**User visibility** +- Fallback is **silent**. The only signal is `debugPrint('[Gemma] local inference failed -> falling back to cloud')` (line 120). No UI affordance: the user sets `AiPreference.local`, fires a chat, and may transparently land on cloud without knowing. +- **P1** — silent fallback can mask "model is broken" failure modes (e.g. file deleted, version mismatch). The chat memory audit doc lists the local→cloud transparent fallback as working as intended, but there is no telemetry / banner that surfaces it. Recommend a one-shot snackbar (or toggle the AI mode pill state) when the repo silently downgrades. + +**preferenceResolver design** +- `translator_providers.dart:75-92` deliberately does not `watch` preferences — to avoid disposing the repo (which closes the native InferenceModel) on every preference toggle. Resolver is read at call-time. This is correct but worth noting: switching local→cloud mid-stream has no effect on in-flight generation. + +--- + +### Butty chat memory architecture (episodic + semantic + sliding window) + +**Layering** — two stores: +- Episodic: `chat_messages` table (`sqlite_chat_datasource.dart:35-43`) — full turn transcript, schema: `id, remote_id, text, is_user, timestamp`. +- Semantic: `chat_memory_facts` table (`sqlite_chat_memory_datasource.dart:46-58`) — distilled facts about the user with a `normalized` UNIQUE index for dedup. Schema: `id, remote_id, fact_type, content, normalized, created_at, last_referenced_at`. + +**Sliding window** +- `ButtyChatController._historyWindow = 20` (`butty_chat_controller.dart:61`). On every `send()`, the last 20 entries from the in-memory `state.messages` are converted to `ChatMessage` and shipped to `generateResponse` (lines 119-131). The system instruction injects 12 most-recent facts (`_buildMemoryBlock`, lines 240-254). +- Window snapshot uses `DateTime.now()` for the timestamp of historical messages (line 128), discarding the actual stored timestamp — **P2**, harmless for prompt assembly but breaks any downstream time-based reasoning in the prompt. + +**Memory extraction (semantic distillation)** +- `MemoryExtractionService` (`lib/features/translator/presentation/providers/memory_extraction_service.dart`). +- Triggers: every 4 user messages (`isDue`, line 38, 46); flushes on app pause via `flushMemoryNow()` (called from `ButtyChatController` lines 197-199, presumably wired to a lifecycle observer in `butty_chat_screen.dart`). +- Throttle: `_minInterval = 30 seconds`, `_running` mutex (lines 31, 40-42, 58-63). Both extraction triggers can fire on the same edge but only one runs. +- Window: 30 messages (`_windowSize` line 35) — independent from the chat sliding window. +- Output: model-generated JSON (with fence stripping) → `ChatMemoryFact[]` → `_repo.addFacts()` which dedupes via the SQLite UNIQUE index. +- **P1 (already documented)** — extraction calls `aiInferenceNotifierProvider.notifier.generateResponse` (line 82-85), which respects the user's `AiPreference`. If the user is in local mode and the local model fails, extraction silently skips (the transparent fallback runs but extraction-specific prompts may misfire on the cloud model only sporadically). Force cloud for extraction. + +**"Start fresh" preserves memory** +- Verified — `ButtyChatController.startFresh()` (`butty_chat_controller.dart:204-208`): clears `chatHistoryNotifierProvider` (chat_messages local + remote) and resets the controller state to `ButtyChatState.initial()`. It does **not** touch `chatMemoryNotifierProvider` or `chat_memory_facts`. The semantic memory survives. +- `_userMessageCount` is reset to 0 so the next 4 messages re-trigger extraction from the freshly-empty episodic window. + +**Profile injection** +- `_buildProfileBlock` (`butty_chat_controller.dart:221-238`) pulls from `profileSummaryNotifierProvider.value` and concatenates name, lessons completed, AI mode into the system instruction. Best-effort: returns `''` if not loaded. **P2** — no fallback to cached SQLite if the provider is in `loading` state at send time; the first message after a cold start may go out without profile context. + +--- + +### Supabase sync & retry behavior + +- All Supabase writes across `SupabaseChatMemoryDatasource`, `SupabaseChatDatasource`, and inline `_syncToSupabase` calls are wrapped in try/catch with `debugPrint` only. No retry, no queue, no `remote_id IS NULL` reaper. Once a sync fails, the row stays orphaned locally. +- Guest mode handled: every cloud call checks `_client.auth.currentUser?.id` first and silently no-ops when null (`supabase_chat_memory_datasource.dart:15, 47, 71, 81, 98`). Same in scan history `_syncToSupabase` (line 82). +- Cold-start restore is the only "sync recovery" mechanism — it runs only when the local store is empty (or scan history is empty). After the first row lands locally, restore is permanently skipped for that device. +- **P0** — no failed-write reconciliation. If a user is in airplane mode for a session, all messages/facts/scans are orphaned forever from the cloud mirror. (Also flagged in butty audit doc Gap 1.) +- Network error classification — `ModelSetupScreen._friendlyModelSetupError` (`model_setup_screen.dart:52-91`) shows that the team is aware of common transient signatures (`socketexception`, `failed host lookup`, etc.) but this awareness is only applied to model setup UX, not to sync writes. + +--- + +## Top Recommendations (perf/offline-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +|---|----------|--------|---------------|----------| +| 1 | P0 | M | Stop YOLO inference when ScanTab is off-screen (PageView keeps it alive). Either gate `ScannerCamera` mount on `_activeTab == AppTab.scan` in `HomeScreen._HomeBody`, or call `detector.pauseInference()` / `resumeInference()` from a tab-change callback. | `auth/presentation/screens/home_screen.dart:122-145` (PageView with all four children), `scanner_camera.dart:355-368` (YOLOView), `yolo_baybayin_detector.dart:155-159` (pause/resume methods) | +| 2 | P0 | M | Add a failed-write reaper: on chat/memory/scan provider build, scan local rows with `remote_id IS NULL` (or any equivalent local-only flag for scan_history) and retry Supabase insert. Without this, offline-only sessions never reach the cloud mirror. | `chat_memory_repository_impl.dart:46-58`, `chat_history_provider.dart:53-58`, `scan_history_provider.dart:60-93` (no retry path) | +| 3 | P1 | S | When `ScannerCamera.paused` flips true, call `detector.pauseInference()` so the native YOLO pipeline actually stops (currently only dispatch is suppressed). Resume in the dismiss path already exists. | `scanner_camera.dart:257` (only dispatch guard), `scan_tab_controller.dart:347-349` (existing resume call) | +| 4 | P1 | S | Make memory extraction force `AiPreference.cloud` (or use a dedicated extraction route) so distillation still runs when the user is in local mode and the local model is broken. | `memory_extraction_service.dart:82-85` (uses default route), `ai_inference_repository_impl.dart:80-97` | +| 5 | P1 | S | Surface silent local→cloud fallback to the user (e.g., transient banner or pill flicker) so "Offline" doesn't lie when it's secretly cloud. | `ai_inference_repository_impl.dart:104-131` (silent fallback) | +| 6 | P1 | S | Add a `remote_id` column to `scan_history` SQLite schema + Supabase, so cross-device sync is idempotent. Today, reinstall after partial sync duplicates rows on next cloud restore. | `sqlite_scan_history_datasource.dart:33-43`, `scan_history_provider.dart:79-93` | +| 7 | P1 | M | Add TTL/staleness to `ProfileManagementRepositoryImpl` cache reads, or invalidate on auth state changes — current cache-first-forever means cross-device profile edits never appear. | `profile_management_repository_impl.dart:21-49` | +| 8 | P1 | M | Move non-critical `main.dart` boot work (Supabase, FlutterGemma init) off the awaited-before-runApp path, or show a true splash that doesn't block painting. | `main.dart:10-19` (all awaits before `runApp`) | +| 9 | P2 | S | Call `ensureModelLoaded()` after `download()` completes so the first inference after fresh-download is instant. Currently this method is unused. | `local_gemma_datasource.dart:101-110` (no callers) | +| 10 | P2 | S | Stop rebuilding `ChatMessage.timestamp = DateTime.now()` for the sliding-window snapshot — preserve the original `state.messages` timestamps if you want temporal reasoning in the prompt. | `butty_chat_controller.dart:127-128` | +| 11 | P2 | S | Tune `RenderRepaintBoundary.toImage(pixelRatio: 1.5)` on hi-DPI Android — large PNGs can stall shutter feedback. Consider JPEG with lower quality for the capture path. | `scan_tab.dart:237` | +| 12 | P2 | S | Allow profile block to fall back to the cached SQLite summary when `profileSummaryNotifierProvider` is still loading at send time, so cold-start first messages have context. | `butty_chat_controller.dart:221-238` | + +--- + +## Methods +- Files read: + - `lib/main.dart` + - `lib/app/app.dart` (listed, not opened — confirmed app root via `main.dart`) + - `lib/features/auth/presentation/screens/home_screen.dart` + - `lib/features/translator/data/datasources/flutter_gemma_bootstrap.dart` + - `lib/features/translator/data/datasources/local_gemma_datasource.dart` + - `lib/features/translator/data/datasources/sqlite_chat_datasource.dart` + - `lib/features/translator/data/datasources/sqlite_chat_memory_datasource.dart` + - `lib/features/translator/data/datasources/supabase_chat_memory_datasource.dart` + - `lib/features/translator/data/repositories/ai_inference_repository_impl.dart` + - `lib/features/translator/data/repositories/chat_memory_repository_impl.dart` + - `lib/features/translator/presentation/providers/translator_providers.dart` + - `lib/features/translator/presentation/providers/chat_history_provider.dart` + - `lib/features/translator/presentation/providers/chat_memory_provider.dart` + - `lib/features/translator/presentation/providers/memory_extraction_service.dart` + - `lib/features/home/presentation/providers/butty_chat_controller.dart` + - `lib/features/home/presentation/providers/model_setup_controller.dart` + - `lib/features/home/presentation/screens/model_setup_screen.dart` + - `lib/features/home/presentation/screens/splash_screen.dart` + - `lib/features/home/presentation/screens/scan_tab.dart` + - `lib/features/home/data/repositories/profile_management_repository_impl.dart` + - `lib/features/scanner/data/datasources/yolo_baybayin_detector.dart` + - `lib/features/scanner/data/datasources/yolo_model_cache.dart` + - `lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart` + - `lib/features/scanner/presentation/providers/scanner_provider.dart` + - `lib/features/scanner/presentation/providers/scan_tab_controller.dart` + - `lib/features/scanner/presentation/providers/scan_history_provider.dart` + - `lib/features/scanner/presentation/providers/scanner_evaluation_provider.dart` + - `lib/features/scanner/presentation/widgets/scanner_camera.dart` +- Prior audits reconciled: + - `docs/gemma_offline_model_loading_audit.md` (referenced — not re-opened in this session) + - `docs/scanner_vision_model_audit.md` (referenced — not re-opened in this session) + - `docs/butty_chat_memory_ai_audit.md` (read in full; Gap 1 and Gap 2 are reflected as P0 #2 and P1 #4 above) + - `docs/butty_chat_memory_and_sync_plan.md` (referenced via butty audit cross-link) + - `docs/realtime_scan_aggregator_plan.md` (referenced via scan_tab_controller aggregator constants) + - `docs/profile_management_feature_plan.md` (referenced via profile repo cache patterns) diff --git a/docs/audit/2026-05-14/05_security_privacy.md b/docs/audit/2026-05-14/05_security_privacy.md new file mode 100644 index 0000000..a998a0a --- /dev/null +++ b/docs/audit/2026-05-14/05_security_privacy.md @@ -0,0 +1,99 @@ +# 05 — Security & Privacy +**Auditor:** general-purpose (security lane) · **Skill invoked:** security-review (deferred — applying security-review heuristics manually) · **Date:** 2026-05-14 + +## Summary +- P0 count: 2 · P1 count: 6 · P2 count: 5 +- Single biggest risk: Phone OTP screen has **no client-side resend cooldown or lockout** — the `_resendCooldown` field is a hard-coded `0` and `_resendCode()` only blocks while a single request is in flight (`phone_otp_screen.dart:46,134-162`). Combined with the verify path that auto-submits on every 6 digits typed (`phone_otp_screen.dart:95`), the app can drive unlimited OTP send/verify traffic to Supabase from a compromised or scripted client. Server-side Supabase rate limits are the only defense, and prior audits (`supabase_phone_otp_integration.md` §4) flag this as "review before prod" rather than confirm it is configured. + +## Findings + +### Auth token handling & refresh +- **Token storage is delegated entirely to `supabase_flutter`.** `supabase_auth_datasource.dart:36-55` only holds a `SupabaseClient` reference; access/refresh tokens live in `gotrue`'s default `SharedPreferences`-backed store on mobile and `localStorage` on web. No explicit secure-storage option (`flutter_secure_storage` / Keychain / Keystore) is configured at init time in `lib/main.dart:13-16`. (P1) — On rooted/jailbroken Android the refresh token can be read from app private storage; on web `localStorage` is XSS-reachable. +- **No manual refresh path.** The app relies on the SDK's internal auto-refresh; there is no `refreshSession()` retry on `Failure.sessionExpired`, and `_mapServerExceptionToFailure` (`auth_repository_impl.dart:117-142`) does not produce `Failure.sessionExpired` for any 401-style payload — those fall through to `Failure.unknown`. (P2) — silent expiry surfaces as a generic error instead of forcing re-login. +- **`currentUserRole` reads `profiles.role` directly with the user's JWT** (`current_user_role_provider.dart:14-27`). If the `profiles` table allows the row owner to UPDATE `role`, a user could self-elevate to admin. RLS policy is implicit (see "RLS trust surface"). (P0) — verify `role` is non-writable client-side. + +### Password reset deep-link safety +- **`/auth/reset` renders `LoginScreen` and nothing else** (`app_router.dart:138-142`). There is no widget that listens for `AuthChangeEvent.passwordRecovery` and prompts for a new password — `grep AuthChangeEvent.passwordRecovery` across `lib/` returns zero hits. The `authStateChanges` mapper in `supabase_auth_datasource.dart:42-48` only forwards `session?.user`, dropping the recovery event entirely. (P0) — the recovery deep link establishes a logged-in session (because `gotrue` honors the recovery token automatically) and drops the user on `LoginScreen`; if the redirect goes to `/auth/reset` while a session is already cached, the user appears to be already signed in without ever choosing a new password. The link effectively becomes a "click here to log in as me" capability if the recovery URL leaks. +- **Mobile deep-link scheme `kudlit://auth/reset`** (`supabase_auth_datasource.dart:87,179`) is not validated for authenticity by the app — Android intent filters / iOS `CFBundleURLSchemes` are not narrowed to a Supabase-signed payload, and any other app registered for the same scheme would intercept the token fragment. (P1) — App Links / Universal Links (HTTPS-verified) are stronger than custom schemes. +- **Web fallback uses `Uri.base.origin`** for OAuth (`supabase_auth_datasource.dart:85-87`). If the app is served from a host the Supabase dashboard hasn't allow-listed, the OAuth call will fail closed — that is acceptable, but it also means any environment that the attacker can host (e.g. PR preview deploy) will end up in the allow-list if Site URL/Redirect URLs are loosely configured server-side. (P2) — depends on dashboard config; flag for review. + +### Phone OTP rate limit / lockout +- **`_resendCooldown` is a `const 0`** (`phone_otp_screen.dart:46`). The `_ResendRow` only ever shows "Resend code" link or "Sending…", never a countdown — the cooldown branch on line 435-442 is dead code. (P0) — see Summary. +- **Auto-submit on every 6-digit entry** (`phone_otp_screen.dart:95`) means a scripted brute-force does not even need a button press; it can paste 000000…999999. The `_isLoading` flag serializes attempts but does not throttle them. (P0) +- **No client-side attempt counter / lockout.** `_submit()` resets `_errorMessage` and immediately allows another attempt on failure (`phone_otp_screen.dart:98-125`). The repository only translates `429 / "too many requests"` into a user-readable failure (`auth_repository_impl.dart:129-131`) — it does not lock the screen. (P1) +- **Phone send screen has no rate guard** either (`phone_sign_in_screen.dart:89-119`); tapping "Send OTP" rapidly issues back-to-back `signInWithOtp` calls with `_isLoading` as the only gate, which races on `setState`. (P1) + +### Google OAuth redirect URIs +- **`signInWithGoogle` pins web redirect to `${Uri.base.origin}/auth/reset`** (`supabase_auth_datasource.dart:85-92`). The `/auth/reset` path here is reused for OAuth callback even though no recovery handler exists at that route — functionally it is just "land back on Login". This conflates two flows (password reset and OAuth callback) on the same route, which makes router logic brittle. (P1) — a dedicated `/auth/callback` route avoids ambiguity. +- **Allowed origins not enforced client-side.** `Uri.base.origin` is whatever the page is served from; the only enforcement is the Supabase dashboard's "Redirect URLs" allow-list. Code does not assert that the resolved origin matches an expected hostname. (P2) — defense-in-depth: pin `kudlit.app` (or the env-configured host) instead of trusting `Uri.base`. +- **`prompt: 'select_account'`** is intentional — good — prevents silent re-auth onto the wrong Google identity. + +### Supabase RLS trust surface (tables accessed) +Every table below is read or written with the user's JWT and assumes RLS confines rows to `auth.uid() = user_id`. None of this is verified in code. + +| Table | File:line | Operation | Trust assumption | +| --- | --- | --- | --- | +| `profiles` | `current_user_role_provider.dart:21` | SELECT `role` by `id=user.id` | **RLS must forbid client UPDATE on `role`** — otherwise self-elevation. (P0) | +| `profiles` | `profile_management_datasource.dart:44,126,169` | SELECT / UPSERT / UPDATE | Owner-only RLS expected; `upsert` payload trusted to set `id=user.id`. | +| `learning_progress` | `profile_management_datasource.dart:46,210`; `streak_provider.dart:23`; `lesson_progress_provider.dart:98,119` | SELECT / UPSERT | RLS must scope by `user_id`. | +| `scan_history` | `profile_management_datasource.dart:50`; `scan_history_provider.dart:84,100` | SELECT / INSERT | RLS must scope by `user_id`. | +| `translation_history` | `profile_management_datasource.dart:52,56`; `translation_history_provider.dart:126,153` | SELECT / INSERT (incl. `input_text`, `ai_response`) | RLS must scope by `user_id`. Stores raw user input. (P1 — sensitive content) | +| `user_preferences` | `profile_management_datasource.dart:92,191` | SELECT / UPSERT | RLS must scope by `user_id`. | +| `chat_messages` | `supabase_chat_datasource.dart:23,47,77` | INSERT / SELECT / DELETE | **Full Butty conversation content uploaded.** RLS must scope by `user_id`. (P1) | +| `chat_memory_facts` | `supabase_chat_memory_datasource.dart:18,52,74,86,104` | SELECT / INSERT / DELETE / UPDATE | Distilled personal facts about the user. RLS critical. (P1) | +| `lessons`, `lesson_steps` | `supabase_lesson_datasource.dart:17,24`; `quiz_provider.dart:70`; `character_gallery_provider.dart:15,57` | SELECT | Read-only public content — should be `select` policy = `published=true`. | +| `stroke_patterns` | `supabase_stroke_pattern_datasource.dart:27,46`; `supabase_lesson_datasource.dart:68`; `character_gallery_provider.dart:57` | INSERT / SELECT | Admin recorder (`stroke_recording_screen`) writes here. RLS must restrict INSERT to admin role; otherwise any user can corrupt the stroke-order canon. (P0/P1 — depends on policy) | +| `avatars` (Storage bucket) | `profile_management_datasource.dart:150,160` | upload `{user.id}/avatar.{ext}`; `getPublicUrl` | **Bucket is treated as public** — bucket policy must restrict writes to `auth.uid()::text = (storage.foldername(name))[1]`. (P1) | + +Prior audits (`backend_audit_2026.md`, `backend_audit_2026-05-05.md`) noted RLS as a pending task — this audit confirms code still implicitly trusts it. + +### PII in logs +All `debugPrint` calls are compile-stripped in release builds in Flutter, so this is principally a debug/staging concern. Still worth tightening: + +- **Message length leaked, content not.** `butty_chat_controller.dart:99-101,156-158` logs `chars=`, not the message body — good. `local_gemma_datasource.dart:186-188` likewise. +- **Raw model output printed verbatim** during memory parsing: `memory_extraction_service.dart:143` — `debugPrint('[MemoryExtraction] JSON parse failed: $e\nraw=$cleaned')`. `cleaned` is Gemma's distilled facts about the user (names, location, preferences). In debug-mode IDE consoles this is the most sensitive PII surface in the app. (P1) — gate behind `assert(false)` or log only a hash/length. +- **Phone numbers / emails / OTP codes never logged.** `phone_otp_screen.dart` / `phone_sign_in_screen.dart` / `supabase_auth_datasource.dart` — clean. Good. +- **Tokens never logged.** `flutter_gemma_bootstrap.dart:5-12` trims but doesn't print the HF token. `local_gemma_datasource.dart:128` reads the token but never logs it. Good. +- **Server error messages forwarded into UI** via `Failure.unknown(message: e.message)` (`auth_repository_impl.dart:141`) — Supabase auth error strings can contain account hints ("user already registered", "email rate limit exceeded"). User enumeration risk is mild but present. (P2) + +### Cloud Gemma data flow & user disclosure +- **All chat history is sent to Google AI in cloud mode** (`cloud_gemma_datasource.dart:64-97`). The full sliding window — including any personal facts injected via the system prompt assembled in `butty_chat_controller.dart:212-254` — is shipped to `gemma-4-26b-a4b-it` via the Genkit Google AI plugin. This includes: + - User display name (from `profiles.display_name`) + - Lessons-completed count + - Up to 12 most-recent memory facts (free-form distilled PII) + - All recent chat turns +- **Sketchpad / image analysis uploads base64-encoded drawings** (`cloud_gemma_datasource.dart:106-162`) plus an optional caller-supplied prompt. +- **Challenge generator** uploads only a focus list of glyphs — low risk. +- **Privacy disclosure is generic.** `privacy_policy_screen.dart:51-71` says "Inputs may be processed locally on your device or sent to configured app services" and "Some Kudlit features use model-based processing." It does **not name Google / Gemini**, does not list the specific data fields (memory facts, profile name) that are forwarded, and does not provide an in-app toggle to switch off cloud mode (the toggle exists in `app_preferences`, but its privacy implication isn't surfaced near the on/off switch). (P1) — GDPR/PH-DPA "transfer to third country / processor disclosure" expectation. +- **No data-minimization on the prompt.** `_buildProfileBlock` always includes display name even when the user could have remained pseudonymous. (P2) +- **No content filter / safety guard** before sending sketchpad images to the cloud — if the user draws something off-topic, it ships anyway. (P2) + +### Secrets / env handling +- **`.env` is committed to the working tree.** `find . -maxdepth 2 -name '.env'` returns both `.env` and `.env.example`. (P0 if `.env` is tracked in git; P1 if only present locally.) **Action:** confirm `.env` is in `.gitignore` and was never committed — `.env.example` (`.env.example:1-3`) is the only file that should be tracked. +- **No hard-coded keys in `lib/`.** `grep -E 'AIza|sk-…|hf_…|eyJ…'` over `lib/` returns zero hits. All secrets flow through `dotenv.env[...]` at runtime: `supabase_config.dart:4-5` (`SUPABASE_URL`, `SUPABASE_ANON_KEY`), `main.dart:17` (`HUGGINGFACE_TOKEN`), `translator_providers.dart:54` (`GEMINI_API_KEY`). Good. +- **`GEMINI_API_KEY` is bundled into the client build at run time.** Because Flutter loads `.env` via `flutter_dotenv` (`main.dart:12`), the Gemini key is shipped inside the APK / web bundle. Any user can extract it from the APK assets and abuse the project's Google AI quota. (P0) — cloud Gemini calls **must** be proxied through a server (Edge Function / backend) so the key never ships to clients. The Supabase anon key is designed for client exposure; the Gemini key is not. +- **`HUGGINGFACE_TOKEN` likewise ships to clients** (`main.dart:17`, `local_gemma_datasource.dart:128`). It is used to authorize Gemma model downloads from HF; treat scope as "read public gated model" and rotate if compromised. (P1) +- **Supabase anon key exposure is expected**, but the project's RLS posture (see above) determines whether that exposure is safe. If RLS is permissive the anon key is effectively a service key. (Cross-ref P0 in "Supabase RLS trust surface".) + +## Top Recommendations (security-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +| --- | --- | --- | --- | --- | +| 1 | P0 | M | Move Gemini API calls behind a server proxy (Supabase Edge Function or dedicated backend); remove `GEMINI_API_KEY` from the client bundle. | `translator_providers.dart:54-55`, `cloud_gemma_datasource.dart:47` | +| 2 | P0 | S | Implement client-side OTP resend cooldown (30–60s) and a 5-attempt verify lockout with backoff; remove the dead `_resendCooldown=0` field. | `phone_otp_screen.dart:46,95,134-162` | +| 3 | P0 | S | Wire `AuthChangeEvent.passwordRecovery` to a dedicated `ResetPasswordScreen` that forces `updateUser(password:)` before letting the user proceed; stop reusing `/auth/reset` for both OAuth callback and recovery. | `app_router.dart:138-142`, `supabase_auth_datasource.dart:179`, no `passwordRecovery` handler anywhere in `lib/` | +| 4 | P0 | S | Verify and document Supabase RLS for: `profiles.role` non-writable by row owner; `stroke_patterns` INSERT admin-only; `avatars` bucket write scoped to `auth.uid()` prefix. Add an integration test that asserts each policy. | `current_user_role_provider.dart:21`, `supabase_stroke_pattern_datasource.dart:27`, `profile_management_datasource.dart:150` | +| 5 | P0 | S | Confirm `.env` is git-ignored and was never committed (rotate any leaked keys if it was). | repo root `.env` present | +| 6 | P1 | M | Switch to `flutter_secure_storage`-backed Supabase auth persistence (configure `Supabase.initialize(authOptions: ...)`); replace `kudlit://` custom scheme with Android App Links / iOS Universal Links. | `main.dart:13-16`, `supabase_auth_datasource.dart:87,179` | +| 7 | P1 | S | Update privacy policy + add an in-app "Cloud AI" disclosure next to the AI-mode toggle naming Google/Gemini and listing forwarded fields (display name, memory facts, chat turns, sketches). | `privacy_policy_screen.dart:51-71`, `butty_chat_controller.dart:212-254` | +| 8 | P1 | XS | Strip raw fact content from `[MemoryExtraction] JSON parse failed` debug log; log only error class + length. | `memory_extraction_service.dart:143` | +| 9 | P1 | S | Add per-session attempt counters for both `sendPhoneOtp` and `verifyPhoneOtp`; surface `Failure.tooManyRequests` distinct from `invalidCredentials` in the UI. | `phone_sign_in_screen.dart:89`, `phone_otp_screen.dart:98` | +| 10 | P2 | S | Map 401/`session_not_found` Supabase errors to `Failure.sessionExpired` so the router can force re-login instead of showing "Unexpected error." | `auth_repository_impl.dart:117-142` | +| 11 | P2 | S | Pin OAuth `redirectTo` to an env-configured allow-list instead of `Uri.base.origin`; assert host matches before calling `signInWithOAuth`. | `supabase_auth_datasource.dart:85-92` | +| 12 | P2 | XS | Suppress raw Supabase error strings in `Failure.unknown` for auth flows (use generic copy) to avoid account enumeration. | `auth_repository_impl.dart:141` | +| 13 | P2 | S | Add a "minimize cloud context" toggle that omits profile/memory blocks from the system prompt when cloud mode is on. | `butty_chat_controller.dart:212-254` | + +## Methods +- **Files read:** `lib/features/auth/data/datasources/supabase_auth_datasource.dart`, `lib/features/auth/data/repositories/auth_repository_impl.dart`, `lib/core/auth/current_user_role_provider.dart`, `lib/app/router/app_router.dart`, `lib/app/constants.dart`, `lib/features/auth/presentation/screens/phone_sign_in_screen.dart`, `lib/features/auth/presentation/screens/phone_otp_screen.dart`, `lib/features/auth/presentation/screens/privacy_policy_screen.dart`, `lib/features/translator/data/datasources/cloud_gemma_datasource.dart`, `lib/features/translator/data/datasources/local_gemma_datasource.dart`, `lib/features/translator/data/datasources/supabase_chat_datasource.dart`, `lib/features/translator/data/datasources/supabase_chat_memory_datasource.dart`, `lib/features/translator/data/datasources/flutter_gemma_bootstrap.dart`, `lib/features/translator/presentation/providers/translator_providers.dart`, `lib/features/translator/presentation/providers/memory_extraction_service.dart`, `lib/features/home/data/datasources/profile_management_datasource.dart`, `lib/features/home/presentation/providers/butty_chat_controller.dart`, `lib/features/home/presentation/providers/translation_history_provider.dart`, `lib/features/scanner/presentation/providers/scan_history_provider.dart`, `lib/features/learning/data/datasources/supabase_lesson_datasource.dart`, `lib/features/admin/data/datasources/supabase_stroke_pattern_datasource.dart`, `lib/main.dart`, `lib/core/config/supabase_config.dart`, `.env.example`. +- **Greps:** `\.from\('` (all Supabase tables), `debugPrint|print\(|Logger\.|log\(` (PII logs), `AIza|sk-…|hf_…|eyJ…` (hard-coded keys), `AuthChangeEvent.passwordRecovery|onAuthStateChange` (recovery handler). +- **Prior audits reconciled:** `docs/supabase_phone_otp_integration.md` (server-side OTP rate-limits flagged as "review before prod" — confirmed client never enforces them), `docs/supabase_phone_google_auth_plan.md`, `docs/backend_audit_2026.md`, `docs/backend_audit_2026-05-05.md` (RLS noted as outstanding — confirmed every table still trusts RLS implicitly). diff --git a/docs/audit/2026-05-14/06_accessibility.md b/docs/audit/2026-05-14/06_accessibility.md new file mode 100644 index 0000000..f5e660a --- /dev/null +++ b/docs/audit/2026-05-14/06_accessibility.md @@ -0,0 +1,180 @@ +# 06 — Accessibility +**Auditor:** general-purpose (a11y lane) · **Skill invoked:** ui-ux-pro-max (a11y topic — heuristics-only) · **Date:** 2026-05-14 + +## Summary +- P0 count: 3 · P1 count: 6 · P2 count: 5 +- Single biggest risk: **Body and hint text fail WCAG AA contrast in multiple core surfaces** (top bar foreground, body-small subtle text, chat/translate input hints), and **no widget in the app honors `MediaQuery.disableAnimations`**, so users with motion-sickness or reduced-motion OS settings still see all looping micro-animations. + +## Findings + +### Contrast (WCAG AA) + +Tokens cited come from `lib/core/design_system/kudlit_colors.dart` and `lib/core/design_system/kudlit_theme.dart`. Ratios computed against pure WCAG formula. AA thresholds: **4.5:1** body / **3:1** large or non-text UI component. + +**Light theme — failures (P0/P1):** + +| # | Pair | Hex | Ratio | AA verdict | Evidence | +|---|------|-----|-------|------------|----------| +| C1 | App-bar foreground on topbar (P0) | `#E9EEFF` on `#6777B6` | **3.71:1** | FAIL body, pass large | `kudlit_theme.dart:86-87` (appBarTheme.backgroundColor `KudlitColors.topbar` = `blue500`; foregroundColor `blue900`). Affects every AppBar title/subtitle the theme produces. | +| C2 | `bodySmall` subtle text on app background (P0) | `grey300 #6C738E` on `blue900 #E9EEFF` | **4.05:1** | FAIL body (small) | `kudlit_theme.dart:68-72` declares `bodySmall.color = subtleForeground` and uses `fontSize: 12` (not "large" by WCAG). Used in topbar subtitle, `floating_tab_nav.dart:319-323`, `lesson_top_bar.dart:55-56`, history screens. | +| C3 | TextField hint `cs.onSurface.withAlpha(110)` on input fill `#F1F4FF` (P0) | blended `#989DAA` on `#F1F4FF` | **2.47:1** | FAIL | `chat_input_bar.dart:47-50`, `text_input_box.dart:45-48`. The hint text "Ask Butty anything..." / "Type in Filipino..." is essentially invisible to low-vision users. | +| C4 | Floating-nav border `cs.outline = borderSoft #D0D4E2` on pill bg `surfaceContainerHighest #C1CCEB` (P1) | — | **1.08:1** | FAIL 3:1 (non-text) | `floating_tab_nav.dart:131-134`. Border is decorative only, so this is P1, but it visually disappears, hurting affordance for low-contrast viewers. | +| C5 | Aggregated winner label `cs.onSurface.withAlpha(140)` on `surfaceContainerHigh #D3DBF0` (P1) | blended `#5E657B` on `#D3DBF0` | **~3.6:1** | FAIL body | `scan_tab.dart:505-511` ("Settled reading" eyebrow). | + +**Light theme — passes (logged for completeness):** +- Primary button `#3F4E87` on `onPrimary #E9EEFF` = **6.84:1** PASS (`kudlit_theme.dart:116-117`). +- Foreground `#0F1725` on background `blue900 #E9EEFF` = **15.51:1** PASS. +- Collapsed nav icon `cs.onSurface` on `surfaceContainerHighest #C1CCEB` = **11.20:1** PASS (`floating_tab_nav.dart:208-212`). +- Expanded inactive nav-pill text `onSurface @ alpha 210` on `#C1CCEB` ≈ **7.46:1** PASS (`floating_tab_nav.dart:284, 319-323`). +- Scan status chip `onSurface @ 230` on blended `#D6DEF2` chip = **10.56:1** PASS (`scan_tab.dart:895-900`). + +**Dark theme — failures (P1):** + +| # | Pair | Hex | Ratio | Evidence | +|---|------|-----|-------|----------| +| C6 | Dark chat hint `onSurface @ 100` on `surfaceContainerLow #0E1828` | blended `#646C7C` on `#0E1828` | **3.37:1** FAIL body | `chat_input_bar.dart:47-50` (same code path, dark token blend). | +| C7 | Dark `bodySmall` `blue600 #7484C7` on dark surface `#0E1830` | `#7484C7` on `#0E1830` | **4.92:1** PASS (barely) — but `headlineMedium` and `titleLarge` use `blue800 #B3C5FF` (10+ pass). Captioned because subtle muted text in cards near edges drops below 4.5 once alpha-blended onto `surfaceContainerHighest #1E3578`. | `kudlit_theme.dart:230-233`. Verify in `scan_tab.dart` snackbar copy where `cs.onSurface.withAlpha(170)` is used (`scan_tab.dart:170-175`) — blended ratio against dark surface drops below 4.5. | + +**Ocean theme on lesson_stage_screen** (`lesson_stage_screen.dart`) uses only `cs.surface` + `LessonTopBar` / `LessonProgressBar` / mode bodies; ocean gradient + bubbles live on `learning_progress_screen.dart` (`_Bubble`, `_OceanWaveClipper` at lines 282–328) and use solid `Container` colors driven by `cs.surfaceContainerLow`. Body copy in `_OverallRingCard` uses default `onSurface` (PASS). Lesson title via `text.titleMedium` PASS. **No lesson-stage-specific contrast failure observed** beyond the C2/C4 token issues above. + +### Semantic labels on interactive elements + +Grepped `IconButton`, `GestureDetector`, `InkWell` across `lib/features/`. Most navigation tabs and chat send buttons have proper `Semantics` + `Tooltip`. Items with **no** affordance (no `Tooltip`, no enclosing `Semantics`, and the child is a non-text `Container`/`Icon`, so screen readers see nothing): + +| Severity | File:line | Element | +|---|---|---| +| P0 | `lib/features/home/presentation/widgets/app_header/profile_button.dart:12` | `GestureDetector` wrapping the profile avatar `Image.asset`. No `Semantics`, no `Tooltip`, no `semanticLabel`. TalkBack reads nothing. | +| P0 | `lib/features/home/presentation/widgets/translate/mic_button.dart:12` | `GestureDetector` over a 38×38 mic icon. No label, no tooltip. Critical because this triggers speech capture. | +| P1 | `lib/features/home/presentation/widgets/learn/lesson_header.dart:25-32` | `GestureDetector` wrapping a bare back-arrow `Icon`. No tooltip/semantics. Use `IconButton` or wrap in `Semantics(button: true, label: 'Back')`. | +| P1 | `lib/features/home/presentation/widgets/translate/translate_mode_switch.dart:81` | `_ModePill` `GestureDetector` (Text/Sketchpad toggle). Text is read, but pill is not announced as a toggle — needs `Semantics(button: true, selected: active, label: '$label mode')`. | +| P1 | `lib/features/home/presentation/widgets/translate/toggle_pill.dart:20` | Same pattern — generic toggle pill missing `selected` state. | +| P1 | `lib/features/home/presentation/widgets/butty_chat/butty_model_mode_selector.dart:135` | `_ModePill` (Cloud/Local AI). Same issue. | +| P1 | `lib/features/home/presentation/widgets/translate/translate_gemma_status_banner.dart:129` | `GestureDetector` "Use local Gemma" toggle. No `Semantics`. | +| P1 | `lib/features/home/presentation/screens/scan_tab.dart:821` | Shutter button `GestureDetector` — **note:** wrapped in `Semantics(label, button: true)` at line 817-820. PASS. (Logged because the line matched the grep.) | +| P1 | `lib/features/home/presentation/widgets/translate/text_input_box.dart:53` | Clear-button `GestureDetector` with bare `Icons.close_rounded`. No tooltip/semantics. | +| P1 | `lib/features/learning/presentation/widgets/butty_help_sheet.dart:432` | `GestureDetector` decorating an icon button inside help sheet. No label. | +| P2 | `lib/features/home/presentation/widgets/learn/pad_button.dart:32` | Carries visible `Text(label)`, so TalkBack reads it — but as static text, not a button. Wrap in `Semantics(button: true)` so the user knows it's actionable. | +| P2 | `lib/features/home/presentation/widgets/learn/draw_button.dart:33` | Same — visible label but not announced as a button. | +| P2 | `lib/features/home/presentation/widgets/lesson_detail_card.dart:53`, `lesson_preview_card.dart:46`, `home_tool_card.dart:47`, `learn_home/butty_talk_card.dart:21` | Card `InkWell`s have no `Semantics` wrapper. Visible text inside is read, but TalkBack does not announce "double-tap to activate" reliably for nested rich content. Add `Semantics(button: true, label: '$title card')`. | +| P2 | `lib/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart:165` | Drawing-pad `GestureDetector` for stroke capture. Needs `Semantics(label: 'Drawing canvas', hint: 'Draw a Baybayin glyph')`. | +| P2 | `lib/features/home/presentation/screens/learning_progress_screen.dart:546, 786` | Lesson-card `GestureDetector`s. Visible text is read, but no `Semantics(button: true)`. | + +Good citizens (do not need changes): `floating_tab_nav.dart:55-64, 285-292`, `app_bottom_nav.dart:72`, `scan_tab.dart:764, 817, 1075, 1531`, `chat_input_bar.dart:61`, `learning_route_back.dart:25-33`, `sign_in_form.dart:131-134`, `login_button.dart:12, 36`. + +### Touch target sizes + +WCAG/Material guideline: **48 dp** (Material) or **44 dp** (Apple HIG). Sampled interactive elements: + +| Severity | File:line | Element | Measured | Verdict | +|---|---|---|---|---| +| P1 | `floating_tab_nav.dart:299-301` | Expanded nav-pill | `minHeight: 54` × ~70 wide | PASS | +| P1 | `floating_tab_nav.dart:128-129` | Collapsed pill `_collapsedSize: 64` | 64×64 | PASS | +| P1 | `home_topbar.dart:53-56` | `_MenuButton` 32×32 | **32×32 — FAIL** | The "hamburger" icon is below 44 dp. Wrap in `IconButton(constraints: BoxConstraints.tightFor(44, 44))` or `SizedBox(48, 48)`. | +| P1 | `home_topbar.dart:121-124` | `_AvatarButton` 34×34 | **34×34 — FAIL** | Same issue, and there is no `Semantics`/`Tooltip` (see P0 above). | +| P1 | `home_topbar.dart:88-94` | `_SignInButton` pill, padding 11×5 + 11.5pt text | computed ≈ 26 dp tall — **FAIL** | Wrap in `ConstrainedBox(minHeight: 44)`. | +| P1 | `profile_button.dart:14-16` | Avatar 34×34 | **34×34 — FAIL** | Below 44. | +| P1 | `mic_button.dart:14-17` | Mic button 38×38 | **38×38 — FAIL** | Below 44. Speech capture is a primary action. | +| P1 | `learn/lesson_header.dart:25-32` | Back arrow `GestureDetector` over a bare `Icon(size: 18)` | **≈18×18 — FAIL** | No padding, no `IconButton`. | +| P1 | `translate/text_input_box.dart:53-63` | Clear-X over `Icon(size: 16)` | **≈16×16 — FAIL** | | +| P1 | `translate/translate_mode_switch.dart:85-100` | Mode-toggle pill `minHeight: 34-44` depending on density | compact = 34 — **FAIL** | Compact path is below 44 dp. | +| P1 | `translate/toggle_pill.dart:24-31` | Toggle pill — only padded by 4-6 vertical (no minHeight) | **~24 dp — FAIL** | Used in `translate_screen` row of toggles. | +| P1 | `butty_chat/butty_model_mode_selector.dart:137-141` | Mode pill `minHeight: 36` | **36 — FAIL** | | +| P1 | `translate/translate_gemma_status_banner.dart:131-135` | Status-toggle pill, no `minHeight` | **~26 dp — FAIL** | | +| P2 | `scan_tab.dart:1462-1466` | `_CyclerButton` 48×48 | PASS | +| P2 | `scan_tab.dart:776-786` | `_ControlIcon`, `size: 44+` | PASS | +| PASS | `_ShutterButton` 64–72 dp; `_ActionChip` 48 dp; `lesson_top_bar.dart:32-35` IconButton 44×44; `learning_route_back.dart` IconButton; chat send 44×44; `_RememberMeToggle` `minHeight: 44`; `_AuxiliaryAuthLink` `minimumSize: Size(44, 44)`. | + +### Font scale tolerance + +The only file in the codebase that reads `MediaQuery.textScalerOf(context)` and reacts is **`app_header.dart:22, 43`** — it shrinks the title and switches to "ultraCompact" when `textScale > 1.35 && compact`. No other widget in the app calls `textScaler` or `textScaleFactor`. **`grep -rn 'textScaleFactor\|textScalerOf' lib/ → 1 hit total`.** + +Hard-coded `fontSize:` inside `TextStyle(...)` is used **364 times** across `lib/features/`. Flutter still scales these by the OS text scaler by default, so the *typography* will grow — but the **containers around them are fixed pixel heights**, which causes clipping at 200%. Concrete examples: + +| Severity | File:line | Issue | +|---|---|---| +| P0 | `floating_tab_nav.dart:128-129` | `_collapsedSize = 64.0`, but inside is `Icon(size: 22) + Text(fontSize: 10.5)`. At 200% text scale the column grows past 64 and overflows the `FittedBox` only partially (icon is fixed). Acceptable in collapsed; **expanded items (lines 305-326)** use a `FittedBox(fit: BoxFit.scaleDown)` which *shrinks* the label so users with large text get a *smaller* label, defeating their setting. | +| P0 | `home_topbar.dart:16` | `height: 56` is fixed. Title font scales but bar does not — risk of label clipping vertically at 150%+. | +| P1 | `lesson_top_bar.dart:32-35` | `IconButton` is locked to 44×44 via `BoxConstraints.tightFor` and the icon size is fixed `Icon(...)` — fine. But `Row` children include `Expanded Text` with `maxLines: 1, overflow: ellipsis` (`scan_history_screen.dart:50-58`, `translation_history_screen.dart:49-58`) — at large text scale the screen title clips. | +| P1 | `scan_tab.dart:891-900` | `_ScanStatusChip` `minHeight: 48`, but the `Text(fontSize: 11.5, maxLines: 2, overflow: ellipsis)` will clip on large scaling. Consider `Semantics(label: …)` + allow taller chip. | +| P1 | `scan_tab.dart:1083` `_NoticeButton` height: 48 + `Text(fontSize: 12.5)` with `overflow: ellipsis` | At 200% the "Use Gallery"/"Try Again" labels clip. | +| P1 | `floating_tab_nav.dart:307-326` | `FittedBox(fit: BoxFit.scaleDown)` shrinks tab labels at large text scale instead of allowing the pill to grow — actively works against the user. | +| P2 | `home_tool_card.dart`, `lesson_preview_card.dart`, `lesson_detail_card.dart` | Card content uses fixed pixel paddings — text grows but icons/illustrations stay the same size, so layout drifts but does not break catastrophically. | + +### Motion / reduced motion + +**`grep -rn disableAnimations lib/ → 0 hits.`** Zero animations honor `MediaQuery.disableAnimations`. + +Looping / infinite animations that will be the most uncomfortable: + +| Severity | File:line | Animation | +|---|---|---| +| P1 | `lib/features/home/presentation/widgets/butty_chat/typing_bubble.dart:65, 107, 155` | Three repeating bobbing/scaling tweens on the typing indicator. | +| P1 | `lib/features/home/presentation/widgets/butty_chat/butty_bubble.dart:125` | Repeating shimmer/wobble. | +| P1 | `lib/features/home/presentation/screens/learning_progress_screen.dart:295` | Ocean bubble `moveY` repeating reverse. | +| P1 | `lib/features/home/presentation/widgets/settings/settings_header.dart:185` | Repeating animation in header. | +| P1 | `lib/features/learning/presentation/widgets/lesson_completion_overlay.dart:236-249` | Three controllers (arc, count, particle) on completion overlay — runs unconditionally. | +| P1 | `lib/features/home/presentation/screens/splash_screen.dart:42` | Splash animation. | +| P2 | `lib/features/learning/presentation/widgets/lesson_progress_bar.dart:26, 41` | `TweenAnimationBuilder` + `AnimatedSwitcher` (non-looping). | +| P2 | `lib/features/learning/presentation/screens/lesson_stage_screen.dart:185` | `AnimatedSwitcher` slide+fade on step change (non-looping). | +| P2 | All `AnimatedContainer` / `AnimatedOpacity` (18 occurrences) | One-shot state transitions; ignorable unless user is in vestibular crisis. | + +Fix pattern: introduce a `useReducedMotion(context)` helper that returns `MediaQuery.maybeOf(context)?.disableAnimations ?? false`, and gate every `.animate(onPlay: (c) => c.repeat())` and `AnimationController(...)..repeat()` behind it. + +### Focus order on web + +| Screen | Wires FocusNodes? | Evidence | Verdict | +|---|---|---|---| +| `sign_in_screen.dart` | Implicit — relies on `TextInputAction.next` → `done` via `EmailField` + `PasswordField` (`sign_in_form.dart:56, 62`). No explicit `FocusNode`. | `sign_in_form.dart:53-64` | **Web: random tab order risk** — Flutter's default web focus traversal follows widget tree order, which is fine for two stacked fields, but the `Remember me` toggle and `Forgot password` links sit in a `Row` with `Expanded+Align`. Tab order on web will be: email → password → remember-me → forgot-password → continue-with-phone → submit. That happens to read correctly, but it is **not enforced** by `FocusTraversalGroup`. P2. | +| `sign_up_screen.dart` | Same pattern — no explicit FocusNodes. | (`grep -n FocusNode lib/features/auth/presentation/screens/sign_up_screen.dart` → 0 hits) | P2 — same as above. | +| `phone_otp_screen.dart` | **Explicit `List` with auto-advance** between 6 OTP digits. | `phone_otp_screen.dart:37-39, 53, 222, 305, 340` | PASS. | +| `phone_sign_in_screen.dart` | No explicit FocusNodes; single phone field. | Default fine. | +| `translate_screen.dart` → `translate_text_mode_panel.dart` | Has `FocusNode _focusNode = FocusNode()` for the main text area + listener. | `translate_text_mode_panel.dart:288, 295, 330` | PASS (single field). | +| `butty_chat_screen.dart` → `chat_input_bar.dart` | **No `FocusNode` on the chat `TextField`.** | `chat_input_bar.dart:37-58` | P1 — when the user tabs back into the screen after the send button, focus may jump unpredictably. Bigger issue: after pressing Send, the controller is cleared (`butty_chat_screen.dart:64`) but focus is *not* explicitly returned to the input, so keyboard users must click again. Wire a `FocusNode`, expose it through `ChatInputBar`, and `_focusNode.requestFocus()` in `_handleSend()` after clearing. | +| `forgot_password_screen.dart` | (Not inspected — single-field; likely OK.) | — | — | + +### Screen-reader empty / error state coverage + +| Screen | Empty state has `Semantics`? | Error state has `Semantics`? | Evidence | Verdict | +|---|---|---|---|---| +| `lib/features/scanner/presentation/screens/scan_history_screen.dart` | **No** — `_EmptyState` (lines 109-162) is plain `Center > Container > Column > Icon + Text("No scans yet") + Text(...)`. TalkBack will still read both `Text` widgets, but the icon is decorative and there is no grouping `Semantics(label: …, container: true)`. P2. | **No** — `_ErrorState` (lines 164-198) is a single `Text` with the failure message. Not announced as a `liveRegion`, no `label`. P2. | scan_history_screen.dart:109, 164 | Add `Semantics(container: true, label: 'No scans yet. Scan Baybayin and your saved readings will appear here.')` and `Semantics(liveRegion: true, label: 'Could not load history: $message')`. | +| `lib/features/home/presentation/screens/translation_history_screen.dart` | Same pattern as above. | Same. | translation_history_screen.dart:108, 159 | Same fix. P2. | +| `lib/features/home/presentation/screens/learning_progress_screen.dart` | No dedicated `_EmptyState` — the screen always shows the ring + lesson list. If `lessons.isEmpty`, just renders an empty list (silent for SR). | No `_ErrorState` widget detected via grep. | (no _Empty/_Error class) | P2 — add a "No lessons available" state with `Semantics(liveRegion: true)`. | +| `scan_tab.dart` notice panel | **PASS** — `_ScanNoticePanel` wraps the panel in `Semantics(liveRegion: true, label: '${notice.title}. ${notice.message}')`. | scan_tab.dart:948-950 | Good citizen — copy this pattern to history screens. | + +## Top Recommendations (a11y-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---| +| 1 | P0 | S | Darken `subtleForeground` from `grey300 #6C738E` to at least `#5A6076` so `bodySmall` 12pt clears 4.5:1 against `blue900` background. | `kudlit_colors.dart:24`, `kudlit_theme.dart:68-72` | +| 2 | P0 | S | Raise TextField hint alpha from `withAlpha(110)` to `withAlpha(160)` (≈4.5:1 on light surface). | `chat_input_bar.dart:47-50`, `text_input_box.dart:45-48` | +| 3 | P0 | S | Either change topbar background `KudlitColors.topbar` to a darker shade (e.g., `blue400 #3F4E87`, which gives 6.8:1 with `blue900` foreground), or swap the foreground to a darker token. | `kudlit_colors.dart:37`, `kudlit_theme.dart:85-91` | +| 4 | P0 | S | Add `Semantics(button: true, label: 'Profile')` / `'Open microphone'` wrappers to `profile_button.dart:12`, `mic_button.dart:12`, and bump those hit targets to ≥44 dp. | `profile_button.dart:12-30`, `mic_button.dart:12-37` | +| 5 | P0 | M | Introduce `useReducedMotion(context)` helper and gate every repeating `AnimationController` / `.animate(onPlay: (c) => c.repeat())` behind it. Start with `typing_bubble.dart`, `butty_bubble.dart`, `lesson_completion_overlay.dart`, `learning_progress_screen.dart:295`, `settings_header.dart:185`. | sections above | +| 6 | P1 | S | Enlarge `_MenuButton`, `_AvatarButton`, `_SignInButton` in `home_topbar.dart` to 44×44 minimum (wrap in `SizedBox` or use `IconButton`). | `home_topbar.dart:47-113` | +| 7 | P1 | S | Add `Semantics(button: true, selected: active, label: ...)` to every `_ModePill`/toggle pill (translate, sketchpad/text, AI cloud/local, status banner). | files cited in §"Semantic labels" | +| 8 | P1 | S | Replace `FittedBox(fit: BoxFit.scaleDown)` in `floating_tab_nav.dart:307-326` with `FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.center)` only when `MediaQuery.textScalerOf(context).scale(1) ≤ 1.15`; otherwise let the pill grow vertically. | `floating_tab_nav.dart:307` | +| 9 | P1 | S | Wire a `FocusNode` into `ChatInputBar` and re-request focus after send (`_handleSend`). | `chat_input_bar.dart:37`, `butty_chat_screen.dart:58-66` | +| 10 | P1 | S | Add `Semantics(button: true, label: 'Back')` and bump tap area on the bare back-arrow `Icon` in `learn/lesson_header.dart:25-32`. | `learn/lesson_header.dart:25` | +| 11 | P2 | S | Wrap history `_EmptyState` / `_ErrorState` in `Semantics(liveRegion: true, container: true, label: ...)`. | `scan_history_screen.dart:109, 164`, `translation_history_screen.dart:108, 159` | +| 12 | P2 | M | Add `Semantics(button: true, label: ' card')` to large card `InkWell`s in `home_tool_card.dart`, `lesson_preview_card.dart`, `lesson_detail_card.dart`, `learn_home/butty_talk_card.dart`. | files cited | +| 13 | P2 | S | Wrap `sign_in_form.dart` and `sign_up_form.dart` body in `FocusTraversalGroup(policy: OrderedTraversalPolicy())` and assign `FocusTraversalOrder` to each field for explicit web tab order. | `sign_in_form.dart:46-113` | +| 14 | P2 | M | Audit every fixed `height:`/`SizedBox.fromHeight` that contains text and replace with `ConstrainedBox(minHeight: ...)` or `IntrinsicHeight`, so 200% text scale doesn't clip. Start with `home_topbar.dart:16`, `scan_tab.dart:1083, 891`. | sections above | + +## Methods +- Files read: + - `lib/core/design_system/kudlit_colors.dart`, `lib/core/design_system/kudlit_theme.dart` + - `lib/features/home/presentation/widgets/floating_tab_nav.dart`, `home_topbar.dart`, `home_tool_card.dart`, `lesson_detail_card.dart`, `lesson_preview_card.dart`, `app_bottom_nav.dart` + - `lib/features/home/presentation/widgets/app_header/app_header.dart`, `profile_button.dart`, `login_button.dart` + - `lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart`, `butty_model_mode_selector.dart` + - `lib/features/home/presentation/widgets/translate/mic_button.dart`, `translate_mode_switch.dart`, `toggle_pill.dart`, `text_input_box.dart`, `translate_sketchpad_mode_panel.dart`, `translate_gemma_status_banner.dart` + - `lib/features/home/presentation/widgets/learn/pad_button.dart`, `draw_button.dart`, `lesson_header.dart` + - `lib/features/home/presentation/widgets/learn_home/butty_talk_card.dart` + - `lib/features/home/presentation/screens/butty_chat_screen.dart`, `scan_tab.dart`, `translation_history_screen.dart`, `learning_progress_screen.dart` + - `lib/features/scanner/presentation/screens/scan_history_screen.dart` + - `lib/features/learning/presentation/screens/lesson_stage_screen.dart` + - `lib/features/learning/presentation/widgets/lesson_top_bar.dart`, `learning_route_back.dart`, `lesson_completion_overlay.dart` + - `lib/features/auth/presentation/screens/sign_in_screen.dart`, `phone_otp_screen.dart` + - `lib/features/auth/presentation/widgets/sign_in_form.dart` +- Grep sweeps: `IconButton`, `GestureDetector`, `InkWell`, `AnimationController`, `AnimatedSwitcher`, `TweenAnimationBuilder`, `disableAnimations`, `textScaleFactor|textScalerOf`, `FocusNode`, `fontSize:`. +- Contrast: computed WCAG 2.1 ratios with the standard sRGB→linear luminance formula for each token pair, including alpha-blended foregrounds where the codebase uses `withAlpha(...)`. +- Prior audits reconciled: `docs/design-improvement-evidence-pack.md`, `docs/kudlit_design_and_setup.md` (referenced for visual-system intent; neither covers contrast, semantics, motion, or focus order in this depth, so this lane stands as the canonical a11y record). diff --git a/docs/audit/2026-05-14/07_nav_ia_visual.md b/docs/audit/2026-05-14/07_nav_ia_visual.md new file mode 100644 index 0000000..6d747dd --- /dev/null +++ b/docs/audit/2026-05-14/07_nav_ia_visual.md @@ -0,0 +1,166 @@ +# 07 — Navigation, IA & Visual Language +**Auditor:** general-purpose (nav/IA lane) · **Skill invoked:** none · **Date:** 2026-05-14 + +## Summary +- P0 count: 2 · P1 count: 5 · P2 count: 4 +- Single biggest risk: the four primary tabs (`AppTab` in `floating_tab_nav.dart:10`) and the four bottom-nav tabs (`AppBottomNav._defs` in `app_bottom_nav.dart:15-20`) describe two *different* information architectures. Only `FloatingTabNav` is wired into `HomeScreen` (`home_screen.dart:138`); `AppBottomNav`, `HomeTab` and `ProfileTab` are orphaned dead code from a previous IA. The app ships with a half-finished IA refactor that no audit has flagged. + +## Route Map + +All routes are declared in `lib/app/router/app_router.dart`. Paths come from `lib/app/constants.dart:6-23`. + +| Path | Destination | Guard | Notes | +|---|---|---|---| +| `/` (routeSplash) | `SplashScreen` | open | Redirect-driven; `app_router.dart:55-68`. Holds while `authState`/`prefsState` load; routes to model-setup, login, or home. | +| `/model-setup` | `ModelSetupScreen` | open | `app_router.dart:71-86`. Returns to splash if loading, to home/login once `hasDownloadedModels`, `hasSeenModelPrompt`, or `sessionSkipped` is true. | +| `/login` | `LoginScreen` | guest-only | Authenticated users are bounced back to `/home` (`app_router.dart:104`). | +| `/sign-up` | `SignUpScreen` | guest-only | Same auth-bounce. | +| `/forgot-password` | `ForgotPasswordScreen` | guest-only | Same. | +| `/home` | `HomeScreen` | open (guest OK) | Listed in `isGuestAccessibleRoute` (`app_router.dart:33`). Hosts the 4-tab `FloatingTabNav`. | +| `/auth/reset` | `LoginScreen` (alias) | guest-only | Same screen as `/login` — duplicate route to absorb password-reset deep links. | +| `/settings` | `SettingsScreen` | open (guest OK) | `app_router.dart:34`. `SettingsList` (`settings_list.dart:38-83`) shows different sections by `user == null`. | +| `/learn/lesson/:id` | `LessonStageScreen` | open (guest OK) | Allow-listed via `matchedLocation.startsWith('${routeLesson}/')` (`app_router.dart:38`). | +| `/terms` | `TermsScreen` | open | Listed as `isOnAuthRoute` (`app_router.dart:96`), which means it bypasses the auth-required check but is *not* on the guest-allow list — odd routing logic, see findings. | +| `/privacy-policy` | `PrivacyPolicyScreen` | open | Same: classified as an "auth route" although it is also reachable when signed in. | +| `/admin/stroke-recorder` | `StrokeRecordingScreen` | **auth-required, but no role check** | Any signed-in user reaches this admin tool. No `isAdmin` gate in router or screen. **P0.** | +| `/learn/gallery` | `CharacterGalleryScreen` | open (guest OK) | `app_router.dart:35`. | +| `/learn/quiz` | `QuizScreen` | open (guest OK) | `app_router.dart:36`. | +| `/scan-history` | `ScanHistoryScreen` | auth-required | Not in guest-allow list; redirects to `/login`. | +| `/translation-history` | `TranslationHistoryScreen` | auth-required | Same. | +| `/learning-progress` | `LearningProgressScreen` | auth-required | Same — yet `learn` itself is open. Inconsistent (see findings). | +| `/butty-data` | `ButtyDataScreen` | auth-required | Same. | + +Guest-allow list (`app_router.dart:32-39`): `/home`, `/settings`, `/learn/gallery`, `/learn/quiz`, `/learn/lesson`, `/learn/lesson/<id>`. + +### Routing red flags +1. `/admin/stroke-recorder` is exposed to every authenticated user (`app_router.dart:163-167`); the entry-point gate is only the visibility check in `AdminSection` (`settings/admin_section.dart:63`) and there is no role check. The route should reject non-admins at the router layer. **P0.** +2. `routeLesson = '/learn/lesson'` (`constants.dart:14`) is declared as a *path* but the router only registers `/learn/lesson/:id` (`app_router.dart:149`). Hitting the bare `/learn/lesson` URL (which `isGuestAccessibleRoute` explicitly allows on line 37) returns a no-match 404. The constant is misleading and the allow-list entry is dead. +3. `/terms` and `/privacy-policy` are treated as `isOnAuthRoute` (`app_router.dart:96-97`). That short-circuits the `isGuestAccessibleRoute` check, so they work — but classifying them as auth routes makes the intent unclear and means signed-in users hitting `/terms` *don't* get bounced to home like real auth routes do (line 104 only fires for the routes already in `isOnAuthRoute` and authenticated). The semantic of "auth route" is overloaded. + +## Findings + +### Dual navigation (floating tab nav vs app bottom nav) +- `FloatingTabNav` (`lib/features/home/presentation/widgets/floating_tab_nav.dart:10-15`) defines `AppTab { scan, translate, learn, butty }` — a collapsible glass pill anchored bottom-right and rendered as the only nav inside `HomeScreen._HomeBody` (`home_screen.dart:135-141`). +- `AppBottomNav` (`lib/features/home/presentation/widgets/app_bottom_nav.dart:5-46`) defines a totally different IA: `Home / Scan / Learn / Profile` (`app_bottom_nav.dart:15-20`). +- **`AppBottomNav` is never imported or rendered anywhere** — `grep -rn AppBottomNav lib` only matches its own declaration. Same for `HomeTab` (`home_tab.dart:11`) and `ProfileTab` (`profile_tab.dart:11`), the screens it would have hosted; both are defined but not wired into the router or `_HomeBody`. **P1 — dead-code fragmentation.** +- This is not intentional dual nav. It is a stalled IA refactor: an earlier design used a static bottom bar with Home/Profile destinations; the current design replaced it with a floating action-style pill that omits Home and Profile and adds Butty. The artifacts of the previous IA still live in the tree. +- Symptom: the Translate tab uses `Icons.g_translate` in `FloatingTabNav` (`floating_tab_nav.dart:201, 240`) but the bottom-nav design called the tab "Scan"; a developer reading the two files cannot tell which is canon. New contributors may add features against the wrong nav. + +### Guest-mode boundary correctness +Walk-through of a non-signed-in user: +1. Splash → since `prefs.hasDownloadedModels == false` on a fresh install, redirect to `/model-setup` (`app_router.dart:60-64`); user can skip; router lands them on `/login`. +2. From `/login`, the user can also reach `/home` because `routeHome` is on the guest-allow list (`app_router.dart:33`). The `LoginButton` is shown in `AppHeader` (`app_header.dart:151-155`). +3. On `/home`, all four tabs in `FloatingTabNav` are tappable (no auth check inside `_HomeBody`): + - **Scan tab** — guest can scan, view detections, copy, share. The "Save reading" action calls `scanHistoryNotifierProvider.addResult()` (`scan_tab.dart:1243-1250`); inside that notifier (`scan_history_provider.dart:79-93`) a missing `userId` is silently treated as "guest" and Supabase sync is skipped. SQLite save still succeeds. **OK, no error.** + - **Translate tab** — guest can use Online Gemma (`translate_screen.dart` invokes `translate_text_controller` with `mode = cloud` by default). Translation history save runs the same null-userId guard in `translation_history_provider.dart`. **OK.** + - **Learn tab** — `LearnTab` (`learn_tab.dart:1-33`) pushes `/learn/lesson/:id`, `/learn/gallery`, `/learn/quiz`; all three are guest-allowed. **OK.** + - **Butty tab** — `ButtyChatScreen` has *no* user-id check in `butty_chat_controller.dart` (grep returns zero matches). Guest can chat. Memory writes go to local SQLite. **OK at runtime, but Butty's "Open Butty Memory" links to `/butty-data` (auth-required) — that path is reachable from Settings (`activity_section.dart:79`), which is open to guests; tapping it as a guest causes a router redirect to `/login`. P2 — silent dead-end for guests.** +4. `AppHeader` profile button (`app_header.dart:157`) only renders `ProfileButton` for authenticated users; guests see `LoginButton`. **Correct.** +5. **However** the `/settings` screen is open to guests. Once there, the `ActivitySection` (`activity_section.dart:53-79`) lists "Learning Progress", "Scan History", "Translation History", "Butty Memory" — all four of those routes are auth-required (router redirects to `/login`). A guest tapping any of them is bounced to login without any explanation. **P1 — visible tile, hidden auth wall.** +6. `SettingsHeader` (`widgets/settings/settings_header.dart`) does a hard `context.go(routeHome)` on close (line 162). For guests this is fine; for an authenticated user who landed on `/settings` from a tab inside `/home`, this collapses the back-stack to a hard re-mount of `HomeScreen`. **P2.** + +### Back-stack & deep-link behavior +- The 4 in-tab screens are siblings inside a `PageView` in `_HomeBody` (`home_screen.dart:125-134`). Switching tabs does *not* push a route — the URL stays at `/home`. The `?tab=learn` query param is *consumed* once on `didChangeDependencies` (`home_screen.dart:38-54`) but never *written back* when the user switches tabs in-app. So: + - Deep-link `/home?tab=butty` works on cold start. + - In-app tab switches do not update the URL. If the user backgrounds the app or shares the URL, the tab state is lost. **P2 — deep-link is one-way.** +- Lesson → back: `LessonStageScreen` is pushed via `context.push` (`learn_tab.dart:14`), so OS back / `LearnRouteBackButton` pops to `/home` and the `_HomeBody` keeps the `learn` tab selected because the `PageController` state survives. **Correct.** +- `learning_route_back.dart:6-16` has the right fallback: if `Navigator.canPop()` is false (cold-launched directly into the lesson via deep link), it `context.go('/home?tab=learn')`. Good. +- Next-lesson uses `pushReplacement` (`lesson_stage_screen.dart:87`) — replaces the current lesson on the stack so a chain of lessons collapses to a single back-pop. **Correct.** +- `context.go` vs `context.push` inconsistencies: + - `settings_header.dart:162` uses `context.go(routeHome)` (resets stack). For a user who arrived at `/settings` via `context.push` from `ProfileButton` (`profile_button.dart:13`), the natural back action would be `Navigator.pop`. Using `go` instead loses any nested route state above `/home`. **P2.** + - `create_account_button.dart:19` uses `context.go(routeSignUp)`. From a settings push, this also wipes the stack. Minor. + - `app_header.dart:154` uses `context.go(routeLogin)` for the header's Login button when guest. Correct because we want to *replace* the current navigation, not stack a login on top of home. +- `ProfileButton.onTap` → `context.push(routeSettings)` (`profile_button.dart:13`) stacks settings on top of home. Back works. Good. + +### Visual language consistency across tabs +Comparing the five primary surfaces: + +| Surface | Surface color source | Header source | Accent color | Custom fonts | Decorative motion | +|---|---|---|---|---|---| +| Scan (`scan_tab.dart`) | `cs.surfaceContainerHigh` and ad-hoc `Color(0x40000000)` shadows (e.g. `scan_tab.dart:489, 698, 830, 852, 883, 959, 1269`) | None (overlay UI) | `cs.primary`, plus hard-coded `Color(0xFF0E1425)` (`scan_tab.dart:830`) and `Color(0x4D7AAAFF)` (`scan_tab.dart:852`) for the shutter | Baybayin inline `fontFamily: 'Baybayin Simple TAWBID'` (`scan_tab.dart:1350`) instead of `KudlitTheme.baybayinDisplay` (`kudlit_theme.dart:327`) | Camera flash, status chip fade | +| Translate (`translate_screen.dart` + `widgets/translate/`) | `cs.surface` (`translate_screen.dart:118`) | `TranslateHeader` (toggle bar) | `cs.primary` and one-off mic glow `Color(0x66FF5040)` (`mic_button.dart:26`) and the export theme palette `Space/Violet/Teal/Ember/Sakura/Parchment` (`export_sheet.dart:31-36`) — six hard-coded colorways with no design-system tie-in | Baybayin inline (`filled_output.dart:39`, `translate_sketchpad_mode_panel.dart:184`, `export_sheet.dart:337`) | None notable | +| Learn — `LearnTab` → `LearnHomeBody` | `cs.surface` (`learn_tab.dart:23`) | None on tab itself | `cs.primary`, `cs.primaryContainer`, `cs.tertiaryContainer` for streak | Baybayin inline in `glyph_item.dart:23` | None — pure list. **Despite MEMORY.md describing a "Philippine Sea ocean theme with glassmorphism cards, drifting bubbles, wave animations" — that theme is not in `LearnHomeBody` or `LearnTab`.** Ocean theme is only on `LearningProgressScreen` (which is *behind an auth wall*) and `SettingsHeader`. **P1 — documented design system not implemented in the surface the user actually sees.** | +| Learn → Learning Progress (`learning_progress_screen.dart`) | `_oceanDeep / _oceanTeal / _oceanCyan / _oceanFoam` (lines 108-111), all hex literals not in `KudlitColors` | Custom `SliverAppBar` with `_OceanWaveClipper` and `_Bubble`s (`learning_progress_screen.dart:159, 173-192, 302`) | The ocean palette **plus** success `Color(0xFF46B986)` and warning `Color(0xFFF5A623)` hard-coded (`learning_progress_screen.dart:616, 632, 660, 674, 722, 734, 752, 754`) — none in the token file | None | Wave clipper, drifting bubbles | +| Butty (`butty_chat_screen.dart` + `widgets/butty_chat/`) | `cs.surface` (`butty_chat_screen.dart:119`) | `ButtyHeader` with hard-coded `Color(0xFF46B986)` for the online dot (`butty_header.dart:81`, `online_dot.dart:13`) — not from `KudlitColors.success400` (`kudlit_colors.dart:27`) which is the *exact same value* | `cs.primary` and `cs.surfaceContainer` mostly. Cleanest tokenization of the five surfaces. | Baybayin inline (`baybayin_chat_renderer.dart:161`), `monospace` for pre/code blocks (`baybayin_chat_renderer.dart:82`) | Typing indicator | +| Profile (orphan `ProfileTab` — see findings) / Settings | `Theme.of(context).colorScheme.surface` (`profile_tab.dart:18`); Settings uses its own ocean palette (`settings_header.dart:16-19`) re-declared as private constants | `SettingsHeader` shares ocean palette with `learning_progress_screen.dart` — duplicated hex literals across files | `_deep/_teal/_cyan/_foam` ocean palette | None | Same ocean motif | + +**Observations:** +1. The ocean palette `0xFF0A4D68 / 0xFF088395 / 0xFF05BFDB / 0xFFBBE1FA` is declared three times: `learning_progress_screen.dart:108-111`, `settings_header.dart:16-19`, and `profile_hero_avatar.dart:19-20`. Identical hex values, three different `static const` declarations. None of them are in `KudlitColors`. **P1.** +2. The success green `0xFF46B986` is declared in `KudlitColors.success400` (`kudlit_colors.dart:27`) but inline-duplicated in `butty_header.dart:81`, `online_dot.dart:13`, `feedback_card.dart:12`, and seven sites in `learning_progress_screen.dart`. **P1 — token exists, it just isn't used.** +3. The amber/warning `Color(0xFFF5A623)` (`learning_progress_screen.dart:616, 674, 722`) does not exist in the token file at all — the closest token is `KudlitColors.yellow200 = 0xFFFFD8B8`, which is a different hue. The Learning Progress screen invented its own warning color. **P2.** +4. The Baybayin font is declared via `KudlitTheme.baybayinDisplay()` (`kudlit_theme.dart:327-332`) but every consumer reaches past the helper and writes `fontFamily: 'Baybayin Simple TAWBID'` directly (22 sites — see grep output in Methods). If the font asset name ever changes, those 22 sites all break. **P1.** +5. `KudlitColors.background` is `blue900 = 0xFFE9EEFF` (`kudlit_colors.dart:6, 32`) — a *light blue*. The theme then sets `scaffoldBackgroundColor: KudlitColors.background` (`kudlit_theme.dart:83`) so the app's default surface is a faint blue. But every tab body forces `cs.surface` (paper white) via `DecoratedBox`/`ColoredBox` (e.g. `learn_tab.dart:23`, `butty_chat_screen.dart:119`, `translate_screen.dart:118`). The scaffold background is never visible; the token is effectively dead. **P2.** +6. The four tabs are stylistically incoherent: Scan is a dark/photographic surface with translucent chrome; Translate is a paper card with workspace toggle; Learn is a flat list; Butty is a chat surface with header pill. Only Settings/Learning Progress carry the ocean motif. The "two-app feel" critique is accurate — the app does feel like four prototypes glued together via the floating pill. **P1.** + +### Token vs ad-hoc styling divergences +Sorted by impact (full list, not exhaustive): + +| Site | Ad-hoc value | Should be | +|---|---|---| +| `settings_header.dart:16-19` | private `_deep/_teal/_cyan/_foam` constants | `KudlitColors.ocean*` (new tokens) | +| `learning_progress_screen.dart:108-111` | same private ocean constants, duplicated | same | +| `widgets/settings/profile_hero_avatar.dart:19-20, 57` | private ocean constants + `Color(0xFFE6F4FA)` | same | +| `learning_progress_screen.dart:433, 632, 660, 752, 754` | `Color(0xFF46B986)` literal | `KudlitColors.success400` | +| `butty_header.dart:81`, `online_dot.dart:13`, `feedback_card.dart:12` | same green literal | `KudlitColors.success400` | +| `learning_progress_screen.dart:616, 674, 722` | `Color(0xFFF5A623)` literal | new `KudlitColors.warning400` token | +| `scan_tab.dart:830, 852` | `Color(0xFF0E1425)`, `Color(0x4D7AAAFF)` | `cs.scrim`, `cs.primary.withAlpha(0x4D)` | +| `welcome_banner.dart:27, 44, 102, 125`, `home_topbar.dart:26`, `home_tool_card.dart:40, 112`, `lesson_preview_card.dart:40`, `lesson_detail_card.dart:46, 110, 163` | ad-hoc shadow / surface ARGB literals | `cs.shadow` (defined in `kudlit_theme.dart:29`) and `cs.onSurface.withAlpha(...)` | +| `widgets/translate/export_sheet.dart:31-36` | six hard-coded gradient palettes | dedicated `ExportPaletteTokens` | +| `widgets/translate/mic_button.dart:26` | `Color(0x66FF5040)` mic glow | `cs.error.withAlpha(...)` | +| 22 sites listed in Methods | `fontFamily: 'Baybayin Simple TAWBID'` | `KudlitTheme.baybayinDisplay(context)` | +| `widgets/home/home_topbar.dart`, `widgets/home/home_tool_card.dart`, `welcome_banner.dart`, `home_tab.dart` | entire `HomeTab` IA | **delete** (dead code from the earlier 5-tab IA — see dual-nav finding) | + +## Top Recommendations (nav/IA-local, severity-ordered) + +| # | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---| +| 1 | P0 | S | Gate `/admin/stroke-recorder` at the router with a role check (`is_admin` flag from `profile_summary`), not only at the Settings tile. Today any authenticated user who knows the URL reaches it. | `app_router.dart:163-167`; entry-tile-only gate at `widgets/settings/admin_section.dart:63` | +| 2 | P0 | S | Delete `AppBottomNav` (`app_bottom_nav.dart`), `HomeTab` (`home_tab.dart`), `ProfileTab` (`profile_tab.dart`), `WelcomeBanner`, `HomeTopbar`, `HomeToolCard`, `LessonPreviewCard` if confirmed orphaned. Either delete or wire the bottom-nav IA back in — but the current state, where two contradictory 4-tab navs co-exist in source, will mislead every new contributor. | `grep -rn AppBottomNav lib` returns only its own declaration; same for `HomeTab`, `ProfileTab` | +| 3 | P1 | M | Add `KudlitColors.oceanDeep/oceanTeal/oceanCyan/oceanFoam`, `KudlitColors.warning400`, and ensure `success400` is used everywhere green is rendered. Remove the three duplicate private ocean palettes. | `settings_header.dart:16-19`, `learning_progress_screen.dart:108-111`, `profile_hero_avatar.dart:19-20`; success-green inline sites listed above | +| 4 | P1 | S | Replace every `fontFamily: 'Baybayin Simple TAWBID'` literal with `KudlitTheme.baybayinDisplay(context)` (or a new `baybayinInline(context, size: ...)` helper). 22 call sites today. | grep listed in Methods | +| 5 | P1 | M | Either bring the ocean/glassmorphism theme to the Learn tab body (matching MEMORY.md's documented design) or update MEMORY.md and the auditor docs to reflect that the ocean theme is scoped to Learning Progress + Settings. Today the user-facing Learn tab does not match its design spec. | `learn_home_body.dart:138-179` is a flat list; ocean theme lives only in `learning_progress_screen.dart` (behind auth) and `settings_header.dart` | +| 6 | P1 | S | Either hide the "Learning Progress / Scan History / Translation History / Butty Memory" tiles in `ActivitySection` for guests, or change the router so those routes are guest-readable (showing the local SQLite slice). Today a guest taps a visible tile and is bounced to `/login` with no copy explaining why. | `widgets/settings/activity_section.dart:53-79`; router redirect at `app_router.dart:99-103` | +| 7 | P1 | M | Decide whether `/terms` and `/privacy-policy` are auth routes or open routes and pick one. Today they live in `isOnAuthRoute` (`app_router.dart:96-97`) but are never gated. Move them to a separate "publicRoutes" predicate to clarify intent. | `app_router.dart:91-104` | +| 8 | P2 | S | Persist tab selection back to the URL: write `tab=scan/translate/learn/butty` on `_onTabSelected` (`home_screen.dart:56-64`) via `context.replace`. Today `?tab=` is read-only. | `home_screen.dart:38-54` reads but never writes | +| 9 | P2 | S | Drop the dead `routeLesson = '/learn/lesson'` entry from `isGuestAccessibleRoute` (`app_router.dart:37`). The path is never registered as a no-`:id` route. | `app_router.dart:149` only registers `${routeLesson}/:id` | +| 10 | P2 | S | Replace the `context.go(routeHome)` in `settings_header.dart:162` with `context.pop()` (with a `canPop` guard fallback to `go`). Today closing settings resets the home stack. | `widgets/settings/settings_header.dart:162` | +| 11 | P2 | S | Remove `Color(0xFF0E1425)`/`Color(0x4D7AAAFF)` literals from `scan_tab.dart:830, 852`; use `cs.scrim` and `cs.primary.withAlpha(...)`. | `scan_tab.dart:830, 852` | + +## Methods +- Files read: + - `lib/app/router/app_router.dart` + - `lib/app/constants.dart` + - `lib/features/auth/presentation/screens/home_screen.dart` + - `lib/features/home/presentation/widgets/floating_tab_nav.dart` + - `lib/features/home/presentation/widgets/app_bottom_nav.dart` + - `lib/features/home/presentation/screens/scan_tab.dart` + - `lib/features/home/presentation/screens/translate_screen.dart` + - `lib/features/home/presentation/screens/learn_tab.dart` + - `lib/features/home/presentation/screens/learn_home_body.dart` + - `lib/features/home/presentation/screens/butty_chat_screen.dart` + - `lib/features/home/presentation/screens/profile_tab.dart` + - `lib/features/home/presentation/screens/settings_screen.dart` + - `lib/features/home/presentation/screens/home_tab.dart` + - `lib/features/home/presentation/screens/learning_progress_screen.dart` (selected sections via grep) + - `lib/features/home/presentation/widgets/settings/settings_list.dart` + - `lib/features/home/presentation/widgets/settings/settings_header.dart` (selected lines) + - `lib/features/home/presentation/widgets/app_header/app_header.dart` + - `lib/features/home/presentation/widgets/app_header/profile_button.dart` + - `lib/features/scanner/presentation/providers/scan_history_provider.dart` + - `lib/features/learning/presentation/widgets/learning_route_back.dart` + - `lib/features/learning/presentation/screens/lesson_stage_screen.dart` + - `lib/core/design_system/kudlit_colors.dart` + - `lib/core/design_system/kudlit_theme.dart` +- Targeted greps: + - `grep -rn "FloatingTabNav\|AppBottomNav" lib --include="*.dart"` + - `grep -rn "ProfileTab\b\|HomeTab\b" lib --include="*.dart"` + - `grep -rn "Color(0xFF\|Color(0x" lib/features/home/presentation --include="*.dart"` + - `grep -rn "fontFamily:" lib --include="*.dart"` → 22 sites for `Baybayin Simple TAWBID`, 3 for `monospace` + - `grep -rn "context\.go\|context\.push" lib/features/home/presentation --include="*.dart"` + - `grep -rn "isGuestAccessibleRoute" lib --include="*.dart"` + - `grep -rn "tab=" lib --include="*.dart"` +- Prior audits reconciled: + - `docs/kudlit_design_and_setup.md` — does not mention the dual-nav, the orphan `HomeTab`/`ProfileTab`, the admin role gap, or the duplicated ocean palette. All four findings here are new. + - `docs/design-improvement-evidence-pack.md` — does not mention `FloatingTabNav` vs `AppBottomNav` fragmentation. + - `docs/jam_the_dev_review_notes.md` and `docs/jam-updates.md` — no `FloatingTabNav`, `AppBottomNav`, "two nav", "dual nav" matches. The earlier review cycle did not catch the IA fragmentation. + - `MEMORY.md` (auto-memory) entry `project_learn_ocean_theme` describes an ocean theme on the Learn page that does not exist on the current `LearnTab`/`LearnHomeBody` surfaces — flagged as P1 #5 above. diff --git a/docs/audit/2026-05-14/99_top10_improvements.md b/docs/audit/2026-05-14/99_top10_improvements.md new file mode 100644 index 0000000..0c2fc22 --- /dev/null +++ b/docs/audit/2026-05-14/99_top10_improvements.md @@ -0,0 +1,38 @@ +# Top 10 Prioritized Improvements — Kudlit Audit 2026-05-14 + +**Synthesizer:** Plan agent · **Sources:** 7 lane reports in this directory + +## Method +- Pulled the P0 and highest-impact P1 items from each lane. +- Sorted by severity (P0→P1→P2) then effort (S→M→L). +- Every row has Evidence (`file_path:line`) drawn from the source lane file. + +## Top 10 + +| # | Lane | Area | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---|---|---| +| 1 | Security | OTP brute-force defense | P0 | S | Implement client-side OTP resend cooldown (30–60s) and a 5-attempt verify lockout with backoff; remove the dead `_resendCooldown=0` field and dead cooldown branch. | `lib/features/auth/presentation/screens/phone_otp_screen.dart:46,95,134-162` (from `05_security_privacy.md`) | +| 2 | Security | Password recovery deep link | P0 | S | Wire `AuthChangeEvent.passwordRecovery` to a dedicated reset screen that forces `updateUser(password:)`; stop reusing `/auth/reset` for both OAuth callback and recovery. | `lib/app/router/app_router.dart:138-142`; `lib/features/auth/data/datasources/supabase_auth_datasource.dart:179` (from `05_security_privacy.md`) | +| 3 | Nav/IA | Admin route exposure | P0 | S | Gate `/admin/stroke-recorder` at the router with a role check (`is_admin` from `profile_summary`), not just at the Settings tile. | `lib/app/router/app_router.dart:163-167`; `lib/features/home/presentation/widgets/settings/admin_section.dart:63` (from `07_nav_ia_visual.md`) | +| 4 | Accessibility | WCAG AA contrast — TextField hints | P0 | S | Raise TextField hint alpha from `withAlpha(110)` to `withAlpha(160)` (≈4.5:1 on light surface); darken `subtleForeground` token so 12pt `bodySmall` clears AA. | `lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart:47-50`; `lib/features/home/presentation/widgets/translate/text_input_box.dart:45-48`; `lib/core/design_system/kudlit_colors.dart:24` (from `06_accessibility.md`) | +| 5 | UX | Auth welcome credibility | P0 | S | Delete the "Authentication is UI-only for now." caption from the welcome card — it contradicts the working Supabase backend at the most fragile point in the funnel. | `lib/features/auth/presentation/screens/auth_welcome_screen.dart:62` (from `01_ux_screens.md`) | +| 6 | Multiplatform | Web password reset redirect | P0 | S | Provide explicit `redirectTo: '${Uri.base.origin}/auth/reset'` for password reset on web (currently `null`) and register an in-app `/auth/reset` handler. | `lib/features/auth/data/datasources/supabase_auth_datasource.dart:179`,`:85-87` (from `02_multiplatform.md`) | +| 7 | Security | Bundled cloud API key | P0 | M | Move Gemini API calls behind a server proxy (Supabase Edge Function); remove `GEMINI_API_KEY` from the client bundle so it cannot be extracted from the APK/web bundle. | `lib/features/translator/presentation/providers/translator_providers.dart:54-55`; `lib/features/translator/data/datasources/cloud_gemma_datasource.dart:47` (from `05_security_privacy.md`) | +| 8 | Performance | YOLO inference leak | P0 | M | Stop YOLO inference when ScanTab is off-screen — PageView keeps it alive. Either gate `ScannerCamera` mount on `_activeTab == AppTab.scan`, or call `detector.pauseInference()` on tab change. | `lib/features/auth/presentation/screens/home_screen.dart:122-145`; `lib/features/scanner/data/datasources/yolo_baybayin_detector.dart:155-159` (from `04_performance_offline.md`) | +| 9 | Multiplatform | Web data layer crash | P0 | M | Add web-stub or `sqflite_common_ffi_web` adapters for every `sqflite` datasource so chat history, scan history, lesson progress and profile cache do not crash on web; currently no `kIsWeb` guard around construction. | `lib/features/translator/data/datasources/sqlite_*.dart`; `lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart`; `lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart`; `lib/features/home/data/datasources/local_profile_management_datasource.dart` (from `02_multiplatform.md`) | +| 10 | Architecture | Scanner failure surface | P0 | L | Add `Either<Failure, T>` to the scanner domain — wrap detect/capture/torch/switch/pause/resume in typed failures and add a `scanner/domain/usecases/` directory; today every error in the highest-failure-rate feature bubbles as a raw exception. | `lib/features/scanner/domain/repositories/baybayin_detector.dart:16-32` (from `03_architecture.md`) | + +## Why these 10 +Every item is a P0 from its source lane and together they span all seven lanes — Security (2), Multiplatform (2), plus one each from UX, Nav/IA, Accessibility, Performance, and Architecture. The ordering is severity-then-effort: six S-effort wins precede three M-effort fixes, and the single L-effort refactor closes the list. Deferred-but-tempting P0s — consolidating Login/Welcome (UX, M), failed-write reaper (Perf, M), deleting orphan nav widgets (Nav/IA, S), reduced-motion helper (A11y, M), guarding `initializeFlutterGemma` on web (Multiplatform, M), and the presentation→data import refactor (Architecture, L) — were held back because they either duplicate the lane coverage already chosen here or depend on one of the Top 10 landing first. + +## Companion Sections +- Item 1 → `05_security_privacy.md` § "Phone OTP rate limit / lockout" +- Item 2 → `05_security_privacy.md` § "Password reset deep-link safety" +- Item 3 → `07_nav_ia_visual.md` § "Routing red flags" +- Item 4 → `06_accessibility.md` § "Contrast (WCAG AA)" +- Item 5 → `01_ux_screens.md` § `auth_welcome_screen.dart` +- Item 6 → `02_multiplatform.md` § "Auth deep links" +- Item 7 → `05_security_privacy.md` § "Secrets / env handling" +- Item 8 → `04_performance_offline.md` § "Inference cadence & camera lifecycle" +- Item 9 → `02_multiplatform.md` § "Platform idiom mismatches" +- Item 10 → `03_architecture.md` § "Error handling (Either<Failure, T>) coverage" diff --git a/docs/audit/2026-05-14/EXECUTIVE_SUMMARY.md b/docs/audit/2026-05-14/EXECUTIVE_SUMMARY.md new file mode 100644 index 0000000..4a32d38 --- /dev/null +++ b/docs/audit/2026-05-14/EXECUTIVE_SUMMARY.md @@ -0,0 +1,23 @@ +# Kudlit — Executive Summary (Audit 2026-05-14) + +## Overall readiness + +**`needs-polish`** — Kudlit's product surface and offline architecture are genuinely strong, but a small number of P0 issues block a confident public release. None are intractable; nine of the Top 10 are S/M-effort and can be cleared in a focused sprint. The single L-effort item (scanner domain `Either<Failure, T>` refactor) is structural and can be sequenced after the rest. + +## Top 3 strengths + +1. **Butty chat memory architecture is sound and verified.** Two-layer split (episodic chat history + semantic memory facts with a `normalized` UNIQUE index), 20-turn sliding window, and "Start fresh" that preserves memory all behave as designed. See `04_performance_offline.md` § Butty chat memory. +2. **Design tokens are real, the theme is wired through `MaterialApp.router`, and shared shells (`KudlitAuthShell`, `HomeTopbar`, `FloatingTabNav`) are reused consistently across auth and home.** Light/dark variants are both present. Many of the visual fragmentation issues called out by Lane 7 are inline-duplication of *existing* tokens, not missing tokens — i.e., the fix is mechanical. +3. **Clean Architecture's domain boundary is held cleanly.** Lane 3 found zero `package:flutter` imports inside `domain/`, single-quote and trailing-comma compliance is solid, and there are no `_buildXxxWidget()` private UI helpers — the codebase already enforces the harder rules in `CLAUDE.md`. + +## Top 5 risks + +1. **`GEMINI_API_KEY` is shipped in the client bundle** (`translator_providers.dart:54-55`). Anyone can extract it from the APK or web bundle. Must move behind a server proxy before any public release. +2. **YOLO inference runs forever.** `HomeScreen` mounts all four tabs in a PageView (`home_screen.dart:122-145`); the native YOLO model keeps detecting even when the user is in Translate/Learn/Butty. Battery, heat, and a recent "pause on result" commit (7f28abc) only papered over the dispatch — not the model. +3. **Web is silently broken in the data layer.** Every `sqflite` datasource is constructed without a `kIsWeb` guard; the first cache read on web will throw `MissingPluginException`. Compounding this, password reset on web passes `redirectTo: null`, so flow correctness depends on Supabase Site URL configuration that is invisible to the code. +4. **Auth is one mis-step from a take-over.** Password recovery deep link establishes a Supabase session but has no `AuthChangeEvent.passwordRecovery` handler and no forced-password-update screen — if a recovery email leaks, the link is effectively a "log in as me." Combined with no client-side OTP cooldown (`phone_otp_screen.dart:46` has a dead `_resendCooldown = 0`), the auth surface is the weakest part of the app. +5. **Admin route is reachable by any signed-in user.** `/admin/stroke-recorder` is only hidden in Settings; the router has no role guard (`app_router.dart:163-167`). A typed URL bypasses the entire affordance. + +## One-paragraph verdict + +Kudlit is a well-shaped, opinionated app that has clearly been built with care — the offline-first memory architecture, the unified design system, and the held-firm Clean Architecture boundary on `domain/` are above-average for a Flutter project of this size. The risks blocking ship cluster narrowly in three areas: an exposed cloud key, a leaking inference loop, and a soft auth surface (password reset handler, OTP throttling, admin route guard). Fix the Top 10 in `99_top10_improvements.md` — six are ≤1-day changes, three are 1–3 days, one is the larger scanner-domain refactor — and Kudlit moves from "needs-polish" to "ship-ready" inside a single focused sprint. Defer the broader presentation→data refactor (24 sites) to a follow-up release; it is real debt but not blocking. diff --git a/docs/audit/2026-05-14/FIX_SUMMARY.md b/docs/audit/2026-05-14/FIX_SUMMARY.md new file mode 100644 index 0000000..995328a --- /dev/null +++ b/docs/audit/2026-05-14/FIX_SUMMARY.md @@ -0,0 +1,81 @@ +# Top 10 P0 Fixes — Implementation Summary (2026-05-14) + +All ten audit P0s shipped via 6 parallel/sequential fix lanes. `flutter analyze` clean. + +## Orchestration + +- **Batch 1 — 5 lanes in parallel** (A, B, C, D, E): no file overlap. +- **Batch 2 — 1 lane sequential** (F): refactors interfaces Batch 1's perf fix uses. +- **Integration cleanup**: 4 small follow-up edits to resolve `flutter analyze` issues across lane boundaries. + +## What landed + +### Lane A — Auth + Router (items 1, 2, 3, 6) +- **Item 1 — OTP cooldown + lockout.** `phone_otp_screen.dart`: 30 s resend cooldown via `Timer.periodic`, 5-attempt verify lockout with 60 s backoff, dead `_resendCooldown=0` removed. Auto-submit on 6-digit entry now gated by lockout. +- **Item 2 — Password recovery handler.** `router_listenable.dart` now subscribes to `Supabase.instance.client.auth.onAuthStateChange` and flips `passwordRecoveryPending` on `AuthChangeEvent.passwordRecovery`. New route `/reset-password` with a real `ResetPasswordScreen` that calls `supabase.auth.updateUser(UserAttributes(password:))` and signs out on success. OAuth callback path on `/auth/reset` left intact and clearly separated. +- **Item 3 — Admin route gate.** `app_router.dart` redirect now reads `currentUserRoleProvider` via `RouterListenable.roleState` and bounces non-admins from `/admin/stroke-recorder` to `/home` (loading/error states also deny). +- **Item 6 — Web reset redirectTo.** `supabase_auth_datasource.dart` web branch now passes `redirectTo: '${Uri.base.origin}/auth/reset'` instead of `null`. Native deep-link path untouched. + +### Lane B — UX + A11y (items 4, 5) +- **Item 4 — WCAG AA contrast.** Hint alpha bumped from `withAlpha(100/110)` to `withAlpha(160)` in `chat_input_bar.dart:49` and `text_input_box.dart:47`. `kudlit_colors.dart:24` `grey300 / subtleForeground` darkened `#6C738E` → `#4A5068` (~6.53:1 on `blue900`). +- **Item 5 — Welcome credibility.** Deleted "Authentication is UI-only for now." caption at `auth_welcome_screen.dart:61-66`. No replacement added. + +### Lane C — YOLO pause off-tab (item 8) +- **Pattern B in-place pause** wired into `home_screen.dart`. New `_syncScannerInference({previous, next})` helper calls `detector.pauseInference()` when leaving Scan and `resumeInference()` when entering. Hooked from `_onTabSelected` and `didChangeDependencies` so both user taps and deep-link routing flip inference correctly. `kIsWeb` guard matches the existing pattern in `scan_tab_controller.dart`. +- **Why this actually halts the native model** (vs commit 7f28abc which only gated the dispatch): `YoloBaybayinDetector.pauseInference()` calls `_controller.stop()` on the `YOLOViewController`, halting the native YOLO pipeline. `resumeInference()` calls `_controller.restartCamera()`. + +### Lane D — sqflite web stubs (item 9) +- 6 datasources now factory-switch on `kIsWeb`: + - **Option A (in-memory)**: `sqlite_chat_datasource`, `sqlite_chat_memory_datasource` (dedupe via normalized set), `sqlite_scan_history_datasource`, `sqlite_translation_history_datasource`, `sqlite_lesson_progress_datasource`. + - **Option B (silent no-op + debugPrint once)**: `local_profile_management_datasource` — `ProfileManagementRepositoryImpl` already cache-first with Supabase fallback, so always-miss + silent writes route via remote. +- Native sqflite code path untouched; no caller modifications. +- Caveat: web data is session-scoped (reload = blank). Authenticated users get cross-session persistence via Supabase sync as before. + +### Lane E — Gemini proxy (item 7) +- **New `supabase/functions/gemini-proxy/index.ts`** — Deno Edge Function. Validates Supabase JWT via service-role `supabase.auth.getUser(jwt)`, rate-limits per user (10 req/60s, in-memory; TODO note for durable counter), forwards body to Google AI Studio Gemini, supports streaming + non-streaming. +- **`cloud_gemma_datasource.dart` refactor** — default constructor now takes `SupabaseClient` and routes via `supabase.functions.invoke('gemini-proxy', ...)`. Test constructor `withAi(Genkit)` preserved for the 16 existing tests. +- **Client cleanup** — removed `dotenv.env['GEMINI_API_KEY']` from `translator_providers.dart`. Updated `.env.example`. +- **README at `supabase/functions/gemini-proxy/README.md`** with deploy commands. +- **DEPLOY COMMANDS the user must run before cloud Gemma works:** + ```bash + supabase secrets set GEMINI_API_KEY=<actual-key> + supabase functions deploy gemini-proxy + ``` +- **Also:** rotate the leaked Gemini key once the proxy is live (it appears in the committed `.env`). + +### Lane F — Scanner Either refactor (item 10) +- **New `lib/features/scanner/domain/failures/scanner_failures.dart`** — `ScannerFailures.init/inference/capture/cameraControl/webUnsupported` factories + `ScannerFailureKind` enum + `scannerFailureKindOf(Failure)` helper. Failures emitted as tagged `Failure.unknown(message: '<TOKEN>: <msg>')` to avoid modifying the sealed core `Failure` class. +- **New use cases** under `lib/features/scanner/domain/usecases/`: `detect_baybayin`, `capture_frame`, `toggle_torch`, `switch_camera`, `pause_scanner`, `resume_scanner`. All extend the shared `UseCase<TResult, Params>` base. +- **Interface refactored.** `baybayinDetector.dart` methods now return `Future<Either<Failure, T>>`. Live `detections` stream and `dispose` unchanged. +- **All 3 implementations updated** to wrap success in `Right(...)` and exceptions in typed `Left(...)`: `yolo_baybayin_detector.dart`, `web_baybayin_detector_stub.dart`, `web_tflite_baybayin_detector.dart`. Web stubs return `Left(webUnsupported)` for torch/switch. +- **All callers migrated** — `scan_tab_controller.dart` (`.fold` everywhere; flash optimistic state revert on failure), `home_screen.dart` (Lane C helper now folds + `debugPrint` on failure without surfacing UI), `scanner_camera.dart` (web capture flow folds). +- **Tests updated** — `yolo_baybayin_detector_test.dart` and `scan_tab_controller_test.dart` updated to the new Either contract. + +### Integration cleanup (post-batch) +Resolved 6 errors + 2 warnings from `flutter analyze`: +- `router_listenable.dart` — hid `AuthUser` from `supabase_flutter` to resolve ambiguity with the local domain entity (cascaded to `app_router.dart:51`). +- `local_profile_management_datasource.dart` — made `_WebProfileManagementDatasource extends SqfliteProfileManagementDatasource` so the factory return type is valid. +- `cloud_gemma_datasource.dart` — added missing `package:genkit_google_genai/genkit_google_genai.dart` import for `GeminiOptions` / `googleAI`; removed dead `?? 0` on non-nullable `response.status`. + +## Final state + +``` +$ flutter analyze +No issues found! (ran in 4.3s) +``` + +10/10 P0s fixed. 0 analyzer issues. + +## What still needs human action + +1. **Deploy the Gemini proxy Edge Function** and set `GEMINI_API_KEY` in Supabase secrets (commands above). Cloud Gemma is non-functional until this is done. +2. **Rotate the leaked Gemini key** that appears in the committed `.env`. +3. **Run `dart run build_runner build --delete-conflicting-outputs`** if the Riverpod codegen needs regeneration for any of the touched providers. +4. **Manual smoke test** of: + - Phone OTP flow with intentional failures to confirm lockout/cooldown UX. + - Password recovery deep link from a real reset email. + - Admin route as non-admin to confirm redirect. + - Web build (`flutter build web`) — confirms sqflite stubs hold up at runtime. + - Scanner end-to-end on Android to confirm pause/resume around tab switches actually halts native inference. + +Nothing was committed. diff --git a/docs/audit/runtime_log_audit_2026-05-17.md b/docs/audit/runtime_log_audit_2026-05-17.md new file mode 100644 index 0000000..a7d0b2b --- /dev/null +++ b/docs/audit/runtime_log_audit_2026-05-17.md @@ -0,0 +1,341 @@ +# Runtime Log Audit — 2026-05-17 + +**Source:** `flutter run -d android` debug session on device `22101320G` (PID 22361) +**Scope:** Issues observable from the run log only. Severities reflect user/runtime impact, not code aesthetics. + +--- + +## Summary + +| # | Issue | Severity | Status | +|---|-------|----------|--------| +| 1 | Unhandled exception: disposed `Ref` in `ProfileSummaryNotifier.refresh` | **P0 — Crash** | ✅ Fixed | +| 2 | Local Gemma inference never works; every request silently falls back to cloud | **P1 — Functional regression** | ✅ Fixed | +| 3 | 33 dependencies behind latest compatible/major versions | **P3 — Maintenance** | Deferred (isolated change) | +| 4 | IME show/hide thrash + viewport-metrics spam | **P2 — Performance/UX** | ✅ Fixed | +| 5 | `FlutterRenderer: Width is zero. 0,0` at startup | **P4 — Cosmetic** | No action (benign) | + +> **Fix log (2026-05-17):** #1, #2, and #4 resolved in this branch. +> `flutter analyze` clean on all touched files. #4's root cause was confirmed +> independent of #1 (a second runtime log showed local inference succeeding and +> no provider crash, but the IME thrash persisting). See "Resolution" notes +> under each issue. + +--- + +## 1. P0 — Unhandled exception: `Ref` used after dispose + +### Log evidence + +``` +E/flutter: [ERROR:...] Unhandled Exception: Cannot use the Ref of +profileSummaryNotifierProvider after it has been disposed. +#0 Ref._throwIfInvalidUsage (package:riverpod/.../ref.dart:236:7) +#1 AnyNotifier.state= (package:riverpod/.../notifier_provider.dart:91:9) +#2 ProfileSummaryNotifier.refresh + (.../home/presentation/providers/profile_management_provider.dart:96:5) +<asynchronous suspension> +``` + +This fired **at least twice** in the session and is an *unhandled* exception — it +is not caught anywhere and will surface as a red error / crash in profile-stat +refresh paths (after a completed lesson, scan, or translation). + +### Root cause + +`profile_management_provider.dart` `ProfileSummaryNotifier.refresh()`: + +```dart +Future<void> refresh() async { + final String? userId = ref.read(profileManagementDatasourceProvider) + .getCurrentUserId(); + if (userId != null) { + try { + await ref.read(localProfileManagementDatasourceProvider) + .clearCachedSummary(userId: userId); // <-- async gap #1 + } catch (_) {} + } + state = const AsyncLoading<...>(); // line 96 + final Option<ProfileSummary> summary = await _fetchSummary(); // gap #2 + state = AsyncValue<...>.data(summary); // line 98 +} +``` + +The provider is disposed (rebuilt/invalidated) during one of the `await` gaps — +consistent with the IME/rebuild churn seen in issue #4 — and the subsequent +`state =` assignment throws because the `Ref` is no longer valid. The notifier +never checks `ref.mounted` after its async suspensions, exactly the failure mode +the Riverpod error message describes. + +### Recommended fix + +Guard every post-`await` `state` write with a mounted check: + +```dart +Future<void> refresh() async { + // ... clearCachedSummary ... + if (!ref.mounted) return; + state = const AsyncLoading<Option<ProfileSummary>>(); + final summary = await _fetchSummary(); + if (!ref.mounted) return; + state = AsyncValue.data(summary); +} +``` + +Apply the same audit to the other mutating methods in this notifier +(`updateDisplayName`, and any sibling notifiers that `await` then assign +`state`). Secondary: the `catch (_) {}` on `clearCachedSummary` silently +swallows cache-clear failures — at minimum log it. + +### Resolution (2026-05-17) + +Added `if (!ref.mounted) return;` guards after **every** async suspension that +precedes a `state` write, in all four affected methods that had the same latent +defect: + +- `ProfileSummaryNotifier.refresh` (the crashing path) +- `ProfileSummaryNotifier.updateDisplayName` +- `ProfileSummaryNotifier.updateAvatar` +- `ProfilePreferencesNotifier.updatePreferences` + +The unhandled exception can no longer occur: if the provider is disposed during +any in-flight `await`, the method returns instead of touching `state`. + +--- + +## 2. P1 — Local Gemma inference is dead; 100% silent cloud fallback + +### Log evidence (repeats on every translate/explain action) + +``` +[Gemma] generateResponse route=local-preferred | messages=1 +[Gemma] local inference starting +[Gemma][local] generate called | history=1 | hasSystemInstruction=true +[Gemma][local] generate error: Bad state: No active inference model set. + Use FlutterGemma.installModel() first. +#0 FlutterGemma.getActiveModel (package:flutter_gemma/.../flutter_gemma.dart:244:7) +#1 LocalGemmaDatasource.generate (.../local_gemma_datasource.dart:171:43) +[Gemma] local inference failed -> falling back to cloud +[Gemma] cloud fallback starting +``` + +### Root cause + +`local_gemma_datasource.dart:171`: + +```dart +_activeModel ??= await FlutterGemma.getActiveModel(); +``` + +`getActiveModel()` requires a model previously installed via +`FlutterGemma.installModel()`. In this session no model was ever +downloaded/installed, so `getActiveModel()` throws `Bad state: No active +inference model set` on **every** request. `_localWithCloudFallback` in +`ai_inference_repository_impl.dart` correctly catches it and falls back to +cloud, so the feature *works* — but: + +- The app's offline-first / on-device Gemma value proposition is **completely + inert** on this device. +- Every single AI action pays a wasted local-attempt round trip plus a full + stack-trace log before the cloud call begins — added latency and log noise on + the hot path. + +### Assessment + +This is **expected behavior when no local model is installed**, so it is not a +code defect per se. It is flagged P1 because: + +1. There is no signal that anything is wrong — the user silently never gets + on-device inference, and there is no UI prompt to download the model. +2. The fallback is attempted *per message* rather than being short-circuited + after the first `No active inference model` failure (cache the "local + unavailable" state for the session to skip the redundant attempt + stack + dump). + +### Real root cause (confirmed) + +This was **not** "no model installed" — it is a genuine activation regression. +flutter_gemma's *active model* is process-scoped and is lost on every app +restart, while the downloaded model **file** persists on disk. +`AiInferenceNotifier._resolveInitialState` reported `AiReady(local)` purely from +a file-exists check (`isLocalModelInstalled`) and **never reactivated** the +model into the native engine. The only code path that reactivates is +`probeReadiness()` → `_reactivateInstalledModel()`, which fires only as a side +effect of `localModelReadinessProvider` (a UI status-banner provider). In the +logged session the user triggered Butty / translate `explain` / `checkInput` +before that banner probe ran, so `getActiveModel()` threw on every request and +the offline model — though fully downloaded — was never used. + +Butty's offline setup confirmed this: `butty_model_mode_selector.dart` reads +`localModelReadinessProvider.future`, i.e. the mode selector is what +incidentally reactivates the model. Inference must not depend on a widget +having been built. + +### Resolution (2026-05-17) + +Made `LocalGemmaDatasource` self-healing instead of depending on a UI probe: + +- Added `_knownModel` + `rememberModel(model)` — a no-native-work setter so the + datasource always knows the installed model. +- `AiInferenceNotifier._resolveInitialState` now calls `rememberModel(active)` + the moment it confirms the model file is installed (before any banner). +- `probeReadiness()` and `download()` also record `_knownModel`. +- Added `_reactivateIfNeeded()`: when the engine reports no active model and the + file is installed, it reactivates via the existing + `_reactivateInstalledModel()` before `getActiveModel()`. It is invoked from + both `generate()` (text) and `analyzeImage()` (scanner vision). + +Net effect: the first inference after an app restart reactivates the +already-downloaded model on demand and runs **on-device**, instead of silently +falling back to cloud on every message. If no model is genuinely installed, +the guard is a no-op and the existing cloud fallback still applies (no crash, +no behavior change). + +--- + +## 3. P3 — 33 outdated dependencies + +``` +33 packages have newer versions incompatible with dependency constraints. +``` + +Notable, including a **major** bump: + +| Package | Current | Available | Note | +|---|---|---|---| +| `xml` | 6.6.1 | 7.0.1 | **major** — review changelog before bumping | +| `json_serializable` | 6.11.4 | 6.14.0 | codegen | +| `json_annotation` | 4.9.0 | 4.12.0 | codegen | +| `mockito` | 5.6.4 | 5.6.5 | test only | +| `test` / `test_api` / `test_core` | 1.30.0 / 0.7.10 / 0.6.16 | 1.31.1 / 0.7.12 / 0.6.18 | test toolchain | +| `ultralytics_yolo` | 0.3.1 | 0.3.4 | **scanner-critical** — review for detection fixes | +| `google_cloud_*` | 0.4.0 | 0.5.2 | API clients | +| `matcher`, `meta`, `vector_math`, `win32`, `gtk`, etc. | — | — | transitive/platform | + +Most are constrained transitively. **Action:** run `flutter pub outdated`, +prioritize `ultralytics_yolo` (scanner accuracy) and the `test`/`mockito` +toolchain (CI parity); treat `xml` major and `json_serializable` as a separate, +tested change since they affect codegen output. + +--- + +## 4. P3 — IME focus thrash and viewport-metrics spam + +### Log evidence + +Hundreds of consecutive: + +``` +D/FlutterJNI: Sending viewport metrics to the engine. +``` + +interleaved with a repeating soft-keyboard cycle: + +``` +ImeTracker: onRequestShow ... SHOW_SOFT_INPUT +ImeTracker: onCancelled at PHASE_CLIENT_ANIMATION_CANCEL +ImeTracker: onRequestHide ... HIDE_SOFT_INPUT +ImeTracker: onHidden +``` + +### Assessment + +The keyboard is being requested and immediately cancelled/hidden in tight +loops, and the engine is receiving an unusually high volume of viewport-metric +updates. This points to a **rebuild loop on the translate screen** — likely a +`TextField`/`FocusNode` fighting `autofocus` or a provider rebuilding on every +frame. This is plausibly the *same* churn that disposes +`profileSummaryNotifierProvider` mid-`await` in issue #1, so the two may share a +root cause. + +### Real root cause (confirmed via second log) + +A second runtime log showed on-device inference completing cleanly with **no** +provider crash, yet the IME thrash persisted — so #4 is **independent** of #1. + +In `translate_screen.dart`, `keyboardOpen` is derived from +`view.viewInsets.bottom`, which animates frame-by-frame as the soft keyboard +slides in/out. `keyboardOpen` gates two structural changes: + +1. The `constrainedKeyboardLayout` branch returns a **bare panel root**, while + the normal path returns a full `Column` — a completely different ancestor + chain. +2. The `TranslateHeader` and `TranslateModelStatusBanner` are conditionally + added/removed, shifting the body's index inside the `Column`. + +Both re-parent the deeply-nested `_InputField`, **disposing its `FocusNode`** +mid-edit. That fires `HIDE_SOFT_INPUT_BY_INSETS_API`; focus is then +re-requested, the keyboard re-shows, the next animation frame flips +`keyboardOpen` again, and the cycle repeats — the observed +`onRequestShow → onCancelled → onRequestHide → onHidden` loop and the +unbounded "Sending viewport metrics to the engine" stream. + +`preserveFocusedPortraitInput` was meant to prevent this but depends on +`_textInputFocused`, which is set **asynchronously** by the focus listener — so +on the first keyboard-animation frame it is still `false`, the constrained +branch fires, the field re-mounts, and the loop starts before the flag can +ever flip true. + +### Resolution (2026-05-17) + +- Added a screen-owned `GlobalKey` (`_textInputFieldKey`) threaded through + `TranslateTextModePanel` → `_BottomInputArea` → `_InputField`. Flutter now + **migrates** the same `Element`/`State` (and its `TextEditingController` + + `FocusNode`) across every layout branch instead of re-mounting it, so focus — + and the keyboard — survive the layout switches entirely. +- Made `preserveFocusedPortraitInput` **eager**: in portrait text mode the + keyboard can only be open because the field is focused, so the layout locks + immediately rather than waiting a frame for the async focus flag. This + removes the per-frame structural churn that drove the viewport-metric spam. + +Net effect: the keyboard stays up while editing; the show/hide loop and the +sustained viewport-metric flood are eliminated. Severity raised P3 → P2 since +it caused a continuous main-thread relayout storm during normal typing. + +#### Second cause (found in third log, also fixed) + +A third runtime log — captured with the on-device model working — showed the +layout loop gone (hundreds of cycles → ~2) but a short keyboard burst +**starting exactly at `[Gemma] local inference completed`**. Cause: +`translate_text_mode_panel.dart` set the input field +`enabled: inputEnabled && !state.aiBusy`. While the AI streamed, `aiBusy` was +true, so the `TextField` was **disabled** — which drops focus and force-closes +the keyboard (`HIDE_SOFT_INPUT`). On completion `aiBusy` flipped false, the +field re-enabled, focus restored, and the keyboard reopened, then bounced once +as insets settled. + +Fix: the input field now stays `enabled: inputEnabled` regardless of `aiBusy`. +Re-entry is still prevented by `_TextActionsRow` (buttons disable on `busy`) +and the controller guard (`if (!state.hasInput || state.aiBusy) return;`), so +keeping the field editable during inference is safe and removes the +post-inference IME burst. + +> **Verification note:** GlobalKey / widget-`State` identity changes are **not** +> applied by Flutter hot reload (`r`). A hot **restart** (`R`) or a full +> rebuild/reinstall is required to validate these fixes. + +--- + +## 5. P4 — `FlutterRenderer: Width is zero. 0,0` at startup + +``` +D/FlutterRenderer: Width is zero. 0,0 +``` + +Logged a few times during cold start before the first real frame. This is a +transient pre-layout state and is almost always benign; flagged only for +completeness. No action unless a blank/zero-size first frame is observed +visually. + +--- + +## Recommended priority order + +1. **Fix #1** (P0 crash) — add `ref.mounted` guards in `ProfileSummaryNotifier`; + smallest, highest-impact change. +2. **Investigate #4** (rebuild/IME loop) — likely shares a root cause with #1 + and is on the actively-modified branch. +3. **Decide on #2** — confirm whether a local Gemma model should ship/install; + at minimum short-circuit the per-message fallback and add a download hint. +4. **Schedule #3** — dependency bump as an isolated, tested change. +5. **Note #5** — no action; monitor. diff --git a/docs/audit/sketchpad_target_glyph_keyboard_audit_2026-05-17.md b/docs/audit/sketchpad_target_glyph_keyboard_audit_2026-05-17.md new file mode 100644 index 0000000..fdeec13 --- /dev/null +++ b/docs/audit/sketchpad_target_glyph_keyboard_audit_2026-05-17.md @@ -0,0 +1,157 @@ +# Sketchpad Target-Glyph Keyboard Audit — 2026-05-17 + +**Reported symptom:** In Translate → Sketchpad, tapping the "Target glyph" +field opens the keyboard and it immediately closes, in a loop. A target +glyph can never be entered, so "Get Feedback" stays disabled and the +feature is unusable. + +**Status:** Root cause confirmed by code trace + runtime-log signature. +No fix applied (audit only). + +--- + +## Summary + +| # | Issue | Severity | Status | +|---|-------|----------|--------| +| 1 | Sketchpad target field: IME show/hide thrash — keyboard opens then closes in a loop, target glyph cannot be entered | **P1 — Functional block** | Root-caused, unfixed | +| 2 | Same re-mount wipes in-progress strokes and typed target every cycle | **P2 — Data loss** | Same root cause | +| 3 | Sketchpad panel overflows when keyboard is open (fixed-height canvas + `Spacer`, no scroll/inset) | **P3 — Layout** | Latent, same path | + +This is the **same defect class** as issue #4 in +`runtime_log_audit_2026-05-17.md` (IME thrash on the text field). That fix +was applied to the **text** panel only and never extended to the +**sketchpad** panel. + +--- + +## Log evidence + +Identical signature to the previously-fixed text-mode thrash: + +``` +ImeTracker: onRequestShow ... reason SHOW_SOFT_INPUT +ImeTracker: onCancelled at PHASE_CLIENT_ANIMATION_CANCEL +ImeTracker: onRequestHide ... reason HIDE_SOFT_INPUT +ImeTracker: onRequestHide ... reason HIDE_SOFT_INPUT_BY_INSETS_API +ImeTracker: onHidden +``` + +repeating, interleaved with viewport-metric spam. + +## Root cause + +`translate_screen.dart` — the keyboard-preservation guard only ever +evaluates true for **text** mode: + +```dart +final bool textMode = pageState.mode == TranslateWorkspaceMode.text; +final bool preserveFocusedPortraitInput = + (textMode && portraitKeyboardOpen) || // false in sketchpad + (_textInputFocused && (...)); // _textInputFocused + // is set ONLY by the + // text panel's + // onInputFocusChanged +``` + +In sketchpad mode: + +- `textMode` is `false` → first clause false. +- `_textInputFocused` is fed exclusively by the text panel + (`onInputFocusChanged: _setTextInputFocused`). The sketchpad target + `TextField` (`translate_sketchpad_mode_panel.dart:239`) has **no + `FocusNode`** and reports focus to nobody → second clause false. + +So `preserveFocusedPortraitInput` is **permanently false** in sketchpad +mode. When the keyboard begins to open, `constrainedKeyboardLayout` +becomes true and the `LayoutBuilder` child changes widget type: + +- Not constrained: `Column( … Expanded( switch → sketchpadPanel() ) … )` +- Constrained: `switch → sketchpadPanel()` returned bare + +The widget at the `LayoutBuilder` child slot flips between `Column` and +`TranslateSketchpadModePanel`. Flutter cannot reuse the `Element`. Unlike +the text field — which received the `_textInputFieldKey` `GlobalKey` fix +so its `Element`/`State` migrates across the branch — the sketchpad panel +has **no `GlobalKey`**. So `_TranslateSketchpadModePanelState` is disposed +and recreated: + +1. Tap target field → focus → keyboard animates in → `keyboardOpen` true. +2. `preserveFocusedPortraitInput` false → `constrainedKeyboardLayout` true. +3. Branch flips `Column` → bare panel → `TranslateSketchpadModePanel` + State disposed, `TextField`'s internal `FocusNode` destroyed. +4. Focus lost → keyboard dismissed (`HIDE_SOFT_INPUT_BY_INSETS_API`). +5. `keyboardOpen` flips false → branch flips back → panel re-mounts. +6. Loop. User sees "opens and closes" and can never type a target. + +### Collateral (same root cause) + +`_targetController`, `_strokes`, and `_current` live in +`_TranslateSketchpadModePanelState`. Every re-mount **wipes the user's +drawing and typed target**, not only the keyboard. + +### Latent layout bug on the same path + +`_InlineCanvas` is a fixed `height: 300` followed by a `Spacer()` with no +scroll view and no keyboard-inset accommodation. Even if focus survived, +an open keyboard would overflow the sketchpad panel (RenderFlex overflow). + +## Recommended fix (mirror the text-mode fix) + +1. Add a screen-owned `GlobalKey` for the sketchpad panel; pass it as the + panel's `key` so Flutter migrates the same `Element`/`State` across the + layout-branch flip instead of re-mounting (preserves keyboard, strokes, + and typed target). +2. Extend `preserveFocusedPortraitInput` to cover sketchpad mode — either + add `((pageState.mode == sketchpad) && portraitKeyboardOpen)` or + generalize the eager clause to `portraitKeyboardOpen` regardless of + mode — so the layout locks on the first keyboard-animation frame. +3. Hardening: give the sketchpad target `TextField` a `FocusNode` that + reports through to `_setTextInputFocused` (parity with the text field), + and make the sketchpad body scrollable / inset-aware to remove the + overflow. + +> **Verification note:** `GlobalKey` / `State`-identity changes are **not** +> applied by Flutter hot reload (`r`). A hot **restart** (`R`) or full +> rebuild is required to validate. + +--- + +## Resolution (2026-05-17) — eliminate the keyboard surface + +Chosen over the GlobalKey patch because it removes the root cause for this +surface entirely rather than surviving it: the free-text target field was +**replaced with a tap-to-pick glyph selector**, so the sketchpad no longer +has any editable text and the keyboard never opens there. With no keyboard +animation, the `keyboardOpen`-driven layout-branch flip cannot fire from +the sketchpad, so the re-mount loop (issue #1) and the stroke/target wipe +(issue #2) are both unreachable in normal use. + +- `baybayin_target_glyphs.dart` — 17 base glyphs as static const data + (glyph + romanized label; label still feeds the AI prompt unchanged). +- `sketchpad_target_glyph_sheet.dart` — modal bottom-sheet grid picker. +- `sketchpad_target_glyph_button.dart` — bottom-bar trigger showing the + current selection; opens the sheet (no `TextField`/`FocusNode`). +- `translate_sketchpad_mode_panel.dart` — dropped `_targetController`, + its `dispose`, and the `didUpdateWidget` text sync; the `_BottomBar` + `TextField` is now `SketchpadTargetGlyphButton`. + +Secondary win: the picker accepts 3-letter labels like `nga`, which the +old `maxLength: 2` field could not even enter. + +**Residual (now unreachable, not fixed):** `_TranslateSketchpadModePanelState` +still has no `GlobalKey`, so a layout-branch flip from some *other* future +keyboard/short-height path would still re-mount and wipe in-progress +strokes. Out of scope here since the sketchpad has no remaining trigger; +worth a `GlobalKey` if an editable surface is ever reintroduced. + +> A word-builder variant was prototyped and then reverted by request — +> the single-glyph tap picker is the shipped resolution. + +**Verification status:** `flutter analyze` clean on all touched files. +The regression test (`translate_density_test.dart` → "sketchpad target +uses a tap picker with no keyboard surface") was written but **could not +be executed locally**: `flutter test` on this macOS host aborts before +any test runs due to an unrelated `flutter_gemma` native-asset relink +failure (`install_name_tool` on `libGemmaModelConstraintProvider.dylib`). +Behavioral verification pending on device / CI. diff --git a/docs/butty_chat_memory_ai_audit.md b/docs/butty_chat_memory_ai_audit.md new file mode 100644 index 0000000..55eb1e8 --- /dev/null +++ b/docs/butty_chat_memory_ai_audit.md @@ -0,0 +1,184 @@ +# Butty Chat & Memory — AI Setup Audit + +> Covers chat history persistence, memory extraction, and local vs cloud inference across all platforms. + +--- + +## Architecture at a Glance + +``` +User sends message + → ButtyChatController.send() + → read AiPreference (cloud | local) + → insert user msg → SQLite (native) / in-memory (web) + → fire-and-forget Supabase sync + → build system instruction (profile + 12 memory facts) + → AiInferenceRepository.generateResponse() + kIsWeb → always CloudGemmaDatasource (Gemini via Genkit) + AiPreference.cloud → CloudGemmaDatasource + AiPreference.local → LocalGemmaDatasource + ↳ on any error: transparent fallback to cloud + → stream tokens to UI + → insert assistant msg → SQLite / in-memory + → fire-and-forget Supabase sync + → MemoryExtractionService.extractIfDue() (every 4 user msgs) + → AI extracts facts → insertIfNew() → fire-and-forget Supabase +``` + +**Persistence by platform:** + +| Platform | Chat History | Memory Facts | AI Inference | +|----------|-------------|--------------|--------------| +| Android / iOS | SQLite → Supabase | SQLite → Supabase | Local Gemma OR Cloud Gemini | +| Web | In-memory → Supabase | In-memory → Supabase | Cloud only (forced) | + +--- + +## What's Working ✅ + +- **Offline-first** — SQLite is source of truth; Supabase is async mirror +- **kIsWeb guard** — forces cloud inference; swaps SQLite for `ChatHistoryWebStore` / `ChatMemoryWebStore` +- **Transparent local→cloud fallback** — `_localWithCloudFallback()` in repo catches any local model error and retries via cloud without interrupting the user +- **Cold-start restore** — empty local cache triggers Supabase fetch (last 100 msgs / 200 facts) on provider `build()` +- **Deduplication** — normalized unique index in both SQLite and Supabase prevents duplicate memory facts +- **Soft-fail sync** — all Supabase writes are `unawaited()` and non-blocking; network failures are logged only +- **Guest mode** — unauthenticated users work locally; Supabase calls silently no-op +- **Memory extraction throttle** — runs every 4 user messages, minimum 30-second interval, flushes on app pause + +--- + +## Identified Gaps ⚠️ + +### Gap 1 — Failed Supabase Syncs Are Never Retried +Messages / facts with `remoteId == null` (sync failed) stay local-only indefinitely. There is no retry queue. Next device never sees them. + +**Fix:** On provider `build()`, after cold-load, query SQLite for rows where `remote_id IS NULL` and re-attempt Supabase sync. +**Files:** `sqlite_chat_datasource.dart`, `chat_history_provider.dart`, `chat_memory_repository_impl.dart` + +--- + +### Gap 2 — Memory Extraction Uses Same AiPreference as Chat +If the user is in local mode and the model is unavailable, memory extraction silently skips. Facts are never distilled for that session. + +**Fix:** Memory extraction should always call cloud for its own inference request, independent of user preference. +**File:** `memory_extraction_service.dart` — override the preference resolver to force `AiPreference.cloud` for extraction-only calls. + +--- + +### Gap 3 — Web: Offline Pill is a False Affordance +The mode selector shows "Offline" as a tappable option on web even though local inference is always blocked by `kIsWeb`. Tapping it sets the preference to local but inference silently falls back to cloud anyway. + +**Fix:** When `kIsWeb`, disable the Offline pill with a tooltip: *"Offline model is not available on web."* +**File:** `butty_model_mode_selector.dart` + +--- + +### Gap 4 — Web: Blank State on Page Refresh +`ChatHistoryWebStore` and `ChatMemoryWebStore` are pure in-memory. Every page refresh starts empty. Supabase cold-load restores data on next provider `build()` but there is a visible blank window. + +**Mitigation (current):** Supabase restore runs automatically. +**Full fix:** Add `localStorage` / IndexedDB persistence layer for web. +**Files:** `chat_history_web_store.dart`, `chat_memory_web_store.dart` + +--- + +### Gap 5 — `imageBytes` Declared in Entity but Never Persisted (Low Priority) +`ChatMessage.imageBytes` field exists but has no SQLite column and no Supabase Storage upload path. + +**Fix:** Add `imageBytes BLOB` column in SQLite v3 migration + upload to Supabase Storage for cloud sync. Only needed when image messages are exposed in UI — leave for that feature branch. +**Files:** `sqlite_chat_datasource.dart`, `supabase_chat_datasource.dart`, `chat_message.dart` + +--- + +## Local vs Cloud — Setup Checklist + +### Online (Cloud) — Gemini via Genkit + +| Item | Where | Status | +|------|-------|--------| +| `GEMINI_API_KEY` set in `.env` | `translator_providers.dart` line 68 | Must confirm in prod | +| `kIsWeb` forces cloud | `ai_inference_repository_impl.dart` line 32 | ✅ | +| Guest users skip sync | `supabase_chat_datasource.dart` line 20 | ✅ | +| All network errors non-fatal | Supabase datasources | ✅ | + +**Action:** Confirm `GEMINI_API_KEY` is present in production environment variables. + +--- + +### Local (Offline) — flutter_gemma + +| Item | Where | Status | +|------|-------|--------| +| `probeReadiness()` before first use | `local_gemma_datasource.dart` | ✅ | +| Model missing → `AiLocalModelMissing` state | `ai_inference_provider.dart` | ✅ | +| Model missing → cloud fallback transparent | `ai_inference_repository_impl.dart` | ✅ | +| UI disables input when model unavailable | `butty_chat_screen.dart` | ✅ | +| Supabase `gemma_models` table has ≥1 row | Production DB | Must confirm | +| Download progress shown | `AiDownloading` state | ✅ | +| Stall detection (20 s Android) | `ai_inference_provider.dart` | ✅ | + +**Action:** Ensure `gemma_models` Supabase table has at least one row with a valid download URL for the production environment. + +--- + +## Key Files + +``` +lib/features/translator/ + data/datasources/ + ai_datasource.dart ← shared AiDatasource interface + local_gemma_datasource.dart ← flutter_gemma wrapper + cloud_gemma_datasource.dart ← Genkit / Gemini wrapper + sqlite_chat_datasource.dart ← native chat history (kIsWeb guard) + sqlite_chat_memory_datasource.dart ← native memory facts (kIsWeb guard) + chat_history_web_store.dart ← in-memory fallback (web) + chat_memory_web_store.dart ← in-memory fallback (web) + supabase_chat_datasource.dart ← cloud sync for chat messages + supabase_chat_memory_datasource.dart ← cloud sync for memory facts + data/repositories/ + ai_inference_repository_impl.dart ← local/cloud router + transparent fallback + chat_memory_repository_impl.dart ← cache-first + fire-and-forget sync + presentation/providers/ + translator_providers.dart ← DI wiring for all datasources + ai_inference_provider.dart ← state machine (download, readiness) + chat_history_provider.dart ← chat message state + chat_memory_provider.dart ← memory fact state + memory_extraction_service.dart ← background fact distillation + +lib/features/home/presentation/providers/ + app_preferences_provider.dart ← AiPreference enum + SharedPreferences + butty_chat_controller.dart ← send message, stream tokens, trigger extraction + +lib/features/home/presentation/screens/ + butty_chat_screen.dart ← UI + lifecycle hooks (flush on pause) + +lib/features/home/presentation/widgets/butty_chat/ + butty_model_mode_selector.dart ← Online / Offline toggle pills +``` + +--- + +## Manual QA Checklist + +### Cloud path (web) +- [ ] Open on Chrome, sign in, send a message → response streams +- [ ] Refresh page → messages restore from Supabase +- [ ] Send 4+ messages → facts appear in Supabase `chat_memory_facts` +- [ ] Offline pill is disabled / greyed with tooltip *(after Gap 3 fix)* + +### Cloud path (native, Online mode) +- [ ] Set preference to Online, send message → Gemini streams response +- [ ] Kill and reopen app → history intact from SQLite +- [ ] Go fully offline → send message → graceful error shown + +### Local path (native, Offline mode) +- [ ] Download a model from setup screen +- [ ] Set preference to Offline, send message → local model responds +- [ ] Turn off WiFi → send message → local model still responds ✓ +- [ ] Delete model file → send message → transparent cloud fallback (no error shown to user) + +### Memory extraction +- [ ] Send 4 messages with personal context ("I am learning BA characters") +- [ ] Check SQLite `chat_memory_facts` → at least one fact row exists +- [ ] Check Supabase `chat_memory_facts` → same fact synced with `remoteId` +- [ ] Send same context again → no duplicate facts created diff --git a/docs/kudlit_full_audit_prompt.md b/docs/kudlit_full_audit_prompt.md new file mode 100644 index 0000000..9bfabba --- /dev/null +++ b/docs/kudlit_full_audit_prompt.md @@ -0,0 +1,345 @@ +# Kudlit — Full-Project Multiplatform Audit Prompt (Multi-Agent Edition) + +> Hand this entire markdown file to a Claude Code session (or any agent host with parallel subagent support) pointed at the Kudlit repository. The orchestrating model will fan out to **parallel specialist subagents** — one per audit lane — and emit a **suite of focused markdown reports** (one per lane plus a synthesis index), not a single mega-document. Paste verbatim. Repo root is assumed to be `/Users/kuya/Documents/Gemma/kudlit-app` or the equivalent on the host machine. + +--- + +## 0. TL;DR for the Orchestrator + +1. You are the **lead auditor**. You do not write findings yourself — you **orchestrate specialist subagents** and **synthesize** their outputs. +2. Spawn **multiple subagents in parallel** (single message, multiple `Agent` tool calls). Each subagent owns one **lane** from §5. +3. Each subagent writes **its own markdown file** under `docs/audit/<YYYY-MM-DD>/`. You never paste their full output into your own messages — you only summarize. +4. After all lanes return, run a **synthesis pass** that produces `00_index.md`, `99_top10_improvements.md`, and `EXECUTIVE_SUMMARY.md`. +5. Use parallelism aggressively. The audit should complete in roughly the time of the slowest lane, not the sum. + +--- + +## 1. Role & Mission + +You are a **senior multiplatform product audit lead** with the combined lens of a UX/product designer, principal Flutter engineer (Clean Architecture · Riverpod codegen · GoRouter · platform channels), and on-device ML / offline-first specialist. + +Your mission: produce an **honest, prioritized, evidence-backed audit suite** of the Kudlit Flutter codebase across Android, iOS, and Web — delivered as a **set of focused markdown files** that engineers and designers can pick up independently. + +No filler. No balance-padding. Every claim cites `file_path:line`. You are writing for the product owner and the engineering lead. + +--- + +## 2. Project Snapshot + +**Kudlit** is a vision-based **Baybayin** (ancient Philippine script) translator and learning app. On-device **YOLO (TFLite)** for character recognition, **Gemma 4** (local via `flutter_gemma`, cloud fallback) for language. Targets Android, iOS, Web. **Mobile-first** UI/UX. + +Stack: Flutter · Riverpod (`@riverpod` codegen) · GoRouter · Supabase (auth + sync) · SQLite + Hive (offline cache) · `ultralytics_yolo` + TFLite · `flutter_gemma`. + +Conventions live in `CLAUDE.md` at the repo root — **respect them**. Flag deviations explicitly rather than rewriting style. + +--- + +## 3. Orchestration Model — multi-agent, parallel, skill-driven + +This prompt is designed to run inside an agentic host (e.g., Claude Code with the `Agent` tool, or any equivalent that supports parallel subagent spawning and Skills). The lead model orchestrates; specialist subagents do the reading and writing. + +### 3.1 Subagent types to use + +Map the work to the agent types the host exposes. With Claude Code these are the defaults; substitute equivalents on other hosts: + +| Subagent type | Used for | +|---|---| +| **Explore** | Read-only codebase reconnaissance — locate files, grep symbols, map call graphs. Cheap, fast, parallelizable. | +| **general-purpose / claude** | Lane auditors. Each owns one lane from §5, reads its files, and writes its lane markdown. | +| **Plan** | The synthesis pass — folds lane outputs into the Top-10 and Executive Summary. | +| **ui-ux-pro-max** (Skill) | Invoked by the UX lane auditor for design heuristics, palette/accessibility checks, and platform-idiom guidance. | +| **review** / **security-review** (Skill) | Invoked by the Architecture and Security lanes respectively. | + +### 3.2 Parallelism rules (HARD requirements) + +- **Reconnaissance fan-out:** in a **single message**, spawn **3 Explore subagents in parallel** (§4.1). Do not run them sequentially. +- **Lane fan-out:** in a **single message**, spawn **all 7 lane auditors in parallel** (§5). Do not stagger them. +- **No duplicate reads in the lead:** once a subagent is researching a file, the lead does not also read it. Trust the report. +- **Idempotent writes:** each subagent writes to its assigned filename in §6 — never to another lane's file. +- **No cross-talk:** subagents do not call each other. The lead is the only synchronization point. +- **Cite-or-omit:** if a subagent cannot find concrete evidence for a claim, it drops the claim. No hand-waving. + +### 3.3 Skills (Obra-style superskills) + +Where the host exposes Skills (e.g., Claude Code's `Skill` tool — `ui-ux-pro-max`, `review`, `security-review`, `simplify`, `claude-api`), the **lane auditor must invoke the relevant Skill before writing its report**. The Skill output is treated as a co-author and cited in the lane file's "Methods" footer. Example mappings: + +- UX lane → invoke `ui-ux-pro-max` (Flutter stack, mobile app project type, dark/light considerations). +- Architecture lane → invoke `review`. +- Security & Privacy lane → invoke `security-review`. +- Code-smell sweep → invoke `simplify`. +- Any Gemma API code touched → invoke `claude-api`. + +If a Skill is unavailable on the host, the lane proceeds without it and notes "Skill unavailable" in its Methods footer. + +--- + +## 4. Workflow + +### 4.1 Phase A — Reconnaissance (parallel, 3 Explore subagents in ONE message) + +Spawn these three Explore subagents in a single message. Each returns a structured inventory; the lead merges them into a working map. + +1. **Explore-A — Surface map.** List every screen under `lib/features/**/presentation/screens/`, every route in `lib/app/router/app_router.dart`, and the floating tab nav (`AppTab` enum). Return file paths only. +2. **Explore-B — Data & integration map.** Catalog every datasource under `lib/features/**/data/datasources/` (Supabase, SQLite, Hive, local/cloud Gemma, YOLO, TFLite web). Note `kIsWeb` branches and platform splits. +3. **Explore-C — Design system & shared chrome.** Map `lib/core/design_system/*`, shared widgets (`kudlit_auth_shell`, `kudlit_home_placeholder`, `kudlit_loading_indicator`, `floating_tab_nav`, app header), Riverpod providers used app-wide, and theming. + +The lead consolidates the three outputs into a single in-memory inventory. **The lead does not write files in Phase A.** + +### 4.2 Phase B — Lane audits (parallel, ALL lanes in ONE message) + +Spawn every lane in §5 in a single message. Each lane is a self-contained subagent that: + +1. Reads only the files in its lane's "Scope" list (plus prior audits relevant to it from §8). +2. Invokes its assigned Skill if available. +3. Writes its lane markdown to `docs/audit/<YYYY-MM-DD>/<NN>_<lane>.md` using the schema in §7. +4. Returns a ≤200-word summary to the lead, naming P0/P1 counts and the lane's single biggest risk. + +### 4.3 Phase C — Synthesis (Plan agent + lead) + +After all lanes return: + +1. Spawn one **Plan** subagent to draft `99_top10_improvements.md` by reading the lane files and producing a sorted Top-10 table. +2. The lead writes `00_index.md` (the navigation hub) and `EXECUTIVE_SUMMARY.md` (one page). +3. The lead verifies §9 acceptance criteria. If a lane file is missing or under-cited, re-spawn that lane only. + +--- + +## 5. The Seven Audit Lanes + +Each lane is owned by exactly one subagent. The subagent's prompt is the **Charter** verbatim — copy it into the `Agent` call's `prompt` field. + +### Lane 1 — UX/UI per-screen, mobile-first +- **Output file:** `docs/audit/<DATE>/01_ux_screens.md` +- **Skill:** `ui-ux-pro-max` (stack: Flutter, project: mobile app) +- **Scope:** every screen in §6.1. +- **Charter:** + > You are the UX lane auditor for Kudlit. Read each screen in the list, plus its immediate providers and extracted widgets. Invoke the `ui-ux-pro-max` Skill once at the start. For each screen, produce a per-screen block using the schema in §7.A. Evaluate: visual hierarchy, touch targets (≥44 px), thumb-zone reachability, copy quality, loading/empty/error/offline states, motion purpose, accessibility (contrast, semantics, font-scale), onboarding clarity. Cite `file_path:line` for every claim. Do not assess architecture or performance — that's other lanes. Write your report to `docs/audit/<DATE>/01_ux_screens.md`. Return ≤200-word summary. + +### Lane 2 — Multiplatform parity (Android / iOS / Web) +- **Output file:** `docs/audit/<DATE>/02_multiplatform.md` +- **Scope:** every `kIsWeb` branch, every web-specific datasource, every native-only capability. +- **Charter:** + > You are the Multiplatform Parity lane auditor. Grep the codebase for every `kIsWeb` branch and every file matching `web_*` or `*_web.dart`. Produce a Parity Matrix (feature × Android × iOS × Web) covering camera, torch, local Gemma, cloud Gemma, YOLO inference, file picker, deep links, OAuth, phone OTP, voice/speech, SQLite, Hive. Mark each cell ✅ full, ⚠ degraded, ❌ missing, and cite `file_path:line`. Call out responsive-layout failures, SafeArea/notch issues, keyboard handling, and platform-idiom mismatches (iOS HIG vs Material 3 vs Web). Output to `docs/audit/<DATE>/02_multiplatform.md` per §7.B. + +### Lane 3 — Architecture & code quality +- **Output file:** `docs/audit/<DATE>/03_architecture.md` +- **Skill:** `review` (plus `simplify` if available) +- **Scope:** Clean Architecture boundaries, Riverpod conventions, widget rules from `CLAUDE.md`. +- **Charter:** + > You are the Architecture lane auditor. Invoke the `review` Skill once at the start. Verify (a) `domain/` has zero Flutter imports, (b) `presentation/` never imports concrete repositories, (c) every provider uses `@riverpod` codegen, (d) `build()` is ≤40 lines everywhere, (e) no `_buildSomething()` private builders are used to decompose UI, (f) widgets contain no business logic. List every violation with `file_path:line`. Note test coverage gaps per feature. Output to `docs/audit/<DATE>/03_architecture.md` per §7.B. + +### Lane 4 — Performance, offline-first, integrations +- **Output file:** `docs/audit/<DATE>/04_performance_offline.md` +- **Scope:** model loaders, inference cadence, camera lifecycle, Supabase sync, SQLite cache-first patterns, Gemma fallback, chat memory two-layer. +- **Charter:** + > You are the Performance & Offline lane auditor. Trace: when each model loads, whether warm-up blocks the UI thread, inference cadence on `scan_tab`, camera open/pause/dispose semantics, Supabase write retry/optimistic patterns, SQLite cache-then-network in each repository, Gemma local↔cloud fallback boundaries, and the Butty chat memory sliding window + "Start fresh" behavior. Cite `file_path:line` for every observed pattern. Do NOT fabricate timings — describe risk qualitatively unless you have a measured number. Output to `docs/audit/<DATE>/04_performance_offline.md` per §7.B. + +### Lane 5 — Security & Privacy +- **Output file:** `docs/audit/<DATE>/05_security_privacy.md` +- **Skill:** `security-review` +- **Scope:** auth flows, token handling, deep-link safety, Supabase RLS assumptions, PII in logs, on-device vs cloud data flow. +- **Charter:** + > You are the Security & Privacy lane auditor. Invoke the `security-review` Skill once at the start. Audit: auth token storage, refresh handling, password reset deep-link validation, phone OTP rate-limiting assumptions, Google OAuth redirect URIs, Supabase RLS assumptions (note where the code assumes RLS without verifying), PII leakage in logs (`debugPrint`, `print`, `Logger`), and the data residency of Gemma cloud calls. Cite `file_path:line`. Output to `docs/audit/<DATE>/05_security_privacy.md` per §7.B. + +### Lane 6 — Accessibility +- **Output file:** `docs/audit/<DATE>/06_accessibility.md` +- **Skill:** `ui-ux-pro-max` (re-invoke with topic: accessibility) +- **Scope:** every interactive screen. +- **Charter:** + > You are the Accessibility lane auditor. Sample contrast on the design tokens in `lib/core/design_system/kudlit_colors.dart` and the major screens. Check semantic labels on icon buttons, touch target minimums, font-scale tolerance, keyboard/focus order on web, and any motion that ignores `MediaQuery.disableAnimations`. List every gap with `file_path:line`. Output to `docs/audit/<DATE>/06_accessibility.md` per §7.B. + +### Lane 7 — Navigation, IA & Visual Language +- **Output file:** `docs/audit/<DATE>/07_nav_ia_visual.md` +- **Scope:** `app_router.dart`, the 4-tab floating nav, guest-mode boundary, deep links, visual consistency across the ocean-themed Learn tab vs the rest. +- **Charter:** + > You are the Navigation, IA & Visual Language lane auditor. Map every route and its auth guard from `lib/app/router/app_router.dart`. Validate the 4-tab `AppTab` floating nav for back-stack behavior and guest-mode boundary correctness. Compare visual language across the Learn tab's ocean theme and the rest of the app — flag fragmentation. Cite `file_path:line`. Output to `docs/audit/<DATE>/07_nav_ia_visual.md` per §7.B. + +--- + +## 6. Screen & File Inventory the lanes must respect + +### 6.1 Screen inventory (the UX lane covers ALL of these) + +**Splash & setup** +- `lib/features/home/presentation/screens/splash_screen.dart` +- `lib/features/home/presentation/screens/model_setup_screen.dart` + +**Auth** +- `lib/features/auth/presentation/screens/auth_welcome_screen.dart` +- `lib/features/auth/presentation/screens/sign_in_screen.dart` +- `lib/features/auth/presentation/screens/login_screen.dart` +- `lib/features/auth/presentation/screens/sign_up_screen.dart` +- `lib/features/auth/presentation/screens/phone_sign_in_screen.dart` +- `lib/features/auth/presentation/screens/phone_otp_screen.dart` +- `lib/features/auth/presentation/screens/forgot_password_screen.dart` +- `lib/features/auth/presentation/screens/reset_password_screen.dart` +- `lib/features/auth/presentation/screens/terms_screen.dart` +- `lib/features/auth/presentation/screens/privacy_policy_screen.dart` + +**Home shell + tabs** +- `lib/features/auth/presentation/screens/home_screen.dart` +- `lib/features/home/presentation/screens/home_tab.dart` +- `lib/features/home/presentation/screens/scan_tab.dart` +- `lib/features/home/presentation/screens/translate_screen.dart` +- `lib/features/home/presentation/screens/learn_tab.dart` (+ `learn_home_body.dart`) +- `lib/features/home/presentation/screens/butty_chat_screen.dart` +- `lib/features/home/presentation/screens/profile_tab.dart` + +**Profile, history & internal** +- `lib/features/home/presentation/screens/settings_screen.dart` +- `lib/features/home/presentation/screens/translation_history_screen.dart` +- `lib/features/home/presentation/screens/learning_progress_screen.dart` +- `lib/features/home/presentation/screens/butty_data_screen.dart` +- `lib/features/scanner/presentation/screens/scan_history_screen.dart` + +**Learning depth** +- `lib/features/learning/presentation/screens/lesson_stage_screen.dart` +- `lib/features/learning/presentation/screens/character_gallery_screen.dart` +- `lib/features/learning/presentation/screens/quiz_screen.dart` + +**Admin / internal** +- `lib/features/admin/presentation/screens/stroke_recording_screen.dart` + +**Cross-cutting widgets** +- `lib/features/home/presentation/widgets/floating_tab_nav.dart` (`AppTab` enum) +- App header(s), `kudlit_auth_shell.dart`, `kudlit_home_placeholder.dart`, `kudlit_loading_indicator.dart` + +**Routing & design tokens** +- `lib/app/router/app_router.dart` +- `lib/core/design_system/kudlit_theme.dart` +- `lib/core/design_system/kudlit_colors.dart` + +--- + +## 7. Output Schemas — STRICT + +All lane files live under `docs/audit/<YYYY-MM-DD>/`. Use exactly these schemas. + +### 7.A Per-screen block (Lane 1 only) + +```markdown +### <relative/path/to/screen_file.dart> — <route or tab name> +- **Purpose:** one line. +- **Platforms reviewed:** Android · iOS · Web +- **Pros:** + - bullet (`file_path:line`) +- **Cons:** + - bullet (`file_path:line`) +- **Improvements:** + - **P0** — concrete change (`file_path:line`) + - **P1** — concrete change + - **P2** — concrete change +- **Multiplatform notes:** any web/native divergence specific to this screen. +``` + +### 7.B Lane file skeleton (Lanes 2–7 and the body of Lane 1) + +```markdown +# <NN> — <Lane Name> + +**Auditor:** <subagent type> · **Skill invoked:** <skill or "none"> · **Date:** <YYYY-MM-DD> + +## Summary +- P0 count: N +- P1 count: N +- P2 count: N +- Single biggest risk: <one line> + +## Findings +<lane-appropriate sections — see each lane's charter> + +## Top Recommendations (lane-local, severity-ordered) +| # | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---| +| 1 | P0 | S | ... | `file:line` | + +## Methods +- Files read: <list> +- Skills invoked: <list> +- Prior audits reconciled: <list from §8> +``` + +### 7.C Top-10 synthesis file + +`docs/audit/<DATE>/99_top10_improvements.md` — produced by the Plan agent. + +```markdown +# Top 10 Prioritized Improvements + +| # | Lane | Area | Severity | Effort | Recommendation | Evidence | +|---|---|---|---|---|---|---| +| 1 | UX | ... | P0 | S | ... | `file:line` | +``` + +Sorted by **severity ascending (P0 first), then effort ascending (S first)**. + +### 7.D Index + Executive Summary + +- `docs/audit/<DATE>/00_index.md` — table linking to every lane file, the Top-10, and the Executive Summary, with one-line hooks. +- `docs/audit/<DATE>/EXECUTIVE_SUMMARY.md` — one page. Top 3 strengths, top 5 risks, overall readiness (`ship-ready` | `needs-polish` | `needs-rework`), one-paragraph verdict. + +Severity legend: **P0** blocks ship / data loss / broken platform · **P1** degrades a core flow · **P2** polish. +Effort legend: **S** ≤1 day · **M** 1–3 days · **L** >3 days. + +--- + +## 8. Prior audits to reconcile (do not restate — extend) + +The lanes must read and reference these as prior art: + +- `system_audit.md`, `system_audit_next_steps.md` +- `backend_audit_2026.md`, `backend_audit_2026-05-05.md` +- `gemma_offline_model_loading_audit.md` +- `scanner_vision_model_audit.md` +- `butty_chat_memory_ai_audit.md`, `butty_chat_memory_and_sync_plan.md` +- `gemma_learning_architecture.md`, `gemma_learning_implementation_plan.md` +- `translate-page-audit.md`, `translate-page-implementation-plan.md` +- `profile_management_feature_plan.md`, `profile_management_remote_dev_comparison.md` +- `realtime_scan_aggregator_plan.md` +- `supabase_phone_otp_integration.md`, `supabase_phone_google_auth_plan.md` +- `auth_polish_updates.md`, `kudlit_design_and_setup.md` +- `design-improvement-evidence-pack.md`, `jam_the_dev_review_notes.md`, `jam-updates.md` + +Each lane lists in its **Methods** footer which of these it reconciled against. + +--- + +## 9. Ground Rules (all subagents + lead) + +1. **Read the actual code.** Never hallucinate paths, functions, or line numbers. +2. **Cite everything.** Every con / improvement bullet ends with `file_path:line`. If no evidence, drop the claim. +3. **Respect `CLAUDE.md`.** Flag deviations rather than recommending wholesale rewrites to a different style. +4. **No fabricated metrics.** No invented frame rates, bundle sizes, or memory numbers. Describe risk qualitatively when unmeasured. +5. **No vague advice.** Every "improvement" names (a) the screen/symbol, (b) the observed behavior, (c) the concrete change. +6. **No cross-talk between subagents.** Lead synchronizes; subagents don't call each other. +7. **One file per lane.** Never edit another lane's file. +8. **Idempotent re-runs.** Re-spawning a lane overwrites only its own file. + +--- + +## 10. Acceptance Criteria + +The audit suite is acceptable **only if all of the following are true**: + +- [ ] Directory `docs/audit/<YYYY-MM-DD>/` exists with all of: `00_index.md`, `01_ux_screens.md`, `02_multiplatform.md`, `03_architecture.md`, `04_performance_offline.md`, `05_security_privacy.md`, `06_accessibility.md`, `07_nav_ia_visual.md`, `99_top10_improvements.md`, `EXECUTIVE_SUMMARY.md`. +- [ ] `01_ux_screens.md` contains a per-screen block for **every** screen in §6.1. +- [ ] `02_multiplatform.md` contains a Parity Matrix listing every native-only or web-only capability found in the code. +- [ ] Every con and every improvement across every lane file cites at least one `file_path:line`. +- [ ] `99_top10_improvements.md` is sorted by severity then effort and every row has an Evidence column with a real `file:line`. +- [ ] `EXECUTIVE_SUMMARY.md` states a concrete readiness verdict. +- [ ] No fabricated paths, functions, or measurements appear anywhere. +- [ ] Prior audits in §8 are referenced (in each lane's Methods footer) rather than restated. + +If a criterion cannot be satisfied for a legitimate reason (e.g., a screen file does not exist), state so explicitly in the relevant file and continue — never silently drop a section. + +--- + +## 11. Begin + +Acknowledge in one line that you have read this prompt and `CLAUDE.md`. Then: + +1. Run **Phase A** by spawning the **3 Explore subagents in a single message** (§4.1). +2. After they return, run **Phase B** by spawning **all 7 lane auditors in a single message** (§4.2, §5). +3. After they return, run **Phase C** (§4.3) and verify §10. + +Do not write findings yourself outside of `00_index.md` and `EXECUTIVE_SUMMARY.md`. Trust your subagents; verify by reading the files they produced. diff --git a/docs/local_gemma_concurrency_audit_and_plan.md b/docs/local_gemma_concurrency_audit_and_plan.md new file mode 100644 index 0000000..c7bfc95 --- /dev/null +++ b/docs/local_gemma_concurrency_audit_and_plan.md @@ -0,0 +1,192 @@ +# Local Gemma Concurrency — Audit & Fix Plan + +Date: 2026-05-17 +Branch: `feat/audit-suite-and-p0-fixes` +Status: Plan — awaiting approval before implementation + +## 1. Symptom + +On device, "multiple local models run at the same time" and the phone lags +during/after using Baybayin AI features (scanner live evaluation, Butty chat, +translate, lessons). + +## 2. Root Cause (single, verified) + +`localGemmaDatasourceProvider` is `@Riverpod(keepAlive: true)` — there is +**exactly one** `LocalGemmaDatasource` for the whole app, owning **one** +native `InferenceModel _activeModel` and **one** `InferenceChat _chat`. + +That single object is driven by eight independent, concurrently-activatable +callers, none of which coordinate: + +| Caller | Entry | File | +|---|---|---| +| Butty chat | `generate` | `butty_chat_controller.dart:137` | +| Translate text | `generate` | `translate_text_controller.dart:348` | +| Scanner live eval | `analyzeImage` + `generate` + follow-up | `scanner_evaluation_provider.dart:90,100,136` | +| Translate sketchpad | `analyzeImage` (reads datasource directly) | `translate_sketchpad_controller.dart:106,122,153` | +| Learning lessons | `analyzeImage` + `generate` | `lesson_controller.dart:138,145` | +| Butty help sheet | `generateResponse` | `butty_help_sheet.dart:127` | +| Memory-extraction | `generateResponse`, `unawaited` background | `memory_extraction_service.dart:82` | +| Readiness / prewarm | `getActiveModel` | `local_gemma_datasource.dart` probe / `ensureModelLoaded` | + +`generate()` and `analyzeImage()` have **no concurrency control**. The only +serialized path is `probeReadiness` (`_probing` / `_pendingProbe`); that +discipline is absent everywhere else. + +When any two callers overlap (reachable cases: scanner auto-evaluates frame +N+1 while frame N runs; a new Butty/translate request or scanner open while +the background memory-extraction `generateResponse` is still running): + +1. **Concurrent native inference loops** share and clobber the single + `_chat` — one caller's `addQueryChunk` interleaves into another's stream. +2. **`analyzeImage` tears the model down mid-use**: it `close()`s `_chat`, + and if the active model is text-only it `close()`s `_activeModel` and + reloads a vision model via `getActiveModel(supportImage: true)` — while a + concurrent `generate()` still iterates the old `_activeModel!` / `_chat!`. + During that window **two native InferenceModel contexts are resident at + once** → the memory/CPU spike that lags the device. The `finally` then + nulls `_chat` out from under the other caller. +3. **Stale work is never cancelled.** Scanner's `_translationGeneration` + counter and stream consumers only discard late Dart values; the native + `generateChatResponseAsync()` keeps running to completion. Rapid frames / + repeated triggers pile up a backlog of running native inferences. + +**One-sentence root cause:** a single shared native model/session is driven +by many features with zero serialization or cancellation, so overlapping +calls run concurrent inferences and trigger model teardown-while-in-use, +leaving multiple native model contexts resident → the lag. + +## 3. Chosen Policy — Supersede + Queue + +- **Same-lane requests supersede.** A newer request in a lane cancels the + older in-flight/queued request in that same lane. Used for rapid, + self-replacing streams: scanner live-frame evaluation, sketchpad feedback. +- **Cross-lane requests queue (FIFO).** Distinct features (chat, translate, + lessons, background memory-extraction, readiness) run strictly + one-after-another — never concurrently, never cancelling each other. No + Butty reply is killed by a background task. +- **Invariant:** at most one native operation (generate / analyzeImage / + model load / close) touches the engine at any instant. + +Lanes: + +| Lane | Callers | Behaviour | +|---|---|---| +| `scan` | scanner translation + image analysis + follow-up | supersede previous `scan` | +| `sketch` | translate sketchpad | supersede previous `sketch` | +| `chat` | Butty, translate, lessons text, help sheet | queue | +| `system` | memory-extraction, readiness, prewarm | queue (lowest priority, never cancels others) | + +## 4. Design + +### 4.1 New unit — `InferenceGate` (pure Dart, isolated, TDD-able) + +`lib/features/translator/data/datasources/inference_gate.dart` + +Responsibilities: + +- **Serialize:** maintain a single `Future` tail; every `run()` awaits the + previous completion before starting. Guarantees the one-op-at-a-time + invariant. +- **Supersede:** `run({required String lane, ...})` — on enqueue, if a + pending or in-flight op shares `lane`, mark it cancelled before this one + proceeds. +- **Cancellation handle:** each op receives a `CancelSignal` (a checked + flag + `isCancelled` getter). Long-running streams poll it between tokens + and abort cooperatively (break loop → close session → return). + +Public surface (draft): + +```dart +class CancelSignal { + bool get isCancelled; + void throwIfCancelled(); // throws InferenceCancelled +} + +class InferenceGate { + Future<T> run<T>(String lane, Future<T> Function(CancelSignal) op); + Stream<T> runStream<T>(String lane, Stream<T> Function(CancelSignal) op); + void cancelLane(String lane); +} +``` + +What it does NOT do: it does not know about Gemma. It is a generic +serializer + lane-supersession primitive — that is why it is unit-testable +without the native engine. + +### 4.2 Wire into `LocalGemmaDatasource` + +- Hold one `InferenceGate _gate`. +- `generate()` / `analyzeImage()` become `_gate.runStream(lane, (sig) async* { ... })`. + Inside the loop: `if (sig.isCancelled) { break; }` each iteration; the + existing `finally` closes the per-op `InferenceChat`. +- `probeReadiness`, `ensureModelLoaded`, model `close()`/reload, and + `dispose()` route through `_gate.run('system', ...)` so a load can never + overlap an in-flight generate (kills root-cause #2). The current + `_probing` / `_pendingProbe` coalescing is replaced by the gate. +- `analyzeImage`'s model close/reload now executes only when it owns the + gate, so no other call holds `_activeModel` during teardown. +- Lane is a new optional parameter on `generate` / `analyzeImage` + (default `chat`); the `AiDatasource` interface gains the optional param. + +### 4.3 Caller changes + +- `scanner_evaluation_provider.dart`: pass lane `scan`. Each new `evaluate` + / `requestFollowUp` supersedes the prior `scan` op (replaces the + ineffective generation-counter approach for native cancellation; the + counter stays for Dart-side state correctness). +- `translate_sketchpad_controller.dart`: pass lane `sketch`; also stop + reading `localGemmaDatasourceProvider` directly — go through the + repository like every other caller (consistency; keeps lane routing in + one place). +- `memory_extraction_service.dart`: lane `system` (queued, never supersedes + foreground chat). +- Butty / translate / lessons / help sheet: default `chat` lane (queue). + +## 5. TDD Test Plan (Phase 4) + +`test/features/translator/data/datasources/inference_gate_test.dart` +(runs as pure Dart — unaffected by the `flutter_gemma` native-asset issue +that currently blocks `flutter test` on this macOS host): + +1. **Serialization:** two overlapping `run` ops never execute concurrently + (assert max in-flight == 1 via a shared counter). +2. **FIFO across lanes:** ops from different lanes complete in start order. +3. **Same-lane supersession:** enqueuing a second `scan` op cancels the + first; the first's `CancelSignal.isCancelled` becomes true and it stops + early. +4. **Cancelled op is observable:** a superseded streaming op stops emitting + after cancellation and releases the gate so the next op runs. +5. **System lane never cancels chat:** a `system` op does not supersede an + in-flight `chat` op. + +Each test written red-first, watched fail, then `InferenceGate` implemented +minimally to green. + +## 6. Affected Files + +- **New:** `lib/features/translator/data/datasources/inference_gate.dart` +- **New:** `test/features/translator/data/datasources/inference_gate_test.dart` +- `lib/features/translator/data/datasources/local_gemma_datasource.dart` +- `lib/features/translator/data/datasources/ai_datasource.dart` (lane param) +- `lib/features/translator/data/repositories/ai_inference_repository_impl.dart` (thread lane) +- `lib/features/scanner/presentation/providers/scanner_evaluation_provider.dart` +- `lib/features/home/presentation/providers/translate_sketchpad_controller.dart` +- `lib/features/translator/presentation/providers/memory_extraction_service.dart` + +## 7. Verification + +- TDD suite for `InferenceGate` green. +- `flutter analyze` clean. +- Manual device check: scanner live-scan + open Butty + trigger translate in + quick succession → only one inference active, no two-model memory spike, + no lag; Butty replies are never truncated by background memory-extraction. +- (`flutter test` full-suite still blocked by the upstream `flutter_gemma` + macOS prebuilt dylib relink failure — unrelated; note for CI/Linux.) + +## 8. Out of Scope + +- Replacing `flutter_gemma`, model quantization, or memory-budget tuning. +- The web `analyzeImage` `UnsupportedError` path (already correct: cloud + fallback). diff --git a/lib/app/constants.dart b/lib/app/constants.dart index 036ea3a..64133b0 100644 --- a/lib/app/constants.dart +++ b/lib/app/constants.dart @@ -11,6 +11,7 @@ class AppConstants { static const String routeHome = '/home'; static const String routeSettings = '/settings'; static const String routeAuthReset = '/auth/reset'; + static const String routeResetPassword = '/reset-password'; static const String routeLesson = '/learn/lesson'; static const String routeTerms = '/terms'; static const String routePrivacyPolicy = '/privacy-policy'; diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index b840e52..4f8556f 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -6,11 +6,13 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:kudlit_ph/app/constants.dart'; import 'package:kudlit_ph/app/router/router_listenable.dart'; +import 'package:kudlit_ph/core/auth/user_role.dart'; import 'package:kudlit_ph/features/auth/domain/entities/auth_user.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/forgot_password_screen.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/home_screen.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/login_screen.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/privacy_policy_screen.dart'; +import 'package:kudlit_ph/features/auth/presentation/screens/reset_password_screen.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/sign_up_screen.dart'; import 'package:kudlit_ph/features/auth/presentation/screens/terms_screen.dart'; import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; @@ -51,6 +53,16 @@ GoRouter appRouter(Ref ref) { final bool isAuthenticated = authState.hasValue && authState.value != null; + // Password recovery: a `passwordRecovery` event from Supabase establishes + // a session but the user has not chosen a new password yet. Force the + // dedicated reset-password screen until the flow completes. This must + // run before any other redirect so a cached session can't bounce the + // user to /home and skip the password update. + if (listenable.passwordRecoveryPending && + state.matchedLocation != AppConstants.routeResetPassword) { + return AppConstants.routeResetPassword; + } + // Splash: hold while loading, then route to correct destination. if (state.matchedLocation == AppConstants.routeSplash) { if (authState.isLoading || prefsState.isLoading) return null; @@ -88,6 +100,17 @@ GoRouter appRouter(Ref ref) { // Still loading auth on other routes — don't redirect. if (authState.isLoading) return null; + // Admin route gate: only allow users whose `profiles.role == admin`. + // Default-deny while the role is still loading or on error so a + // non-admin can't briefly slip through during the async resolve. + if (state.matchedLocation == AppConstants.routeAdminStrokeRecorder) { + if (!isAuthenticated) return AppConstants.routeLogin; + final AsyncValue<UserRole> roleState = listenable.roleState; + final bool isAdmin = + roleState.hasValue && (roleState.value?.isAdmin ?? false); + if (!isAdmin) return AppConstants.routeHome; + } + final bool isOnAuthRoute = state.matchedLocation == AppConstants.routeLogin || state.matchedLocation == AppConstants.routeSignUp || @@ -136,10 +159,18 @@ GoRouter appRouter(Ref ref) { const HomeScreen(), ), GoRoute( + // OAuth callback (Google) lands here; password-recovery sessions are + // intercepted by the top-level redirect above and forwarded to + // `/reset-password` before this builder is reached. path: AppConstants.routeAuthReset, builder: (BuildContext context, GoRouterState state) => const LoginScreen(), ), + GoRoute( + path: AppConstants.routeResetPassword, + builder: (BuildContext context, GoRouterState state) => + const ResetPasswordScreen(), + ), GoRoute( path: AppConstants.routeSettings, builder: (BuildContext context, GoRouterState state) => diff --git a/lib/app/router/router_listenable.dart b/lib/app/router/router_listenable.dart index 8b3d950..d30c5de 100644 --- a/lib/app/router/router_listenable.dart +++ b/lib/app/router/router_listenable.dart @@ -1,6 +1,11 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; +import 'package:kudlit_ph/core/auth/current_user_role_provider.dart'; +import 'package:kudlit_ph/core/auth/user_role.dart'; import 'package:kudlit_ph/features/auth/domain/entities/auth_user.dart'; import 'package:kudlit_ph/features/auth/presentation/providers/auth_notifier.dart'; import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; @@ -19,17 +24,55 @@ class RouterListenable extends ChangeNotifier { modelSetupSkippedProvider, (previous, next) => notifyListeners(), ); + _ref.listen<AsyncValue<UserRole>>( + currentUserRoleProvider, + (previous, next) => notifyListeners(), + ); + + // Listen for password-recovery deep-link events from Supabase. When the + // user opens the reset email, `gotrue` establishes a session and fires + // `AuthChangeEvent.passwordRecovery` — we flip the gate so the router + // forces navigation to the dedicated reset screen. + _authSub = Supabase.instance.client.auth.onAuthStateChange.listen( + (AuthState event) { + if (event.event == AuthChangeEvent.passwordRecovery) { + _passwordRecoveryPending = true; + notifyListeners(); + } + }, + ); } final Ref _ref; + StreamSubscription<AuthState>? _authSub; + bool _passwordRecoveryPending = false; AsyncValue<AuthUser?> get authState => _ref.read(authNotifierProvider); AsyncValue<AppPreferences> get prefsState => _ref.read(appPreferencesNotifierProvider); + AsyncValue<UserRole> get roleState => _ref.read(currentUserRoleProvider); /// True when the user tapped "Not now" this session. /// Resets to false on every cold launch — setup screen shows again next time. bool get sessionSkipped => _ref.read(modelSetupSkippedProvider); + + /// True between the moment a recovery deep link establishes a session and + /// the moment the user completes (or abandons) the password update. + bool get passwordRecoveryPending => _passwordRecoveryPending; + + /// Called by the reset screen after the password is updated (or the user + /// signs out) so the router stops forcing redirects to `/reset-password`. + void clearPasswordRecoveryPending() { + if (!_passwordRecoveryPending) return; + _passwordRecoveryPending = false; + notifyListeners(); + } + + @override + void dispose() { + _authSub?.cancel(); + super.dispose(); + } } final routerListenableProvider = Provider<RouterListenable>( diff --git a/lib/core/design_system/kudlit_colors.dart b/lib/core/design_system/kudlit_colors.dart index 7d4fb5c..1f55a7b 100644 --- a/lib/core/design_system/kudlit_colors.dart +++ b/lib/core/design_system/kudlit_colors.dart @@ -21,7 +21,7 @@ class KudlitColors { static const Color grey500 = Color(0xFFE9ECF6); static const Color grey400 = Color(0xFFD0D4E2); - static const Color grey300 = Color(0xFF6C738E); + static const Color grey300 = Color(0xFF4A5068); static const Color grey200 = Color(0xFF5C6078); static const Color success400 = Color(0xFF46B986); diff --git a/lib/features/auth/data/datasources/supabase_auth_datasource.dart b/lib/features/auth/data/datasources/supabase_auth_datasource.dart index 4814b02..d58470c 100644 --- a/lib/features/auth/data/datasources/supabase_auth_datasource.dart +++ b/lib/features/auth/data/datasources/supabase_auth_datasource.dart @@ -173,10 +173,11 @@ class SupabaseAuthDatasourceImpl implements SupabaseAuthDatasource { @override Future<void> resetPassword({required String email}) async { try { - // Web browsers cannot open custom URL schemes — fall back to null so - // Supabase uses the Site URL configured in the project dashboard. - // Mobile uses the deep link to re-open the app directly. - final String? redirectTo = kIsWeb ? null : 'kudlit://auth/reset'; + // On web, route the user back to the in-app `/auth/reset` handler. + // On native, use the deep link so the OS re-opens Kudlit directly. + final String redirectTo = kIsWeb + ? '${Uri.base.origin}/auth/reset' + : 'kudlit://auth/reset'; await _client.auth.resetPasswordForEmail(email, redirectTo: redirectTo); } on AuthException catch (e) { throw ServerException(message: e.message); diff --git a/lib/features/auth/presentation/screens/auth_welcome_screen.dart b/lib/features/auth/presentation/screens/auth_welcome_screen.dart index 06de316..b9492ab 100644 --- a/lib/features/auth/presentation/screens/auth_welcome_screen.dart +++ b/lib/features/auth/presentation/screens/auth_welcome_screen.dart @@ -58,12 +58,6 @@ class AuthWelcomeScreen extends StatelessWidget { icon: Icons.login_rounded, onTap: () => _openSignIn(context), ), - const SizedBox(height: 16), - Text( - 'Authentication is UI-only for now.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), ], ), ), diff --git a/lib/features/auth/presentation/screens/home_screen.dart b/lib/features/auth/presentation/screens/home_screen.dart index 7df7475..c322020 100644 --- a/lib/features/auth/presentation/screens/home_screen.dart +++ b/lib/features/auth/presentation/screens/home_screen.dart @@ -1,13 +1,20 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:go_router/go_router.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/home/presentation/screens/butty_chat_screen.dart'; import 'package:kudlit_ph/features/home/presentation/screens/learn_tab.dart'; import 'package:kudlit_ph/features/home/presentation/screens/scan_tab.dart'; import 'package:kudlit_ph/features/home/presentation/screens/translate_screen.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/app_header/app_header.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/floating_tab_nav.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; +import 'package:kudlit_ph/features/scanner/presentation/providers/scanner_provider.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -44,6 +51,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> { final AppTab? targetTab = _tabFromRoute(routeTab); if (targetTab == null || targetTab == _activeTab) return; + final AppTab previousTab = _activeTab; _activeTab = targetTab; if (_pageController.hasClients) { _pageController.jumpToPage(targetTab.index); @@ -51,16 +59,60 @@ class _HomeScreenState extends ConsumerState<HomeScreen> { _pageController.dispose(); _pageController = PageController(initialPage: targetTab.index); } + _syncScannerInference(previous: previousTab, next: targetTab); } void _onTabSelected(AppTab tab) { if (tab == _activeTab) return; + final AppTab previousTab = _activeTab; setState(() => _activeTab = tab); _pageController.animateToPage( tab.index, duration: const Duration(milliseconds: 320), curve: Curves.easeInOutCubic, ); + _syncScannerInference(previous: previousTab, next: tab); + } + + /// Pauses the native YOLO pipeline when leaving the Scan tab and resumes it + /// when returning, so inference does not run while the user is in another + /// tab (the PageView keeps `ScanTab` mounted for the app's lifetime). + /// + /// No-op on web — the web detector stubs already make these calls inert, + /// but the `kIsWeb` guard keeps us from instantiating the detector via the + /// `keepAlive` provider during a web build that may not need it yet. + void _syncScannerInference({required AppTab previous, required AppTab next}) { + if (kIsWeb) return; + if (previous == next) return; + final bool leavingScan = previous == AppTab.scan && next != AppTab.scan; + final bool enteringScan = previous != AppTab.scan && next == AppTab.scan; + if (!leavingScan && !enteringScan) return; + final BaybayinDetector detector = ref.read(baybayinDetectorProvider); + final Future<Either<Failure, Unit>> action = leavingScan + ? detector.pauseInference() + : detector.resumeInference(); + // Pause / resume failures on tab change are not user-facing — the user + // didn't ask the camera to do anything; we're just being a good citizen + // about resource usage. Log and move on. + unawaited( + action.then((Either<Failure, Unit> result) { + result.fold( + (Failure failure) => debugPrint( + '[HomeScreen] scanner ${leavingScan ? 'pauseInference' : 'resumeInference'} ' + 'failed: ${_failureMessage(failure)}', + ), + (_) {}, + ); + }), + ); + } + + String _failureMessage(Failure failure) { + return switch (failure) { + NetworkFailure(:final String message) => message, + UnknownFailure(:final String message) => message, + _ => failure.toString(), + }; } @override diff --git a/lib/features/auth/presentation/screens/phone_otp_screen.dart b/lib/features/auth/presentation/screens/phone_otp_screen.dart index 3a2bc13..07e6ca0 100644 --- a/lib/features/auth/presentation/screens/phone_otp_screen.dart +++ b/lib/features/auth/presentation/screens/phone_otp_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -28,6 +30,9 @@ class PhoneOtpScreen extends ConsumerStatefulWidget { class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { static const int _length = 6; + static const int _resendCooldownSeconds = 30; + static const int _maxVerifyAttempts = 5; + static const int _lockoutSeconds = 60; final List<TextEditingController> _controllers = List<TextEditingController>.generate( @@ -43,10 +48,24 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { bool _isResending = false; String? _errorMessage; String? _resendMessage; - final int _resendCooldown = 0; + int _resendCooldown = 0; + int _failedAttempts = 0; + int _lockoutCountdown = 0; + Timer? _resendTimer; + Timer? _lockoutTimer; + + @override + void initState() { + super.initState(); + // OTP was requested by the previous screen — start the cooldown + // immediately so the user cannot hammer "Resend" right away. + _startResendCooldown(); + } @override void dispose() { + _resendTimer?.cancel(); + _lockoutTimer?.cancel(); for (final TextEditingController c in _controllers) { c.dispose(); } @@ -61,6 +80,7 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { bool get _isComplete => _otp.length == _length; bool get _hasError => _errorMessage != null; + bool get _isLockedOut => _lockoutCountdown > 0; String _mapFailure(Failure failure) => failure.when( network: (String msg) => '${AppConstants.networkErrorPrefix}$msg', @@ -74,10 +94,56 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { passwordResetEmailSent: () => AppConstants.unexpectedError, ); + void _startResendCooldown() { + _resendTimer?.cancel(); + setState(() => _resendCooldown = _resendCooldownSeconds); + _resendTimer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + if (!mounted) { + t.cancel(); + return; + } + if (_resendCooldown <= 1) { + t.cancel(); + setState(() => _resendCooldown = 0); + } else { + setState(() => _resendCooldown -= 1); + } + }); + } + + void _startLockout() { + _lockoutTimer?.cancel(); + setState(() { + _lockoutCountdown = _lockoutSeconds; + _errorMessage = + 'Too many incorrect attempts. Try again in ${_lockoutSeconds}s.'; + }); + _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + if (!mounted) { + t.cancel(); + return; + } + if (_lockoutCountdown <= 1) { + t.cancel(); + setState(() { + _lockoutCountdown = 0; + _failedAttempts = 0; + _errorMessage = null; + }); + } else { + setState(() { + _lockoutCountdown -= 1; + _errorMessage = + 'Too many incorrect attempts. Try again in ${_lockoutCountdown}s.'; + }); + } + }); + } + void _onDigitChanged(int index, String value) { if (_errorMessage != null || _resendMessage != null) { setState(() { - _errorMessage = null; + if (!_isLockedOut) _errorMessage = null; _resendMessage = null; }); } @@ -92,11 +158,12 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { } else { _focusNodes[index].unfocus(); } - if (_isComplete) _submit(); + // Do not auto-submit when locked out — user must wait for backoff. + if (_isComplete && !_isLockedOut) _submit(); } Future<void> _submit() async { - if (!_isComplete || _isLoading) return; + if (!_isComplete || _isLoading || _isLockedOut) return; setState(() { _isLoading = true; _errorMessage = null; @@ -109,13 +176,22 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { if (!mounted) return; result.fold( (Failure failure) { + _failedAttempts += 1; + if (_failedAttempts >= _maxVerifyAttempts) { + setState(() => _isLoading = false); + _startLockout(); + } else { + setState(() { + _isLoading = false; + _errorMessage = _mapFailure(failure); + }); + } + }, + (_) { setState(() { _isLoading = false; - _errorMessage = _mapFailure(failure); + _failedAttempts = 0; }); - }, - (_) { - setState(() => _isLoading = false); final NavigatorState navigator = Navigator.of(context); while (navigator.canPop()) { navigator.pop(); @@ -132,7 +208,7 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { } Future<void> _resendCode() async { - if (_isResending || _isLoading) return; + if (_isResending || _isLoading || _resendCooldown > 0) return; setState(() { _isResending = true; _errorMessage = null; @@ -157,6 +233,7 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { _isResending = false; _resendMessage = 'A new code was sent.'; }); + _startResendCooldown(); }, ); } @@ -182,6 +259,7 @@ class _PhoneOtpScreenState extends ConsumerState<PhoneOtpScreen> { errorMessage: _errorMessage, isLoading: _isLoading, isResending: _isResending, + isLockedOut: _isLockedOut, resendMessage: _resendMessage, resendCooldown: _resendCooldown, onDigitChanged: _onDigitChanged, @@ -210,6 +288,7 @@ class _OtpSheetBody extends StatelessWidget { required this.errorMessage, required this.isLoading, required this.isResending, + required this.isLockedOut, required this.resendMessage, required this.resendCooldown, required this.onDigitChanged, @@ -224,6 +303,7 @@ class _OtpSheetBody extends StatelessWidget { final String? errorMessage; final bool isLoading; final bool isResending; + final bool isLockedOut; final String? resendMessage; final int resendCooldown; final void Function(int index, String value) onDigitChanged; @@ -266,7 +346,7 @@ class _OtpSheetBody extends StatelessWidget { AuthSubmitButton( label: 'Verify', isLoading: isLoading, - onTap: onSubmit, + onTap: isLockedOut ? () {} : onSubmit, ), if (resendMessage != null) ...<Widget>[ const SizedBox(height: 12), diff --git a/lib/features/auth/presentation/screens/reset_password_screen.dart b/lib/features/auth/presentation/screens/reset_password_screen.dart index 70936f3..e12734d 100644 --- a/lib/features/auth/presentation/screens/reset_password_screen.dart +++ b/lib/features/auth/presentation/screens/reset_password_screen.dart @@ -1,47 +1,103 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + import 'package:kudlit_ph/app/constants.dart'; +import 'package:kudlit_ph/app/router/router_listenable.dart'; +import 'package:kudlit_ph/core/design_system/kudlit_colors.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/auth_drag_handle.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/auth_screen_shell.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/auth_sheet.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/auth_sheet_headline.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/auth_submit_button.dart'; -import 'package:kudlit_ph/features/auth/presentation/widgets/email_field.dart'; +import 'package:kudlit_ph/features/auth/presentation/widgets/confirm_password_field.dart'; import 'package:kudlit_ph/features/auth/presentation/widgets/login_hero.dart'; +import 'package:kudlit_ph/features/auth/presentation/widgets/password_field.dart'; -class ResetPasswordScreen extends StatefulWidget { +/// Screen reached after the user taps the password-recovery deep link from +/// Supabase. The recovery session is already established by `gotrue`; this +/// screen forces the user to choose a new password before doing anything else +/// and signs them out on success so they re-authenticate with the new one. +class ResetPasswordScreen extends ConsumerStatefulWidget { const ResetPasswordScreen({super.key}); @override - State<ResetPasswordScreen> createState() => _ResetPasswordScreenState(); + ConsumerState<ResetPasswordScreen> createState() => + _ResetPasswordScreenState(); } -class _ResetPasswordScreenState extends State<ResetPasswordScreen> { +class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> { final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); - final TextEditingController _emailController = TextEditingController(); - bool _hasSent = false; + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmController = TextEditingController(); + + bool _isSubmitting = false; + String? _errorMessage; + String? _successMessage; @override void dispose() { - _emailController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); super.dispose(); } - void _submit() { - if (!(_formKey.currentState?.validate() ?? false)) { - return; + String? _validatePassword(String? value) { + final String password = value ?? ''; + if (password.isEmpty) return AppConstants.passwordRequiredMessage; + if (password.length < 6) return AppConstants.passwordTooShortMessage; + return null; + } + + String? _validateConfirm(String? value) { + if ((value ?? '').isEmpty) { + return AppConstants.confirmPasswordRequiredMessage; + } + if (value != _passwordController.text) { + return AppConstants.passwordsDoNotMatchMessage; } + return null; + } + + Future<void> _submit() async { + if (_isSubmitting) return; + if (!(_formKey.currentState?.validate() ?? false)) return; setState(() { - _hasSent = true; + _isSubmitting = true; + _errorMessage = null; + _successMessage = null; }); - } - String? _validateEmail(String? value) { - final String email = value?.trim() ?? ''; - if (email.isEmpty) return AppConstants.emailRequiredMessage; - final bool valid = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); - if (!valid) return AppConstants.invalidEmailMessage; - return null; + try { + final SupabaseClient client = Supabase.instance.client; + await client.auth.updateUser( + UserAttributes(password: _passwordController.text), + ); + // Force a fresh sign-in with the new credentials. Clearing the + // recovery flag here lets the router redirect to /login after signOut. + ref.read(routerListenableProvider).clearPasswordRecoveryPending(); + await client.auth.signOut(); + + if (!mounted) return; + setState(() { + _isSubmitting = false; + _successMessage = + 'Password updated. Please sign in with your new password.'; + }); + } on AuthException catch (e) { + if (!mounted) return; + setState(() { + _isSubmitting = false; + _errorMessage = e.message; + }); + } on Exception catch (_) { + if (!mounted) return; + setState(() { + _isSubmitting = false; + _errorMessage = AppConstants.unexpectedError; + }); + } } @override @@ -50,39 +106,115 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> { heroFraction: 0.38, hero: const LoginHero( buttyAsset: 'assets/brand/ButtyPhone.webp', - bubbleText: 'Almost there — enter your email.', - showBackButton: true, + bubbleText: 'Choose a new password', + showBackButton: false, showLanguageToggle: false, ), sheet: AuthSheet( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: <Widget>[ - const AuthDragHandle(), - const SizedBox(height: 10), - const AuthSheetHeadline( - title: 'Reset password', - subtitle: 'Enter your email and we will prepare a reset link.', - ), - const SizedBox(height: 20), - Form( - key: _formKey, - child: EmailField( - controller: _emailController, - validator: _validateEmail, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _submit(), + child: _ResetPasswordSheetBody( + formKey: _formKey, + passwordController: _passwordController, + confirmController: _confirmController, + validatePassword: _validatePassword, + validateConfirm: _validateConfirm, + isSubmitting: _isSubmitting, + errorMessage: _errorMessage, + successMessage: _successMessage, + onSubmit: _submit, + ), + ), + ); + } +} + +class _ResetPasswordSheetBody extends StatelessWidget { + const _ResetPasswordSheetBody({ + required this.formKey, + required this.passwordController, + required this.confirmController, + required this.validatePassword, + required this.validateConfirm, + required this.isSubmitting, + required this.errorMessage, + required this.successMessage, + required this.onSubmit, + }); + + final GlobalKey<FormState> formKey; + final TextEditingController passwordController; + final TextEditingController confirmController; + final String? Function(String?) validatePassword; + final String? Function(String?) validateConfirm; + final bool isSubmitting; + final String? errorMessage; + final String? successMessage; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + const AuthDragHandle(), + const SizedBox(height: 10), + const AuthSheetHeadline( + title: 'Set a new password', + subtitle: 'You\'ll use this the next time you sign in.', + ), + const SizedBox(height: 20), + Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + PasswordField( + controller: passwordController, + validator: validatePassword, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + ConfirmPasswordField( + controller: confirmController, + validator: validateConfirm, + ), + ], + ), + ), + if (errorMessage != null) ...<Widget>[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + color: KudlitColors.danger400, + fontSize: 12, ), ), - const SizedBox(height: 16), - AuthSubmitButton( - label: _hasSent ? 'Send again' : 'Send reset link', - isLoading: false, - onTap: _submit, + ), + ], + if (successMessage != null) ...<Widget>[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + successMessage!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), ), - ], + ), + ], + const SizedBox(height: 20), + AuthSubmitButton( + label: 'Update password', + isLoading: isSubmitting, + onTap: onSubmit, ), - ), + ], ); } } diff --git a/lib/features/home/data/datasources/local_profile_management_datasource.dart b/lib/features/home/data/datasources/local_profile_management_datasource.dart index 52783ca..0d41c4d 100644 --- a/lib/features/home/data/datasources/local_profile_management_datasource.dart +++ b/lib/features/home/data/datasources/local_profile_management_datasource.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -24,7 +25,12 @@ abstract interface class LocalProfileManagementDatasource { class SqfliteProfileManagementDatasource implements LocalProfileManagementDatasource { - SqfliteProfileManagementDatasource(); + factory SqfliteProfileManagementDatasource() { + if (kIsWeb) return _WebProfileManagementDatasource(); + return SqfliteProfileManagementDatasource._native(); + } + + SqfliteProfileManagementDatasource._native(); static const String _dbName = 'kudlit_profile.db'; static const int _dbVersion = 2; @@ -215,3 +221,64 @@ class SqfliteProfileManagementDatasource ); } } + +/// Web fallback: graceful no-op cache. `ProfileManagementRepositoryImpl` +/// already treats the local layer as a best-effort cache with a remote +/// fallback, so always-miss reads + silent writes keep web functional — +/// every read just hits Supabase. We log the first cache miss so the +/// degraded mode is discoverable in DevTools. +class _WebProfileManagementDatasource extends SqfliteProfileManagementDatasource + implements LocalProfileManagementDatasource { + _WebProfileManagementDatasource() : super._native(); + + bool _loggedDegradedMode = false; + + void _logOnce() { + if (_loggedDegradedMode) return; + _loggedDegradedMode = true; + debugPrint( + '[ProfileManagement] sqflite unavailable on web — ' + 'profile cache disabled, reads will fall through to Supabase.', + ); + } + + @override + Future<ProfileSummaryModel?> getCachedSummary({ + required String userId, + }) async { + _logOnce(); + return null; + } + + @override + Future<void> cacheSummary({ + required String userId, + required ProfileSummaryModel summary, + }) async { + _logOnce(); + } + + @override + Future<void> clearCachedSummary({required String userId}) async { + _logOnce(); + } + + @override + Future<ProfilePreferencesModel?> getCachedPreferences({ + required String userId, + }) async { + _logOnce(); + return null; + } + + @override + Future<void> cachePreferences({ + required String userId, + required ProfilePreferencesModel preferences, + }) async { + _logOnce(); + } + + @override + Future<void> dispose() async {} +} diff --git a/lib/features/home/data/datasources/sqlite_translation_history_datasource.dart b/lib/features/home/data/datasources/sqlite_translation_history_datasource.dart index 7a06128..c8f162c 100644 --- a/lib/features/home/data/datasources/sqlite_translation_history_datasource.dart +++ b/lib/features/home/data/datasources/sqlite_translation_history_datasource.dart @@ -1,11 +1,23 @@ +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:kudlit_ph/core/error/exceptions.dart'; import 'package:kudlit_ph/features/home/domain/entities/translation_result.dart'; +/// SQLite-backed translation history store. +/// +/// On web, where `sqflite` is unavailable, this resolves to an in-memory +/// implementation that keeps translation history for the current browser +/// session only. Supabase sync still provides cross-session persistence for +/// authenticated users. class SqliteTranslationHistoryDatasource { - SqliteTranslationHistoryDatasource(); + factory SqliteTranslationHistoryDatasource() { + if (kIsWeb) return _InMemoryTranslationHistoryDatasource(); + return SqliteTranslationHistoryDatasource._native(); + } + + SqliteTranslationHistoryDatasource._native(); static const String _dbName = 'kudlit_translations.db'; static const int _dbVersion = 1; @@ -130,3 +142,61 @@ class SqliteTranslationHistoryDatasource { ); } } + +/// Web fallback: session-scoped in-memory translation history. Data is lost +/// on page reload — Supabase sync gives cross-session persistence for +/// authenticated users. +class _InMemoryTranslationHistoryDatasource + extends SqliteTranslationHistoryDatasource { + _InMemoryTranslationHistoryDatasource() : super._native(); + + final Map<int, TranslationResult> _byId = <int, TranslationResult>{}; + int _nextId = 1; + + @override + Future<List<TranslationResult>> loadAll({int? limit}) async { + final List<TranslationResult> sorted = _byId.values.toList() + ..sort( + (TranslationResult a, TranslationResult b) => + (b.id ?? 0).compareTo(a.id ?? 0), + ); + if (limit == null || sorted.length <= limit) { + return List<TranslationResult>.unmodifiable(sorted); + } + return List<TranslationResult>.unmodifiable(sorted.take(limit)); + } + + @override + Future<TranslationResult> insert(TranslationResult result) async { + final int id = _nextId++; + final TranslationResult saved = result.copyWith(id: id); + _byId[id] = saved; + return saved; + } + + @override + Future<void> updateBookmark(int id, bool value) async { + final TranslationResult? existing = _byId[id]; + if (existing == null) return; + _byId[id] = existing.copyWith(isBookmarked: value); + } + + @override + Future<void> updateAiResponse(int id, String aiResponse) async { + final TranslationResult? existing = _byId[id]; + if (existing == null) return; + _byId[id] = existing.copyWith(aiResponse: aiResponse); + } + + @override + Future<void> clear() async { + _byId.clear(); + _nextId = 1; + } + + @override + Future<void> dispose() async { + _byId.clear(); + _nextId = 1; + } +} diff --git a/lib/features/home/presentation/providers/profile_management_provider.dart b/lib/features/home/presentation/providers/profile_management_provider.dart index faac02c..3bf3b6f 100644 --- a/lib/features/home/presentation/providers/profile_management_provider.dart +++ b/lib/features/home/presentation/providers/profile_management_provider.dart @@ -93,8 +93,13 @@ class ProfileSummaryNotifier extends _$ProfileSummaryNotifier { .clearCachedSummary(userId: userId); } catch (_) {} } + // The provider can be disposed while the awaits above are in flight + // (e.g. the profile screen is popped). Touching `state` after disposal + // throws an unhandled exception, so bail out if we're no longer mounted. + if (!ref.mounted) return; state = const AsyncLoading<Option<ProfileSummary>>(); final Option<ProfileSummary> summary = await _fetchSummary(); + if (!ref.mounted) return; state = AsyncValue<Option<ProfileSummary>>.data(summary); } @@ -106,6 +111,7 @@ class ProfileSummaryNotifier extends _$ProfileSummaryNotifier { UpdateDisplayNameParams(displayName: displayName), ); + if (!ref.mounted) return; if (result.isLeft()) { state = AsyncError<Option<ProfileSummary>>( result.getLeft().toNullable()!, @@ -115,6 +121,7 @@ class ProfileSummaryNotifier extends _$ProfileSummaryNotifier { } final summary = await _fetchSummary(); + if (!ref.mounted) return; state = AsyncValue.data(summary); } @@ -133,6 +140,7 @@ class ProfileSummaryNotifier extends _$ProfileSummaryNotifier { mimeType: mimeType, ); + if (!ref.mounted) return; if (result.isLeft()) { final Failure failure = result.getLeft().toNullable()!; state = AsyncValue.data(previousSummary); @@ -140,6 +148,7 @@ class ProfileSummaryNotifier extends _$ProfileSummaryNotifier { } final summary = await _fetchSummary(); + if (!ref.mounted) return; state = AsyncValue.data(summary); } } @@ -179,6 +188,7 @@ class ProfilePreferencesNotifier extends _$ProfilePreferencesNotifier { SaveProfilePreferencesParams(preferences: preferences), ); + if (!ref.mounted) return; if (result.isLeft()) { state = AsyncError<Option<ProfilePreferences>>( result.getLeft().toNullable()!, @@ -188,6 +198,7 @@ class ProfilePreferencesNotifier extends _$ProfilePreferencesNotifier { } final prefs = await _fetchPreferences(); + if (!ref.mounted) return; state = AsyncValue.data(prefs); } } diff --git a/lib/features/home/presentation/providers/translate_sketchpad_controller.dart b/lib/features/home/presentation/providers/translate_sketchpad_controller.dart index 6d115b6..a60544d 100644 --- a/lib/features/home/presentation/providers/translate_sketchpad_controller.dart +++ b/lib/features/home/presentation/providers/translate_sketchpad_controller.dart @@ -117,6 +117,13 @@ class TranslateSketchpadController extends Notifier<TranslateSketchpadState> { final StringBuffer buffer = StringBuffer(); bool localFailed = false; try { + // Prime + reactivate the offline model the same way Butty does + // (its mode selector reads this readiness provider). The sketchpad + // vision path calls `analyzeImage` directly, so without this the + // native engine has no active model after a restart and + // `getActiveModel()` throws — silently dropping to cloud. The probe + // is coalesced/fast-pathed, so this is cheap once warm. + await ref.read(localModelReadinessProvider.future); await for (final String chunk in ref .read(localGemmaDatasourceProvider) diff --git a/lib/features/home/presentation/providers/translate_text_controller.dart b/lib/features/home/presentation/providers/translate_text_controller.dart index 12ecdf2..d39d7c4 100644 --- a/lib/features/home/presentation/providers/translate_text_controller.dart +++ b/lib/features/home/presentation/providers/translate_text_controller.dart @@ -11,7 +11,8 @@ import 'package:kudlit_ph/features/home/presentation/providers/translation_histo import 'package:kudlit_ph/features/home/presentation/utils/safe_ai_output.dart'; import 'package:kudlit_ph/features/learning/domain/entities/gemma_prompts.dart'; import 'package:kudlit_ph/features/translator/domain/entities/chat_message.dart'; -import 'package:kudlit_ph/features/translator/presentation/providers/translator_providers.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_provider.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; @immutable class TranslateTextState { @@ -25,6 +26,7 @@ class TranslateTextState { required this.aiResponse, this.cleanupPreview, this.aiSource, + this.inputRevision = 0, }); const TranslateTextState.initial() @@ -48,6 +50,12 @@ class TranslateTextState { final String? cleanupPreview; final TranslateAiResultSource? aiSource; + /// Bumped only by external (non-typing) input mutations — example chips, + /// clear, etc. The text field watches this to know when to resync its + /// controller; plain typing never bumps it, so the cursor is never reset + /// mid-sentence. + final int inputRevision; + bool get hasInput => inputText.trim().isNotEmpty; TranslateTextState copyWith({ @@ -62,6 +70,7 @@ class TranslateTextState { bool clearCleanupPreview = false, TranslateAiResultSource? aiSource, bool clearAiSource = false, + int? inputRevision, }) { return TranslateTextState( inputText: inputText ?? this.inputText, @@ -75,6 +84,7 @@ class TranslateTextState { ? null : (cleanupPreview ?? this.cleanupPreview), aiSource: clearAiSource ? null : (aiSource ?? this.aiSource), + inputRevision: inputRevision ?? this.inputRevision, ); } } @@ -94,23 +104,66 @@ class TranslateTextController extends Notifier<TranslateTextState> { ); static final RegExp _baybayinPattern = RegExp(r'[ᜀ-ᜟ]'); + /// How long after the last keystroke the heavy transliteration runs. + /// Keeps `baybayifyWord` + regex feedback off the typing hot path so the + /// keyboard stays responsive during continuous input. + static const Duration _deriveDebounceDuration = Duration(milliseconds: 180); + Timer? _saveDebounce; + Timer? _deriveDebounce; @override TranslateTextState build() { - ref.onDispose(() => _saveDebounce?.cancel()); + ref.onDispose(() { + _saveDebounce?.cancel(); + _deriveDebounce?.cancel(); + }); return const TranslateTextState.initial(); } + /// Typing path. Echoes the raw text instantly (cheap, no rebuild storm) + /// and defers the expensive derive until typing pauses. Never bumps + /// [TranslateTextState.inputRevision], so the field/cursor is untouched. void setInput(String value) { - state = _deriveState( + if (value.trim().isEmpty) { + _deriveDebounce?.cancel(); + state = state.copyWith( + inputText: value, + baybayinText: '', + latinText: '', + feedbackMessages: const <String>[], + clearCleanupPreview: true, + aiResponse: '', + clearAiSource: true, + ); + _scheduleAutoSave(); + return; + } + state = state.copyWith(inputText: value); + _deriveDebounce?.cancel(); + _deriveDebounce = Timer(_deriveDebounceDuration, () { + state = _deriveState( + inputText: state.inputText, + latinToBaybayin: state.latinToBaybayin, + ); + _scheduleAutoSave(); + }); + } + + /// External input (example chips, etc). Derives immediately and bumps + /// [TranslateTextState.inputRevision] so the field resyncs its controller. + void applyExternalInput(String value) { + _deriveDebounce?.cancel(); + final TranslateTextState derived = _deriveState( inputText: value, latinToBaybayin: state.latinToBaybayin, ); + state = derived.copyWith(inputRevision: state.inputRevision + 1); _scheduleAutoSave(); } void setDirection(bool latinToBaybayin) { + _deriveDebounce?.cancel(); state = _deriveState( inputText: state.inputText, latinToBaybayin: latinToBaybayin, @@ -120,7 +173,10 @@ class TranslateTextController extends Notifier<TranslateTextState> { void clearInput() { _saveDebounce?.cancel(); - state = const TranslateTextState.initial(); + _deriveDebounce?.cancel(); + state = const TranslateTextState.initial().copyWith( + inputRevision: state.inputRevision + 1, + ); } void _scheduleAutoSave() { @@ -274,51 +330,29 @@ class TranslateTextController extends Notifier<TranslateTextState> { final List<ChatMessage> history = <ChatMessage>[ ChatMessage(text: userPrompt, isUser: true, timestamp: DateTime.now()), ]; - final AiPreference mode = - ref.read(appPreferencesNotifierProvider).value?.aiPreference ?? - AiPreference.cloud; - - if (mode == AiPreference.cloud) { - await _streamResponse( - stream: ref - .read(cloudGemmaDatasourceProvider) - .generate(history, systemInstruction: GemmaPrompts.translatorMode), - source: TranslateAiResultSource.online, - rethrowOnError: false, - ); - return; - } - final TranslateOfflineStatus offline = await ref.read( - translateOfflineStatusProvider.future, - ); - if (!offline.usable) { - state = state.copyWith( - aiBusy: false, - aiResponse: 'Offline model is unavailable for this action.', - clearAiSource: true, - ); - return; - } + // Route through the same shared inference notifier Butty uses. The + // repository owns model resolution, local-vs-cloud selection, and + // cloud fallback — translate no longer hand-rolls any of that, so the + // local Gemma model loads and behaves identically to the Butty chat. + final AiInferenceState? inference = ref + .read(aiInferenceNotifierProvider) + .value; + final TranslateAiResultSource source = switch (inference) { + AiReady(mode: AiPreference.local) => TranslateAiResultSource.offline, + _ => TranslateAiResultSource.online, + }; - try { - await _streamResponse( - stream: ref - .read(localGemmaDatasourceProvider) - .generate(history, systemInstruction: GemmaPrompts.translatorMode), - source: TranslateAiResultSource.offline, - rethrowOnError: true, - ); - } catch (error) { - await _streamResponse( - stream: ref - .read(cloudGemmaDatasourceProvider) - .generate(history, systemInstruction: GemmaPrompts.translatorMode), - source: TranslateAiResultSource.fallback, - prefix: 'Offline inference failed, so cloud fallback was used.\n\n', - rethrowOnError: false, - ); - } + await _streamResponse( + stream: ref + .read(aiInferenceNotifierProvider.notifier) + .generateResponse( + history, + systemInstruction: GemmaPrompts.translatorMode, + ), + source: source, + rethrowOnError: false, + ); } Future<void> _streamResponse({ diff --git a/lib/features/home/presentation/screens/model_setup_screen.dart b/lib/features/home/presentation/screens/model_setup_screen.dart index 9f06e27..c123fc6 100644 --- a/lib/features/home/presentation/screens/model_setup_screen.dart +++ b/lib/features/home/presentation/screens/model_setup_screen.dart @@ -114,10 +114,7 @@ class _SetupBackground extends StatelessWidget { // Faded Baybayin glyphs const BaybayinBackdrop(), // Soft radial aura — larger on desktop for a more dramatic backdrop - _AuraGlow( - size: isDesktop ? 520 : 280, - top: isDesktop ? -100 : -40, - ), + _AuraGlow(size: isDesktop ? 520 : 280, top: isDesktop ? -100 : -40), ], ); } @@ -174,22 +171,21 @@ class _ModelSetupBody extends StatelessWidget { builder: (BuildContext context, BoxConstraints constraints) { final bool isDesktop = constraints.maxWidth >= 900; final bool landscape = constraints.maxWidth > constraints.maxHeight; - final bool shortPortrait = - !landscape && constraints.maxHeight < 680; - if (isDesktop) { - return _DesktopSetupLayout( - busy: busy, - onContinue: onContinue, - onSkip: onSkip, - errorMessage: errorMessage, - ); - } - return landscape + final bool shortPortrait = !landscape && constraints.maxHeight < 680; + final Widget content = isDesktop + ? _DesktopSetupLayout( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + errorMessage: errorMessage, + ) + : landscape ? _LandscapeSetupLayout( busy: busy, onContinue: onContinue, onSkip: onSkip, errorMessage: errorMessage, + webCentered: kIsWeb, ) : shortPortrait ? _ShortPortraitSetupLayout( @@ -197,13 +193,26 @@ class _ModelSetupBody extends StatelessWidget { onContinue: onContinue, onSkip: onSkip, errorMessage: errorMessage, + webCentered: kIsWeb, ) : _PortraitSetupLayout( busy: busy, onContinue: onContinue, onSkip: onSkip, errorMessage: errorMessage, + webCentered: kIsWeb, ); + + if (kIsWeb) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center(child: content), + ), + ); + } + + return content; }, ), ); @@ -330,22 +339,58 @@ class _DesktopDivider extends StatelessWidget { } } - class _PortraitSetupLayout extends StatelessWidget { const _PortraitSetupLayout({ required this.busy, required this.onContinue, required this.onSkip, this.errorMessage, + this.webCentered = false, }); final bool busy; final VoidCallback onContinue; final VoidCallback onSkip; final String? errorMessage; + final bool webCentered; @override Widget build(BuildContext context) { + if (webCentered) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + const _SetupHero(), + const SizedBox(height: 20), + const _SetupHeadline(), + const SizedBox(height: 18), + const _ModelDownloadsPanel(), + const SizedBox(height: 10), + const _DownloadNotice(), + if (errorMessage != null) ...<Widget>[ + const SizedBox(height: 10), + _SetupErrorBanner(message: errorMessage!), + ], + _SetupActions( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ); + } + return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), @@ -383,15 +428,53 @@ class _ShortPortraitSetupLayout extends StatelessWidget { required this.onContinue, required this.onSkip, this.errorMessage, + this.webCentered = false, }); final bool busy; final VoidCallback onContinue; final VoidCallback onSkip; final String? errorMessage; + final bool webCentered; @override Widget build(BuildContext context) { + if (webCentered) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + const _SetupHero(height: 86), + const SizedBox(height: 14), + const _SetupHeadline(compact: true), + const SizedBox(height: 16), + const _ModelDownloadsPanel(), + const SizedBox(height: 10), + const _DownloadNotice(compact: true), + if (errorMessage != null) ...<Widget>[ + const SizedBox(height: 10), + _SetupErrorBanner(message: errorMessage!), + ], + const SizedBox(height: 18), + _SetupActions( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + compact: true, + ), + ], + ), + ), + ), + ); + } + return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 18, 24, 16), child: Column( @@ -427,61 +510,74 @@ class _LandscapeSetupLayout extends StatelessWidget { required this.onContinue, required this.onSkip, this.errorMessage, + this.webCentered = false, }); final bool busy; final VoidCallback onContinue; final VoidCallback onSkip; final String? errorMessage; + final bool webCentered; @override Widget build(BuildContext context) { + final Widget content = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: <Widget>[ + const Expanded( + flex: 5, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + _SetupHero(height: 86), + SizedBox(height: 14), + _SetupHeadline(compact: true), + ], + ), + ), + const SizedBox(width: 22), + Expanded( + flex: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + const _ModelDownloadsPanel(), + const SizedBox(height: 8), + const _DownloadNotice(compact: true), + if (errorMessage != null) ...<Widget>[ + const SizedBox(height: 8), + _SetupErrorBanner(message: errorMessage!), + ], + const SizedBox(height: 10), + _SetupActions( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + compact: true, + ), + ], + ), + ), + ], + ); + + if (webCentered) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: content, + ), + ); + } + return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 980), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: <Widget>[ - const Expanded( - flex: 5, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: <Widget>[ - _SetupHero(height: 86), - SizedBox(height: 14), - _SetupHeadline(compact: true), - ], - ), - ), - const SizedBox(width: 22), - Expanded( - flex: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: <Widget>[ - const _ModelDownloadsPanel(), - const SizedBox(height: 8), - const _DownloadNotice(compact: true), - if (errorMessage != null) ...<Widget>[ - const SizedBox(height: 8), - _SetupErrorBanner(message: errorMessage!), - ], - const SizedBox(height: 10), - _SetupActions( - busy: busy, - onContinue: onContinue, - onSkip: onSkip, - compact: true, - ), - ], - ), - ), - ], - ), + child: content, ), ), ); @@ -524,19 +620,37 @@ class _SetupHeadline extends StatelessWidget { 'Get ready to use Kudlit', style: TextStyle( color: KudlitColors.blue900, - fontSize: large ? 32 : compact ? 24 : 28, + fontSize: large + ? 32 + : compact + ? 24 + : 28, fontWeight: FontWeight.w700, ), ), - SizedBox(height: large ? 14 : compact ? 8 : 10), + SizedBox( + height: large + ? 14 + : compact + ? 8 + : 10, + ), Text( kIsWeb ? 'Set up the downloads Kudlit needs before you start.' : 'Download these once so key features can keep working even without internet.', style: TextStyle( color: KudlitColors.grey300, - fontSize: large ? 16 : compact ? 13 : 15, - height: large ? 1.6 : compact ? 1.35 : 1.55, + fontSize: large + ? 16 + : compact + ? 13 + : 15, + height: large + ? 1.6 + : compact + ? 1.35 + : 1.55, ), ), ], diff --git a/lib/features/home/presentation/screens/translate_screen.dart b/lib/features/home/presentation/screens/translate_screen.dart index 7024d17..305cc4a 100644 --- a/lib/features/home/presentation/screens/translate_screen.dart +++ b/lib/features/home/presentation/screens/translate_screen.dart @@ -10,6 +10,7 @@ import 'package:kudlit_ph/features/home/presentation/providers/translate_sketchp import 'package:kudlit_ph/features/home/presentation/providers/translate_text_controller.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/export_sheet.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_header.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_model_status_banner.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_text_mode_panel.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/floating_tab_nav.dart'; @@ -24,6 +25,15 @@ class TranslateScreen extends ConsumerStatefulWidget { class _TranslateScreenState extends ConsumerState<TranslateScreen> { bool _textInputFocused = false; + /// Stable identity for the text input across every layout branch. The + /// keyboard inset animates frame-by-frame, and several layout switches key + /// off it (constrained vs full layout, header/banner add/remove). Without a + /// GlobalKey those switches re-parent and re-mount the field, disposing its + /// FocusNode mid-edit — which hides the keyboard, refocuses, and loops + /// (the IME show/hide thrash + viewport-metric spam). A GlobalKey makes + /// Flutter migrate the same Element/State instead of re-mounting it. + final GlobalKey _textInputFieldKey = GlobalKey(); + void _setTextInputFocused(bool focused) { if (_textInputFocused == focused) return; setState(() => _textInputFocused = focused); @@ -43,26 +53,14 @@ class _TranslateScreenState extends ConsumerState<TranslateScreen> { final AsyncValue<AppPreferences> prefsAsync = ref.watch( appPreferencesNotifierProvider, ); - final AsyncValue<TranslateOfflineStatus> offlineStatus = ref.watch( - translateOfflineStatusProvider, - ); - final AiPreference mode = prefsAsync.value?.aiPreference ?? AiPreference.cloud; - final bool offlinePending = - mode == AiPreference.local && offlineStatus.isLoading; - final bool offlineUnavailable = - mode == AiPreference.local && - !offlineStatus.isLoading && - !(offlineStatus.value?.usable ?? false); - final bool aiActionsEnabled = !offlinePending && !offlineUnavailable; - final String? disabledReason = switch (mode) { - AiPreference.local when offlinePending => 'Preparing offline Gemma...', - AiPreference.local when offlineUnavailable => - offlineStatus.value?.detail ?? - 'Offline model is unavailable for this action.', - _ => null, - }; + // The shared inference repository now owns model resolution and cloud + // fallback, so AI actions stay enabled; the status banner communicates + // offline readiness and the setup affordance (parity with Butty). + const bool aiActionsEnabled = true; + const String? disabledReason = null; + final bool showModelBanner = mode == AiPreference.local; final Size screenSize = MediaQuery.sizeOf(context); final view = View.of(context); final double rawKeyboardInset = @@ -82,12 +80,16 @@ class _TranslateScreenState extends ConsumerState<TranslateScreen> { inputEnabled: aiActionsEnabled, disabledReason: disabledReason, compactLayout: compactLayout, + inputFieldKey: _textInputFieldKey, onDirectionChanged: ref .read(translateTextControllerProvider.notifier) .setDirection, onInputChanged: ref .read(translateTextControllerProvider.notifier) .setInput, + onExternalInput: ref + .read(translateTextControllerProvider.notifier) + .applyExternalInput, onClear: ref.read(translateTextControllerProvider.notifier).clearInput, onExplain: () => unawaited( ref.read(translateTextControllerProvider.notifier).explain(), @@ -123,10 +125,19 @@ class _TranslateScreenState extends ConsumerState<TranslateScreen> { builder: (BuildContext context, BoxConstraints constraints) { final bool portraitKeyboardOpen = keyboardOpen && screenSize.height >= screenSize.width; + final bool textMode = + pageState.mode == TranslateWorkspaceMode.text; + // Eager (non-async) preserve: in portrait text mode the keyboard + // can only be open because the text field is focused, so lock the + // layout immediately instead of waiting for the focus-listener + // setState to land a frame later (which is what let the constrained + // branch fire on the first animation frame and start the loop). final bool preserveFocusedPortraitInput = - _textInputFocused && - (portraitKeyboardOpen || - (screenSize.width <= 500 && constraints.maxHeight < 560)); + (textMode && portraitKeyboardOpen) || + (_textInputFocused && + (portraitKeyboardOpen || + (screenSize.width <= 500 && + constraints.maxHeight < 560))); final bool shortLandscape = constraints.maxWidth > constraints.maxHeight && constraints.maxHeight < 500; @@ -152,6 +163,10 @@ class _TranslateScreenState extends ConsumerState<TranslateScreen> { .read(translatePageControllerProvider.notifier) .setMode, ), + if (showModelBanner && + !keyboardOpen && + pageState.mode == TranslateWorkspaceMode.text) + const TranslateModelStatusBanner(), Expanded( child: switch (pageState.mode) { TranslateWorkspaceMode.text => textModePanel( diff --git a/lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart b/lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart index 4c3b175..76c995f 100644 --- a/lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart +++ b/lib/features/home/presentation/widgets/butty_chat/chat_input_bar.dart @@ -46,7 +46,7 @@ class ChatInputBar extends StatelessWidget { : (disabledHint ?? 'Preparing offline Gemma...'), hintStyle: TextStyle( fontSize: 13, - color: cs.onSurface.withAlpha(100), + color: cs.onSurface.withAlpha(160), ), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric( diff --git a/lib/features/home/presentation/widgets/translate/baybayin_target_glyphs.dart b/lib/features/home/presentation/widgets/translate/baybayin_target_glyphs.dart new file mode 100644 index 0000000..56d1a55 --- /dev/null +++ b/lib/features/home/presentation/widgets/translate/baybayin_target_glyphs.dart @@ -0,0 +1,39 @@ +import 'package:meta/meta.dart'; + +/// A selectable Baybayin base character for the sketchpad target picker. +/// +/// [glyph] is the Unicode codepoint (rendered with the Baybayin font); +/// [label] is the romanized name fed to the AI coach prompt (unchanged +/// from the previous free-text field, so feedback behavior is identical). +@immutable +class BaybayinTargetGlyph { + const BaybayinTargetGlyph(this.glyph, this.label); + + final String glyph; + final String label; +} + +/// The 17 base Baybayin characters (U+1700–U+1711, excluding the reserved +/// U+170D and the kudlit/virama marks). Static reference data, not user +/// input — picking from this list replaces the keyboard textbox entirely, +/// so the sketchpad never opens the IME (see the runtime-log audit: +/// the target field's keyboard was what drove the layout re-mount loop). +const List<BaybayinTargetGlyph> kBaybayinTargetGlyphs = <BaybayinTargetGlyph>[ + BaybayinTargetGlyph('ᜀ', 'a'), + BaybayinTargetGlyph('ᜁ', 'i'), + BaybayinTargetGlyph('ᜂ', 'u'), + BaybayinTargetGlyph('ᜃ', 'ka'), + BaybayinTargetGlyph('ᜄ', 'ga'), + BaybayinTargetGlyph('ᜅ', 'nga'), + BaybayinTargetGlyph('ᜆ', 'ta'), + BaybayinTargetGlyph('ᜇ', 'da'), + BaybayinTargetGlyph('ᜈ', 'na'), + BaybayinTargetGlyph('ᜉ', 'pa'), + BaybayinTargetGlyph('ᜊ', 'ba'), + BaybayinTargetGlyph('ᜋ', 'ma'), + BaybayinTargetGlyph('ᜌ', 'ya'), + BaybayinTargetGlyph('ᜎ', 'la'), + BaybayinTargetGlyph('ᜏ', 'wa'), + BaybayinTargetGlyph('ᜐ', 'sa'), + BaybayinTargetGlyph('ᜑ', 'ha'), +]; diff --git a/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_button.dart b/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_button.dart new file mode 100644 index 0000000..052c067 --- /dev/null +++ b/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_button.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:kudlit_ph/features/home/presentation/widgets/translate/baybayin_target_glyphs.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/translate/sketchpad_target_glyph_sheet.dart'; + +/// Tap-to-pick replacement for the old free-text target field. Opens the +/// glyph grid sheet instead of a keyboard, so the sketchpad never +/// triggers the keyboard-driven layout re-mount loop from the audit. +class SketchpadTargetGlyphButton extends StatelessWidget { + const SketchpadTargetGlyphButton({ + super.key, + required this.currentLabel, + required this.onSelected, + }); + + /// The romanized label currently stored in the controller state. + final String currentLabel; + final ValueChanged<String> onSelected; + + @override + Widget build(BuildContext context) { + final ColorScheme cs = Theme.of(context).colorScheme; + final String trimmed = currentLabel.trim(); + final BaybayinTargetGlyph? selected = trimmed.isEmpty + ? null + : kBaybayinTargetGlyphs + .where((BaybayinTargetGlyph g) => g.label == trimmed) + .firstOrNull; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () async { + final String? picked = await SketchpadTargetGlyphSheet.show( + context, + currentLabel: trimmed, + ); + if (picked != null) { + onSelected(picked); + } + }, + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: cs.outline), + ), + child: Row( + children: <Widget>[ + if (selected != null) ...<Widget>[ + Text( + selected.glyph, + style: const TextStyle( + fontFamily: 'Baybayin Simple TAWBID', + fontSize: 22, + height: 1, + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + selected?.label ?? 'Target glyph', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: selected != null + ? FontWeight.w700 + : FontWeight.w400, + color: selected != null + ? cs.onSurface + : cs.onSurface.withAlpha(120), + ), + ), + ), + Icon( + Icons.expand_more_rounded, + size: 20, + color: cs.onSurface.withAlpha(150), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_sheet.dart b/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_sheet.dart new file mode 100644 index 0000000..eb67dd3 --- /dev/null +++ b/lib/features/home/presentation/widgets/translate/sketchpad_target_glyph_sheet.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; + +import 'package:kudlit_ph/features/home/presentation/widgets/translate/baybayin_target_glyphs.dart'; + +/// Keyboard-free target picker: a grid of Baybayin base glyphs. Tapping a +/// tile pops the sheet with the romanized label; no text input, so the +/// keyboard never opens here (the textbox was what drove the layout +/// re-mount loop — see the runtime-log audit). +class SketchpadTargetGlyphSheet extends StatelessWidget { + const SketchpadTargetGlyphSheet({super.key, required this.currentLabel}); + + final String currentLabel; + + /// Opens the picker and resolves to the chosen romanized label, or + /// `null` if dismissed without a selection. + static Future<String?> show( + BuildContext context, { + required String currentLabel, + }) { + // No `showDragHandle` / `isScrollControlled`: both make the sheet + // measure its child under unbounded constraints. The content bounds + // its own width/height instead. + return showModalBottomSheet<String>( + context: context, + builder: (BuildContext context) => + SketchpadTargetGlyphSheet(currentLabel: currentLabel), + ); + } + + @override + Widget build(BuildContext context) { + final ColorScheme cs = Theme.of(context).colorScheme; + final Size screen = MediaQuery.sizeOf(context); + return SafeArea( + top: false, + child: SizedBox( + width: screen.width, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: screen.height * 0.7), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration( + color: cs.onSurface.withAlpha(60), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 12, left: 4), + child: Text( + 'Choose target glyph', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + ), + Wrap( + spacing: 10, + runSpacing: 10, + children: <Widget>[ + for (final BaybayinTargetGlyph entry + in kBaybayinTargetGlyphs) + _TargetGlyphTile( + entry: entry, + selected: entry.label == currentLabel.trim(), + onTap: () => + Navigator.of(context).pop(entry.label), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TargetGlyphTile extends StatelessWidget { + const _TargetGlyphTile({ + required this.entry, + required this.selected, + required this.onTap, + }); + + final BaybayinTargetGlyph entry; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ColorScheme cs = Theme.of(context).colorScheme; + return Semantics( + button: true, + selected: selected, + label: 'Target glyph ${entry.label}', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( + width: 60, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: selected + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? cs.primary : cs.outline.withAlpha(90), + width: selected ? 1.5 : 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Text( + entry.glyph, + style: TextStyle( + fontFamily: 'Baybayin Simple TAWBID', + fontSize: 30, + height: 1, + color: selected ? cs.onPrimaryContainer : cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + entry.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: selected + ? cs.onPrimaryContainer + : cs.onSurface.withAlpha(180), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/translate/text_input_box.dart b/lib/features/home/presentation/widgets/translate/text_input_box.dart index e38fe92..7849b34 100644 --- a/lib/features/home/presentation/widgets/translate/text_input_box.dart +++ b/lib/features/home/presentation/widgets/translate/text_input_box.dart @@ -44,7 +44,7 @@ class TextInputBox extends StatelessWidget { hintText: 'Type in Filipino…', hintStyle: TextStyle( fontSize: 15, - color: cs.onSurface.withAlpha(110), + color: cs.onSurface.withAlpha(160), ), ), ), diff --git a/lib/features/home/presentation/widgets/translate/translate_gemma_status_banner.dart b/lib/features/home/presentation/widgets/translate/translate_gemma_status_banner.dart deleted file mode 100644 index 35373c6..0000000 --- a/lib/features/home/presentation/widgets/translate/translate_gemma_status_banner.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; -import 'package:kudlit_ph/features/home/presentation/providers/translate_page_controller.dart'; - -class TranslateGemmaStatusBanner extends StatelessWidget { - const TranslateGemmaStatusBanner({ - super.key, - required this.mode, - required this.offlineStatus, - required this.onModeChanged, - }); - - final AiPreference mode; - final AsyncValue<TranslateOfflineStatus> offlineStatus; - final ValueChanged<AiPreference> onModeChanged; - - @override - Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - final bool checking = mode == AiPreference.local && offlineStatus.isLoading; - final TranslateOfflineStatus? status = offlineStatus.value; - final String helper = switch (mode) { - AiPreference.cloud => 'Online Gemma is active.', - AiPreference.local when checking => 'Preparing offline Gemma...', - AiPreference.local when status?.usable ?? false => - status?.modelName == null - ? 'Offline ready.' - : 'Offline ready: ${status!.modelName}.', - AiPreference.local when status?.installed ?? false => - 'Offline model found, but local runtime is unavailable.', - _ => status?.detail ?? 'Offline model is unavailable for this action.', - }; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outline), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: <Widget>[ - _SourceSwitch(mode: mode, onModeChanged: onModeChanged), - const SizedBox(height: 8), - Row( - children: <Widget>[ - if (checking) - Padding( - padding: const EdgeInsets.only(right: 8), - child: SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ), - ), - Expanded( - child: Text( - helper, - style: TextStyle( - fontSize: 12, - color: cs.onSurface.withAlpha(185), - ), - ), - ), - ], - ), - ], - ), - ); - } -} - -class _SourceSwitch extends StatelessWidget { - const _SourceSwitch({required this.mode, required this.onModeChanged}); - - final AiPreference mode; - final ValueChanged<AiPreference> onModeChanged; - - @override - Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: cs.surfaceContainer, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: cs.outline), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: <Widget>[ - _SourcePill( - label: 'Online', - active: mode == AiPreference.cloud, - onTap: () => onModeChanged(AiPreference.cloud), - ), - _SourcePill( - label: 'Offline', - active: mode == AiPreference.local, - onTap: () => onModeChanged(AiPreference.local), - ), - ], - ), - ); - } -} - -class _SourcePill extends StatelessWidget { - const _SourcePill({ - required this.label, - required this.active, - required this.onTap, - }); - - final String label; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: active ? cs.primary : Colors.transparent, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w700, - color: active ? cs.onPrimary : cs.onSurface.withAlpha(170), - ), - ), - ), - ); - } -} diff --git a/lib/features/home/presentation/widgets/translate/translate_model_status_banner.dart b/lib/features/home/presentation/widgets/translate/translate_model_status_banner.dart new file mode 100644 index 0000000..b86027e --- /dev/null +++ b/lib/features/home/presentation/widgets/translate/translate_model_status_banner.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_provider.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; + +/// Offline-model status for the translate page, driven by the same +/// `aiInferenceNotifierProvider` Butty uses. Gives translate the identical +/// missing → download → ready lifecycle and setup affordance. +class TranslateModelStatusBanner extends ConsumerWidget { + const TranslateModelStatusBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ColorScheme cs = Theme.of(context).colorScheme; + final AsyncValue<AiInferenceState> stateAsync = ref.watch( + aiInferenceNotifierProvider, + ); + + final Widget content = stateAsync.when( + loading: () => _line(cs, 'Preparing offline Gemma…'), + error: (Object e, _) => _line(cs, 'Offline model error: $e', error: true), + data: (AiInferenceState state) => _StatusContent(state: state), + ); + + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outline.withAlpha(120)), + ), + child: content, + ); + } + + static Widget _line(ColorScheme cs, String text, {bool error = false}) { + return Text( + text, + style: TextStyle( + fontSize: 12, + color: error ? cs.error : cs.onSurface.withAlpha(180), + ), + ); + } +} + +class _StatusContent extends ConsumerWidget { + const _StatusContent({required this.state}); + + final AiInferenceState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ColorScheme cs = Theme.of(context).colorScheme; + + return switch (state) { + AiReady(:final AiPreference mode) => + mode == AiPreference.cloud + ? TranslateModelStatusBanner._line( + cs, + 'Online Gemma is active.', + ) + : Row( + children: <Widget>[ + Icon( + Icons.check_circle_rounded, + size: 15, + color: Colors.green.shade600, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Offline Gemma ready.', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.green.shade700, + ), + ), + ), + ], + ), + AiLocalModelMissing(:final String? note) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Row( + children: <Widget>[ + Icon(Icons.warning_amber_rounded, size: 15, color: cs.error), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Offline Gemma is not downloaded yet.', + style: TextStyle(fontSize: 12, color: cs.error), + ), + ), + TextButton( + onPressed: () => ref + .read(aiInferenceNotifierProvider.notifier) + .downloadLocalModel(), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: const Text( + 'Set up', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + ], + ), + if (note != null) ...<Widget>[ + const SizedBox(height: 4), + Text( + note, + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(150), + ), + ), + ], + ], + ), + AiDownloading(:final int progress, :final String? statusMessage) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + Text( + 'Downloading offline Gemma… $progress%', + style: TextStyle(fontSize: 12, color: cs.primary), + ), + InkWell( + onTap: () => ref + .read(aiInferenceNotifierProvider.notifier) + .cancelDownload(), + child: Icon( + Icons.cancel_rounded, + size: 16, + color: cs.error, + ), + ), + ], + ), + if (statusMessage != null) ...<Widget>[ + const SizedBox(height: 4), + Text( + statusMessage, + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withAlpha(150), + ), + ), + ], + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator(value: progress / 100), + ), + ], + ), + AiInferenceError(:final String message) => + TranslateModelStatusBanner._line( + cs, + 'Offline Gemma error: $message', + error: true, + ), + _ => TranslateModelStatusBanner._line(cs, 'Getting offline Gemma ready…'), + }; + } +} diff --git a/lib/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart b/lib/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart index ed7740c..09c5ffa 100644 --- a/lib/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart +++ b/lib/features/home/presentation/widgets/translate/translate_sketchpad_mode_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:kudlit_ph/features/home/presentation/providers/translate_sketchpad_controller.dart'; import 'package:kudlit_ph/features/home/presentation/utils/safe_ai_output.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/learn/live_stroke_painter.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/translate/sketchpad_target_glyph_button.dart'; class TranslateSketchpadModePanel extends StatefulWidget { const TranslateSketchpadModePanel({ @@ -28,28 +29,9 @@ class TranslateSketchpadModePanel extends StatefulWidget { class _TranslateSketchpadModePanelState extends State<TranslateSketchpadModePanel> { - final TextEditingController _targetController = TextEditingController(); final List<List<Offset>> _strokes = <List<Offset>>[]; final List<Offset> _current = <Offset>[]; - @override - void dispose() { - _targetController.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant TranslateSketchpadModePanel oldWidget) { - super.didUpdateWidget(oldWidget); - final String nextTarget = widget.state.target; - if (_targetController.text != nextTarget) { - _targetController.value = TextEditingValue( - text: nextTarget, - selection: TextSelection.collapsed(offset: nextTarget.length), - ); - } - } - void _onPanStart(DragStartDetails d) { setState(() { _current @@ -115,7 +97,6 @@ class _TranslateSketchpadModePanelState else const Spacer(), _BottomBar( - targetController: _targetController, state: widget.state, canRequest: canRequest, disabledReason: widget.disabledReason, @@ -197,7 +178,6 @@ class _InlineCanvas extends StatelessWidget { class _BottomBar extends StatelessWidget { const _BottomBar({ - required this.targetController, required this.state, required this.canRequest, required this.disabledReason, @@ -207,7 +187,6 @@ class _BottomBar extends StatelessWidget { required this.onGetFeedback, }); - final TextEditingController targetController; final TranslateSketchpadState state; final bool canRequest; final String? disabledReason; @@ -236,37 +215,9 @@ class _BottomBar extends StatelessWidget { Row( children: <Widget>[ Expanded( - child: TextField( - controller: targetController, - onChanged: onTargetChanged, - maxLength: 2, - decoration: InputDecoration( - hintText: 'Target glyph (e.g. ba)', - filled: true, - fillColor: cs.surfaceContainerLow, - isDense: true, - counterText: '', - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: cs.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: cs.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide(color: cs.primary, width: 1.5), - ), - hintStyle: TextStyle( - color: cs.onSurface.withAlpha(120), - fontSize: 14, - ), - ), + child: SketchpadTargetGlyphButton( + currentLabel: state.target, + onSelected: onTargetChanged, ), ), const SizedBox(width: 8), diff --git a/lib/features/home/presentation/widgets/translate/translate_text_mode_panel.dart b/lib/features/home/presentation/widgets/translate/translate_text_mode_panel.dart index e282d45..b023cf4 100644 --- a/lib/features/home/presentation/widgets/translate/translate_text_mode_panel.dart +++ b/lib/features/home/presentation/widgets/translate/translate_text_mode_panel.dart @@ -15,12 +15,14 @@ class TranslateTextModePanel extends StatelessWidget { required this.disabledReason, required this.onDirectionChanged, required this.onInputChanged, + required this.onExternalInput, required this.onClear, required this.onExplain, required this.onCheckInput, required this.onCopy, required this.onShare, this.onInputFocusChanged, + this.inputFieldKey, this.compactLayout = false, }); @@ -29,12 +31,22 @@ class TranslateTextModePanel extends StatelessWidget { final String? disabledReason; final ValueChanged<bool> onDirectionChanged; final ValueChanged<String> onInputChanged; + + /// Non-typing input (example chips). Routes through the controller's + /// `applyExternalInput`, which bumps the field revision so the text + /// field picks the value up; plain typing uses [onInputChanged]. + final ValueChanged<String> onExternalInput; final VoidCallback onClear; final VoidCallback onExplain; final VoidCallback onCheckInput; final VoidCallback onCopy; final VoidCallback onShare; final ValueChanged<bool>? onInputFocusChanged; + + /// Stable identity for the inner text field so it survives the screen's + /// keyboard-driven layout switches without re-mounting (see + /// `_TranslateScreenState._textInputFieldKey`). + final Key? inputFieldKey; final bool compactLayout; @override @@ -49,10 +61,12 @@ class TranslateTextModePanel extends StatelessWidget { compact: true, onDirectionChanged: onDirectionChanged, onInputChanged: onInputChanged, + onExternalInput: onExternalInput, onClear: onClear, onExplain: onExplain, onCheckInput: onCheckInput, onInputFocusChanged: onInputFocusChanged, + inputFieldKey: inputFieldKey, ), ); } @@ -88,10 +102,12 @@ class TranslateTextModePanel extends StatelessWidget { compact: false, onDirectionChanged: onDirectionChanged, onInputChanged: onInputChanged, + onExternalInput: onExternalInput, onClear: onClear, onExplain: onExplain, onCheckInput: onCheckInput, onInputFocusChanged: onInputFocusChanged, + inputFieldKey: inputFieldKey, ), ], ), @@ -142,10 +158,12 @@ class TranslateTextModePanel extends StatelessWidget { compact: false, onDirectionChanged: onDirectionChanged, onInputChanged: onInputChanged, + onExternalInput: onExternalInput, onClear: onClear, onExplain: onExplain, onCheckInput: onCheckInput, onInputFocusChanged: onInputFocusChanged, + inputFieldKey: inputFieldKey, ), ], ); @@ -160,18 +178,22 @@ class _BottomInputArea extends StatelessWidget { required this.compact, required this.onDirectionChanged, required this.onInputChanged, + required this.onExternalInput, required this.onClear, required this.onExplain, required this.onCheckInput, this.onInputFocusChanged, + this.inputFieldKey, }); final TranslateTextState state; final bool inputEnabled; final String? disabledReason; final bool compact; + final Key? inputFieldKey; final ValueChanged<bool> onDirectionChanged; final ValueChanged<String> onInputChanged; + final ValueChanged<String> onExternalInput; final VoidCallback onClear; final VoidCallback onExplain; final VoidCallback onCheckInput; @@ -204,8 +226,16 @@ class _BottomInputArea extends StatelessWidget { ), SizedBox(height: keyboardCompact ? 4 : 8), _InputField( + key: inputFieldKey, text: state.inputText, - enabled: inputEnabled && !state.aiBusy, + revision: state.inputRevision, + // Stay enabled while the AI is working. Disabling the field on + // `aiBusy` drops focus and force-closes the keyboard, then + // re-enabling on completion reopens it — the IME show/hide burst + // seen right after each inference. The action buttons + // (`_TextActionsRow`) and the controller already block re-entry + // while busy, so keeping the field editable is safe. + enabled: inputEnabled, expanded: !compact, dense: keyboardCompact, hintText: state.latinToBaybayin @@ -220,7 +250,7 @@ class _BottomInputArea extends StatelessWidget { _ReverseExamplesHint( compact: keyboardCompact, enabled: inputEnabled && !state.aiBusy, - onSelect: onInputChanged, + onSelect: onExternalInput, ), ], if (state.feedbackMessages.isNotEmpty || @@ -260,7 +290,9 @@ class _BottomInputArea extends StatelessWidget { class _InputField extends StatefulWidget { const _InputField({ + super.key, required this.text, + required this.revision, required this.enabled, required this.expanded, required this.dense, @@ -271,6 +303,11 @@ class _InputField extends StatefulWidget { }); final String text; + + /// Bumped by the controller only on external (non-typing) mutations. + /// The field resyncs its controller exclusively when this changes, so + /// plain typing never resets the cursor or breaks IME composition. + final int revision; final bool enabled; final bool expanded; final bool dense; @@ -286,15 +323,26 @@ class _InputField extends StatefulWidget { class _InputFieldState extends State<_InputField> { final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); + bool _hasText = false; @override void initState() { super.initState(); _controller.text = widget.text; - _controller.addListener(() => setState(() {})); + _hasText = widget.text.isNotEmpty; + _controller.addListener(_handleTextChanged); _focusNode.addListener(_handleFocusChanged); } + /// Rebuild only when the text crosses the empty/non-empty boundary — + /// that is the only thing the field's own build depends on (the clear + /// icon). Rebuilding on every keystroke is what made typing janky. + void _handleTextChanged() { + final bool hasText = _controller.text.isNotEmpty; + if (hasText == _hasText) return; + setState(() => _hasText = hasText); + } + void _handleFocusChanged() { widget.onFocusChanged?.call(_focusNode.hasFocus); } @@ -302,6 +350,10 @@ class _InputFieldState extends State<_InputField> { @override void didUpdateWidget(covariant _InputField oldWidget) { super.didUpdateWidget(oldWidget); + // Only an external mutation (clear, example chip, direction-driven + // reset) bumps the revision. Typing-driven rebuilds leave the field — + // and the user's cursor / IME composing region — completely alone. + if (widget.revision == oldWidget.revision) return; if (_controller.text == widget.text) return; _controller.value = TextEditingValue( text: widget.text, @@ -311,6 +363,7 @@ class _InputFieldState extends State<_InputField> { @override void dispose() { + _controller.removeListener(_handleTextChanged); _focusNode.removeListener(_handleFocusChanged); _focusNode.dispose(); _controller.dispose(); diff --git a/lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart b/lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart index ea9a0a1..eb11659 100644 --- a/lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart +++ b/lib/features/learning/data/datasources/sqlite_lesson_progress_datasource.dart @@ -1,11 +1,23 @@ +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:kudlit_ph/core/error/exceptions.dart'; import 'package:kudlit_ph/features/learning/domain/entities/lesson_progress.dart'; +/// SQLite-backed lesson progress store. +/// +/// On web, where `sqflite` is unavailable, this resolves to an in-memory +/// implementation keyed by `lesson_id` for the current browser session. +/// Supabase still acts as the source of truth across sessions — the +/// notifier seeds the in-memory cache from Supabase on cold start. class SqliteLessonProgressDatasource { - SqliteLessonProgressDatasource(); + factory SqliteLessonProgressDatasource() { + if (kIsWeb) return _InMemoryLessonProgressDatasource(); + return SqliteLessonProgressDatasource._native(); + } + + SqliteLessonProgressDatasource._native(); static const String _dbName = 'kudlit_learning.db'; static const int _dbVersion = 1; @@ -123,3 +135,42 @@ class SqliteLessonProgressDatasource { ); } } + +/// Web fallback: session-scoped in-memory lesson progress, keyed by +/// `lesson_id` (mirrors the SQLite primary key). Replaced on conflict to +/// match the native `ConflictAlgorithm.replace` semantics. +class _InMemoryLessonProgressDatasource extends SqliteLessonProgressDatasource { + _InMemoryLessonProgressDatasource() : super._native(); + + final Map<String, LessonProgress> _byLessonId = <String, LessonProgress>{}; + + @override + Future<List<LessonProgress>> loadAll() async { + final List<LessonProgress> sorted = _byLessonId.values.toList() + ..sort( + (LessonProgress a, LessonProgress b) => + b.lastModified.compareTo(a.lastModified), + ); + return List<LessonProgress>.unmodifiable(sorted); + } + + @override + Future<LessonProgress?> loadForLesson(String lessonId) async { + return _byLessonId[lessonId]; + } + + @override + Future<void> save(LessonProgress progress) async { + _byLessonId[progress.lessonId] = progress; + } + + @override + Future<void> clear() async { + _byLessonId.clear(); + } + + @override + Future<void> dispose() async { + _byLessonId.clear(); + } +} diff --git a/lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart b/lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart index b93d77e..c9b1a70 100644 --- a/lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart +++ b/lib/features/scanner/data/datasources/sqlite_scan_history_datasource.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -8,6 +9,11 @@ import 'package:kudlit_ph/features/scanner/domain/entities/scan_result.dart'; /// SQLite-backed scan result history store. /// +/// On web, where `sqflite` is unavailable, this resolves to an in-memory +/// implementation that keeps scan history for the current browser session +/// only. Supabase sync still provides cross-session persistence for +/// authenticated users. +/// /// Schema: /// ```sql /// CREATE TABLE scan_history ( @@ -18,7 +24,12 @@ import 'package:kudlit_ph/features/scanner/domain/entities/scan_result.dart'; /// ); /// ``` class SqliteScanHistoryDatasource { - SqliteScanHistoryDatasource(); + factory SqliteScanHistoryDatasource() { + if (kIsWeb) return _InMemoryScanHistoryDatasource(); + return SqliteScanHistoryDatasource._native(); + } + + SqliteScanHistoryDatasource._native(); static const String _dbName = 'kudlit_scan.db'; static const int _dbVersion = 1; @@ -105,3 +116,42 @@ class SqliteScanHistoryDatasource { ); } } + +/// Web fallback: session-scoped in-memory scan history. Data is lost on page +/// reload — Supabase sync gives cross-session persistence for authenticated +/// users. +class _InMemoryScanHistoryDatasource extends SqliteScanHistoryDatasource { + _InMemoryScanHistoryDatasource() : super._native(); + + final List<ScanResult> _results = <ScanResult>[]; + int _nextId = 1; + + @override + Future<List<ScanResult>> loadAll({int? limit}) async { + final List<ScanResult> sorted = List<ScanResult>.from(_results) + ..sort((ScanResult a, ScanResult b) => (b.id ?? 0).compareTo(a.id ?? 0)); + if (limit == null || sorted.length <= limit) { + return List<ScanResult>.unmodifiable(sorted); + } + return List<ScanResult>.unmodifiable(sorted.take(limit)); + } + + @override + Future<ScanResult> insert(ScanResult result) async { + final ScanResult saved = result.copyWith(id: _nextId++); + _results.add(saved); + return saved; + } + + @override + Future<void> clear() async { + _results.clear(); + _nextId = 1; + } + + @override + Future<void> dispose() async { + _results.clear(); + _nextId = 1; + } +} diff --git a/lib/features/scanner/data/datasources/web_baybayin_detector_stub.dart b/lib/features/scanner/data/datasources/web_baybayin_detector_stub.dart index aee9277..ede3f61 100644 --- a/lib/features/scanner/data/datasources/web_baybayin_detector_stub.dart +++ b/lib/features/scanner/data/datasources/web_baybayin_detector_stub.dart @@ -1,7 +1,11 @@ import 'dart:typed_data'; +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/web_vision_model_url_resolver.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; +import 'package:kudlit_ph/features/scanner/domain/failures/scanner_failures.dart'; import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; BaybayinDetector createPlatformWebBaybayinDetector({ @@ -18,23 +22,37 @@ class WebBaybayinDetectorStub implements BaybayinDetector { const Stream<List<BaybayinDetection>>.empty(); @override - Future<List<BaybayinDetection>> detectImage(Uint8List imageBytes) async => - const <BaybayinDetection>[]; + Future<Either<Failure, List<BaybayinDetection>>> detectImage( + Uint8List imageBytes, + ) async => + right(const <BaybayinDetection>[]); @override - Future<Uint8List?> captureFrame() async => null; + Future<Either<Failure, Uint8List?>> captureFrame() async => right(null); @override - Future<void> toggleTorch({required bool enabled}) async {} + Future<Either<Failure, Unit>> toggleTorch({required bool enabled}) async => + left( + ScannerFailures.webUnsupported( + 'Torch is not available on the web scanner.', + ), + ); @override - Future<void> switchCamera() async {} + Future<Either<Failure, Unit>> switchCamera() async => left( + ScannerFailures.webUnsupported( + 'Camera switching is not available on the web scanner.', + ), + ); + /// Pause is a no-op on the stub — the stub has nothing running to pause — + /// so it returns success to keep tab-pause logic frictionless. @override - Future<void> pauseInference() async {} + Future<Either<Failure, Unit>> pauseInference() async => right(unit); + /// See [pauseInference]. @override - Future<void> resumeInference() async {} + Future<Either<Failure, Unit>> resumeInference() async => right(unit); @override void dispose() {} diff --git a/lib/features/scanner/data/datasources/web_tflite_baybayin_detector.dart b/lib/features/scanner/data/datasources/web_tflite_baybayin_detector.dart index f1ba6ea..c9e78fa 100644 --- a/lib/features/scanner/data/datasources/web_tflite_baybayin_detector.dart +++ b/lib/features/scanner/data/datasources/web_tflite_baybayin_detector.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:fpdart/fpdart.dart'; import 'package:tflite_web/tflite_web.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/web_tflite_model_runtime.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/web_vision_model_url_resolver.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/web_yolo_output_parser.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; +import 'package:kudlit_ph/features/scanner/domain/failures/scanner_failures.dart'; import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; const List<String> kBaybayinWebYoloLabels = <String>[ @@ -58,10 +61,22 @@ class WebTfliteBaybayinDetector implements BaybayinDetector { Stream<List<BaybayinDetection>> get detections => _detections.stream; @override - Future<List<BaybayinDetection>> detectImage(Uint8List imageBytes) async { - final TFLiteModel model = await _loadModel(); + Future<Either<Failure, List<BaybayinDetection>>> detectImage( + Uint8List imageBytes, + ) async { + final TFLiteModel model; + try { + model = await _loadModel(); + } on StateError catch (e) { + return left(ScannerFailures.init(e.message)); + } catch (e) { + return left(ScannerFailures.init(e.toString())); + } + if (model.inputs.isEmpty) { - throw StateError('The web scanner model has no input tensor.'); + return left( + ScannerFailures.init('The web scanner model has no input tensor.'), + ); } final ModelTensorInfo inputInfo = model.inputs.first; final List<int> inputShape = resolvedWebInputShape(inputInfo.shape); @@ -89,7 +104,11 @@ class WebTfliteBaybayinDetector implements BaybayinDetector { if (!_detections.isClosed) { _detections.add(detections); } - return detections; + return right(detections); + } on StateError catch (e) { + return left(ScannerFailures.inference(e.message)); + } catch (e) { + return left(ScannerFailures.inference(e.toString())); } finally { input.dispose(); for (final Tensor tensor in outputTensors) { @@ -128,7 +147,7 @@ class WebTfliteBaybayinDetector implements BaybayinDetector { } @override - Future<Uint8List?> captureFrame() async => null; + Future<Either<Failure, Uint8List?>> captureFrame() async => right(null); Future<TFLiteModel> _loadModel() async { final String? modelUrl = await modelUrlResolver(); @@ -145,16 +164,25 @@ class WebTfliteBaybayinDetector implements BaybayinDetector { } @override - Future<void> toggleTorch({required bool enabled}) async {} + Future<Either<Failure, Unit>> toggleTorch({required bool enabled}) async => + left( + ScannerFailures.webUnsupported( + 'Torch is not available on the web scanner.', + ), + ); @override - Future<void> switchCamera() async {} + Future<Either<Failure, Unit>> switchCamera() async => left( + ScannerFailures.webUnsupported( + 'Camera switching is not available on the web scanner.', + ), + ); @override - Future<void> pauseInference() async {} + Future<Either<Failure, Unit>> pauseInference() async => right(unit); @override - Future<void> resumeInference() async {} + Future<Either<Failure, Unit>> resumeInference() async => right(unit); @override void dispose() { diff --git a/lib/features/scanner/data/datasources/yolo_baybayin_detector.dart b/lib/features/scanner/data/datasources/yolo_baybayin_detector.dart index 0a1b6c5..f856736 100644 --- a/lib/features/scanner/data/datasources/yolo_baybayin_detector.dart +++ b/lib/features/scanner/data/datasources/yolo_baybayin_detector.dart @@ -2,9 +2,12 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/foundation.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:ultralytics_yolo/ultralytics_yolo.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; +import 'package:kudlit_ph/features/scanner/domain/failures/scanner_failures.dart'; import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; typedef SingleImageYoloFactory = YOLO Function(String modelPath); @@ -63,28 +66,43 @@ class YoloBaybayinDetector implements BaybayinDetector { Stream<List<BaybayinDetection>> get detections => _streamController.stream; @override - Future<List<BaybayinDetection>> detectImage(Uint8List imageBytes) async { - final YOLO yolo = await _singleImageModel(); - final Map<String, dynamic> result = await yolo.predict( - imageBytes, - confidenceThreshold: _kConfidenceThreshold, - iouThreshold: _kIoUThreshold, - ); - final List<BaybayinDetection> detections = - (result['detections'] as List<dynamic>? ?? const <dynamic>[]) - .whereType<Map<dynamic, dynamic>>() - .map(YOLOResult.fromMap) - .where(_isUsefulStillImageResult) - .map(_toDetection) - .toList(growable: false); - if (!_streamController.isClosed) { - _streamController.add(detections); + Future<Either<Failure, List<BaybayinDetection>>> detectImage( + Uint8List imageBytes, + ) async { + try { + final YOLO yolo = await _singleImageModel(); + final Map<String, dynamic> result = await yolo.predict( + imageBytes, + confidenceThreshold: _kConfidenceThreshold, + iouThreshold: _kIoUThreshold, + ); + final List<BaybayinDetection> detections = + (result['detections'] as List<dynamic>? ?? const <dynamic>[]) + .whereType<Map<dynamic, dynamic>>() + .map(YOLOResult.fromMap) + .where(_isUsefulStillImageResult) + .map(_toDetection) + .toList(growable: false); + if (!_streamController.isClosed) { + _streamController.add(detections); + } + return right(detections); + } on StateError catch (e) { + return left(ScannerFailures.init(e.message)); + } catch (e) { + return left(ScannerFailures.inference(e.toString())); } - return detections; } @override - Future<Uint8List?> captureFrame() => _controller.captureFrame(); + Future<Either<Failure, Uint8List?>> captureFrame() async { + try { + final Uint8List? frame = await _controller.captureFrame(); + return right(frame); + } catch (e) { + return left(ScannerFailures.capture(e.toString())); + } + } Future<YOLO> _singleImageModel() async { final Future<String> Function()? resolver = modelPathResolver; @@ -146,17 +164,44 @@ class YoloBaybayinDetector implements BaybayinDetector { } @override - Future<void> toggleTorch({required bool enabled}) => - _controller.setTorchMode(enabled); + Future<Either<Failure, Unit>> toggleTorch({required bool enabled}) async { + try { + await _controller.setTorchMode(enabled); + return right(unit); + } catch (e) { + return left(ScannerFailures.cameraControl(e.toString())); + } + } @override - Future<void> switchCamera() => _controller.switchCamera(); + Future<Either<Failure, Unit>> switchCamera() async { + try { + await _controller.switchCamera(); + return right(unit); + } catch (e) { + return left(ScannerFailures.cameraControl(e.toString())); + } + } @override - Future<void> pauseInference() => _controller.stop(); + Future<Either<Failure, Unit>> pauseInference() async { + try { + await _controller.stop(); + return right(unit); + } catch (e) { + return left(ScannerFailures.cameraControl(e.toString())); + } + } @override - Future<void> resumeInference() => _controller.restartCamera(); + Future<Either<Failure, Unit>> resumeInference() async { + try { + await _controller.restartCamera(); + return right(unit); + } catch (e) { + return left(ScannerFailures.cameraControl(e.toString())); + } + } @override void dispose() { diff --git a/lib/features/scanner/domain/failures/scanner_failures.dart b/lib/features/scanner/domain/failures/scanner_failures.dart new file mode 100644 index 0000000..c78083b --- /dev/null +++ b/lib/features/scanner/domain/failures/scanner_failures.dart @@ -0,0 +1,67 @@ +import 'package:kudlit_ph/core/error/failures.dart'; + +/// Scanner-specific failure constructors. +/// +/// The core [Failure] type is a sealed `freezed` class shared across the app, +/// so scanner failure kinds are produced here as tagged [Failure.unknown] +/// instances. Each factory prefixes the message with a stable scanner kind +/// token so presentation code can both display the message verbatim *and* +/// (optionally) branch on the failure kind via [scannerFailureKindOf]. +/// +/// This keeps the [Either<Failure, T>] return type consistent with the rest +/// of the codebase (auth, translator, learning, home, admin) while still +/// giving the scanner domain its own taxonomy of typed errors. +final class ScannerFailures { + const ScannerFailures._(); + + /// Model could not load, camera permission denied, hardware unsupported. + static Failure init(String message) => + Failure.unknown(message: '${ScannerFailureKind.init.token}: $message'); + + /// Inference (still-image or live frame) threw at runtime. + static Failure inference(String message) => Failure.unknown( + message: '${ScannerFailureKind.inference.token}: $message', + ); + + /// Camera frame capture failed (e.g. native controller returned null or + /// the platform raised an error while grabbing the bytes). + static Failure capture(String message) => + Failure.unknown(message: '${ScannerFailureKind.capture.token}: $message'); + + /// Camera control (torch / lens switch) failed. + static Failure cameraControl(String message) => Failure.unknown( + message: '${ScannerFailureKind.cameraControl.token}: $message', + ); + + /// The requested method is not supported on the current platform — used + /// by the web detector for torch / switch-camera / pause / resume so the + /// UI can render an explicit notice rather than silently succeeding. + static Failure webUnsupported(String message) => Failure.unknown( + message: '${ScannerFailureKind.webUnsupported.token}: $message', + ); +} + +/// Stable taxonomy of scanner failure kinds. The [token] is the leading +/// substring in [Failure.unknown.message] produced by [ScannerFailures]. +enum ScannerFailureKind { + init('SCANNER_INIT'), + inference('SCANNER_INFERENCE'), + capture('SCANNER_CAPTURE'), + cameraControl('SCANNER_CAMERA_CONTROL'), + webUnsupported('SCANNER_WEB_UNSUPPORTED'); + + const ScannerFailureKind(this.token); + + final String token; +} + +/// Returns the [ScannerFailureKind] encoded in [failure], when [failure] was +/// produced by [ScannerFailures]. Returns `null` for any other [Failure]. +ScannerFailureKind? scannerFailureKindOf(Failure failure) { + if (failure is! UnknownFailure) return null; + final String message = failure.message; + for (final ScannerFailureKind kind in ScannerFailureKind.values) { + if (message.startsWith('${kind.token}:')) return kind; + } + return null; +} diff --git a/lib/features/scanner/domain/repositories/baybayin_detector.dart b/lib/features/scanner/domain/repositories/baybayin_detector.dart index 2d3d132..0df6259 100644 --- a/lib/features/scanner/domain/repositories/baybayin_detector.dart +++ b/lib/features/scanner/domain/repositories/baybayin_detector.dart @@ -1,5 +1,8 @@ import 'dart:typed_data'; +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; /// Abstract interface for Baybayin detection. @@ -7,29 +10,39 @@ import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.da /// Implementations: /// - [YoloBaybayinDetector] — on-device YOLO (iOS / Android) /// - [WebTfliteBaybayinDetector] — browser TFLite inference for web +/// +/// Methods that can fail return [Either<Failure, T>] so presentation code can +/// `fold` on errors without relying on raw exceptions. The live detection +/// stream remains a plain [Stream] of detection lists; transient errors during +/// streaming are surfaced through the stream's own error channel by the +/// implementation. abstract class BaybayinDetector { /// Live stream of detections from the camera feed. /// Emits a new list each time the model processes a frame. Stream<List<BaybayinDetection>> get detections; /// Run inference on a single image (e.g. from the gallery). - Future<List<BaybayinDetection>> detectImage(Uint8List imageBytes); + Future<Either<Failure, List<BaybayinDetection>>> detectImage( + Uint8List imageBytes, + ); /// Capture the current camera frame when the platform detector owns a live - /// camera session. Returns null when unsupported or not ready. - Future<Uint8List?> captureFrame(); + /// camera session. Returns [Right(null)] when the platform does not support + /// frame capture or no frame is ready yet (this is a normal idle state, not + /// a failure); returns [Left] when the capture attempt itself errors. + Future<Either<Failure, Uint8List?>> captureFrame(); - /// Toggle the device torch / flash. No-op on web. - Future<void> toggleTorch({required bool enabled}); + /// Toggle the device torch / flash. + Future<Either<Failure, Unit>> toggleTorch({required bool enabled}); /// Switch between available camera lenses when the platform supports it. - Future<void> switchCamera(); + Future<Either<Failure, Unit>> switchCamera(); /// Pause live inference (e.g. while a result panel is visible). - Future<void> pauseInference(); + Future<Either<Failure, Unit>> pauseInference(); /// Resume live inference after a pause. - Future<void> resumeInference(); + Future<Either<Failure, Unit>> resumeInference(); /// Release all resources (camera, model). void dispose(); diff --git a/lib/features/scanner/domain/usecases/capture_frame.dart b/lib/features/scanner/domain/usecases/capture_frame.dart new file mode 100644 index 0000000..5d597fd --- /dev/null +++ b/lib/features/scanner/domain/usecases/capture_frame.dart @@ -0,0 +1,22 @@ +import 'dart:typed_data'; + +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Captures the current camera frame from a live preview. +/// +/// A successful `Right(null)` means the platform does not own a live frame +/// (e.g. web preview) — callers should fall back to a still-image source. +class CaptureFrame implements UseCase<Uint8List?, NoParams> { + const CaptureFrame(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, Uint8List?>> call(NoParams params) { + return _detector.captureFrame(); + } +} diff --git a/lib/features/scanner/domain/usecases/detect_baybayin.dart b/lib/features/scanner/domain/usecases/detect_baybayin.dart new file mode 100644 index 0000000..3d697f7 --- /dev/null +++ b/lib/features/scanner/domain/usecases/detect_baybayin.dart @@ -0,0 +1,22 @@ +import 'dart:typed_data'; + +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Runs Baybayin detection on a single still image (gallery pick or a +/// captured camera frame). +class DetectBaybayin + implements UseCase<List<BaybayinDetection>, Uint8List> { + const DetectBaybayin(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, List<BaybayinDetection>>> call(Uint8List params) { + return _detector.detectImage(params); + } +} diff --git a/lib/features/scanner/domain/usecases/pause_scanner.dart b/lib/features/scanner/domain/usecases/pause_scanner.dart new file mode 100644 index 0000000..3faf05e --- /dev/null +++ b/lib/features/scanner/domain/usecases/pause_scanner.dart @@ -0,0 +1,18 @@ +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Pauses the live scanner inference (e.g. when a result panel is visible +/// or the scan tab is no longer active). +class PauseScanner implements UseCase<Unit, NoParams> { + const PauseScanner(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, Unit>> call(NoParams params) { + return _detector.pauseInference(); + } +} diff --git a/lib/features/scanner/domain/usecases/resume_scanner.dart b/lib/features/scanner/domain/usecases/resume_scanner.dart new file mode 100644 index 0000000..e83d7cd --- /dev/null +++ b/lib/features/scanner/domain/usecases/resume_scanner.dart @@ -0,0 +1,17 @@ +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Resumes the live scanner inference after a pause. +class ResumeScanner implements UseCase<Unit, NoParams> { + const ResumeScanner(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, Unit>> call(NoParams params) { + return _detector.resumeInference(); + } +} diff --git a/lib/features/scanner/domain/usecases/switch_camera.dart b/lib/features/scanner/domain/usecases/switch_camera.dart new file mode 100644 index 0000000..2f00f55 --- /dev/null +++ b/lib/features/scanner/domain/usecases/switch_camera.dart @@ -0,0 +1,17 @@ +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Switches between available camera lenses on the live preview. +class SwitchCamera implements UseCase<Unit, NoParams> { + const SwitchCamera(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, Unit>> call(NoParams params) { + return _detector.switchCamera(); + } +} diff --git a/lib/features/scanner/domain/usecases/toggle_torch.dart b/lib/features/scanner/domain/usecases/toggle_torch.dart new file mode 100644 index 0000000..b637234 --- /dev/null +++ b/lib/features/scanner/domain/usecases/toggle_torch.dart @@ -0,0 +1,17 @@ +import 'package:fpdart/fpdart.dart'; + +import 'package:kudlit_ph/core/error/failures.dart'; +import 'package:kudlit_ph/core/usecases/usecase.dart'; +import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; + +/// Toggles the device torch / flash on the live camera preview. +class ToggleTorch implements UseCase<Unit, bool> { + const ToggleTorch(this._detector); + + final BaybayinDetector _detector; + + @override + Future<Either<Failure, Unit>> call(bool params) { + return _detector.toggleTorch(enabled: params); + } +} diff --git a/lib/features/scanner/presentation/providers/scan_tab_controller.dart b/lib/features/scanner/presentation/providers/scan_tab_controller.dart index 6b1f6d6..09398f6 100644 --- a/lib/features/scanner/presentation/providers/scan_tab_controller.dart +++ b/lib/features/scanner/presentation/providers/scan_tab_controller.dart @@ -3,8 +3,10 @@ import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/core/utils/baybayify.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/scanner_evaluation_provider.dart'; @@ -140,11 +142,29 @@ class ScanTabController extends Notifier<ScanTabState> { Future<void> toggleFlash() async { final bool next = !state.flashOn; state = state.copyWith(flashOn: next); - await ref.read(baybayinDetectorProvider).toggleTorch(enabled: next); + final Either<Failure, Unit> result = await ref + .read(baybayinDetectorProvider) + .toggleTorch(enabled: next); + result.fold( + (Failure failure) { + debugPrint('[ScanTab] toggleTorch failed: ${_messageOf(failure)}'); + // Revert the optimistic flash state so the UI doesn't lie about it. + state = state.copyWith(flashOn: !next); + }, + (_) {}, + ); } Future<void> switchCamera() async { - await ref.read(baybayinDetectorProvider).switchCamera(); + final Either<Failure, Unit> result = await ref + .read(baybayinDetectorProvider) + .switchCamera(); + result.fold( + (Failure failure) { + debugPrint('[ScanTab] switchCamera failed: ${_messageOf(failure)}'); + }, + (_) {}, + ); } Future<void> pickImageFromGallery() async { @@ -159,7 +179,7 @@ class ScanTabController extends Notifier<ScanTabState> { await processGalleryImageBytes(bytes); } catch (e) { _beginStillImageScan(); - _finishStillImageError(_noticeForStillImageError(e)); + _finishStillImageError(_noticeForStillImageError(e.toString())); } } @@ -171,12 +191,18 @@ class ScanTabController extends Notifier<ScanTabState> { Future<void> captureNativeFrame({Uint8List? fallbackBytes}) async { _beginStillImageScan(); - Uint8List? imageBytes; - try { - imageBytes = await ref.read(baybayinDetectorProvider).captureFrame(); - } catch (e) { - debugPrint('[ScanTab] native camera frame capture failed: $e'); - } + final Either<Failure, Uint8List?> result = await ref + .read(baybayinDetectorProvider) + .captureFrame(); + Uint8List? imageBytes = result.fold( + (Failure failure) { + debugPrint( + '[ScanTab] native camera frame capture failed: ${_messageOf(failure)}', + ); + return null; + }, + (Uint8List? bytes) => bytes, + ); imageBytes ??= fallbackBytes; if (imageBytes == null || imageBytes.isEmpty) { @@ -345,7 +371,18 @@ class ScanTabController extends Notifier<ScanTabState> { clearScanNotice: true, ); if (!kIsWeb) { - ref.read(baybayinDetectorProvider).resumeInference(); + unawaited( + ref.read(baybayinDetectorProvider).resumeInference().then(( + Either<Failure, Unit> result, + ) { + result.fold( + (Failure failure) => debugPrint( + '[ScanTab] resumeInference failed: ${_messageOf(failure)}', + ), + (_) {}, + ); + }), + ); } } @@ -397,15 +434,19 @@ class ScanTabController extends Notifier<ScanTabState> { _beginStillImageScan(); } - try { - final List<BaybayinDetection> results = await ref - .read(baybayinDetectorProvider) - .detectImage(bytes); - _finishStillImageScan(results, imageBytes: bytes, source: source); - } catch (e) { - debugPrint('[ScanTab] still-image scan failed: $e'); - _finishStillImageError(_noticeForStillImageError(e)); - } + final Either<Failure, List<BaybayinDetection>> result = await ref + .read(baybayinDetectorProvider) + .detectImage(bytes); + result.fold( + (Failure failure) { + final String message = _messageOf(failure); + debugPrint('[ScanTab] still-image scan failed: $message'); + _finishStillImageError(_noticeForStillImageError(message)); + }, + (List<BaybayinDetection> results) { + _finishStillImageScan(results, imageBytes: bytes, source: source); + }, + ); } void _beginStillImageScan() { @@ -483,8 +524,19 @@ class ScanTabController extends Notifier<ScanTabState> { ); } - ScanNotice _noticeForStillImageError(Object error) { - final String raw = error.toString().toLowerCase(); + /// Extracts a human-readable message from any [Failure] sealed variant for + /// debug logging. Presentation copy lives in [_noticeForStillImageError] and + /// the scan notice widgets, not here. + String _messageOf(Failure failure) { + return switch (failure) { + NetworkFailure(:final String message) => message, + UnknownFailure(:final String message) => message, + _ => failure.toString(), + }; + } + + ScanNotice _noticeForStillImageError(String error) { + final String raw = error.toLowerCase(); if (raw.contains('permission')) { return const ScanNotice( title: 'Image access blocked', diff --git a/lib/features/scanner/presentation/widgets/scanner_camera.dart b/lib/features/scanner/presentation/widgets/scanner_camera.dart index 16b552f..7d6e3aa 100644 --- a/lib/features/scanner/presentation/widgets/scanner_camera.dart +++ b/lib/features/scanner/presentation/widgets/scanner_camera.dart @@ -5,10 +5,12 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; import 'package:go_router/go_router.dart'; import 'package:ultralytics_yolo/ultralytics_yolo.dart'; import 'package:kudlit_ph/app/constants.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/yolo_baybayin_detector.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/scan_tab_controller.dart'; @@ -560,17 +562,12 @@ class _WebCameraPreviewState extends ConsumerState<_WebCameraPreview> { } _setStatus(WebScannerStatus.detecting); + final Uint8List bytes; try { final XFile image = await controller.takePicture(); - final Uint8List bytes = await image.readAsBytes(); - final List<BaybayinDetection> detections = await ref - .read(baybayinDetectorProvider) - .detectImage(bytes); - widget.onDetections(detections); - _setStatus(WebScannerStatus.ready); - return (detections, bytes); + bytes = await image.readAsBytes(); } catch (e) { - final ScanNotice notice = _noticeForCaptureError(e); + final ScanNotice notice = _noticeForCaptureError(e.toString()); _setStatus( notice.title == 'Web model unavailable' ? WebScannerStatus.modelUnavailable @@ -579,10 +576,41 @@ class _WebCameraPreviewState extends ConsumerState<_WebCameraPreview> { ); throw ScanCaptureException(notice); } + + final Either<Failure, List<BaybayinDetection>> result = await ref + .read(baybayinDetectorProvider) + .detectImage(bytes); + return result.fold( + (Failure failure) { + final ScanNotice notice = _noticeForCaptureError( + _failureMessage(failure), + ); + _setStatus( + notice.title == 'Web model unavailable' + ? WebScannerStatus.modelUnavailable + : WebScannerStatus.error, + message: notice.message, + ); + throw ScanCaptureException(notice); + }, + (List<BaybayinDetection> detections) { + widget.onDetections(detections); + _setStatus(WebScannerStatus.ready); + return (detections, bytes); + }, + ); + } + + String _failureMessage(Failure failure) { + return switch (failure) { + NetworkFailure(:final String message) => message, + UnknownFailure(:final String message) => message, + _ => failure.toString(), + }; } - ScanNotice _noticeForCaptureError(Object error) { - final String raw = error.toString().toLowerCase(); + ScanNotice _noticeForCaptureError(String error) { + final String raw = error.toLowerCase(); if (raw.contains('404') || raw.contains('not_found') || raw.contains('object not found')) { diff --git a/lib/features/translator/data/datasources/cloud_gemma_datasource.dart b/lib/features/translator/data/datasources/cloud_gemma_datasource.dart index 952d7b4..4e836ae 100644 --- a/lib/features/translator/data/datasources/cloud_gemma_datasource.dart +++ b/lib/features/translator/data/datasources/cloud_gemma_datasource.dart @@ -6,6 +6,7 @@ import 'package:genkit/genkit.dart'; // ignore: implementation_imports import 'package:genkit/src/ai/generate.dart' show GenerateResponse; import 'package:genkit_google_genai/genkit_google_genai.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:kudlit_ph/features/translator/data/datasources/ai_datasource.dart'; import 'package:kudlit_ph/features/translator/domain/entities/baybayin_challenge.dart'; @@ -14,6 +15,10 @@ import 'package:kudlit_ph/features/translator/domain/entities/chat_message.dart' /// The model used for all cloud Baybayin inference. (using Gemma) const String _kModel = 'gemma-4-26b-a4b-it'; +/// Name of the Supabase Edge Function that proxies Gemini calls. The function +/// owns the upstream `GEMINI_API_KEY`; the client never sees it. +const String _kProxyFunction = 'gemini-proxy'; + /// System prompt that scopes Butty to Baybayin / Filipino culture only. const String _kChatSystemPrompt = ''' You are Butty, a friendly Baybayin learning companion inside the Kudlit app. @@ -37,21 +42,28 @@ Schema: } '''; -/// Live cloud inference datasource powered by Genkit + Google AI (Gemini). +/// Live cloud inference datasource. +/// +/// Production builds use the Supabase Edge Function `gemini-proxy` so the +/// Google AI Studio key never ships to clients. Tests inject a fake [Genkit] +/// via [CloudGemmaDatasource.withAi] to avoid touching the network. /// /// One instance is kept alive for the lifetime of the app via the /// `cloudGemmaDatasourceProvider`. class CloudGemmaDatasource implements AiDatasource { - /// Production constructor — creates a real [Genkit] instance. - CloudGemmaDatasource({required String apiKey}) - : _ai = Genkit(plugins: [googleAI(apiKey: apiKey)]); + /// Production constructor — routes through the Supabase Edge Function. + CloudGemmaDatasource({required SupabaseClient supabase}) + : _supabase = supabase, + _ai = null; /// Test constructor — accepts an injected [Genkit] instance. - CloudGemmaDatasource.withAi(Genkit ai) : _ai = ai; + CloudGemmaDatasource.withAi(Genkit ai) : _ai = ai, _supabase = null; - final Genkit _ai; + final Genkit? _ai; + final SupabaseClient? _supabase; - ModelRef<GeminiOptions> get _model => googleAI.gemini(_kModel); + ModelRef<GeminiOptions>? get _model => + _ai == null ? null : googleAI.gemini(_kModel); // ─── 1. Scoped chat ──────────────────────────────────────────────────────── @@ -64,16 +76,27 @@ class CloudGemmaDatasource implements AiDatasource { Stream<String> generate( List<ChatMessage> history, { String? systemInstruction, + }) { + final String prompt = systemInstruction ?? _kChatSystemPrompt; + if (_ai != null) { + return _generateViaGenkit(history, systemInstruction: prompt); + } + return _generateViaProxy(history, systemInstruction: prompt); + } + + Stream<String> _generateViaGenkit( + List<ChatMessage> history, { + required String systemInstruction, }) { final StreamController<String> controller = StreamController<String>(); - final List<Message> messages = _buildMessages( + final List<Message> messages = _buildGenkitMessages( history, - systemInstruction: systemInstruction ?? _kChatSystemPrompt, + systemInstruction: systemInstruction, ); - _ai + _ai! .generate( - model: _model, + model: _model!, messages: messages, onChunk: (GenerateResponseChunk chunk) { final String token = chunk.content @@ -96,6 +119,31 @@ class CloudGemmaDatasource implements AiDatasource { return controller.stream; } + Stream<String> _generateViaProxy( + List<ChatMessage> history, { + required String systemInstruction, + }) async* { + final Map<String, dynamic> payload = <String, dynamic>{ + 'systemInstruction': <String, dynamic>{ + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{'text': systemInstruction}, + ], + }, + 'contents': <Map<String, dynamic>>[ + for (final ChatMessage msg in history) + <String, dynamic>{ + 'role': msg.isUser ? 'user' : 'model', + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{'text': msg.text}, + ], + }, + ], + }; + + final String text = await _invokeProxy(payload); + if (text.isNotEmpty) yield text; + } + // ─── 2. Image analysis ──────────────────────────────────────────────────── /// Streams a description / translation of drawn or photographed @@ -108,17 +156,32 @@ class CloudGemmaDatasource implements AiDatasource { String mimeType = 'image/png', String? prompt, }) { - final StreamController<String> controller = StreamController<String>(); - final String base64Image = base64Encode(imageBytes); - final String dataUrl = 'data:$mimeType;base64,$base64Image'; - - // When a custom prompt is provided (e.g. sketchpad evaluator), treat it - // as a system instruction so the model follows it rather than echoing it. - // The user message is just the image + a minimal trigger phrase. final String systemInstruction = prompt ?? 'Identify the Baybayin character(s) in this image. ' 'Give the romanized equivalent and a short explanation of each.'; + if (_ai != null) { + return _analyzeImageViaGenkit( + imageBytes, + mimeType: mimeType, + systemInstruction: systemInstruction, + ); + } + return _analyzeImageViaProxy( + imageBytes, + mimeType: mimeType, + systemInstruction: systemInstruction, + ); + } + + Stream<String> _analyzeImageViaGenkit( + Uint8List imageBytes, { + required String mimeType, + required String systemInstruction, + }) { + final StreamController<String> controller = StreamController<String>(); + final String base64Image = base64Encode(imageBytes); + final String dataUrl = 'data:$mimeType;base64,$base64Image'; final List<Message> messages = <Message>[ Message.from( @@ -136,9 +199,9 @@ class CloudGemmaDatasource implements AiDatasource { ), ]; - _ai + _ai! .generate( - model: _model, + model: _model!, messages: messages, onChunk: (GenerateResponseChunk chunk) { final String token = chunk.content @@ -161,6 +224,38 @@ class CloudGemmaDatasource implements AiDatasource { return controller.stream; } + Stream<String> _analyzeImageViaProxy( + Uint8List imageBytes, { + required String mimeType, + required String systemInstruction, + }) async* { + final String base64Image = base64Encode(imageBytes); + final Map<String, dynamic> payload = <String, dynamic>{ + 'systemInstruction': <String, dynamic>{ + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{'text': systemInstruction}, + ], + }, + 'contents': <Map<String, dynamic>>[ + <String, dynamic>{ + 'role': 'user', + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{ + 'inlineData': <String, dynamic>{ + 'mimeType': mimeType, + 'data': base64Image, + }, + }, + <String, dynamic>{'text': 'Evaluate this drawing.'}, + ], + }, + ], + }; + + final String text = await _invokeProxy(payload); + if (text.isNotEmpty) yield text; + } + // ─── 3. Challenge generation ────────────────────────────────────────────── /// Asks Gemini to produce one Baybayin challenge, returned as a typed @@ -179,6 +274,15 @@ class CloudGemmaDatasource implements AiDatasource { userPrompt.write(' Focus on these characters: ${characters.join(', ')}.'); } + if (_ai != null) { + return _generateChallengeViaGenkit(userPrompt.toString()); + } + return _generateChallengeViaProxy(userPrompt.toString()); + } + + Future<BaybayinChallenge> _generateChallengeViaGenkit( + String userPrompt, + ) async { final List<Message> messages = <Message>[ Message.from( role: Role.system, @@ -186,13 +290,13 @@ class CloudGemmaDatasource implements AiDatasource { ), Message.from( role: Role.user, - content: <Part>[TextPart.from(text: userPrompt.toString())], + content: <Part>[TextPart.from(text: userPrompt)], ), ]; final StringBuffer raw = StringBuffer(); - final GenerateResponse response = await _ai.generate( - model: _model, + final GenerateResponse response = await _ai!.generate( + model: _model!, messages: messages, onChunk: (GenerateResponseChunk chunk) { final String token = chunk.content @@ -210,11 +314,97 @@ class CloudGemmaDatasource implements AiDatasource { return _parseChallenge(json); } + Future<BaybayinChallenge> _generateChallengeViaProxy( + String userPrompt, + ) async { + final Map<String, dynamic> payload = <String, dynamic>{ + 'systemInstruction': <String, dynamic>{ + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{'text': _kChallengeSystemPrompt}, + ], + }, + 'contents': <Map<String, dynamic>>[ + <String, dynamic>{ + 'role': 'user', + 'parts': <Map<String, dynamic>>[ + <String, dynamic>{'text': userPrompt}, + ], + }, + ], + }; + + final String text = await _invokeProxy(payload); + return _parseChallenge(text); + } + // ─── Helpers ────────────────────────────────────────────────────────────── + /// Invokes the Supabase `gemini-proxy` Edge Function with [payload] (a raw + /// Gemini REST request body) and returns the first candidate's text. + /// + /// The Supabase client automatically forwards the signed-in user's JWT in + /// the `Authorization` header, which the Edge Function verifies before + /// calling Gemini with the server-held API key. + Future<String> _invokeProxy(Map<String, dynamic> payload) async { + final FunctionResponse response = await _supabase!.functions.invoke( + _kProxyFunction, + body: <String, dynamic>{ + 'model': _kModel, + 'stream': false, + 'payload': payload, + }, + ); + + final int status = response.status; + if (status < 200 || status >= 300) { + throw Exception( + 'gemini-proxy returned $status: ${response.data}', + ); + } + + return _extractTextFromGeminiResponse(response.data); + } + + /// Extracts the concatenated text of the first candidate from a Gemini + /// `generateContent` response. Returns an empty string if the response is + /// shaped unexpectedly. + String _extractTextFromGeminiResponse(Object? data) { + Map<String, dynamic>? json; + if (data is Map<String, dynamic>) { + json = data; + } else if (data is String && data.isNotEmpty) { + try { + json = jsonDecode(data) as Map<String, dynamic>; + } catch (_) { + return ''; + } + } + if (json == null) return ''; + + final List<dynamic>? candidates = json['candidates'] as List<dynamic>?; + if (candidates == null || candidates.isEmpty) return ''; + + final Map<String, dynamic>? first = + candidates.first as Map<String, dynamic>?; + final Map<String, dynamic>? content = + first?['content'] as Map<String, dynamic>?; + final List<dynamic>? parts = content?['parts'] as List<dynamic>?; + if (parts == null) return ''; + + final StringBuffer buf = StringBuffer(); + for (final dynamic part in parts) { + if (part is Map<String, dynamic>) { + final Object? text = part['text']; + if (text is String) buf.write(text); + } + } + return buf.toString(); + } + /// Converts domain [ChatMessage] list → Genkit [Message] list, - /// prepending a system instruction message. - List<Message> _buildMessages( + /// prepending a system instruction message. Used only by the [withAi] + /// (test) code path. + List<Message> _buildGenkitMessages( List<ChatMessage> history, { required String systemInstruction, }) { diff --git a/lib/features/translator/data/datasources/local_gemma_datasource.dart b/lib/features/translator/data/datasources/local_gemma_datasource.dart index 8e8632d..8c0a05c 100644 --- a/lib/features/translator/data/datasources/local_gemma_datasource.dart +++ b/lib/features/translator/data/datasources/local_gemma_datasource.dart @@ -25,11 +25,44 @@ class LocalGemmaDatasource implements AiDatasource { bool _activeModelHasVision = false; InferenceChat? _chat; + /// Last model we know is installed for this device. flutter_gemma's native + /// "active model" is process-scoped and is lost on every app restart, while + /// the downloaded file persists. Remembering the model lets the inference + /// path reactivate it on demand instead of relying on a UI readiness probe + /// having run first. + GemmaModelInfo? _knownModel; + + /// Records [model] as the installed model without doing any native work. + /// Called from the inference notifier as soon as it resolves the active + /// model so reactivation can self-heal even before any readiness probe. + void rememberModel(GemmaModelInfo model) { + _knownModel = model; + } + + /// Reactivates the installed model into the native engine when the engine + /// reports no active model (typical after an app restart). No-op when an + /// active model already exists, when we don't know the model, or when the + /// file is not installed (the caller's `getActiveModel()` then surfaces the + /// error and the repository falls back to cloud). + Future<void> _reactivateIfNeeded() async { + if (FlutterGemma.hasActiveModel() || _knownModel == null) return; + final bool installed = await FlutterGemma.isModelInstalled( + _knownModel!.fileName, + ); + if (!installed) return; + debugPrint( + '[Gemma][local] engine has no active model — reactivating ' + '${_knownModel!.fileName}', + ); + await _reactivateInstalledModel(_knownModel!); + } + // Mutex so concurrent probeReadiness calls share one native operation. bool _probing = false; Future<LocalGemmaReadiness>? _pendingProbe; Future<LocalGemmaReadiness> probeReadiness(GemmaModelInfo model) { + _knownModel = model; // Fast path: model is already loaded — skip all native work. if (_activeModel != null) { debugPrint( @@ -123,6 +156,7 @@ class LocalGemmaDatasource implements AiDatasource { GemmaModelInfo model, { void Function(int progress)? onProgress, }) async { + _knownModel = model; _cancelToken = CancelToken(); try { final String? hfToken = dotenv.env['HUGGINGFACE_TOKEN']; @@ -168,6 +202,9 @@ class LocalGemmaDatasource implements AiDatasource { ); // Vision-enabled models work fine for text generation, so reuse // _activeModel regardless of _activeModelHasVision. + if (_activeModel == null) { + await _reactivateIfNeeded(); + } _activeModel ??= await FlutterGemma.getActiveModel(); debugPrint('[Gemma][local] active model ready'); _chat ??= await _activeModel!.createChat( @@ -229,6 +266,9 @@ class LocalGemmaDatasource implements AiDatasource { await _activeModel!.close(); _activeModel = null; } + if (_activeModel == null) { + await _reactivateIfNeeded(); + } _activeModel ??= await FlutterGemma.getActiveModel( supportImage: true, maxNumImages: 1, diff --git a/lib/features/translator/data/datasources/sqlite_chat_datasource.dart b/lib/features/translator/data/datasources/sqlite_chat_datasource.dart index a4c2cef..b9c0983 100644 --- a/lib/features/translator/data/datasources/sqlite_chat_datasource.dart +++ b/lib/features/translator/data/datasources/sqlite_chat_datasource.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -6,6 +7,11 @@ import 'package:kudlit_ph/features/translator/domain/entities/chat_message.dart' /// SQLite-backed chat history store. /// +/// On web, where `sqflite` is unavailable, this resolves to an in-memory +/// implementation (see [_InMemoryChatDatasource]) that keeps chat history for +/// the current browser session only. Supabase sync still provides +/// cross-session persistence for authenticated users. +/// /// Schema: /// ```sql /// CREATE TABLE chat_messages ( @@ -17,7 +23,12 @@ import 'package:kudlit_ph/features/translator/domain/entities/chat_message.dart' /// ); /// ``` class SqliteChatDatasource { - SqliteChatDatasource(); + factory SqliteChatDatasource() { + if (kIsWeb) return _InMemoryChatDatasource(); + return SqliteChatDatasource._native(); + } + + SqliteChatDatasource._native(); static const String _dbName = 'kudlit_chat.db'; static const int _dbVersion = 2; @@ -148,3 +159,65 @@ class SqliteChatDatasource { ); } } + +/// Web fallback: session-scoped in-memory chat history. Data is lost on page +/// reload — Supabase sync gives cross-session persistence for authenticated +/// users. +class _InMemoryChatDatasource extends SqliteChatDatasource { + _InMemoryChatDatasource() : super._native(); + + final List<ChatMessage> _messages = <ChatMessage>[]; + int _nextId = 1; + + int _compareById(ChatMessage a, ChatMessage b) => + (a.id ?? 0).compareTo(b.id ?? 0); + + @override + Future<List<ChatMessage>> loadAll({int? limit}) async { + final List<ChatMessage> sorted = List<ChatMessage>.from(_messages) + ..sort(_compareById); + if (limit == null || sorted.length <= limit) { + return List<ChatMessage>.unmodifiable(sorted); + } + return List<ChatMessage>.unmodifiable(sorted.take(limit)); + } + + @override + Future<List<ChatMessage>> loadRecent({required int limit}) async { + final List<ChatMessage> sorted = List<ChatMessage>.from(_messages) + ..sort(_compareById); + if (sorted.length <= limit) return List<ChatMessage>.unmodifiable(sorted); + return List<ChatMessage>.unmodifiable( + sorted.sublist(sorted.length - limit), + ); + } + + @override + Future<ChatMessage> insert(ChatMessage message) async { + final ChatMessage saved = message.copyWith(id: _nextId++); + _messages.add(saved); + return saved; + } + + @override + Future<void> setRemoteId({ + required int localId, + required String remoteId, + }) async { + final int idx = _messages.indexWhere((ChatMessage m) => m.id == localId); + if (idx == -1) return; + _messages[idx] = _messages[idx].copyWith(remoteId: remoteId); + } + + @override + Future<void> clear() async { + _messages.clear(); + _nextId = 1; + } + + @override + Future<void> dispose() async { + _messages.clear(); + _nextId = 1; + } +} diff --git a/lib/features/translator/data/datasources/sqlite_chat_memory_datasource.dart b/lib/features/translator/data/datasources/sqlite_chat_memory_datasource.dart index eec267e..45adc10 100644 --- a/lib/features/translator/data/datasources/sqlite_chat_memory_datasource.dart +++ b/lib/features/translator/data/datasources/sqlite_chat_memory_datasource.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -6,6 +7,11 @@ import 'package:kudlit_ph/features/translator/domain/entities/chat_memory_fact.d /// SQLite-backed long-term memory for the Butty chat. /// +/// On web, where `sqflite` is unavailable, this resolves to an in-memory +/// implementation that keeps facts for the current browser session only. +/// Supabase sync still provides cross-session persistence for authenticated +/// users. +/// /// Schema: /// ```sql /// CREATE TABLE chat_memory_facts ( @@ -20,7 +26,12 @@ import 'package:kudlit_ph/features/translator/domain/entities/chat_memory_fact.d /// CREATE UNIQUE INDEX chat_memory_facts_norm ON chat_memory_facts(normalized); /// ``` class SqliteChatMemoryDatasource { - SqliteChatMemoryDatasource(); + factory SqliteChatMemoryDatasource() { + if (kIsWeb) return _InMemoryChatMemoryDatasource(); + return SqliteChatMemoryDatasource._native(); + } + + SqliteChatMemoryDatasource._native(); static const String _dbName = 'kudlit_chat_memory.db'; static const int _dbVersion = 1; @@ -202,3 +213,98 @@ class SqliteChatMemoryDatasource { ); } } + +/// Web fallback: session-scoped in-memory chat memory store, keyed by +/// normalized content to honour the dedupe contract. +class _InMemoryChatMemoryDatasource extends SqliteChatMemoryDatasource { + _InMemoryChatMemoryDatasource() : super._native(); + + final Map<int, ChatMemoryFact> _factsById = <int, ChatMemoryFact>{}; + final Set<String> _normalizedSeen = <String>{}; + int _nextId = 1; + + @override + Future<List<ChatMemoryFact>> loadAll({int? limit}) async { + final List<ChatMemoryFact> sorted = _factsById.values.toList() + ..sort( + (ChatMemoryFact a, ChatMemoryFact b) => + b.createdAt.compareTo(a.createdAt), + ); + if (limit == null || sorted.length <= limit) { + return List<ChatMemoryFact>.unmodifiable(sorted); + } + return List<ChatMemoryFact>.unmodifiable(sorted.take(limit)); + } + + @override + Future<ChatMemoryFact?> insertIfNew(ChatMemoryFact fact) async { + final String normalized = SqliteChatMemoryDatasource.normalize( + fact.content, + ); + if (_normalizedSeen.contains(normalized)) return null; + final ChatMemoryFact saved = fact.copyWith(id: _nextId++); + _factsById[saved.id!] = saved; + _normalizedSeen.add(normalized); + return saved; + } + + @override + Future<void> setRemoteId({ + required int localId, + required String remoteId, + }) async { + final ChatMemoryFact? existing = _factsById[localId]; + if (existing == null) return; + _factsById[localId] = existing.copyWith(remoteId: remoteId); + } + + @override + Future<void> updateFact({ + required int localId, + required String factType, + required String content, + }) async { + final ChatMemoryFact? existing = _factsById[localId]; + if (existing == null) return; + final String oldNorm = SqliteChatMemoryDatasource.normalize( + existing.content, + ); + final String newNorm = SqliteChatMemoryDatasource.normalize(content); + _normalizedSeen + ..remove(oldNorm) + ..add(newNorm); + _factsById[localId] = existing.copyWith( + factType: factType, + content: content, + lastReferencedAt: DateTime.now(), + ); + } + + @override + Future<ChatMemoryFact?> findById(int localId) async { + return _factsById[localId]; + } + + @override + Future<void> deleteById(int localId) async { + final ChatMemoryFact? existing = _factsById.remove(localId); + if (existing == null) return; + _normalizedSeen.remove( + SqliteChatMemoryDatasource.normalize(existing.content), + ); + } + + @override + Future<void> clear() async { + _factsById.clear(); + _normalizedSeen.clear(); + _nextId = 1; + } + + @override + Future<void> dispose() async { + _factsById.clear(); + _normalizedSeen.clear(); + _nextId = 1; + } +} diff --git a/lib/features/translator/presentation/providers/ai_inference_provider.dart b/lib/features/translator/presentation/providers/ai_inference_provider.dart index 01e2de8..4572567 100644 --- a/lib/features/translator/presentation/providers/ai_inference_provider.dart +++ b/lib/features/translator/presentation/providers/ai_inference_provider.dart @@ -83,9 +83,15 @@ class AiInferenceNotifier extends _$AiInferenceNotifier { return AiInferenceError(_failureMessage(f)); } final bool installed = installedResult.getRight().getOrElse(() => false); - return installed - ? AiReady(mode: AiPreference.local, activeModel: active) - : AiLocalModelMissing(active); + if (installed) { + // The native engine loses its active model on every app restart while + // the downloaded file persists. Tell the datasource which model is + // installed so the first inference call can reactivate it on demand + // instead of silently falling back to cloud. + ref.read(localGemmaDatasourceProvider).rememberModel(active); + return AiReady(mode: AiPreference.local, activeModel: active); + } + return AiLocalModelMissing(active); } /// Picks the median-ranked model unless [preferredId] is set diff --git a/lib/features/translator/presentation/providers/translator_providers.dart b/lib/features/translator/presentation/providers/translator_providers.dart index 41f4aff..cbe9c40 100644 --- a/lib/features/translator/presentation/providers/translator_providers.dart +++ b/lib/features/translator/presentation/providers/translator_providers.dart @@ -1,6 +1,5 @@ // ignore: unnecessary_import — flutter_riverpod is needed for Ref resolution import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -51,8 +50,12 @@ LocalGemmaDatasource localGemmaDatasource(Ref ref) { @Riverpod(keepAlive: true) CloudGemmaDatasource cloudGemmaDatasource(Ref ref) { - final String apiKey = dotenv.env['GEMINI_API_KEY'] ?? ''; - final CloudGemmaDatasource ds = CloudGemmaDatasource(apiKey: apiKey); + // The Gemini API key is intentionally NOT read on the client. All cloud + // Gemma calls are proxied through the Supabase Edge Function + // `gemini-proxy`, which holds the upstream key server-side and verifies + // the caller's Supabase JWT. + final SupabaseClient client = ref.watch(supabaseClientProvider); + final CloudGemmaDatasource ds = CloudGemmaDatasource(supabase: client); ref.onDispose(ds.dispose); return ds; } diff --git a/supabase/functions/gemini-proxy/README.md b/supabase/functions/gemini-proxy/README.md new file mode 100644 index 0000000..8a8e0dd --- /dev/null +++ b/supabase/functions/gemini-proxy/README.md @@ -0,0 +1,69 @@ +# `gemini-proxy` Edge Function + +Server-side proxy for Google AI Studio (Gemini) calls. The Flutter client +authenticates with its Supabase user JWT; this function verifies the JWT, +applies a best-effort per-user rate limit, then forwards the request to the +Gemini REST API using a `GEMINI_API_KEY` that lives **only** in the function's +environment. This prevents the key from shipping inside the Flutter APK / web +bundle. + +## Deploy + +```bash +# 1. Set the upstream Gemini key (one-time; rotate as needed). +supabase secrets set GEMINI_API_KEY=<your-google-ai-studio-key> + +# 2. Deploy the function. +supabase functions deploy gemini-proxy +``` + +> Both steps are required. Until the secret is set **and** the function is +> deployed, **cloud Gemma features in the Kudlit app will fail** (chat, +> sketchpad analysis, and challenge generation will surface an error from +> `CloudGemmaDatasource`). + +`SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are injected automatically by +Supabase into every deployed function — you do not need to set those. + +## Request shape + +The client sends (via `supabase.functions.invoke('gemini-proxy', body: ...)`): + +```json +{ + "model": "gemma-4-26b-a4b-it", + "stream": false, + "payload": { + "contents": [...], + "systemInstruction": { "parts": [{ "text": "..." }] }, + "generationConfig": { ... } + } +} +``` + +- `model` — optional, defaults to `gemma-4-26b-a4b-it`. +- `stream` — when `true`, the function calls Gemini's + `streamGenerateContent` endpoint with `alt=sse` and pipes the SSE response + back to the caller. When `false`, it calls `generateContent` and returns the + full JSON response. +- `payload` — forwarded verbatim as the Gemini REST request body. + +## Rate limiting + +In-memory: 10 requests / 60s / user, per Supabase Edge isolate. This is a +best-effort soft limit — under multi-isolate fan-out the effective ceiling can +be N × 10 / minute. For a hard global limit, swap the in-memory map for a +Supabase table or Upstash/Redis counter keyed by `user_id` with a 60s TTL +(see the `TODO` in `index.ts`). + +## Failure modes + +| Status | `error` body | Meaning | +| ------ | --------------------------- | --------------------------------------------- | +| 401 | `missing_authorization` | No `Authorization: Bearer …` header | +| 401 | `invalid_jwt` | JWT failed Supabase auth verification | +| 400 | `invalid_json` / `missing_payload` | Bad client request body | +| 429 | `rate_limited` | Per-user 10/min ceiling reached | +| 500 | `server_misconfigured` | `SUPABASE_URL` / service-role key missing | +| 500 | `upstream_key_missing` | `GEMINI_API_KEY` secret not set | +| 5xx | (Gemini body passed through) | Upstream Google AI error | diff --git a/supabase/functions/gemini-proxy/index.ts b/supabase/functions/gemini-proxy/index.ts new file mode 100644 index 0000000..3a41352 --- /dev/null +++ b/supabase/functions/gemini-proxy/index.ts @@ -0,0 +1,174 @@ +// supabase/functions/gemini-proxy/index.ts +// +// Gemini API proxy. The client must NOT hold a `GEMINI_API_KEY` — that key +// lives only in this function's environment (set via +// `supabase secrets set GEMINI_API_KEY=...`). +// +// The client calls this function with a Supabase user JWT in the +// `Authorization` header (the supabase-js / supabase_flutter SDK forwards it +// automatically when using `functions.invoke`). We verify the JWT, apply a +// best-effort per-user rate limit, then forward the request body to the +// Google AI Studio Gemini REST endpoint and stream the response back. +// +// Deploy: +// supabase functions deploy gemini-proxy +// +// Set the upstream key (one-time): +// supabase secrets set GEMINI_API_KEY=<your-google-ai-studio-key> + +// deno-lint-ignore-file no-explicit-any +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.4'; + +// ─── Config ────────────────────────────────────────────────────────────────── + +const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'; +const DEFAULT_MODEL = 'gemma-4-26b-a4b-it'; + +// Rate limit: 10 requests per 60s per user. In-memory, per-isolate +// (best-effort — Supabase may spin up multiple isolates, so the effective +// ceiling can be N×10/min where N = active isolate count). +// TODO: For a hard global limit, swap this for a Supabase table or +// Upstash/Redis counter keyed by user_id with a 60s TTL. +const RATE_LIMIT_MAX = 10; +const RATE_LIMIT_WINDOW_MS = 60_000; +const recentCallsByUser = new Map<string, number[]>(); + +function isRateLimited(userId: string): boolean { + const now = Date.now(); + const calls = recentCallsByUser.get(userId) ?? []; + const fresh = calls.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); + if (fresh.length >= RATE_LIMIT_MAX) { + recentCallsByUser.set(userId, fresh); + return true; + } + fresh.push(now); + recentCallsByUser.set(userId, fresh); + return false; +} + +// ─── CORS ──────────────────────────────────────────────────────────────────── + +const CORS_HEADERS: Record<string, string> = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': + 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', +}; + +function jsonResponse( + body: Record<string, unknown>, + status = 200, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +Deno.serve(async (req: Request) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: CORS_HEADERS }); + } + + if (req.method !== 'POST') { + return jsonResponse({ error: 'method_not_allowed' }, 405); + } + + // 1. Extract & verify the user's JWT. + const authHeader = req.headers.get('Authorization') ?? ''; + const jwt = authHeader.replace(/^Bearer\s+/i, '').trim(); + if (!jwt) { + return jsonResponse({ error: 'missing_authorization' }, 401); + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; + if (!supabaseUrl || !serviceRoleKey) { + return jsonResponse({ error: 'server_misconfigured' }, 500); + } + + const supabase = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userData, error: userErr } = await supabase.auth.getUser(jwt); + if (userErr || !userData?.user) { + return jsonResponse({ error: 'invalid_jwt' }, 401); + } + const userId = userData.user.id; + + // 2. Per-user rate limit. + if (isRateLimited(userId)) { + return jsonResponse( + { error: 'rate_limited', retry_after_seconds: 60 }, + 429, + ); + } + + // 3. Verify upstream key. + const geminiKey = Deno.env.get('GEMINI_API_KEY') ?? ''; + if (!geminiKey) { + return jsonResponse({ error: 'upstream_key_missing' }, 500); + } + + // 4. Parse request body. Expected shape: + // { + // "model"?: string, // defaults to gemma-4-26b-a4b-it + // "stream"?: boolean, // SSE pass-through when true + // "payload": <Gemini REST body — passed verbatim> + // } + let body: any; + try { + body = await req.json(); + } catch (_) { + return jsonResponse({ error: 'invalid_json' }, 400); + } + + const model = + typeof body?.model === 'string' && body.model.length > 0 + ? body.model + : DEFAULT_MODEL; + const stream = body?.stream === true; + const payload = body?.payload; + if (!payload || typeof payload !== 'object') { + return jsonResponse({ error: 'missing_payload' }, 400); + } + + const endpoint = stream ? 'streamGenerateContent' : 'generateContent'; + const upstreamUrl = + `${GEMINI_BASE}/${encodeURIComponent(model)}:${endpoint}` + + `?key=${encodeURIComponent(geminiKey)}` + + (stream ? '&alt=sse' : ''); + + // 5. Forward to Gemini. + const upstreamRes = await fetch(upstreamUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + // 6. Stream the response back to the caller unchanged. + if (stream) { + return new Response(upstreamRes.body, { + status: upstreamRes.status, + headers: { + ...CORS_HEADERS, + 'Content-Type': + upstreamRes.headers.get('Content-Type') ?? 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + }); + } + + const text = await upstreamRes.text(); + return new Response(text, { + status: upstreamRes.status, + headers: { + ...CORS_HEADERS, + 'Content-Type': + upstreamRes.headers.get('Content-Type') ?? 'application/json', + }, + }); +}); diff --git a/test/features/home/presentation/providers/translate_text_controller_test.dart b/test/features/home/presentation/providers/translate_text_controller_test.dart new file mode 100644 index 0000000..cfd67bd --- /dev/null +++ b/test/features/home/presentation/providers/translate_text_controller_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kudlit_ph/features/home/presentation/providers/translate_text_controller.dart'; + +void main() { + test( + 'setInput echoes raw text immediately without synchronous derive', + () { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + container + .read(translateTextControllerProvider.notifier) + .setInput('kamusta'); + + final TranslateTextState state = container.read( + translateTextControllerProvider, + ); + expect(state.inputText, 'kamusta'); + expect(state.hasInput, isTrue); + // Heavy transliteration is debounced off the typing hot path. + expect(state.baybayinText, isEmpty); + // Typing must never bump the revision (would reset the field/cursor). + expect(state.inputRevision, 0); + }, + ); + + test('setInput derives the preview after the debounce window', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + container + .read(translateTextControllerProvider.notifier) + .setInput('kamusta'); + + await Future<void>.delayed(const Duration(milliseconds: 260)); + + final TranslateTextState state = container.read( + translateTextControllerProvider, + ); + expect(state.inputText, 'kamusta'); + expect(state.baybayinText, isNotEmpty); + expect(state.inputRevision, 0); + }); + + test('applyExternalInput derives immediately and bumps revision', () { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + container + .read(translateTextControllerProvider.notifier) + .applyExternalInput('ka'); + + final TranslateTextState state = container.read( + translateTextControllerProvider, + ); + expect(state.inputText, 'ka'); + expect(state.baybayinText, isNotEmpty); + expect(state.inputRevision, 1); + }); + + test('clearInput resets state and bumps revision', () { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final TranslateTextController controller = container.read( + translateTextControllerProvider.notifier, + ); + controller.applyExternalInput('ka'); + controller.clearInput(); + + final TranslateTextState state = container.read( + translateTextControllerProvider, + ); + expect(state.inputText, isEmpty); + expect(state.baybayinText, isEmpty); + expect(state.inputRevision, 2); + }); +} diff --git a/test/features/home/presentation/widgets/translate_density_test.dart b/test/features/home/presentation/widgets/translate_density_test.dart index 46071d4..021f566 100644 --- a/test/features/home/presentation/widgets/translate_density_test.dart +++ b/test/features/home/presentation/widgets/translate_density_test.dart @@ -8,6 +8,7 @@ import 'package:kudlit_ph/features/home/presentation/widgets/app_header/app_head import 'package:kudlit_ph/features/home/presentation/widgets/translate/filled_output.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/output_actions.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_header.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/translate/sketchpad_target_glyph_button.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/translate/translate_text_mode_panel.dart'; void main() { @@ -365,6 +366,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -395,6 +397,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -433,6 +436,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -469,6 +473,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -510,6 +515,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -753,6 +759,7 @@ void main() { disabledReason: null, onDirectionChanged: (_) {}, onInputChanged: (_) {}, + onExternalInput: (_) {}, onClear: () {}, onExplain: () {}, onCheckInput: () {}, @@ -872,6 +879,49 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets( + 'sketchpad target uses a tap picker with no keyboard surface', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp(home: Scaffold(body: TranslateScreen())), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Sketchpad')); + await tester.pump(); + + // The keyboard-driven re-mount loop was rooted in the target + // TextField. The picker must not introduce any editable surface. + expect(find.byType(EditableText), findsNothing); + expect(find.byType(SketchpadTargetGlyphButton), findsOneWidget); + expect(find.text('Target glyph'), findsOneWidget); + + await tester.tap(find.byType(SketchpadTargetGlyphButton)); + await tester.pumpAndSettle(); + expect(find.text('Choose target glyph'), findsOneWidget); + + await tester.tap(find.widgetWithText(InkWell, 'ba').last); + await tester.pumpAndSettle(); + + expect(find.text('Choose target glyph'), findsNothing); + expect(find.byType(EditableText), findsNothing); + expect( + tester + .widget<SketchpadTargetGlyphButton>( + find.byType(SketchpadTargetGlyphButton), + ) + .currentLabel, + 'ba', + ); + expect(tester.takeException(), isNull); + }, + ); } Widget _largeTextBuilder(BuildContext context, Widget? child) { diff --git a/test/features/scanner/data/datasources/yolo_baybayin_detector_test.dart b/test/features/scanner/data/datasources/yolo_baybayin_detector_test.dart index 6c20592..1b5744f 100644 --- a/test/features/scanner/data/datasources/yolo_baybayin_detector_test.dart +++ b/test/features/scanner/data/datasources/yolo_baybayin_detector_test.dart @@ -1,29 +1,44 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/yolo_baybayin_detector.dart'; +import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; +import 'package:kudlit_ph/features/scanner/domain/failures/scanner_failures.dart'; import 'package:ultralytics_yolo/ultralytics_yolo.dart'; void main() { - test('disposes still-image YOLO instance when model loading fails', () async { - late _FailingYolo failingYolo; - final YoloBaybayinDetector detector = YoloBaybayinDetector( - modelPathResolver: () async => 'bad-model.tflite', - singleImageYoloFactory: (String modelPath) { - failingYolo = _FailingYolo(modelPath: modelPath); - return failingYolo; - }, - ); - addTearDown(detector.dispose); - - await expectLater( - detector.detectImage(Uint8List(0)), - throwsA(isA<StateError>()), - ); - - expect(failingYolo.loadCalls, 1); - expect(failingYolo.disposeCalls, 1); - }); + test( + 'disposes still-image YOLO instance when model loading fails and returns a scanner init failure', + () async { + late _FailingYolo failingYolo; + final YoloBaybayinDetector detector = YoloBaybayinDetector( + modelPathResolver: () async => 'bad-model.tflite', + singleImageYoloFactory: (String modelPath) { + failingYolo = _FailingYolo(modelPath: modelPath); + return failingYolo; + }, + ); + addTearDown(detector.dispose); + + final Either<Failure, List<BaybayinDetection>> result = await detector + .detectImage(Uint8List(0)); + + expect(failingYolo.loadCalls, 1); + expect(failingYolo.disposeCalls, 1); + expect(result.isLeft(), isTrue); + result.fold( + (Failure failure) { + expect( + scannerFailureKindOf(failure), + anyOf(ScannerFailureKind.init, ScannerFailureKind.inference), + ); + }, + (_) => fail('Expected a Left for a failed model load.'), + ); + }, + ); } class _FailingYolo extends YOLO { diff --git a/test/features/scanner/presentation/providers/scan_tab_controller_test.dart b/test/features/scanner/presentation/providers/scan_tab_controller_test.dart index 48a7850..c8e97b7 100644 --- a/test/features/scanner/presentation/providers/scan_tab_controller_test.dart +++ b/test/features/scanner/presentation/providers/scan_tab_controller_test.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:kudlit_ph/core/error/failures.dart'; import 'package:kudlit_ph/features/scanner/domain/entities/baybayin_detection.dart'; import 'package:kudlit_ph/features/scanner/domain/repositories/baybayin_detector.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/scan_tab_controller.dart'; @@ -222,26 +224,30 @@ class _FakeBaybayinDetector implements BaybayinDetector { Stream<List<BaybayinDetection>> get detections => _stream.stream; @override - Future<List<BaybayinDetection>> detectImage(Uint8List imageBytes) async { + Future<Either<Failure, List<BaybayinDetection>>> detectImage( + Uint8List imageBytes, + ) async { lastImageBytes = imageBytes; _stream.add(nextDetections); - return nextDetections; + return right(nextDetections); } @override - Future<Uint8List?> captureFrame() async => nextCapturedFrame; + Future<Either<Failure, Uint8List?>> captureFrame() async => + right(nextCapturedFrame); @override - Future<void> toggleTorch({required bool enabled}) async {} + Future<Either<Failure, Unit>> toggleTorch({required bool enabled}) async => + right(unit); @override - Future<void> switchCamera() async {} + Future<Either<Failure, Unit>> switchCamera() async => right(unit); @override - Future<void> pauseInference() async {} + Future<Either<Failure, Unit>> pauseInference() async => right(unit); @override - Future<void> resumeInference() async {} + Future<Either<Failure, Unit>> resumeInference() async => right(unit); @override void dispose() {