diff --git a/apps/client/lib/core/utils/toast_throttle.dart b/apps/client/lib/core/utils/toast_throttle.dart index 1273403a..20ad135d 100644 --- a/apps/client/lib/core/utils/toast_throttle.dart +++ b/apps/client/lib/core/utils/toast_throttle.dart @@ -13,11 +13,34 @@ class ToastThrottle { static final Map _last = {}; - /// 同一提示的最小间隔。取 3 秒:比 snackbar 默认停留时间略长, + // ===== 设备/通话闸门提示的固定 key ===== + // + // ⚠️ 跨页面必须共用同一把 key:AI 页的卡片和翻译中转页弹的是同一句话, + // 各用各的 key 会让用户在两处之间来回点时照样连着弹。 + + /// 「需要在通话中」——没在通话却点了通话翻译/通话录音 + static const String keyCallRequired = 'gate.call_required'; + + /// 「通话中不可用」——通话里点了现场录音/同传/面对面/多媒体 + static const String keyInCallBusy = 'gate.in_call_busy'; + + /// 「请先连接设备」 + static const String keyConnectDevice = 'gate.connect_device'; + + /// 同一提示的默认最小间隔。取 3 秒:比 snackbar 默认停留时间略长, /// 保证「上一条还没消失就不会再来一条」。 static const Duration window = Duration(seconds: 3); + /// 「这个功能现在就是用不了」这类闸门提示的间隔,比默认长得多。 + /// + /// 区别在于用户还会不会再点:普通提示(保存失败之类)用户看完就换动作了, + /// 3 秒够;而灰着的功能卡片用户会连点好几下试,每 3 秒补一条同样的提示 + /// 一样是刷屏。这里的信息量只有第一条,后面全是噪音。 + static const Duration gateWindow = Duration(seconds: 8); + /// 弹一条受节流的提示。被节流掉时返回 false。 + /// + /// [window] 不传就用 [ToastThrottle.window];闸门类提示传 [gateWindow]。 static bool show( String key, { required String title, @@ -25,10 +48,12 @@ class ToastThrottle { SnackPosition position = SnackPosition.TOP, Color? background, Color? textColor, + Duration? window, }) { + final gap = window ?? ToastThrottle.window; final now = DateTime.now(); final last = _last[key]; - if (last != null && now.difference(last) < window) return false; + if (last != null && now.difference(last) < gap) return false; _last[key] = now; Get.snackbar( title, diff --git a/apps/client/lib/modules/home/controllers/home_controller.dart b/apps/client/lib/modules/home/controllers/home_controller.dart index f1228b33..d3bfc0c6 100644 --- a/apps/client/lib/modules/home/controllers/home_controller.dart +++ b/apps/client/lib/modules/home/controllers/home_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import '/core/utils/id_verify_guard.dart'; import '/core/utils/device_connection.dart'; +import '/core/utils/toast_throttle.dart'; import 'package:device_jieli/device_jieli.dart'; import 'package:get/get.dart'; @@ -271,25 +272,30 @@ class HomeController extends GetxController { // 三条链路任意一条连上都算已连接(杰理 / 蓝讯支架 / 恒玄耳机)。 // 之前只看 _isFullyConnected(杰理专用),恒玄耳机连上了照样被拦。 // 通话翻译还要求正处于通话中——耳机得先进通话模式(0xD1)才有双路音频 + // ⚠️ 下面三条一律走 ToastThrottle 而不是裸 Get.snackbar: + // 它们拦的都是「这个功能现在就是用不了」,而卡片只是灰着、点得动, + // 用户会连点几下试——每点一下补一条一模一样的提示就把屏幕糊满了, + // 而且 Get 的 snackbar 会排队,要等它们逐条超时才散得干净。 + // 信息量只在第一条,后面全是噪音。 if (needBle && DeviceConnection.any && !DeviceConnection.inCall) { - Get.snackbar('callRequiredTitle'.tr, 'callRequiredDesc'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2)); + ToastThrottle.show(ToastThrottle.keyCallRequired, + title: 'callRequiredTitle'.tr, + message: 'callRequiredDesc'.tr, + window: ToastThrottle.gateWindow); return; } if (needBle && !DeviceConnection.any) { - Get.snackbar( - 'connectDevice'.tr, // 连接设备 - 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 1), - ); + ToastThrottle.show(ToastThrottle.keyConnectDevice, + title: 'connectDevice'.tr, // 连接设备 + message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 + window: ToastThrottle.gateWindow); return; } if (DeviceConnection.isBlockedByCall(translationId)) { - Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2)); + ToastThrottle.show(ToastThrottle.keyInCallBusy, + title: 'inCallBusyTitle'.tr, + message: 'inCallBusyDesc'.tr, + window: ToastThrottle.gateWindow); return; } AgentService.interruptCurrentResponse(); @@ -301,18 +307,17 @@ class HomeController extends GetxController { void openCallRecording() { if (IdVerifyGuard.blocked()) return; if (DeviceConnection.any && !DeviceConnection.inCall) { - Get.snackbar('callRequiredTitle'.tr, 'callRequiredDesc'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2)); + ToastThrottle.show(ToastThrottle.keyCallRequired, + title: 'callRequiredTitle'.tr, + message: 'callRequiredDesc'.tr, + window: ToastThrottle.gateWindow); return; } if (!DeviceConnection.any) { - Get.snackbar( - 'connectDevice'.tr, // 连接设备 - 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 1), - ); + ToastThrottle.show(ToastThrottle.keyConnectDevice, + title: 'connectDevice'.tr, // 连接设备 + message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 + window: ToastThrottle.gateWindow); return; } Get.toNamed(Routes.recording, @@ -325,9 +330,10 @@ class HomeController extends GetxController { /// (具体分支见 CallRecordingController._startRecording)。 void openFieldRecording() { if (DeviceConnection.isBlockedByCall('fieldRecording')) { - Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2)); + ToastThrottle.show(ToastThrottle.keyInCallBusy, + title: 'inCallBusyTitle'.tr, + message: 'inCallBusyDesc'.tr, + window: ToastThrottle.gateWindow); return; } if (IdVerifyGuard.blocked()) return; diff --git a/apps/client/lib/modules/main_tab/views/widgets/module_card.dart b/apps/client/lib/modules/main_tab/views/widgets/module_card.dart index 522febfa..9be35771 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/module_card.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/module_card.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import '../../../../core/utils/ai_navigation.dart'; import '../../../../core/utils/id_verify_guard.dart'; import '../../../../core/utils/device_connection.dart'; +import '../../../../core/utils/toast_throttle.dart'; import '../../../../data/services/bes_bluetooth_service.dart'; import '../../../../data/models/agent_module.dart'; @@ -307,19 +308,22 @@ void launchModule(AgentModule module) { //(不替调用方重放动作——有些功能带副作用)。 if (IdVerifyGuard.blocked()) return; // 通话中:现场录音/同声翻译/面对面翻译/多媒体翻译 都不可用 + // ⚠️ 这里的卡片只是画成灰的(ModuleSquareCard 的 enabled 只管透明度), + // 点击照样走到这儿。用户看到灰卡片会连点几下试,所以这两条必须节流—— + // 否则一串一模一样的提示叠在屏幕上,还要逐条等超时才散。 if (DeviceConnection.isBlockedByCall(module.id)) { - Get.snackbar('inCallBusyTitle'.tr, 'inCallBusyDesc'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2)); + ToastThrottle.show(ToastThrottle.keyInCallBusy, + title: 'inCallBusyTitle'.tr, + message: 'inCallBusyDesc'.tr, + window: ToastThrottle.gateWindow); return; } if (module.comingSoon) { - Get.snackbar( - module.title.tr, - 'featureInDev'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), - ); + // 按模块分 key:不同功能的「开发中」是不同的话,互相不该被对方节流掉 + ToastThrottle.show('gate.coming_soon.${module.id}', + title: module.title.tr, + message: 'featureInDev'.tr, + window: ToastThrottle.gateWindow); return; } // 每次进功能都刷一次耳机状态(版本/按键/EQ)——耳机可能在后台被摘下、 diff --git a/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart b/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart index 24fe3fbc..e9989d03 100644 --- a/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart +++ b/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart @@ -1,6 +1,7 @@ // import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; +import '../../../../core/utils/toast_throttle.dart'; import '../../../../routes/app_routes.dart'; import '../../../../data/services/ble_manager.dart'; @@ -92,10 +93,12 @@ class TranslationMiddlewareController extends GetxController { // 不可用则提示用户连接设备 if (!isOptionEnabled(option)) { // 连接设备 / 请连接 Eaimar 设备 - Get.snackbar('connectDevice'.tr, - 'pleaseConnectDeepSoundDevice'.tr, // 连接设备 / 请连接 Eaimar 设备 - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 1)); + // 与 AI 页卡片共用同一把 key:两处弹的是同一句话,各用各的 key 会让 + // 用户在两个页面之间来回点时照样连着弹。 + ToastThrottle.show(ToastThrottle.keyConnectDevice, + title: 'connectDevice'.tr, + message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 + window: ToastThrottle.gateWindow); //EasyLoading.showInfo('connectDevice'.tr); return; }