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/memory_item.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; } } /// 重新排期:先撤旧的,再按服务端给的时刻排新的。 /// /// 每次都全撤全排,不做增量 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); await _cancelAllOurs(); if (slots.isEmpty) { Logger.d(_tag, '没有待排的提醒'); return; } // 服务端已按 remind_at 升序返回。按顺序取前 N 条 —— 顺序错了会把明天的 // 排掉、留下一周后的,所以这里不再自己排序,直接信任服务端口径。 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(); } }