Browse Source

拾忆:修分类筛选无效、加自动补传与提示节流;试修 iOS 播报

真机反馈的三条。

## 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) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
4c5b6f0c65
  1. 49
      apps/client/lib/core/utils/toast_throttle.dart
  2. 5
      apps/client/lib/data/services/account_service.dart
  3. 43
      apps/client/lib/data/services/assistant_directive_service.dart
  4. 24
      apps/client/lib/data/utils/pcm_stream_player.dart
  5. 20
      apps/client/lib/modules/home/controllers/calendar_controller.dart
  6. 25
      apps/client/lib/modules/main_tab/views/widgets/todo_scope_list.dart

49
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<String, DateTime> _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();
}

5
apps/client/lib/data/services/account_service.dart

@ -9,6 +9,7 @@ import '../services/network/api.dart';
import 'db/database_helper.dart'; import 'db/database_helper.dart';
import '../../data/models/user_Info.dart'; import '../../data/models/user_Info.dart';
import 'meeting/meeting_upload_service.dart'; import 'meeting/meeting_upload_service.dart';
import '../../core/utils/toast_throttle.dart';
import 'bes_device_auth.dart'; import 'bes_device_auth.dart';
import 'memory_reminder_scheduler.dart'; import 'memory_reminder_scheduler.dart';
import 'memory_service.dart'; import 'memory_service.dart';
@ -47,6 +48,8 @@ class AccountService extends GetxService {
if (Get.isRegistered<MemoryService>()) { if (Get.isRegistered<MemoryService>()) {
MemoryService.to.clear(); MemoryService.to.clear();
} }
// 上个账号的提示节流状态不该影响下一个
ToastThrottle.clear();
if (Get.isRegistered<MemoryReminderScheduler>()) { if (Get.isRegistered<MemoryReminderScheduler>()) {
unawaited(MemoryReminderScheduler.to.clear()); unawaited(MemoryReminderScheduler.to.clear());
} }
@ -67,6 +70,8 @@ class AccountService extends GetxService {
if (Get.isRegistered<MemoryService>()) { if (Get.isRegistered<MemoryService>()) {
MemoryService.to.clear(); MemoryService.to.clear();
} }
// 上个账号的提示节流状态不该影响下一个
ToastThrottle.clear();
if (Get.isRegistered<MemoryReminderScheduler>()) { if (Get.isRegistered<MemoryReminderScheduler>()) {
unawaited(MemoryReminderScheduler.to.clear()); unawaited(MemoryReminderScheduler.to.clear());
} }

43
apps/client/lib/data/services/assistant_directive_service.dart

@ -129,6 +129,49 @@ class AssistantDirectiveService extends GetxService {
unawaited(_uploadReminder(reminder)); unawaited(_uploadReminder(reminder));
} }
/// 把所有**还没同步**的本地闹钟补传一遍。
///
/// 由拾忆页的刷新(进页面 / 下拉 / 切前台)驱动。存在的意义是自愈:
/// 服务端没部署、断网、或恰好那一下超时,闹钟都会先落在本地 `synced=false`,
/// 等下一次能连上时自动补上去,不需要用户做任何事、也不该反复弹提示催他。
///
/// 并发保护:多个入口可能同时触发刷新,重入会把同一条传两遍(服务端有
/// (uid, client_key) 唯一索引兜底不会插重,但白跑一趟)。
bool _syncing = false;
Future<int> syncPending() async {
if (_syncing) return 0;
if (!Get.isRegistered<MemoryService>() || !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 去重只在**本进程内存**里 /// ⚠️ **幂等键用指令 id**:端侧指令会重试,而本类的按 id 去重只在**本进程内存**里

24
apps/client/lib/data/utils/pcm_stream_player.dart

@ -64,11 +64,26 @@ class PcmStreamPlayer {
/// 调用方据此给服务端回 LocalRespondingEnded。 /// 调用方据此给服务端回 LocalRespondingEnded。
Stream<void> get onCompleted => _completedCtl.stream; Stream<void> get onCompleted => _completedCtl.stream;
/// 配置音频会话(只做一次;配置本身是静态的)并**每轮激活一次**。
///
/// ⚠️ 激活必须每轮都做,不能只在首次配置时做(iOS 真机踩到):
/// 手机这条路径是「按住说话」,`MicPcmStreamer` 用 record 的
/// `echoCancel/noiseSuppress`,在 iOS 上会让插件把 AVAudioSession 切到
/// voice-processing(.voiceChat)并自己持有;松手时 `_mic.stop()` 只停引擎,
/// **不会把会话还原**。随后这一轮回复音频到达,AVPlayer 在那个会话上
/// 起不来,报 `-11850 Operation Stopped`,而且每来一包就重试一次,
/// 日志里刷成几十条。
///
/// 显式 setActive(true) 是把会话按本方的配置重新激活一次,
/// 相当于「录音刚放开,播放把它拿回来」。
Future<void> _ensureSession() async { Future<void> _ensureSession() async {
if (_sessionConfigured) return;
_sessionConfigured = true;
try { try {
final session = await AudioSession.instance; final session = await AudioSession.instance;
if (_sessionConfigured) {
await session.setActive(true);
return;
}
_sessionConfigured = true;
// ⚠️ usage 千万别用 voiceCommunication。 // ⚠️ usage 千万别用 voiceCommunication。
// 华为(EMUI/HarmonyOS)的音频策略对通话流卡得很严:不在 // 华为(EMUI/HarmonyOS)的音频策略对通话流卡得很严:不在
// MODE_IN_COMMUNICATION 下建的 VOICE_COMMUNICATION 音轨会被直接静音, // MODE_IN_COMMUNICATION 下建的 VOICE_COMMUNICATION 音轨会被直接静音,
@ -93,6 +108,7 @@ class PcmStreamPlayer {
// 助手说话时把背景音乐暂停掉,而不是压低——压低了在嘈杂环境里还是听不清 // 助手说话时把背景音乐暂停掉,而不是压低——压低了在嘈杂环境里还是听不清
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient, androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
)); ));
await session.setActive(true);
} catch (e) { } catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试播放): $e'); Logger.w(_tag, '音频会话配置失败(继续尝试播放): $e');
} }
@ -131,6 +147,10 @@ class PcmStreamPlayer {
Logger.e(_tag, '启动播放失败: $e'); Logger.e(_tag, '启动播放失败: $e');
_active = false; _active = false;
_pending.clear(); _pending.clear();
// 起播失败多半是会话被别人(录音的 voice-processing)占着。
// 复位标志,下一轮重新走完整的 configure + setActive,
// 而不是只补一次 setActive —— 只激活救不回被改过 category/mode 的会话。
_sessionConfigured = false;
} finally { } finally {
_starting = false; _starting = false;
} }

