From 78aa032c1be9d1dbf6fc790a0fe3f35c3e16677c Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Sun, 26 Apr 2026 15:49:52 +1000 Subject: [PATCH] feat: implement settings screen with reminders, biometrics, theme, and PDF export - Daily reminder scheduling via flutter_local_notifications (inexact, repeating at chosen time) - Biometric lock gate on app launch with auto-prompt and manual unlock button - Theme mode selector (System/Light/Dark) persisted to settings DB - PDF export of full mood history shared via share_plus - Android: POST_NOTIFICATIONS, USE_BIOMETRIC permissions; switched to FlutterFragmentActivity --- README.md | 16 +- android/app/src/main/AndroidManifest.xml | 4 + .../net/stefwill/dailyou/MainActivity.kt | 4 +- lib/app.dart | 66 ++++++- lib/core/biometrics/biometric_service.dart | 23 +++ lib/core/database/daos/mood_dao.dart | 5 + lib/core/export/export_service.dart | 83 +++++++++ .../notifications/notification_service.dart | 66 +++++++ lib/core/providers/biometric_provider.dart | 23 +++ lib/core/providers/theme_provider.dart | 14 ++ lib/features/settings/settings_screen.dart | 166 +++++++++++++++++- lib/main.dart | 5 + pubspec.lock | 2 +- pubspec.yaml | 1 + 14 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 lib/core/biometrics/biometric_service.dart create mode 100644 lib/core/export/export_service.dart create mode 100644 lib/core/notifications/notification_service.dart create mode 100644 lib/core/providers/biometric_provider.dart create mode 100644 lib/core/providers/theme_provider.dart diff --git a/README.md b/README.md index 9eaf736..91d01ff 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ lib/ ## To-do ### Core screens -- [ ] Bottom nav shell (home, history, insights, settings) -- [ ] History / calendar heatmap screen -- [ ] Day detail screen — list entries for a selected day -- [ ] Insights screen — weekly/monthly AI summaries -- [ ] Settings screen — reminders, biometrics, theme, data export +- [x] Bottom nav shell (home, history, insights, settings) +- [x] History / calendar heatmap screen +- [x] Day detail screen — list entries for a selected day +- [x] Insights screen — weekly/monthly AI summaries +- [x] Settings screen — reminders, biometrics, theme, data export - [ ] Onboarding flow — first-launch walkthrough ### Mood logging @@ -68,12 +68,12 @@ lib/ ### Activities - [ ] Activity management screen — add, reorder, delete custom activities -- [ ] Seed database with default activities on first launch +- [x] Seed database with default activities on first launch ### AI / insights -- [ ] Model download + progress UI +- [x] Model setup screen — manual transfer instructions + scan button - [ ] Query mode — ask a free-form question about your data -- [ ] Prompt builder — serialize mood history into LLM context +- [x] Prompt builder — serialize mood history into LLM context ### Analytics - [ ] Charts screen — mood trend line, activity frequency bar chart, sleep vs mood scatter diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 77d52de..a435e4c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + - present ? const AppShell() : const ModelDownloadScreen(), + present ? mainContent() : const ModelDownloadScreen(), loading: () => const Scaffold( body: Center(child: CircularProgressIndicator()), ), @@ -47,3 +64,46 @@ class App extends ConsumerWidget { ), ); } + +class _BiometricLockScreen extends ConsumerStatefulWidget { + const _BiometricLockScreen(); + + @override + ConsumerState<_BiometricLockScreen> createState() => + _BiometricLockScreenState(); +} + +class _BiometricLockScreenState extends ConsumerState<_BiometricLockScreen> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(biometricUnlockedProvider.notifier).tryUnlock(); + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.lock_outline, size: 64, color: cs.primary), + const SizedBox(height: 24), + Text('DailyYou is locked', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => + ref.read(biometricUnlockedProvider.notifier).tryUnlock(), + icon: const Icon(Icons.fingerprint), + label: const Text('Unlock'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/biometrics/biometric_service.dart b/lib/core/biometrics/biometric_service.dart new file mode 100644 index 0000000..cfd79f1 --- /dev/null +++ b/lib/core/biometrics/biometric_service.dart @@ -0,0 +1,23 @@ +import 'package:local_auth/local_auth.dart'; + +class BiometricService { + static final _auth = LocalAuthentication(); + + static Future isAvailable() async { + try { + return await _auth.canCheckBiometrics || await _auth.isDeviceSupported(); + } catch (_) { + return false; + } + } + + static Future authenticate() async { + try { + return await _auth.authenticate( + localizedReason: 'Unlock DailyYou', + ); + } catch (_) { + return false; + } + } +} diff --git a/lib/core/database/daos/mood_dao.dart b/lib/core/database/daos/mood_dao.dart index 509ada3..7f71b20 100644 --- a/lib/core/database/daos/mood_dao.dart +++ b/lib/core/database/daos/mood_dao.dart @@ -55,6 +55,11 @@ class MoodDao extends DatabaseAccessor with _$MoodDaoMixin { // ── Reads ─────────────────────────────────────────────────────────────────── + Future> getAllEntries() => + (select(moodEntries) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)])) + .get(); + Stream> watchAllEntries() => (select(moodEntries)..orderBy([(t) => OrderingTerm.desc(t.timestamp)])) .watch(); diff --git a/lib/core/export/export_service.dart b/lib/core/export/export_service.dart new file mode 100644 index 0000000..0fd7a60 --- /dev/null +++ b/lib/core/export/export_service.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:share_plus/share_plus.dart'; + +import '../database/app_database.dart'; + +class ExportService { + static String _fmtDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}'; + + static String _level(int? v) => v == null ? '—' : '$v/5'; + + static String _sleep(int? mins) { + if (mins == null) return '—'; + final h = mins ~/ 60; + final m = mins % 60; + return m > 0 ? '${h}h ${m}m' : '${h}h'; + } + + static Future exportToPdf(List entries) async { + final doc = pw.Document(); + + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + build: (ctx) => [ + pw.Header( + level: 0, + child: pw.Text('DailyYou — Mood Journal'), + ), + pw.SizedBox(height: 12), + pw.TableHelper.fromTextArray( + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), + cellAlignments: { + 0: pw.Alignment.centerLeft, + 1: pw.Alignment.center, + 2: pw.Alignment.center, + 3: pw.Alignment.center, + 4: pw.Alignment.center, + 5: pw.Alignment.center, + }, + headers: [ + 'Date & time', + 'Mood', + 'Sleep', + 'Energy', + 'Positivity', + 'Self-worth', + ], + data: entries + .map((e) => [ + _fmtDate(e.timestamp), + '${e.moodLevel}/5', + _sleep(e.sleepMinutes), + _level(e.energyLevel), + _level(e.positivityLevel), + _level(e.selfWorthLevel), + ]) + .toList(), + ), + ], + ), + ); + + final bytes = await doc.save(); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/dailyou_export.pdf'); + await file.writeAsBytes(bytes); + + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'application/pdf')], + subject: 'DailyYou Export', + ), + ); + } +} diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart new file mode 100644 index 0000000..f122039 --- /dev/null +++ b/lib/core/notifications/notification_service.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; + +class NotificationService { + static final _plugin = FlutterLocalNotificationsPlugin(); + static bool _initialised = false; + + static const _channelId = 'daily_reminder'; + static const _notificationId = 0; + + static Future init() async { + if (_initialised) return; + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + await _plugin.initialize( + settings: const InitializationSettings(android: android), + ); + _initialised = true; + } + + static Future requestPermission() async { + final android = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + return (await android?.requestNotificationsPermission()) ?? false; + } + + static Future scheduleDaily(TimeOfDay time) async { + await _plugin.cancelAll(); + const details = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Daily reminder', + channelDescription: 'Reminds you to log your mood', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + ); + await _plugin.zonedSchedule( + id: _notificationId, + title: 'DailyYou', + body: 'Time to log your mood!', + scheduledDate: _nextInstanceOfTime(time.hour, time.minute), + notificationDetails: details, + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + matchDateTimeComponents: DateTimeComponents.time, + ); + } + + static Future cancelAll() => _plugin.cancelAll(); + + static tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { + final now = tz.TZDateTime.now(tz.UTC); + final offset = DateTime.now().timeZoneOffset; + var targetMinutes = hour * 60 + minute - offset.inMinutes; + // Normalise into [0, 24*60) + targetMinutes = ((targetMinutes % (24 * 60)) + 24 * 60) % (24 * 60); + final utcHour = targetMinutes ~/ 60; + final utcMin = targetMinutes % 60; + var scheduled = + tz.TZDateTime(tz.UTC, now.year, now.month, now.day, utcHour, utcMin); + if (scheduled.isBefore(now)) { + scheduled = scheduled.add(const Duration(days: 1)); + } + return scheduled; + } +} diff --git a/lib/core/providers/biometric_provider.dart b/lib/core/providers/biometric_provider.dart new file mode 100644 index 0000000..81779f9 --- /dev/null +++ b/lib/core/providers/biometric_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../biometrics/biometric_service.dart'; + +final biometricAvailableProvider = FutureProvider( + (_) => BiometricService.isAvailable(), +); + +class BiometricLockNotifier extends Notifier { + @override + bool build() => false; + + Future tryUnlock() async { + final success = await BiometricService.authenticate(); + if (!ref.mounted) return; + if (success) state = true; + } + + void lock() => state = false; +} + +final biometricUnlockedProvider = + NotifierProvider(BiometricLockNotifier.new); diff --git a/lib/core/providers/theme_provider.dart b/lib/core/providers/theme_provider.dart new file mode 100644 index 0000000..0f6655f --- /dev/null +++ b/lib/core/providers/theme_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'settings_provider.dart'; + +final themeModeProvider = FutureProvider((ref) async { + final val = + await ref.watch(settingProvider(SettingsKeys.themMode).future); + return switch (val) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; +}); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 6fcf86a..4d5ba4d 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,12 +1,170 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class SettingsScreen extends StatelessWidget { +import '../../core/export/export_service.dart'; +import '../../core/notifications/notification_service.dart'; +import '../../core/providers/biometric_provider.dart'; +import '../../core/providers/database_provider.dart'; +import '../../core/providers/settings_provider.dart'; + +class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text('Settings coming soon')), + Widget build(BuildContext context, WidgetRef ref) { + final reminderEnabled = + ref.watch(settingProvider(SettingsKeys.reminderEnabled)); + final reminderTimeStr = + ref.watch(settingProvider(SettingsKeys.reminderTime)); + final biometricEnabled = + ref.watch(settingProvider(SettingsKeys.biometricEnabled)); + final themeVal = ref.watch(settingProvider(SettingsKeys.themMode)); + final biometricAvailable = ref.watch(biometricAvailableProvider); + final notifier = ref.read(settingsNotifierProvider.notifier); + + final isReminderOn = reminderEnabled.value == 'true'; + final currentTheme = themeVal.value ?? 'system'; + final isBiometricOn = biometricEnabled.value == 'true'; + final reminderTime = + _parseTime(reminderTimeStr.value) ?? const TimeOfDay(hour: 20, minute: 0); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + _section(context, 'Appearance'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 'system', + label: Text('System'), + icon: Icon(Icons.brightness_auto)), + ButtonSegment( + value: 'light', + label: Text('Light'), + icon: Icon(Icons.light_mode)), + ButtonSegment( + value: 'dark', + label: Text('Dark'), + icon: Icon(Icons.dark_mode)), + ], + selected: {currentTheme}, + onSelectionChanged: (s) => notifier.setThemeMode(s.first), + ), + ), + _section(context, 'Reminders'), + SwitchListTile( + title: const Text('Daily reminder'), + subtitle: const Text('Get prompted to log your mood'), + value: isReminderOn, + onChanged: (v) => + _toggleReminder(context, ref, v, reminderTime, notifier), + ), + if (isReminderOn) + ListTile( + leading: const Icon(Icons.schedule), + title: const Text('Reminder time'), + trailing: Text( + reminderTime.format(context), + style: Theme.of(context).textTheme.bodyLarge, + ), + onTap: () => _pickTime(context, reminderTime, notifier), + ), + if (biometricAvailable.value == true) ...[ + _section(context, 'Privacy'), + SwitchListTile( + title: const Text('Biometric lock'), + subtitle: + const Text('Require fingerprint or face unlock to open'), + value: isBiometricOn, + onChanged: (v) => notifier.setBiometricEnabled(v), + ), + ], + _section(context, 'Data'), + ListTile( + leading: const Icon(Icons.picture_as_pdf_outlined), + title: const Text('Export to PDF'), + subtitle: const Text('Share your complete mood history'), + onTap: () => _export(context, ref), + ), + const Divider(), + const ListTile( + title: Text('DailyYou'), + subtitle: Text('Version 1.0.0'), + ), + ], + ), ); } + + Widget _section(BuildContext context, String title) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + + TimeOfDay? _parseTime(String? val) { + if (val == null) return null; + final parts = val.split(':'); + if (parts.length != 2) return null; + final h = int.tryParse(parts[0]); + final m = int.tryParse(parts[1]); + if (h == null || m == null) return null; + return TimeOfDay(hour: h, minute: m); + } + + Future _toggleReminder( + BuildContext context, + WidgetRef ref, + bool enable, + TimeOfDay time, + SettingsNotifier notifier, + ) async { + if (enable) { + final granted = await NotificationService.requestPermission(); + if (!context.mounted) return; + if (!granted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Notification permission required to set reminders')), + ); + return; + } + await NotificationService.scheduleDaily(time); + } else { + await NotificationService.cancelAll(); + } + await notifier.setReminderEnabled(enable); + } + + Future _pickTime( + BuildContext context, + TimeOfDay current, + SettingsNotifier notifier, + ) async { + final picked = + await showTimePicker(context: context, initialTime: current); + if (!context.mounted || picked == null) return; + await notifier.setReminderTime(picked.hour, picked.minute); + await NotificationService.scheduleDaily(picked); + } + + Future _export(BuildContext context, WidgetRef ref) async { + try { + final entries = await ref.read(moodDaoProvider).getAllEntries(); + await ExportService.exportToPdf(entries); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } } diff --git a/lib/main.dart b/lib/main.dart index f307ee4..5d56e7a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:timezone/data/latest_all.dart' as tz; + import 'app.dart'; +import 'core/notifications/notification_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + tz.initializeTimeZones(); + await NotificationService.init(); runApp( const ProviderScope( child: App(), diff --git a/pubspec.lock b/pubspec.lock index e8880df..91a86da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -998,7 +998,7 @@ packages: source: hosted version: "0.6.16" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" diff --git a/pubspec.yaml b/pubspec.yaml index f3410d7..d90a13a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: # Reminders flutter_local_notifications: ^21.0.0 + timezone: ^0.11.0 # Export & share pdf: ^3.11.1