import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; import '../../core/l10n/l10n.dart'; import '../models/reminder_payload.dart'; import '../repositories/models/schedule_item_model.dart'; import 'reminder_notification_callbacks.dart'; class LocalNotificationService { final FlutterLocalNotificationsPlugin _plugin; bool _initialized = false; LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); Future initialize() async { if (_initialized) { return; } tz_data.initializeTimeZones(); const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const ios = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, requestSoundPermission: false, ); final settings = InitializationSettings(android: android, iOS: ios); await _plugin.initialize( settings, onDidReceiveNotificationResponse: ReminderNotificationCallbacks.onForegroundResponse, onDidReceiveBackgroundNotificationResponse: reminderNotificationTapBackground, ); final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); await androidImpl?.requestNotificationsPermission(); await androidImpl?.requestExactAlarmsPermission(); await androidImpl?.requestFullScreenIntentPermission(); final iosImpl = _plugin .resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin >(); await iosImpl?.requestPermissions(alert: true, badge: true, sound: true); _initialized = true; } Future upsertEventReminder(ScheduleItemModel event) async { await initialize(); if (event.status != ScheduleStatus.active || event.metadata?.reminderMinutes == null) { await cancelEventReminder(event.id); return; } final now = DateTime.now(); final reminderMinutes = event.metadata?.reminderMinutes ?? 0; final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); if (fireAt.isBefore(now)) { await cancelEventReminder(event.id); return; } await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } Future scheduleReminderAt( ScheduleItemModel event, DateTime fireAt, ) async { await initialize(); await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } Future cancelEventReminder(String eventId) async { await initialize(); final pending = await _plugin.pendingNotificationRequests(); for (final request in pending) { final payload = _decodePayload(request.payload); if (payload == null) { continue; } if (payload.eventId == eventId || payload.aggregateIds.contains(eventId)) { await _plugin.cancel(request.id); } } await _plugin.cancel(_notificationIdForEvent(eventId)); } Future rebuildUpcomingReminders( Iterable events, ) async { await initialize(); for (final event in events) { await upsertEventReminder(event); } } int _notificationIdForEvent(String eventId) { return eventId.hashCode & 0x7fffffff; } int _notificationIdForEventCycle( String eventId, DateTime fireAt, ReminderPayloadMode mode, ) { final cycleMinute = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff; } Future _resolveAndroidScheduleMode() async { final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidImpl == null) { return AndroidScheduleMode.exactAllowWhileIdle; } final canScheduleExact = await androidImpl.canScheduleExactNotifications() ?? false; return canScheduleExact ? AndroidScheduleMode.exactAllowWhileIdle : AndroidScheduleMode.inexactAllowWhileIdle; } NotificationDetails _buildNotificationDetails(DateTime fireAt) { return NotificationDetails( android: AndroidNotificationDetails( 'calendar_alarm_channel_v2', L10n.current.notificationChannelName, channelDescription: L10n.current.notificationChannelDescription, importance: Importance.max, priority: Priority.max, category: AndroidNotificationCategory.alarm, audioAttributesUsage: AudioAttributesUsage.alarm, fullScreenIntent: true, playSound: true, enableVibration: true, vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), timeoutAfter: 30000, autoCancel: true, groupKey: _getGroupKey(fireAt), ), iOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, presentBadge: true, threadIdentifier: _getThreadIdentifier(fireAt), ), ); } String _getThreadIdentifier(DateTime fireAt) { final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; return 'calendar_reminder_$bucket'; } String _getGroupKey(DateTime fireAt) { final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds; return 'com.socialapp.calendar.$bucket'; } Future _scheduleSingleReminder({ required ScheduleItemModel event, required DateTime fireAt, }) async { final notificationId = _notificationIdForEventCycle( event.id, fireAt, ReminderPayloadMode.single, ); final payload = ReminderPayload( eventId: event.id, title: event.title, startAt: event.startAt, endAt: event.endAt, timezone: event.timezone, location: event.metadata?.location, notes: event.metadata?.notes, color: event.metadata?.color, mode: ReminderPayloadMode.single, fireTimeBucket: fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds, version: 1, ); final details = _buildNotificationDetails(fireAt); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); final mode = await _resolveAndroidScheduleMode(); try { await _plugin.zonedSchedule( notificationId, event.title, _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), scheduledAt, details, payload: jsonEncode(payload.toJson()), androidScheduleMode: mode, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, ); } catch (_) { await _plugin.zonedSchedule( notificationId, event.title, _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), scheduledAt, details, payload: jsonEncode(payload.toJson()), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, ); } } Future _scheduleRemindersFrom({ required ScheduleItemModel event, required DateTime firstFireAt, }) async { final endAt = event.endAt; var cursor = firstFireAt; if (endAt == null) { await _scheduleSingleReminder(event: event, fireAt: cursor); return; } while (cursor.isBefore(endAt)) { await _scheduleSingleReminder(event: event, fireAt: cursor); cursor = cursor.add(const Duration(minutes: 10)); } } ReminderPayload? _decodePayload(String? raw) { if (raw == null || raw.isEmpty) { return null; } try { return ReminderPayload.fromJson(jsonDecode(raw) as Map); } catch (_) { return null; } } String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { final when = reminderMinutes == 0 ? L10n.current.notificationStartsNow : L10n.current.notificationStartsInMinutes(reminderMinutes); final location = event.metadata?.location; final notes = event.metadata?.notes; final buffer = StringBuffer(when); if (location != null && location.isNotEmpty) { buffer.write('\n${L10n.current.notificationLocation(location)}'); } if (notes != null && notes.isNotEmpty) { final preview = notes.length > 30 ? '${notes.substring(0, 30)}...' : notes; buffer.write('\n${L10n.current.notificationNotes(preview)}'); } return buffer.toString(); } Future handleNotificationResponse(NotificationResponse response) async { final payloadRaw = response.payload; if (payloadRaw == null || payloadRaw.isEmpty) { return; } ReminderPayload payload; try { payload = ReminderPayload.fromJson( Map.from(jsonDecode(payloadRaw) as Map), ); } catch (_) { debugPrint('failed to handle reminder notification response'); return; } ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload); } }