mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
notification overhaul
This commit is contained in:
359
lib/services/simple_notification_service.dart
Normal file
359
lib/services/simple_notification_service.dart
Normal 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 iOS’s 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 tomorrow’s 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 today’s 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user