20
apps/client/lib/modules/home/controllers/calendar_controller.dart

@ -233,6 +233,11 @@ class CalendarController extends GetxController
/// 拉取当前可见窗口的数据。下拉刷新与首次进入都走它。 /// 拉取当前可见窗口的数据。下拉刷新与首次进入都走它。
Future<void> refreshFromServer({bool silent = false}) async { Future<void> refreshFromServer({bool silent = false}) async {
if (!Get.isRegistered<MemoryService>()) return; if (!Get.isRegistered<MemoryService>()) return;
// 先把本地没同步上去的闹钟补传,再拉——顺序反了的话刚补传的这次拉不到,
// 要等下一次刷新才显示。
if (Get.isRegistered<AssistantDirectiveService>()) {
await AssistantDirectiveService.to.syncPending();
}
final today = DateTime.now(); final today = DateTime.now();
await MemoryService.to.load( await MemoryService.to.load(
start: today.subtract(const Duration(days: 30)), start: today.subtract(const Duration(days: 30)),
@ -357,11 +362,13 @@ class CalendarController extends GetxController
// ==================== 日期选择 ==================== // ==================== 日期选择 ====================
/// 用户点击某日(月视图或周视图):切换日期后重置代办/花销筛选,显示全部 /// 用户点击某日(月视图或周视图)。
///
/// ⚠️ **不再重置分类筛选**:旧首页的筛选是折叠态横向条上的临时开关,切天清掉合理;
/// 拾忆页的筛选条是常驻的,用户选了「花销」再点一天就被清空,会显得筛选按钮失灵。
void onDaySelected(DateTime day) { void onDaySelected(DateTime day) {
selectedDate.value = day; selectedDate.value = day;
focusedDay.value = DateTime(day.year, day.month, 1); focusedDay.value = DateTime(day.year, day.month, 1);
categoryFilter.value = null;
} }
/// 回到今天(替代参考里的"今天"FAB) /// 回到今天(替代参考里的"今天"FAB)
@ -467,8 +474,15 @@ class CalendarController extends GetxController
} }
/// 当前范围内的全部事件,按日期升序(同一天内保持原顺序) /// 当前范围内的全部事件,按日期升序(同一天内保持原顺序)
/// 当前范围内、且通过分类筛选的事项。
///
/// ⚠️ **必须读 categoryFilter**:拾忆页顶部的分类筛选条改的就是它。
/// 早先这里只按日期过滤,于是筛选条点了没有任何反应——值改了但没人读。
List<CalendarEvent> get eventsForScope { List<CalendarEvent> 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)); list.sort((a, b) => a.date.compareTo(b.date));
return list; return list;
} }

