Files
supplements/lib/services/notification_router.dart

348 lines
12 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:shadcn_ui/shadcn_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supplements/logging.dart';
import 'package:supplements/services/simple_notification_service.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
import '../widgets/dialogs/bulk_take_dialog.dart';
import '../widgets/dialogs/take_supplement_dialog.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);
// Cancel retry notifications only for Take actions (not for taps or snoozes)
if (actionId == 'take_single' || actionId == 'take_group') {
await _cancelRetryNotificationsForResponse(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!;
if (!context.mounted) return;
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();
if (!context.mounted) return;
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);
if (!context.mounted) return;
} 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);
if (!context.mounted) return;
}
} 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));
SupplementProvider? provider;
if (ready) {
final ctx = _navigatorKey?.currentContext;
if (ctx != null && ctx.mounted) {
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) {
if (!ctx.mounted) continue;
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) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ShadSonner.of(context).show(
ShadToast(
title: Text(message),
),
);
});
}
/// Cancel retry notifications when user responds to any notification.
/// This prevents redundant notifications after user interaction.
Future<void> _cancelRetryNotificationsForResponse(Map<String, dynamic>? payloadMap) async {
if (payloadMap == null) return;
try {
final type = payloadMap['type'];
if (type == 'single') {
// For single notifications, we need to find the time slot to cancel retries
// We can extract this from the meta if it's a retry, or find it from the supplement
final meta = payloadMap['meta'] as Map<String, dynamic>?;
if (meta != null && meta['originalTime'] != null) {
// This is a retry notification, cancel remaining retries for the original time
final originalTime = meta['originalTime'] as String;
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(originalTime);
printLog('🚫 Cancelled retries for original time slot: $originalTime');
} else {
// This is an original notification, find the time slot from the supplement
final supplementId = payloadMap['id'];
if (supplementId is int) {
// We need to find which time slots this supplement has and cancel retries for all of them
// For now, we'll use the broader cancellation method
await SimpleNotificationService.instance.cancelRetryNotificationsForSupplement(supplementId);
printLog('🚫 Cancelled retries for supplement ID: $supplementId');
}
}
} else if (type == 'group') {
// For group notifications, we have the time key directly
final timeKey = payloadMap['time'] as String?;
if (timeKey != null) {
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(timeKey);
printLog('🚫 Cancelled retries for time slot: $timeKey');
}
}
} catch (e) {
printLog('⚠️ Failed to cancel retry notifications: $e');
}
}
}