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