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