feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -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}');
|
||||
}
|
||||
Reference in New Issue
Block a user