From f4e5cb2d79073f4982b1aefafd8f0f68ed90fbee Mon Sep 17 00:00:00 2001 From: Patrick Walukagga Date: Thu, 14 May 2026 09:22:58 +0300 Subject: [PATCH] feat: Implement model download safety features and UI enhancements - Added a maximum bundle size limit to prevent excessive storage usage during model downloads. - Introduced a stall timeout to abort downloads that remain idle for too long. - Updated the model settings sheet to use centralized SharedPreferences keys for better maintainability. - Refactored audio modes and prompt overrides to use a new AudioMode type for improved type safety. - Created a new relative time formatting utility to standardize time displays across the app. - Added a JumpToLatestPill widget for improved user navigation in question history. - Developed a QuestionHistorySheet to manage and display user question history persistently. - Introduced a MinimalTopBar widget to streamline the app's top navigation interface. - Created a VoiceReviewBar for inline transcript review, enhancing user interaction during voice input. --- .gitignore | 1 + lib/audio/audio_prompt_resolver.dart | 145 ++ lib/audio/audio_types.dart | 60 + lib/completion/cactus_runner.dart | 54 +- lib/conversation/cards.dart | 57 +- lib/conversation/question_history.dart | 105 ++ lib/main.dart | 1833 +++++------------------ lib/model_manager.dart | 41 +- lib/model_settings_sheet.dart | 63 +- lib/preferences.dart | 19 + lib/util/relative_time.dart | 11 + lib/widgets/jump_to_latest_pill.dart | 68 + lib/widgets/question_history_sheet.dart | 203 +++ lib/widgets/slot_card.dart | 92 +- lib/widgets/top_bar.dart | 268 ++++ lib/widgets/voice_review_bar.dart | 169 +++ 16 files changed, 1577 insertions(+), 1612 deletions(-) create mode 100644 lib/audio/audio_prompt_resolver.dart create mode 100644 lib/audio/audio_types.dart create mode 100644 lib/conversation/question_history.dart create mode 100644 lib/preferences.dart create mode 100644 lib/util/relative_time.dart create mode 100644 lib/widgets/jump_to_latest_pill.dart create mode 100644 lib/widgets/question_history_sheet.dart create mode 100644 lib/widgets/top_bar.dart create mode 100644 lib/widgets/voice_review_bar.dart diff --git a/.gitignore b/.gitignore index 06b47ed..75ef2c4 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ prompts/ sunflower_app_prompt.txt +*.apk diff --git a/lib/audio/audio_prompt_resolver.dart b/lib/audio/audio_prompt_resolver.dart new file mode 100644 index 0000000..7de2573 --- /dev/null +++ b/lib/audio/audio_prompt_resolver.dart @@ -0,0 +1,145 @@ +// Pure prompt-routing for the flat-bundle audio path. Pulled out of +// `_sendAudioBackedPrompt` so the four-branch decision tree is a +// testable function rather than nested ifs in an async method. +// +// Decisions captured here (in order, first match wins): +// +// 1. User typed text alongside the audio → use the typed text as the +// user content; pair with the bundle's system prompt override +// (or null → caller falls back to the classroom prompt). +// 2. Bundle declares audioModes → use the mode-based SFT prompt +// (Transcribe / Answer) the user pre-selected. Per-mode override +// from `audioPromptOverrides` takes precedence — empty string is +// honoured (the QA bundle pairs audio with a blank user prompt). +// System message = bundle override OR kTrainedSystemPrompt. +// 3. Bundle has a legacy `audioUserPrompt` but no modes → use that. +// Kept for back-compat / custom installs. +// 4. Fallback → caller's `classroomFallback` user content with no +// system override (caller pairs with the classroom prompt). +// +// All decisions encoded into [AudioPromptPlan] so the caller stays +// declarative. + +import '../classroom_prompt.dart'; +import '../conversation/cards.dart'; +import '../languages.dart'; +import '../model_settings_sheet.dart'; +import 'audio_types.dart'; + +class AudioPromptPlan { + /// User content for the messages JSON. Empty string is intentional + /// (the QA bundle's Answer mode trains on a blank user message). + final String userContent; + + /// System prompt override. `null` → caller pairs with its default + /// classroom system prompt. + final String? systemPrompt; + + /// What kind of card the response should render as. + final CardType cardType; + + /// Language the response will be in. + final Language responseLang; + + /// Source language for cross-language answer cards. `null` when the + /// response language is also the source (transcribe mode, or when + /// the user typed in the same language they spoke). + final Language? sourceLang; + + /// In-progress status string for diagnostics. + final String inProgressStatus; + + const AudioPromptPlan({ + required this.userContent, + required this.systemPrompt, + required this.cardType, + required this.responseLang, + required this.sourceLang, + required this.inProgressStatus, + }); +} + +/// Resolve the prompt + card plan for one flat-bundle audio turn. +/// +/// `audioModes` is the user-mode-eligible subset for the currently +/// loaded bundle (computed by caller from `bundle.audioModes`). When +/// it is empty, the bundle doesn't declare modes — branches 3/4 apply. +AudioPromptPlan resolveAudioPrompt({ + required KnownModel? bundle, + required List audioModes, + required AudioMode currentMode, + required Language sourceLang, + required Language responseLang, + required String typedText, + required String classroomFallback, +}) { + final hasTyped = typedText.isNotEmpty; + + if (hasTyped) { + return AudioPromptPlan( + userContent: typedText, + systemPrompt: bundle?.systemPrompt, + cardType: CardType.answer, + responseLang: responseLang, + sourceLang: sourceLang != responseLang ? sourceLang : null, + inProgressStatus: 'Thinking with audio in ${responseLang.name}...', + ); + } + + if (audioModes.isNotEmpty) { + final override = bundle?.audioPromptOverrides[currentMode]; + final userContent = + override ?? _defaultUserPromptForMode(currentMode, sourceLang); + final systemPrompt = bundle?.systemPrompt ?? kTrainedSystemPrompt; + final isTranscribe = currentMode == AudioMode.transcribe; + final cardType = + isTranscribe ? CardType.voiceTranscript : CardType.answer; + final cardResponseLang = isTranscribe ? sourceLang : responseLang; + // sourceLang is only meaningful on answer cards in the mode-aware + // flat path; transcripts self-label with the spoken language. + final cardSourceLang = isTranscribe ? null : sourceLang; + final inProgress = isTranscribe + ? 'Transcribing ${sourceLang.name}...' + : 'Listening in ${responseLang.name}...'; + return AudioPromptPlan( + userContent: userContent, + systemPrompt: systemPrompt, + cardType: cardType, + responseLang: cardResponseLang, + sourceLang: cardSourceLang, + inProgressStatus: inProgress, + ); + } + + final legacyPrompt = bundle?.audioUserPrompt; + if (legacyPrompt != null) { + return AudioPromptPlan( + userContent: legacyPrompt, + systemPrompt: bundle?.systemPrompt, + cardType: CardType.answer, + responseLang: responseLang, + sourceLang: null, + inProgressStatus: 'Listening in ${responseLang.name}...', + ); + } + + return AudioPromptPlan( + userContent: classroomFallback, + systemPrompt: null, + cardType: CardType.answer, + responseLang: responseLang, + sourceLang: null, + inProgressStatus: 'Listening in ${responseLang.name}...', + ); +} + +/// Default audio user-prompt template per mode. Verbatim from the SFT +/// corpus — diverging from these strings drops on-distribution quality. +String _defaultUserPromptForMode(AudioMode mode, Language sourceLang) { + switch (mode) { + case AudioMode.transcribe: + return 'Please transcribe this ${sourceLang.name} audio.'; + case AudioMode.answer: + return 'Please answer this spoken question.'; + } +} diff --git a/lib/audio/audio_types.dart b/lib/audio/audio_types.dart new file mode 100644 index 0000000..10ab0d3 --- /dev/null +++ b/lib/audio/audio_types.dart @@ -0,0 +1,60 @@ +// Shared audio domain types. Previously private to `main.dart`, which +// meant the catalogue in `model_settings_sheet.dart` declared audio +// modes as strings ('answer' / 'transcribe') and the page parsed them +// back to an enum — a typo there silently disabled a bundle's audio. +// Lifting these to a public module lets the catalogue (and tests) +// reference the enum directly. + +import '../languages.dart'; + +/// Lifecycle of the input bar's mic mode. +/// +/// idle ──tap mic──> recording +/// recording ──tap stop──> idle (auto-transcribed) +/// recording ──silence end-pointing──> idle (auto-transcribed) +/// recording ──15s cap──> idle (auto-transcribed) +/// recording ──tap cancel──> idle (discarded, no transcribe) +enum RecPhase { idle, recording } + +/// What the user wants done with a just-transcribed voice clip. +/// Both map to trained SFT prefixes — see the audio prompt resolver. +enum VoiceIntent { translate, explain } + +/// Audio task families the user can pre-select for flat-bundle voice +/// turns (the audio_tower-direct path, not the stitched cascade). +/// Each value maps to a verbatim SFT-trained user prompt. Per-bundle +/// availability is declared in `KnownModel.audioModes`; the page picks +/// the first available mode as the default when a bundle is loaded. +enum AudioMode { transcribe, answer } + +String audioModeLabel(AudioMode m) { + switch (m) { + case AudioMode.transcribe: + return 'Transcribe'; + case AudioMode.answer: + return 'Answer'; + } +} + +/// Parses a catalogue string back to an [AudioMode]. Returns null on +/// unknown values — callers should either error out (typo in catalogue) +/// or filter them away with `whereType()`. +AudioMode? audioModeFromName(String name) { + for (final m in AudioMode.values) { + if (m.name == name) return m; + } + return null; +} + +/// In-flight transcript awaiting a user intent. Lives on the page so +/// the input bar can swap to the review UI in place of the recorder / +/// text input — no modal context-switch between hearing the transcript +/// and committing to an action. +class VoiceReviewState { + final String transcript; + final Language target; + const VoiceReviewState({required this.transcript, required this.target}); + + VoiceReviewState withTarget(Language language) => + VoiceReviewState(transcript: transcript, target: language); +} diff --git a/lib/completion/cactus_runner.dart b/lib/completion/cactus_runner.dart index a5194dc..8e8ac10 100644 --- a/lib/completion/cactus_runner.dart +++ b/lib/completion/cactus_runner.dart @@ -242,17 +242,51 @@ class CactusRunner { /// signal stop first so the worker exits `cactusComplete` cleanly — /// killing the isolate while native code is mid-call is a /// use-after-free against the model handle. - void disposeForShutdown() { + /// + /// Awaits the worker reaching a natural completion (cactusStop is + /// cooperative — the worker exits at the next token boundary). Falls + /// back to a bounded grace period and then `Isolate.immediate` if + /// the worker doesn't acknowledge — at that point we accept the + /// hazard rather than blocking the disposal indefinitely. Callers + /// (typically `State.dispose`) MUST `await` this before destroying + /// the model handle. + Future disposeForShutdown({ + Duration grace = const Duration(milliseconds: 750), + }) async { final model = _activeModel; - if (model != null) { - cactusStop(model); + if (model == null) { + // No active completion — fast path. Still tear down any leftover + // listeners just in case. + await _activeSub?.cancel(); + _activePort?.close(); + _activeIsolate?.kill(priority: Isolate.immediate); + _activeSub = null; + _activePort = null; + _activeIsolate = null; + return; + } + + debugPrint('[runner] shutdown: signalling stop and awaiting worker'); + cactusStop(model); + + // Wait up to [grace] for the worker to emit `done`/`error` and + // exit. The active sub/port get cleaned up inside [run]'s + // finally block when that happens, so we poll for the active + // isolate going null. + final deadline = DateTime.now().add(grace); + while (_activeIsolate != null && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } + if (_activeIsolate != null) { + debugPrint( + '[runner] shutdown: grace elapsed, force-killing worker'); + await _activeSub?.cancel(); + _activePort?.close(); + _activeIsolate?.kill(priority: Isolate.immediate); + _activeSub = null; + _activePort = null; + _activeIsolate = null; + _activeModel = null; } - _activeSub?.cancel(); - _activePort?.close(); - _activeIsolate?.kill(priority: Isolate.immediate); - _activeSub = null; - _activePort = null; - _activeIsolate = null; - _activeModel = null; } } diff --git a/lib/conversation/cards.dart b/lib/conversation/cards.dart index d3dda4b..732cc81 100644 --- a/lib/conversation/cards.dart +++ b/lib/conversation/cards.dart @@ -4,6 +4,8 @@ // // See `card_spec.md` §6, §7, §8 for the source of truth. +import 'package:flutter/foundation.dart'; + import '../languages.dart'; import 'labels.dart'; @@ -14,6 +16,15 @@ enum ChipKind { translate, explain } /// A single artifact in the conversation stack. Always produced by the /// model — the user's input doesn't get its own card; it lives in the /// input bar until they submit, then disappears. +/// +/// **Streaming hot path.** Token appends used to mutate `body` via +/// `body += token` from outside; that was O(n) per token (Dart strings +/// are immutable, each `+=` allocates n+k bytes) and at ~9 tok/s on +/// long responses it stacked up to O(n²) total. Now there's a +/// [StringBuffer] inside the card; [appendToken] writes to it and +/// bumps [bodyVersion]. Widgets subscribe to [bodyVersion] (a +/// `ValueListenable`) so only the active card's body widget +/// repaints per token — not the whole Scaffold. class ConversationCard { final String id; final CardType type; @@ -22,15 +33,9 @@ class ConversationCard { /// Optional source language for cross-language cards. The header /// renders "{source} → {language} answer" when this is set and /// differs from [language]; otherwise the source is implicit and we - /// fall back to the shorter "{language} answer" form. Used by audio - /// Answer-mode cards where the spoken language can differ from the - /// reply language picked by the user. + /// fall back to the shorter "{language} answer" form. final Language? sourceLanguage; - /// Mutable on purpose: streaming completions append tokens to this - /// field and the UI rebuilds via setState on the owning widget. - String body; - final DateTime timestamp; /// True while tokens are still arriving. Drives the streaming-tint @@ -54,12 +59,17 @@ class ConversationCard { /// v1 leaves this empty for the same reason as [anchoredTerms]. final Map glosses; + // Streaming buffer. Reads materialise to a String on access; writes + // go through [appendToken] or the `body` setter. + final StringBuffer _buffer; + final ValueNotifier _bodyVersion = ValueNotifier(0); + ConversationCard({ required this.id, required this.type, required this.language, this.sourceLanguage, - this.body = '', + String body = '', DateTime? timestamp, this.isStreaming = true, this.decodeTps = 0, @@ -67,12 +77,39 @@ class ConversationCard { this.errorMessage, this.anchoredTerms = const [], this.glosses = const {}, - }) : timestamp = timestamp ?? DateTime.now(); + }) : timestamp = timestamp ?? DateTime.now(), + _buffer = StringBuffer(body); + + /// Materialised body. O(buffer.length) on access — read only when the + /// UI is about to paint it, not in tight loops. + String get body => _buffer.toString(); + + /// Replace the body wholesale. Used by completion finalisation + /// (`c.body = cleanedResponse`) and tests. + set body(String value) { + _buffer + ..clear() + ..write(value); + _bodyVersion.value++; + } + + /// Append a streamed token. Cheap (amortised O(token length)); + /// notifies [bodyVersion] subscribers so the active card body can + /// repaint without bouncing through ConversationState's listeners. + void appendToken(String token) { + if (token.isEmpty) return; + _buffer.write(token); + _bodyVersion.value++; + } + + /// Per-card change signal. Subscribe to this from the widget that + /// actually renders [body] so the streaming hot path doesn't trigger + /// a rebuild of the whole conversation surface. + ValueListenable get bodyVersion => _bodyVersion; /// The label shown in the card header. Type-qualified so model /// outputs aren't all flattened to "Luganda" / "Acholi" — the user /// needs to tell an answer apart from a translation at a glance. - /// Localised TODO when we move past chip-only localisation. String get headerLabel { switch (type) { case CardType.voiceTranscript: diff --git a/lib/conversation/question_history.dart b/lib/conversation/question_history.dart new file mode 100644 index 0000000..fc06220 --- /dev/null +++ b/lib/conversation/question_history.dart @@ -0,0 +1,105 @@ +// Persisted history of questions the student typed. Stored in +// SharedPreferences as a length-prefixed list so the history survives +// app kills and cold launches — previously this was an in-memory list +// on the page state and was lost whenever Android reclaimed the app. + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../preferences.dart'; + +const _maxEntries = 20; + +class QuestionHistoryEntry { + final String question; + final DateTime timestamp; + + const QuestionHistoryEntry({ + required this.question, + required this.timestamp, + }); + + Map toJson() => { + 'q': question, + 't': timestamp.millisecondsSinceEpoch, + }; + + static QuestionHistoryEntry? fromJson(Object? raw) { + if (raw is! Map) return null; + final q = raw['q']; + final t = raw['t']; + if (q is! String || t is! int) return null; + return QuestionHistoryEntry( + question: q, + timestamp: DateTime.fromMillisecondsSinceEpoch(t), + ); + } +} + +/// Newest-first list of past student questions. Mutations notify on +/// every successful change. +class QuestionHistory extends ChangeNotifier { + final List _entries = []; + bool _loaded = false; + + List get entries => + List.unmodifiable(_entries); + bool get isEmpty => _entries.isEmpty; + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(kQuestionHistoryPrefKey); + if (raw == null || raw.isEmpty) return; + try { + final list = jsonDecode(raw); + if (list is! List) return; + _entries + ..clear() + ..addAll(list + .map(QuestionHistoryEntry.fromJson) + .whereType()); + notifyListeners(); + } catch (_) { + // Corrupt blob — drop it. + } + } + + /// Insert at the top, de-dupe on identical question text, cap to + /// [_maxEntries]. Persists asynchronously; the in-memory view is + /// updated synchronously and listeners are notified immediately. + void remember(String question) { + final trimmed = question.trim(); + if (trimmed.isEmpty) return; + _entries.removeWhere((e) => e.question == trimmed); + _entries.insert( + 0, + QuestionHistoryEntry(question: trimmed, timestamp: DateTime.now()), + ); + if (_entries.length > _maxEntries) { + _entries.removeRange(_maxEntries, _entries.length); + } + notifyListeners(); + _persist(); + } + + void clear() { + if (_entries.isEmpty) return; + _entries.clear(); + notifyListeners(); + _persist(); + } + + Future _persist() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = jsonEncode(_entries.map((e) => e.toJson()).toList()); + await prefs.setString(kQuestionHistoryPrefKey, raw); + } catch (_) { + // Best-effort; losing a single persist doesn't break the UI. + } + } +} diff --git a/lib/main.dart b/lib/main.dart index eef7d73..11534c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'audio/audio_prompt_resolver.dart'; +import 'audio/audio_types.dart'; import 'audio_trim.dart'; import 'cactus.dart'; import 'classroom_prompt.dart'; @@ -18,25 +20,29 @@ import 'completion/cactus_runner.dart'; import 'completion/download_foreground.dart'; import 'conversation/cards.dart'; import 'conversation/instruction_detection.dart'; +import 'conversation/question_history.dart'; import 'conversation/state.dart'; import 'languages.dart'; import 'model_manager.dart'; import 'model_settings_sheet.dart'; +import 'preferences.dart'; import 'theme/sunflower_tokens.dart'; import 'widgets/card_stack.dart'; import 'widgets/chip_row.dart'; +import 'widgets/jump_to_latest_pill.dart'; import 'widgets/morphing_input_bar.dart'; +import 'widgets/question_history_sheet.dart'; +import 'widgets/top_bar.dart'; +import 'widgets/voice_review_bar.dart'; // ---------- Worker isolates ---------- /// Top-level arg type for the init [compute] worker. /// -/// `whisperDir` is null for flat bundles (today's -/// `ak3ra/sunflower-uga-cactus-int4` layout — single multimodal Gemma -/// at the root). For stitched bundles it points at the Whisper -/// subdirectory and `gemmaDir` points at the Gemma subdirectory; the -/// loader produces two handles and the page wires audio through -/// Whisper → text → Gemma. +/// `whisperDir` is null for flat bundles. For stitched bundles it +/// points at the Whisper subdirectory and `gemmaDir` points at the +/// Gemma subdirectory; the loader produces two handles and the page +/// wires audio through Whisper → text → Gemma. class _InitWorkerArg { final String gemmaDir; final String? whisperDir; @@ -48,36 +54,16 @@ class _InitWorkerArg { /// on the bundle layout), then primes Gemma's prefix cache with the /// actual classroom system prompt. Returns `[gemmaAddr, whisperAddr]` /// as integer addresses — `whisperAddr` is 0 when the bundle is flat. -/// The main isolate reconstructs both pointers with -/// `Pointer.fromAddress`. Off-thread so the UI doesn't freeze during -/// the weight-load + warmup. /// -/// Two effects ride on the same warmup call: -/// -/// 1. **Page-cache warmup.** cactusInit mmaps the 4.7 GB Gemma weight -/// files but doesn't page them in. The first cactusComplete then -/// walks every transformer layer, taking page faults from UFS — -/// measured ~2 tok/s cold vs ~9 tok/s warm on Pixel 10 INT4. -/// cactusPrefill proactively walks the layers under the -/// "Loading model…" indicator so the user's first decoded token -/// streams at full speed. -/// -/// 2. **Prefix-cache prime.** cactusPrefill stores the tokenised -/// system prompt in `handle->processed_tokens`. The first real -/// cactusComplete's prompt_context_matches check -/// (cactus_complete.cpp:605) then finds the cached system prefix -/// is a prefix of the new prompt, and only prefills the -/// user-content delta. Measured TTFT win on turn 1: ~7 s → ~1-2 s. -/// -/// Only Gemma is warmed — Whisper is an encoder/decoder ASR graph, -/// `cactusPrefill` is LLM-specific and doesn't apply. The first voice -/// turn pays Whisper's cold-start tax; the saved Gemma TTFT more -/// than makes up for it. +/// Two effects ride on the same warmup call: page-cache warmup +/// (mmap'd weights walked under the "Loading…" indicator) and +/// prefix-cache prime (the tokenised system prompt is stored in +/// `handle->processed_tokens` so the first real completion skips +/// re-prefilling the system message — ~7 s → ~1-2 s TTFT on turn 1). /// /// If the Whisper init fails after Gemma succeeded, we destroy the /// Gemma handle before returning [0, 0] — failing the whole stitched -/// load is the right call. The picker entry promised both, and -/// silently degrading would mislead the user. +/// load is the right call. List _runInitWorker(_InitWorkerArg arg) { debugPrint('[init-worker] cactusInit gemma at ${arg.gemmaDir}'); final gemmaSw = Stopwatch()..start(); @@ -106,7 +92,8 @@ List _runInitWorker(_InitWorkerArg arg) { } } - // Prefix-cache prime on Gemma only. See doc comment above. + // Prefix-cache prime on Gemma only — cactusPrefill is LLM-specific + // and doesn't apply to Whisper's encoder/decoder ASR graph. try { final warmupMessages = jsonEncode([ {'role': 'system', 'content': arg.systemPrompt}, @@ -135,47 +122,24 @@ class _TranscribeWorkerArg { } /// Standard Whisper decoder-primer for English transcription, no -/// timestamps. Whisper expects the 4-token sequence -/// `<|startoftranscript|><|lang|><|task|><|notimestamps|>`; without -/// a language token the decoder auto-detects from the audio and -/// Whisper-tiny gets that badly wrong on short Pixel-mic clips — we -/// caught it emitting just `<|nn|>` (Norwegian Nynorsk) during -/// validation on 2026-05-12. -/// -/// This is right for the smoke test (English speech, off-the-shelf -/// openai/whisper-tiny). When a Luganda-fine-tuned Whisper lands, -/// this becomes per-bundle: either swap `<|en|>` for the new lang -/// token if the fine-tune's vocab carries one, or drop the primer -/// entirely if the fine-tune always decodes into the target -/// language regardless of the input token. +/// timestamps. Without a language token the decoder auto-detects from +/// the audio and Whisper-tiny gets that badly wrong on short +/// Pixel-mic clips. When a Luganda-fine-tuned Whisper lands, this +/// becomes per-bundle. const _whisperPromptEn = '<|startoftranscript|><|en|><|transcribe|><|notimestamps|>'; -/// Re-exported from classroom_prompt.dart so the picker entries and -/// the audio flow can reference the same string. See the docstring -/// there for the SFT-corpus provenance. -const _trainedSystemPrompt = kTrainedSystemPrompt; - /// Runs `cactusTranscribe` on a worker isolate so the blocking ASR -/// FFI call (~1 s for a 5 s clip on Pixel 10 with Whisper-tiny INT4) -/// doesn't freeze the UI. Returns just the transcript text. -/// -/// cactus_transcribe returns a JSON envelope (cactus_transcribe.cpp:620 -/// — `construct_response_json(...)`) — `{"success":true,"response":"…", -/// "decode_tps":…,"segments":[…],...}`. The whole envelope is what -/// the FFI buffer holds; we parse it and return only the `response` -/// string so downstream sees the transcript, not the JSON. Forgetting -/// this was a real bug: feeding the JSON envelope as a "transcript" -/// to Gemma produced noisy/short responses with stray <…> token-like -/// fragments leaking out (Gemma was trying to respond to JSON). -/// -/// On any parse failure we fall back to the raw FFI string — better -/// than dropping the audio turn entirely. +/// FFI call doesn't freeze the UI. Returns just the transcript text, +/// never the JSON envelope (feeding the envelope as a "transcript" +/// to Gemma produces noisy responses with stray `<…>` token-like +/// fragments). String _runTranscribeWorker(_TranscribeWorkerArg arg) { debugPrint('[transcribe-worker] starting on ${arg.audioPath}'); final sw = Stopwatch()..start(); final model = Pointer.fromAddress(arg.whisperAddr); - final raw = cactusTranscribe(model, arg.audioPath, arg.prompt, null, null, null); + final raw = cactusTranscribe( + model, arg.audioPath, arg.prompt, null, null, null); sw.stop(); String text; try { @@ -196,29 +160,12 @@ String _runTranscribeWorker(_TranscribeWorkerArg arg) { // ---------- App-wide constants ---------- const _modelDirName = 'model'; -const _preferredLanguagePrefKey = 'preferred_classroom_language'; - -/// SharedPreferences flag set to `true` immediately before we cross the -/// FFI boundary into `cactusInit`, and cleared after the call returns -/// (success or caught throw). If the process dies inside cactusInit — -/// e.g., a SIGSEGV from corrupt weights or an ABI mismatch — the flag -/// stays set across the relaunch. The next cold-launch auto-load -/// checks this flag and falls back to manual-load with a retry toast -/// instead of repeating the crash. -const _loadPendingPrefKey = 'model_load_pending'; - -/// Default audio user-prompt fallback for bundles that don't carry a -/// trained user prompt of their own (`KnownModel.audioUserPrompt == -/// null`). Maintains the pre-per-bundle-routing behaviour. -String _defaultAudioUserPrompt(Language lang) => - buildAudioOnlyUserPrompt(lang); void main() { initDownloadForegroundService(); - // Set up the comms port flutter_foreground_task uses to send - // events from the service isolate back to the UI isolate. Required - // before runApp; safe to call on platforms without foreground - // services (no-op on iOS / desktop). + // Comms port flutter_foreground_task uses to send events from the + // service isolate back to the UI isolate. Required before runApp; + // safe to call on platforms without foreground services. FlutterForegroundTask.initCommunicationPort(); runApp(const SunflowerApp()); } @@ -233,8 +180,8 @@ class SunflowerApp extends StatelessWidget { title: 'Sunflower', debugShowCheckedModeBanner: false, // The Sunflower palette is hand-tuned (cream/peach/coral) and - // doesn't have a dark counterpart yet. Use the same scheme for - // both — the chrome is light-on-light by design and Material's + // doesn't have a dark counterpart yet. Same scheme both modes — + // the chrome is light-on-light by design and Material's // automatic dark-mode generation would break the palette. theme: _buildTheme(scheme), darkTheme: _buildTheme(scheme), @@ -242,15 +189,6 @@ class SunflowerApp extends StatelessWidget { ); } - /// Font family is intentionally not pinned — the platform default - /// sans-serif is what users actually see (Google Sans Text on Pixel, - /// Roboto on stock Android, San Francisco on iOS). Forcing 'Roboto' - /// without bundling the .ttf would silently fall back to whichever - /// of these the platform decides anyway. - /// - /// All scale + weight decisions live in `buildSunflowerTextTheme()` - /// (in sunflower_tokens.dart) so widgets and flutter_markdown_plus - /// both pull hierarchy from a single source. ThemeData _buildTheme(ColorScheme scheme) { return ThemeData( colorScheme: scheme, @@ -262,119 +200,25 @@ class SunflowerApp extends StatelessWidget { } } -// ---------- Page state ---------- - -// ---------- Conversation model ---------- - -/// Lifecycle of the input bar's mic mode. -/// -/// idle ──tap mic──> recording -/// recording ──tap stop──> idle (auto-transcribed) -/// recording ──silence end-pointing──> idle (auto-transcribed) -/// recording ──15s cap──> idle (auto-transcribed) -/// recording ──tap cancel──> idle (discarded, no transcribe) -/// -/// In the chat layout the mic always auto-transcribes on stop — the -/// transcript shows up as a model response in the conversation list, so -/// the user gets review naturally without a separate "review chip" state. -enum _RecPhase { idle, recording } - -/// What the user wants done with a just-transcribed voice clip. -/// Both map to trained SFT prefixes; see [_runVoiceReviewIntent]. -enum _VoiceIntent { translate, explain } - -/// Audio task families the user can pre-select for flat-bundle voice -/// turns (the audio_tower-direct path, not the stitched cascade). -/// Each value maps to a verbatim SFT-trained user prompt — see -/// [_audioUserPromptForMode]. Per-bundle availability is declared in -/// `KnownModel.audioModes`; we pick the first one in that list as -/// the default when the bundle is loaded. -enum _AudioMode { transcribe, answer } - -String _audioModeLabel(_AudioMode m) { - switch (m) { - case _AudioMode.transcribe: - return 'Transcribe'; - case _AudioMode.answer: - return 'Answer'; - } -} - -_AudioMode? _audioModeFromName(String name) { - for (final m in _AudioMode.values) { - if (m.name == name) return m; - } - return null; -} - -/// The exact SFT-trained user message for a given audio mode. Trained -/// system prompt is [kTrainedSystemPrompt] in both cases. -String _audioUserPromptForMode(_AudioMode mode, Language sourceLang) { - switch (mode) { - case _AudioMode.transcribe: - // Verbatim from configs/sft/gemma4-e2b-mixed-sft.yml — every - // Sunbird/speech subset used this user_message template with - // {language} substituted in. - return 'Please transcribe this ${sourceLang.name} audio.'; - case _AudioMode.answer: - // Verbatim from jq's speech-QA fine-tune (and works on the - // multilingual bundles too — the prompt is generic enough). - return 'Please answer this spoken question.'; - } -} - -/// In-flight transcript awaiting a user intent. Lives on the page so -/// the input bar can swap to the review UI in place of the recorder / -/// text input — no modal context-switch between hearing the -/// transcript and committing to an action. -class _VoiceReviewState { - final String transcript; - final Language target; - const _VoiceReviewState({required this.transcript, required this.target}); - - _VoiceReviewState withTarget(Language language) => - _VoiceReviewState(transcript: transcript, target: language); -} - -class _QuestionHistoryEntry { - final String question; - final DateTime timestamp; - - const _QuestionHistoryEntry({ - required this.question, - required this.timestamp, - }); -} +// ---------- Recording tuning constants ---------- /// Auto-stop ceiling on any single recording. The audio_tower cost /// scales linearly with clip length (~1 s of TTFT per 1 s of audio on /// Pixel 10 CPU). End-pointing handles the typical case; this cap is -/// the backstop for "user wandered off with the mic open." Was 30 s -/// pre-end-pointing — halved because the runaway-clip risk drops once -/// silence detection auto-stops most recordings. +/// the backstop for "user wandered off with the mic open." const _maxRecordingSeconds = 15; /// Speech/silence threshold in dBFS for end-pointing and VAD-trim. -/// The `record` plugin reports amplitude in dBFS where 0 = peak and -/// -60 ≈ silence. -38 sits comfortably above typical room hum and -/// below normal-volume speech; tune if testing in noisier classrooms -/// shows word-onsets being clipped. const _speechThresholdDbfs = -38.0; /// Sustained silence after the last detected speech before -/// end-pointing fires. 1.5 s matches voice-assistant conventions — -/// long enough not to cut mid-sentence pauses, short enough not to -/// feel sluggish. +/// end-pointing fires. 1.5 s matches voice-assistant conventions. const _silenceTimeoutMs = 1500; -/// Padding either side of the detected speech range when trimming the -/// WAV. Buys some margin against the energy threshold clipping word -/// onsets/offsets that are below `_speechThresholdDbfs`. +/// Padding either side of the detected speech range when trimming. const _trimPaddingMs = 200; -/// Number of amplitude samples held in the waveform's rolling history. -/// At an 80 ms sampling cadence this is roughly the last 2.4 seconds of -/// audio, which is enough to look like "live" without a stuttering tail. +/// Rolling-history length for the live waveform. const _waveformSampleCount = 30; class TranslateDemoPage extends StatefulWidget { @@ -385,16 +229,12 @@ class TranslateDemoPage extends StatefulWidget { } class _TranslateDemoPageState extends State { - /// Gemma handle. Populated for every loaded bundle. For flat bundles - /// this is the multimodal Gemma 4 (audio_tower intact); for stitched - /// bundles it's text-only Gemma 4 (audio/vision towers stripped at - /// export time) and audio reaches it as a Whisper transcript. + /// Gemma handle. For flat bundles this is the multimodal Gemma 4 + /// (audio_tower intact); for stitched bundles it's text-only Gemma 4 + /// and audio reaches it as a Whisper transcript. CactusModelT? _model; - /// Whisper handle. Non-null only when the loaded bundle is a - /// stitched layout (`gemma/` + `whisper/` subdirs). When set, - /// [_sendAudioBackedPrompt] routes mic audio through Whisper first - /// and feeds the transcript as text to Gemma. + /// Whisper handle. Non-null only when the loaded bundle is stitched. CactusModelT? _whisperModel; late final AudioRecorder _recorder; @@ -405,7 +245,7 @@ class _TranslateDemoPageState extends State { bool _streaming = false; /// Brief SnackBar for errors and asides that don't belong in the - /// conversation history (permission denied, model not loaded, etc.). + /// conversation history. void _toast(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -413,105 +253,60 @@ class _TranslateDemoPageState extends State { ); } - /// Currently-recording WAV path. Becomes the audio of the next - /// UserMessage we append. Null when not recording. + /// Currently-recording WAV path. Null when not recording. String? _audioPath; DateTime? _recordingStartedAt; - /// Conversation history as a card stack. NEWEST card is at index 0; - /// older cards have higher indices and render as peeks behind the - /// active card. Owned by [ConversationState] so the list, focus - /// pointer, and per-card mutation surface are testable in isolation - /// (see test/conversation_state_test.dart). + /// Conversation history as a card stack (newest at index 0). final ConversationState _state = ConversationState(); - final List<_QuestionHistoryEntry> _questionHistory = []; + final QuestionHistory _questionHistory = QuestionHistory(); - /// Model capability flags reserved for future expansion. We keep this - /// in state so capability-based UI changes can still trigger rebuilds - /// cleanly later. + /// Model capability flags reserved for future expansion. final ModelCapabilities _capabilities = const ModelCapabilities(); - /// Preferred classroom language for the assistant session. Loaded - /// from local settings and used to build the per-session system - /// prompt. Translation chips can still branch into other languages. + /// Preferred classroom language for the assistant session. Language _preferredLanguage = english; - /// Focus node for the input field; used so explain-starter chips can - /// pull the keyboard up after prefilling. Disposed alongside the - /// other widget-owned resources in [dispose]. + /// Focus node for the input field; explain-starter chips can pull + /// the keyboard up after prefilling. final FocusNode _inputFocusNode = FocusNode(); - // Recording state machine. See _RecPhase for the transition graph. - _RecPhase _recPhase = _RecPhase.idle; + // Recording state machine. + RecPhase _recPhase = RecPhase.idle; Duration _liveRecDuration = Duration.zero; Timer? _recTimer; - // Rolling buffer of normalised (0..1) amplitudes for the waveform strip. - // Newest sample on the right; the list is sized to _waveformSampleCount. final List _waveform = List.filled(_waveformSampleCount, 0); StreamSubscription? _amplitudeSub; - /// Slug of the catalogue entry the user last installed via the - /// settings sheet. Empty when no slug is recorded (custom-URL - /// install or first launch). Drives per-bundle prompt selection - /// in [_sendAudioBackedPrompt] — bundles with SFT-specific prompt - /// pairs (e.g. the Speech-QA bundle) get their trained prompts - /// instead of the default classroom prompt. + /// Slug of the catalogue entry the user last installed. String _activeModelSlug = ''; - /// Selected audio task family for the next voice turn. Drives the - /// "Mode: …" pill above the input bar and the user-prompt picker - /// in [_sendAudioBackedPrompt]'s flat path. Re-syncs to the loaded - /// bundle's first available mode whenever a new bundle gets - /// installed (see [_resyncAudioMode]). - _AudioMode _audioMode = _AudioMode.answer; - - /// Language the user expects to speak in. Drives the `{language}` - /// substitution in the Transcribe-mode prompt and signals to the - /// model what input language to expect. Defaults to the - /// preferred-classroom-language; user can override per session via - /// the "Speaking: …" pill above the input bar. + /// Selected audio task family for the next voice turn. + AudioMode _audioMode = AudioMode.answer; + + /// Language the user expects to speak in. Language _audioSourceLang = english; - /// Non-null while a stitched-bundle voice turn is in the - /// "review-the-transcript" stage, between Whisper finishing and the - /// user picking Translate / Explain / Discard. The input bar - /// renders the review UI when this is set, hiding the recorder - /// and text input until the review resolves. - _VoiceReviewState? _voiceReview; - - /// Milliseconds since _recordingStartedAt where amplitude first - /// crossed _speechThresholdDbfs. Null while no speech has been - /// detected — the end-pointing path is suppressed in that case so - /// a user who taps mic and then thinks for a moment doesn't get - /// auto-stopped before speaking. - int? _firstSpeechMs; + /// Non-null while a stitched-bundle voice turn is in transcript- + /// review stage. + VoiceReviewState? _voiceReview; - /// Milliseconds since _recordingStartedAt of the most recent - /// above-threshold sample. Used by both end-pointing (have we been - /// silent long enough to stop?) and the VAD trim (where does - /// trailing silence start?). + /// Speech-time tracking for end-pointing / VAD trim. + int? _firstSpeechMs; int? _lastSpeechMs; - // Active streaming generation lives inside [CactusRunner]: spawn, - // SendPort listen, cooperative stop, and the cactusReset/Stop dance - // are all encapsulated there. `_streaming` below is just the page's - // mirror used to gate UI affordances. + // Active streaming generation lives inside [CactusRunner]. final CactusRunner _runner = CactusRunner(); - // Inferred quant label ("INT4" / "INT8" / "FP16") populated after a - // successful load. Comes from extracted-dir size buckets; will be - // replaced with metadata.json read once the convert script emits one. + // Inferred quant label populated after a successful load. String? _quantLabel; - // Tracks whether the on-disk model dir looks valid. Drives the hero - // "Download model" empty state vs. the regular three-card layout. - // Recomputed whenever the settings sheet reports a change. + // Tracks whether the on-disk model dir looks valid. final _modelManager = ModelManager(); bool? _modelInstalled; /// Lives at the page level so dismissing the settings sheet doesn't - /// kill the in-flight download. The sheet observes this via - /// AnimatedBuilder; reopening shows current progress. + /// kill the in-flight download. late final DownloadCoordinator _downloadCoordinator = DownloadCoordinator( _modelManager, ); @@ -521,42 +316,33 @@ class _TranslateDemoPageState extends State { super.initState(); _recorder = AudioRecorder(); _state.addListener(_onConversationChanged); + _questionHistory.addListener(_onConversationChanged); _downloadCoordinator.addListener(_onDownloadChanged); - _cleanupOldRecordings(); - // Cold-launch auto-load: read preferred language first so the - // warmup primes the prefix cache with the right system prompt, - // then check installed status, then load if eligible. Sequenced - // explicitly because _loadModel depends on both. + // Best-effort old-recording sweep, off the first frame so a slow + // documents-dir walk on cold UFS doesn't delay paint. + unawaited(Future.microtask(_cleanupOldRecordings)); + // Cold-launch auto-load: chain preferred language → slug → history + // → installed status → autoload (depends on each preceding step). unawaited(_bootstrapAndAutoLoad()); } - /// Chains the async pieces required before [_maybeAutoLoadOnLaunch] - /// can fire: the preferred language (so [_systemPrompt] is correct - /// when the init worker primes the prefix cache), the active model - /// slug (so the audio path uses the right per-bundle prompts), and - /// the installed status (so we don't try to load an empty model dir). Future _bootstrapAndAutoLoad() async { await _loadPreferredLanguage(); await _loadActiveModelSlug(); + await _questionHistory.load(); await _refreshInstalled(); if (!mounted) return; await _maybeAutoLoadOnLaunch(); } - /// Reads the active model slug persisted by the settings sheet on - /// download. Empty when never set (custom URL / adb-pushed install). - /// - /// Migrates users who installed a model BEFORE per-bundle slug - /// persistence shipped: if the slug pref is empty but the older - /// `model_url` pref exists and matches a catalogue entry, we - /// back-fill the slug so the picker can show the installed badge - /// correctly without forcing a re-download. One-time write per - /// install. + /// Reads the active model slug persisted by the settings sheet. + /// One-shot migration for users who installed before slug + /// persistence shipped: back-fill the slug from the URL pref. Future _loadActiveModelSlug() async { final prefs = await SharedPreferences.getInstance(); var slug = prefs.getString(kActiveModelSlugPrefKey) ?? ''; if (slug.isEmpty) { - final url = prefs.getString('model_url') ?? ''; + final url = prefs.getString(kModelUrlPrefKey) ?? ''; final match = findKnownModelByUrl(url); if (match != null) { slug = match.slug; @@ -569,16 +355,12 @@ class _TranslateDemoPageState extends State { _resyncAudioMode(); } - /// ChangeNotifier bridge: any mutation in [_state] triggers a page - /// rebuild so peek/active widget contracts stay unchanged. Widget-level - /// subscription via InheritedNotifier is a v2 refinement. + /// ChangeNotifier bridge: state changes trigger a page rebuild. void _onConversationChanged() { if (mounted) setState(() {}); } - /// Tracks the last download stage we acted on. We only react to the - /// edge into 'complete' — every notify before that is just streaming - /// progress for the settings sheet to render. + /// Tracks the last download stage we acted on. String _lastDownloadStage = ''; void _onDownloadChanged() { @@ -586,11 +368,10 @@ class _TranslateDemoPageState extends State { final stage = _downloadCoordinator.stage; if (stage == 'complete' && _lastDownloadStage != 'complete') { // Fresh download — auto-load the new weights. The user just - // waited 4+ GB of download; making them tap again would be - // gratuitous friction. _onModelReplaced suppresses its - // "tap to load" toast in this case. - _onModelReplaced(willAutoLoad: true); - unawaited(_loadModel()); + // waited 4+ GB; making them tap again would be gratuitous + // friction. + unawaited(_onModelReplaced(willAutoLoad: true) + .then((_) => mounted ? _loadModel() : null)); } _lastDownloadStage = stage; } @@ -598,22 +379,19 @@ class _TranslateDemoPageState extends State { /// Called when the on-disk model just changed (download completed, /// or settings sheet's Delete just wiped the dir). Refreshes the /// install-status indicator and drops any stale loaded handle. - /// Idempotent — safe to call from both the coordinator listener - /// (download path) and the settings sheet (delete path). /// /// [willAutoLoad] suppresses the "tap to load" toast for callers - /// that are about to issue a _loadModel themselves (specifically the - /// fresh-download path). Defaults to false so the settings-sheet - /// delete path still hints at the manual step. - void _onModelReplaced({bool willAutoLoad = false}) { - _refreshInstalled(); - // Pick up any slug change from the settings sheet — the sheet - // writes the active slug to SharedPreferences when the user - // starts a new download. We reload it here so the audio path - // (and any subsequent completion) uses the right per-bundle - // prompts. - unawaited(_loadActiveModelSlug()); + /// that are about to issue a _loadModel themselves. + Future _onModelReplaced({bool willAutoLoad = false}) async { + await _refreshInstalled(); + await _loadActiveModelSlug(); if (_modelLoaded) { + // F3: Await the runner before destroying the handle. Killing the + // worker isolate while it's mid-FFI on this model is a + // use-after-free; cactus_runner.disposeForShutdown signals + // cooperative stop and waits up to its grace window. + await _runner.disposeForShutdown(); + if (!mounted) return; setState(_unloadModel); _resetConversation(showToast: false); if (!willAutoLoad) { @@ -624,34 +402,25 @@ class _TranslateDemoPageState extends State { Future _loadPreferredLanguage() async { final prefs = await SharedPreferences.getInstance(); - final code = prefs.getString(_preferredLanguagePrefKey); + final code = prefs.getString(kPreferredLanguagePrefKey); final language = languageForCode(code); if (!mounted) return; setState(() { _preferredLanguage = language; - // Seed audio source-lang from preferred on bootstrap; user can - // override per session via the Speaking pill. + // Seed audio source-lang from preferred on bootstrap. _audioSourceLang = language; }); } /// Modes the currently-installed bundle supports for audio input. - /// Empty list means the bundle doesn't take audio directly (e.g. - /// the stitched Whisper→Gemma bundle, which routes through the - /// transcript-review intent picker instead). - List<_AudioMode> get _availableAudioModes { + List get _availableAudioModes { final bundle = findKnownModelBySlug(_activeModelSlug); - if (bundle == null) return const []; - return bundle.audioModes - .map(_audioModeFromName) - .whereType<_AudioMode>() - .toList(); + return bundle?.audioModes ?? const []; } /// Re-aligns [_audioMode] with the currently-loaded bundle's - /// available modes. Called whenever the active slug changes (new - /// install, slug-migration on first launch) so the Mode pill doesn't - /// show something the bundle wasn't trained for. + /// available modes so the Mode pill doesn't show something the + /// bundle wasn't trained for. void _resyncAudioMode() { final modes = _availableAudioModes; if (modes.isEmpty) return; @@ -662,7 +431,7 @@ class _TranslateDemoPageState extends State { Future _savePreferredLanguage(Language language) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_preferredLanguagePrefKey, language.code); + await prefs.setString(kPreferredLanguagePrefKey, language.code); } Future _refreshInstalled() async { @@ -671,8 +440,9 @@ class _TranslateDemoPageState extends State { setState(() => _modelInstalled = installed); } - /// Drops any `rec_*.wav` files lingering from a previous app session. - /// Recordings only need to live long enough to be transcribed. + /// Drops any `rec_*.wav` files lingering from a previous app + /// session. Recordings only need to live long enough to be + /// transcribed. Future _cleanupOldRecordings() async { try { final dir = await getApplicationDocumentsDirectory(); @@ -692,22 +462,35 @@ class _TranslateDemoPageState extends State { @override void dispose() { - // CactusRunner's shutdown path signals stop on the active model - // before killing the worker isolate so the native call returns - // cleanly — destroying the handle while `cactusComplete` is still - // running on it is a use-after-free. - _runner.disposeForShutdown(); + // Tear down in dependency order. Runner uses the model handle so + // it must wind down before cactusDestroy. Recorder owns an + // in-flight WAV path so we wipe it before cancelling streams. + _state.removeListener(_onConversationChanged); + _questionHistory.removeListener(_onConversationChanged); + _downloadCoordinator.removeListener(_onDownloadChanged); + + // F2: fire and forget — dispose() can't await, but + // disposeForShutdown signals cactusStop synchronously and only + // races a bounded grace window before force-killing the worker. + // The synchronous signal is what prevents the worst use-after- + // free; we accept the residual hazard if the user kills the app + // mid-tokenisation, since the OS reclaims the process anyway. + unawaited(_runner.disposeForShutdown()); + _recTimer?.cancel(); - _amplitudeSub?.cancel(); + unawaited(_amplitudeSub?.cancel()); + // F4: wipe any in-flight recording so a kill-mid-record doesn't + // leak the WAV until the next app launch. + unawaited(_wipeAudioFile()); + if (_model != null) { cactusDestroy(_model!); } if (_whisperModel != null) { cactusDestroy(_whisperModel!); } - _state.removeListener(_onConversationChanged); _state.dispose(); - _downloadCoordinator.removeListener(_onDownloadChanged); + _questionHistory.dispose(); _downloadCoordinator.dispose(); _recorder.dispose(); _input.dispose(); @@ -774,134 +557,22 @@ class _TranslateDemoPageState extends State { _toast('Classroom language set to ${picked.name}.'); } - void _rememberQuestion(String question) { - final trimmed = question.trim(); - if (trimmed.isEmpty) return; - _questionHistory.removeWhere((entry) => entry.question == trimmed); - _questionHistory.insert( - 0, - _QuestionHistoryEntry(question: trimmed, timestamp: DateTime.now()), - ); - if (_questionHistory.length > 20) { - _questionHistory.removeRange(20, _questionHistory.length); - } - } - - void _clearQuestionHistory() { - if (!mounted) return; - setState(() => _questionHistory.clear()); - } - - void _prefillQuestionFromHistory(String question) { - if (!mounted) return; - _input.text = question; - _input.selection = TextSelection.collapsed(offset: question.length); - setState(() {}); - FocusScope.of(context).requestFocus(_inputFocusNode); - } - Future _openQuestionHistory() async { if (_busy || _streaming) return; - final now = DateTime.now(); - final today = _questionHistory - .where((entry) => _isSameDay(entry.timestamp, now)) - .toList(growable: false); - final earlier = _questionHistory - .where((entry) => !_isSameDay(entry.timestamp, now)) - .toList(growable: false); - await showModalBottomSheet( + final picked = await showQuestionHistorySheet( context: context, - showDragHandle: true, - builder: (ctx) => SafeArea( - child: _questionHistory.isEmpty - ? const Padding( - padding: EdgeInsets.fromLTRB(20, 12, 20, 28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.history_rounded, size: 28), - SizedBox(height: 12), - Text( - 'No saved text questions yet.', - textAlign: TextAlign.center, - ), - SizedBox(height: 6), - Text( - 'When a student asks a text question, it will appear here so you can ask it again later.', - textAlign: TextAlign.center, - ), - ], - ), - ) - : ListView( - shrinkWrap: true, - children: [ - const ListTile( - title: Text('Question history'), - subtitle: Text( - 'Tap a previous text question to ask it again.', - ), - ), - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.only(right: 16, bottom: 8), - child: TextButton.icon( - onPressed: () { - _clearQuestionHistory(); - Navigator.of(ctx).pop(); - }, - icon: const Icon(Icons.delete_sweep_rounded), - label: const Text('Clear history'), - ), - ), - ), - if (today.isNotEmpty) ...[ - _HistorySectionHeader(label: 'Today'), - for (final entry in today) - _HistoryQuestionTile( - question: entry.question, - timeLabel: _shortRelativeTime(entry.timestamp), - onTap: () { - Navigator.of(ctx).pop(); - _prefillQuestionFromHistory(entry.question); - }, - ), - ], - if (earlier.isNotEmpty) ...[ - _HistorySectionHeader(label: 'Earlier'), - for (final entry in earlier) - _HistoryQuestionTile( - question: entry.question, - timeLabel: _shortRelativeTime(entry.timestamp), - onTap: () { - Navigator.of(ctx).pop(); - _prefillQuestionFromHistory(entry.question); - }, - ), - ], - const SizedBox(height: 16), - ], - ), - ), + history: _questionHistory, ); + if (picked == null || !mounted) return; + _input.text = picked; + _input.selection = TextSelection.collapsed(offset: picked.length); + setState(() {}); + FocusScope.of(context).requestFocus(_inputFocusNode); } - String _shortRelativeTime(DateTime timestamp) { - final delta = DateTime.now().difference(timestamp); - if (delta.inSeconds < 60) return 'just now'; - if (delta.inMinutes < 60) return '${delta.inMinutes}m ago'; - if (delta.inHours < 24) return '${delta.inHours}h ago'; - return '${delta.inDays}d ago'; - } - - bool _isSameDay(DateTime a, DateTime b) { - return a.year == b.year && a.month == b.month && a.day == b.day; - } - - /// Walks the model dir to total its size, then buckets into a quant label. - /// Cactus emits ~1959 files per model so this is ~50ms on a Pixel 10 — too - /// slow to put on the load critical path, fine to do after. + /// Walks the model dir to total its size, then buckets into a + /// quant label. ~50 ms on Pixel 10 — fine off the load critical + /// path. Future _refreshQuantLabel(String modelDir) async { try { final dir = Directory(modelDir); @@ -917,12 +588,11 @@ class _TranslateDemoPageState extends State { final gb = total / (1024 * 1024 * 1024); // Bucket boundaries chosen for Gemma 4 E2B specifically: // INT4 ≈ 4.7 GB, INT8 ≈ 5.5 GB, FP16 ≈ 10 GB. - // The ~5.0 / ~7.5 GB cuts give comfortable margin around each centroid. final label = gb < 5.0 ? 'INT4' : gb < 7.5 - ? 'INT8' - : 'FP16'; + ? 'INT8' + : 'FP16'; if (mounted) setState(() => _quantLabel = label); } catch (_) { /* size walk is best-effort */ @@ -941,16 +611,13 @@ class _TranslateDemoPageState extends State { final path = await _modelPath(); final dir = Directory(path); - // Layout detection. Flat bundles drop `output_norm.weights` at - // the model root; stitched bundles nest it under `gemma/` - // alongside a sibling `whisper/`. cactusInit needs to point at - // the dir that contains `config.txt`, so for stitched bundles - // we pass the subdirs. + // Layout detection. final marker = File(p.join(path, '.copy_complete')); final flatSignal = File(p.join(path, 'output_norm.weights')); final stitchedGemma = Directory(p.join(path, 'gemma')); final stitchedWhisper = Directory(p.join(path, 'whisper')); - final stitchedSignal = File(p.join(path, 'gemma', 'output_norm.weights')); + final stitchedSignal = + File(p.join(path, 'gemma', 'output_norm.weights')); if (!dir.existsSync() || (!marker.existsSync() && !flatSignal.existsSync() && @@ -967,16 +634,7 @@ class _TranslateDemoPageState extends State { ' gemma=$gemmaDir whisper=${whisperDir ?? "—"}', ); - // Set the crash-loop guard before crossing the FFI boundary. If - // the process dies inside cactusInit (corrupt weights, ABI - // mismatch), the flag persists to the next launch and the - // cold-launch auto-load skips itself. The guard wraps the - // *whole* dual init — a Whisper-side crash should equally - // block auto-load. - await prefs.setBool(_loadPendingPrefKey, true); - // Run cactusInit on a worker isolate so the ~5 s weight walk doesn't - // block the UI thread (which would trigger Android's ANR watchdog - // for slow devices and looks like a frozen app on every device). + await prefs.setBool(kModelLoadPendingPrefKey, true); final addrs = await compute( _runInitWorker, _InitWorkerArg(gemmaDir, whisperDir, _systemPrompt), @@ -988,37 +646,40 @@ class _TranslateDemoPageState extends State { 'cactusInit returned a null handle: ${cactusGetLastError()}', ); } + // F1: mount-check after the long async hop. If the page was + // disposed between cactusInit returning and us getting here, we + // own the handles and have to destroy them — otherwise the + // model stays mmap'd for the process lifetime. + if (!mounted) { + cactusDestroy(Pointer.fromAddress(gemmaAddr)); + if (whisperAddr != 0) { + cactusDestroy(Pointer.fromAddress(whisperAddr)); + } + return; + } setState(() { _model = Pointer.fromAddress(gemmaAddr); _whisperModel = whisperAddr == 0 ? null : Pointer.fromAddress(whisperAddr); }); - // Infer quant from on-disk size buckets, surfaced in the appbar - // tooltip. Done after load so the user sees "loaded" immediately - // and the size walk doesn't add to perceived load latency. unawaited(_refreshQuantLabel(path)); } catch (e) { _toast('Load failed: $e'); } finally { - // Clear the crash-loop guard whether we succeeded, caught the - // failure, or bailed out for a missing model file. The flag is - // only meant to catch process-killing native crashes — caught + // Clear the crash-loop guard whether we succeeded or failed. + // The flag only catches process-killing native crashes — caught // exceptions shouldn't punish the next launch. - await prefs.setBool(_loadPendingPrefKey, false); + await prefs.setBool(kModelLoadPendingPrefKey, false); if (mounted) setState(() => _busy = false); } } - /// Cold-launch auto-load: chained after preferred-language + installed - /// status so the warmup uses the right system prompt and so we don't - /// auto-load when no model is on disk. Skipped when [_loadPendingPrefKey] - /// is still set from a previous run — that means the prior attempt - /// crashed the process before clearing the flag, so retrying would - /// loop. The user can still tap the dot to retry manually. + /// Cold-launch auto-load: skipped when the crash-loop guard is + /// still set from a previous run (process died inside cactusInit). Future _maybeAutoLoadOnLaunch() async { final prefs = await SharedPreferences.getInstance(); - final loadPending = prefs.getBool(_loadPendingPrefKey) ?? false; + final loadPending = prefs.getBool(kModelLoadPendingPrefKey) ?? false; if (loadPending) { debugPrint('[main] auto-load skipped: previous load did not finish'); if (!mounted) return; @@ -1039,28 +700,24 @@ class _TranslateDemoPageState extends State { required String systemPrompt, String? audioPath, }) { - final messages = >[ - {'role': 'system', 'content': systemPrompt}, - {'role': 'user', 'content': userContent}, - ]; + final user = { + 'role': 'user', + 'content': userContent, + }; if (audioPath != null) { - final user = Map.from(messages.last); user['audio'] = [audioPath]; - messages[messages.length - 1] = user; } - return jsonEncode(messages); + return jsonEncode([ + {'role': 'system', 'content': systemPrompt}, + user, + ]); } - /// Runs a completion and streams the result into a [ConversationCard] - /// at the top of the [_state] stack (newest = index 0). Pulls focus to - /// the new card on arrival — UNLESS the user is currently reviewing - /// history (a v2 "tap to view new" indicator is the spec'd answer - /// there; for v1 [ConversationState.addTurn] always focuses the new - /// card). + /// Runs a completion and streams the result into a + /// [ConversationCard] at the top of the [_state] stack. Future _runCompletion({ required String userContent, String? audioPath, - required String inProgressStatus, required CardType cardType, required Language responseLang, Language? sourceLang, @@ -1071,16 +728,8 @@ class _TranslateDemoPageState extends State { return false; } - // Default to the classroom prompt for callers that don't ask - // otherwise (free-text "ask me anything" path). Callers using - // trained prompt prefixes — translate/explain chips, voice - // intent picker, looksLikeInstruction-detected typed input — - // pass `_trainedSystemPrompt` so the system+user pair matches - // what the SFT corpus paired them with. final effectiveSystemPrompt = systemPrompt ?? _systemPrompt; - debugPrint('[main] starting completion: $inProgressStatus'); var completedSuccessfully = false; - // Build a streaming card and insert at the top of the stack. final card = ConversationCard( id: 'c${DateTime.now().microsecondsSinceEpoch}', type: cardType, @@ -1098,41 +747,22 @@ class _TranslateDemoPageState extends State { systemPrompt: effectiveSystemPrompt, audioPath: audioPath, ); - // auto_handoff defaults to TRUE in cactus's InferenceOptions, which - // means low-confidence outputs trigger a Cactus Cloud round-trip - // with a 15 s timeout. We're offline-first (rural-Uganda use case) - // so this is wrong on both axes: privacy (silent network egress - // of prompt + audio) and UX (mystery 15 s pauses). Disable. + // auto_handoff defaults to TRUE in cactus's InferenceOptions, + // which triggers a Cactus Cloud round-trip on low-confidence + // outputs. We're offline-first; disable. // - // stop_sequences: Gemma 4 emits `` natively to mark - // turn end. Without an explicit stop, cactus keeps decoding up to - // max_tokens after the model has already said "I'm done" — burns - // tokens and produces trailing garbage when generation goes - // off-rails. - // Minimal option set verified to work with the cactus build we - // ship. We tried `top_p` and `repeat_penalty` (in the 1.15-1.20 - // range) against the token-level loops seen on Lug/Ach Answer - // turns; on-device behaviour was unchanged, which strongly - // suggests cactus's native sampler is silently ignoring those - // keys at this libcactus.so version. Until the recognised JSON - // option names are confirmed against the cactus source, leave - // them out — code that lies about tuning something it isn't is - // worse than no tuning. Loop / template-repetition behaviour is - // a model-quality issue, surfaced to jq for the next retrain. + // stop_sequences: Gemma 4 emits `` natively. Without + // an explicit stop, cactus keeps decoding to max_tokens after the + // model has already said "I'm done". final optionsJson = jsonEncode({ 'max_tokens': 200, 'temperature': 0.3, 'auto_handoff': false, 'stop_sequences': [''], }); - debugPrint( - '[main] dispatching streaming runner, msg=${messagesJson.length}c, audio=${audioPath != null}', - ); - if (audioPath != null) { - // Diagnostic: dump the exact JSON going over the FFI for audio - // turns so we can verify the system prompt + user content match - // what the active bundle was trained on. The audio path is - // captured as a string (not inline bytes), so this stays small. + if (audioPath != null && kDebugMode) { + // E4: audio-turn diagnostics gated to debug builds so release + // logcat doesn't carry the system prompt + user content. debugPrint( '[main] audio-turn payload | slug=$_activeModelSlug ' 'mode=${_audioMode.name} src=${_audioSourceLang.name} ' @@ -1146,14 +776,17 @@ class _TranslateDemoPageState extends State { model: _model!, messagesJson: messagesJson, optionsJson: optionsJson, - // Audio token sequences are duration-quantised placeholders — + // Audio token sequences are duration-quantised placeholders; // cactus's prefix match can't see content divergence between // two same-duration clips and silently returns the previous // turn's response. Force a fresh prefill on every audio turn. forceReset: audioPath != null, onToken: (token) { if (!mounted) return; - _state.touchCard(card, (c) => c.body += token); + // F7 + C1: appendToken uses an internal StringBuffer + // (O(token) per call), and only THIS card's body listener + // fires — not the whole conversation surface. + card.appendToken(token); }, onDone: (result) { if (!mounted) return; @@ -1168,7 +801,7 @@ class _TranslateDemoPageState extends State { c.ttftMs = result.ttftMs; c.isStreaming = false; }); - if (audioPath != null) { + if (audioPath != null && kDebugMode) { final out = (result.cleanedResponse ?? '').trim(); debugPrint('[main] audio-turn response: $out'); } @@ -1193,69 +826,46 @@ class _TranslateDemoPageState extends State { return completedSuccessfully; } - /// Cancels the active generation. Cactus's stop is cooperative — it sets - /// a flag and `cactusComplete` returns at the next token boundary, so the - /// UI may continue streaming for one or two more tokens before [_runner] - /// resolves and finalises the card. void _stopGeneration() { if (!_streaming) return; _runner.stop(); } - /// Send-button entry point. Takes the current input field text and - /// sends it as a classroom-assistant message. If the user typed one of - /// the known trained instruction prefixes, we preserve it verbatim. + /// Send-button entry point. Trained-prefix instruction → use the + /// trained system prompt; free text → classroom prompt. Future _sendInput() async { final raw = _input.text.trim(); if (_busy) return; if (raw.isEmpty) return; final isInstruction = looksLikeInstruction(raw); - final wrapped = raw; final responseLang = _preferredLanguage; final cardType = isInstruction ? CardType.explanation : CardType.answer; - final inProgress = isInstruction - ? 'Generating ${responseLang.name} response...' - : 'Thinking in ${responseLang.name}...'; - _rememberQuestion(raw); + _questionHistory.remember(raw); setState(() { _input.clear(); }); - // Trained instruction prefixes (Translate to …, Explain in …, etc.) - // were paired with the trained system message in the SFT corpus. - // Detecting one in the typed input is a strong signal to switch - // off the classroom prompt and use the on-distribution pair. Free - // text without a trained prefix keeps the classroom prompt so the - // "ask me anything" path still feels like a classroom assistant. await _runCompletion( - userContent: wrapped, - inProgressStatus: inProgress, + userContent: raw, cardType: cardType, responseLang: responseLang, - systemPrompt: isInstruction ? _trainedSystemPrompt : null, + systemPrompt: isInstruction ? kTrainedSystemPrompt : null, ); } Future _sendAudioBackedPrompt({required String audioPath}) async { final raw = _input.text.trim(); - final hasTypedPrompt = raw.isNotEmpty; final responseLang = _preferredLanguage; setState(() { _input.clear(); }); - // Stitched bundle path. Whisper transcribes the (already VAD- + // Stitched-bundle path. Whisper transcribes the (already VAD- // trimmed, end-pointed, no-speech-checked) clip on a worker - // isolate, then we show the user the transcript and let them - // pick an intent (Translate / Explain). Each intent maps to the - // trained user-prefix + trained system prompt pair from the SFT - // corpus so we hit the on-distribution path. Gemma in stitched - // bundles has its audio_tower stripped at export time so we never - // pass audioPath into the messages JSON — the transcript IS the - // user message. + // isolate, then the user picks an intent. if (_whisperModel != null) { setState(() => _busy = true); String transcript; @@ -1283,10 +893,10 @@ class _TranslateDemoPageState extends State { final cleanedTranscript = transcript.trim(); debugPrint( - '[main] stitched: transcribed in ${transcribeSw.elapsedMilliseconds} ms,' + '[main] stitched: transcribed in ' + '${transcribeSw.elapsedMilliseconds} ms,' ' ${cleanedTranscript.length} chars', ); - // We no longer need the WAV once we have the transcript. await _wipeAudioFile(forcePath: audioPath); if (cleanedTranscript.isEmpty) { @@ -1294,181 +904,94 @@ class _TranslateDemoPageState extends State { return; } - // Enter the inline review state. The input bar morphs to show - // the transcript + intent buttons in place; picking an intent - // calls _runVoiceReviewIntent which runs the completion with - // the trained system prompt + SFT prefix. setState(() { - _voiceReview = _VoiceReviewState( - transcript: hasTypedPrompt - ? '$raw\n\n$cleanedTranscript' - : cleanedTranscript, + _voiceReview = VoiceReviewState( + transcript: raw.isEmpty + ? cleanedTranscript + : '$raw\n\n$cleanedTranscript', target: responseLang, ); }); return; } - // Flat-bundle path: multimodal Gemma 4 sees the audio directly - // via its audio_tower. The forceReset in _runCompletion handles - // the audio cache-replay bug on this path; stitched mode doesn't - // need it because Gemma sees only fresh text every turn. - // - // Prompt routing, three cases: - // 1. Bundle declares audioModes → use the mode-based SFT prompt - // (Transcribe / Answer) the user pre-selected with the - // pills above the input bar, paired with kTrainedSystemPrompt. - // This is the modern path — every flat bundle in the - // catalogue declares modes. - // 2. Bundle has a legacy `audioUserPrompt` override but no - // modes → use that. Kept for backwards-compat / any future - // bundle that wants a single fixed prompt. - // 3. Neither → fall back to the verbose classroom prompt - // (`buildAudioOnlyUserPrompt`). Matches pre-routing - // behaviour for ADB-pushed / custom-URL installs. - final activeBundle = findKnownModelBySlug(_activeModelSlug); - final modes = _availableAudioModes; - final String userContent; - final String? systemPromptOverride; - if (hasTypedPrompt) { - userContent = raw; - systemPromptOverride = activeBundle?.systemPrompt; - } else if (modes.isNotEmpty) { - // Per-mode override takes precedence — load-bearing for the - // retrained speech-QA bundle whose Answer mode was trained - // with an empty user prompt. Empty string is intentional and - // must round-trip through the messages JSON unchanged. - final override = activeBundle?.audioPromptOverrides[_audioMode.name]; - userContent = override ?? _audioUserPromptForMode(_audioMode, _audioSourceLang); - systemPromptOverride = - activeBundle?.systemPrompt ?? kTrainedSystemPrompt; - } else if (activeBundle?.audioUserPrompt != null) { - userContent = activeBundle!.audioUserPrompt!; - systemPromptOverride = activeBundle.systemPrompt; - } else { - userContent = _defaultAudioUserPrompt(_preferredLanguage); - systemPromptOverride = null; - } - - // Card label routing. In Transcribe mode the model emits text IN - // the source language (whatever the user spoke), so the card - // belongs labelled with that language and the `voiceTranscript` - // type. In Answer mode the model answers in the preferred - // classroom language; passing sourceLang so the header reads - // "{source} → {reply} answer" when they differ (and stays as - // "{reply} answer" when they match). - final isTranscribeMode = - modes.isNotEmpty && _audioMode == _AudioMode.transcribe; - final audioCardType = - isTranscribeMode ? CardType.voiceTranscript : CardType.answer; - final audioResponseLang = - isTranscribeMode ? _audioSourceLang : responseLang; - // Source language is only meaningful for Answer cards in the - // mode-aware (modes.isNotEmpty) flat path; transcripts already - // self-label with the spoken language. - final audioSourceLangForCard = - (modes.isNotEmpty && !isTranscribeMode) ? _audioSourceLang : null; - - final inProgress = hasTypedPrompt - ? 'Thinking with audio in ${responseLang.name}...' - : (isTranscribeMode - ? 'Transcribing ${_audioSourceLang.name}...' - : 'Listening in ${responseLang.name}...'); + // Flat-bundle path. Pure prompt routing extracted to + // resolveAudioPrompt — see audio/audio_prompt_resolver.dart. + final plan = resolveAudioPrompt( + bundle: findKnownModelBySlug(_activeModelSlug), + audioModes: _availableAudioModes, + currentMode: _audioMode, + sourceLang: _audioSourceLang, + responseLang: responseLang, + typedText: raw, + classroomFallback: buildAudioOnlyUserPrompt(_preferredLanguage), + ); await _runCompletion( - userContent: userContent, + userContent: plan.userContent, audioPath: audioPath, - inProgressStatus: inProgress, - cardType: audioCardType, - responseLang: audioResponseLang, - sourceLang: audioSourceLangForCard, - systemPrompt: systemPromptOverride, + cardType: plan.cardType, + responseLang: plan.responseLang, + sourceLang: plan.sourceLang, + systemPrompt: plan.systemPrompt, ); await _wipeAudioFile(forcePath: audioPath); } - /// Chip action on an existing card: re-render that card's body in - /// [target] language. Append the new card to the stack — does not - /// modify the source card. Uses the trained system prompt because - /// the user-content prefix `Translate to {lang}: …` was paired - /// with that system message during SFT. - Future _translateExisting(ConversationCard src, Language target) async { - if (_busy) return; - final text = src.body.trim(); - if (text.isEmpty) return; - await _runCompletion( - userContent: translatePrompt(target, text), - inProgressStatus: 'Translating to ${target.name}...', - cardType: CardType.translation, - responseLang: target, - systemPrompt: _trainedSystemPrompt, - ); - } - - /// Chip action: expand on the source card's content in [target]. The - /// user picks [target] via a language picker on chip tap. - /// - /// The user prompt uses the `Explain in {lang} {text}` instruction - /// template from the SFT corpus, and the system message is the - /// verbatim trained string — the two were paired in training, so - /// matching both gets the model's strongest behaviour for the - /// "explain in X" task family. - Future _explainCardInLanguage( - ConversationCard src, - Language target, - ) async { + /// Chip action on an existing card. Uses the trained system prompt + /// because the user-content prefix (`Translate to {lang}: …` / + /// `Explain in {lang} …`) was paired with that system message + /// during SFT. + Future _runChipAction({ + required ConversationCard src, + required Language target, + required String userContent, + required CardType cardType, + }) async { if (_busy) return; - final text = src.body.trim(); - if (text.isEmpty) return; + if (src.body.trim().isEmpty) return; await _runCompletion( - userContent: 'Explain in ${target.name} $text', - inProgressStatus: 'Explaining in ${target.name}...', - cardType: CardType.explanation, + userContent: userContent, + cardType: cardType, responseLang: target, - systemPrompt: _trainedSystemPrompt, + systemPrompt: kTrainedSystemPrompt, ); } /// Fires the trained-prompt completion for the currently-reviewed - /// transcript. Mapped intent → SFT prefix: - /// - translate → "Translate from English to {target}: …" (70% - /// form in the SFT corpus; we know the source is English - /// because Whisper was pinned to it) - /// - explain → "Explain in {target} …" (instruction prefix) - /// Both use the trained system message. - Future _runVoiceReviewIntent(_VoiceIntent intent) async { + /// transcript. + Future _runVoiceReviewIntent(VoiceIntent intent) async { final review = _voiceReview; if (review == null || _busy) return; setState(() => _voiceReview = null); final String userContent; final CardType cardType; - final String inProgress; switch (intent) { - case _VoiceIntent.translate: + case VoiceIntent.translate: + // We know the source is English because Whisper was pinned + // to it (current smoke-test bundle). userContent = translateFromToPrompt( english, review.target, review.transcript, ); cardType = CardType.translation; - inProgress = 'Translating to ${review.target.name}...'; break; - case _VoiceIntent.explain: - userContent = 'Explain in ${review.target.name} ${review.transcript}'; + case VoiceIntent.explain: + userContent = + 'Explain in ${review.target.name} ${review.transcript}'; cardType = CardType.explanation; - inProgress = 'Explaining in ${review.target.name}...'; break; } await _runCompletion( userContent: userContent, audioPath: null, - inProgressStatus: inProgress, cardType: cardType, responseLang: review.target, - systemPrompt: _trainedSystemPrompt, + systemPrompt: kTrainedSystemPrompt, ); } @@ -1478,9 +1001,7 @@ class _TranslateDemoPageState extends State { setState(() => _voiceReview = null); } - /// Opens the language picker for the in-progress review — user can - /// change the target language per voice turn without leaving the - /// review UI. + /// Opens the language picker for the in-progress review. Future _pickVoiceReviewTarget() async { final review = _voiceReview; if (review == null) return; @@ -1517,8 +1038,7 @@ class _TranslateDemoPageState extends State { ); } - /// Dispatcher for chip taps from the ChipRow. The widget passes a - /// ChipAction; this maps it to the right completion path. + /// Dispatcher for chip taps from the ChipRow. Future _onChipTap(ConversationCard src, ChipAction action) async { switch (action.kind) { case ChipKind.translate: @@ -1528,7 +1048,12 @@ class _TranslateDemoPageState extends State { excludeLanguageCode: src.language.code, ); if (target == null) return; - await _translateExisting(src, target); + await _runChipAction( + src: src, + target: target, + userContent: translatePrompt(target, src.body.trim()), + cardType: CardType.translation, + ); break; } case ChipKind.explain: @@ -1537,36 +1062,29 @@ class _TranslateDemoPageState extends State { title: 'Choose a language to explain in', ); if (target == null) return; - await _explainCardInLanguage(src, target); + await _runChipAction( + src: src, + target: target, + userContent: 'Explain in ${target.name} ${src.body.trim()}', + cardType: CardType.explanation, + ); break; } } } - /// Pre-fills the input with the trained explain template for a given - /// target language. Used by the per-response "Explain..." chip and by - /// the empty-state starter chips. The cursor lands at the end so the - /// user can keep typing the topic. + /// Pre-fills the input with the trained explain template. void _prefillExplain(Language target) { final prefix = 'Explain in ${target.name} how to '; _input.text = prefix; _input.selection = TextSelection.collapsed(offset: prefix.length); - // Move focus to the field so the soft keyboard appears. FocusScope.of(context).requestFocus(_inputFocusNode); } // ---------- Recording (gesture-driven) ---------- - /// Toggles the mic. First tap starts capture; second tap stops and saves - /// the clip into hasClip review (Discard / Transcribe / Voice → English). - /// Permission errors surface on the status line; the user can just tap - /// again. We don't pop a dialog mid-flow because the user has already - /// committed to the action. Future _toggleRecording() async { - if (_recPhase == _RecPhase.recording) { - // Stop = save + transcribe. The chat layout has no in-between - // review state — the audio bubble + streaming transcript IS the - // review. (Cancel button uses _cancelRecording with discard:true.) + if (_recPhase == RecPhase.recording) { await _finishRecording(autoTranscribe: true); return; } @@ -1577,7 +1095,6 @@ class _TranslateDemoPageState extends State { return; } - // Drop any prior clip — starting a new recording always supersedes. await _wipeAudioFile(); final dir = await getApplicationDocumentsDirectory(); @@ -1602,17 +1119,13 @@ class _TranslateDemoPageState extends State { } if (!mounted) return; - // Reset the waveform so a new recording starts visually empty. for (var i = 0; i < _waveform.length; i++) { _waveform[i] = 0; } - // Clear speech-time tracking — a fresh recording starts with no - // detected speech, so end-pointing is suppressed until the first - // above-threshold sample arrives. _firstSpeechMs = null; _lastSpeechMs = null; setState(() { - _recPhase = _RecPhase.recording; + _recPhase = RecPhase.recording; _recordingStartedAt = DateTime.now(); _liveRecDuration = Duration.zero; _audioPath = outPath; @@ -1624,8 +1137,9 @@ class _TranslateDemoPageState extends State { if (started == null) return; final dur = DateTime.now().difference(started); if (dur.inSeconds >= _maxRecordingSeconds) { - // Hard ceiling: stop and auto-transcribe so a forgotten recorder - // can't run away into a 60s clip the model has to encode in FP16. + // Hard ceiling: stop and auto-transcribe so a forgotten + // recorder can't run away into a clip the model has to + // encode in FP16. _recTimer?.cancel(); unawaited(_finishRecording(autoTranscribe: true)); return; @@ -1637,60 +1151,50 @@ class _TranslateDemoPageState extends State { _amplitudeSub = _recorder .onAmplitudeChanged(const Duration(milliseconds: 80)) .listen((amp) { - if (!mounted) return; - // record's amplitude is in dBFS (negative). -60 ≈ silence, 0 ≈ peak. - final normalised = ((amp.current + 60) / 60).clamp(0.0, 1.0); - // Speech-time tracking for end-pointing and VAD trim. The - // recorder gives us amplitude per 80 ms window; we treat - // each window as a single time point at "now - 0 ms" — close - // enough given the 200 ms padding the trimmer adds either - // side of the speech range. - final started = _recordingStartedAt; - if (started != null && amp.current > _speechThresholdDbfs) { - final elapsedMs = DateTime.now().difference(started).inMilliseconds; - _firstSpeechMs ??= elapsedMs; - _lastSpeechMs = elapsedMs; - } - // End-pointing: only fires once we've heard at least one - // speech sample (so the user can take a beat before speaking - // without being auto-stopped). After that, sustained silence - // beyond _silenceTimeoutMs auto-finishes the recording. - final lastSpeech = _lastSpeechMs; - if (lastSpeech != null && started != null) { - final silentMs = - DateTime.now().difference(started).inMilliseconds - lastSpeech; - if (silentMs > _silenceTimeoutMs) { - debugPrint( - '[rec] end-pointing: silence ${silentMs}ms after last speech' - ' at ${lastSpeech}ms — auto-stop', - ); - _recTimer?.cancel(); - unawaited(_finishRecording(autoTranscribe: true)); - return; - } - } - setState(() { - // Shift the ring buffer one slot left and write the newest - // sample onto the right end. In-place to avoid the allocation - // churn of removeAt+add at 12.5 Hz, and we kept the list as - // fixed-length so this is the only legal mutation anyway. - for (var i = 0; i < _waveform.length - 1; i++) { - _waveform[i] = _waveform[i + 1]; - } - _waveform[_waveform.length - 1] = normalised; - }); - }); + if (!mounted) return; + // record's amplitude is in dBFS; -60 ≈ silence, 0 ≈ peak. + final normalised = ((amp.current + 60) / 60).clamp(0.0, 1.0); + final started = _recordingStartedAt; + if (started != null && amp.current > _speechThresholdDbfs) { + final elapsedMs = DateTime.now().difference(started).inMilliseconds; + _firstSpeechMs ??= elapsedMs; + _lastSpeechMs = elapsedMs; + } + // End-pointing only fires after first speech (so a user can + // take a beat before speaking without being auto-stopped). + final lastSpeech = _lastSpeechMs; + if (lastSpeech != null && started != null) { + final silentMs = + DateTime.now().difference(started).inMilliseconds - lastSpeech; + if (silentMs > _silenceTimeoutMs) { + debugPrint( + '[rec] end-pointing: silence ${silentMs}ms after last speech' + ' at ${lastSpeech}ms — auto-stop', + ); + _recTimer?.cancel(); + unawaited(_finishRecording(autoTranscribe: true)); + return; + } + } + setState(() { + // Shift the ring buffer left, write newest sample on the + // right. In-place to avoid the allocation churn of + // removeAt+add at 12.5 Hz. + for (var i = 0; i < _waveform.length - 1; i++) { + _waveform[i] = _waveform[i + 1]; + } + _waveform[_waveform.length - 1] = normalised; + }); + }); } /// Cancel an in-progress recording without saving or transcribing. Future _cancelRecording() async { - if (_recPhase != _RecPhase.recording) return; + if (_recPhase != RecPhase.recording) return; await _finishRecording(autoTranscribe: false, discard: true); } - /// Common shutdown for any recording termination path. `autoTranscribe` - /// chains into transcription (the normal stop path); `discard` throws - /// away the clip without producing a UserMessage. + /// Common shutdown for any recording termination path. Future _finishRecording({ required bool autoTranscribe, bool discard = false, @@ -1711,7 +1215,7 @@ class _TranslateDemoPageState extends State { if (discard) { await _wipeAudioFile(forcePath: path); setState(() { - _recPhase = _RecPhase.idle; + _recPhase = RecPhase.idle; _audioPath = null; _liveRecDuration = Duration.zero; _recordingStartedAt = null; @@ -1719,9 +1223,7 @@ class _TranslateDemoPageState extends State { return; } - // Snapshot speech timing before we clear the recording state, so - // _trimToSpeech below has the right window to slice to. _finishRecording - // is the only path that consumes these, so wiping them after is safe. + // Snapshot speech timing before we clear the recording state. final firstSpeech = _firstSpeechMs; final lastSpeech = _lastSpeechMs; _firstSpeechMs = null; @@ -1730,26 +1232,19 @@ class _TranslateDemoPageState extends State { _audioPath = null; _liveRecDuration = Duration.zero; _recordingStartedAt = null; - _recPhase = _RecPhase.idle; + _recPhase = RecPhase.idle; }); if (path != null && autoTranscribe) { if (firstSpeech == null || lastSpeech == null) { // No above-threshold sample for the entire recording. Sending // it would burn 5-15 s of audio_tower encode on silence and - // produce a hallucinated boilerplate response from the model - // (observed during validation on 2026-05-12). Drop the clip - // and tell the user — they can retry. Cheaper for the device, - // less misleading for the user. + // produce a hallucinated boilerplate response. debugPrint('[rec] no speech detected — dropping clip, not sending'); await _wipeAudioFile(forcePath: path); _toast('No speech detected. Try again.'); return; } - // Trim leading/trailing silence before the clip reaches the - // audio_tower. trimWavInPlace is best-effort — if it can't - // parse the header it returns the original path so the audio - // still flows. final startMs = (firstSpeech - _trimPaddingMs).clamp(0, 1 << 30); final endMs = lastSpeech + _trimPaddingMs; final preSize = File(path).existsSync() ? File(path).lengthSync() : 0; @@ -1761,15 +1256,10 @@ class _TranslateDemoPageState extends State { ); await _handleRecordedAudio(path: path); } else { - // Recording captured but not transcribing — drop the file since we - // don't surface a "review chip" anymore in the chat layout. await _wipeAudioFile(forcePath: path); } } - /// Sends the just-recorded clip into the classroom-assistant flow. - /// If the student typed text first, we send a mixed text+audio turn; - /// otherwise we send an audio-only classroom-assistant request. Future _handleRecordedAudio({required String path}) async { if (!File(path).existsSync()) { _toast('Recording vanished before it could be sent.'); @@ -1813,10 +1303,9 @@ class _TranslateDemoPageState extends State { builder: (ctx) => ModelSettingsSheet( coordinator: _downloadCoordinator, manager: _modelManager, - onModelChanged: _onModelReplaced, + onModelChanged: () => unawaited(_onModelReplaced()), ), ); - // Pick up any install/uninstall the sheet just performed. await _refreshInstalled(); } @@ -1827,30 +1316,27 @@ class _TranslateDemoPageState extends State { final scheme = Theme.of(context).colorScheme; return Scaffold( - // No AppBar — the minimal top bar lives inside the SafeArea so it - // can sit ~48 px tall (vs the previous ~95 px AppBar) and free up - // vertical real estate for the card stack. body: SafeArea( child: Column( children: [ - _MinimalTopBar( + MinimalTopBar( loaded: _modelLoaded, busy: _busy, classroomLanguage: _preferredLanguage, quantLabel: _quantLabel, onLoad: _busy && _modelLoaded ? null : _loadModel, onOpenHistory: - _busy || _streaming || _recPhase == _RecPhase.recording - ? null - : _openQuestionHistory, + _busy || _streaming || _recPhase == RecPhase.recording + ? null + : _openQuestionHistory, onPickLanguage: - _busy || _streaming || _recPhase == _RecPhase.recording - ? null - : _openLanguagePicker, + _busy || _streaming || _recPhase == RecPhase.recording + ? null + : _openLanguagePicker, onResetConversation: - _busy || _streaming || _recPhase == _RecPhase.recording - ? null - : () => _resetConversation(showToast: true), + _busy || _streaming || _recPhase == RecPhase.recording + ? null + : () => _resetConversation(showToast: true), onSettings: _busy ? null : _openModelSheet, ), Expanded( @@ -1864,9 +1350,8 @@ class _TranslateDemoPageState extends State { ); } - /// Shown when no model is on disk. Single CTA — opening the settings - /// sheet — instead of three disabled cards. Keeps the empty state - /// purposeful rather than presenting controls the user can't use. + /// Shown when no model is on disk. Single CTA — opening the + /// settings sheet — instead of three disabled cards. Widget _heroEmptyState(ColorScheme scheme) { return Padding( padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), @@ -1898,9 +1383,10 @@ class _TranslateDemoPageState extends State { 'Sunflower runs Gemma 4 E2B fully on-device for Luganda, ' 'Acholi, English, and more classroom languages. The model is ' '~4.7 GB — download once and use offline.', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -1917,29 +1403,15 @@ class _TranslateDemoPageState extends State { ); } - // ---- Card stack body ---- - Widget _cardStackBody(ColorScheme scheme) { final activeCard = _state.active; final chips = activeCard == null ? const [] : chipsFor(activeCard, _capabilities); - // Drop the chip row + its 12 px gap while the IME is up. The user - // is typing, not browsing alternative actions; chips would only - // crowd the active card out of view (the body shrinks to - // ~358 px logical with the keyboard up, and the bottom Wrap can - // overflow when chips break to two lines). - // - // Also drop chips while reviewing history (activeIndex > 0). The - // "Jump to latest" pill takes that real estate, and chips for an - // older card aren't actionable — the user is in past-mode, not - // in pick-an-action mode. final keyboardUp = MediaQuery.viewInsetsOf(context).bottom > 0; final inHistory = _state.activeIndex > 0; - // Hide the chip row while a voice transcript is being reviewed — - // the review bar already shows Translate/Explain pills tailored - // to the transcript, and the per-card chips are about previous - // responses, not the in-flight voice turn. + // Hide chips when IME is up, when reviewing history, or while + // the voice-review bar is rendering its own actions. final showChips = !keyboardUp && !inHistory && _voiceReview == null; return Column( @@ -1958,11 +1430,7 @@ class _TranslateDemoPageState extends State { ), ), ), - // "Jump to latest" cue: surfaces when the user is reviewing - // history (activeIndex > 0). The scrubber communicates position - // but doesn't suggest an action; this pill makes the path back - // to the present unmissable. Animated in/out so the layout - // doesn't shift abruptly when crossing the threshold. + // "Jump to latest" pill — surfaces when reviewing history. AnimatedSwitcher( duration: const Duration(milliseconds: 220), switchInCurve: Curves.easeOutCubic, @@ -1977,10 +1445,9 @@ class _TranslateDemoPageState extends State { key: const ValueKey('jump-latest'), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Center( - child: _JumpToLatestPill( + child: JumpToLatestPill( cardsBack: _state.activeIndex, onTap: () => _state.focusIndex(0), - scheme: scheme, ), ), ) @@ -2000,22 +1467,17 @@ class _TranslateDemoPageState extends State { ), const SizedBox(height: 12), ], - // Audio source-lang + mode pills, only when the active bundle - // declares audioModes (i.e., flat bundles that go through the - // audio_tower path). Hidden while voice-reviewing or - // recording, since the relevant choices have already been - // made by then, and while the keyboard is up to save vertical - // space. + // Audio source-lang + mode pills (flat bundles only). if (_voiceReview == null && !keyboardUp && - _recPhase == _RecPhase.idle && + _recPhase == RecPhase.idle && _availableAudioModes.isNotEmpty && _modelLoaded) ...[ _buildAudioPillsRow(scheme), const SizedBox(height: 6), ], if (_voiceReview != null) - _VoiceReviewBar( + VoiceReviewBar( review: _voiceReview!, busy: _busy, onPickIntent: _runVoiceReviewIntent, @@ -2027,16 +1489,15 @@ class _TranslateDemoPageState extends State { controller: _input, focusNode: _inputFocusNode, canSend: _modelLoaded && !_busy && !_streaming, - canRecord: - _modelLoaded && + canRecord: _modelLoaded && !_busy && !_streaming && - _recPhase == _RecPhase.idle, + _recPhase == RecPhase.idle, sourceLang: _preferredLanguage, onSend: _sendInput, onMicTap: _toggleRecording, onTextChanged: () => setState(() {}), - isRecording: _recPhase == _RecPhase.recording, + isRecording: _recPhase == RecPhase.recording, liveDuration: _liveRecDuration, maxRecordingSeconds: _maxRecordingSeconds, waveformSamples: _waveform, @@ -2050,17 +1511,8 @@ class _TranslateDemoPageState extends State { ); } - /// Two small pills above the input bar showing — and letting the - /// user override — what the model is about to be prompted to do - /// with the next voice clip. "Speaking: {lang}" feeds the - /// {language} placeholder in the Transcribe-mode SFT prompt and - /// signals expected input language to the model. "Mode: {label}" - /// picks between Transcribe and Answer (or whichever subset the - /// loaded bundle declared in [KnownModel.audioModes]). - /// - /// Renders low-contrast / chip-density so it reads as in-flight - /// metadata rather than a primary action, sitting unobtrusively - /// above the mic. + /// "Speaking: {lang}" + "Mode: {label}" pills above the input bar + /// (flat bundles only). Widget _buildAudioPillsRow(ColorScheme scheme) { final modes = _availableAudioModes; if (modes.isEmpty) return const SizedBox.shrink(); @@ -2081,28 +1533,28 @@ class _TranslateDemoPageState extends State { }, ), if (modes.length > 1) - PopupMenuButton<_AudioMode>( + PopupMenuButton( initialValue: _audioMode, tooltip: 'What should the model do with your audio?', onSelected: (m) => setState(() => _audioMode = m), itemBuilder: (ctx) => [ for (final m in modes) - PopupMenuItem<_AudioMode>( + PopupMenuItem( value: m, - child: Text(_audioModeLabel(m)), + child: Text(audioModeLabel(m)), ), ], child: _audioMetaPillShape( scheme, icon: Icons.tune_rounded, - label: 'Mode: ${_audioModeLabel(_audioMode)}', + label: 'Mode: ${audioModeLabel(_audioMode)}', ), ) else _audioMetaPillShape( scheme, icon: Icons.tune_rounded, - label: 'Mode: ${_audioModeLabel(_audioMode)}', + label: 'Mode: ${audioModeLabel(_audioMode)}', dimmed: true, ), ], @@ -2164,19 +1616,10 @@ class _TranslateDemoPageState extends State { ); } - /// Empty conversation placeholder. Per spec §12 we keep the screen - /// quiet — only the input bar is required — but a centred whisper of - /// guidance helps a first-time user. Starter chips render as a simple - /// Wrap so they can break to a second line if the language list grows. + /// Empty conversation placeholder. Widget _conversationPlaceholder() { final theme = Theme.of(context); final scheme = theme.colorScheme; - // The body collapses below the placeholder's natural height when - // the IME pops up briefly during launch (~229 px logical). Wrap in - // a LayoutBuilder + SingleChildScrollView so we vertically centre - // when there's room and scroll when there isn't, instead of - // overflowing. ConstrainedBox forces the child to fill at least - // the viewport so Center has bounded height to work with. return LayoutBuilder( builder: (ctx, constraints) => SingleChildScrollView( child: ConstrainedBox( @@ -2198,20 +1641,14 @@ class _TranslateDemoPageState extends State { ), const SizedBox(height: 6), Text( - 'Ask a question, try a starter, or record classroom audio.', + 'Ask a question, try a starter, or record classroom ' + 'audio.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant.withValues(alpha: 0.7), ), ), const SizedBox(height: 18), - // One quick starter for the user's preferred language + - // an escape hatch into the language picker. With 10 - // supported languages, listing one chip per language - // (the colleague's original empty state) filled the - // screen with near-identical rows and pushed the - // composer off the page. The picker matches the rest - // of the chip flow on this branch. Wrap( spacing: 8, runSpacing: 8, @@ -2255,605 +1692,3 @@ class _TranslateDemoPageState extends State { ); } } - -/// Minimal top bar — single Row with optional topic line on the left, -/// model-loaded indicator + settings icon on the right. ~48 px tall -/// (14 px top padding + 24 px content + 10 px bottom padding) replacing -/// the previous ~95 px AppBar. The topic slot stays empty in v1 because -/// topic detection is deferred per spec §10. -class _MinimalTopBar extends StatelessWidget { - final bool loaded; - final bool busy; - final Language classroomLanguage; - final String? quantLabel; - final VoidCallback? onLoad; - final VoidCallback? onOpenHistory; - final VoidCallback? onPickLanguage; - final VoidCallback? onResetConversation; - final VoidCallback? onSettings; - - const _MinimalTopBar({ - required this.loaded, - required this.busy, - required this.classroomLanguage, - required this.quantLabel, - required this.onLoad, - required this.onOpenHistory, - required this.onPickLanguage, - required this.onResetConversation, - required this.onSettings, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _LanguagePill( - language: classroomLanguage, - onTap: onPickLanguage, - scheme: scheme, - ), - const SizedBox(width: 6), - _HistoryButton(onTap: onOpenHistory, scheme: scheme), - const SizedBox(width: 6), - _ResetButton(onTap: onResetConversation, scheme: scheme), - ], - ), - ), - _LoadedDot( - loaded: loaded, - busy: busy, - quantLabel: quantLabel, - onTap: onLoad, - scheme: scheme, - ), - const SizedBox(width: 6), - _SettingsButton(onTap: onSettings, scheme: scheme), - ], - ), - ); - } -} - -class _HistoryButton extends StatelessWidget { - final VoidCallback? onTap; - final ColorScheme scheme; - - const _HistoryButton({required this.onTap, required this.scheme}); - - @override - Widget build(BuildContext context) { - final color = onTap == null - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : scheme.onSurfaceVariant; - return Tooltip( - message: 'Question history', - child: InkResponse( - onTap: onTap, - radius: 18, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(Icons.history_rounded, size: 20, color: color), - ), - ), - ); - } -} - -class _LanguagePill extends StatelessWidget { - final Language language; - final VoidCallback? onTap; - final ColorScheme scheme; - - const _LanguagePill({ - required this.language, - required this.onTap, - required this.scheme, - }); - - @override - Widget build(BuildContext context) { - final fg = onTap == null - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : scheme.onSurfaceVariant; - return Tooltip( - message: 'Classroom language: ${language.name}', - child: Material( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(999), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.language_rounded, size: 15, color: fg), - const SizedBox(width: 6), - Text( - language.abbreviation, - style: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: fg), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// 24 px circular indicator: filled primary with a white check when the -/// model is loaded, filled primary with a tiny spinner while loading, -/// and an outlined empty circle when not loaded. The not-loaded state -/// is intentionally muted — the input bar's load-gate banner is the -/// primary call-to-action; the dot is just a status reflection. -class _LoadedDot extends StatelessWidget { - final bool loaded; - final bool busy; - final String? quantLabel; - final VoidCallback? onTap; - final ColorScheme scheme; - - const _LoadedDot({ - required this.loaded, - required this.busy, - required this.quantLabel, - required this.onTap, - required this.scheme, - }); - - @override - Widget build(BuildContext context) { - final Widget content; - if (loaded) { - content = Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: scheme.primary, - shape: BoxShape.circle, - ), - child: const Icon(Icons.check_rounded, size: 14, color: Colors.white), - ); - } else if (busy) { - content = Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: scheme.primary, - shape: BoxShape.circle, - ), - child: const Padding( - padding: EdgeInsets.all(5), - child: CircularProgressIndicator( - strokeWidth: 1.8, - color: Colors.white, - ), - ), - ); - } else { - content = Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: scheme.onSurfaceVariant.withValues(alpha: 0.4), - width: 1, - ), - ), - ); - } - final tooltip = loaded - ? 'Loaded · gemma-4-e2b · ${quantLabel ?? '…'}' - : busy - ? 'Loading model…' - : 'Tap to load model'; - return Tooltip( - message: tooltip, - child: InkResponse(onTap: onTap, radius: 18, child: content), - ); - } -} - -class _ResetButton extends StatelessWidget { - final VoidCallback? onTap; - final ColorScheme scheme; - - const _ResetButton({required this.onTap, required this.scheme}); - - @override - Widget build(BuildContext context) { - final color = onTap == null - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : scheme.onSurfaceVariant; - return Tooltip( - message: 'Start fresh', - child: InkResponse( - onTap: onTap, - radius: 18, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(Icons.refresh_rounded, size: 20, color: color), - ), - ), - ); - } -} - -/// Floating "↓ Jump to latest · N back" pill rendered between the -/// card stack and the chip row when the user is reviewing history. -/// Reusing the spec's §7.4 cue surface — "New response · tap to view" -/// would slot in here as a label variant when we wire up the -/// keep-focus-on-mid-review-arrivals behaviour. -class _JumpToLatestPill extends StatelessWidget { - final int cardsBack; - final VoidCallback onTap; - final ColorScheme scheme; - - const _JumpToLatestPill({ - required this.cardsBack, - required this.onTap, - required this.scheme, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final label = cardsBack == 1 ? '1 back' : '$cardsBack back'; - return Material( - color: scheme.surfaceContainerHigh, - shape: StadiumBorder( - side: BorderSide( - color: scheme.primary.withValues(alpha: 0.5), - width: 1, - ), - ), - elevation: 4, - shadowColor: Colors.black.withValues(alpha: 0.15), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - customBorder: const StadiumBorder(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.arrow_downward_rounded, - size: 16, - color: scheme.primary, - ), - const SizedBox(width: 6), - Text( - 'Jump to latest', - style: theme.textTheme.labelLarge?.copyWith( - color: scheme.onSurface, - ), - ), - const SizedBox(width: 6), - Text( - '· $label', - style: theme.textTheme.labelLarge?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _HistorySectionHeader extends StatelessWidget { - final String label; - - const _HistorySectionHeader({required this.label}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ); - } -} - -class _HistoryQuestionTile extends StatelessWidget { - final String question; - final String timeLabel; - final VoidCallback onTap; - - const _HistoryQuestionTile({ - required this.question, - required this.timeLabel, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), - child: Material( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: scheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.history_rounded, - size: 16, - color: scheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - question, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - 'Asked $timeLabel', - style: theme.textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 10), - Text( - 'Ask again', - style: theme.textTheme.labelLarge?.copyWith( - color: scheme.primary, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// 36 px tap target wrapping the tune icon. We don't use IconButton -/// because its default density yields a 48 px touch target, which would -/// blow the minimal bar's height budget. -/// Renders in the input-bar slot while a stitched-bundle voice turn -/// is waiting on a user intent. Shows the transcript and the two -/// trained-prompt action buttons (Translate / Explain), plus a -/// per-turn target-language pill and a discard X. Replaces the -/// modal bottom-sheet flow with an inline in-place state machine: -/// the same surface that held the recording waveform now holds the -/// review. -class _VoiceReviewBar extends StatelessWidget { - final _VoiceReviewState review; - final bool busy; - final void Function(_VoiceIntent intent) onPickIntent; - final VoidCallback onPickTarget; - final VoidCallback onDiscard; - - const _VoiceReviewBar({ - required this.review, - required this.busy, - required this.onPickIntent, - required this.onPickTarget, - required this.onDiscard, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), - child: Material( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - // Transcript block. Quote-mark accent on the left + a - // discard X on the right keeps the same visual rhythm - // as a chat bubble — user reads what was heard, then - // either commits or backs out. - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.format_quote_rounded, - size: 18, - color: scheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - review.transcript, - style: theme.textTheme.bodyLarge, - ), - ), - const SizedBox(width: 4), - Tooltip( - message: 'Discard', - child: IconButton( - visualDensity: VisualDensity.compact, - onPressed: busy ? null : onDiscard, - icon: Icon( - Icons.close_rounded, - size: 18, - color: scheme.onSurfaceVariant, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - // Target-language pill above the action buttons so the - // hierarchy reads "what language → what action" — and - // the buttons themselves don't need to repeat the - // language name. Tap to retarget this voice turn - // without changing the global preferred language. - Row( - children: [ - Text( - 'Target', - style: theme.textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - Material( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - child: InkWell( - onTap: busy ? null : onPickTarget, - borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.language_rounded, - size: 14, - color: scheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - review.target.name, - style: theme.textTheme.labelLarge?.copyWith( - color: scheme.onSurface, - ), - ), - const SizedBox(width: 2), - Icon( - Icons.expand_more_rounded, - size: 16, - color: scheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - // Two short labels. The pill above carries the target - // language, so the button text doesn't need to. Avoids - // the two-line wrap we saw with "Translate to Luganda" - // / "Explain in Luganda" on a phone width. - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: busy - ? null - : () => onPickIntent(_VoiceIntent.translate), - icon: const Icon(Icons.translate_rounded, size: 18), - label: const Text('Translate'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FilledButton.tonalIcon( - onPressed: busy - ? null - : () => onPickIntent(_VoiceIntent.explain), - icon: const Icon( - Icons.lightbulb_outline_rounded, - size: 18, - ), - label: const Text('Explain'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _SettingsButton extends StatelessWidget { - final VoidCallback? onTap; - final ColorScheme scheme; - const _SettingsButton({required this.onTap, required this.scheme}); - - @override - Widget build(BuildContext context) { - final color = onTap == null - ? scheme.onSurfaceVariant.withValues(alpha: 0.5) - : scheme.onSurfaceVariant; - return Tooltip( - message: 'Manage model', - child: InkResponse( - onTap: onTap, - radius: 22, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(Icons.tune_rounded, size: 24, color: color), - ), - ), - ); - } -} diff --git a/lib/model_manager.dart b/lib/model_manager.dart index 21c5874..d109351 100644 --- a/lib/model_manager.dart +++ b/lib/model_manager.dart @@ -117,6 +117,20 @@ class ModelManager { } } + /// Soft ceiling on what we'll accept as a model tarball. The largest + /// catalogued bundle today is ~7.3 GB (E4B INT8); doubling that as a + /// safety margin gives 15 GB. A malicious / misconfigured server + /// delivering an unbounded stream would otherwise fill the device's + /// storage before we noticed. + static const int _maxBundleBytes = 15 * 1024 * 1024 * 1024; + + /// How long we'll tolerate the response staying idle before + /// abandoning the download. The HF S3 redirect occasionally stalls + /// mid-stream rather than dropping the socket cleanly; without a + /// stall watchdog we'd hold the user's phone in a foreground service + /// indefinitely. + static const Duration _stallTimeout = Duration(seconds: 30); + /// Streams a `.tar.gz` from [url] directly into the app's private model /// directory. Never buffers the whole tarball — gunzip + untar happens /// in one pipe so the only on-disk cost is the extracted files. @@ -126,10 +140,11 @@ class ModelManager { /// URL or 404 leaves the previous install intact. /// /// On any failure between the wipe and the completion marker (network - /// drop, gzip/tar corruption, [CancelToken.cancel] from the caller), the - /// half-extracted dir is wiped before the exception propagates — leaving - /// a clean "not installed" state rather than a corrupt partial that - /// would crash `cactusInit`. + /// drop, gzip/tar corruption, content cap breach, stall, or + /// [CancelToken.cancel] from the caller), the half-extracted dir is + /// wiped before the exception propagates — leaving a clean "not + /// installed" state rather than a corrupt partial that would crash + /// `cactusInit`. Future downloadAndInstall({ required String url, required void Function(ModelDownloadProgress) onProgress, @@ -163,9 +178,16 @@ class ModelManager { extractionStarted = true; final totalBytes = response.contentLength; + // Reject obvious oversize-by-claim before we even allocate disk. + if (totalBytes != null && totalBytes > _maxBundleBytes) { + throw Exception( + 'Bundle too large: ${formatBytes(totalBytes)} exceeds the ' + '${formatBytes(_maxBundleBytes)} safety cap.'); + } var bytesRead = 0; var lastEmit = DateTime.now(); + var lastByteAt = DateTime.now(); final counted = response.stream.map((chunk) { // Check cancel per-chunk so a cancel during the bulk download @@ -178,7 +200,18 @@ class ModelManager { throw const CancelledException(); } bytesRead += chunk.length; + if (bytesRead > _maxBundleBytes) { + // Server lied about Content-Length, or didn't send one. + throw Exception( + 'Bundle exceeded ${formatBytes(_maxBundleBytes)} safety cap; ' + 'aborting download.'); + } final now = DateTime.now(); + if (now.difference(lastByteAt) > _stallTimeout) { + throw Exception( + 'Download stalled for ${_stallTimeout.inSeconds}s — aborting.'); + } + lastByteAt = now; if (now.difference(lastEmit).inMilliseconds > 200) { onProgress(ModelDownloadProgress( stage: 'downloading', diff --git a/lib/model_settings_sheet.dart b/lib/model_settings_sheet.dart index 6bcb448..6cc3e36 100644 --- a/lib/model_settings_sheet.dart +++ b/lib/model_settings_sheet.dart @@ -9,17 +9,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'audio/audio_types.dart'; import 'classroom_prompt.dart'; import 'model_manager.dart'; - -const _urlPrefKey = 'model_url'; - -/// SharedPreferences key for the currently-active bundle slug. Written -/// when the user starts a download via this sheet (`_saveUrl`) and on -/// app boot read by [_TranslateDemoPageState] so the audio flow can -/// pick the right user prompt / system message per bundle. Empty when -/// the user supplied a custom URL (no match in [knownModels]). -const kActiveModelSlugPrefKey = 'active_model_slug'; +import 'preferences.dart'; const _urlExampleHint = 'https://huggingface.co///resolve/main/model.tar.gz'; @@ -46,24 +39,23 @@ class KnownModel { /// Audio task families this bundle can be prompted for. Drives the /// per-recording mode picker shown above the mic — first entry is - /// the default mode for a freshly-loaded bundle. Use canonical - /// short names: `'transcribe'`, `'answer'`. Empty (default) for - /// bundles that don't take audio directly (e.g. the stitched + /// the default mode for a freshly-loaded bundle. Empty (default) + /// for bundles that don't take audio directly (e.g. the stitched /// Whisper→Gemma bundle, which uses its own transcript-review /// flow). When a bundle declares modes, the audio path's user /// message is the SFT prompt for the selected mode and the system /// message is [kTrainedSystemPrompt] — overriding the per-bundle /// `audioUserPrompt` / `systemPrompt` fields above (those remain /// for legacy / non-mode bundles). - final List audioModes; + final List audioModes; - /// Per-mode user-content override, keyed by the same short names as - /// [audioModes]. When a value is present (including the empty - /// string) it is used verbatim as the user message for that mode, - /// bypassing the default `_audioUserPromptForMode` template. The - /// empty-string case is load-bearing for the speech-QA fine-tune, - /// whose retrained SFT recipe pairs audio with a blank user prompt. - final Map audioPromptOverrides; + /// Per-mode user-content override. When a value is present + /// (including the empty string) it is used verbatim as the user + /// message for that mode, bypassing the default-template path. + /// The empty-string case is load-bearing for the speech-QA + /// fine-tune, whose retrained SFT recipe pairs audio with a blank + /// user prompt. + final Map audioPromptOverrides; const KnownModel({ required this.slug, @@ -134,8 +126,8 @@ const knownModels = [ recommended: true, systemPrompt: 'You are an educational assistant that can give explanations, transcriptions and translations in Ugandan languages.', - audioModes: ['answer', 'transcribe'], - audioPromptOverrides: {'answer': ''}, + audioModes: [AudioMode.answer, AudioMode.transcribe], + audioPromptOverrides: {AudioMode.answer: ''}, ), // Luganda-only Speech-Q&A fine-tune from // jq/gemma4-e2b-question-answering-lug-only. Same architecture as @@ -163,8 +155,8 @@ const knownModels = [ 'https://huggingface.co/ak3ra/sunflower-qa-lug-cactus-int4/resolve/main/model.tar.gz', systemPrompt: 'You are an educational assistant that can give explanations, transcriptions and translations in Ugandan languages.', - audioModes: ['answer', 'transcribe'], - audioPromptOverrides: {'answer': ''}, + audioModes: [AudioMode.answer, AudioMode.transcribe], + audioPromptOverrides: {AudioMode.answer: ''}, ), // Multilingual E2B fine-tune from jq/gemma4-e2b-uga. Same E2B size // class as the canonical Sunflower (so same ~9 tok/s decode on @@ -183,7 +175,7 @@ const knownModels = [ tagline: 'Best multilingual coverage on Pixel-class', url: 'https://huggingface.co/ak3ra/sunflower-uga-cactus-int4/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), KnownModel( slug: 'sunflower-uga-int8', @@ -195,7 +187,7 @@ const knownModels = [ tagline: 'Higher quality · 12 GB+ RAM', url: 'https://huggingface.co/ak3ra/sunflower-uga-cactus-int8/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), // Canonical Lug/Ach specialist E2B (ak3ra/gemma-4-e2b-mixed-sft-fft). // Same architecture / speed as the multilingual variant above but @@ -211,7 +203,7 @@ const knownModels = [ tagline: 'Strongest Lug/Ach · same Pixel speed', url: 'https://huggingface.co/ak3ra/sunflower-cactus-int4/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), KnownModel( slug: 'sunflower-int8', @@ -222,7 +214,7 @@ const knownModels = [ tagline: 'Higher quality · best on 12 GB+ RAM', url: 'https://huggingface.co/ak3ra/sunflower-cactus-int8/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), // E4B fine-tune from jq/gemma4-e4b-uga. Vision tower stripped via // scripts/convert_and_push.sh strip-vision. Trained on 12 ASR @@ -240,7 +232,7 @@ const knownModels = [ tagline: 'Best of E4B for 8 GB devices', url: 'https://huggingface.co/ak3ra/sunflower-e4b-cactus-int4/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), KnownModel( slug: 'sunflower-e4b-int8', @@ -252,7 +244,7 @@ const knownModels = [ tagline: 'Highest quality · needs 12 GB+ RAM', url: 'https://huggingface.co/ak3ra/sunflower-e4b-cactus-int8/resolve/main/model.tar.gz', - audioModes: ['answer', 'transcribe'], + audioModes: [AudioMode.answer, AudioMode.transcribe], ), // Experimental stitched bundle for the voice-stitch architecture: // off-the-shelf Whisper-tiny (multilingual) cascades into Gemma E2B @@ -365,7 +357,7 @@ class _ModelSettingsSheetState extends State { final size = installed ? await widget.manager.sizeBytes() : 0; if (!mounted) return; setState(() { - _urlController.text = prefs.getString(_urlPrefKey) ?? _defaultUrl; + _urlController.text = prefs.getString(kModelUrlPrefKey) ?? _defaultUrl; _installed = installed; _sizeBytes = size; _activeModelSlug = @@ -376,7 +368,7 @@ class _ModelSettingsSheetState extends State { Future _saveUrl() async { final prefs = await SharedPreferences.getInstance(); final url = _urlController.text.trim(); - await prefs.setString(_urlPrefKey, url); + await prefs.setString(kModelUrlPrefKey, url); // Also persist which catalogue entry this URL belongs to (if any), // so the page-level audio flow can pick the right per-bundle // prompts on subsequent app launches. Empty slug = user pasted a @@ -388,8 +380,11 @@ class _ModelSettingsSheetState extends State { Future _download() async { final url = _urlController.text.trim(); - if (url.isEmpty || !url.startsWith('http')) { - setState(() => _localError = 'Enter a valid http(s) URL'); + // Require HTTPS so the 4 GB model weight stream isn't MITM-able. + // The weights become executable behaviour on device — plaintext + // delivery is a model-substitution attack surface. + if (url.isEmpty || !url.startsWith('https://')) { + setState(() => _localError = 'Enter a valid https:// URL'); return; } setState(() { diff --git a/lib/preferences.dart b/lib/preferences.dart new file mode 100644 index 0000000..623540d --- /dev/null +++ b/lib/preferences.dart @@ -0,0 +1,19 @@ +// SharedPreferences keys, gathered in one place so callers don't reach +// across UI files to find the right string constant. Adding a new key +// here is the only place that should ever own a string literal for +// prefs access. + +const kActiveModelSlugPrefKey = 'active_model_slug'; +const kModelUrlPrefKey = 'model_url'; +const kPreferredLanguagePrefKey = 'preferred_classroom_language'; + +/// SharedPreferences flag set to `true` immediately before we cross the +/// FFI boundary into `cactusInit`, and cleared after the call returns +/// (success or caught throw). If the process dies inside cactusInit — +/// a SIGSEGV from corrupt weights or an ABI mismatch — the flag stays +/// set across the relaunch. The cold-launch auto-load checks this flag +/// and falls back to manual-load with a retry toast instead of +/// repeating the crash. +const kModelLoadPendingPrefKey = 'model_load_pending'; + +const kQuestionHistoryPrefKey = 'question_history'; diff --git a/lib/util/relative_time.dart b/lib/util/relative_time.dart new file mode 100644 index 0000000..15dddcf --- /dev/null +++ b/lib/util/relative_time.dart @@ -0,0 +1,11 @@ +// Single short-relative-time formatter. Used by the top-of-card header, +// the peek-slab label, and the question-history sheet — three callsites +// previously carried near-identical copies that drifted. + +String formatRelative(DateTime t, {DateTime? now}) { + final delta = (now ?? DateTime.now()).difference(t); + if (delta.inSeconds < 60) return 'just now'; + if (delta.inMinutes < 60) return '${delta.inMinutes}m ago'; + if (delta.inHours < 24) return '${delta.inHours}h ago'; + return '${delta.inDays}d ago'; +} diff --git a/lib/widgets/jump_to_latest_pill.dart b/lib/widgets/jump_to_latest_pill.dart new file mode 100644 index 0000000..566ae77 --- /dev/null +++ b/lib/widgets/jump_to_latest_pill.dart @@ -0,0 +1,68 @@ +// Floating "↓ Jump to latest · N back" pill rendered between the card +// stack and the chip row when the user is reviewing history. Reuses +// the spec's §7.4 cue surface — "New response · tap to view" would +// slot in here as a label variant when we wire up the +// keep-focus-on-mid-review-arrivals behaviour. + +import 'package:flutter/material.dart'; + +class JumpToLatestPill extends StatelessWidget { + final int cardsBack; + final VoidCallback onTap; + + const JumpToLatestPill({ + super.key, + required this.cardsBack, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final label = cardsBack == 1 ? '1 back' : '$cardsBack back'; + return Material( + color: scheme.surfaceContainerHigh, + shape: StadiumBorder( + side: BorderSide( + color: scheme.primary.withValues(alpha: 0.5), + width: 1, + ), + ), + elevation: 4, + shadowColor: Colors.black.withValues(alpha: 0.15), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_downward_rounded, + size: 16, + color: scheme.primary, + ), + const SizedBox(width: 6), + Text( + 'Jump to latest', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurface, + ), + ), + const SizedBox(width: 6), + Text( + '· $label', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/question_history_sheet.dart b/lib/widgets/question_history_sheet.dart new file mode 100644 index 0000000..6f5f1f6 --- /dev/null +++ b/lib/widgets/question_history_sheet.dart @@ -0,0 +1,203 @@ +// Question history bottom sheet + the row tile + section header that +// previously lived in main.dart. Reads through [QuestionHistory] so the +// list survives an app kill (previously it was an in-memory list on the +// page state). + +import 'package:flutter/material.dart'; + +import '../conversation/question_history.dart'; +import '../util/relative_time.dart'; + +/// Show the question history sheet. Returns null when nothing was +/// picked. When the user taps an entry, the returned string is the +/// question to prefill into the input field. +Future showQuestionHistorySheet({ + required BuildContext context, + required QuestionHistory history, +}) { + final now = DateTime.now(); + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) { + final today = history.entries + .where((entry) => _isSameDay(entry.timestamp, now)) + .toList(growable: false); + final earlier = history.entries + .where((entry) => !_isSameDay(entry.timestamp, now)) + .toList(growable: false); + return SafeArea( + child: history.isEmpty + ? const Padding( + padding: EdgeInsets.fromLTRB(20, 12, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history_rounded, size: 28), + SizedBox(height: 12), + Text( + 'No saved text questions yet.', + textAlign: TextAlign.center, + ), + SizedBox(height: 6), + Text( + 'When a student asks a text question, it will appear ' + 'here so you can ask it again later.', + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text('Question history'), + subtitle: Text( + 'Tap a previous text question to ask it again.', + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 16, bottom: 8), + child: TextButton.icon( + onPressed: () { + history.clear(); + Navigator.of(ctx).pop(); + }, + icon: const Icon(Icons.delete_sweep_rounded), + label: const Text('Clear history'), + ), + ), + ), + if (today.isNotEmpty) ...[ + const _HistorySectionHeader(label: 'Today'), + for (final entry in today) + _HistoryQuestionTile( + question: entry.question, + timeLabel: formatRelative(entry.timestamp), + onTap: () => + Navigator.of(ctx).pop(entry.question), + ), + ], + if (earlier.isNotEmpty) ...[ + const _HistorySectionHeader(label: 'Earlier'), + for (final entry in earlier) + _HistoryQuestionTile( + question: entry.question, + timeLabel: formatRelative(entry.timestamp), + onTap: () => + Navigator.of(ctx).pop(entry.question), + ), + ], + const SizedBox(height: 16), + ], + ), + ); + }, + ); +} + +bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +class _HistorySectionHeader extends StatelessWidget { + final String label; + const _HistorySectionHeader({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ); + } +} + +class _HistoryQuestionTile extends StatelessWidget { + final String question; + final String timeLabel; + final VoidCallback onTap; + + const _HistoryQuestionTile({ + required this.question, + required this.timeLabel, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Material( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.history_rounded, + size: 16, + color: scheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + question, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Asked $timeLabel', + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Text( + 'Ask again', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.primary, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slot_card.dart b/lib/widgets/slot_card.dart index 477f007..6365886 100644 --- a/lib/widgets/slot_card.dart +++ b/lib/widgets/slot_card.dart @@ -26,6 +26,7 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../conversation/cards.dart'; import '../theme/sunflower_tokens.dart'; +import '../util/relative_time.dart'; /// Static interpolators between the four slot anchors. All functions /// accept a continuous double and clamp at the ends so out-of-range @@ -326,7 +327,7 @@ class _FullBody extends StatelessWidget { ) else Text( - _shortRelative(card.timestamp), + formatRelative(card.timestamp), style: theme.textTheme.labelSmall?.copyWith( color: scheme.onSurfaceVariant.withValues(alpha: 0.7), ), @@ -340,29 +341,39 @@ class _FullBody extends StatelessWidget { final bodyStyle = theme.textTheme.bodyMedium!.copyWith( color: theme.colorScheme.onSurface, ); - if (card.body.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 2), - child: Align( - alignment: Alignment.centerLeft, - child: _SunflowerSpinner(), - ), - ); - } - // While streaming, render the body as plain text. Re-parsing - // markdown on every token append is O(body) per rebuild, and at - // ~10 tok/s with body lengths in the hundreds of characters that - // stacks up. Once streaming completes (isStreaming flips false), - // swap to a fully-styled MarkdownBody. - if (card.isStreaming) { - return SelectableText(card.body, style: bodyStyle); - } - return SelectionArea( - child: MarkdownBody( - data: card.body, - styleSheet: buildSunflowerMarkdownStyle(context), - softLineBreak: true, - ), + // Subscribe to the per-card body listener so token appends repaint + // only THIS widget, not the whole Scaffold. Previously every token + // bounced through ConversationState.notifyListeners → page + // setState → full-tree rebuild at ~9 Hz. + return ValueListenableBuilder( + valueListenable: card.bodyVersion, + builder: (context, _, _) { + final body = card.body; + if (body.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Align( + alignment: Alignment.centerLeft, + child: _SunflowerSpinner(), + ), + ); + } + // While streaming, render the body as plain text. Re-parsing + // markdown on every token append is O(body) per rebuild and + // at ~10 tok/s with hundred-character bodies that stacks up. + // Once streaming completes (isStreaming flips false), swap to + // a fully-styled MarkdownBody. + if (card.isStreaming) { + return SelectableText(body, style: bodyStyle); + } + return SelectionArea( + child: MarkdownBody( + data: body, + styleSheet: buildSunflowerMarkdownStyle(context), + softLineBreak: true, + ), + ); + }, ); } @@ -392,7 +403,7 @@ class _SlabBody extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - Icon(_slabIcon(card.type), + Icon(_typeIcon(card.type), size: 11, color: scheme.onSurfaceVariant.withValues(alpha: 0.7)), const SizedBox(width: 6), Expanded( @@ -428,19 +439,6 @@ IconData _typeIcon(CardType type) { } } -IconData _slabIcon(CardType type) { - switch (type) { - case CardType.voiceTranscript: - return Icons.mic_rounded; - case CardType.translation: - return Icons.translate_rounded; - case CardType.explanation: - return Icons.lightbulb_outline_rounded; - case CardType.answer: - return Icons.auto_awesome_rounded; - } -} - String _slabLabel(ConversationCard card) { final type = switch (card.type) { CardType.voiceTranscript => 'voice', @@ -448,7 +446,7 @@ String _slabLabel(ConversationCard card) { CardType.explanation => 'explain', CardType.answer => 'answer', }; - final age = _age(card.timestamp); + final age = formatRelative(card.timestamp); final preview = _firstFiveWords(card.body); return preview.isEmpty ? '$type · $age' : '$type · $age — $preview'; } @@ -463,22 +461,6 @@ String _firstFiveWords(String body) { return words.take(5).join(' '); } -String _age(DateTime t) { - final d = DateTime.now().difference(t); - if (d.inMinutes < 1) return 'just now'; - if (d.inMinutes < 60) return '${d.inMinutes}m ago'; - if (d.inHours < 24) return '${d.inHours}h ago'; - return '${d.inDays}d ago'; -} - -String _shortRelative(DateTime t) { - final d = DateTime.now().difference(t); - if (d.inSeconds < 60) return 'just now'; - if (d.inMinutes < 60) return '${d.inMinutes}m ago'; - if (d.inHours < 24) return '${d.inHours}h ago'; - return '${d.inDays}d ago'; -} - // ----- Sunflower-petal spinner ------------------------------------------ // // Drop-in placeholder for the in-flight body of an empty active card. diff --git a/lib/widgets/top_bar.dart b/lib/widgets/top_bar.dart new file mode 100644 index 0000000..00a82a5 --- /dev/null +++ b/lib/widgets/top_bar.dart @@ -0,0 +1,268 @@ +// Minimal top bar that replaces the default ~95 px AppBar with a +// ~48 px bar inside the SafeArea. Hosts the classroom-language pill, +// the question-history shortcut, the reset shortcut, the model-loaded +// dot, and the settings entry-point. +// +// Pulled out of main.dart so the page state isn't responsible for +// rendering five sibling private widgets inline. + +import 'package:flutter/material.dart'; + +import '../languages.dart'; + +class MinimalTopBar extends StatelessWidget { + final bool loaded; + final bool busy; + final Language classroomLanguage; + final String? quantLabel; + final VoidCallback? onLoad; + final VoidCallback? onOpenHistory; + final VoidCallback? onPickLanguage; + final VoidCallback? onResetConversation; + final VoidCallback? onSettings; + + const MinimalTopBar({ + super.key, + required this.loaded, + required this.busy, + required this.classroomLanguage, + required this.quantLabel, + required this.onLoad, + required this.onOpenHistory, + required this.onPickLanguage, + required this.onResetConversation, + required this.onSettings, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _LanguagePill( + language: classroomLanguage, + onTap: onPickLanguage, + scheme: scheme, + ), + const SizedBox(width: 6), + _HistoryButton(onTap: onOpenHistory, scheme: scheme), + const SizedBox(width: 6), + _ResetButton(onTap: onResetConversation, scheme: scheme), + ], + ), + ), + _LoadedDot( + loaded: loaded, + busy: busy, + quantLabel: quantLabel, + onTap: onLoad, + scheme: scheme, + ), + const SizedBox(width: 6), + _SettingsButton(onTap: onSettings, scheme: scheme), + ], + ), + ); + } +} + +class _HistoryButton extends StatelessWidget { + final VoidCallback? onTap; + final ColorScheme scheme; + const _HistoryButton({required this.onTap, required this.scheme}); + + @override + Widget build(BuildContext context) { + final color = onTap == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : scheme.onSurfaceVariant; + return Tooltip( + message: 'Question history', + child: InkResponse( + onTap: onTap, + radius: 18, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.history_rounded, size: 20, color: color), + ), + ), + ); + } +} + +class _LanguagePill extends StatelessWidget { + final Language language; + final VoidCallback? onTap; + final ColorScheme scheme; + + const _LanguagePill({ + required this.language, + required this.onTap, + required this.scheme, + }); + + @override + Widget build(BuildContext context) { + final fg = onTap == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : scheme.onSurfaceVariant; + return Tooltip( + message: 'Classroom language: ${language.name}', + child: Material( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.language_rounded, size: 15, color: fg), + const SizedBox(width: 6), + Text( + language.abbreviation, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: fg), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 24 px circular indicator: filled primary with a white check when the +/// model is loaded, filled primary with a tiny spinner while loading, +/// and an outlined empty circle when not loaded. The not-loaded state +/// is intentionally muted — the input bar's load-gate banner is the +/// primary call-to-action; the dot is just a status reflection. +class _LoadedDot extends StatelessWidget { + final bool loaded; + final bool busy; + final String? quantLabel; + final VoidCallback? onTap; + final ColorScheme scheme; + + const _LoadedDot({ + required this.loaded, + required this.busy, + required this.quantLabel, + required this.onTap, + required this.scheme, + }); + + @override + Widget build(BuildContext context) { + final Widget content; + if (loaded) { + content = Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: scheme.primary, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check_rounded, size: 14, color: Colors.white), + ); + } else if (busy) { + content = Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: scheme.primary, + shape: BoxShape.circle, + ), + child: const Padding( + padding: EdgeInsets.all(5), + child: CircularProgressIndicator( + strokeWidth: 1.8, + color: Colors.white, + ), + ), + ); + } else { + content = Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: scheme.onSurfaceVariant.withValues(alpha: 0.4), + width: 1, + ), + ), + ); + } + final tooltip = loaded + ? 'Loaded · gemma-4-e2b · ${quantLabel ?? '…'}' + : busy + ? 'Loading model…' + : 'Tap to load model'; + return Tooltip( + message: tooltip, + child: InkResponse(onTap: onTap, radius: 18, child: content), + ); + } +} + +class _ResetButton extends StatelessWidget { + final VoidCallback? onTap; + final ColorScheme scheme; + + const _ResetButton({required this.onTap, required this.scheme}); + + @override + Widget build(BuildContext context) { + final color = onTap == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : scheme.onSurfaceVariant; + return Tooltip( + message: 'Start fresh', + child: InkResponse( + onTap: onTap, + radius: 18, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.refresh_rounded, size: 20, color: color), + ), + ), + ); + } +} + +class _SettingsButton extends StatelessWidget { + final VoidCallback? onTap; + final ColorScheme scheme; + const _SettingsButton({required this.onTap, required this.scheme}); + + @override + Widget build(BuildContext context) { + final color = onTap == null + ? scheme.onSurfaceVariant.withValues(alpha: 0.5) + : scheme.onSurfaceVariant; + return Tooltip( + message: 'Manage model', + child: InkResponse( + onTap: onTap, + radius: 22, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.tune_rounded, size: 24, color: color), + ), + ), + ); + } +} diff --git a/lib/widgets/voice_review_bar.dart b/lib/widgets/voice_review_bar.dart new file mode 100644 index 0000000..e8e5633 --- /dev/null +++ b/lib/widgets/voice_review_bar.dart @@ -0,0 +1,169 @@ +// Inline transcript-review UI for the stitched cascade. Renders in +// place of the recorder / text input while a stitched-bundle voice +// turn is waiting on the user to pick an intent. Replaces the modal +// bottom-sheet flow with an in-place state machine: the same surface +// that held the recording waveform now holds the review. + +import 'package:flutter/material.dart'; + +import '../audio/audio_types.dart'; + +class VoiceReviewBar extends StatelessWidget { + final VoiceReviewState review; + final bool busy; + final void Function(VoiceIntent intent) onPickIntent; + final VoidCallback onPickTarget; + final VoidCallback onDiscard; + + const VoiceReviewBar({ + super.key, + required this.review, + required this.busy, + required this.onPickIntent, + required this.onPickTarget, + required this.onDiscard, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), + child: Material( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + // Transcript + discard X. Quote-mark accent on the left + // mirrors a chat bubble rhythm — user reads what was + // heard, then either commits or backs out. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.format_quote_rounded, + size: 18, + color: scheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + review.transcript, + style: theme.textTheme.bodyLarge, + ), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Discard', + child: IconButton( + visualDensity: VisualDensity.compact, + onPressed: busy ? null : onDiscard, + icon: Icon( + Icons.close_rounded, + size: 18, + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + // Target-language pill above the action buttons so the + // hierarchy reads "what language → what action". + Row( + children: [ + Text( + 'Target', + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Material( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: busy ? null : onPickTarget, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.language_rounded, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + review.target.name, + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.onSurface, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.expand_more_rounded, + size: 16, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + // The pill above carries the target language, so the + // button text doesn't need to. Avoids the two-line wrap + // we saw with "Translate to Luganda" / "Explain in + // Luganda" on a phone width. + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: busy + ? null + : () => onPickIntent(VoiceIntent.translate), + icon: const Icon(Icons.translate_rounded, size: 18), + label: const Text('Translate'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.tonalIcon( + onPressed: busy + ? null + : () => onPickIntent(VoiceIntent.explain), + icon: const Icon( + Icons.lightbulb_outline_rounded, + size: 18, + ), + label: const Text('Explain'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +}