diff --git a/packages/flutter_gemma/example/integration_test/voice_loop_test.dart b/packages/flutter_gemma/example/integration_test/voice_loop_test.dart index b79959f3..bbc50a4e 100644 --- a/packages/flutter_gemma/example/integration_test/voice_loop_test.dart +++ b/packages/flutter_gemma/example/integration_test/voice_loop_test.dart @@ -12,6 +12,7 @@ // --dart-define=HF_TOKEN=$HF_TOKEN import 'dart:io'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_gemma/flutter_gemma.dart'; import 'package:flutter_gemma_example/utils/audio_converter.dart'; @@ -41,14 +42,32 @@ const _llmModelUrl = const _ttsModelUrl = 'https://huggingface.co/litert-community/Matcha-TTS/resolve/main/'; -const _hfToken = String.fromEnvironment('HF_TOKEN'); - -/// On desktop, prefer a locally-staged model file (no network, no token) — the -/// convention the other desktop integration tests use. Stage a Gemma 3 1B IT -/// `.litertlm` into the app's documents dir as `gemma3-1b-it-int4.litertlm`. -/// Returns null when no staged file is present (iOS / CI → network install). +// The rest of the suite reads HUGGINGFACE_TOKEN (litertlm_ffi_test.dart et al); +// this file historically read HF_TOKEN. Accept both so the repo's habitual +// --dart-define does not silently yield an empty token and push the run onto +// the tokenless gated-download path. +const _hfTokenStandard = String.fromEnvironment('HUGGINGFACE_TOKEN'); +const _hfTokenLegacy = String.fromEnvironment('HF_TOKEN'); +final _hfToken = _hfTokenStandard.isNotEmpty + ? _hfTokenStandard + : _hfTokenLegacy; + +/// Prefer a device-local staged model file (no network, no token) — the +/// convention the other integration tests use. Desktop and iOS read it from the +/// app documents dir as `gemma3-1b-it-int4.litertlm`; Android (Firebase Test +/// Lab) reads it from `/data/local/tmp/flutter_gemma_test/`. +/// Returns null when no staged file is present (CI → network install). Future _stagedLlmPath() async { - if (!(Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { + // Android (Firebase Test Lab): the model is pushed to the device via + // `--other-files /data/local/tmp/flutter_gemma_test/...` — no network/token. + if (Platform.isAndroid) { + const p = '/data/local/tmp/flutter_gemma_test/gemma3-1b-it-int4.litertlm'; + return File(p).existsSync() ? p : null; + } + if (!(Platform.isMacOS || + Platform.isLinux || + Platform.isWindows || + Platform.isIOS)) { return null; } final docs = await getApplicationDocumentsDirectory(); @@ -84,18 +103,37 @@ void main() { .ofType(SttModelType.moonshine) .install(); - // Desktop: install the LLM from a locally-staged .litertlm (no network, - // no token) — the convention used by the other desktop integration tests - // (litertlm_ffi_test.dart / active_model_restore_test.dart). iOS / CI - // without a staged file fall back to the network install. + // Install the LLM from a device-local staged .litertlm (no network, no + // token) — the convention used by the other integration tests + // (litertlm_ffi_test.dart / active_model_restore_test.dart). A run + // without a staged file falls back to the gated network install, which + // is reported loudly below: a silent fallback would hide a broken + // `--other-files` push and burn 0.5 GB per run on a path the test was + // written to avoid. final llm = FlutterGemma.installModel( modelType: ModelType.gemmaIt, fileType: ModelFileType.litertlm, ); final llmLocalPath = await _stagedLlmPath(); if (llmLocalPath != null) { + debugPrint('[voice_loop] LLM from staged file: $llmLocalPath'); await llm.fromFile(llmLocalPath).install(); } else { + debugPrint( + '[voice_loop] no staged LLM found — falling back to the gated ' + 'network install', + ); + expect( + _hfToken.isNotEmpty, + isTrue, + reason: + 'No device-local staged LLM and no HuggingFace token, so the ' + 'gated network fallback cannot authenticate. Stage the model ' + '(Android: --other-files ' + '/data/local/tmp/flutter_gemma_test/gemma3-1b-it-int4.litertlm; ' + 'desktop/iOS: app documents dir) or pass ' + '--dart-define=HUGGINGFACE_TOKEN=...', + ); await llm .fromNetwork( _llmModelUrl, diff --git a/packages/flutter_gemma/example/lib/chat_screen.dart b/packages/flutter_gemma/example/lib/chat_screen.dart index e2199b38..fadfad4a 100644 --- a/packages/flutter_gemma/example/lib/chat_screen.dart +++ b/packages/flutter_gemma/example/lib/chat_screen.dart @@ -27,6 +27,7 @@ class ChatScreenState extends State { bool _isInitializing = false; // Protection against concurrent initialization bool _isStreaming = false; // Track streaming state String? _error; + int? _downloadPercent; Color _backgroundColor = const Color(0xFF0b2351); late String _appTitle; // App bar title; tool calls can override after load @@ -123,10 +124,18 @@ class ChatScreenState extends State { // (the download screen normally does this first, but ChatScreen is also // reachable directly, so guard here too). if (widget.model.isBuiltIn) { - await installer.fromBundled(widget.model.filename).install(); + await installer.fromBundled(widget.model.filename).withProgress(( + percent, + ) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }).install(); await BuiltInAi.ensureReady(); } else if (widget.model.localModel) { - await installer.fromAsset(widget.model.url).install(); + await installer.fromAsset(widget.model.url).withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }).install(); } else { // Load token if model needs authentication String? token; @@ -137,7 +146,13 @@ class ChatScreenState extends State { ); } - await installer.fromNetwork(widget.model.url, token: token).install(); + await installer + .fromNetwork(widget.model.url, token: token) + .withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }) + .install(); } debugPrint('[ChatScreen] Step 1: Model installed ✅'); @@ -182,6 +197,9 @@ class ChatScreenState extends State { setState(() { _error = 'Failed to initialize model: ${e.toString()}'; _isModelInitialized = false; + // Drop the stale percentage — a frozen "63%" claims a download is + // still running long after it died. + _downloadPercent = null; }); } rethrow; @@ -450,7 +468,18 @@ class ChatScreenState extends State { ), ], ) - : const LoadingWidget(message: 'Initializing model'), + // Initialization failed: show the error, not a frozen + // percentage. _error is only reachable here — the banner above + // lives in the initialized branch. + : _error != null + ? _buildErrorBanner(_error!) + : LoadingWidget( + message: 'Initializing model', + progress: + (_downloadPercent != null && _downloadPercent! < 100) + ? _downloadPercent + : null, + ), ], ), ); diff --git a/packages/flutter_gemma/example/lib/cosine_similarity_screen.dart b/packages/flutter_gemma/example/lib/cosine_similarity_screen.dart index ecdaac06..daa9f53f 100644 --- a/packages/flutter_gemma/example/lib/cosine_similarity_screen.dart +++ b/packages/flutter_gemma/example/lib/cosine_similarity_screen.dart @@ -29,6 +29,12 @@ class _CosineSimilarityScreenState extends State { EmbeddingModel? _embeddingModel; bool _isGenerating = false; String? _errorMessage; + int? _downloadPercent; + + /// Which file the percentage refers to. The model and the tokenizer are + /// downloaded sequentially into the same counter, so without this the bar + /// runs 0→100→0 and the label lies during the second phase. + String _downloadStage = 'model'; // Embeddings List? _queryEmbedding; @@ -88,6 +94,20 @@ class _CosineSimilarityScreenState extends State { await FlutterGemma.installEmbedder() .modelFromNetwork(widget.model.url, token: token) .tokenizerFromNetwork(widget.model.tokenizerUrl, token: token) + .withModelProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'model'; + _downloadPercent = percent; + }); + }) + .withTokenizerProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'tokenizer'; + _downloadPercent = percent; + }); + }) .install(); if (kDebugMode) { @@ -113,6 +133,9 @@ class _CosineSimilarityScreenState extends State { } setState(() { _errorMessage = e.toString(); + // Without this the progress card stays on screen forever, contradicting + // the error card right below it. + _downloadPercent = null; }); } } @@ -307,6 +330,13 @@ class _CosineSimilarityScreenState extends State { ), ), + if (_embeddingModel == null && + _downloadPercent != null && + _errorMessage == null) ...[ + const SizedBox(height: 16), + _buildDownloadProgressCard(), + ], + const SizedBox(height: 24), // Test sentences @@ -502,6 +532,39 @@ class _CosineSimilarityScreenState extends State { ); } + Widget _buildDownloadProgressCard() { + final percent = _downloadPercent!; + final showPercent = percent < 100; + return Card( + color: const Color(0xFF1a3a5c), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.blue, + value: showPercent ? percent / 100.0 : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + showPercent + ? 'Downloading $_downloadStage… $percent%' + : 'Installing model…', + style: const TextStyle(color: Colors.white60), + ), + ), + ], + ), + ), + ); + } + Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), diff --git a/packages/flutter_gemma/example/lib/embedding_test_screen.dart b/packages/flutter_gemma/example/lib/embedding_test_screen.dart index b3191a99..9544e843 100644 --- a/packages/flutter_gemma/example/lib/embedding_test_screen.dart +++ b/packages/flutter_gemma/example/lib/embedding_test_screen.dart @@ -25,6 +25,12 @@ class _EmbeddingTestScreenState extends State { bool _isGenerating = false; String? _errorMessage; EmbeddingModel? _embeddingModel; + int? _downloadPercent; + + /// Which file the percentage refers to. The model and the tokenizer are + /// downloaded sequentially into the same counter, so without this the bar + /// runs 0→100→0 and the label lies during the second phase. + String _downloadStage = 'model'; @override void initState() { @@ -85,6 +91,22 @@ class _EmbeddingTestScreenState extends State { builder = builder.tokenizerFromBundled(widget.model.tokenizerUrl); } + builder = builder + .withModelProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'model'; + _downloadPercent = percent; + }); + }) + .withTokenizerProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'tokenizer'; + _downloadPercent = percent; + }); + }); + await builder.install(); if (kDebugMode) { @@ -108,11 +130,15 @@ class _EmbeddingTestScreenState extends State { if (kDebugMode) { debugPrint('✅ Embedding model created on test screen (Modern API)'); } - } catch (e) { - if (kDebugMode) { - debugPrint('⚠️ Could not create embedding model: $e'); - } - // Don't set error state here - let user try to generate and see the error + } catch (e, st) { + debugPrint('[EmbeddingTestScreen] ❌ install/init failed: $e\n$st'); + if (!mounted) return; + setState(() { + _errorMessage = 'Failed to install the embedding model: $e'; + // Without this the progress card stays on screen forever, claiming a + // download that already died. + _downloadPercent = null; + }); } } @@ -163,6 +189,13 @@ class _EmbeddingTestScreenState extends State { ), ), + if (_embeddingModel == null && + _downloadPercent != null && + _errorMessage == null) ...[ + const SizedBox(height: 16), + _buildDownloadProgressCard(), + ], + const SizedBox(height: 24), // Input section @@ -311,6 +344,39 @@ class _EmbeddingTestScreenState extends State { ); } + Widget _buildDownloadProgressCard() { + final percent = _downloadPercent!; + final showPercent = percent < 100; + return Card( + color: const Color(0xFF1a3a5c), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.blue, + value: showPercent ? percent / 100.0 : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + showPercent + ? 'Downloading $_downloadStage… $percent%' + : 'Installing model…', + style: const TextStyle(color: Colors.white60), + ), + ), + ], + ), + ), + ); + } + Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), diff --git a/packages/flutter_gemma/example/lib/loading_widget.dart b/packages/flutter_gemma/example/lib/loading_widget.dart index c0440b57..f5e396d5 100644 --- a/packages/flutter_gemma/example/lib/loading_widget.dart +++ b/packages/flutter_gemma/example/lib/loading_widget.dart @@ -15,7 +15,13 @@ class LoadingWidget extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator(), + // Determinate while a real percentage is in flight, indeterminate + // once the bytes are down and the engine is warming up. + CircularProgressIndicator( + value: (progress != null && progress! >= 0 && progress! < 100) + ? progress! / 100.0 + : null, + ), const SizedBox(height: 16), Text(message), if (progress != null) ...[ diff --git a/packages/flutter_gemma/example/lib/stt_screen.dart b/packages/flutter_gemma/example/lib/stt_screen.dart index 46086a56..72dc736f 100644 --- a/packages/flutter_gemma/example/lib/stt_screen.dart +++ b/packages/flutter_gemma/example/lib/stt_screen.dart @@ -29,6 +29,12 @@ class _SttScreenState extends State { SpeechRecognizer? _recognizer; bool _isInitializing = true; String? _initError; + int? _downloadPercent; + + /// Which file the percentage refers to. The model and the tokenizer are + /// downloaded sequentially into the same counter, so without this the bar + /// runs 0→100→0 and the label lies during the second phase. + String _downloadStage = 'model'; bool _isTranscribing = false; String? _transcript; @@ -72,6 +78,20 @@ class _SttScreenState extends State { .modelFromNetwork(widget.model.modelUrl, token: token) .tokenizerFromNetwork(widget.model.tokenizerUrl, token: token) .ofType(widget.model.sttModelType) + .withModelProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'model'; + _downloadPercent = percent; + }); + }) + .withTokenizerProgress((percent) { + if (!mounted) return; + setState(() { + _downloadStage = 'tokenizer'; + _downloadPercent = percent; + }); + }) .install(); final recognizer = await FlutterGemma.getActiveStt(); @@ -324,16 +344,23 @@ class _SttScreenState extends State { } Widget _buildInitializingState() { - return const Center( + final percent = _downloadPercent; + final showPercent = percent != null && percent < 100; + return Center( child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), + padding: const EdgeInsets.symmetric(vertical: 32.0), child: Column( children: [ - CircularProgressIndicator(color: Colors.blue), - SizedBox(height: 16), + CircularProgressIndicator( + color: Colors.blue, + value: showPercent ? percent / 100.0 : null, + ), + const SizedBox(height: 16), Text( - 'Installing model and preparing recognizer…', - style: TextStyle(color: Colors.white60), + showPercent + ? 'Downloading $_downloadStage… $percent%' + : 'Installing model and preparing recognizer…', + style: const TextStyle(color: Colors.white60), ), ], ), diff --git a/packages/flutter_gemma/example/lib/translate_screen.dart b/packages/flutter_gemma/example/lib/translate_screen.dart index 6e48176d..d8b0708a 100644 --- a/packages/flutter_gemma/example/lib/translate_screen.dart +++ b/packages/flutter_gemma/example/lib/translate_screen.dart @@ -34,6 +34,7 @@ class _TranslateScreenState extends State { String? _error; InferenceModel? _inference; TranslateRunner? _runner; + int? _downloadPercent; String _src = 'en'; String _dst = 'fr'; @@ -79,7 +80,10 @@ class _TranslateScreenState extends State { await FlutterGemma.installModel( modelType: widget.model.modelType, fileType: widget.model.fileType, - ).fromNetwork(widget.model.url, token: token).install(); + ).fromNetwork(widget.model.url, token: token).withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }).install(); if (kDebugMode) { debugPrint('[TranslateScreen] Model installed, getting active…'); @@ -175,8 +179,12 @@ class _TranslateScreenState extends State { body: !_isModelInitialized ? (_error != null ? _buildErrorState(_error!) - : const LoadingWidget( + : LoadingWidget( message: 'Initializing translation model', + progress: + (_downloadPercent != null && _downloadPercent! < 100) + ? _downloadPercent + : null, )) : Padding( padding: const EdgeInsets.all(16), diff --git a/packages/flutter_gemma/example/lib/tts_screen.dart b/packages/flutter_gemma/example/lib/tts_screen.dart index af608cf0..5736a862 100644 --- a/packages/flutter_gemma/example/lib/tts_screen.dart +++ b/packages/flutter_gemma/example/lib/tts_screen.dart @@ -32,6 +32,7 @@ class _TtsScreenState extends State { SpeechSynthesizer? _synth; bool _isInitializing = true; String? _initError; + int? _downloadPercent; final _textController = TextEditingController(text: 'Hello world.'); final _player = AudioPlayer(); @@ -60,6 +61,10 @@ class _TtsScreenState extends State { await FlutterGemma.installTts() .fromNetwork(_model.baseUrl) .ofType(_model.ttsModelType) + .withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }) .install(); final synth = await FlutterGemma.getActiveTts(); @@ -184,16 +189,23 @@ class _TtsScreenState extends State { } Widget _buildInitializingState() { - return const Center( + final percent = _downloadPercent; + final showPercent = percent != null && percent < 100; + return Center( child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), + padding: const EdgeInsets.symmetric(vertical: 32.0), child: Column( children: [ - CircularProgressIndicator(color: Colors.blue), - SizedBox(height: 16), + CircularProgressIndicator( + color: Colors.blue, + value: showPercent ? percent / 100.0 : null, + ), + const SizedBox(height: 16), Text( - 'Installing model and preparing synthesizer…', - style: TextStyle(color: Colors.white60), + showPercent + ? 'Downloading model… $percent%' + : 'Installing model and preparing synthesizer…', + style: const TextStyle(color: Colors.white60), ), ], ), diff --git a/packages/flutter_gemma/example/lib/voice_screen.dart b/packages/flutter_gemma/example/lib/voice_screen.dart index 5bff66a0..1370ef87 100644 --- a/packages/flutter_gemma/example/lib/voice_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_screen.dart @@ -44,6 +44,8 @@ class _VoiceScreenState extends State { bool _isInitializing = true; String? _initError; + String _stage = 'Downloading speech model'; + int? _downloadPercent; final AudioRecorder _audioRecorder = AudioRecorder(); final _player = AudioPlayer(); @@ -89,17 +91,39 @@ class _VoiceScreenState extends State { final sttToken = _sttModel.needsAuth ? await AuthTokenService.loadToken() : null; + if (!mounted) return; + setState(() { + _stage = 'Downloading speech model'; + _downloadPercent = null; + }); await FlutterGemma.installStt() .modelFromNetwork(_sttModel.modelUrl, token: sttToken) .tokenizerFromNetwork(_sttModel.tokenizerUrl, token: sttToken) .ofType(_sttModel.sttModelType) + .withModelProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }) + .withTokenizerProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }) .install(); final recognizer = await FlutterGemma.getActiveStt(); // --- TTS --- + if (!mounted) return; + setState(() { + _stage = 'Downloading voice model'; + _downloadPercent = null; + }); await FlutterGemma.installTts() .fromNetwork(_ttsModel.baseUrl) .ofType(_ttsModel.ttsModelType) + .withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }) .install(); final synth = await FlutterGemma.getActiveTts(); @@ -108,10 +132,18 @@ class _VoiceScreenState extends State { if (_llmModel.needsAuth) { llmToken = await AuthTokenService.loadToken(); } + if (!mounted) return; + setState(() { + _stage = 'Downloading language model'; + _downloadPercent = null; + }); await FlutterGemma.installModel( modelType: _llmModel.modelType, fileType: _llmModel.fileType, - ).fromNetwork(_llmModel.url, token: llmToken).install(); + ).fromNetwork(_llmModel.url, token: llmToken).withProgress((percent) { + if (!mounted) return; + setState(() => _downloadPercent = percent); + }).install(); final model = await FlutterGemma.getActiveModel( maxTokens: _llmModel.maxTokens, @@ -430,16 +462,23 @@ class _VoiceScreenState extends State { } Widget _buildInitializingState() { - return const Center( + final percent = _downloadPercent; + final showPercent = percent != null && percent < 100; + return Center( child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), + padding: const EdgeInsets.symmetric(vertical: 32.0), child: Column( children: [ - CircularProgressIndicator(color: Colors.blue), - SizedBox(height: 16), + CircularProgressIndicator( + color: Colors.blue, + value: showPercent ? percent / 100.0 : null, + ), + const SizedBox(height: 16), Text( - 'Installing models and preparing the voice session…', - style: TextStyle(color: Colors.white60), + showPercent + ? '$_stage… $percent%' + : 'Installing models and preparing the voice session…', + style: const TextStyle(color: Colors.white60), ), ], ), diff --git a/packages/flutter_gemma/example/pubspec.lock b/packages/flutter_gemma/example/pubspec.lock index cc081564..e20f00b0 100644 --- a/packages/flutter_gemma/example/pubspec.lock +++ b/packages/flutter_gemma/example/pubspec.lock @@ -129,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.13" + decimal: + dependency: transitive + description: + name: decimal + sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" equatable: dependency: "direct main" description: @@ -425,6 +441,14 @@ packages: description: flutter source: sdk version: "0.0.0" + genai_primitives: + dependency: transitive + description: + name: genai_primitives + sha256: "5a1e8c7ae9caf01aa609f59c54de47b73f51bf1ae95e0f679b7c7ab6948c4d9d" + url: "https://pub.dev" + source: hosted + version: "0.2.4" glob: dependency: transitive description: @@ -526,6 +550,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -542,6 +574,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + json_schema_builder: + dependency: transitive + description: + name: json_schema_builder + sha256: e46b1a2957590d2c811f47b22079710a273ebf9c8240a9e1440b759efb8ded5f + url: "https://pub.dev" + source: hosted + version: "0.1.6" just_audio: dependency: "direct main" description: @@ -846,6 +886,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.dev" + source: hosted + version: "2.2.3" record: dependency: "direct main" description: