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? _navigatorKey; void initialize(GlobalKey navigatorKey) { _navigatorKey = navigatorKey; } Future 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 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? _decodePayload(String? payload) { if (payload == null || payload.isEmpty) return null; // Try JSON first try { final map = jsonDecode(payload); if (map is Map) 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 _routeFromPayload(Map? 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(); 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 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 _scheduleSnoozeFromPayload(Map? 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(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 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 _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(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 _cancelRetryNotificationsForResponse(Map? 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?; 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'); } } }