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:
2025-08-30 01:51:38 +02:00
parent 811c1f3d6a
commit f7966ce587
8 changed files with 940 additions and 183 deletions

View 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);
}
}

View File

@@ -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));

View File

@@ -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();