notification overhaul

This commit is contained in:
2025-08-30 00:12:29 +02:00
parent 9ae2bb5654
commit 6dccac6124
25 changed files with 1313 additions and 3947 deletions

View File

@@ -0,0 +1,359 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:supplements/logging.dart';
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 '../models/supplement.dart';
/// A minimal notification scheduler focused purely on:
/// - Initialization
/// - Permission requests
/// - Scheduling daily notifications for supplements
/// - Canceling scheduled notifications
///
/// No retries, no snooze, no database logic.
class SimpleNotificationService {
SimpleNotificationService._internal();
static final SimpleNotificationService instance =
SimpleNotificationService._internal();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
// Channel IDs
static const String _channelDailyId = 'supplement_reminders';
static const String _channelDailyName = 'Supplement Reminders';
static const String _channelDailyDescription = 'Daily supplement intake reminders';
/// Initialize timezone data and the notifications plugin.
///
/// Note: This does not request runtime permissions. Call [requestPermissions]
/// to prompt the user for notification permissions.
Future<void> initialize() async {
if (_initialized) return;
// Initialize timezone database and set a sane default.
// If you prefer, replace 'Europe/Amsterdam' with your preferred default,
// or integrate a platform timezone resolver.
tzdata.initializeTimeZones();
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
final iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
notificationCategories: [
DarwinNotificationCategory(
'single',
actions: [
DarwinNotificationAction.plain('take_single', 'Take'),
],
),
DarwinNotificationCategory(
'group',
actions: [
DarwinNotificationAction.plain('take_group', 'Take All'),
],
),
],
);
const linuxSettings = LinuxInitializationSettings(
defaultActionName: 'Open notification',
);
final initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
linux: linuxSettings,
);
await _plugin.initialize(
initSettings,
onDidReceiveNotificationResponse: (response) {
NotificationRouter.instance.handleNotificationResponse(response);
},
);
_initialized = true;
}
/// Request runtime notification permissions.
///
/// On Android 13+, this will prompt for POST_NOTIFICATIONS. On older Android,
/// this is a no-op. On iOS, it requests alert/badge/sound.
Future<bool> requestPermissions() async {
// Ensure the plugin is ready before requesting permissions.
if (!_initialized) {
await initialize();
}
bool granted = true;
final androidPlugin = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
final ok = await androidPlugin.requestNotificationsPermission();
granted = granted && (ok == true);
}
final iosPlugin = _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
final ok = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
granted = granted && (ok == true);
}
return granted;
}
/// Schedule grouped daily reminders for a list of supplements.
///
/// - Groups supplements by HH:mm and schedules one notification per time slot.
/// - Uses daily recurrence via matchDateTimeComponents: DateTimeComponents.time.
/// - Keeps iOS pending notifications well below the 64 limit.
///
/// IDs:
/// - Group ID per time slot: 40000 + hour*60 + minute.
/// - Stable and predictable for cancel/update operations.
Future<void> scheduleDailyGroupedReminders(List<Supplement> supplements) async {
if (!_initialized) {
await initialize();
}
printLog('🛠 scheduleDailyGroupedReminders -> ${supplements.length} supplements');
// Clear everything first to avoid duplicates or stale schedules
await cancelAll();
printLog('🧹 Cleared all existing notifications before scheduling groups');
// Build groups: HH:mm -> list<Supplement>
final Map<String, List<Supplement>> groups = {};
for (final s in supplements.where((s) => s.isActive && s.reminderTimes.isNotEmpty && s.id != null)) {
for (final timeStr in s.reminderTimes) {
final parts = timeStr.split(':');
if (parts.length != 2) continue;
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null) continue;
final key = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
groups.putIfAbsent(key, () => []).add(s);
}
}
printLog('⏱ Found ${groups.length} time group(s): ${groups.keys.toList()}');
if (groups.isEmpty) {
printLog('⚠️ No groups to schedule (no active supplements with reminder times)');
return;
}
// Schedule one notification per time group
for (final entry in groups.entries) {
final timeKey = entry.key; // HH:mm
final items = entry.value;
final parts = timeKey.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final when = _nextInstanceOfTime(hour, minute);
final id = 40000 + (hour * 60) + minute;
final count = items.length;
final title = count == 1
? 'Time for ${items.first.name}'
: 'Time for $count supplements';
// Build body that lists each supplement concisely
final bodyLines = items.map((s) {
final units = s.numberOfUnits;
final unitType = s.unitType;
final perUnit = s.ingredientsPerUnit;
return '${s.name}$units $unitType (${perUnit})';
}).toList();
final body = bodyLines.join('\n');
printLog('📅 Scheduling group $timeKey (count=$count) id=$id');
printLog('🕒 Now=${tz.TZDateTime.now(tz.local)} | When=$when');
// 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});
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,
),
],
);
final iosDetails = DarwinNotificationDetails(
categoryIdentifier: isSingle ? 'single' : 'group',
);
await _plugin.zonedSchedule(
id,
title,
isSingle ? body : 'Tap to see details',
when,
NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
payload: payloadStr,
);
printLog('✅ Scheduled group $timeKey with ID $id');
}
// Log what the system reports as pending
try {
final pending = await _plugin.pendingNotificationRequests();
printLog('📋 Pending notifications after scheduling: ${pending.length}');
for (final p in pending) {
printLog(' - ID=${p.id}, Title=${p.title}, BodyLen=${p.body?.length ?? 0}');
}
} catch (e) {
printLog('⚠️ Could not fetch pending notifications: $e');
}
}
/// Convenience to schedule grouped reminders for today and tomorrow.
///
/// For iOSs 64 limit, we stick to one day for recurring (matchDateTimeComponents)
/// which already repeats every day without needing to schedule future dates.
/// If you want an extra safety net, you could schedule tomorrows one-offs,
/// but with daily components this is unnecessary and risks hitting iOS limits.
Future<void> scheduleDailyGroupedRemindersSafe(List<Supplement> supplements) async {
// For now, just schedule todays recurring groups.
await scheduleDailyGroupedReminders(supplements);
}
/// Cancel all scheduled reminders for a given [supplementId].
///
/// We assume up to 100 slots per supplement (00-99). This is simple and safe.
Future<void> cancelSupplementReminders(int supplementId) async {
if (!_initialized) {
await initialize();
}
for (int i = 0; i < 100; i++) {
await _plugin.cancel(supplementId * 100 + i);
}
}
/// Cancel all scheduled notifications.
Future<void> cancelAll() async {
if (!_initialized) {
await initialize();
}
await _plugin.cancelAll();
}
/// Show an immediate notification. Useful for quick diagnostics.
Future<void> showInstant({
required String title,
required String body,
String? payload,
}) async {
if (!_initialized) {
await initialize();
}
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(),
),
payload: payload,
);
}
Future<NotificationAppLaunchDetails?> getLaunchDetails() async {
if (!_initialized) {
await initialize();
}
try {
final details = await _plugin.getNotificationAppLaunchDetails();
return details;
} catch (e) {
printLog('⚠️ getLaunchDetails error: $e');
return null;
}
}
/// Helper to compute the next instance of [hour]:[minute] in the local tz.
tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
final now = tz.TZDateTime.now(tz.local);
var scheduled =
tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
if (scheduled.isBefore(now)) {
scheduled = scheduled.add(const Duration(days: 1));
printLog('⏭ Scheduling for tomorrow at ${scheduled.toString()} (${scheduled.timeZoneName})');
} else {
printLog('⏲ Scheduling for today at ${scheduled.toString()} (${scheduled.timeZoneName})');
}
return scheduled;
}
/// Debug helper to fetch and log all pending notifications.
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
if (!_initialized) {
await initialize();
}
final list = await _plugin.pendingNotificationRequests();
printLog('🧾 getPendingNotifications -> ${list.length} pending');
for (final p in list) {
printLog(' • ID=${p.id}, Title=${p.title}, Payload=${p.payload}');
}
return list;
}
}