25
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_event.dart';
import '../../../home/views/widgets/calendar/calendar_tokens.dart'; import '../../../home/views/widgets/calendar/calendar_tokens.dart';
import '../../../home/views/widgets/calendar/kids_card.dart'; import '../../../home/views/widgets/calendar/kids_card.dart';
import 'dart:async';
import '../../../../core/theme/app_palette.dart'; import '../../../../core/theme/app_palette.dart';
import '../../../../core/utils/toast_throttle.dart';
import '../../../../data/models/memory_item.dart'; import '../../../../data/models/memory_item.dart';
import '../../../../data/services/memory_service.dart'; import '../../../../data/services/memory_service.dart';
import 'memory_edit_sheet.dart'; import 'memory_edit_sheet.dart';
@ -325,11 +328,16 @@ class TodoScopeList extends GetView<CalendarController> {
/// 先按 memoryId 回查真实的 MemoryItem——日历事件是投影出来的(重复项会摊成 /// 先按 memoryId 回查真实的 MemoryItem——日历事件是投影出来的(重复项会摊成
/// 多天,id 带了日期后缀),不能拿它直接当记忆项用。 /// 多天,id 带了日期后缀),不能拿它直接当记忆项用。
Future<void> _openEdit(CalendarEvent event) async { Future<void> _openEdit(CalendarEvent event) async {
// memoryId == 0 表示这条还没同步到服务端(迁移期的本地助手闹钟)。 // memoryId == 0 表示这条还没同步到服务端(本地助手闹钟,服务端没收下)。
// 它没有服务端 id,改了也没处提交,先明确告诉用户而不是静默无反应。 // 它没有服务端 id,改了没处提交,只能先提示。
//
// ⚠️ 提示走节流:用户连点几下会叠出一串一模一样的条,把内容全挡住。
// 同时**主动触发一次补传**——用户点它就说明他在意这条,
// 正好借这个动作试一次同步,成功了下次点就能编辑了。
if (event.memoryId == 0) { if (event.memoryId == 0) {
Get.snackbar('memCatAlarm'.tr, 'memLocalOnlyHint'.tr, ToastThrottle.show('memory_local_only',
snackPosition: SnackPosition.TOP); title: 'memCatAlarm'.tr, message: 'memLocalOnlyHint'.tr);
unawaited(controller.refreshFromServer(silent: true));
return; return;
} }
final item = MemoryService.to.items final item = MemoryService.to.items
@ -340,8 +348,9 @@ class TodoScopeList extends GetView<CalendarController> {
Future<void> _toggleDoneGuarded(CalendarEvent event) async { Future<void> _toggleDoneGuarded(CalendarEvent event) async {
if (event.memoryId == 0) { if (event.memoryId == 0) {
Get.snackbar('memCatAlarm'.tr, 'memLocalOnlyHint'.tr, ToastThrottle.show('memory_local_only',
snackPosition: SnackPosition.TOP); title: 'memCatAlarm'.tr, message: 'memLocalOnlyHint'.tr);
unawaited(controller.refreshFromServer(silent: true));
return; return;
} }
await _toggleDone(event); await _toggleDone(event);
@ -353,8 +362,8 @@ class TodoScopeList extends GetView<CalendarController> {
if (item == null) return; if (item == null) return;
final ok = await MemoryService.to.complete([item.id], !item.isDone); final ok = await MemoryService.to.complete([item.id], !item.isDone);
if (!ok) { if (!ok) {
Get.snackbar('error'.tr, 'memCompleteFailed'.tr, ToastThrottle.show('memory_complete_failed',
snackPosition: SnackPosition.TOP); title: 'error'.tr, message: 'memCompleteFailed'.tr);
} }
} }

Loading…
Cancel
Save