diff --git a/.gitignore b/.gitignore index 3820a95..00364b5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ app.*.symbols # Obfuscation related app.*.map.json +# LLM model files +*.bin + # Android Studio will place build artifacts here /android/app/debug /android/app/profile diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3e72dd4..65c5cd1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.dailyou" + namespace = "net.stefwill.dailyou" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -22,7 +22,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.dailyou" + applicationId = "net.stefwill.dailyou" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/kotlin/com/example/dailyou/MainActivity.kt b/android/app/src/main/kotlin/net/stefwill/dailyou/MainActivity.kt similarity index 76% rename from android/app/src/main/kotlin/com/example/dailyou/MainActivity.kt rename to android/app/src/main/kotlin/net/stefwill/dailyou/MainActivity.kt index cbcd0ed..de6ef75 100644 --- a/android/app/src/main/kotlin/com/example/dailyou/MainActivity.kt +++ b/android/app/src/main/kotlin/net/stefwill/dailyou/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.dailyou +package net.stefwill.dailyou import io.flutter.embedding.android.FlutterActivity diff --git a/lib/app.dart b/lib/app.dart index 58edd11..df6312a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -9,6 +9,7 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final skipped = ref.watch(modelSkippedProvider); final modelPresentAsync = ref.watch(modelPresentProvider); return MaterialApp( @@ -17,14 +18,16 @@ class App extends ConsumerWidget { theme: _lightTheme(), darkTheme: _darkTheme(), themeMode: ThemeMode.system, - home: modelPresentAsync.when( - data: (present) => - present ? const AppShell() : const ModelDownloadScreen(), - loading: () => const Scaffold( - body: Center(child: CircularProgressIndicator()), - ), - error: (_, __) => const ModelDownloadScreen(), - ), + home: skipped + ? const AppShell() + : modelPresentAsync.when( + data: (present) => + present ? const AppShell() : const ModelDownloadScreen(), + loading: () => const Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + error: (_, __) => const ModelDownloadScreen(), + ), ); } diff --git a/lib/core/providers/model_download_provider.dart b/lib/core/providers/model_download_provider.dart index e28ad9f..28d97e9 100644 --- a/lib/core/providers/model_download_provider.dart +++ b/lib/core/providers/model_download_provider.dart @@ -8,31 +8,46 @@ final modelPresentProvider = FutureProvider((ref) { return ModelDownloadService().isModelPresent(); }); -// ── Download notifier — streams progress and marks completion ───────────────── +// ── Has the user chosen to skip the model requirement? ─────────────────────── -class ModelDownloadNotifier extends Notifier> { +class _SkipNotifier 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); + bool build() => false; + void skip() => state = true; } -final modelDownloadNotifierProvider = - NotifierProvider>( - ModelDownloadNotifier.new); +final modelSkippedProvider = + NotifierProvider<_SkipNotifier, bool>(_SkipNotifier.new); + +// ── Scan notifier — checks for the model on demand ────────────────��────────── + +class ModelScanNotifier extends Notifier> { + @override + AsyncValue build() => const AsyncData(false); + + Future scan() async { + state = const AsyncLoading(); + final next = await AsyncValue.guard( + () => ModelDownloadService().isModelPresent(), + ); + if (!ref.mounted) return; + next.whenData((present) async { + if (present) { + await ref.read(settingsNotifierProvider.notifier).markModelDownloaded(); + ref.invalidate(modelPresentProvider); + } else { + state = AsyncError( + Exception('Model file not found at expected path'), + StackTrace.current, + ); + } + }); + if (next is AsyncError) state = next; + } + + void skipToApp() => ref.read(modelSkippedProvider.notifier).skip(); +} + +final modelScanProvider = + NotifierProvider.autoDispose>( + ModelScanNotifier.new); diff --git a/lib/features/model_download/model_download_screen.dart b/lib/features/model_download/model_download_screen.dart index 8b5b49a..08cd5fc 100644 --- a/lib/features/model_download/model_download_screen.dart +++ b/lib/features/model_download/model_download_screen.dart @@ -7,27 +7,68 @@ class ModelDownloadScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final downloadState = ref.watch(modelDownloadNotifierProvider); + final scanState = ref.watch(modelScanProvider); 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(); - }, - ), + child: 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: 32), + _InstructionCard(), + const SizedBox(height: 24), + if (scanState is AsyncLoading) + const Center(child: CircularProgressIndicator()) + else ...[ + if (scanState is AsyncError) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + 'Model not found at expected path.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + FilledButton.icon( + onPressed: () => + ref.read(modelScanProvider.notifier).scan(), + icon: const Icon(Icons.search), + label: const Text('Scan for model'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => ref + .read(modelScanProvider.notifier) + .skipToApp(), + child: const Text('Continue without AI'), + ), + ], + ], ), ), ), @@ -35,133 +76,77 @@ class ModelDownloadScreen extends ConsumerWidget { } } -class _IdleView extends StatelessWidget { - const _IdleView({required this.onDownload}); - - final VoidCallback onDownload; - +class _InstructionCard extends StatelessWidget { @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, + final cs = Theme.of(context).colorScheme; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('How to get the model', + style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 10), + _Step( + n: '1', + text: 'Download gemma-2b-it-cpu-int4.bin from Kaggle ' + '(requires a free Google account and licence acceptance).', + ), + _Step( + n: '2', + text: 'Transfer the file to the device using adb:\n' + 'adb push gemma-2b-it-cpu-int4.bin ' + r'$(adb shell run-as net.stefwill.dailyou ' + r'printenv DATA_DIR)/files/', + ), + _Step( + n: '3', + text: 'Tap "Scan for model" once the transfer is complete.', + ), + const SizedBox(height: 8), + Text( + 'The model is ~1.3 GB and only needs to be transferred once.', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.outline, + ), + ), + ], ), - 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; +class _Step extends StatelessWidget { + const _Step({required this.n, required this.text}); + final String n; + final String text; @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'), - ), - ], + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 10, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Text(n, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + Theme.of(context).colorScheme.onPrimaryContainer, + )), + ), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), ); } }