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,12 @@
enum ReminderAction { archive, snooze10m }
extension ReminderActionValue on ReminderAction {
String get value {
switch (this) {
case ReminderAction.archive:
return 'archive';
case ReminderAction.snooze10m:
return 'snooze10m';
}
}
}
@@ -0,0 +1,97 @@
class ReminderEventSnapshot {
const ReminderEventSnapshot({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
required this.reminderMinutes,
this.endAt,
this.location,
this.notes,
this.isArchived = false,
});
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final int? reminderMinutes;
final String? location;
final String? notes;
final bool isArchived;
}
class ReminderAlarm {
const ReminderAlarm({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
required this.reminderMinutes,
required this.fireAt,
required this.fireTimeBucket,
this.endAt,
this.location,
this.notes,
this.version = 1,
});
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final int reminderMinutes;
final DateTime fireAt;
final int fireTimeBucket;
final String? location;
final String? notes;
final int version;
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'title': title,
'startAt': startAt.toIso8601String(),
'endAt': endAt?.toIso8601String(),
'timezone': timezone,
'reminderMinutes': reminderMinutes,
'fireAt': fireAt.toIso8601String(),
'fireTimeBucket': fireTimeBucket,
'location': location,
'notes': notes,
'version': version,
};
}
factory ReminderAlarm.fromJson(Map<String, dynamic> json) {
return ReminderAlarm(
eventId: json['eventId'] as String,
title: json['title'] as String? ?? '',
startAt: DateTime.parse(json['startAt'] as String),
endAt: json['endAt'] == null
? null
: DateTime.parse(json['endAt'] as String),
timezone: json['timezone'] as String? ?? 'UTC',
reminderMinutes: json['reminderMinutes'] as int? ?? 0,
fireAt: DateTime.parse(json['fireAt'] as String),
fireTimeBucket: json['fireTimeBucket'] as int,
location: json['location'] as String?,
notes: json['notes'] as String?,
version: json['version'] as int? ?? 1,
);
}
}
class ReminderNotificationTap {
const ReminderNotificationTap({
required this.eventId,
required this.fireTimeBucket,
required this.payload,
});
final String eventId;
final int fireTimeBucket;
final Map<String, dynamic> payload;
}
@@ -1,182 +0,0 @@
class ReminderPayload {
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final String? location;
final String? notes;
final String? color;
final ReminderPayloadMode mode;
final List<String> aggregateIds;
final int? fireTimeBucket;
final int version;
const ReminderPayload({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
this.endAt,
this.location,
this.notes,
this.color,
this.mode = ReminderPayloadMode.single,
this.aggregateIds = const [],
this.fireTimeBucket,
this.version = 1,
});
ReminderPayload copyWith({
String? eventId,
String? title,
DateTime? startAt,
DateTime? endAt,
String? timezone,
String? location,
String? notes,
String? color,
ReminderPayloadMode? mode,
List<String>? aggregateIds,
int? fireTimeBucket,
int? version,
}) {
return ReminderPayload(
eventId: eventId ?? this.eventId,
title: title ?? this.title,
startAt: startAt ?? this.startAt,
endAt: endAt ?? this.endAt,
timezone: timezone ?? this.timezone,
location: location ?? this.location,
notes: notes ?? this.notes,
color: color ?? this.color,
mode: mode ?? this.mode,
aggregateIds: aggregateIds ?? this.aggregateIds,
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
version: version ?? this.version,
);
}
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'title': title,
'startAt': startAt.toIso8601String(),
'endAt': endAt?.toIso8601String(),
'timezone': timezone,
'location': location,
'notes': notes,
'color': color,
'mode': mode.value,
'aggregateIds': aggregateIds,
'fireTimeBucket': fireTimeBucket,
'version': version,
};
}
factory ReminderPayload.fromJson(Map<String, dynamic> json) {
final eventId = (json['eventId'] as String?) ?? '';
if (eventId.isEmpty) {
throw const FormatException('eventId is required');
}
final startAtRaw = json['startAt'] as String?;
if (startAtRaw == null || startAtRaw.isEmpty) {
throw const FormatException('startAt is required');
}
final parsedStartAt = DateTime.parse(startAtRaw);
final mode = ReminderPayloadMode.fromValue(
(json['mode'] as String?) ?? 'single',
);
final aggregateIds = (json['aggregateIds'] as List<dynamic>? ?? const [])
.map((item) => item.toString())
.toList();
if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
throw const FormatException('aggregateIds must contain at least 2 items');
}
return ReminderPayload(
eventId: eventId,
title: (json['title'] as String?) ?? '',
startAt: parsedStartAt,
endAt: json['endAt'] != null
? DateTime.parse(json['endAt'] as String)
: null,
timezone: (json['timezone'] as String?) ?? 'UTC',
location: json['location'] as String?,
notes: json['notes'] as String?,
color: json['color'] as String?,
mode: mode,
aggregateIds: aggregateIds,
fireTimeBucket: json['fireTimeBucket'] as int?,
version: (json['version'] as int?) ?? 1,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ReminderPayload &&
other.eventId == eventId &&
other.title == title &&
other.startAt == startAt &&
other.endAt == endAt &&
other.timezone == timezone &&
other.location == location &&
other.notes == notes &&
other.color == color &&
other.mode == mode &&
_listEquals(other.aggregateIds, aggregateIds) &&
other.fireTimeBucket == fireTimeBucket &&
other.version == version;
}
@override
int get hashCode {
return Object.hash(
eventId,
title,
startAt,
endAt,
timezone,
location,
notes,
color,
mode,
Object.hashAll(aggregateIds),
fireTimeBucket,
version,
);
}
}
enum ReminderPayloadMode {
single('single'),
aggregate('aggregate');
const ReminderPayloadMode(this.value);
final String value;
static ReminderPayloadMode fromValue(String raw) {
return ReminderPayloadMode.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderPayloadMode.single,
);
}
}
bool _listEquals(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) {
return false;
}
}
return true;
}
@@ -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}');
}