feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
@@ -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}');
}