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
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -42,3 +43,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
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/drift.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -84,6 +85,15 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QueryExecutor _openConnection() {
|
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(
|
return driftDatabase(
|
||||||
name: 'lumina_db',
|
name: 'lumina_db',
|
||||||
native: DriftNativeOptions(
|
native: DriftNativeOptions(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ActivityNotifier extends Notifier<AsyncValue<void>> {
|
|||||||
required String colourHex,
|
required String colourHex,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(
|
final next = await AsyncValue.guard(
|
||||||
() => ref.read(activityDaoProvider).insertActivity(
|
() => ref.read(activityDaoProvider).insertActivity(
|
||||||
ActivitiesCompanion.insert(
|
ActivitiesCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
@@ -60,13 +60,17 @@ class ActivityNotifier extends Notifier<AsyncValue<void>> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
state = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteActivity(int id) async {
|
Future<void> deleteActivity(int id) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(
|
final next = await AsyncValue.guard(
|
||||||
() => ref.read(activityDaoProvider).deleteActivity(id),
|
() => 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 dao = ref.read(insightDaoProvider);
|
||||||
|
|
||||||
final cached = await dao.getCached(hash);
|
final cached = await dao.getCached(hash);
|
||||||
|
if (!ref.mounted) return;
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
state = AsyncData(cached.responseText);
|
state = AsyncData(cached.responseText);
|
||||||
return;
|
return;
|
||||||
@@ -62,6 +63,7 @@ class InsightNotifier extends Notifier<AsyncValue<String?>> {
|
|||||||
rangeEnd: Value(rangeEnd),
|
rangeEnd: Value(rangeEnd),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = AsyncData(responseText);
|
state = AsyncData(responseText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ class LlmResponseNotifier extends Notifier<AsyncValue<String>> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await for (final token in service.generateStream(prompt)) {
|
await for (final token in service.generateStream(prompt)) {
|
||||||
if (_cancelled) break;
|
if (_cancelled || !ref.mounted) break;
|
||||||
buffer.write(token);
|
buffer.write(token);
|
||||||
state = AsyncData(buffer.toString());
|
state = AsyncData(buffer.toString());
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = AsyncError(e, st);
|
state = AsyncError(e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
|
|||||||
DateTime? timestamp,
|
DateTime? timestamp,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() async {
|
final next = await AsyncValue.guard(() async {
|
||||||
final dao = ref.read(moodDaoProvider);
|
final dao = ref.read(moodDaoProvider);
|
||||||
await dao.insertEntryWithActivities(
|
await dao.insertEntryWithActivities(
|
||||||
entry: MoodEntriesCompanion.insert(
|
entry: MoodEntriesCompanion.insert(
|
||||||
@@ -69,13 +69,17 @@ class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
|
|||||||
tagIds: tagIds,
|
tagIds: tagIds,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
state = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEntry(int id) async {
|
Future<void> deleteEntry(int id) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(
|
final next = await AsyncValue.guard(
|
||||||
() => ref.read(moodDaoProvider).deleteEntry(id),
|
() => ref.read(moodDaoProvider).deleteEntry(id),
|
||||||
);
|
);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
state = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateEntry({
|
Future<void> updateEntry({
|
||||||
@@ -85,7 +89,7 @@ class MoodEntryNotifier extends Notifier<AsyncValue<void>> {
|
|||||||
DateTime? timestamp,
|
DateTime? timestamp,
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(
|
final next = await AsyncValue.guard(
|
||||||
() => ref.read(moodDaoProvider).updateEntry(
|
() => ref.read(moodDaoProvider).updateEntry(
|
||||||
MoodEntriesCompanion(
|
MoodEntriesCompanion(
|
||||||
id: Value(id),
|
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 {
|
Future<void> set(String key, String value) async {
|
||||||
await ref.read(settingsDaoProvider).set(key, value);
|
await ref.read(settingsDaoProvider).set(key, value);
|
||||||
|
if (!ref.mounted) return;
|
||||||
ref.invalidate(settingProvider(key));
|
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/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
runApp(const App());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
}
|
runApp(
|
||||||
|
const ProviderScope(
|
||||||
class App extends StatelessWidget {
|
child: App(),
|
||||||
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'))),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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