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
+313
View File
@@ -0,0 +1,313 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../features/messages/data/apis/inbox_api.dart' show InboxApi;
import '../../features/messages/data/models/inbox_message.dart';
import '../../features/messages/data/repositories/inbox_repository.dart';
class InboxSyncStore extends ChangeNotifier {
InboxSyncStore({
required InboxRepository repository,
required InboxApi inboxApi,
}) : _repository = repository,
_inboxApi = inboxApi;
final InboxRepository _repository;
final InboxApi _inboxApi;
final Map<String, InboxMessage> _messagesById = <String, InboxMessage>{};
final Map<String, int> _messageVersionById = <String, int>{};
StreamSubscription<String>? _sseSubscription;
bool _started = false;
bool _disposed = false;
String? _activeUserId;
String? _lastEventId;
Object? _lastStreamError;
Object? get lastStreamError => _lastStreamError;
int get unreadCount => _messagesById.values.where((m) => !m.isRead).length;
List<InboxMessage> get unreadMessages {
final list = _messagesById.values.where((m) => !m.isRead).toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return list;
}
List<InboxMessage> get readMessages {
final list = _messagesById.values.where((m) => m.isRead).toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return list;
}
Future<void> ensureStarted() async {
if (_activeUserId == null) {
return;
}
if (_started) {
return;
}
_started = true;
await refreshSnapshot();
unawaited(_streamLoop());
}
Future<void> refreshSnapshot() async {
final result = await Future.wait([
_repository.getMessages(isRead: false, forceRefresh: true),
_repository.getMessages(isRead: true, forceRefresh: true),
]);
final merged = <InboxMessage>[...result[0], ...result[1]];
_messagesById
..clear()
..addEntries(merged.map((m) => MapEntry(m.id, m)));
_messageVersionById
..clear()
..addEntries(
merged.map((m) => MapEntry(m.id, m.createdAt.millisecondsSinceEpoch)),
);
_notifyIfActive();
}
Future<void> stop() async {
_started = false;
final sub = _sseSubscription;
_sseSubscription = null;
await sub?.cancel();
}
Future<void> resetForUser(String? userId) async {
await stop();
_activeUserId = userId;
_lastEventId = null;
_lastStreamError = null;
_messagesById.clear();
_messageVersionById.clear();
if (userId != null) {
await ensureStarted();
}
_notifyIfActive();
}
@override
void dispose() {
_disposed = true;
unawaited(stop());
super.dispose();
}
Future<void> _streamLoop() async {
var retry = 0;
while (_started && !_disposed) {
try {
final lines = await _inboxApi.streamEvents(lastEventId: _lastEventId);
await _consumeLines(lines);
retry = 0;
if (_lastStreamError != null) {
_lastStreamError = null;
_notifyIfActive();
}
} catch (error) {
retry += 1;
_lastStreamError = error;
_notifyIfActive();
}
if (!_started || _disposed) {
break;
}
final waitMs = (retry * 300).clamp(300, 5000);
await Future<void>.delayed(Duration(milliseconds: waitMs));
}
}
Future<void> _consumeLines(Stream<String> lines) async {
final completer = Completer<void>();
String? eventId;
String? eventType;
final dataBuffer = StringBuffer();
void flushFrame() {
if (dataBuffer.isEmpty) {
eventId = null;
eventType = null;
return;
}
final raw = dataBuffer.toString();
dataBuffer.clear();
if (eventId != null && eventId!.isNotEmpty) {
_lastEventId = eventId;
}
if (eventType == null || eventType == 'INBOX_MESSAGE') {
eventId = null;
eventType = null;
return;
}
try {
final jsonValue = jsonDecode(raw);
if (jsonValue is Map<String, dynamic>) {
_applyEnvelope(jsonValue);
}
} catch (error) {
_lastStreamError = StateError(
'Failed to parse inbox SSE frame: $error',
);
_notifyIfActive();
}
eventId = null;
eventType = null;
}
late final StreamSubscription<String> subscription;
subscription = lines.listen(
(line) {
if (!_started || _disposed) {
if (!completer.isCompleted) {
completer.complete();
}
unawaited(subscription.cancel());
return;
}
if (line.isEmpty) {
flushFrame();
return;
}
if (line.startsWith(':')) {
return;
}
if (line.startsWith('id:')) {
eventId = line.substring(3).trim();
return;
}
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
return;
}
if (line.startsWith('data:')) {
final fragment = line.substring(5).trim();
if (dataBuffer.isNotEmpty) {
dataBuffer.write('\n');
}
dataBuffer.write(fragment);
}
},
onError: (Object error, StackTrace stackTrace) {
_lastStreamError = error;
_notifyIfActive();
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
},
onDone: () {
flushFrame();
if (!completer.isCompleted) {
completer.complete();
}
},
cancelOnError: false,
);
_sseSubscription = subscription;
await completer.future;
if (identical(_sseSubscription, subscription)) {
_sseSubscription = null;
}
}
void _applyEnvelope(Map<String, dynamic> envelope) {
final messageId = envelope['message_id'];
final op = envelope['op'];
final version = envelope['version'];
if (messageId is! String || op is! String || version is! int) {
return;
}
final currentVersion = _messageVersionById[messageId];
if (currentVersion != null && version <= currentVersion) {
return;
}
final data = envelope['data'];
if (op == 'snapshot_required') {
_messageVersionById[messageId] = version;
unawaited(refreshSnapshot());
return;
}
if (data is! Map<String, dynamic>) {
return;
}
switch (op) {
case 'created':
final messageRaw = data['message'];
if (messageRaw is! Map<String, dynamic>) {
return;
}
final message = InboxMessage.fromJson(messageRaw);
_messagesById[message.id] = message;
case 'read_changed':
final existing = _messagesById[messageId];
final isRead = data['is_read'];
if (existing == null || isRead is! bool) {
return;
}
_messagesById[messageId] = InboxMessage(
id: existing.id,
recipientId: existing.recipientId,
senderId: existing.senderId,
messageType: existing.messageType,
scheduleItemId: existing.scheduleItemId,
friendshipId: existing.friendshipId,
content: existing.content,
isRead: isRead,
status: existing.status,
createdAt: existing.createdAt,
);
case 'status_changed':
final existing = _messagesById[messageId];
final status = data['status'];
if (existing == null || status is! String) {
return;
}
_messagesById[messageId] = InboxMessage(
id: existing.id,
recipientId: existing.recipientId,
senderId: existing.senderId,
messageType: existing.messageType,
scheduleItemId: existing.scheduleItemId,
friendshipId: existing.friendshipId,
content: existing.content,
isRead: existing.isRead,
status: _statusFromApi(status),
createdAt: existing.createdAt,
);
default:
return;
}
_messageVersionById[messageId] = version;
_notifyIfActive();
}
InboxMessageStatus _statusFromApi(String raw) {
switch (raw) {
case 'pending':
return InboxMessageStatus.pending;
case 'accepted':
return InboxMessageStatus.accepted;
case 'rejected':
return InboxMessageStatus.rejected;
case 'dismissed':
return InboxMessageStatus.dismissed;
default:
throw StateError('Unsupported inbox message status: $raw');
}
}
void _notifyIfActive() {
if (_disposed) {
return;
}
notifyListeners();
}
}
@@ -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}');
}