mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
348 lines
12 KiB
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');
|
|
}
|
|
}
|
|
}
|