feat: 实现日历提醒 in-app fallback 机制及通知服务重构

This commit is contained in:
zl-q
2026-03-20 01:30:34 +08:00
parent 7fd536e976
commit d574128815
55 changed files with 4565 additions and 647 deletions
@@ -1,17 +1,23 @@
enum ReminderAction {
cancel('cancel'),
snooze10m('snooze_10m'),
timeout30s('timeout_30s'),
autoArchive('auto_archive');
archive('archive'),
snooze10m('snooze10m');
const ReminderAction(this.value);
final String value;
static ReminderAction fromValue(String raw) {
return ReminderAction.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderAction.timeout30s,
);
switch (raw) {
case 'archive':
case 'cancel':
case 'auto_archive':
return ReminderAction.archive;
case 'snooze10m':
case 'snooze_10m':
case 'timeout_30s':
return ReminderAction.snooze10m;
default:
throw ArgumentError.value(raw, 'raw', 'Unsupported reminder action');
}
}
}
@@ -9,6 +9,7 @@ class ReminderPayload {
final String? color;
final ReminderPayloadMode mode;
final List<String> aggregateIds;
final int? fireTimeBucket;
final int version;
const ReminderPayload({
@@ -22,6 +23,7 @@ class ReminderPayload {
this.color,
this.mode = ReminderPayloadMode.single,
this.aggregateIds = const [],
this.fireTimeBucket,
this.version = 1,
});
@@ -36,6 +38,7 @@ class ReminderPayload {
String? color,
ReminderPayloadMode? mode,
List<String>? aggregateIds,
int? fireTimeBucket,
int? version,
}) {
return ReminderPayload(
@@ -49,6 +52,7 @@ class ReminderPayload {
color: color ?? this.color,
mode: mode ?? this.mode,
aggregateIds: aggregateIds ?? this.aggregateIds,
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
version: version ?? this.version,
);
}
@@ -65,6 +69,7 @@ class ReminderPayload {
'color': color,
'mode': mode.value,
'aggregateIds': aggregateIds,
'fireTimeBucket': fireTimeBucket,
'version': version,
};
}
@@ -104,6 +109,7 @@ class ReminderPayload {
color: json['color'] as String?,
mode: mode,
aggregateIds: aggregateIds,
fireTimeBucket: json['fireTimeBucket'] as int?,
version: (json['version'] as int?) ?? 1,
);
}
@@ -124,6 +130,7 @@ class ReminderPayload {
other.color == color &&
other.mode == mode &&
_listEquals(other.aggregateIds, aggregateIds) &&
other.fireTimeBucket == fireTimeBucket &&
other.version == version;
}
@@ -140,6 +147,7 @@ class ReminderPayload {
color,
mode,
Object.hashAll(aggregateIds),
fireTimeBucket,
version,
);
}
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
typedef SetStringListFn = Future<bool> Function(String key, List<String> value);
class ReminderActionDedupeStore {
static const String _key = 'calendar_reminder_action_dedupe_v1';
static const int _maxEntries = 512;
final SharedPreferences _prefs;
final SetStringListFn _setStringList;
Future<void> _queue = Future<void>.value();
ReminderActionDedupeStore(
SharedPreferences prefs, {
SetStringListFn? setStringList,
}) : _prefs = prefs,
_setStringList = setStringList ?? prefs.setStringList;
Future<bool> markIfNew(String actionExecutionId) async {
final completer = Completer<bool>();
_queue = _queue
.then((_) async {
completer.complete(await _markIfNewInternal(actionExecutionId));
})
.catchError((_) {
if (!completer.isCompleted) {
completer.complete(false);
}
});
return completer.future;
}
Future<bool> _markIfNewInternal(String actionExecutionId) async {
final current = List<String>.from(
_prefs.getStringList(_key) ?? const <String>[],
);
if (current.contains(actionExecutionId)) {
return false;
}
current.add(actionExecutionId);
if (current.length > _maxEntries) {
current.removeRange(0, current.length - _maxEntries);
}
final saved = await _setStringList(_key, current);
return saved;
}
}
@@ -27,19 +27,20 @@ class ReminderActionExecutor {
required ReminderPayload payload,
}) async {
final ids = payload.mode == ReminderPayloadMode.aggregate
? payload.aggregateIds
? (payload.aggregateIds.isNotEmpty
? payload.aggregateIds
: <String>[payload.eventId])
: <String>[payload.eventId];
if (action == ReminderAction.cancel) {
if (action == ReminderAction.archive) {
for (final id in ids) {
await _notificationService.cancelEventReminder(id);
await _archiveEvent(id, ReminderAction.cancel);
await _archiveEvent(id, ReminderAction.archive);
}
return;
}
if (action == ReminderAction.snooze10m ||
action == ReminderAction.timeout30s) {
if (action == ReminderAction.snooze10m) {
for (final id in ids) {
await _snoozeEvent(id);
}
@@ -50,7 +51,6 @@ class ReminderActionExecutor {
final pending = await _outboxStore.listPending();
for (final item in pending) {
if (item.targetStatus != 'archived') {
await _outboxStore.markDone(item.opId);
continue;
}
try {
@@ -71,14 +71,14 @@ class ReminderActionExecutor {
final endAt = event.endAt;
if (endAt != null && !now.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId, ReminderAction.autoArchive);
await _archiveEvent(eventId, ReminderAction.archive);
return;
}
final nextAt = now.add(const Duration(minutes: 10));
if (endAt != null && !nextAt.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId, ReminderAction.autoArchive);
await _archiveEvent(eventId, ReminderAction.archive);
return;
}
@@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:collection';
typedef ReminderColdStartReplayTask = Future<void> Function();
typedef ReminderColdStartTaskErrorHandler =
void Function(Object error, StackTrace stackTrace);
class ReminderColdStartQueue {
final Queue<ReminderColdStartReplayTask> _tasks =
Queue<ReminderColdStartReplayTask>();
final ReminderColdStartTaskErrorHandler? _onTaskError;
Future<void>? _inFlightReplay;
ReminderColdStartQueue({ReminderColdStartTaskErrorHandler? onTaskError})
: _onTaskError = onTaskError;
void enqueue(ReminderColdStartReplayTask task) {
_tasks.add(task);
}
Future<void> replay() {
final inFlightReplay = _inFlightReplay;
if (inFlightReplay != null) {
return inFlightReplay;
}
final replayCompleter = Completer<void>();
final replayFuture = replayCompleter.future;
_inFlightReplay = replayFuture;
scheduleMicrotask(() async {
try {
await _replayInternal();
replayCompleter.complete();
} catch (error, stackTrace) {
replayCompleter.completeError(error, stackTrace);
} finally {
if (identical(_inFlightReplay, replayFuture)) {
_inFlightReplay = null;
}
}
});
return replayFuture;
}
Future<void> _replayInternal() async {
while (_tasks.isNotEmpty) {
final task = _tasks.removeFirst();
try {
await task();
} catch (error, stackTrace) {
_onTaskError?.call(error, stackTrace);
}
}
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../models/reminder_action.dart';
import '../models/reminder_payload.dart';
import '../reminder_action_executor.dart';
import 'reminder_presentation_coordinator.dart';
import 'widgets/reminder_action_sheet.dart';
class ReminderForegroundPresenter {
final GlobalKey<NavigatorState> _navigatorKey;
final ReminderActionExecutor _executor;
final ReminderPresentationCoordinator _coordinator;
bool _isPresenting = false;
ReminderForegroundPresenter({
required GlobalKey<NavigatorState> navigatorKey,
required ReminderActionExecutor executor,
ReminderPresentationCoordinator? coordinator,
}) : _navigatorKey = navigatorKey,
_executor = executor,
_coordinator = coordinator ?? ReminderPresentationCoordinator();
Future<void> present(ReminderPayload payload) async {
final context = _navigatorKey.currentContext;
if (context == null) {
return;
}
final lifecycleState = WidgetsBinding.instance.lifecycleState;
final isAppActive = lifecycleState == AppLifecycleState.resumed;
final shouldPresent = _coordinator.shouldPresent(
eventId: payload.eventId,
isAppActive: isAppActive,
);
if (!shouldPresent || _isPresenting) {
return;
}
_isPresenting = true;
try {
final action = await showModalBottomSheet<ReminderAction>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: ReminderActionSheet(
onSnooze: () {
Navigator.of(sheetContext).pop(ReminderAction.snooze10m);
},
onArchive: () {
Navigator.of(sheetContext).pop(ReminderAction.archive);
},
),
),
);
},
);
if (action == null) {
return;
}
await _executor.handleAction(action: action, payload: payload);
} finally {
_isPresenting = false;
}
}
}
@@ -0,0 +1,29 @@
typedef ReminderPresentationNow = DateTime Function();
class ReminderPresentationCoordinator {
final Duration _dedupeWindow;
final ReminderPresentationNow _now;
final Map<String, DateTime> _lastPresentedAtByEventId = <String, DateTime>{};
ReminderPresentationCoordinator({
Duration dedupeWindow = const Duration(seconds: 30),
ReminderPresentationNow? now,
}) : _dedupeWindow = dedupeWindow,
_now = now ?? DateTime.now;
bool shouldPresent({required String eventId, required bool isAppActive}) {
if (!isAppActive) {
return false;
}
final currentTime = _now();
final lastPresentedAt = _lastPresentedAtByEventId[eventId];
if (lastPresentedAt != null &&
currentTime.difference(lastPresentedAt) < _dedupeWindow) {
return false;
}
_lastPresentedAtByEventId[eventId] = currentTime;
return true;
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../../../../../core/theme/design_tokens.dart';
import '../../../../../shared/widgets/app_button.dart';
class ReminderActionSheet extends StatelessWidget {
const ReminderActionSheet({
super.key,
required this.onSnooze,
required this.onArchive,
});
final VoidCallback onSnooze;
final VoidCallback onArchive;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'提醒操作',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppColors.slate900),
),
const SizedBox(height: AppSpacing.lg),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AppButton(
text: '稍后提醒',
isOutlined: true,
onPressed: onSnooze,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: AppButton(text: '归档', onPressed: onArchive),
),
],
),
],
),
);
}
}