Files
supplements/lib/services/notification_router.dart
Menno van Leeuwen f7966ce587 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.
2025-08-30 01:51:38 +02:00

291 lines
9.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:supplements/logging.dart';
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.
class NotificationRouter {
NotificationRouter._internal();
static final NotificationRouter instance = NotificationRouter._internal();
GlobalKey<NavigatorState>? _navigatorKey;
void initialize(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
Future<void> handleNotificationResponse(NotificationResponse response) async {
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);
}
Future<void> handleAppLaunchDetails(NotificationAppLaunchDetails? details) async {
if (details == null) return;
if (!details.didNotificationLaunchApp) return;
final resp = details.notificationResponse;
final payloadMap = _decodePayload(resp?.payload);
printLog('🚀 App launched from notification: payload=${resp?.payload} map=$payloadMap');
await _routeFromPayload(payloadMap);
}
Map<String, dynamic>? _decodePayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
// Try JSON first
try {
final map = jsonDecode(payload);
if (map is Map<String, dynamic>) return map;
} catch (_) {
// Ignore and try fallback
}
// Fallback: previous implementation used HH:mm as raw payload
final hhmm = RegExp(r'^\d{2}:\d{2}$');
if (hhmm.hasMatch(payload)) {
return { 'type': 'group', 'time': payload };
}
return null;
}
Future<void> _routeFromPayload(Map<String, dynamic>? payload) async {
if (_navigatorKey == null) {
printLog('⚠️ NotificationRouter not initialized with navigatorKey');
return;
}
// Wait until navigator is ready and providers have loaded
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
if (!ready) {
printLog('⚠️ Timeout waiting for app to be ready for routing');
return;
}
final context = _navigatorKey!.currentContext!;
final provider = context.read<SupplementProvider>();
if (payload == null) {
printLog('⚠️ No payload to route');
return;
}
final type = payload['type'];
if (type == 'single') {
final id = payload['id'];
if (id is int) {
Supplement? s;
try {
s = provider.supplements.firstWhere((el) => el.id == id);
} catch (_) {
s = null;
}
if (s == null) {
// Attempt reload once
await provider.loadSupplements();
try {
s = provider.supplements.firstWhere((el) => el.id == id);
} catch (_) {
s = null;
}
}
if (s != null) {
// For single: use the regular dialog (with time selection)
// Ensure we close any existing dialog first
_popAnyDialog(context);
await showTakeSupplementDialog(context, s, hideTime: false);
} else {
printLog('⚠️ Supplement id=$id not found for single-take routing');
_showSnack(context, 'Supplement not found');
}
}
} else if (type == 'group') {
final timeKey = payload['time'];
if (timeKey is String) {
// Build list of supplements scheduled at this timeKey
final List<Supplement> list = provider.supplements.where((s) {
return s.isActive && s.reminderTimes.contains(timeKey);
}).toList();
if (list.isEmpty) {
printLog('⚠️ No supplements found for group time=$timeKey');
_showSnack(context, 'No supplements for $timeKey');
return;
}
_popAnyDialog(context);
await showBulkTakeDialog(context, list);
}
} else {
printLog('⚠️ Unknown payload type: $type');
}
}
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 key = _navigatorKey;
final ctx = key?.currentContext;
if (ctx != null) {
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));
}
return false;
}
void _popAnyDialog(BuildContext context) {
if (Navigator.of(context, rootNavigator: true).canPop()) {
Navigator.of(context, rootNavigator: true).pop();
}
}
void _showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}