You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
307 lines
12 KiB
307 lines
12 KiB
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import '../models/assistant_directive.dart';
|
|
import '../models/memory_item.dart';
|
|
import 'assistant_directive_service.dart';
|
|
import 'memory_service.dart';
|
|
|
|
/// 拾忆的提前提醒:**只在 App 内提示,不发系统通知**。
|
|
///
|
|
/// ## ⚠️ 这是刻意的产品选择,别顺手改回系统通知
|
|
/// 2026-09-05 定的口径:提醒内容只停留在 App 里。代价必须说清楚——
|
|
/// **App 没在前台跑的时候不会有任何提示**,到点也不响。它是「打开 App 时
|
|
/// 告诉你有什么要做」,不是系统闹钟。要真正做到关着 App 也能响,只有
|
|
/// 系统通知/AlarmManager 一条路,那就得回到通知权限那套(还有 48 小时
|
|
/// 合规冷却、Android 14 的精确闹钟权限、国内 ROM 省电策略三道坎)。
|
|
///
|
|
/// ## 实现
|
|
/// 进程内一个 Timer 指向**最近的那一条**,响完再武装下一条。
|
|
/// 不给每条各排一个 Timer:几十个 Timer 既没必要,改期时还容易漏撤。
|
|
///
|
|
/// 定时器最长只压 [_maxTimerSpan],超过就到期重算一次。理由是长定时器在
|
|
/// 移动端本来就不可靠(进程随时被回收),而且中途数据可能变了。
|
|
///
|
|
/// ## ⚠️ 错过的不补弹
|
|
/// App 起来时如果发现某条提醒时刻已经过去了,直接丢掉,不追着弹。
|
|
/// 追弹的话,早上打开 App 会一次性糊上来一串昨天的提醒。
|
|
///
|
|
/// ## ⚠️ 仍然保留 flutter_local_notifications,只用来「撤」
|
|
/// 老版本(2026-09-05 之前)真的往系统里排过通知,装过老包的机器上
|
|
/// 那些排期还在。[_cancelLegacyNotifications] 启动时撤一次,
|
|
/// 否则用户会在改口径之后仍然收到系统提醒,且再也没有地方能关掉它。
|
|
///
|
|
/// ⚠️ **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知,
|
|
/// 表现是录音悄悄失去前台身份、被系统回收。只按我们自己的 id 区间撤。
|
|
class MemoryReminderScheduler extends GetxService {
|
|
static MemoryReminderScheduler get to => Get.find<MemoryReminderScheduler>();
|
|
static const String _tag = 'MemoryReminder';
|
|
|
|
/// 提醒通知的 id 区间:[_idBase, _idBase + _maxScheduled)
|
|
static const int _idBase = 920000;
|
|
|
|
/// 老版本排过的通知 id 区间上限,只用于启动时撤干净
|
|
static const int _maxScheduled = 48;
|
|
|
|
/// 只看未来这么多天
|
|
static const int _days = 7;
|
|
|
|
/// 单个定时器最长压多久,超过就到期重算
|
|
static const Duration _maxTimerSpan = Duration(minutes: 30);
|
|
|
|
/// 待提醒队列,按 remindAt 升序,队首是最近的一条
|
|
final List<MemoryRemindSlot> _pending = [];
|
|
|
|
/// 指向队首那条的定时器
|
|
Timer? _timer;
|
|
|
|
/// 正在展示提醒弹窗——同一时刻挤进来两条时不叠第二个 dialog
|
|
bool _showing = false;
|
|
|
|
/// 老版本遗留的系统通知只需要撤一次
|
|
bool _legacyCleared = false;
|
|
|
|
final FlutterLocalNotificationsPlugin _plugin =
|
|
FlutterLocalNotificationsPlugin();
|
|
|
|
bool _pluginReady = false;
|
|
|
|
Future<void> _ensurePlugin() async {
|
|
if (_pluginReady) return;
|
|
const androidSettings =
|
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
|
const iosSettings = DarwinInitializationSettings(
|
|
// 不在这里请求权限:请求要走 PermissionUtil 那套 48 小时冷却,
|
|
// 绕过它等于绕过合规规则。
|
|
requestAlertPermission: false,
|
|
requestBadgePermission: false,
|
|
requestSoundPermission: false,
|
|
);
|
|
await _plugin.initialize(
|
|
const InitializationSettings(android: androidSettings, iOS: iosSettings),
|
|
onDidReceiveNotificationResponse: _onTap,
|
|
);
|
|
_pluginReady = true;
|
|
}
|
|
|
|
void _onTap(NotificationResponse response) {
|
|
final payload = response.payload;
|
|
if (payload == null || payload.isEmpty) return;
|
|
try {
|
|
final data = jsonDecode(payload) as Map<String, dynamic>;
|
|
final itemId = memInt(data['item_id']);
|
|
Logger.d(_tag, '用户点开提醒 item:$itemId');
|
|
// 播报交给拾忆页:通知里不直接出声,锁屏状态下强行播报是骚扰。
|
|
Get.toNamed('/main', arguments: {'tab': 'memory', 'focusItem': itemId});
|
|
} catch (e) {
|
|
Logger.w(_tag, '解析提醒 payload 失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也算进待提醒队列。
|
|
///
|
|
/// ⚠️ 不做这个,「对助手说定个闹钟」在真机上就是**完全不响**。
|
|
/// 排期的数据源原先只有 `MemoryService.loadUpcoming`,那是纯服务端接口;
|
|
/// 后端还没部署时它恒返回空,于是 SET_clock 把闹钟写进了本地 reminders、
|
|
/// 拾忆页也显示出来了,却一条通知都没排——用户看到的是「记下了但不会响」,
|
|
/// 而这跟「指令压根没落地」在表现上分不出来。
|
|
///
|
|
/// 已同步的跳过:那些服务端会在 loadUpcoming 里给出来,两边都排会响两遍。
|
|
/// 口径与 CalendarController._localReminderEvents 保持一致。
|
|
List<MemoryRemindSlot> _localSlots(int days) {
|
|
if (!Get.isRegistered<AssistantDirectiveService>()) return const [];
|
|
final now = DateTime.now();
|
|
final until = now.add(Duration(days: days));
|
|
final out = <MemoryRemindSlot>[];
|
|
for (final AssistantReminder r in AssistantDirectiveService.to.reminders) {
|
|
if (r.synced) continue;
|
|
if (r.done) continue;
|
|
final item = r.toMemoryItem();
|
|
for (final happen in _occurrences(item, now, until)) {
|
|
final remind = happen.subtract(Duration(minutes: item.remindAhead));
|
|
// 已经过去的不排。注意判据是**提醒时刻**不是发生时刻:
|
|
// 「10:00 的会、提前 5 分钟」在 9:58 定下来时仍然该响。
|
|
if (!remind.isAfter(now)) continue;
|
|
out.add(MemoryRemindSlot(
|
|
// 本地项没有服务端 id。0 会被通知点击处理成 focusItem:0,
|
|
// 拾忆页查不到就只是不定位,不会出错。
|
|
itemId: 0,
|
|
remindAt: remind.millisecondsSinceEpoch ~/ 1000,
|
|
happenAt: happen.millisecondsSinceEpoch ~/ 1000,
|
|
title: item.title,
|
|
detail: '',
|
|
category: item.category,
|
|
));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// 把一条记忆项在 [from, until] 窗口内的发生时刻摊开。
|
|
///
|
|
/// 重复规则的判定与 CalendarController._expandItem 逐条对齐——两处口径不一致
|
|
/// 会造成「日历上显示了但不会响」或者反过来,而且很难发现。
|
|
List<DateTime> _occurrences(MemoryItem item, DateTime from, DateTime until) {
|
|
final anchor = item.happenDateTime;
|
|
if (!item.isRepeating) {
|
|
return anchor.isAfter(from) && anchor.isBefore(until) ? [anchor] : const [];
|
|
}
|
|
final out = <DateTime>[];
|
|
var day = DateTime(from.year, from.month, from.day);
|
|
final anchorDay = DateTime(anchor.year, anchor.month, anchor.day);
|
|
while (!day.isAfter(until)) {
|
|
if (!day.isBefore(anchorDay)) {
|
|
final hit = switch (item.repeatRule) {
|
|
MemoryRepeat.daily => true,
|
|
// weekday 用 1-7(周一~周日),与 DateTime.weekday 一致
|
|
MemoryRepeat.weekly => day.weekday == item.weekday,
|
|
MemoryRepeat.weekdays => day.weekday <= DateTime.friday,
|
|
MemoryRepeat.weekend => day.weekday == DateTime.saturday ||
|
|
day.weekday == DateTime.sunday,
|
|
MemoryRepeat.monthly => day.day == anchor.day,
|
|
_ => false,
|
|
};
|
|
if (hit) {
|
|
out.add(DateTime(
|
|
day.year, day.month, day.day, anchor.hour, anchor.minute));
|
|
}
|
|
}
|
|
day = day.add(const Duration(days: 1));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// 重新算一遍待提醒队列,并武装最近的那一条。
|
|
///
|
|
/// 每次都全量重算,不做增量 diff —— 改期后旧的还在响比多算一次贵得多。
|
|
Future<void> reschedule() async {
|
|
await _cancelLegacyNotifications();
|
|
|
|
final now = DateTime.now();
|
|
final slots = <MemoryRemindSlot>[
|
|
...await MemoryService.to.loadUpcoming(days: _days),
|
|
..._localSlots(_days),
|
|
]
|
|
// 已经过去的直接丢:错过的不补弹(见类注释)
|
|
.where((e) => e.remindDateTime.isAfter(now))
|
|
.toList();
|
|
// 合流之后**必须自己排一次**:服务端那份是升序的,本地补进来的不是,
|
|
// 直接拼起来队首就不是最近的那条,武装出来的定时器指向的是随便一条。
|
|
slots.sort((a, b) => a.remindAt.compareTo(b.remindAt));
|
|
|
|
_pending
|
|
..clear()
|
|
..addAll(slots);
|
|
Logger.w(_tag, '待提醒 ${_pending.length} 条(App 内提示,不发系统通知)');
|
|
_armNext();
|
|
}
|
|
|
|
/// 武装队首那条的定时器。
|
|
void _armNext() {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
|
|
final now = DateTime.now();
|
|
// 队首可能在这次武装之前就已经过期了(进程被挂起过一段时间),逐个丢掉
|
|
while (_pending.isNotEmpty && !_pending.first.remindDateTime.isAfter(now)) {
|
|
final missed = _pending.removeAt(0);
|
|
Logger.w(_tag, '错过的提醒不补弹: ${missed.title}');
|
|
}
|
|
if (_pending.isEmpty) return;
|
|
|
|
final due = _pending.first.remindDateTime;
|
|
var wait = due.difference(now);
|
|
// 封顶:长定时器在移动端不可靠,到点重算一次即可
|
|
if (wait > _maxTimerSpan) wait = _maxTimerSpan;
|
|
_timer = Timer(wait, () {
|
|
// 封顶重算的情况下这里还没到点,_armNext 会重新压一段
|
|
if (_pending.isNotEmpty &&
|
|
!_pending.first.remindDateTime.isAfter(DateTime.now())) {
|
|
_fire(_pending.removeAt(0));
|
|
}
|
|
_armNext();
|
|
});
|
|
}
|
|
|
|
/// 到点了:在 App 内弹一条。
|
|
///
|
|
/// ⚠️ 用 dialog 不用 snackbar:闹钟是需要用户确认的,snackbar 两秒就滑走了,
|
|
/// 用户低头一眼没看见就等于没提醒过。
|
|
void _fire(MemoryRemindSlot slot) {
|
|
if (_showing) {
|
|
// 同一分钟撞车的第二条:不叠 dialog,退化成一条提示,
|
|
// 叠两个 dialog 会把返回键和路由栈弄乱。
|
|
Get.snackbar(
|
|
slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title,
|
|
_bodyOf(slot),
|
|
snackPosition: SnackPosition.TOP,
|
|
duration: const Duration(seconds: 4),
|
|
);
|
|
return;
|
|
}
|
|
_showing = true;
|
|
Logger.w(_tag, '触发提醒: ${slot.title} item:${slot.itemId}');
|
|
Get.dialog(
|
|
AlertDialog(
|
|
title: Text(slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title),
|
|
content: Text(_bodyOf(slot)),
|
|
// 只放一个按钮:没有现成的「查看详情」文案,为一个按钮补 41 个语言
|
|
// 的 key 不值当;提醒内容本身已经在标题和正文里了。
|
|
actions: [
|
|
TextButton(onPressed: Get.back, child: Text('gotIt'.tr)),
|
|
],
|
|
),
|
|
barrierDismissible: true,
|
|
).whenComplete(() => _showing = false);
|
|
}
|
|
|
|
String _bodyOf(MemoryRemindSlot slot) {
|
|
if (slot.detail.isNotEmpty) return slot.detail;
|
|
final t = DateTime.fromMillisecondsSinceEpoch(slot.happenAt * 1000);
|
|
return '${t.hour.toString().padLeft(2, '0')}:'
|
|
'${t.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
/// 撤掉**老版本**排进系统的那些通知。
|
|
///
|
|
/// 2026-09-05 之前的包真的往系统里排过提醒,装过老包的机器上那些排期还在,
|
|
/// 不撤的话用户在改口径之后仍会收到系统提醒,而且没有任何地方能关掉。
|
|
/// 一个进程内只需要跑一次。
|
|
///
|
|
/// ⚠️ **绝不能用 cancelAll()**:它会把录音的前台通知一起干掉,
|
|
/// 表现是录音悄悄失去前台身份、被系统回收。只撤我们自己的 id 区间
|
|
/// (cancel 一个不存在的 id 是安全的空操作)。
|
|
Future<void> _cancelLegacyNotifications() async {
|
|
if (_legacyCleared) return;
|
|
_legacyCleared = true;
|
|
try {
|
|
await _ensurePlugin();
|
|
for (var i = 0; i < _maxScheduled; i++) {
|
|
try {
|
|
await _plugin.cancel(_idBase + i);
|
|
} catch (_) {}
|
|
}
|
|
} catch (e) {
|
|
Logger.w(_tag, '撤老版本系统通知失败(不影响 App 内提醒): $e');
|
|
}
|
|
}
|
|
|
|
/// 登出时清掉所有提醒——换账号不该继续弹上一个账号的日程
|
|
Future<void> clear() async {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_pending.clear();
|
|
await _cancelLegacyNotifications();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_timer?.cancel();
|
|
super.onClose();
|
|
}
|
|
}
|
|
|