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(); 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 _scheduledIds = []; /// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页 final RxBool permissionDenied = false.obs; Future _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 _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; 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 _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 _localSlots(int days) { if (!Get.isRegistered()) return const []; final now = DateTime.now(); final until = now.add(Duration(days: days)); final out = []; 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 _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 = []; 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 reschedule() async { if (!await _hasPermission()) { Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」'); await _cancelAllOurs(); return; } await _ensureTz(); await _ensurePlugin(); final slots = [ ...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 _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 clear() async { await _ensurePlugin(); await _cancelAllOurs(); } }