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:
+7
-5
@@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user