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
This commit is contained in:
2026-04-26 12:00:14 +10:00
parent 33d1713f51
commit 59597c7a4f
12 changed files with 337 additions and 22 deletions
+5
View File
@@ -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")
}
+44
View File
@@ -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,
),
);
}
+10
View File
@@ -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(
+6 -2
View File
@@ -50,7 +50,7 @@ class ActivityNotifier extends Notifier<AsyncValue<void>> {
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<AsyncValue<void>> {
),
),
);
if (!ref.mounted) return;
state = next;
}
Future<void> 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;
}
}
+2
View File
@@ -37,6 +37,7 @@ class InsightNotifier extends Notifier<AsyncValue<String?>> {
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<AsyncValue<String?>> {
rangeEnd: Value(rangeEnd),
),
);
if (!ref.mounted) return;
state = AsyncData(responseText);
}
+2 -1
View File
@@ -33,11 +33,12 @@ class LlmResponseNotifier extends Notifier<AsyncValue<String>> {
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);
}
}
+9 -3
View File
@@ -57,7 +57,7 @@ class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
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<AsyncValue<void>> {
tagIds: tagIds,
);
});
if (!ref.mounted) return;
state = next;
}
Future<void> 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<void> updateEntry({
@@ -85,7 +89,7 @@ class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
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<AsyncValue<void>> {
),
),
);
if (!ref.mounted) return;
state = next;
}
}
@@ -27,6 +27,7 @@ class SettingsNotifier extends Notifier<AsyncValue<void>> {
Future<void> set(String key, String value) async {
await ref.read(settingsDaoProvider).set(key, value);
if (!ref.mounted) return;
ref.invalidate(settingProvider(key));
}
+154
View File
@@ -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<LogMoodScreen> createState() => _LogMoodScreenState();
}
class _LogMoodScreenState extends ConsumerState<LogMoodScreen> {
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<void> _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),
),
),
),
],
),
),
);
}
}
+9 -16
View File
@@ -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(),
),
);
}
+42
View File
@@ -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'),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
class MoodSelector extends StatelessWidget {
final int selected;
final List<String> emojis;
final List<String> labels;
final List<Color> colors;
final ValueChanged<int> 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),
),
),
),
);
}),
);
}
}