feat: add model download screen and gate app on model presence

Adds a one-time download flow for the on-device Gemma 2B model (~1.1 GB)
with a progress bar, partial-download cleanup, and automatic navigation
to the app shell once the file is verified. App.dart now gates on
modelPresentProvider instead of onboarding state.
This commit is contained in:
2026-04-26 14:42:39 +10:00
parent ea412d6fe9
commit e464c5621e
4 changed files with 275 additions and 5 deletions
+63
View File
@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
class ModelDownloadService {
static const modelFileName = 'gemma-2b-it-cpu-int4.bin';
static const modelSizeBytes = 1183285248; // ~1.1 GB
// Gemma 2B IT CPU int4 — MediaPipe LLM Inference model
static const _downloadUrl =
'https://storage.googleapis.com/mediapipe-models/llm_inference/'
'gemma-2b-it-cpu-int4/float32/1/gemma-2b-it-cpu-int4.bin';
Future<String> get modelPath async {
final dir = await getApplicationSupportDirectory();
return p.join(dir.path, modelFileName);
}
Future<bool> isModelPresent() async {
final path = await modelPath;
final file = File(path);
if (!file.existsSync()) return false;
// Verify the file isn't a partial download (allow small variance)
final size = await file.length();
return size > modelSizeBytes * 0.99;
}
/// Streams download progress as a value from 0.0 to 1.0.
Stream<double> download() async* {
final path = await modelPath;
final file = File(path);
final client = HttpClient();
try {
final request = await client.getUrl(Uri.parse(_downloadUrl));
final response = await request.close();
if (response.statusCode != 200) {
throw Exception('Download failed: HTTP ${response.statusCode}');
}
final totalBytes =
response.contentLength > 0 ? response.contentLength : modelSizeBytes;
int receivedBytes = 0;
final sink = file.openWrite();
try {
await for (final chunk in response) {
sink.add(chunk);
receivedBytes += chunk.length;
yield (receivedBytes / totalBytes).clamp(0.0, 1.0);
}
} finally {
await sink.close();
}
} catch (_) {
if (await file.exists()) await file.delete();
rethrow;
} finally {
client.close();
}
}
}
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../llm/model_download_service.dart';
import 'settings_provider.dart';
// ── Is the model file present on disk? ───────────────────────────────────────
final modelPresentProvider = FutureProvider<bool>((ref) {
return ModelDownloadService().isModelPresent();
});
// ── Download notifier — streams progress and marks completion ─────────────────
class ModelDownloadNotifier extends Notifier<AsyncValue<double>> {
@override
AsyncValue<double> build() => const AsyncData(0.0);
Future<void> startDownload() async {
state = const AsyncData(0.0);
try {
await for (final progress in ModelDownloadService().download()) {
if (!ref.mounted) return;
state = AsyncData(progress);
}
if (!ref.mounted) return;
await ref.read(settingsNotifierProvider.notifier).markModelDownloaded();
ref.invalidate(modelPresentProvider);
} catch (e, st) {
if (!ref.mounted) return;
state = AsyncError(e, st);
}
}
void reset() => state = const AsyncData(0.0);
}
final modelDownloadNotifierProvider =
NotifierProvider<ModelDownloadNotifier, AsyncValue<double>>(
ModelDownloadNotifier.new);