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
+7 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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'; import 'shell.dart';
class App extends ConsumerWidget { class App extends ConsumerWidget {
@@ -8,7 +9,7 @@ class App extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final onboardingAsync = ref.watch(isOnboardingCompleteProvider); final modelPresentAsync = ref.watch(modelPresentProvider);
return MaterialApp( return MaterialApp(
title: 'DailyYou', title: 'DailyYou',
@@ -16,12 +17,13 @@ class App extends ConsumerWidget {
theme: _lightTheme(), theme: _lightTheme(),
darkTheme: _darkTheme(), darkTheme: _darkTheme(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
home: onboardingAsync.when( home: modelPresentAsync.when(
data: (complete) => const AppShell(), data: (present) =>
present ? const AppShell() : const ModelDownloadScreen(),
loading: () => const Scaffold( loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
), ),
error: (e, _) => const AppShell(), error: (_, __) => const ModelDownloadScreen(),
), ),
); );
} }
+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);
@@ -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'),
),
],
);
}
}