feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/reminder_alarm.dart';
|
||||
import 'reminder_scheduler_service.dart';
|
||||
|
||||
class ReminderNotificationRouter {
|
||||
ReminderNotificationRouter({required ReminderSchedulerService scheduler})
|
||||
: _scheduler = scheduler;
|
||||
|
||||
final ReminderSchedulerService _scheduler;
|
||||
final StreamController<ReminderNotificationTap> _controller =
|
||||
StreamController<ReminderNotificationTap>.broadcast();
|
||||
|
||||
Stream<ReminderNotificationTap> get taps => _controller.stream;
|
||||
|
||||
Future<void> start() async {
|
||||
await _scheduler.initialize(onTap: _controller.add);
|
||||
final launchTap = await _scheduler.consumeLaunchTap();
|
||||
if (launchTap != null) {
|
||||
_controller.add(launchTap);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'reminder_scheduler_service.dart';
|
||||
|
||||
class ReminderPermissionService {
|
||||
const ReminderPermissionService({required ReminderSchedulerService scheduler})
|
||||
: _scheduler = scheduler;
|
||||
|
||||
final ReminderSchedulerService _scheduler;
|
||||
|
||||
Future<bool> initializeAtBoot() async {
|
||||
await _scheduler.initialize();
|
||||
return _scheduler.requestNotificationPermission();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import '../models/reminder_payload.dart';
|
||||
|
||||
class ReminderQueueManager {
|
||||
ReminderPayload? _currentPayload;
|
||||
final List<ReminderPayload> _pending = [];
|
||||
|
||||
void enqueueFromClick(ReminderPayload payload) {
|
||||
_currentPayload = payload;
|
||||
}
|
||||
|
||||
void enqueuePending(List<ReminderPayload> payloads) {
|
||||
payloads.sort((a, b) => a.startAt.compareTo(b.startAt));
|
||||
_pending.addAll(payloads);
|
||||
}
|
||||
|
||||
ReminderPayload? get currentPayload => _currentPayload;
|
||||
|
||||
bool get isEmpty => _currentPayload == null && _pending.isEmpty;
|
||||
|
||||
void dequeueCurrent() {
|
||||
_currentPayload = null;
|
||||
if (_pending.isNotEmpty) {
|
||||
_currentPayload = _pending.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_currentPayload = null;
|
||||
_pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../models/reminder_alarm.dart';
|
||||
import 'reminder_scheduler_service.dart';
|
||||
|
||||
class ReminderReconcileService {
|
||||
const ReminderReconcileService({required ReminderSchedulerService scheduler})
|
||||
: _scheduler = scheduler;
|
||||
|
||||
final ReminderSchedulerService _scheduler;
|
||||
|
||||
Future<void> reconcileEvent(
|
||||
ReminderEventSnapshot event, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
if (event.isArchived || event.reminderMinutes == null) {
|
||||
await _scheduler.cancelEventReminders(event.eventId);
|
||||
return;
|
||||
}
|
||||
await _scheduler.upsertEventReminders(event, now: now);
|
||||
}
|
||||
|
||||
Future<void> reconcileEvents(
|
||||
List<ReminderEventSnapshot> events, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
for (final event in events) {
|
||||
await reconcileEvent(event, now: now);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> archiveAndCancel(String eventId) {
|
||||
return _scheduler.cancelEventReminders(eventId);
|
||||
}
|
||||
|
||||
Future<void> snooze10m(ReminderEventSnapshot event) async {
|
||||
await _scheduler.cancelEventReminders(event.eventId);
|
||||
await _scheduler.scheduleSingleSnooze(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
import '../models/reminder_alarm.dart';
|
||||
|
||||
class ReminderSchedulerService {
|
||||
ReminderSchedulerService({FlutterLocalNotificationsPlugin? plugin})
|
||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const String _channelId = 'calendar_reminder_alarm_v2';
|
||||
static const String _channelName = 'Schedule alarm';
|
||||
static const String _channelDescription =
|
||||
'Alarm-style notifications for scheduled events';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
final List<void Function(ReminderNotificationTap tap)> _tapCallbacks = [];
|
||||
ReminderNotificationTap? _launchTap;
|
||||
bool _initialized = false;
|
||||
bool _tzInitialized = false;
|
||||
|
||||
Future<void> initialize({
|
||||
void Function(ReminderNotificationTap tap)? onTap,
|
||||
}) async {
|
||||
if (onTap != null && !_tapCallbacks.contains(onTap)) {
|
||||
_tapCallbacks.add(onTap);
|
||||
}
|
||||
|
||||
if (!_tzInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_tzInitialized = true;
|
||||
}
|
||||
|
||||
final android = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await android?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.max,
|
||||
),
|
||||
);
|
||||
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const iosSettings = DarwinInitializationSettings();
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final tap = _parseTap(response.payload);
|
||||
if (tap != null) {
|
||||
for (final callback in _tapCallbacks) {
|
||||
callback(tap);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundTap,
|
||||
);
|
||||
|
||||
final launchDetails = await _plugin.getNotificationAppLaunchDetails();
|
||||
if (launchDetails?.didNotificationLaunchApp ?? false) {
|
||||
_launchTap = _parseTap(launchDetails?.notificationResponse?.payload);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<ReminderNotificationTap?> consumeLaunchTap() async {
|
||||
final value = _launchTap;
|
||||
_launchTap = null;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<bool> requestNotificationPermission() async {
|
||||
await _ensureInitialized();
|
||||
final android = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final ios = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macos = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
final androidGranted = await android?.requestNotificationsPermission();
|
||||
final iosGranted = await ios?.requestPermissions(alert: true, sound: true);
|
||||
final macosGranted = await macos?.requestPermissions(
|
||||
alert: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
return (androidGranted ?? true) &&
|
||||
(iosGranted ?? true) &&
|
||||
(macosGranted ?? true);
|
||||
}
|
||||
|
||||
Future<void> upsertEventReminders(
|
||||
ReminderEventSnapshot event, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
await cancelEventReminders(event.eventId);
|
||||
final alarms = buildAlarmsForEvent(event, now: now);
|
||||
for (final alarm in alarms) {
|
||||
await _scheduleAlarm(alarm);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scheduleSingleSnooze(
|
||||
ReminderEventSnapshot event, {
|
||||
Duration delay = const Duration(minutes: 10),
|
||||
DateTime? now,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
final current = now ?? DateTime.now();
|
||||
final fireAt = current.add(delay);
|
||||
if (event.endAt != null && fireAt.isAfter(event.endAt!)) {
|
||||
return;
|
||||
}
|
||||
final alarm = ReminderAlarm(
|
||||
eventId: event.eventId,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.reminderMinutes ?? 0,
|
||||
fireAt: fireAt,
|
||||
fireTimeBucket: _toBucket(fireAt),
|
||||
location: event.location,
|
||||
notes: event.notes,
|
||||
);
|
||||
await _scheduleAlarm(alarm);
|
||||
}
|
||||
|
||||
Future<void> cancelEventReminders(String eventId) async {
|
||||
await _ensureInitialized();
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
for (final request in pending) {
|
||||
final payloadRaw = request.payload;
|
||||
if (payloadRaw == null || payloadRaw.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final decoded = _parsePayload(payloadRaw);
|
||||
if (decoded['eventId'] == eventId) {
|
||||
await _plugin.cancel(request.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelAllReminders() {
|
||||
return _ensureInitialized().then((_) => _plugin.cancelAll());
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialized() {
|
||||
if (_initialized) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
return initialize();
|
||||
}
|
||||
|
||||
static List<ReminderAlarm> buildAlarmsForEvent(
|
||||
ReminderEventSnapshot event, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
if (event.isArchived) {
|
||||
return const [];
|
||||
}
|
||||
final reminderMinutes = event.reminderMinutes;
|
||||
if (reminderMinutes == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final current = now ?? DateTime.now();
|
||||
final remindAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
|
||||
final endAt = event.endAt;
|
||||
|
||||
if (endAt != null && current.isAfter(endAt)) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final List<ReminderAlarm> alarms = [];
|
||||
DateTime fireAt;
|
||||
|
||||
if (current.isBefore(remindAt)) {
|
||||
fireAt = remindAt;
|
||||
} else {
|
||||
fireAt = current.add(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
var iterations = 0;
|
||||
while (iterations < 144) {
|
||||
if (endAt != null && fireAt.isAfter(endAt)) {
|
||||
break;
|
||||
}
|
||||
alarms.add(
|
||||
ReminderAlarm(
|
||||
eventId: event.eventId,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: reminderMinutes,
|
||||
fireAt: fireAt,
|
||||
fireTimeBucket: _toBucket(fireAt),
|
||||
location: event.location,
|
||||
notes: event.notes,
|
||||
),
|
||||
);
|
||||
|
||||
if (endAt == null) {
|
||||
break;
|
||||
}
|
||||
fireAt = fireAt.add(const Duration(minutes: 10));
|
||||
iterations += 1;
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
|
||||
Future<void> _scheduleAlarm(ReminderAlarm alarm) async {
|
||||
final location = _safeLocation(alarm.timezone);
|
||||
final fireAt = tz.TZDateTime.from(alarm.fireAt, location);
|
||||
final payload = jsonEncode(alarm.toJson());
|
||||
final id = _notificationId(alarm.eventId, alarm.fireTimeBucket);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
category: AndroidNotificationCategory.alarm,
|
||||
timeoutAfter: 15000,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
fullScreenIntent: false,
|
||||
ticker: 'calendar-reminder',
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
);
|
||||
|
||||
try {
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
alarm.title,
|
||||
_buildBody(alarm),
|
||||
fireAt,
|
||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
payload: payload,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} on PlatformException {
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
alarm.title,
|
||||
_buildBody(alarm),
|
||||
fireAt,
|
||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
payload: payload,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _buildBody(ReminderAlarm alarm) {
|
||||
final startLabel =
|
||||
'${alarm.startAt.hour.toString().padLeft(2, '0')}:${alarm.startAt.minute.toString().padLeft(2, '0')}';
|
||||
if (alarm.location == null || alarm.location!.isEmpty) {
|
||||
return '开始时间 $startLabel';
|
||||
}
|
||||
return '开始时间 $startLabel · ${alarm.location}';
|
||||
}
|
||||
|
||||
ReminderNotificationTap? _parseTap(String? rawPayload) {
|
||||
if (rawPayload == null || rawPayload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final decoded = _parsePayload(rawPayload);
|
||||
final eventId = decoded['eventId'] as String?;
|
||||
final fireBucket = decoded['fireTimeBucket'] as int?;
|
||||
if (eventId == null || fireBucket == null) {
|
||||
return null;
|
||||
}
|
||||
return ReminderNotificationTap(
|
||||
eventId: eventId,
|
||||
fireTimeBucket: fireBucket,
|
||||
payload: decoded,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _parsePayload(String rawPayload) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawPayload);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
static int _toBucket(DateTime value) {
|
||||
return value.millisecondsSinceEpoch ~/ 60000;
|
||||
}
|
||||
|
||||
static int _notificationId(String eventId, int fireTimeBucket) {
|
||||
final input = '$eventId|$fireTimeBucket';
|
||||
var hash = 0x811c9dc5;
|
||||
for (final unit in input.codeUnits) {
|
||||
hash ^= unit;
|
||||
hash = (hash * 0x01000193) & 0x7fffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
static tz.Location _safeLocation(String timezone) {
|
||||
try {
|
||||
return tz.getLocation(timezone);
|
||||
} catch (_) {
|
||||
return tz.UTC;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void _onBackgroundTap(NotificationResponse response) {
|
||||
debugPrint('Background reminder tap received: ${response.payload}');
|
||||
}
|
||||
Reference in New Issue
Block a user