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.
 
 
 
 
 
 

334 lines
14 KiB

import 'dart:convert';
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';
import '../models/memory_item.dart';
import 'assistant_directive_service.dart';
import 'memory_service.dart';
/// 拾忆的提前提醒:把服务端展开好的提醒时刻排成系统本地通知。
///
/// ## 为什么在客户端做
/// App 未打开、甚至无网时也要响。服务端推送做不到——没有 APNs/FCM 通道,
/// 国内厂商推送还要逐家接。
///
/// ## ⚠️ 送达率做不到 100%,这是已知的
/// 三条叠加,都不是代码能绕过去的:
/// 1. 通知权限有 48 小时合规冷却(见 PermissionUtil):用户在别处拒过一次,
/// 两天内提醒完全静默,而且 App **不被允许**弹窗解释。
/// 2. Android 14 起 SCHEDULE_EXACT_ALARM 对普通应用默认不授予。真正该用的是
/// USE_EXACT_ALARM,但 Google Play 要求「应用核心功能就是闹钟/日历」并单独
/// 提交说明——那是产品决策。拿不到精确权限时这里退化成 inexact 排期,
/// 「提前 5 分钟」会变成「提前 5 到 20 分钟之间」。
/// 3. 国内 ROM 的省电策略会清掉未加白名单应用的 AlarmManager 排期。
///
/// ## ⚠️ 通知 id 必须分命名空间
/// 仓库里已经有两个各自 new 出来的 FlutterLocalNotificationsPlugin 实例
/// (background_session_notifier / meeting_record_controller),各管各的 id。
/// 提醒占 [_idBase] 起的一段固定区间,取消时**逐条 cancel**,
/// **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知,
/// 表现是录音悄悄失去前台身份、被系统回收。
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;
/// 排期上限。iOS 最多 64 条待处理通知,留一些余量给别的通知用。
static const int _maxScheduled = 48;
/// 只排未来这么多天
static const int _days = 7;
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _pluginReady = false;
bool _tzReady = false;
/// 上一次真正排出去的 id,取消时按它逐条撤
final List<int> _scheduledIds = [];
/// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页
final RxBool permissionDenied = false.obs;
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;
}
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');
}
}
/// 通知权限是否已授予。
///
/// ⚠️ **只查不申请**。申请必须走 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`,那是纯服务端接口;
/// 后端还没部署时它恒返回空,于是 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 {
if (!await _hasPermission()) {
Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」');
await _cancelAllOurs();
return;
}
await _ensureTz();
await _ensurePlugin();
final slots = <MemoryRemindSlot>[
...await MemoryService.to.loadUpcoming(days: _days),
..._localSlots(_days),
];
// 合流之后**必须自己排一次**:服务端那份是升序的,本地补进来的不是,
// 直接拼起来会让本地闹钟按插入位置抢占前 N 个名额,把服务端更早的挤掉。
slots.sort((a, b) => a.remindAt.compareTo(b.remindAt));
await _cancelAllOurs();
if (slots.isEmpty) {
Logger.w(_tag, '没有待排的提醒');
return;
}
Logger.w(_tag, '待排提醒 ${slots.length} 条');
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;
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');
}
}
}
Logger.i(_tag, '已排定 $idx 条提醒(共 ${slots.length} 个待选时刻)');
}
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')}';
}
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 (_) {}
}
_scheduledIds.clear();
}
/// 登出时清掉所有提醒——换账号不该继续响上一个账号的日程
Future<void> clear() async {
await _ensurePlugin();
await _cancelAllOurs();
}
}