|
|
|
@ -1,11 +1,9 @@ |
|
|
|
import 'dart:async'; |
|
|
|
import 'dart:convert'; |
|
|
|
|
|
|
|
import 'package:flutter/material.dart'; |
|
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; |
|
|
|
import 'package:flutter_timezone/flutter_timezone.dart'; |
|
|
|
import 'package:get/get.dart'; |
|
|
|
import 'package:permission_handler/permission_handler.dart'; |
|
|
|
import 'package:timezone/data/latest_all.dart' as tzdata; |
|
|
|
import 'package:timezone/timezone.dart' as tz; |
|
|
|
|
|
|
|
import '../../core/utils/logger.dart'; |
|
|
|
import '../models/assistant_directive.dart'; |
|
|
|
@ -13,28 +11,33 @@ import '../models/memory_item.dart'; |
|
|
|
import 'assistant_directive_service.dart'; |
|
|
|
import 'memory_service.dart'; |
|
|
|
|
|
|
|
/// 拾忆的提前提醒:把服务端展开好的提醒时刻排成系统本地通知。 |
|
|
|
/// 拾忆的提前提醒:**只在 App 内提示,不发系统通知**。 |
|
|
|
/// |
|
|
|
/// ## 为什么在客户端做 |
|
|
|
/// App 未打开、甚至无网时也要响。服务端推送做不到——没有 APNs/FCM 通道, |
|
|
|
/// 国内厂商推送还要逐家接。 |
|
|
|
/// ## ⚠️ 这是刻意的产品选择,别顺手改回系统通知 |
|
|
|
/// 2026-09-05 定的口径:提醒内容只停留在 App 里。代价必须说清楚—— |
|
|
|
/// **App 没在前台跑的时候不会有任何提示**,到点也不响。它是「打开 App 时 |
|
|
|
/// 告诉你有什么要做」,不是系统闹钟。要真正做到关着 App 也能响,只有 |
|
|
|
/// 系统通知/AlarmManager 一条路,那就得回到通知权限那套(还有 48 小时 |
|
|
|
/// 合规冷却、Android 14 的精确闹钟权限、国内 ROM 省电策略三道坎)。 |
|
|
|
/// |
|
|
|
/// ## ⚠️ 送达率做不到 100%,这是已知的 |
|
|
|
/// 三条叠加,都不是代码能绕过去的: |
|
|
|
/// 1. 通知权限有 48 小时合规冷却(见 PermissionUtil):用户在别处拒过一次, |
|
|
|
/// 两天内提醒完全静默,而且 App **不被允许**弹窗解释。 |
|
|
|
/// 2. Android 14 起 SCHEDULE_EXACT_ALARM 对普通应用默认不授予。真正该用的是 |
|
|
|
/// USE_EXACT_ALARM,但 Google Play 要求「应用核心功能就是闹钟/日历」并单独 |
|
|
|
/// 提交说明——那是产品决策。拿不到精确权限时这里退化成 inexact 排期, |
|
|
|
/// 「提前 5 分钟」会变成「提前 5 到 20 分钟之间」。 |
|
|
|
/// 3. 国内 ROM 的省电策略会清掉未加白名单应用的 AlarmManager 排期。 |
|
|
|
/// ## 实现 |
|
|
|
/// 进程内一个 Timer 指向**最近的那一条**,响完再武装下一条。 |
|
|
|
/// 不给每条各排一个 Timer:几十个 Timer 既没必要,改期时还容易漏撤。 |
|
|
|
/// |
|
|
|
/// ## ⚠️ 通知 id 必须分命名空间 |
|
|
|
/// 仓库里已经有两个各自 new 出来的 FlutterLocalNotificationsPlugin 实例 |
|
|
|
/// (background_session_notifier / meeting_record_controller),各管各的 id。 |
|
|
|
/// 提醒占 [_idBase] 起的一段固定区间,取消时**逐条 cancel**, |
|
|
|
/// **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知, |
|
|
|
/// 表现是录音悄悄失去前台身份、被系统回收。 |
|
|
|
/// 定时器最长只压 [_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'; |
|
|
|
@ -42,47 +45,31 @@ class MemoryReminderScheduler extends GetxService { |
|
|
|
/// 提醒通知的 id 区间:[_idBase, _idBase + _maxScheduled) |
|
|
|
static const int _idBase = 920000; |
|
|
|
|
|
|
|
/// 排期上限。iOS 最多 64 条待处理通知,留一些余量给别的通知用。 |
|
|
|
/// 老版本排过的通知 id 区间上限,只用于启动时撤干净 |
|
|
|
static const int _maxScheduled = 48; |
|
|
|
|
|
|
|
/// 只排未来这么多天 |
|
|
|
/// 只看未来这么多天 |
|
|
|
static const int _days = 7; |
|
|
|
|
|
|
|
final FlutterLocalNotificationsPlugin _plugin = |
|
|
|
FlutterLocalNotificationsPlugin(); |
|
|
|
/// 单个定时器最长压多久,超过就到期重算 |
|
|
|
static const Duration _maxTimerSpan = Duration(minutes: 30); |
|
|
|
|
|
|
|
bool _pluginReady = false; |
|
|
|
bool _tzReady = false; |
|
|
|
/// 待提醒队列,按 remindAt 升序,队首是最近的一条 |
|
|
|
final List<MemoryRemindSlot> _pending = []; |
|
|
|
|
|
|
|
/// 上一次真正排出去的 id,取消时按它逐条撤 |
|
|
|
final List<int> _scheduledIds = []; |
|
|
|
/// 指向队首那条的定时器 |
|
|
|
Timer? _timer; |
|
|
|
|
|
|
|
/// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页 |
|
|
|
final RxBool permissionDenied = false.obs; |
|
|
|
/// 正在展示提醒弹窗——同一时刻挤进来两条时不叠第二个 dialog |
|
|
|
bool _showing = false; |
|
|
|
|
|
|
|
Future<void> _ensureTz() async { |
|
|
|
if (_tzReady) return; |
|
|
|
tzdata.initializeTimeZones(); |
|
|
|
try { |
|
|
|
final name = await FlutterTimezone.getLocalTimezone(); |
|
|
|
tz.setLocalLocation(tz.getLocation(name.toString())); |
|
|
|
} catch (e) { |
|
|
|
// 拿不到系统时区就用 tz 的默认 UTC 之外的兜底:按本机偏移找一个等价区。 |
|
|
|
// 不做这一步会让所有提醒按 UTC 排,国内用户全部偏 8 小时。 |
|
|
|
Logger.w(_tag, '读系统时区失败,按本机偏移兜底: $e'); |
|
|
|
try { |
|
|
|
final offset = DateTime.now().timeZoneOffset; |
|
|
|
final match = tz.timeZoneDatabase.locations.values.firstWhere( |
|
|
|
(l) => l.currentTimeZone.offset == offset.inMilliseconds, |
|
|
|
orElse: () => tz.getLocation('Asia/Shanghai'), |
|
|
|
); |
|
|
|
tz.setLocalLocation(match); |
|
|
|
} catch (_) { |
|
|
|
tz.setLocalLocation(tz.getLocation('Asia/Shanghai')); |
|
|
|
} |
|
|
|
} |
|
|
|
_tzReady = true; |
|
|
|
} |
|
|
|
/// 老版本遗留的系统通知只需要撤一次 |
|
|
|
bool _legacyCleared = false; |
|
|
|
|
|
|
|
final FlutterLocalNotificationsPlugin _plugin = |
|
|
|
FlutterLocalNotificationsPlugin(); |
|
|
|
|
|
|
|
bool _pluginReady = false; |
|
|
|
|
|
|
|
Future<void> _ensurePlugin() async { |
|
|
|
if (_pluginReady) return; |
|
|
|
@ -116,24 +103,7 @@ class MemoryReminderScheduler extends GetxService { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 通知权限是否已授予。 |
|
|
|
/// |
|
|
|
/// ⚠️ **只查不申请**。申请必须走 PermissionUtil 的 requestPermission(它收敛了 |
|
|
|
/// 48 小时冷却),而且只允许在「设置 → 权限管理」里由用户主动发起。 |
|
|
|
/// 这里主动申请会直接违反那条合规规则。 |
|
|
|
Future<bool> _hasPermission() async { |
|
|
|
try { |
|
|
|
final status = await Permission.notification.status; |
|
|
|
final ok = status.isGranted; |
|
|
|
permissionDenied.value = !ok; |
|
|
|
return ok; |
|
|
|
} catch (e) { |
|
|
|
Logger.w(_tag, '查询通知权限失败: $e'); |
|
|
|
return false; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也排成通知。 |
|
|
|
/// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也算进待提醒队列。 |
|
|
|
/// |
|
|
|
/// ⚠️ 不做这个,「对助手说定个闹钟」在真机上就是**完全不响**。 |
|
|
|
/// 排期的数据源原先只有 `MemoryService.loadUpcoming`,那是纯服务端接口; |
|
|
|
@ -206,80 +176,88 @@ class MemoryReminderScheduler extends GetxService { |
|
|
|
return out; |
|
|
|
} |
|
|
|
|
|
|
|
/// 重新排期:先撤旧的,再按服务端给的时刻排新的。 |
|
|
|
/// 重新算一遍待提醒队列,并武装最近的那一条。 |
|
|
|
/// |
|
|
|
/// 每次都全撤全排,不做增量 diff —— 改期后旧通知还在响比多排一次贵得多。 |
|
|
|
/// 每次都全量重算,不做增量 diff —— 改期后旧的还在响比多算一次贵得多。 |
|
|
|
Future<void> reschedule() async { |
|
|
|
if (!await _hasPermission()) { |
|
|
|
Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」'); |
|
|
|
await _cancelAllOurs(); |
|
|
|
return; |
|
|
|
} |
|
|
|
await _ensureTz(); |
|
|
|
await _ensurePlugin(); |
|
|
|
await _cancelLegacyNotifications(); |
|
|
|
|
|
|
|
final now = DateTime.now(); |
|
|
|
final slots = <MemoryRemindSlot>[ |
|
|
|
...await MemoryService.to.loadUpcoming(days: _days), |
|
|
|
..._localSlots(_days), |
|
|
|
]; |
|
|
|
] |
|
|
|
// 已经过去的直接丢:错过的不补弹(见类注释) |
|
|
|
.where((e) => e.remindDateTime.isAfter(now)) |
|
|
|
.toList(); |
|
|
|
// 合流之后**必须自己排一次**:服务端那份是升序的,本地补进来的不是, |
|
|
|
// 直接拼起来会让本地闹钟按插入位置抢占前 N 个名额,把服务端更早的挤掉。 |
|
|
|
// 直接拼起来队首就不是最近的那条,武装出来的定时器指向的是随便一条。 |
|
|
|
slots.sort((a, b) => a.remindAt.compareTo(b.remindAt)); |
|
|
|
await _cancelAllOurs(); |
|
|
|
if (slots.isEmpty) { |
|
|
|
Logger.w(_tag, '没有待排的提醒'); |
|
|
|
return; |
|
|
|
} |
|
|
|
Logger.w(_tag, '待排提醒 ${slots.length} 条'); |
|
|
|
|
|
|
|
_pending |
|
|
|
..clear() |
|
|
|
..addAll(slots); |
|
|
|
Logger.w(_tag, '待提醒 ${_pending.length} 条(App 内提示,不发系统通知)'); |
|
|
|
_armNext(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 武装队首那条的定时器。 |
|
|
|
void _armNext() { |
|
|
|
_timer?.cancel(); |
|
|
|
_timer = null; |
|
|
|
|
|
|
|
final now = DateTime.now(); |
|
|
|
var idx = 0; |
|
|
|
for (final slot in slots) { |
|
|
|
if (idx >= _maxScheduled) break; |
|
|
|
final when = slot.remindDateTime; |
|
|
|
if (!when.isAfter(now)) continue; |
|
|
|
// 队首可能在这次武装之前就已经过期了(进程被挂起过一段时间),逐个丢掉 |
|
|
|
while (_pending.isNotEmpty && !_pending.first.remindDateTime.isAfter(now)) { |
|
|
|
final missed = _pending.removeAt(0); |
|
|
|
Logger.w(_tag, '错过的提醒不补弹: ${missed.title}'); |
|
|
|
} |
|
|
|
if (_pending.isEmpty) return; |
|
|
|
|
|
|
|
final id = _idBase + idx; |
|
|
|
try { |
|
|
|
await _plugin.zonedSchedule( |
|
|
|
id, |
|
|
|
slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, |
|
|
|
_bodyOf(slot), |
|
|
|
tz.TZDateTime.from(when, tz.local), |
|
|
|
_details(), |
|
|
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, |
|
|
|
// 按「墙上时钟时间」解释:用户设的是 8:00,跨时区飞行后也该是当地 8:00 语义 |
|
|
|
// 由服务端按项自己的 tz 算好绝对时刻,这里只需保证不被二次偏移。 |
|
|
|
uiLocalNotificationDateInterpretation: |
|
|
|
UILocalNotificationDateInterpretation.absoluteTime, |
|
|
|
payload: jsonEncode({'item_id': slot.itemId}), |
|
|
|
); |
|
|
|
_scheduledIds.add(id); |
|
|
|
idx++; |
|
|
|
} catch (e) { |
|
|
|
// 拿不到精确闹钟权限(Android 14 常见)会在这里抛。退化成 inexact |
|
|
|
// 再试一次:不精确的提醒也比没有强。 |
|
|
|
try { |
|
|
|
await _plugin.zonedSchedule( |
|
|
|
id, |
|
|
|
slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, |
|
|
|
_bodyOf(slot), |
|
|
|
tz.TZDateTime.from(when, tz.local), |
|
|
|
_details(), |
|
|
|
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, |
|
|
|
uiLocalNotificationDateInterpretation: |
|
|
|
UILocalNotificationDateInterpretation.absoluteTime, |
|
|
|
payload: jsonEncode({'item_id': slot.itemId}), |
|
|
|
); |
|
|
|
_scheduledIds.add(id); |
|
|
|
idx++; |
|
|
|
Logger.w(_tag, '精确排期失败已退化为 inexact(提醒时间可能延后): $e'); |
|
|
|
} catch (e2) { |
|
|
|
Logger.e(_tag, '排期失败 item:${slot.itemId}: $e2'); |
|
|
|
} |
|
|
|
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; |
|
|
|
} |
|
|
|
Logger.i(_tag, '已排定 $idx 条提醒(共 ${slots.length} 个待选时刻)'); |
|
|
|
_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) { |
|
|
|
@ -289,46 +267,41 @@ class MemoryReminderScheduler extends GetxService { |
|
|
|
'${t.minute.toString().padLeft(2, '0')}'; |
|
|
|
} |
|
|
|
|
|
|
|
NotificationDetails _details() { |
|
|
|
// 单独的通道:录音提示是常驻低优先级通道,共用会让提醒被降级成静默。 |
|
|
|
const android = AndroidNotificationDetails( |
|
|
|
'memory_reminder', |
|
|
|
'拾忆提醒', |
|
|
|
channelDescription: '待办与闹钟的提前提醒', |
|
|
|
importance: Importance.high, |
|
|
|
priority: Priority.high, |
|
|
|
); |
|
|
|
const ios = DarwinNotificationDetails( |
|
|
|
presentAlert: true, |
|
|
|
presentBadge: true, |
|
|
|
presentSound: true, |
|
|
|
); |
|
|
|
return const NotificationDetails(android: android, iOS: ios); |
|
|
|
} |
|
|
|
|
|
|
|
/// 只撤我们自己排的那些 id。 |
|
|
|
/// 撤掉**老版本**排进系统的那些通知。 |
|
|
|
/// |
|
|
|
/// ⚠️ **绝不能用 cancelAll()**:它会把录音的前台通知一起干掉。 |
|
|
|
Future<void> _cancelAllOurs() async { |
|
|
|
if (!_pluginReady) return; |
|
|
|
for (final id in _scheduledIds) { |
|
|
|
try { |
|
|
|
await _plugin.cancel(id); |
|
|
|
} catch (_) {} |
|
|
|
} |
|
|
|
// 进程重启后 _scheduledIds 是空的,但系统里可能还留着上次的排期, |
|
|
|
// 所以把整个区间都撤一遍(cancel 不存在的 id 是安全的空操作)。 |
|
|
|
for (var i = 0; i < _maxScheduled; i++) { |
|
|
|
try { |
|
|
|
await _plugin.cancel(_idBase + i); |
|
|
|
} catch (_) {} |
|
|
|
/// 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'); |
|
|
|
} |
|
|
|
_scheduledIds.clear(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 登出时清掉所有提醒——换账号不该继续响上一个账号的日程 |
|
|
|
/// 登出时清掉所有提醒——换账号不该继续弹上一个账号的日程 |
|
|
|
Future<void> clear() async { |
|
|
|
await _ensurePlugin(); |
|
|
|
await _cancelAllOurs(); |
|
|
|
_timer?.cancel(); |
|
|
|
_timer = null; |
|
|
|
_pending.clear(); |
|
|
|
await _cancelLegacyNotifications(); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
void onClose() { |
|
|
|
_timer?.cancel(); |
|
|
|
super.onClose(); |
|
|
|
} |
|
|
|
} |
|
|
|
|