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:
@@ -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);
|
||||
Reference in New Issue
Block a user