mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
feat: Implement snooze functionality for notifications
- Added snooze duration setting in SettingsScreen. - Created DebugNotificationsScreen to view pending notifications and logs. - Integrated notification logging with NotificationDebugStore. - Enhanced SimpleNotificationService to handle snooze actions and log notifications. - Removed ProfileSetupScreen as it is no longer needed. - Updated NotificationRouter to manage snooze actions without UI. - Refactored settings provider to include snooze duration management.
This commit is contained in:
@@ -1,20 +1,37 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:supplements/logging.dart';
|
import 'package:supplements/logging.dart';
|
||||||
|
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/simple_sync_provider.dart';
|
import 'providers/simple_sync_provider.dart';
|
||||||
import 'providers/supplement_provider.dart';
|
import 'providers/supplement_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/profile_setup_screen.dart';
|
|
||||||
import 'services/notification_router.dart';
|
import 'services/notification_router.dart';
|
||||||
import 'services/simple_notification_service.dart';
|
import 'services/simple_notification_service.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
// Top-level function to handle notification responses in the background
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||||
|
// handle action here
|
||||||
|
printLog('Background notification action tapped: ${notificationResponse.actionId}');
|
||||||
|
NotificationRouter.instance.handleNotificationResponse(notificationResponse);
|
||||||
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize SimpleNotificationService early
|
||||||
|
await SimpleNotificationService.instance.initialize(
|
||||||
|
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||||
|
);
|
||||||
|
|
||||||
final settingsProvider = SettingsProvider();
|
final settingsProvider = SettingsProvider();
|
||||||
await settingsProvider.initialize();
|
await settingsProvider.initialize();
|
||||||
|
|
||||||
@@ -48,9 +65,11 @@ class MyApp extends StatelessWidget {
|
|||||||
final supplementProvider = context.read<SupplementProvider>();
|
final supplementProvider = context.read<SupplementProvider>();
|
||||||
|
|
||||||
// Initialize notification router with the app's navigator
|
// Initialize notification router with the app's navigator
|
||||||
|
// This is done here because navigatorKey is only available after MaterialApp is built
|
||||||
NotificationRouter.instance.initialize(navigatorKey);
|
NotificationRouter.instance.initialize(navigatorKey);
|
||||||
|
|
||||||
// If the app was launched via a notification, route to the proper dialog
|
// If the app was launched via a notification, route to the proper dialog
|
||||||
|
// This needs to be called after the router is initialized with the navigatorKey
|
||||||
SimpleNotificationService.instance.getLaunchDetails().then((details) {
|
SimpleNotificationService.instance.getLaunchDetails().then((details) {
|
||||||
NotificationRouter.instance.handleAppLaunchDetails(details);
|
NotificationRouter.instance.handleAppLaunchDetails(details);
|
||||||
});
|
});
|
||||||
@@ -94,9 +113,7 @@ class MyApp extends StatelessWidget {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
themeMode: settingsProvider.themeMode,
|
themeMode: settingsProvider.themeMode,
|
||||||
home: (settingsProvider.age == null || settingsProvider.gender == null)
|
home: const HomeScreen(),
|
||||||
? const ProfileSetupScreen()
|
|
||||||
: const HomeScreen(),
|
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@@ -24,7 +24,8 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
int _nightStart = 23;
|
int _nightStart = 23;
|
||||||
int _nightEnd = 4;
|
int _nightEnd = 4;
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
int _snoozeMinutes = 10;
|
||||||
|
|
||||||
// Auto-sync settings
|
// Auto-sync settings
|
||||||
bool _autoSyncEnabled = false;
|
bool _autoSyncEnabled = false;
|
||||||
@@ -32,19 +33,6 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
ThemeOption get themeOption => _themeOption;
|
ThemeOption get themeOption => _themeOption;
|
||||||
|
|
||||||
// Profile getters
|
|
||||||
DateTime? get dateOfBirth => _dateOfBirth;
|
|
||||||
String? get gender => _gender;
|
|
||||||
int? get age {
|
|
||||||
if (_dateOfBirth == null) return null;
|
|
||||||
final now = DateTime.now();
|
|
||||||
int years = now.year - _dateOfBirth!.year;
|
|
||||||
final hasHadBirthday = (now.month > _dateOfBirth!.month) ||
|
|
||||||
(now.month == _dateOfBirth!.month && now.day >= _dateOfBirth!.day);
|
|
||||||
if (!hasHadBirthday) years--;
|
|
||||||
return years;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time range getters
|
// Time range getters
|
||||||
int get morningStart => _morningStart;
|
int get morningStart => _morningStart;
|
||||||
int get morningEnd => _morningEnd;
|
int get morningEnd => _morningEnd;
|
||||||
@@ -55,7 +43,8 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
int get nightStart => _nightStart;
|
int get nightStart => _nightStart;
|
||||||
int get nightEnd => _nightEnd;
|
int get nightEnd => _nightEnd;
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
int get snoozeMinutes => _snoozeMinutes;
|
||||||
|
|
||||||
// Auto-sync getters
|
// Auto-sync getters
|
||||||
bool get autoSyncEnabled => _autoSyncEnabled;
|
bool get autoSyncEnabled => _autoSyncEnabled;
|
||||||
@@ -104,7 +93,8 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
_nightStart = prefs.getInt('night_start') ?? 23;
|
_nightStart = prefs.getInt('night_start') ?? 23;
|
||||||
_nightEnd = prefs.getInt('night_end') ?? 4;
|
_nightEnd = prefs.getInt('night_end') ?? 4;
|
||||||
|
|
||||||
|
// Load snooze setting
|
||||||
|
_snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10;
|
||||||
|
|
||||||
// Load auto-sync settings
|
// Load auto-sync settings
|
||||||
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
|
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
|
||||||
@@ -269,7 +259,18 @@ class SettingsProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifications setters
|
||||||
|
Future<void> setSnoozeMinutes(int minutes) async {
|
||||||
|
const allowed = [5, 10, 15, 20];
|
||||||
|
if (!allowed.contains(minutes)) {
|
||||||
|
throw ArgumentError('Snooze minutes must be one of ${allowed.join(", ")}');
|
||||||
|
}
|
||||||
|
_snoozeMinutes = minutes;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('snooze_minutes', minutes);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-sync setters
|
// Auto-sync setters
|
||||||
Future<void> setAutoSyncEnabled(bool enabled) async {
|
Future<void> setAutoSyncEnabled(bool enabled) async {
|
||||||
|
415
lib/screens/debug_notifications_screen.dart
Normal file
415
lib/screens/debug_notifications_screen.dart
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:supplements/providers/supplement_provider.dart';
|
||||||
|
import 'package:supplements/services/notification_debug_store.dart';
|
||||||
|
import 'package:supplements/services/simple_notification_service.dart';
|
||||||
|
|
||||||
|
class DebugNotificationsScreen extends StatefulWidget {
|
||||||
|
const DebugNotificationsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DebugNotificationsScreen> createState() => _DebugNotificationsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
List<PendingNotificationRequest> _pending = const [];
|
||||||
|
List<NotificationLogEntry> _logEntries = const [];
|
||||||
|
final Map<int, String> _supplementNameCache = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch pending from plugin
|
||||||
|
final pending = await SimpleNotificationService.instance.getPendingNotifications();
|
||||||
|
|
||||||
|
// Fetch log from local store
|
||||||
|
final logs = await NotificationDebugStore.instance.getAll();
|
||||||
|
|
||||||
|
// Optionally resolve supplement names for single payloads
|
||||||
|
await _resolveSupplementNames(pending, logs);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pending = pending;
|
||||||
|
_logEntries = logs.reversed.toList(); // newest first
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveSupplementNames(
|
||||||
|
List<PendingNotificationRequest> pending,
|
||||||
|
List<NotificationLogEntry> logs,
|
||||||
|
) async {
|
||||||
|
final ctx = context;
|
||||||
|
if (!mounted) return;
|
||||||
|
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||||
|
// Use existing list; attempt load if empty
|
||||||
|
if (provider.supplements.isEmpty) {
|
||||||
|
try {
|
||||||
|
await provider.loadSupplements();
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect potential IDs
|
||||||
|
final Set<int> ids = {};
|
||||||
|
for (final p in pending) {
|
||||||
|
final id = _extractSingleIdFromPayload(p.payload);
|
||||||
|
if (id != null) ids.add(id);
|
||||||
|
}
|
||||||
|
for (final e in logs) {
|
||||||
|
if (e.singleId != null) ids.add(e.singleId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cache
|
||||||
|
for (final id in ids) {
|
||||||
|
try {
|
||||||
|
final s = provider.supplements.firstWhere((el) => el.id == id);
|
||||||
|
_supplementNameCache[id] = s.name;
|
||||||
|
} catch (_) {
|
||||||
|
// leave missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _extractSingleIdFromPayload(String? payload) {
|
||||||
|
if (payload == null || payload.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(payload);
|
||||||
|
if (map is Map && map['type'] == 'single') {
|
||||||
|
final v = map['id'];
|
||||||
|
if (v is int) return v;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractGroupTimeFromPayload(String? payload) {
|
||||||
|
if (payload == null || payload.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(payload);
|
||||||
|
if (map is Map && map['type'] == 'group') {
|
||||||
|
final v = map['time'];
|
||||||
|
if (v is String) return v;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractKindFromPayload(String? payload) {
|
||||||
|
if (payload == null || payload.isEmpty) return 'unknown';
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(payload);
|
||||||
|
if (map is Map) {
|
||||||
|
final meta = map['meta'];
|
||||||
|
if (meta is Map && meta['kind'] is String) return meta['kind'] as String;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _estimateScheduledAtFromPayload(String? payload) {
|
||||||
|
// For snooze with meta.createdAt/delayMin we can compute when.
|
||||||
|
if (payload == null || payload.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(payload);
|
||||||
|
if (map is Map) {
|
||||||
|
final meta = map['meta'];
|
||||||
|
if (meta is Map) {
|
||||||
|
final kind = meta['kind'];
|
||||||
|
if (kind == 'snooze') {
|
||||||
|
final createdAt = meta['createdAt'];
|
||||||
|
final delayMin = meta['delayMin'];
|
||||||
|
if (createdAt is int && delayMin is int) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(createdAt)
|
||||||
|
.add(Duration(minutes: delayMin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For daily group we can compute next HH:mm
|
||||||
|
if (map['type'] == 'group' && map['time'] is String) {
|
||||||
|
final timeKey = map['time'] as String;
|
||||||
|
final parts = timeKey.split(':');
|
||||||
|
if (parts.length == 2) {
|
||||||
|
final hour = int.tryParse(parts[0]) ?? 0;
|
||||||
|
final minute = int.tryParse(parts[1]) ?? 0;
|
||||||
|
final now = DateTime.now();
|
||||||
|
DateTime sched = DateTime(now.year, now.month, now.day, hour, minute);
|
||||||
|
if (!sched.isAfter(now)) {
|
||||||
|
sched = sched.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
return sched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelId(int id) async {
|
||||||
|
await SimpleNotificationService.instance.cancelById(id);
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Canceled notification $id')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelAll() async {
|
||||||
|
await SimpleNotificationService.instance.cancelAll();
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Canceled all notifications')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearLog() async {
|
||||||
|
await NotificationDebugStore.instance.clear();
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Cleared debug log')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard(String text) {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pendingIds = _pending.map((e) => e.id).toSet();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Debug Notifications'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Cancel All',
|
||||||
|
onPressed: _cancelAll,
|
||||||
|
icon: const Icon(Icons.cancel_schedule_send),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Clear Log',
|
||||||
|
onPressed: _clearLog,
|
||||||
|
icon: const Icon(Icons.delete_sweep),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
_buildPendingSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTestSnoozeSection(), // Add the test snooze section
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildLogSection(pendingIds),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Pending (${_pending.length})', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_pending.isEmpty)
|
||||||
|
const Text('No pending notifications'),
|
||||||
|
for (final p in _pending) _buildPendingTile(p),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTestSnoozeSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Test Snooze', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
// Trigger a test snooze notification with snooze actions
|
||||||
|
await SimpleNotificationService.instance.showInstant(
|
||||||
|
title: 'Test Snooze Notification',
|
||||||
|
body: 'This is a test notification for snooze.',
|
||||||
|
payload: jsonEncode({
|
||||||
|
"type": "single",
|
||||||
|
"id": 1, // Use a dummy ID for testing
|
||||||
|
"meta": {"kind": "daily"} // Simulate a daily notification
|
||||||
|
}),
|
||||||
|
includeSnoozeActions: true, // Include snooze actions
|
||||||
|
isSingle: true, // This is a single notification
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Test snooze notification sent!')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Send Test Snooze Notification'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingTile(PendingNotificationRequest p) {
|
||||||
|
final kind = _extractKindFromPayload(p.payload);
|
||||||
|
final singleId = _extractSingleIdFromPayload(p.payload);
|
||||||
|
final groupTime = _extractGroupTimeFromPayload(p.payload);
|
||||||
|
final schedAt = _estimateScheduledAtFromPayload(p.payload);
|
||||||
|
|
||||||
|
final forStr = singleId != null
|
||||||
|
? (_supplementNameCache[singleId] != null
|
||||||
|
? '${_supplementNameCache[singleId]} (id=$singleId)'
|
||||||
|
: 'Supplement id=$singleId')
|
||||||
|
: (groupTime != null ? 'Time $groupTime' : 'unknown');
|
||||||
|
|
||||||
|
// Build a clearer subtitle with scheduled time prominently displayed
|
||||||
|
String subtitle = 'Kind: $kind • For: $forStr';
|
||||||
|
if (schedAt != null) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = schedAt.difference(now);
|
||||||
|
String whenStr;
|
||||||
|
if (diff.isNegative) {
|
||||||
|
whenStr = 'Overdue (${schedAt.toLocal().toString().substring(0, 16)})';
|
||||||
|
} else if (diff.inDays > 0) {
|
||||||
|
whenStr = 'In ${diff.inDays}d ${diff.inHours % 24}h (${schedAt.toLocal().toString().substring(0, 16)})';
|
||||||
|
} else if (diff.inHours > 0) {
|
||||||
|
whenStr = 'In ${diff.inHours}h ${diff.inMinutes % 60}m (${schedAt.toLocal().toString().substring(11, 16)})';
|
||||||
|
} else if (diff.inMinutes > 0) {
|
||||||
|
whenStr = 'In ${diff.inMinutes}m (${schedAt.toLocal().toString().substring(11, 16)})';
|
||||||
|
} else {
|
||||||
|
whenStr = 'Very soon (${schedAt.toLocal().toString().substring(11, 16)})';
|
||||||
|
}
|
||||||
|
subtitle = '$subtitle\n🕒 $whenStr';
|
||||||
|
}
|
||||||
|
// Removed the "else" block that displayed "Schedule time unknown"
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text('ID ${p.id} — ${p.title ?? "(no title)"}'),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
trailing: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy payload',
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
onPressed: () => _copyToClipboard(p.payload ?? ''),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
icon: const Icon(Icons.cancel),
|
||||||
|
onPressed: () => _cancelId(p.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogSection(Set<int> pendingIds) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Schedule Log (${_logEntries.length})', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_logEntries.isEmpty)
|
||||||
|
const Text('No log entries'),
|
||||||
|
for (final e in _logEntries) _buildLogTile(e, pendingIds),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogTile(NotificationLogEntry e, Set<int> pendingIds) {
|
||||||
|
final inQueue = pendingIds.contains(e.id);
|
||||||
|
String forStr = 'unknown';
|
||||||
|
if (e.type == 'single') {
|
||||||
|
if (e.singleId != null) {
|
||||||
|
final name = _supplementNameCache[e.singleId!];
|
||||||
|
forStr = name != null ? '$name (id=${e.singleId})' : 'id=${e.singleId}';
|
||||||
|
}
|
||||||
|
} else if (e.type == 'group') {
|
||||||
|
if (e.timeKey != null) {
|
||||||
|
forStr = 'Time ${e.timeKey}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final scheduledAt = DateTime.fromMillisecondsSinceEpoch(e.whenEpochMs).toLocal();
|
||||||
|
final createdAt = DateTime.fromMillisecondsSinceEpoch(e.createdAtEpochMs).toLocal();
|
||||||
|
|
||||||
|
// Format times more clearly
|
||||||
|
final scheduledStr = '${scheduledAt.toString().substring(0, 16)}';
|
||||||
|
final createdStr = '${createdAt.toString().substring(0, 16)}';
|
||||||
|
|
||||||
|
// Show status and timing info
|
||||||
|
final statusStr = inQueue ? '🟡 Pending' : '✅ Completed/Canceled';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(inQueue ? Icons.pending : Icons.check, color: inQueue ? Colors.amber : Colors.green),
|
||||||
|
title: Text('[${e.kind}] ${e.title} (ID ${e.id})'),
|
||||||
|
subtitle: Text('$statusStr • Type: ${e.type} • For: $forStr\n🕒 Scheduled: $scheduledStr\n📝 Created: $createdStr'),
|
||||||
|
trailing: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy payload',
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
onPressed: () => _copyToClipboard(e.payload),
|
||||||
|
),
|
||||||
|
if (inQueue)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
icon: const Icon(Icons.cancel),
|
||||||
|
onPressed: () => _cancelId(e.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,129 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:supplements/screens/home_screen.dart';
|
|
||||||
|
|
||||||
import '../providers/settings_provider.dart';
|
|
||||||
|
|
||||||
// Profile setup screen
|
|
||||||
class ProfileSetupScreen extends StatefulWidget {
|
|
||||||
const ProfileSetupScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
DateTime? _dateOfBirth;
|
|
||||||
String? _gender;
|
|
||||||
|
|
||||||
final List<String> _genders = ['Male', 'Female', 'Other', 'Prefer not to say'];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
|
|
||||||
_dateOfBirth = settingsProvider.dateOfBirth;
|
|
||||||
_gender = settingsProvider.gender;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _saveProfile() {
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
_formKey.currentState!.save();
|
|
||||||
Provider.of<SettingsProvider>(context, listen: false).setDateOfBirthAndGender(_dateOfBirth!, _gender!);
|
|
||||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _selectDate(BuildContext context) async {
|
|
||||||
final DateTime? picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: _dateOfBirth ?? DateTime.now(),
|
|
||||||
firstDate: DateTime(1900),
|
|
||||||
lastDate: DateTime.now(),
|
|
||||||
);
|
|
||||||
if (picked != null && picked != _dateOfBirth) {
|
|
||||||
setState(() {
|
|
||||||
_dateOfBirth = picked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Set Up Your Profile'),
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'To provide you with personalized ingredient insights, please provide your date of birth and gender.',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
TextFormField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Date of Birth',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
suffixIcon: Icon(Icons.calendar_today),
|
|
||||||
),
|
|
||||||
readOnly: true,
|
|
||||||
controller: TextEditingController(
|
|
||||||
text: _dateOfBirth == null
|
|
||||||
? ''
|
|
||||||
: '${_dateOfBirth!.toLocal()}'.split(' ')[0],
|
|
||||||
),
|
|
||||||
onTap: () => _selectDate(context),
|
|
||||||
validator: (value) {
|
|
||||||
if (_dateOfBirth == null) {
|
|
||||||
return 'Please select your date of birth';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Gender',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
value: _gender,
|
|
||||||
items: _genders.map((String gender) {
|
|
||||||
return DropdownMenuItem<String>(
|
|
||||||
value: gender,
|
|
||||||
child: Text(gender),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_gender = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Please select your gender';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onSaved: (value) {
|
|
||||||
_gender = value;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _saveProfile,
|
|
||||||
child: const Text('Save and Continue'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,8 +1,9 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import 'profile_setup_screen.dart';
|
import 'debug_notifications_screen.dart';
|
||||||
import 'simple_sync_settings_screen.dart';
|
import 'simple_sync_settings_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
@@ -19,22 +20,25 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
// Debug section (only in debug builds)
|
||||||
child: ListTile(
|
if (kDebugMode) ...[
|
||||||
leading: const Icon(Icons.person),
|
Card(
|
||||||
title: const Text('Profile'),
|
child: ListTile(
|
||||||
subtitle: Text('Date of Birth: ${settingsProvider.dateOfBirth != null ? '${settingsProvider.dateOfBirth!.toLocal()}'.split(' ')[0] : 'Not set'}, Gender: ${settingsProvider.gender ?? 'Not set'}'),
|
leading: const Icon(Icons.bug_report),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
title: const Text('Debug Notifications'),
|
||||||
onTap: () {
|
subtitle: const Text('View scheduled notifications and debug log'),
|
||||||
Navigator.of(context).push(
|
trailing: const Icon(Icons.chevron_right),
|
||||||
MaterialPageRoute(
|
onTap: () {
|
||||||
builder: (context) => const ProfileSetupScreen(),
|
Navigator.of(context).push(
|
||||||
),
|
MaterialPageRoute(
|
||||||
);
|
builder: (context) => const DebugNotificationsScreen(),
|
||||||
},
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
],
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.cloud_sync),
|
leading: const Icon(Icons.cloud_sync),
|
||||||
@@ -100,7 +104,28 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Reminders settings removed
|
// Notifications
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.snooze),
|
||||||
|
title: const Text('Snooze duration'),
|
||||||
|
subtitle: const Text('Delay for Snooze action'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: settingsProvider.snoozeMinutes,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 5, child: Text('5 min')),
|
||||||
|
DropdownMenuItem(value: 10, child: Text('10 min')),
|
||||||
|
DropdownMenuItem(value: 15, child: Text('15 min')),
|
||||||
|
DropdownMenuItem(value: 20, child: Text('20 min')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.setSnoozeMinutes(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
92
lib/services/notification_debug_store.dart
Normal file
92
lib/services/notification_debug_store.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class NotificationLogEntry {
|
||||||
|
final int id;
|
||||||
|
final String kind; // 'daily' | 'snooze'
|
||||||
|
final String type; // 'single' | 'group'
|
||||||
|
final int whenEpochMs; // exact scheduled time (epoch ms)
|
||||||
|
final int createdAtEpochMs; // when we created the schedule (epoch ms)
|
||||||
|
final String title;
|
||||||
|
final String payload;
|
||||||
|
final int? singleId; // supplement id for single
|
||||||
|
final String? timeKey; // HH:mm for group
|
||||||
|
|
||||||
|
const NotificationLogEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.type,
|
||||||
|
required this.whenEpochMs,
|
||||||
|
required this.createdAtEpochMs,
|
||||||
|
required this.title,
|
||||||
|
required this.payload,
|
||||||
|
this.singleId,
|
||||||
|
this.timeKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'kind': kind,
|
||||||
|
'type': type,
|
||||||
|
'when': whenEpochMs,
|
||||||
|
'createdAt': createdAtEpochMs,
|
||||||
|
'title': title,
|
||||||
|
'payload': payload,
|
||||||
|
'singleId': singleId,
|
||||||
|
'timeKey': timeKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
static NotificationLogEntry fromJson(Map<String, dynamic> map) {
|
||||||
|
return NotificationLogEntry(
|
||||||
|
id: map['id'] is int ? map['id'] as int : int.tryParse('${map['id']}') ?? 0,
|
||||||
|
kind: map['kind'] ?? 'unknown',
|
||||||
|
type: map['type'] ?? 'unknown',
|
||||||
|
whenEpochMs: map['when'] is int ? map['when'] as int : int.tryParse('${map['when']}') ?? 0,
|
||||||
|
createdAtEpochMs: map['createdAt'] is int ? map['createdAt'] as int : int.tryParse('${map['createdAt']}') ?? 0,
|
||||||
|
title: map['title'] ?? '',
|
||||||
|
payload: map['payload'] ?? '',
|
||||||
|
singleId: map['singleId'],
|
||||||
|
timeKey: map['timeKey'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationDebugStore {
|
||||||
|
NotificationDebugStore._internal();
|
||||||
|
static final NotificationDebugStore instance = NotificationDebugStore._internal();
|
||||||
|
|
||||||
|
static const String _prefsKey = 'notification_log';
|
||||||
|
static const int _maxEntries = 200;
|
||||||
|
|
||||||
|
Future<List<NotificationLogEntry>> getAll() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_prefsKey);
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(raw) as List;
|
||||||
|
return list
|
||||||
|
.map((e) => NotificationLogEntry.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> add(NotificationLogEntry entry) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final current = await getAll();
|
||||||
|
current.add(entry);
|
||||||
|
// Cap size
|
||||||
|
final trimmed = current.length > _maxEntries
|
||||||
|
? current.sublist(current.length - _maxEntries)
|
||||||
|
: current;
|
||||||
|
final serialized = jsonEncode(trimmed.map((e) => e.toJson()).toList());
|
||||||
|
await prefs.setString(_prefsKey, serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_prefsKey);
|
||||||
|
}
|
||||||
|
}
|
@@ -10,6 +10,8 @@ import '../models/supplement.dart';
|
|||||||
import '../providers/supplement_provider.dart';
|
import '../providers/supplement_provider.dart';
|
||||||
import '../widgets/dialogs/bulk_take_dialog.dart';
|
import '../widgets/dialogs/bulk_take_dialog.dart';
|
||||||
import '../widgets/dialogs/take_supplement_dialog.dart';
|
import '../widgets/dialogs/take_supplement_dialog.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:supplements/services/simple_notification_service.dart';
|
||||||
|
|
||||||
/// Centralizes routing from notification actions/taps to in-app UI.
|
/// Centralizes routing from notification actions/taps to in-app UI.
|
||||||
/// Handles both foreground/background taps and terminated-launch scenarios.
|
/// Handles both foreground/background taps and terminated-launch scenarios.
|
||||||
@@ -27,7 +29,22 @@ class NotificationRouter {
|
|||||||
final payloadMap = _decodePayload(response.payload);
|
final payloadMap = _decodePayload(response.payload);
|
||||||
final actionId = response.actionId;
|
final actionId = response.actionId;
|
||||||
printLog('🔔 handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap');
|
printLog('🔔 handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap');
|
||||||
|
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
|
||||||
|
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
|
||||||
|
|
||||||
|
// Handle Snooze actions without surfacing UI
|
||||||
|
if (actionId == 'snooze_single' || actionId == 'snooze_group') {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final minutes = prefs.getInt('snooze_minutes') ?? 10;
|
||||||
|
await _scheduleSnoozeFromPayload(payloadMap, Duration(minutes: minutes));
|
||||||
|
} catch (e) {
|
||||||
|
printLog('⚠️ Failed to handle snooze action: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: route to in-app UI for Take actions and normal taps
|
||||||
await _routeFromPayload(payloadMap);
|
await _routeFromPayload(payloadMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,14 +151,124 @@ class NotificationRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _scheduleSnoozeFromPayload(Map<String, dynamic>? payload, Duration delay) async {
|
||||||
|
if (payload == null) {
|
||||||
|
printLog('⚠️ Snooze requested but payload was null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to wait for providers to be ready to build rich content.
|
||||||
|
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
|
||||||
|
BuildContext? ctx = _navigatorKey?.currentContext;
|
||||||
|
|
||||||
|
SupplementProvider? provider;
|
||||||
|
if (ready && ctx != null) {
|
||||||
|
provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String title = 'Supplement reminder';
|
||||||
|
String body = 'Tap to see details';
|
||||||
|
bool isSingle = false;
|
||||||
|
// Start with a mutable copy of the payload to add meta information
|
||||||
|
final Map<String, dynamic> mutablePayload = Map.from(payload);
|
||||||
|
|
||||||
|
final type = mutablePayload['type'];
|
||||||
|
|
||||||
|
if (type == 'single') {
|
||||||
|
final id = payload['id'];
|
||||||
|
isSingle = true;
|
||||||
|
// Ensure the payload for single snooze is correctly formatted
|
||||||
|
mutablePayload['type'] = 'single';
|
||||||
|
mutablePayload['id'] = id;
|
||||||
|
|
||||||
|
if (id is int && provider != null) {
|
||||||
|
Supplement? s;
|
||||||
|
try {
|
||||||
|
s = provider.supplements.firstWhere((el) => el.id == id);
|
||||||
|
} catch (_) {
|
||||||
|
s = null;
|
||||||
|
}
|
||||||
|
if (s != null) {
|
||||||
|
title = 'Time for ${s.name}';
|
||||||
|
body =
|
||||||
|
'${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})';
|
||||||
|
} else {
|
||||||
|
body = 'Tap to take supplement';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type == 'group') {
|
||||||
|
final timeKey = mutablePayload['time'];
|
||||||
|
if (timeKey is String) {
|
||||||
|
if (provider != null) {
|
||||||
|
final list = provider.supplements
|
||||||
|
.where((s) =>
|
||||||
|
s.isActive && s.reminderTimes.contains(timeKey))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (list.length == 1) {
|
||||||
|
final s = list.first;
|
||||||
|
isSingle = true;
|
||||||
|
title = 'Time for ${s.name}';
|
||||||
|
body =
|
||||||
|
'${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})';
|
||||||
|
// If a group becomes a single, update the payload type
|
||||||
|
mutablePayload['type'] = 'single';
|
||||||
|
mutablePayload['id'] = s.id;
|
||||||
|
mutablePayload.remove('time'); // Remove time key for single
|
||||||
|
} else if (list.isNotEmpty) {
|
||||||
|
isSingle = false;
|
||||||
|
title = 'Time for ${list.length} supplements';
|
||||||
|
final lines = list
|
||||||
|
.map((s) =>
|
||||||
|
'${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})')
|
||||||
|
.toList();
|
||||||
|
body = lines.join('\n');
|
||||||
|
// Ensure payload type is group
|
||||||
|
mutablePayload['type'] = 'group';
|
||||||
|
mutablePayload['time'] = timeKey;
|
||||||
|
} else {
|
||||||
|
// Fallback generic group
|
||||||
|
isSingle = false;
|
||||||
|
title = 'Supplement reminder';
|
||||||
|
body = 'Tap to see details';
|
||||||
|
mutablePayload['type'] = 'group';
|
||||||
|
mutablePayload['time'] = timeKey;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Provider not ready; schedule generic group payload
|
||||||
|
isSingle = false;
|
||||||
|
mutablePayload['type'] = 'group';
|
||||||
|
mutablePayload['time'] = timeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the payload always has the correct type and ID/time for logging
|
||||||
|
// and re-scheduling. The SimpleNotificationService will add the 'meta' field.
|
||||||
|
final payloadStr = jsonEncode(mutablePayload);
|
||||||
|
|
||||||
|
await SimpleNotificationService.instance.scheduleOneOffReminder(
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
payload: payloadStr,
|
||||||
|
isSingle: isSingle,
|
||||||
|
delay: delay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _waitUntilReady({required Duration timeout}) async {
|
Future<bool> _waitUntilReady({required Duration timeout}) async {
|
||||||
final start = DateTime.now();
|
final start = DateTime.now();
|
||||||
while (DateTime.now().difference(start) < timeout) {
|
while (DateTime.now().difference(start) < timeout) {
|
||||||
final ctx = _navigatorKey!.currentContext;
|
final key = _navigatorKey;
|
||||||
|
final ctx = key?.currentContext;
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
try {
|
||||||
if (!provider.isLoading) {
|
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||||
return true;
|
if (!provider.isLoading) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Provider not available yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
@@ -4,6 +4,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
|
|||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:supplements/services/notification_router.dart';
|
import 'package:supplements/services/notification_router.dart';
|
||||||
|
import 'package:supplements/services/notification_debug_store.dart';
|
||||||
|
|
||||||
import '../models/supplement.dart';
|
import '../models/supplement.dart';
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ class SimpleNotificationService {
|
|||||||
///
|
///
|
||||||
/// Note: This does not request runtime permissions. Call [requestPermissions]
|
/// Note: This does not request runtime permissions. Call [requestPermissions]
|
||||||
/// to prompt the user for notification permissions.
|
/// to prompt the user for notification permissions.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize({
|
||||||
|
DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse,
|
||||||
|
}) async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
|
|
||||||
// Initialize timezone database and set a sane default.
|
// Initialize timezone database and set a sane default.
|
||||||
@@ -52,12 +55,14 @@ class SimpleNotificationService {
|
|||||||
'single',
|
'single',
|
||||||
actions: [
|
actions: [
|
||||||
DarwinNotificationAction.plain('take_single', 'Take'),
|
DarwinNotificationAction.plain('take_single', 'Take'),
|
||||||
|
DarwinNotificationAction.plain('snooze_single', 'Snooze'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
DarwinNotificationCategory(
|
DarwinNotificationCategory(
|
||||||
'group',
|
'group',
|
||||||
actions: [
|
actions: [
|
||||||
DarwinNotificationAction.plain('take_group', 'Take All'),
|
DarwinNotificationAction.plain('take_group', 'Take All'),
|
||||||
|
DarwinNotificationAction.plain('snooze_group', 'Snooze'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -77,6 +82,7 @@ class SimpleNotificationService {
|
|||||||
onDidReceiveNotificationResponse: (response) {
|
onDidReceiveNotificationResponse: (response) {
|
||||||
NotificationRouter.instance.handleNotificationResponse(response);
|
NotificationRouter.instance.handleNotificationResponse(response);
|
||||||
},
|
},
|
||||||
|
onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
@@ -190,9 +196,12 @@ class SimpleNotificationService {
|
|||||||
|
|
||||||
// Use BigTextStyle/InboxStyle for Android to show multiple lines
|
// Use BigTextStyle/InboxStyle for Android to show multiple lines
|
||||||
final bool isSingle = count == 1;
|
final bool isSingle = count == 1;
|
||||||
final String payloadStr = isSingle
|
// Tag payload with origin meta for debug/inspection
|
||||||
? jsonEncode({"type": "single", "id": items.first.id})
|
final Map<String, dynamic> payloadMap = isSingle
|
||||||
: jsonEncode({"type": "group", "time": timeKey});
|
? {"type": "single", "id": items.first.id}
|
||||||
|
: {"type": "group", "time": timeKey};
|
||||||
|
payloadMap["meta"] = {"kind": "daily"};
|
||||||
|
final String payloadStr = jsonEncode(payloadMap);
|
||||||
|
|
||||||
final androidDetails = AndroidNotificationDetails(
|
final androidDetails = AndroidNotificationDetails(
|
||||||
_channelDailyId,
|
_channelDailyId,
|
||||||
@@ -220,6 +229,20 @@ class SimpleNotificationService {
|
|||||||
showsUserInterface: true,
|
showsUserInterface: true,
|
||||||
cancelNotification: true,
|
cancelNotification: true,
|
||||||
),
|
),
|
||||||
|
if (isSingle)
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_single',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
// Removed cancelNotification: true for debugging
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_group',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
// Removed cancelNotification: true for debugging
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -241,6 +264,22 @@ class SimpleNotificationService {
|
|||||||
payload: payloadStr,
|
payload: payloadStr,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log to debug store
|
||||||
|
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await NotificationDebugStore.instance.add(
|
||||||
|
NotificationLogEntry(
|
||||||
|
id: id,
|
||||||
|
kind: 'daily',
|
||||||
|
type: isSingle ? 'single' : 'group',
|
||||||
|
whenEpochMs: when.millisecondsSinceEpoch,
|
||||||
|
createdAtEpochMs: createdAtMs,
|
||||||
|
title: title,
|
||||||
|
payload: payloadStr,
|
||||||
|
singleId: isSingle ? items.first.id as int? : null,
|
||||||
|
timeKey: isSingle ? null : timeKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
printLog('✅ Scheduled group $timeKey with ID $id');
|
printLog('✅ Scheduled group $timeKey with ID $id');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,34 +327,204 @@ class SimpleNotificationService {
|
|||||||
await _plugin.cancelAll();
|
await _plugin.cancelAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel a specific notification by ID.
|
||||||
|
Future<void> cancelById(int id) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
await _plugin.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Show an immediate notification. Useful for quick diagnostics.
|
/// Show an immediate notification. Useful for quick diagnostics.
|
||||||
Future<void> showInstant({
|
Future<void> showInstant({
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
String? payload,
|
String? payload,
|
||||||
|
bool includeSnoozeActions = false, // New parameter
|
||||||
|
bool isSingle = true, // New parameter, defaults to single for instant
|
||||||
}) async {
|
}) async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
'instant_notifications',
|
||||||
|
'Instant Notifications',
|
||||||
|
channelDescription: 'One-off or immediate notifications',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
actions: includeSnoozeActions
|
||||||
|
? [
|
||||||
|
if (isSingle)
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_single',
|
||||||
|
'Take',
|
||||||
|
showsUserInterface: true,
|
||||||
|
cancelNotification: true,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_group',
|
||||||
|
'Take All',
|
||||||
|
showsUserInterface: true,
|
||||||
|
cancelNotification: true,
|
||||||
|
),
|
||||||
|
if (isSingle)
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_single',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
cancelNotification: true,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_group',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
cancelNotification: true,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [], // No actions by default
|
||||||
|
);
|
||||||
|
|
||||||
|
final iosDetails = DarwinNotificationDetails(
|
||||||
|
categoryIdentifier: includeSnoozeActions
|
||||||
|
? (isSingle ? 'single' : 'group')
|
||||||
|
: null, // Use category for actions
|
||||||
|
);
|
||||||
|
|
||||||
await _plugin.show(
|
await _plugin.show(
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: androidDetails,
|
||||||
'instant_notifications',
|
iOS: iosDetails,
|
||||||
'Instant Notifications',
|
|
||||||
channelDescription: 'One-off or immediate notifications',
|
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
),
|
|
||||||
iOS: DarwinNotificationDetails(),
|
|
||||||
),
|
),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schedule a one-off (non-repeating) reminder, typically used for Snooze.
|
||||||
|
Future<void> scheduleOneOffReminder({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required String payload,
|
||||||
|
required bool isSingle,
|
||||||
|
required Duration delay,
|
||||||
|
}) async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
final when = tz.TZDateTime.now(tz.local).add(delay);
|
||||||
|
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_channelDailyId,
|
||||||
|
_channelDailyName,
|
||||||
|
channelDescription: _channelDailyDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
styleInformation: BigTextStyleInformation(
|
||||||
|
body,
|
||||||
|
contentTitle: title,
|
||||||
|
htmlFormatContentTitle: false,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (isSingle)
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_single',
|
||||||
|
'Take',
|
||||||
|
showsUserInterface: true,
|
||||||
|
cancelNotification: true,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'take_group',
|
||||||
|
'Take All',
|
||||||
|
showsUserInterface: true,
|
||||||
|
cancelNotification: true,
|
||||||
|
),
|
||||||
|
if (isSingle)
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_single',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
// Removed cancelNotification: true for debugging
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AndroidNotificationAction(
|
||||||
|
'snooze_group',
|
||||||
|
'Snooze',
|
||||||
|
showsUserInterface: false,
|
||||||
|
// Removed cancelNotification: true for debugging
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final iosDetails = DarwinNotificationDetails(
|
||||||
|
categoryIdentifier: isSingle ? 'single' : 'group',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich payload with meta for snooze; also capture linkage for logging
|
||||||
|
Map<String, dynamic>? pmap;
|
||||||
|
try {
|
||||||
|
pmap = jsonDecode(payload) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
pmap = null;
|
||||||
|
}
|
||||||
|
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
String payloadFinal = payload;
|
||||||
|
int? logSingleId;
|
||||||
|
String? logTimeKey;
|
||||||
|
if (pmap != null) {
|
||||||
|
final meta = {
|
||||||
|
'kind': 'snooze',
|
||||||
|
'createdAt': createdAtMs,
|
||||||
|
'delayMin': delay.inMinutes,
|
||||||
|
};
|
||||||
|
pmap['meta'] = meta;
|
||||||
|
if (pmap['type'] == 'single') {
|
||||||
|
final v = pmap['id'];
|
||||||
|
logSingleId = v is int ? v : null;
|
||||||
|
} else if (pmap['type'] == 'group') {
|
||||||
|
logTimeKey = pmap['time'] as String?;
|
||||||
|
}
|
||||||
|
payloadFinal = jsonEncode(pmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
isSingle ? body : 'Tap to see details',
|
||||||
|
when,
|
||||||
|
NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
),
|
||||||
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
payload: payloadFinal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log to debug store
|
||||||
|
await NotificationDebugStore.instance.add(
|
||||||
|
NotificationLogEntry(
|
||||||
|
id: id,
|
||||||
|
kind: 'snooze',
|
||||||
|
type: isSingle ? 'single' : 'group',
|
||||||
|
whenEpochMs: when.millisecondsSinceEpoch,
|
||||||
|
createdAtEpochMs: createdAtMs,
|
||||||
|
title: title,
|
||||||
|
payload: payloadFinal,
|
||||||
|
singleId: logSingleId,
|
||||||
|
timeKey: logTimeKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
printLog('⏰ Scheduled one-off reminder (id=$id) at $when, isSingle=$isSingle');
|
||||||
|
}
|
||||||
|
|
||||||
Future<NotificationAppLaunchDetails?> getLaunchDetails() async {
|
Future<NotificationAppLaunchDetails?> getLaunchDetails() async {
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
await initialize();
|
await initialize();
|
||||||
|
Reference in New Issue
Block a user