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:
@@ -54,11 +54,11 @@ lib/
|
|||||||
## To-do
|
## To-do
|
||||||
|
|
||||||
### Core screens
|
### Core screens
|
||||||
- [ ] Bottom nav shell (home, history, insights, settings)
|
- [x] Bottom nav shell (home, history, insights, settings)
|
||||||
- [ ] History / calendar heatmap screen
|
- [x] History / calendar heatmap screen
|
||||||
- [ ] Day detail screen — list entries for a selected day
|
- [x] Day detail screen — list entries for a selected day
|
||||||
- [ ] Insights screen — weekly/monthly AI summaries
|
- [x] Insights screen — weekly/monthly AI summaries
|
||||||
- [ ] Settings screen — reminders, biometrics, theme, data export
|
- [x] Settings screen — reminders, biometrics, theme, data export
|
||||||
- [ ] Onboarding flow — first-launch walkthrough
|
- [ ] Onboarding flow — first-launch walkthrough
|
||||||
|
|
||||||
### Mood logging
|
### Mood logging
|
||||||
@@ -68,12 +68,12 @@ lib/
|
|||||||
|
|
||||||
### Activities
|
### Activities
|
||||||
- [ ] Activity management screen — add, reorder, delete custom 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
|
### 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
|
- [ ] 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
|
### Analytics
|
||||||
- [ ] Charts screen — mood trend line, activity frequency bar chart, sleep vs mood scatter
|
- [ ] Charts screen — mood trend line, activity frequency bar chart, sleep vs mood scatter
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<application
|
<application
|
||||||
android:label="dailyou"
|
android:label="dailyou"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package net.stefwill.dailyou
|
package net.stefwill.dailyou
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|||||||
+63
-3
@@ -1,6 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'core/providers/biometric_provider.dart';
|
||||||
import 'core/providers/model_download_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 'features/model_download/model_download_screen.dart';
|
||||||
import 'shell.dart';
|
import 'shell.dart';
|
||||||
|
|
||||||
@@ -11,18 +15,31 @@ class App extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final skipped = ref.watch(modelSkippedProvider);
|
final skipped = ref.watch(modelSkippedProvider);
|
||||||
final modelPresentAsync = ref.watch(modelPresentProvider);
|
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(
|
return MaterialApp(
|
||||||
title: 'DailyYou',
|
title: 'DailyYou',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: _lightTheme(),
|
theme: _lightTheme(),
|
||||||
darkTheme: _darkTheme(),
|
darkTheme: _darkTheme(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: themeMode,
|
||||||
home: skipped
|
home: skipped
|
||||||
? const AppShell()
|
? mainContent()
|
||||||
: modelPresentAsync.when(
|
: modelPresentAsync.when(
|
||||||
data: (present) =>
|
data: (present) =>
|
||||||
present ? const AppShell() : const ModelDownloadScreen(),
|
present ? mainContent() : const ModelDownloadScreen(),
|
||||||
loading: () => const Scaffold(
|
loading: () => const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Reads ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<MoodEntry>> getAllEntries() =>
|
||||||
|
(select(moodEntries)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.timestamp)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
Stream<List<MoodEntry>> watchAllEntries() =>
|
Stream<List<MoodEntry>> watchAllEntries() =>
|
||||||
(select(moodEntries)..orderBy([(t) => OrderingTerm.desc(t.timestamp)]))
|
(select(moodEntries)..orderBy([(t) => OrderingTerm.desc(t.timestamp)]))
|
||||||
.watch();
|
.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/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});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const Scaffold(
|
final reminderEnabled =
|
||||||
body: Center(child: Text('Settings coming soon')),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/notifications/notification_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
await NotificationService.init();
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
child: App(),
|
child: App(),
|
||||||
|
|||||||
+1
-1
@@ -998,7 +998,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.16"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ dependencies:
|
|||||||
|
|
||||||
# Reminders
|
# Reminders
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
|
timezone: ^0.11.0
|
||||||
|
|
||||||
# Export & share
|
# Export & share
|
||||||
pdf: ^3.11.1
|
pdf: ^3.11.1
|
||||||
|
|||||||
Reference in New Issue
Block a user