From e464c5621e16776e2c1946c2675c6fc6ca1221b8 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Sun, 26 Apr 2026 14:42:39 +1000 Subject: [PATCH] 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. --- lib/app.dart | 12 +- lib/core/llm/model_download_service.dart | 63 +++++++ .../providers/model_download_provider.dart | 38 ++++ .../model_download/model_download_screen.dart | 167 ++++++++++++++++++ 4 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 lib/core/llm/model_download_service.dart create mode 100644 lib/core/providers/model_download_provider.dart create mode 100644 lib/features/model_download/model_download_screen.dart diff --git a/lib/app.dart b/lib/app.dart index 4445a72..58edd11 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'core/providers/settings_provider.dart'; +import 'core/providers/model_download_provider.dart'; +import 'features/model_download/model_download_screen.dart'; import 'shell.dart'; class App extends ConsumerWidget { @@ -8,7 +9,7 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final onboardingAsync = ref.watch(isOnboardingCompleteProvider); + final modelPresentAsync = ref.watch(modelPresentProvider); return MaterialApp( title: 'DailyYou', @@ -16,12 +17,13 @@ class App extends ConsumerWidget { theme: _lightTheme(), darkTheme: _darkTheme(), themeMode: ThemeMode.system, - home: onboardingAsync.when( - data: (complete) => const AppShell(), + home: modelPresentAsync.when( + data: (present) => + present ? const AppShell() : const ModelDownloadScreen(), loading: () => const Scaffold( body: Center(child: CircularProgressIndicator()), ), - error: (e, _) => const AppShell(), + error: (_, __) => const ModelDownloadScreen(), ), ); } diff --git a/lib/core/llm/model_download_service.dart b/lib/core/llm/model_download_service.dart new file mode 100644 index 0000000..fea01b5 --- /dev/null +++ b/lib/core/llm/model_download_service.dart @@ -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 get modelPath async { + final dir = await getApplicationSupportDirectory(); + return p.join(dir.path, modelFileName); + } + + Future 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 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(); + } + } +} diff --git a/lib/core/providers/model_download_provider.dart b/lib/core/providers/model_download_provider.dart new file mode 100644 index 0000000..e28ad9f --- /dev/null +++ b/lib/core/providers/model_download_provider.dart @@ -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((ref) { + return ModelDownloadService().isModelPresent(); +}); + +// ── Download notifier — streams progress and marks completion ───────────────── + +class ModelDownloadNotifier extends Notifier> { + @override + AsyncValue build() => const AsyncData(0.0); + + Future 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.new); diff --git a/lib/features/model_download/model_download_screen.dart b/lib/features/model_download/model_download_screen.dart new file mode 100644 index 0000000..8b5b49a --- /dev/null +++ b/lib/features/model_download/model_download_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/model_download_provider.dart'; + +class ModelDownloadScreen extends ConsumerWidget { + const ModelDownloadScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final downloadState = ref.watch(modelDownloadNotifierProvider); + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: downloadState.when( + data: (progress) => progress == 0.0 + ? _IdleView( + onDownload: () => ref + .read(modelDownloadNotifierProvider.notifier) + .startDownload(), + ) + : _DownloadingView(progress: progress), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorView( + error: e.toString(), + onRetry: () { + ref.read(modelDownloadNotifierProvider.notifier).reset(); + }, + ), + ), + ), + ), + ); + } +} + +class _IdleView extends StatelessWidget { + const _IdleView({required this.onDownload}); + + final VoidCallback onDownload; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.auto_awesome, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'On-device AI', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'DailyYou uses an on-device AI model to spot patterns in your moods ' + 'and behaviour. Your data never leaves your phone.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'The model is ~1.1 GB and only needs to be downloaded once.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + FilledButton.icon( + onPressed: onDownload, + icon: const Icon(Icons.download), + label: const Text('Download model'), + ), + ], + ); + } +} + +class _DownloadingView extends StatelessWidget { + const _DownloadingView({required this.progress}); + + final double progress; + + @override + Widget build(BuildContext context) { + final pct = (progress * 100).toStringAsFixed(0); + final done = progress >= 1.0; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + done ? Icons.check_circle : Icons.downloading, + size: 64, + color: done + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + ), + const SizedBox(height: 24), + Text( + done ? 'Ready!' : 'Downloading…', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + LinearProgressIndicator(value: progress), + const SizedBox(height: 8), + Text( + done ? 'Model downloaded successfully.' : '$pct% — keep the app open', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.error, required this.onRetry}); + + final String error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.error_outline, + size: 64, color: Theme.of(context).colorScheme.error), + const SizedBox(height: 24), + Text( + 'Download failed', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Check your connection and try again.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ); + } +}