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