Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<String?> _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();
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 33 additions & 4 deletions packages/flutter_gemma/example/lib/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ChatScreenState extends State<ChatScreen> {
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

Expand Down Expand Up @@ -123,10 +124,18 @@ class ChatScreenState extends State<ChatScreen> {
// (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;
Expand All @@ -137,7 +146,13 @@ class ChatScreenState extends State<ChatScreen> {
);
}

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 ✅');
Expand Down Expand Up @@ -182,6 +197,9 @@ class ChatScreenState extends State<ChatScreen> {
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;
Expand Down Expand Up @@ -450,7 +468,18 @@ class ChatScreenState extends State<ChatScreen> {
),
],
)
: 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,
),
],
),
);
Expand Down
63 changes: 63 additions & 0 deletions packages/flutter_gemma/example/lib/cosine_similarity_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class _CosineSimilarityScreenState extends State<CosineSimilarityScreen> {
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<double>? _queryEmbedding;
Expand Down Expand Up @@ -88,6 +94,20 @@ class _CosineSimilarityScreenState extends State<CosineSimilarityScreen> {
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) {
Expand All @@ -113,6 +133,9 @@ class _CosineSimilarityScreenState extends State<CosineSimilarityScreen> {
}
setState(() {
_errorMessage = e.toString();
// Without this the progress card stays on screen forever, contradicting
// the error card right below it.
_downloadPercent = null;
});
}
}
Expand Down Expand Up @@ -307,6 +330,13 @@ class _CosineSimilarityScreenState extends State<CosineSimilarityScreen> {
),
),

if (_embeddingModel == null &&
_downloadPercent != null &&
_errorMessage == null) ...[
const SizedBox(height: 16),
_buildDownloadProgressCard(),
],

const SizedBox(height: 24),

// Test sentences
Expand Down Expand Up @@ -502,6 +532,39 @@ class _CosineSimilarityScreenState extends State<CosineSimilarityScreen> {
);
}

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),
Expand Down
76 changes: 71 additions & 5 deletions packages/flutter_gemma/example/lib/embedding_test_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ class _EmbeddingTestScreenState extends State<EmbeddingTestScreen> {
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() {
Expand Down Expand Up @@ -85,6 +91,22 @@ class _EmbeddingTestScreenState extends State<EmbeddingTestScreen> {
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) {
Expand All @@ -108,11 +130,15 @@ class _EmbeddingTestScreenState extends State<EmbeddingTestScreen> {
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;
});
}
}

Expand Down Expand Up @@ -163,6 +189,13 @@ class _EmbeddingTestScreenState extends State<EmbeddingTestScreen> {
),
),

if (_embeddingModel == null &&
_downloadPercent != null &&
_errorMessage == null) ...[
const SizedBox(height: 16),
_buildDownloadProgressCard(),
],

const SizedBox(height: 24),

// Input section
Expand Down Expand Up @@ -311,6 +344,39 @@ class _EmbeddingTestScreenState extends State<EmbeddingTestScreen> {
);
}

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),
Expand Down
8 changes: 7 additions & 1 deletion packages/flutter_gemma/example/lib/loading_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) ...[
Expand Down
Loading
Loading