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
This commit is contained in:
2026-04-26 15:49:52 +10:00
parent ffb0b427b7
commit 78aa032c1b
14 changed files with 460 additions and 18 deletions
+63 -3
View File
@@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/providers/biometric_provider.dart';
import 'core/providers/model_download_provider.dart';
import 'core/providers/settings_provider.dart';
import 'core/providers/theme_provider.dart';
import 'features/model_download/model_download_screen.dart';
import 'shell.dart';
@@ -11,18 +15,31 @@ class App extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final skipped = ref.watch(modelSkippedProvider);
final modelPresentAsync = ref.watch(modelPresentProvider);
final themeModeAsync = ref.watch(themeModeProvider);
final biometricEnabled =
ref.watch(settingProvider(SettingsKeys.biometricEnabled));
final biometricUnlocked = ref.watch(biometricUnlockedProvider);
final themeMode = themeModeAsync.value ?? ThemeMode.system;
Widget mainContent() {
if (biometricEnabled.value == 'true' && !biometricUnlocked) {
return const _BiometricLockScreen();
}
return const AppShell();
}
return MaterialApp(
title: 'DailyYou',
debugShowCheckedModeBanner: false,
theme: _lightTheme(),
darkTheme: _darkTheme(),
themeMode: ThemeMode.system,
themeMode: themeMode,
home: skipped
? const AppShell()
? mainContent()
: modelPresentAsync.when(
data: (present) =>
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'),
),
],
),
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:local_auth/local_auth.dart';
class BiometricService {
static final _auth = LocalAuthentication();
static Future<bool> isAvailable() async {
try {
return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
} catch (_) {
return false;
}
}
static Future<bool> authenticate() async {
try {
return await _auth.authenticate(
localizedReason: 'Unlock DailyYou',
);
} catch (_) {
return false;
}
}
}
+5
View File
@@ -55,6 +55,11 @@ class MoodDao extends DatabaseAccessor<AppDatabase> with _$MoodDaoMixin {
// ── Reads ───────────────────────────────────────────────────────────────────
Future<List<MoodEntry>> getAllEntries() =>
(select(moodEntries)
..orderBy([(t) => OrderingTerm.asc(t.timestamp)]))
.get();
Stream<List<MoodEntry>> watchAllEntries() =>
(select(moodEntries)..orderBy([(t) => OrderingTerm.desc(t.timestamp)]))
.watch();
+83
View File
@@ -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<void> exportToPdf(List<MoodEntry> 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',
),
);
}
}
@@ -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<void> init() async {
if (_initialised) return;
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
settings: const InitializationSettings(android: android),
);
_initialised = true;
}
static Future<bool> requestPermission() async {
final android = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
return (await android?.requestNotificationsPermission()) ?? false;
}
static Future<void> 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<void> 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;
}
}
@@ -0,0 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../biometrics/biometric_service.dart';
final biometricAvailableProvider = FutureProvider<bool>(
(_) => BiometricService.isAvailable(),
);
class BiometricLockNotifier extends Notifier<bool> {
@override
bool build() => false;
Future<void> tryUnlock() async {
final success = await BiometricService.authenticate();
if (!ref.mounted) return;
if (success) state = true;
}
void lock() => state = false;
}
final biometricUnlockedProvider =
NotifierProvider<BiometricLockNotifier, bool>(BiometricLockNotifier.new);
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'settings_provider.dart';
final themeModeProvider = FutureProvider<ThemeMode>((ref) async {
final val =
await ref.watch(settingProvider(SettingsKeys.themMode).future);
return switch (val) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
});
+162 -4
View File
@@ -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<String>(
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<void> _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<void> _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<void> _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')),
);
}
}
}
+5
View File
@@ -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(),