From 59597c7a4f3ce3a4ac7c32757b58880096de8885 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Sun, 26 Apr 2026 12:00:14 +1000 Subject: [PATCH] feat: wire up app shell, log mood screen, and fix Android build - Bootstrap ProviderScope in main.dart and move App to app.dart with light/dark theming - Add initial LogMoodScreen with mood entry flow and shared activity/mood widgets - Add kIsWeb guard in app_database.dart for drift web support - Enable Android core library desugaring required by flutter_local_notifications 21.x - Guard all async notifier state assignments with ref.mounted checks to prevent use-after-dispose errors when providers are auto-disposed mid-operation --- android/app/build.gradle.kts | 5 + lib/app.dart | 44 ++++++ lib/core/database/app_database.dart | 10 ++ lib/core/providers/activity_provider.dart | 8 +- lib/core/providers/insight_provider.dart | 2 + lib/core/providers/llm_provider.dart | 3 +- lib/core/providers/mood_provider.dart | 12 +- lib/core/providers/settings_provider.dart | 1 + lib/features/log_mood/log_mood_screen.dart | 154 +++++++++++++++++++++ lib/main.dart | 25 ++-- lib/shared/widgets/activity_grid.dart | 42 ++++++ lib/shared/widgets/mood_selector.dart | 53 +++++++ 12 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 lib/features/log_mood/log_mood_screen.dart create mode 100644 lib/shared/widgets/activity_grid.dart create mode 100644 lib/shared/widgets/mood_selector.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c3026ca..3e72dd4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,6 +11,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -42,3 +43,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/lib/app.dart b/lib/app.dart index e69de29..1a63730 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/providers/settings_provider.dart'; +import 'features/log_mood/log_mood_screen.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final onboardingAsync = ref.watch(isOnboardingCompleteProvider); + + return MaterialApp( + title: 'DailyYou', + debugShowCheckedModeBanner: false, + theme: _lightTheme(), + darkTheme: _darkTheme(), + themeMode: ThemeMode.system, + home: onboardingAsync.when( + data: (complete) => const LogMoodScreen(), + loading: () => const Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => const LogMoodScreen(), + ), + ); + } + + ThemeData _lightTheme() => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6B8CFF), + brightness: Brightness.light, + ), + ); + + ThemeData _darkTheme() => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6B8CFF), + brightness: Brightness.dark, + ), + ); +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 8f27005..9802400 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:path_provider/path_provider.dart'; @@ -84,6 +85,15 @@ class AppDatabase extends _$AppDatabase { } QueryExecutor _openConnection() { + if (kIsWeb) { + return driftDatabase( + name: 'lumina_db', + web: DriftWebOptions( + sqlite3Wasm: Uri.parse('sqlite3.wasm'), + driftWorker: Uri.parse('drift_worker.dart.js'), + ), + ); + } return driftDatabase( name: 'lumina_db', native: DriftNativeOptions( diff --git a/lib/core/providers/activity_provider.dart b/lib/core/providers/activity_provider.dart index 3836865..e8e2ef6 100644 --- a/lib/core/providers/activity_provider.dart +++ b/lib/core/providers/activity_provider.dart @@ -50,7 +50,7 @@ class ActivityNotifier extends Notifier> { required String colourHex, }) async { state = const AsyncLoading(); - state = await AsyncValue.guard( + final next = await AsyncValue.guard( () => ref.read(activityDaoProvider).insertActivity( ActivitiesCompanion.insert( name: name, @@ -60,13 +60,17 @@ class ActivityNotifier extends Notifier> { ), ), ); + if (!ref.mounted) return; + state = next; } Future deleteActivity(int id) async { state = const AsyncLoading(); - state = await AsyncValue.guard( + final next = await AsyncValue.guard( () => ref.read(activityDaoProvider).deleteActivity(id), ); + if (!ref.mounted) return; + state = next; } } diff --git a/lib/core/providers/insight_provider.dart b/lib/core/providers/insight_provider.dart index 12a5fae..17e57b3 100644 --- a/lib/core/providers/insight_provider.dart +++ b/lib/core/providers/insight_provider.dart @@ -37,6 +37,7 @@ class InsightNotifier extends Notifier> { final dao = ref.read(insightDaoProvider); final cached = await dao.getCached(hash); + if (!ref.mounted) return; if (cached != null) { state = AsyncData(cached.responseText); return; @@ -62,6 +63,7 @@ class InsightNotifier extends Notifier> { rangeEnd: Value(rangeEnd), ), ); + if (!ref.mounted) return; state = AsyncData(responseText); } diff --git a/lib/core/providers/llm_provider.dart b/lib/core/providers/llm_provider.dart index 94f25e3..fb75e01 100644 --- a/lib/core/providers/llm_provider.dart +++ b/lib/core/providers/llm_provider.dart @@ -33,11 +33,12 @@ class LlmResponseNotifier extends Notifier> { try { await for (final token in service.generateStream(prompt)) { - if (_cancelled) break; + if (_cancelled || !ref.mounted) break; buffer.write(token); state = AsyncData(buffer.toString()); } } catch (e, st) { + if (!ref.mounted) return; state = AsyncError(e, st); } } diff --git a/lib/core/providers/mood_provider.dart b/lib/core/providers/mood_provider.dart index 7a01cd8..8a2faf8 100644 --- a/lib/core/providers/mood_provider.dart +++ b/lib/core/providers/mood_provider.dart @@ -57,7 +57,7 @@ class MoodEntryNotifier extends Notifier> { DateTime? timestamp, }) async { state = const AsyncLoading(); - state = await AsyncValue.guard(() async { + final next = await AsyncValue.guard(() async { final dao = ref.read(moodDaoProvider); await dao.insertEntryWithActivities( entry: MoodEntriesCompanion.insert( @@ -69,13 +69,17 @@ class MoodEntryNotifier extends Notifier> { tagIds: tagIds, ); }); + if (!ref.mounted) return; + state = next; } Future deleteEntry(int id) async { state = const AsyncLoading(); - state = await AsyncValue.guard( + final next = await AsyncValue.guard( () => ref.read(moodDaoProvider).deleteEntry(id), ); + if (!ref.mounted) return; + state = next; } Future updateEntry({ @@ -85,7 +89,7 @@ class MoodEntryNotifier extends Notifier> { DateTime? timestamp, }) async { state = const AsyncLoading(); - state = await AsyncValue.guard( + final next = await AsyncValue.guard( () => ref.read(moodDaoProvider).updateEntry( MoodEntriesCompanion( id: Value(id), @@ -95,6 +99,8 @@ class MoodEntryNotifier extends Notifier> { ), ), ); + if (!ref.mounted) return; + state = next; } } diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index 88581ac..e42678a 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -27,6 +27,7 @@ class SettingsNotifier extends Notifier> { Future set(String key, String value) async { await ref.read(settingsDaoProvider).set(key, value); + if (!ref.mounted) return; ref.invalidate(settingProvider(key)); } diff --git a/lib/features/log_mood/log_mood_screen.dart b/lib/features/log_mood/log_mood_screen.dart new file mode 100644 index 0000000..8f446ca --- /dev/null +++ b/lib/features/log_mood/log_mood_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/mood_provider.dart'; +import '../../core/providers/activity_provider.dart'; +import '../../shared/widgets/mood_selector.dart'; +import '../../shared/widgets/activity_grid.dart'; + +class LogMoodScreen extends ConsumerStatefulWidget { + const LogMoodScreen({super.key}); + + @override + ConsumerState createState() => _LogMoodScreenState(); +} + +class _LogMoodScreenState extends ConsumerState { + int _selectedMood = 3; + final _noteController = TextEditingController(); + bool _saving = false; + + static const _moodLabels = ['Awful', 'Bad', 'Okay', 'Good', 'Great']; + static const _moodEmojis = ['😞', '😔', '😐', '😊', '😄']; + static const _moodColors = [ + Color(0xFFE57373), + Color(0xFFFFB74D), + Color(0xFFFFD54F), + Color(0xFF81C784), + Color(0xFF4DB6AC), + ]; + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + final selectedActivities = ref.read(selectedActivitiesProvider); + await ref.read(moodEntryNotifierProvider.notifier).addEntry( + moodLevel: _selectedMood, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + activityIds: selectedActivities.toList(), + ); + if (mounted) { + ref.read(selectedActivitiesProvider.notifier).clear(); + _noteController.clear(); + setState(() { + _saving = false; + _selectedMood = 3; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Mood logged!')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final moodColor = _moodColors[_selectedMood - 1]; + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: const Text('How are you feeling?'), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Mood selector ────────────────────────────────────── + Center( + child: MoodSelector( + selected: _selectedMood, + emojis: _moodEmojis, + labels: _moodLabels, + colors: _moodColors, + onChanged: (v) => setState(() => _selectedMood = v), + ), + ), + const SizedBox(height: 32), + + // ── Current mood label ───────────────────────────────── + Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + _moodLabels[_selectedMood - 1], + key: ValueKey(_selectedMood), + style: theme.textTheme.headlineMedium?.copyWith( + color: moodColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 32), + + // ── Activities ───────────────────────────────────────── + Text('Activities', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + const ActivityGrid(), + const SizedBox(height: 24), + + // ── Note ─────────────────────────────────────────────── + Text('Note', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _noteController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Add a note (optional)...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(height: 32), + + // ── Save button ──────────────────────────────────────── + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: moodColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: _saving + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'Save', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 29a51f8..f307ee4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app.dart'; -void main() { - runApp(const App()); -} - -class App extends StatelessWidget { - const App({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Lumina', - debugShowCheckedModeBanner: false, - theme: ThemeData(useMaterial3: true), - home: const Scaffold(body: Center(child: Text('Lumina'))), - ); - } +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + const ProviderScope( + child: App(), + ), + ); } diff --git a/lib/shared/widgets/activity_grid.dart b/lib/shared/widgets/activity_grid.dart new file mode 100644 index 0000000..6eb1d58 --- /dev/null +++ b/lib/shared/widgets/activity_grid.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/activity_provider.dart'; + +class ActivityGrid extends ConsumerWidget { + const ActivityGrid({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activitiesAsync = ref.watch(activitiesProvider); + final selected = ref.watch(selectedActivitiesProvider); + final theme = Theme.of(context); + + return activitiesAsync.when( + data: (activities) => Wrap( + spacing: 8, + runSpacing: 8, + children: activities.map((a) { + final isSelected = selected.contains(a.id); + return FilterChip( + avatar: Text( + String.fromCharCode(a.iconCodepoint), + style: const TextStyle(fontFamily: 'MaterialIcons'), + ), + label: Text(a.name), + selected: isSelected, + onSelected: (_) => + ref.read(selectedActivitiesProvider.notifier).toggle(a.id), + selectedColor: Color( + int.parse(a.colourHex.replaceFirst('#', '0xFF')), + ).withOpacity(0.2), + checkmarkColor: Color( + int.parse(a.colourHex.replaceFirst('#', '0xFF')), + ), + ); + }).toList(), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error loading activities: $e'), + ); + } +} diff --git a/lib/shared/widgets/mood_selector.dart b/lib/shared/widgets/mood_selector.dart new file mode 100644 index 0000000..71444be --- /dev/null +++ b/lib/shared/widgets/mood_selector.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class MoodSelector extends StatelessWidget { + final int selected; + final List emojis; + final List labels; + final List colors; + final ValueChanged onChanged; + + const MoodSelector({ + super.key, + required this.selected, + required this.emojis, + required this.labels, + required this.colors, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(5, (i) { + final level = i + 1; + final isSelected = selected == level; + return GestureDetector( + onTap: () => onChanged(level), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isSelected ? 64 : 52, + height: isSelected ? 64 : 52, + decoration: BoxDecoration( + color: + isSelected ? colors[i].withOpacity(0.15) : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? colors[i] : Colors.transparent, + width: 2, + ), + ), + child: Center( + child: Text( + emojis[i], + style: TextStyle(fontSize: isSelected ? 36 : 28), + ), + ), + ), + ); + }), + ); + } +}