308 lines
9.3 KiB
Dart
308 lines
9.3 KiB
Dart
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<void> 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<void> 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<void> scheduleReminderAt(
|
|
ScheduleItemModel event,
|
|
DateTime fireAt,
|
|
) async {
|
|
await initialize();
|
|
await cancelEventReminder(event.id);
|
|
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
|
|
}
|
|
|
|
Future<void> 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<void> rebuildUpcomingReminders(
|
|
Iterable<ScheduleItemModel> 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<AndroidScheduleMode> _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<void> _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<void> _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<String, dynamic>);
|
|
} 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<void> handleNotificationResponse(NotificationResponse response) async {
|
|
final payloadRaw = response.payload;
|
|
if (payloadRaw == null || payloadRaw.isEmpty) {
|
|
return;
|
|
}
|
|
ReminderPayload payload;
|
|
try {
|
|
payload = ReminderPayload.fromJson(
|
|
Map<String, dynamic>.from(jsonDecode(payloadRaw) as Map),
|
|
);
|
|
} catch (_) {
|
|
debugPrint('failed to handle reminder notification response');
|
|
return;
|
|
}
|
|
|
|
ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload);
|
|
}
|
|
}
|