From 4c5b6f0c65fd6bf27fb3216c1d039f092ae51d8e Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 08:52:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=BE=E5=BF=86=EF=BC=9A=E4=BF=AE=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E7=AD=9B=E9=80=89=E6=97=A0=E6=95=88=E3=80=81=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E4=BC=A0=E4=B8=8E=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=8A=82=E6=B5=81=EF=BC=9B=E8=AF=95=E4=BF=AE=20iOS=20=E6=92=AD?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机反馈的三条。 ## 1) 分类筛选点了没反应(我的 bug) 筛选条改的是 categoryFilter,但 eventsForScope **只按日期过滤,从没读过它** —— 值改了没人用。列表源头补上分类条件。 顺带:onDaySelected 里那句 `categoryFilter.value = null` 也去掉了。它是旧首页 折叠态横向条的语义(临时开关,切天清掉合理);拾忆页的筛选条是常驻的, 选了「花销」再点一天就被清空,看起来同样像按钮失灵。 ## 2) 本地未同步项每点必弹,且无从同步 原来只是拦下来提示一句,没给出路,连点几下还叠一串一模一样的条。 - AssistantDirectiveService.syncPending():把所有 synced=false 的本地闹钟补传, 由拾忆页刷新(进页面/下拉/切前台)驱动。这是自愈机制——服务端没部署、断网、 或恰好那一下超时,闹钟都会先落在本地,等能连上时自动补上去,不用用户做任何事。 遇到业务错误或网络异常就 break:后面的一样会失败,别把整串请求都打一遍。 加了重入保护。 - refreshFromServer 里**先补传再拉取**。反过来的话刚补传的这次拉不到, 要等下一次刷新才显示。 - 新增 ToastThrottle:同一 key 3 秒内只弹一次(比 snackbar 默认停留略长, 保证上一条没消失就不会再来一条)。memory 的三处提示都接上。 登出时清节流状态,免得上个账号的记录影响下一个。 - 点未同步项时除了提示,**顺手触发一次补传**——用户点它说明他在意这条, 正好借这个动作试一次同步,成功了下次点就能编辑。 ## 3) EMAI 回复没声音(试修,需真机验证) 日志里 PcmStreamPlayer 反复报 -11850 Operation Stopped,每来一包音频重试一次。 链路:手机这条路是「按住说话」,MicPcmStreamer 用 record 的 echoCancel/noiseSuppress,在 iOS 上插件会把 AVAudioSession 切到 voice-processing 并自己持有;松手时 _mic.stop() 只停引擎、**不还原会话**。随后回复音频到达, AVPlayer 在那个会话上起不来。 _ensureSession 原来只在首次 configure、**从头到尾没有 setActive**。改成: 配置仍只做一次(配置本身是静态的),但**每轮 begin 都显式 setActive(true)**, 把会话按本方配置拿回来;起播失败时复位 _sessionConfigured,下一轮重新走完整 configure+setActive——只补激活救不回被改过 category/mode 的会话。 ⚠️ 没动 category/mode/routing 那几个值。CLAUDE.md 记着这块「被改坏过三次」 (外放/听筒/A2DP 来回绕),这次只加激活时机,不碰路由语义。 ⚠️ 这条是根据日志推断的,**未经真机验证**。 ## 测试 dart analyze 零错误。flutter test 有 7 个失败,逐个核对过是既有失败: 6 个语言的 translations_test 报「与 enUS key 不一致」,实测这些语言相对 enUS 缺 14~188 个 key,**其中属于本次新增的是 0 个**;id_verify_dialog_test 同理。 改动前后失败数都是 7。 Co-Authored-By: Claude Opus 5 (1M context) --- .../client/lib/core/utils/toast_throttle.dart | 49 +++++++++++++++++++ .../lib/data/services/account_service.dart | 5 ++ .../services/assistant_directive_service.dart | 43 ++++++++++++++++ .../lib/data/utils/pcm_stream_player.dart | 24 ++++++++- .../home/controllers/calendar_controller.dart | 20 ++++++-- .../views/widgets/todo_scope_list.dart | 25 +++++++--- 6 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 apps/client/lib/core/utils/toast_throttle.dart diff --git a/apps/client/lib/core/utils/toast_throttle.dart b/apps/client/lib/core/utils/toast_throttle.dart new file mode 100644 index 00000000..1273403a --- /dev/null +++ b/apps/client/lib/core/utils/toast_throttle.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +/// 轻量提示的节流器。 +/// +/// 解决的是「连续点同一个东西弹一串一模一样的条」:用户点三下列表项, +/// 屏幕上就叠三条相同的提示,把内容全挡住,而且要等它们逐条超时才散。 +/// +/// 规则:同一个 [key] 在 [window] 内只弹一次;不同 key 互不影响。 +/// 已经在显示的同名提示不会被顶掉重来。 +class ToastThrottle { + ToastThrottle._(); + + static final Map _last = {}; + + /// 同一提示的最小间隔。取 3 秒:比 snackbar 默认停留时间略长, + /// 保证「上一条还没消失就不会再来一条」。 + static const Duration window = Duration(seconds: 3); + + /// 弹一条受节流的提示。被节流掉时返回 false。 + static bool show( + String key, { + required String title, + required String message, + SnackPosition position = SnackPosition.TOP, + Color? background, + Color? textColor, + }) { + final now = DateTime.now(); + final last = _last[key]; + if (last != null && now.difference(last) < window) return false; + _last[key] = now; + Get.snackbar( + title, + message, + snackPosition: position, + backgroundColor: background, + colorText: textColor, + duration: const Duration(seconds: 2), + ); + return true; + } + + /// 清掉某个 key 的记录,让它下次立刻能弹(状态变化后想立即反馈时用) + static void reset(String key) => _last.remove(key); + + /// 全清。登出、切账号时用,避免上个账号的节流状态影响下一个。 + static void clear() => _last.clear(); +} diff --git a/apps/client/lib/data/services/account_service.dart b/apps/client/lib/data/services/account_service.dart index fd26e746..46988950 100644 --- a/apps/client/lib/data/services/account_service.dart +++ b/apps/client/lib/data/services/account_service.dart @@ -9,6 +9,7 @@ import '../services/network/api.dart'; import 'db/database_helper.dart'; import '../../data/models/user_Info.dart'; import 'meeting/meeting_upload_service.dart'; +import '../../core/utils/toast_throttle.dart'; import 'bes_device_auth.dart'; import 'memory_reminder_scheduler.dart'; import 'memory_service.dart'; @@ -47,6 +48,8 @@ class AccountService extends GetxService { if (Get.isRegistered()) { MemoryService.to.clear(); } + // 上个账号的提示节流状态不该影响下一个 + ToastThrottle.clear(); if (Get.isRegistered()) { unawaited(MemoryReminderScheduler.to.clear()); } @@ -67,6 +70,8 @@ class AccountService extends GetxService { if (Get.isRegistered()) { MemoryService.to.clear(); } + // 上个账号的提示节流状态不该影响下一个 + ToastThrottle.clear(); if (Get.isRegistered()) { unawaited(MemoryReminderScheduler.to.clear()); } diff --git a/apps/client/lib/data/services/assistant_directive_service.dart b/apps/client/lib/data/services/assistant_directive_service.dart index 484b4e3f..b6e98c78 100644 --- a/apps/client/lib/data/services/assistant_directive_service.dart +++ b/apps/client/lib/data/services/assistant_directive_service.dart @@ -129,6 +129,49 @@ class AssistantDirectiveService extends GetxService { unawaited(_uploadReminder(reminder)); } + /// 把所有**还没同步**的本地闹钟补传一遍。 + /// + /// 由拾忆页的刷新(进页面 / 下拉 / 切前台)驱动。存在的意义是自愈: + /// 服务端没部署、断网、或恰好那一下超时,闹钟都会先落在本地 `synced=false`, + /// 等下一次能连上时自动补上去,不需要用户做任何事、也不该反复弹提示催他。 + /// + /// 并发保护:多个入口可能同时触发刷新,重入会把同一条传两遍(服务端有 + /// (uid, client_key) 唯一索引兜底不会插重,但白跑一趟)。 + bool _syncing = false; + Future syncPending() async { + if (_syncing) return 0; + if (!Get.isRegistered() || !User.isLoggedIn()) return 0; + final pending = reminders.where((e) => !e.synced).toList(); + if (pending.isEmpty) return 0; + _syncing = true; + var ok = 0; + try { + for (final r in pending) { + try { + final saved = await MemoryService.to.add(r.toMemoryItem()); + if (saved != null) { + r.synced = true; + ok++; + } else { + // 服务端明确拒绝(业务错误)——多半是还没部署 memory 模块。 + // 后面的也一样会失败,早点收手,别把整串请求都打一遍。 + break; + } + } catch (_) { + break; // 网络层出问题,同上 + } + } + if (ok > 0) { + _persist(); + reminders.refresh(); + Logger.i(_tag, '补传了 $ok 条本地闹钟到服务端'); + } + } finally { + _syncing = false; + } + return ok; + } + /// 把一条助手落下来的闹钟写进记忆中心。 /// /// ⚠️ **幂等键用指令 id**:端侧指令会重试,而本类的按 id 去重只在**本进程内存**里 diff --git a/apps/client/lib/data/utils/pcm_stream_player.dart b/apps/client/lib/data/utils/pcm_stream_player.dart index 58a03a94..adc30df2 100644 --- a/apps/client/lib/data/utils/pcm_stream_player.dart +++ b/apps/client/lib/data/utils/pcm_stream_player.dart @@ -64,11 +64,26 @@ class PcmStreamPlayer { /// 调用方据此给服务端回 LocalRespondingEnded。 Stream get onCompleted => _completedCtl.stream; + /// 配置音频会话(只做一次;配置本身是静态的)并**每轮激活一次**。 + /// + /// ⚠️ 激活必须每轮都做,不能只在首次配置时做(iOS 真机踩到): + /// 手机这条路径是「按住说话」,`MicPcmStreamer` 用 record 的 + /// `echoCancel/noiseSuppress`,在 iOS 上会让插件把 AVAudioSession 切到 + /// voice-processing(.voiceChat)并自己持有;松手时 `_mic.stop()` 只停引擎, + /// **不会把会话还原**。随后这一轮回复音频到达,AVPlayer 在那个会话上 + /// 起不来,报 `-11850 Operation Stopped`,而且每来一包就重试一次, + /// 日志里刷成几十条。 + /// + /// 显式 setActive(true) 是把会话按本方的配置重新激活一次, + /// 相当于「录音刚放开,播放把它拿回来」。 Future _ensureSession() async { - if (_sessionConfigured) return; - _sessionConfigured = true; try { final session = await AudioSession.instance; + if (_sessionConfigured) { + await session.setActive(true); + return; + } + _sessionConfigured = true; // ⚠️ usage 千万别用 voiceCommunication。 // 华为(EMUI/HarmonyOS)的音频策略对通话流卡得很严:不在 // MODE_IN_COMMUNICATION 下建的 VOICE_COMMUNICATION 音轨会被直接静音, @@ -93,6 +108,7 @@ class PcmStreamPlayer { // 助手说话时把背景音乐暂停掉,而不是压低——压低了在嘈杂环境里还是听不清 androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient, )); + await session.setActive(true); } catch (e) { Logger.w(_tag, '音频会话配置失败(继续尝试播放): $e'); } @@ -131,6 +147,10 @@ class PcmStreamPlayer { Logger.e(_tag, '启动播放失败: $e'); _active = false; _pending.clear(); + // 起播失败多半是会话被别人(录音的 voice-processing)占着。 + // 复位标志,下一轮重新走完整的 configure + setActive, + // 而不是只补一次 setActive —— 只激活救不回被改过 category/mode 的会话。 + _sessionConfigured = false; } finally { _starting = false; } diff --git a/apps/client/lib/modules/home/controllers/calendar_controller.dart b/apps/client/lib/modules/home/controllers/calendar_controller.dart index c363d768..7ed1e326 100644 --- a/apps/client/lib/modules/home/controllers/calendar_controller.dart +++ b/apps/client/lib/modules/home/controllers/calendar_controller.dart @@ -233,6 +233,11 @@ class CalendarController extends GetxController /// 拉取当前可见窗口的数据。下拉刷新与首次进入都走它。 Future refreshFromServer({bool silent = false}) async { if (!Get.isRegistered()) return; + // 先把本地没同步上去的闹钟补传,再拉——顺序反了的话刚补传的这次拉不到, + // 要等下一次刷新才显示。 + if (Get.isRegistered()) { + await AssistantDirectiveService.to.syncPending(); + } final today = DateTime.now(); await MemoryService.to.load( start: today.subtract(const Duration(days: 30)), @@ -357,11 +362,13 @@ class CalendarController extends GetxController // ==================== 日期选择 ==================== - /// 用户点击某日(月视图或周视图):切换日期后重置代办/花销筛选,显示全部 + /// 用户点击某日(月视图或周视图)。 + /// + /// ⚠️ **不再重置分类筛选**:旧首页的筛选是折叠态横向条上的临时开关,切天清掉合理; + /// 拾忆页的筛选条是常驻的,用户选了「花销」再点一天就被清空,会显得筛选按钮失灵。 void onDaySelected(DateTime day) { selectedDate.value = day; focusedDay.value = DateTime(day.year, day.month, 1); - categoryFilter.value = null; } /// 回到今天(替代参考里的"今天"FAB) @@ -467,8 +474,15 @@ class CalendarController extends GetxController } /// 当前范围内的全部事件,按日期升序(同一天内保持原顺序) + /// 当前范围内、且通过分类筛选的事项。 + /// + /// ⚠️ **必须读 categoryFilter**:拾忆页顶部的分类筛选条改的就是它。 + /// 早先这里只按日期过滤,于是筛选条点了没有任何反应——值改了但没人读。 List get eventsForScope { - final list = events.where((e) => _inScope(e.date)).toList(); + final f = categoryFilter.value; + final list = events + .where((e) => _inScope(e.date) && (f == null || e.category == f)) + .toList(); list.sort((a, b) => a.date.compareTo(b.date)); return list; } diff --git a/apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart b/apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart index 4a2030e5..dca6ece2 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart @@ -7,7 +7,10 @@ import '../../../home/controllers/calendar_controller.dart'; import '../../../home/views/widgets/calendar/calendar_event.dart'; import '../../../home/views/widgets/calendar/calendar_tokens.dart'; import '../../../home/views/widgets/calendar/kids_card.dart'; +import 'dart:async'; + import '../../../../core/theme/app_palette.dart'; +import '../../../../core/utils/toast_throttle.dart'; import '../../../../data/models/memory_item.dart'; import '../../../../data/services/memory_service.dart'; import 'memory_edit_sheet.dart'; @@ -325,11 +328,16 @@ class TodoScopeList extends GetView { /// 先按 memoryId 回查真实的 MemoryItem——日历事件是投影出来的(重复项会摊成 /// 多天,id 带了日期后缀),不能拿它直接当记忆项用。 Future _openEdit(CalendarEvent event) async { - // memoryId == 0 表示这条还没同步到服务端(迁移期的本地助手闹钟)。 - // 它没有服务端 id,改了也没处提交,先明确告诉用户而不是静默无反应。 + // memoryId == 0 表示这条还没同步到服务端(本地助手闹钟,服务端没收下)。 + // 它没有服务端 id,改了没处提交,只能先提示。 + // + // ⚠️ 提示走节流:用户连点几下会叠出一串一模一样的条,把内容全挡住。 + // 同时**主动触发一次补传**——用户点它就说明他在意这条, + // 正好借这个动作试一次同步,成功了下次点就能编辑了。 if (event.memoryId == 0) { - Get.snackbar('memCatAlarm'.tr, 'memLocalOnlyHint'.tr, - snackPosition: SnackPosition.TOP); + ToastThrottle.show('memory_local_only', + title: 'memCatAlarm'.tr, message: 'memLocalOnlyHint'.tr); + unawaited(controller.refreshFromServer(silent: true)); return; } final item = MemoryService.to.items @@ -340,8 +348,9 @@ class TodoScopeList extends GetView { Future _toggleDoneGuarded(CalendarEvent event) async { if (event.memoryId == 0) { - Get.snackbar('memCatAlarm'.tr, 'memLocalOnlyHint'.tr, - snackPosition: SnackPosition.TOP); + ToastThrottle.show('memory_local_only', + title: 'memCatAlarm'.tr, message: 'memLocalOnlyHint'.tr); + unawaited(controller.refreshFromServer(silent: true)); return; } await _toggleDone(event); @@ -353,8 +362,8 @@ class TodoScopeList extends GetView { if (item == null) return; final ok = await MemoryService.to.complete([item.id], !item.isDone); if (!ok) { - Get.snackbar('error'.tr, 'memCompleteFailed'.tr, - snackPosition: SnackPosition.TOP); + ToastThrottle.show('memory_complete_failed', + title: 'error'.tr, message: 'memCompleteFailed'.tr); } }