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:
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 '../widgets/dialogs/bulk_take_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.
|
||||
/// Handles both foreground/background taps and terminated-launch scenarios.
|
||||
@@ -27,7 +29,22 @@ class NotificationRouter {
|
||||
final payloadMap = _decodePayload(response.payload);
|
||||
final actionId = response.actionId;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
final start = DateTime.now();
|
||||
while (DateTime.now().difference(start) < timeout) {
|
||||
final ctx = _navigatorKey!.currentContext;
|
||||
final key = _navigatorKey;
|
||||
final ctx = key?.currentContext;
|
||||
if (ctx != null) {
|
||||
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||
if (!provider.isLoading) {
|
||||
return true;
|
||||
try {
|
||||
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||
if (!provider.isLoading) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Provider not available yet
|
||||
}
|
||||
}
|
||||
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 'dart:convert';
|
||||
import 'package:supplements/services/notification_router.dart';
|
||||
import 'package:supplements/services/notification_debug_store.dart';
|
||||
|
||||
import '../models/supplement.dart';
|
||||
|
||||
@@ -33,7 +34,9 @@ class SimpleNotificationService {
|
||||
///
|
||||
/// Note: This does not request runtime permissions. Call [requestPermissions]
|
||||
/// to prompt the user for notification permissions.
|
||||
Future<void> initialize() async {
|
||||
Future<void> initialize({
|
||||
DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse,
|
||||
}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
// Initialize timezone database and set a sane default.
|
||||
@@ -52,12 +55,14 @@ class SimpleNotificationService {
|
||||
'single',
|
||||
actions: [
|
||||
DarwinNotificationAction.plain('take_single', 'Take'),
|
||||
DarwinNotificationAction.plain('snooze_single', 'Snooze'),
|
||||
],
|
||||
),
|
||||
DarwinNotificationCategory(
|
||||
'group',
|
||||
actions: [
|
||||
DarwinNotificationAction.plain('take_group', 'Take All'),
|
||||
DarwinNotificationAction.plain('snooze_group', 'Snooze'),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -77,6 +82,7 @@ class SimpleNotificationService {
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
NotificationRouter.instance.handleNotificationResponse(response);
|
||||
},
|
||||
onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
@@ -190,9 +196,12 @@ class SimpleNotificationService {
|
||||
|
||||
// Use BigTextStyle/InboxStyle for Android to show multiple lines
|
||||
final bool isSingle = count == 1;
|
||||
final String payloadStr = isSingle
|
||||
? jsonEncode({"type": "single", "id": items.first.id})
|
||||
: jsonEncode({"type": "group", "time": timeKey});
|
||||
// Tag payload with origin meta for debug/inspection
|
||||
final Map<String, dynamic> payloadMap = isSingle
|
||||
? {"type": "single", "id": items.first.id}
|
||||
: {"type": "group", "time": timeKey};
|
||||
payloadMap["meta"] = {"kind": "daily"};
|
||||
final String payloadStr = jsonEncode(payloadMap);
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelDailyId,
|
||||
@@ -220,6 +229,20 @@ class SimpleNotificationService {
|
||||
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
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -241,6 +264,22 @@ class SimpleNotificationService {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -288,34 +327,204 @@ class SimpleNotificationService {
|
||||
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.
|
||||
Future<void> showInstant({
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
bool includeSnoozeActions = false, // New parameter
|
||||
bool isSingle = true, // New parameter, defaults to single for instant
|
||||
}) async {
|
||||
if (!_initialized) {
|
||||
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(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'instant_notifications',
|
||||
'Instant Notifications',
|
||||
channelDescription: 'One-off or immediate notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
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 {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
|
Reference in New Issue
Block a user