From 2d709f9cd0a9b04048374607f42a2617c781d5fb Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 12 Jan 2026 19:41:32 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(=E5=BD=95=E9=9F=B3):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=BD=95=E9=9F=B3=E6=82=AC=E6=B5=AE=E7=AA=97=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E5=BD=95=E9=9F=B3=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8D=E6=98=BE=E7=A4=BA=EF=BC=8C=E6=AD=A4=E5=A4=96?= =?UTF-8?q?=E9=83=BD=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现应用生命周期状态监听,优化录音状态指示器的显示逻辑 移除未使用的波形着色器代码,简化视图层实现 将底部弹窗的insets参数从视图层传递到控制器 --- .../meeting_record_controller.dart | 253 +++++++++++++++++- .../meeting/views/meeting_record_view.dart | 21 +- 2 files changed, 255 insertions(+), 19 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 9f3a2398f..ea598852c 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -8,6 +8,7 @@ import '/core/utils/overlay_permission_util.dart'; import '/core/utils/permission_util.dart'; import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:floating_ui_plugin/native_plugin.dart'; @@ -28,14 +29,20 @@ enum TimerStatus { /// 会议记录控制器 /// 负责会议记录的状态管理、音频处理、语音识别和UI交互 class MeetingRecordController extends GetxController - with GetTickerProviderStateMixin { + with GetTickerProviderStateMixin, WidgetsBindingObserver { static const int _backgroundRecordingNotificationId = 91001; static FlutterLocalNotificationsPlugin? _localNotificationsPlugin; final NativePlugin _nativePlugin = NativePlugin(); Timer? _recordingFloatingBarTimer; + Timer? _routeMonitorTimer; StreamSubscription? _recordingFloatingActionSubscription; bool _floatingBarManuallyClosed = false; + AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + String _lastKnownRoute = ''; + + OverlayEntry? _recordingInAppOverlay; + Offset? _recordingInAppOverlayOffset; Future _ensureLocalNotificationsPlugin() async { @@ -76,8 +83,15 @@ class MeetingRecordController extends GetxController await _requestNotificationPermissionIfNeeded(); if (!Platform.isIOS) return; + if (_floatingBarManuallyClosed) return; + if (_appLifecycleState == AppLifecycleState.resumed) { + await _showInAppRecordingOverlay(); + return; + } + + _hideInAppRecordingOverlay(); final plugin = await _ensureLocalNotificationsPlugin(); - final details = NotificationDetails( + const details = NotificationDetails( iOS: DarwinNotificationDetails( presentAlert: true, presentBadge: false, @@ -102,6 +116,8 @@ class MeetingRecordController extends GetxController if (!Platform.isIOS) return; final plugin = await _ensureLocalNotificationsPlugin(); await plugin.cancel(_backgroundRecordingNotificationId); + _floatingBarManuallyClosed = false; + _hideInAppRecordingOverlay(); } String _formatTimeForFloatingBar(int ms) { @@ -154,7 +170,7 @@ class MeetingRecordController extends GetxController _recordingFloatingBarTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (!isRecording.value) { - _stopAndroidRecordingFloatingBar(); + unawaited(_stopAndroidRecordingFloatingBar()); return; } _nativePlugin.updateRecordingFloatingBar( @@ -262,15 +278,23 @@ class MeetingRecordController extends GetxController _initializeComponents(); _setupFromArguments(); _setupRecordingFloatingBarActionListener(); + WidgetsBinding.instance.addObserver(this); //generateWaveData(); } @override void onClose() { _cleanupResources(); + WidgetsBinding.instance.removeObserver(this); super.onClose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appLifecycleState = state; + unawaited(_refreshRecordingIndicator()); + } + void applyRouteArgumentsIfIdle() { if (isEditingTitle.value) return; if (isRecording.value) return; @@ -332,9 +356,12 @@ class MeetingRecordController extends GetxController /// 控制器销毁时清理资源 void _cleanupResources() { _timer?.cancel(); - _stopAndroidRecordingFloatingBar(); + unawaited(_stopAndroidRecordingFloatingBar()); + _routeMonitorTimer?.cancel(); + _routeMonitorTimer = null; _recordingFloatingActionSubscription?.cancel(); _recordingFloatingActionSubscription = null; + _hideInAppRecordingOverlay(); _stopWaveformTimer(); _recognitionSubscription?.cancel(); _recognitionSubscription = null; @@ -472,6 +499,7 @@ class MeetingRecordController extends GetxController _resetWaveform(); _startWaveformTimer(); startTimer(); + await _refreshRecordingIndicator(); } /// 根据音频类型启动对应的硬件服务 @@ -497,6 +525,7 @@ class MeetingRecordController extends GetxController _stopWaveformTimer(); rms.value = 0.0; pauseTimer(); + await _refreshRecordingIndicator(); } /// 恢复已暂停的录音会话 @@ -504,6 +533,7 @@ class MeetingRecordController extends GetxController await _asrService.resumeRecord(); _startWaveformTimer(); startTimer(); + await _refreshRecordingIndicator(); } /// 处理原始Uint8List音频数据 @@ -609,6 +639,7 @@ class MeetingRecordController extends GetxController _smoothedRms = 0.0; _latestRms = 0.0; _resetWaveform(); + await _refreshRecordingIndicator(); } Future discardRecording() async { @@ -627,6 +658,7 @@ class MeetingRecordController extends GetxController _smoothedRms = 0.0; _latestRms = 0.0; _resetWaveform(); + await _refreshRecordingIndicator(); } /// 将控制器重置到初始状态 @@ -692,4 +724,217 @@ class MeetingRecordController extends GetxController seconds.value = 0; timerStatus.value = TimerStatus.stopped; } + + bool _shouldShowRecordingIndicator() { + if (!isRecording.value) return false; + if (_appLifecycleState != AppLifecycleState.resumed) return true; + return Get.currentRoute != Routes.meetingRecord; + } + + Future _refreshRecordingIndicator() async { + if (!isRecording.value) { + _routeMonitorTimer?.cancel(); + _routeMonitorTimer = null; + _hideInAppRecordingOverlay(); + await _stopAndroidRecordingFloatingBar(); + return; + } + + _startRouteMonitorIfNeeded(); + final shouldShow = _shouldShowRecordingIndicator(); + if (!shouldShow) { + _hideInAppRecordingOverlay(); + await _stopAndroidRecordingFloatingBar(); + if (Platform.isIOS) { + final plugin = await _ensureLocalNotificationsPlugin(); + await plugin.cancel(_backgroundRecordingNotificationId); + } + return; + } + + if (_floatingBarManuallyClosed) return; + + if (Platform.isAndroid) { + await showBackgroundRecordingIndicator(); + return; + } + + if (Platform.isIOS) { + if (_appLifecycleState == AppLifecycleState.resumed) { + final plugin = await _ensureLocalNotificationsPlugin(); + await plugin.cancel(_backgroundRecordingNotificationId); + await _showInAppRecordingOverlay(); + } else { + await showBackgroundRecordingIndicator(); + } + } + } + + void _startRouteMonitorIfNeeded() { + if (_routeMonitorTimer != null) return; + _lastKnownRoute = Get.currentRoute; + _routeMonitorTimer = Timer.periodic(const Duration(milliseconds: 400), (_) { + final currentRoute = Get.currentRoute; + if (currentRoute == _lastKnownRoute) return; + _lastKnownRoute = currentRoute; + unawaited(_refreshRecordingIndicator()); + }); + } + + Future _showInAppRecordingOverlay() async { + if (!Platform.isIOS) return; + if (_recordingInAppOverlay != null) return; + final context = Get.overlayContext; + if (context == null) return; + + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + final safeTop = mediaQuery.padding.top; + final safeBottom = mediaQuery.padding.bottom; + + final width = 260.w; + final height = 52.h; + final initial = _recordingInAppOverlayOffset ?? + Offset(16.w, max(16.h + safeTop, 100.h)); + _recordingInAppOverlayOffset = Offset( + initial.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)), + initial.dy.clamp( + 8.h + safeTop, + max(8.h + safeTop, screenSize.height - height - 8.h - safeBottom), + ), + ); + + _recordingInAppOverlay = OverlayEntry( + builder: (_) { + final offset = _recordingInAppOverlayOffset ?? Offset(16.w, 120.h); + return Positioned( + left: offset.dx, + top: offset.dy, + child: Material( + color: Colors.transparent, + child: GestureDetector( + onPanUpdate: (details) { + final next = Offset( + offset.dx + details.delta.dx, + offset.dy + details.delta.dy, + ); + _recordingInAppOverlayOffset = Offset( + next.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)), + next.dy.clamp( + 8.h + safeTop, + max( + 8.h + safeTop, + screenSize.height - height - 8.h - safeBottom, + ), + ), + ); + _recordingInAppOverlay?.markNeedsBuild(); + }, + child: Container( + width: width, + height: height, + padding: EdgeInsets.symmetric(horizontal: 10.w), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(26.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 10.r, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Icon( + Icons.mic_rounded, + size: 18.sp, + color: Colors.white, + ), + SizedBox(width: 8.w), + Expanded( + child: Obx(() { + final durationText = + _formatTimeForFloatingBar(milliseconds.value); + final typeText = _getRecordingTypeText(); + return Text( + '$durationText · $typeText', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontWeight: FontWeight.w600, + ), + ); + }), + ), + SizedBox(width: 6.w), + Obx(() { + final paused = timerStatus.value == TimerStatus.paused; + return InkWell( + onTap: togglePauseResumeFromFloatingBar, + borderRadius: BorderRadius.circular(18.r), + child: Padding( + padding: EdgeInsets.all(6.w), + child: Icon( + paused + ? Icons.play_arrow_rounded + : Icons.pause_rounded, + size: 18.sp, + color: Colors.white, + ), + ), + ); + }), + InkWell( + onTap: () { + _floatingBarManuallyClosed = false; + if (Get.currentRoute == Routes.meetingRecord) return; + Get.toNamed( + Routes.meetingRecord, + arguments: {'audioTypes': audioType.value}, + ); + }, + borderRadius: BorderRadius.circular(18.r), + child: Padding( + padding: EdgeInsets.all(6.w), + child: Icon( + Icons.open_in_new_rounded, + size: 18.sp, + color: Colors.white, + ), + ), + ), + InkWell( + onTap: () { + _floatingBarManuallyClosed = true; + _hideInAppRecordingOverlay(); + }, + borderRadius: BorderRadius.circular(18.r), + child: Padding( + padding: EdgeInsets.all(6.w), + child: Icon( + Icons.close_rounded, + size: 18.sp, + color: Colors.white70, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + Overlay.of(context, rootOverlay: true).insert(_recordingInAppOverlay!); + } + + void _hideInAppRecordingOverlay() { + _recordingInAppOverlay?.remove(); + _recordingInAppOverlay = null; + } } diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 654bf1653..48ef7e0cd 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -367,17 +367,6 @@ class MeetingRecordView extends GetView { final size = Size(screenWidth, 200.h); final waveColor = isDarkMode ? Colors.white : Colors.black87; - final shader = ui.Gradient.linear( - const Offset(0, 0), - Offset(size.width, 0), - [ - waveColor.withValues(alpha: 0.0), - waveColor.withValues(alpha: 0.25), - waveColor.withValues(alpha: 0.6), - waveColor.withValues(alpha: 0.95), - ], - const [0.0, 0.55, 0.8, 1.0], - ); return Center( child: AudioWaveforms( @@ -399,7 +388,6 @@ class MeetingRecordView extends GetView { showTop: true, showBottom: true, extendWaveform: true, - // gradient: shader, waveColor: waveColor, scaleFactor: 90, ), @@ -517,7 +505,10 @@ class MeetingRecordView extends GetView { ), // 完成按钮 _buildActionButton( - onTap: () => _bottomSheet(context, isDarkMode), + onTap: () => _bottomSheet( + isDarkMode, + MediaQuery.of(context).viewInsets.bottom, + ), child: Icon( Icons.check_rounded, size: 36.sp, @@ -616,7 +607,7 @@ class MeetingRecordView extends GetView { } // 底部编辑弹窗 - void _bottomSheet(BuildContext context, bool isDarkMode) async { + void _bottomSheet(bool isDarkMode, double bottomInset) async { //如果当前计时器在运行,先暂停录音,避免编辑时继续录音 if (controller.timerStatus.value == TimerStatus.running) { await controller.toggleRecording(); @@ -628,7 +619,7 @@ class MeetingRecordView extends GetView { left: 20.w, right: 20.w, top: 20.w, - bottom: MediaQuery.of(context).viewInsets.bottom + 20.w, + bottom: bottomInset + 20.w, ), decoration: BoxDecoration( color: isDarkMode ? AppColors.darkBackground : Colors.white, From fef859e0d3db3ce0bb2385a5c21a8550e480b609 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 12 Jan 2026 20:41:00 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(ble):=20=E5=AE=9E=E7=8E=B0BLE?= =?UTF-8?q?=E5=94=A4=E9=86=92=E7=AD=96=E7=95=A5=E7=9A=84=E9=9B=86=E4=B8=AD?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构BLE唤醒功能的控制逻辑,将其从各页面迁移至AppLifecycleManager统一管理 添加路由检测和录音任务检测来自动更新唤醒策略 优化错误处理和日志记录 --- lib/core/utils/app_lifecycle_manager.dart | 52 ++++++++++++++++++- lib/main.dart | 11 ++++ .../home/controllers/home_controller.dart | 35 ++----------- .../meeting/views/meeting_record_view.dart | 16 +++--- .../agent_service/AgentServiceImpl.swift | 9 +++- .../agent_service/AgentServicePlugin.swift | 8 +++ .../agent_service/lib/agent_service.dart | 8 ++- 7 files changed, 98 insertions(+), 41 deletions(-) diff --git a/lib/core/utils/app_lifecycle_manager.dart b/lib/core/utils/app_lifecycle_manager.dart index 6c4e178c5..5ba7e1399 100644 --- a/lib/core/utils/app_lifecycle_manager.dart +++ b/lib/core/utils/app_lifecycle_manager.dart @@ -1,6 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:agent_service/agent_service.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import '../../modules/meeting/controllers/meeting_record_controller.dart'; +import '../../routes/app_routes.dart'; import 'logger.dart'; import '../translations/app_translations.dart'; //import '../../modules/settings/controllers/settings_controller.dart'; @@ -23,17 +28,19 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver { final RxBool _isInForeground = true.obs; bool get isInForeground => _isInForeground.value; + bool? _lastCanWakeUp; + @override void onInit() { super.onInit(); // 注册应用生命周期观察者 WidgetsBinding.instance.addObserver(this); Logger.info('AppLifecycleManager 已初始化'); + unawaited(refreshBleWakeupPolicy()); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - final previousState = _appLifecycleState.value; _appLifecycleState.value = state; _lifecycleStateStream.value = state; @@ -44,6 +51,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver { case AppLifecycleState.resumed: _isInForeground.value = true; _handleAppResumed(); + unawaited(refreshBleWakeupPolicy()); break; case AppLifecycleState.paused: case AppLifecycleState.inactive: @@ -56,6 +64,48 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver { } } + Future refreshBleWakeupPolicy({String? route}) async { + final currentRoute = route ?? Get.currentRoute; + + final bool hasMeetingRecordingTask = _hasMeetingRecordingTask(); + final bool shouldDisableWakeup = hasMeetingRecordingTask || + _isMeetingRoute(currentRoute) || + _isTranslationRoute(currentRoute); + + final bool canWakeUp = !shouldDisableWakeup; + if (_lastCanWakeUp == canWakeUp) return; + _lastCanWakeUp = canWakeUp; + + try { + Logger.info('lxm---设置是否可以ble唤醒agent: $canWakeUp'); + await AgentService.setCanWakeUp(canWakeUp); + } catch (e) { + Logger.warning('setCanWakeUp失败: $e'); + } + } + + bool _isMeetingRoute(String route) { + if (route.isEmpty) return false; + if (route == Routes.profileMeeting) return true; + return route == Routes.meeting || route.startsWith('${Routes.meeting}/'); + } + + bool _isTranslationRoute(String route) { + if (route.isEmpty) return false; + if (route == Routes.translationMiddleware) return true; + if (route == Routes.pictureTranslation) return true; + if (route == Routes.textTranslation) return true; + return route == Routes.translation || + route.startsWith('${Routes.translation}/'); + } + + bool _hasMeetingRecordingTask() { + if (!Get.isRegistered()) return false; + final mrc = Get.find(); + return mrc.isRecording.value || + mrc.timerStatus.value != TimerStatus.stopped; + } + /// 处理应用进入前台 void _handleAppResumed() { // Logger.info('应用进入前台'); diff --git a/lib/main.dart b/lib/main.dart index 8ceedabe1..001c35645 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; @@ -8,6 +10,7 @@ import 'package:get_storage/get_storage.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'core/utils/app_lifecycle_manager.dart'; import 'routes/app_pages.dart'; import 'routes/app_routes.dart'; import 'core/translations/app_translations.dart'; @@ -127,6 +130,14 @@ class MainApp extends StatelessWidget { initialBinding: InitialBinding(), initialRoute: Routes.splash, getPages: AppPages.pages, + routingCallback: (routing) { + if (!Get.isRegistered()) return; + unawaited( + Get.find().refreshBleWakeupPolicy( + route: routing?.current, + ), + ); + }, debugShowCheckedModeBanner: false, translations: AppTranslations(), localizationsDelegates: const [ diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 6073ecdcb..0dede3002 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -11,7 +11,6 @@ import '../../../data/services/ble_manager.dart'; import '../../../data/services/ota_check_service.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; -import '../../meeting/controllers/meeting_record_controller.dart'; /// 连接状态枚举 enum ConnectionStatus { @@ -241,13 +240,9 @@ class HomeController extends GetxController { Get.toNamed(Routes.pairing); } - void openTranslation() async { + void openTranslation() { AgentService.interruptCurrentResponse(); - AgentService.setCanWakeUp(false); - Logger.d(_tag, 'liwei-----------关闭唤醒功能'); - await Get.toNamed(Routes.translationMiddleware); - AgentService.setCanWakeUp(true); - Logger.d(_tag, 'liwei-----------打开唤醒功能'); + Get.toNamed(Routes.translationMiddleware); } /// 打开会议模块 @@ -255,31 +250,9 @@ class HomeController extends GetxController { /// 返回值:`Future` 无返回数据 /// 异常:不显式抛出;内部仅记录日志 /// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用) - void openMeet() async { + void openMeet() { AgentService.interruptCurrentResponse(); - AgentService.setCanWakeUp(false); - Logger.d(_tag, 'liwei-----------关闭唤醒功能'); - await Get.toNamed(Routes.meeting); - try { - if (Get.isRegistered()) { - final mrc = Get.find(); - final hasRecording = mrc.isRecording.value || - mrc.timerStatus.value != TimerStatus.stopped; - if (hasRecording) { - AgentService.setCanWakeUp(false); - Logger.d(_tag, 'openMeet返回:检测到录音任务,保持关闭唤醒功能'); - } else { - AgentService.setCanWakeUp(true); - Logger.d(_tag, 'openMeet返回:无录音任务,打开唤醒功能'); - } - } else { - AgentService.setCanWakeUp(true); - Logger.d(_tag, 'openMeet返回:未注册MeetingRecordController,默认打开唤醒功能'); - } - } catch (e) { - AgentService.setCanWakeUp(true); - Logger.e(_tag, 'openMeet返回:检测录音任务异常,默认打开唤醒功能: $e'); - } + Get.toNamed(Routes.meeting); } void exploreAIFeatures() { diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 48ef7e0cd..5950067da 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import '../controllers/meeting_record_controller.dart'; import '../../../core/theme/app_colors.dart'; +/// 会议录音退出操作 enum _MeetingRecordExitAction { saveAndEnd, endWithoutSave, @@ -13,6 +14,7 @@ enum _MeetingRecordExitAction { cancel, } +/// 会议录音页面 class MeetingRecordView extends GetView { const MeetingRecordView({super.key}); @@ -24,6 +26,7 @@ class MeetingRecordView extends GetView { }); } + /// 显示退出确认弹窗 Future<_MeetingRecordExitAction?> _showExitConfirmDialog(bool isDarkMode) { return Get.dialog<_MeetingRecordExitAction>( Dialog( @@ -58,7 +61,7 @@ class MeetingRecordView extends GetView { ), SizedBox(height: 14.h), Text( - 'meetingRecordExitTitle'.tr, // 对应中文:退出录音 + 'meetingRecordExitTitle'.tr, // 退出录音 style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.bold, @@ -67,7 +70,7 @@ class MeetingRecordView extends GetView { ), SizedBox(height: 8.h), Text( - 'meetingRecordExitMessage'.tr, // 对应中文:请选择离开后的操作 + 'meetingRecordExitMessage'.tr, // 请选择离开后的操作 textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, @@ -96,7 +99,7 @@ class MeetingRecordView extends GetView { elevation: 0, ), child: Text( - 'meetingRecordEndAndSave'.tr, // 对应中文:结束录音并保存 + 'meetingRecordEndAndSave'.tr, // 结束录音并保存 style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, @@ -121,7 +124,7 @@ class MeetingRecordView extends GetView { ), ), child: Text( - 'meetingRecordContinueInBackground'.tr, // 对应中文:后台继续录音 + 'meetingRecordContinueInBackground'.tr, // 后台继续录音 style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, @@ -146,7 +149,7 @@ class MeetingRecordView extends GetView { ), ), child: Text( - 'meetingRecordEndWithoutSave'.tr, // 对应中文:结束录音不保存 + 'meetingRecordEndWithoutSave'.tr, // 结束录音不保存 style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, @@ -168,7 +171,7 @@ class MeetingRecordView extends GetView { ), ), child: Text( - 'cancel'.tr, // 对应中文:取消 + 'cancel'.tr, // 取消 style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, @@ -305,6 +308,7 @@ class MeetingRecordView extends GetView { ); } + /// 构建音频标签页 Widget _buildAudioTab(BuildContext context, bool isDarkMode) { return LayoutBuilder( builder: (context, constraints) { diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index b22c0e2c5..98c3d4685 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -80,6 +80,7 @@ class AgentServiceImpl: NSObject { private var isRecognizing: Bool = false private var hasSpeechDetected: Bool = false internal var isAiStreaming: Bool = false + private var isCanWakeUp: Bool = true // 新增:识别"启动中"与"启动中收到停止请求"状态 // 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题 @@ -167,6 +168,10 @@ class AgentServiceImpl: NSObject { private func sendError(_ message: String, code: String = "ERROR") { sendEvent(name: "error", data: ["code": code, "message": message]) } + + func setCanWakeUp(_ canWakeUp: Bool) { + isCanWakeUp = canWakeUp + } private func initSystemPrompt() { systemPrompt = """ @@ -2025,7 +2030,9 @@ extension AgentServiceImpl: BleService.Callback { func onWakeupSignalReceived() { - + if !isCanWakeUp { + return + } startRecognition(useBle: true, mode: "ble_wakeup") } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index 88ed6f736..e8a5d6209 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -194,6 +194,14 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { } let success = impl.setTtsRate(rate) result(success) + case "setCanWakeUp": + guard let arguments = call.arguments as? [String: Any], + let canWakeUp = arguments["canWakeUp"] as? Bool else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "canWakeUp参数不能为空", details: nil)) + return + } + impl.setCanWakeUp(canWakeUp) + result(true) default: result(FlutterMethodNotImplemented) } diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 97737c671..3ddbed06d 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -520,7 +520,10 @@ class AgentService { } } - /// 设置是否可以唤醒 + /// 设置是否可以ble唤醒agent + /// + /// [canWakeUp] 是否可以ble唤醒agent + /// 返回是否设置成功 static Future setCanWakeUp(bool canWakeUp) async { try { final bool result = await _channel.invokeMethod('setCanWakeUp', { @@ -528,7 +531,8 @@ class AgentService { }); return result; } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '设置是否可以唤醒失败', e.details); + throw AgentServiceException( + e.code, e.message ?? '设置是否可以ble唤醒agent失败', e.details); } } From de1a4802d0027ea19303b6d443faea181c5aaec5 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 12 Jan 2026 21:01:35 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(meeting=5Frecord):=20=E5=9C=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=B5=AE=E5=8A=A8=E6=9D=A1=E5=89=8D=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=B9=B6=E4=BF=9D=E5=AD=98=E5=BD=95=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 防止在录音状态下直接关闭浮动条导致录音丢失 --- .../meeting/controllers/meeting_record_controller.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index ea598852c..48fd48925 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -218,6 +218,10 @@ class MeetingRecordController extends GetxController ); return; case 'closeFloatingBar': + if (isRecording.value) { + await saveRecording(); + return; + } _floatingBarManuallyClosed = true; await _stopAndroidRecordingFloatingBar(); return; From 360331318372b5e95bd807903630a60d0be4ece0 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 11:37:04 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(=E5=BD=95=E9=9F=B3):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=BD=95=E9=9F=B3=E4=BF=9D=E5=AD=98=E5=92=8C=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E7=9A=84=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=8F=8A?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加录音保存和结束操作时的错误处理和超时机制,包括: 1. 增加超时和失败时的错误提示 2. 添加保存状态标志防止重复操作 3. 失败时恢复录音界面状态 4. 添加相关翻译文本 --- lib/core/translations/language/en_us.dart | 5 + lib/core/translations/language/zh_cn.dart | 3 + .../meeting_record_controller.dart | 214 +++++++++++++----- .../meeting/views/meeting_record_view.dart | 30 ++- 4 files changed, 187 insertions(+), 65 deletions(-) diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index 9297a6ab4..e64d06e47 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -443,6 +443,11 @@ const Map enUS = { 'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存 'meetingRecordContinueInBackground': 'Continue recording in background', // 后台继续录音 + 'meetingRecordStopTimeout': + 'Stopping the recording timed out. Please try again.', // 结束录音超时,请重试 + 'meetingRecordStopFailed': + 'Failed to stop the recording. Please try again.', // 结束录音失败,请重试 + 'meetingRecordSaveFailed': 'Failed to save the recording. Please try again.', // 保存录音失败,请重试 'meetingRecordRecordingNotificationTitle': 'Recording in progress', // 录音进行中 'meetingRecordRecordingNotificationBody': 'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用 diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 3b1352f64..2c108c5f5 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -419,6 +419,9 @@ const Map zhCN = { 'meetingRecordEndAndSave': '结束录音并保存', // 结束录音并保存 'meetingRecordEndWithoutSave': '结束录音不保存', // 结束录音不保存 'meetingRecordContinueInBackground': '后台继续录音', // 后台继续录音 + 'meetingRecordStopTimeout': '结束录音超时,请重试', // 结束录音超时,请重试 + 'meetingRecordStopFailed': '结束录音失败,请重试', // 结束录音失败,请重试 + 'meetingRecordSaveFailed': '保存录音失败,请重试', // 保存录音失败,请重试 'meetingRecordRecordingNotificationTitle': '录音进行中', // 录音进行中 'meetingRecordRecordingNotificationBody': '正在后台录音,点击返回应用', // 正在后台录音,点击返回应用 'overlayPermissionTitle': '悬浮窗权限', // 悬浮窗权限 diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 48fd48925..405fcef1f 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -31,6 +31,8 @@ enum TimerStatus { class MeetingRecordController extends GetxController with GetTickerProviderStateMixin, WidgetsBindingObserver { static const int _backgroundRecordingNotificationId = 91001; + static const Duration _stopRecordTimeout = Duration(seconds: 12); + static const Duration _fileOpTimeout = Duration(seconds: 8); static FlutterLocalNotificationsPlugin? _localNotificationsPlugin; final NativePlugin _nativePlugin = NativePlugin(); @@ -160,11 +162,18 @@ class MeetingRecordController extends GetxController final typeText = _getRecordingTypeText(); final durationText = _formatTimeForFloatingBar(milliseconds.value); - await _nativePlugin.showRecordingFloatingBar( - duration: durationText, - recordingType: typeText, - isPaused: timerStatus.value == TimerStatus.paused, - ); + try { + await _nativePlugin + .showRecordingFloatingBar( + duration: durationText, + recordingType: typeText, + isPaused: timerStatus.value == TimerStatus.paused, + ) + .timeout(const Duration(seconds: 3)); + } catch (e) { + Logger.error('显示录音悬浮条失败: ${e.toString()}'); + return; + } _recordingFloatingBarTimer?.cancel(); _recordingFloatingBarTimer = @@ -185,7 +194,13 @@ class MeetingRecordController extends GetxController if (!Platform.isAndroid) return; _recordingFloatingBarTimer?.cancel(); _recordingFloatingBarTimer = null; - await _nativePlugin.hideRecordingFloatingBar(); + try { + await _nativePlugin + .hideRecordingFloatingBar() + .timeout(const Duration(seconds: 3)); + } catch (e) { + Logger.error('隐藏录音悬浮条失败: ${e.toString()}'); + } } Future togglePauseResumeFromFloatingBar() async { @@ -246,6 +261,7 @@ class MeetingRecordController extends GetxController final RxBool isMarked = false.obs; final RxBool showTextContent = false.obs; final RxBool isRecording = false.obs; + final RxBool isSaving = false.obs; //final RxBool isVoiceRecognitionActive = false.obs; final RxDouble rms = 0.0.obs; @@ -607,62 +623,152 @@ class MeetingRecordController extends GetxController } /// 保存录音并清理相关资源 - Future saveRecording() async { - resetTimer(); + Future saveRecording() async { + if (isSaving.value) return false; + if (!isRecording.value) return true; + isSaving.value = true; + + final prevStatus = timerStatus.value; + pauseTimer(); + final prevElapsedMs = _elapsedMilliseconds; _stopWaveformTimer(); - if (audioType.value != 0) { - _bleManager.closeCodec(); - } - if (!isRecording.value) return; - await _asrService.stopRecord(true); - await clearBackgroundRecordingIndicator(); - - // 如果标题被修改则重命名文件 - if (fileName.value != newName.value) { - final appDir = await appDocDir; - final dir = Directory("${appDir.path}/MeetingAudio/"); - if (!await dir.exists()) await dir.create(recursive: true); - if (isMarked.value) { - final newDir = Directory("${appDir.path}/DestAudio/"); - if (!await newDir.exists()) await newDir.create(recursive: true); - await _asrService.moveFile( - "${dir.path}${fileName.value}.wav", - "${newDir.path}${fileName.value}.wav", - ); - } else { - await _asrService.renameFile( - "${dir.path}${fileName.value}.wav", - newName.value, - ); + + try { + final bool stopped = await _asrService + .stopRecord(true) + .timeout(_stopRecordTimeout, onTimeout: () => false); + if (!stopped) { + _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); + Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr); + return false; } - } - _resetToDefaultState(); - isRecording.value = false; - rms.value = 0.0; - _smoothedRms = 0.0; - _latestRms = 0.0; - _resetWaveform(); - await _refreshRecordingIndicator(); + if (audioType.value != 0) { + _bleManager.closeCodec(); + } + + try { + await clearBackgroundRecordingIndicator() + .timeout(const Duration(seconds: 3)); + } catch (e) { + Logger.error('清理录音指示器失败: ${e.toString()}'); + } + + try { + await _applyTitleChangeAfterSave(); + } catch (e) { + Logger.error('保存录音后的文件处理失败: ${e.toString()}'); + } + + resetTimer(); + _resetToDefaultState(); + isRecording.value = false; + rms.value = 0.0; + _smoothedRms = 0.0; + _latestRms = 0.0; + _resetWaveform(); + await _refreshRecordingIndicator(); + return true; + } catch (e) { + Logger.error('保存录音失败: ${e.toString()}'); + _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); + Get.snackbar('error'.tr, 'meetingRecordSaveFailed'.tr); + return false; + } finally { + isSaving.value = false; + } } - Future discardRecording() async { - resetTimer(); + Future discardRecording() async { + if (!isRecording.value) return true; + if (isSaving.value) return false; + isSaving.value = true; + + final prevStatus = timerStatus.value; + pauseTimer(); + final prevElapsedMs = _elapsedMilliseconds; _stopWaveformTimer(); - if (audioType.value != 0) { - _bleManager.closeCodec(); + + try { + final bool stopped = await _asrService + .stopRecord(false) + .timeout(_stopRecordTimeout, onTimeout: () => false); + if (!stopped) { + _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); + Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr); + return false; + } + + if (audioType.value != 0) { + _bleManager.closeCodec(); + } + + try { + await clearBackgroundRecordingIndicator() + .timeout(const Duration(seconds: 3)); + } catch (e) { + Logger.error('清理录音指示器失败: ${e.toString()}'); + } + + resetTimer(); + _resetToDefaultState(); + isRecording.value = false; + rms.value = 0.0; + _smoothedRms = 0.0; + _latestRms = 0.0; + _resetWaveform(); + await _refreshRecordingIndicator(); + return true; + } catch (e) { + Logger.error('结束录音不保存失败: ${e.toString()}'); + _restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs); + Get.snackbar('error'.tr, 'meetingRecordStopFailed'.tr); + return false; + } finally { + isSaving.value = false; } - if (isRecording.value) { - await _asrService.stopRecord(false); + } + + void _restoreRecordingUiAfterFailedStop( + TimerStatus prevStatus, + int prevElapsedMs, + ) { + _elapsedMilliseconds = prevElapsedMs; + seconds.value = (prevElapsedMs / 1000).round(); + milliseconds.value = prevElapsedMs; + if (prevStatus == TimerStatus.running) { + timerStatus.value = TimerStatus.stopped; + startTimer(); + } else { + timerStatus.value = prevStatus; } - await clearBackgroundRecordingIndicator(); - _resetToDefaultState(); - isRecording.value = false; - rms.value = 0.0; - _smoothedRms = 0.0; - _latestRms = 0.0; - _resetWaveform(); - await _refreshRecordingIndicator(); + _startWaveformTimer(); + } + + Future _applyTitleChangeAfterSave() async { + if (fileName.value == newName.value) return; + final appDir = await appDocDir; + final dir = Directory("${appDir.path}/MeetingAudio/"); + if (!await dir.exists()) await dir.create(recursive: true); + + if (isMarked.value) { + final newDir = Directory("${appDir.path}/DestAudio/"); + if (!await newDir.exists()) await newDir.create(recursive: true); + await _asrService + .moveFile( + "${dir.path}${fileName.value}.wav", + "${newDir.path}${fileName.value}.wav", + ) + .timeout(_fileOpTimeout); + return; + } + + await _asrService + .renameFile( + "${dir.path}${fileName.value}.wav", + newName.value, + ) + .timeout(_fileOpTimeout); } /// 将控制器重置到初始状态 diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 5950067da..7ce7fa7b7 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -214,14 +214,18 @@ class MeetingRecordView extends GetView { switch (action) { case _MeetingRecordExitAction.saveAndEnd: await controller.saveTitleChange(); - await controller.saveRecording(); - Get.back(result: 'meeting_record_saved'); - _deleteControllerAfterPop(); + final saved = await controller.saveRecording(); + if (saved) { + Get.back(result: 'meeting_record_saved'); + _deleteControllerAfterPop(); + } return; case _MeetingRecordExitAction.endWithoutSave: - await controller.discardRecording(); - Get.back(result: 'meeting_record_discarded'); - _deleteControllerAfterPop(); + final discarded = await controller.discardRecording(); + if (discarded) { + Get.back(result: 'meeting_record_discarded'); + _deleteControllerAfterPop(); + } return; case _MeetingRecordExitAction.continueInBackground: await controller.saveTitleChange(); @@ -695,8 +699,10 @@ class MeetingRecordView extends GetView { textInputAction: TextInputAction.done, onSubmitted: (_) async { await controller.saveTitleChange(); - await controller.saveRecording(); - Get.back(); + final saved = await controller.saveRecording(); + if (saved) { + Get.back(); + } }, ), ), @@ -728,9 +734,11 @@ class MeetingRecordView extends GetView { child: ElevatedButton( onPressed: () async { await controller.saveTitleChange(); - await controller.saveRecording(); - Get.back(); //关闭弹窗 - Get.back(); //回退到会议记录页面 + final saved = await controller.saveRecording(); + if (saved) { + Get.back(); //关闭弹窗 + Get.back(); //回退到会议记录页面 + } }, style: ElevatedButton.styleFrom( backgroundColor: From 5a041a0ed99f93e3dfbe515d9ac2d93d45bf810d Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 11:37:27 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(=E4=BC=9A=E8=AE=AE):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=9C=AC=E5=9C=B0=E5=BD=95=E9=9F=B3=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在会议录音保存时触发本地录音导入流程 添加MeetingTaskService中的localImportState状态和请求方法 在MeetingHomeController中监听状态变化并调用导入方法 --- lib/data/services/meeting/meeting_task_service.dart | 5 +++++ .../meeting/controllers/meeting_home_controller.dart | 5 +++++ .../meeting/controllers/meeting_record_controller.dart | 6 +++++- lib/modules/meeting/views/meeting_record_view.dart | 7 +++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/data/services/meeting/meeting_task_service.dart b/lib/data/services/meeting/meeting_task_service.dart index c8d7bb9e7..b3a080d63 100644 --- a/lib/data/services/meeting/meeting_task_service.dart +++ b/lib/data/services/meeting/meeting_task_service.dart @@ -14,6 +14,7 @@ class MeetingTaskService extends GetxService { bool _isTask = false; //是否有正在处理的任务 RxInt updateListState = 0.obs; //是否更新首页列表 + RxInt localImportState = 0.obs; //是否触发本地录音导入 int meetingId = 0; RxInt tasktype = 1.obs; //任务类型,0-初始,1-等待转写中,2-转写中,3-等待总结中,4-总结中,5-完成 List textList = []; @@ -21,6 +22,10 @@ class MeetingTaskService extends GetxService { String summary = ''; String overview = ''; + void requestLocalImport() { + localImportState.value++; + } + Future addTask( int id, String audiourl, diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index 1e0cf5b4d..910431d64 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -49,6 +49,7 @@ class MeetingHomeController extends GetxController { final bleManager = Get.find(); late final Worker _updateListStateListener; + late final Worker _localImportListener; @override void onInit() { @@ -69,6 +70,9 @@ class MeetingHomeController extends GetxController { ever(meetingTaskService.updateListState, (value) async { _getDataList(); }); + _localImportListener = ever(meetingTaskService.localImportState, (value) { + addMeetingAudio(); + }); } @override @@ -77,6 +81,7 @@ class MeetingHomeController extends GetxController { filtrateController.dispose(); _audioPlayer.dispose(); _updateListStateListener.dispose(); + _localImportListener.dispose(); super.onClose(); } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 405fcef1f..529db22b6 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -18,6 +18,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/ble_manager.dart'; +import '../../../data/services/meeting/meeting_task_service.dart'; /// 会议记录状态枚举 enum TimerStatus { @@ -234,7 +235,10 @@ class MeetingRecordController extends GetxController return; case 'closeFloatingBar': if (isRecording.value) { - await saveRecording(); + final saved = await saveRecording(); + if (saved && Get.isRegistered()) { + Get.find().requestLocalImport(); + } return; } _floatingBarManuallyClosed = true; diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 7ce7fa7b7..f2bf8f9d3 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../controllers/meeting_record_controller.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../data/services/meeting/meeting_task_service.dart'; /// 会议录音退出操作 enum _MeetingRecordExitAction { @@ -18,6 +19,11 @@ enum _MeetingRecordExitAction { class MeetingRecordView extends GetView { const MeetingRecordView({super.key}); + void _notifyMeetingLocalImport() { + if (!Get.isRegistered()) return; + Get.find().requestLocalImport(); + } + void _deleteControllerAfterPop() { Future.delayed(const Duration(milliseconds: 400), () { if (Get.isRegistered()) { @@ -216,6 +222,7 @@ class MeetingRecordView extends GetView { await controller.saveTitleChange(); final saved = await controller.saveRecording(); if (saved) { + _notifyMeetingLocalImport(); Get.back(result: 'meeting_record_saved'); _deleteControllerAfterPop(); } From 1ec576e31dacd44dc19f02e3f8670f7d621f67a6 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 12:01:02 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix(meeting):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=9C=AC=E5=9C=B0=E5=BD=95=E9=9F=B3=E6=97=B6?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E6=AD=A3=E5=9C=A8=E5=BD=95=E5=88=B6=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E6=97=A0=E6=95=88=E6=96=87=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加对正在录制文件的检查,避免导入冲突 跳过小于1KB的文件和无效音频文件 优化音频时长计算逻辑 --- .../controllers/meeting_home_controller.dart | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index 910431d64..8564ef0b4 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -22,6 +22,7 @@ import '../../../data/services/meeting/meeting_task_service.dart'; import '../../../data/services/meeting/meeting_upload_service.dart'; import '../../../data/services/network/api.dart'; import '../../../routes/app_routes.dart'; +import 'meeting_record_controller.dart'; import 'meeting_controller.dart'; // 会议首页控制器 @@ -193,12 +194,13 @@ class MeetingHomeController extends GetxController { } } -// 核心方法:导入本地录音文件到统一目录 + // 核心方法:导入本地录音文件到统一目录 void addMeetingAudio() async { EasyLoading.show(status: 'importingLocalRecordings'.tr); // 对应中文:正在导入本地录音... try { int addUp = 0; // 新增计数 final appDir = await appDocDir; // 获取应用文档目录 + final activeRecordingWavName = _getActiveRecordingWavFileName(); final sourceDirs = [ Directory("${appDir.path}/MeetingAudio/"), // 会议录音目录 @@ -226,16 +228,31 @@ class MeetingHomeController extends GetxController { // 遍历所有文件实体 for (final fileEntity in allFiles) { try { + final entityName = path.basename(fileEntity.path); + if (activeRecordingWavName != null && + entityName == activeRecordingWavName) { + continue; + } + final file = File(fileEntity.path); //将文件实体转换为具体文件操作对象 final filePath = file.path; + final fileStat = await file.stat(); //获取文件状态信息,包含修改时间等 + if (fileStat.size < 1024) { + continue; + } + // 使用just_audio库加载音频文件以获取时长 - await _audioPlayer.setAudioSource(AudioSource.file(filePath)); - int seconds = - _audioPlayer.duration?.inSeconds ?? 0; //获取音频时长(秒),若为空则设为0 + final Duration? duration = + await _audioPlayer.setAudioSource(AudioSource.file(filePath)); + final Duration? effectiveDuration = duration ?? _audioPlayer.duration; + final int durationMs = effectiveDuration?.inMilliseconds ?? 0; + final int seconds = + durationMs <= 0 ? 0 : ((durationMs + 999) ~/ 1000); + if (seconds <= 0) { + continue; + } - // 获取文件修改时间 - final fileStat = await file.stat(); //获取文件状态信息,包含修改时间等 final creationTime = fileStat.changed.millisecondsSinceEpoch; // 准备新路径 @@ -312,6 +329,15 @@ class MeetingHomeController extends GetxController { } } + String? _getActiveRecordingWavFileName() { + if (!Get.isRegistered()) return null; + final controller = Get.find(); + if (!controller.isRecording.value) return null; + final name = controller.fileName.value; + if (name.isEmpty) return null; + return '$name.wav'; + } + // 新增(2025-11-04):将翻译音频归档到 LocalAudio 并返回新路径(优先 rename,失败再 copy+delete) Future _archiveTranslatToLocal(String srcPath) async { // 获取应用文档目录并确保 LocalAudio 目录存在 @@ -329,7 +355,7 @@ class MeetingHomeController extends GetxController { if (await File(destPath).exists()) { final ts = DateTime.now().millisecondsSinceEpoch; - destPath = path.join(destDir.path, '$baseName\_$ts$ext'); + destPath = path.join(destDir.path, '${baseName}_$ts$ext'); } // 中文注释:尝试 rename(更快),失败则 copy + delete From f6e09dc1708fa8803a5b367e5227b18df512f966 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 15:43:35 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(meeting):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E8=AE=A1=E6=97=B6=E5=99=A8=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E9=98=B2=E6=AD=A2=E9=87=8D=E5=A4=8D=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重置计时器时同时重置毫秒显示 添加_isImportingLocalRecordings标志防止重复导入录音 移除导航栏底部弹窗中不必要的录音导入检查 --- .../controllers/meeting_home_controller.dart | 5 +++++ .../controllers/meeting_record_controller.dart | 1 + .../bottomSheet/navigation_bar_bottom_sheet.dart | 15 +++------------ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index 8564ef0b4..bbe666138 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -33,6 +33,8 @@ class MeetingHomeController extends GetxController { final AudioPlayer _audioPlayer = AudioPlayer(); + bool _isImportingLocalRecordings = false; + List selected = []; final appDocDir = getApplicationDocumentsDirectory(); @@ -196,6 +198,8 @@ class MeetingHomeController extends GetxController { // 核心方法:导入本地录音文件到统一目录 void addMeetingAudio() async { + if (_isImportingLocalRecordings) return; + _isImportingLocalRecordings = true; EasyLoading.show(status: 'importingLocalRecordings'.tr); // 对应中文:正在导入本地录音... try { int addUp = 0; // 新增计数 @@ -325,6 +329,7 @@ class MeetingHomeController extends GetxController { .tr .replaceAll('{error}', e.toString())); // 对应中文:导入失败: {error} } finally { + _isImportingLocalRecordings = false; EasyLoading.dismiss(); } } diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 529db22b6..83377d4a2 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -836,6 +836,7 @@ class MeetingRecordController extends GetxController _elapsedMilliseconds = 0; _lastStartTime = null; seconds.value = 0; + milliseconds.value = 0; timerStatus.value = TimerStatus.stopped; } diff --git a/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart b/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart index e41e7d0aa..872fccbf2 100644 --- a/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart +++ b/lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart @@ -30,12 +30,9 @@ class NavigationBarBottomSheet extends GetView { Colors.red, onTap: () async { Get.back(); - final result = await Get.toNamed(Routes.meetingRecord, arguments: { + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 0, }); - if (result != 'meeting_record_background') { - controller.addMeetingAudio(); - } }, ), if (controller.meetingController.type == 1) @@ -47,12 +44,9 @@ class NavigationBarBottomSheet extends GetView { onTap: controller.bleManager.isConnected ? () async { Get.back(); - final result = await Get.toNamed(Routes.meetingRecord, arguments: { + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 1, }); - if (result != 'meeting_record_background') { - controller.addMeetingAudio(); - } } : null, ), @@ -65,12 +59,9 @@ class NavigationBarBottomSheet extends GetView { onTap: controller.bleManager.isConnected ? () async { Get.back(); - final result = await Get.toNamed(Routes.meetingRecord, arguments: { + await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 2, }); - if (result != 'meeting_record_background') { - controller.addMeetingAudio(); - } } : null, ), From 96ff393a680c34125ca6a21b93dc52b2b2e46e17 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 15:56:56 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(translations):=20=E4=B8=BA=E4=BC=9A?= =?UTF-8?q?=E8=AE=AE=E6=A8=A1=E5=9D=97=E6=B7=BB=E5=8A=A0=E5=8F=91=E8=A8=80?= =?UTF-8?q?=E8=80=85=E7=9B=B8=E5=85=B3=E7=9A=84=E6=96=B0=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/translations/language/ar_sa.dart | 3 +++ lib/core/translations/language/bg_bg.dart | 3 +++ lib/core/translations/language/cs_cz.dart | 3 +++ lib/core/translations/language/da_dk.dart | 3 +++ lib/core/translations/language/de_de.dart | 7 +++++-- lib/core/translations/language/el_gr.dart | 9 ++++++--- lib/core/translations/language/en_us.dart | 3 +++ lib/core/translations/language/es_es.dart | 3 +++ lib/core/translations/language/et_ee.dart | 3 +++ lib/core/translations/language/fa_ir.dart | 3 +++ lib/core/translations/language/fi_fi.dart | 3 +++ lib/core/translations/language/fr_fr.dart | 3 +++ lib/core/translations/language/hi_in.dart | 3 +++ lib/core/translations/language/hr_hr.dart | 3 +++ lib/core/translations/language/hu_hu.dart | 3 +++ lib/core/translations/language/id_id.dart | 3 +++ lib/core/translations/language/is_is.dart | 3 +++ lib/core/translations/language/it_it.dart | 3 +++ lib/core/translations/language/ja_jp.dart | 3 +++ lib/core/translations/language/ko_kr.dart | 2 ++ lib/core/translations/language/lt_lt.dart | 3 +++ lib/core/translations/language/lv_lv.dart | 3 +++ lib/core/translations/language/ms_my.dart | 3 +++ lib/core/translations/language/nb_no.dart | 3 +++ lib/core/translations/language/nl_nl.dart | 3 +++ lib/core/translations/language/pl_pl.dart | 3 +++ lib/core/translations/language/pt_pt.dart | 3 +++ lib/core/translations/language/ro_ro.dart | 3 +++ lib/core/translations/language/ru_ru.dart | 3 +++ lib/core/translations/language/sk_sk.dart | 3 +++ lib/core/translations/language/sl_si.dart | 3 +++ lib/core/translations/language/sr_rs.dart | 3 +++ lib/core/translations/language/sv_se.dart | 3 +++ lib/core/translations/language/th_th.dart | 2 ++ lib/core/translations/language/tl_ph.dart | 3 +++ lib/core/translations/language/tr_tr.dart | 3 +++ lib/core/translations/language/uk_ua.dart | 3 +++ lib/core/translations/language/vi_vn.dart | 3 +++ lib/core/translations/language/zh_cn.dart | 2 ++ lib/core/translations/language/zh_hk.dart | 2 ++ lib/core/translations/language/zh_tw.dart | 2 ++ 41 files changed, 123 insertions(+), 5 deletions(-) diff --git a/lib/core/translations/language/ar_sa.dart b/lib/core/translations/language/ar_sa.dart index 9ce3ca106..384c83b2e 100644 --- a/lib/core/translations/language/ar_sa.dart +++ b/lib/core/translations/language/ar_sa.dart @@ -608,6 +608,9 @@ const Map arSA = { 'exportingPDF': 'جاري تصدير PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'تطبيق على جميع فقرات هذا المتحدث', + 'summaryWillBeUpdatedAutomatically': + 'سيتم تحديث الملخص تلقائيًا بناءً على القالب', 'nameCurrentSpeaker': 'تسمية المتحدث الحالي', 'recentlyUsedNames': 'الأسماء المستخدمة مؤخرًا', 'applyToCurrentParagraph': 'تطبيق على الفقرة الحالية', diff --git a/lib/core/translations/language/bg_bg.dart b/lib/core/translations/language/bg_bg.dart index e8b6a3699..a45fc4863 100644 --- a/lib/core/translations/language/bg_bg.dart +++ b/lib/core/translations/language/bg_bg.dart @@ -619,6 +619,9 @@ const Map bgBG = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Приложи към всички абзаци на този говорител', + 'summaryWillBeUpdatedAutomatically': + 'Резюмето ще се обновява автоматично според шаблона', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/cs_cz.dart b/lib/core/translations/language/cs_cz.dart index 1d5cac588..1f01c1d07 100644 --- a/lib/core/translations/language/cs_cz.dart +++ b/lib/core/translations/language/cs_cz.dart @@ -611,6 +611,9 @@ const Map csCZ = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Použít na všechny odstavce tohoto mluvčího', + 'summaryWillBeUpdatedAutomatically': + 'Souhrn se bude automaticky aktualizovat podle šablony', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/da_dk.dart b/lib/core/translations/language/da_dk.dart index efd0010fe..fd6d6c77f 100644 --- a/lib/core/translations/language/da_dk.dart +++ b/lib/core/translations/language/da_dk.dart @@ -614,6 +614,9 @@ const Map daDK = { 'exportingPDF': 'Eksporterer PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Anvend på alle afsnit for denne taler', + 'summaryWillBeUpdatedAutomatically': + 'Resuméet opdateres automatisk ud fra skabelonen', 'nameCurrentSpeaker': 'Navngiv nuværende taler', 'recentlyUsedNames': 'Nyligt brugte navne', 'applyToCurrentParagraph': 'Anvend på nuværende afsnit', diff --git a/lib/core/translations/language/de_de.dart b/lib/core/translations/language/de_de.dart index 99afb8e0d..fee851951 100644 --- a/lib/core/translations/language/de_de.dart +++ b/lib/core/translations/language/de_de.dart @@ -622,12 +622,15 @@ const Map deDE = { 'exportingPDF': 'Exportiere PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Auf alle Absätze dieses Sprechers anwenden', + 'summaryWillBeUpdatedAutomatically': + 'Die Zusammenfassung wird basierend auf der Vorlage automatisch aktualisiert', 'nameCurrentSpeaker': 'Aktuellen Sprecher benennen', 'recentlyUsedNames': 'Zuletzt verwendete Namen', 'applyToCurrentParagraph': 'Auf aktuellen Absatz anwenden', - 'totalTime': 'Gesamtzeit', + 'totalTime': 'Gesamt', 'summarySpeakerWillBeUpdatedAutomatically': - 'Zusammenfassungssprecher werden automatisch aktualisiert', + 'Sprecher in der Zusammenfassung werden automatisch aktualisiert', // Meeting模块 - 云端同步相关 'syncToCloud': 'Mit Cloud synchronisieren', diff --git a/lib/core/translations/language/el_gr.dart b/lib/core/translations/language/el_gr.dart index 24c3ca033..e99990ebe 100644 --- a/lib/core/translations/language/el_gr.dart +++ b/lib/core/translations/language/el_gr.dart @@ -628,12 +628,15 @@ const Map elGR = { 'exportingPDF': 'Εξαγωγή PDF...', // Meeting模块 - 发言者相关 - 'nameCurrentSpeaker': 'Ονομασία τρέχοντος ομιλητή', + 'applyToAllParagraphs': 'Εφαρμογή σε όλες τις παραγράφους αυτού του ομιλητή', + 'summaryWillBeUpdatedAutomatically': + 'Η σύνοψη θα ενημερώνεται αυτόματα βάσει του προτύπου', + 'nameCurrentSpeaker': 'Ονοματοδοσία τρέχοντος ομιλητή', 'recentlyUsedNames': 'Πρόσφατα χρησιμοποιημένα ονόματα', 'applyToCurrentParagraph': 'Εφαρμογή στην τρέχουσα παράγραφο', - 'totalTime': 'Συνολικός χρόνος', + 'totalTime': 'Σύνολο', 'summarySpeakerWillBeUpdatedAutomatically': - 'Οι ομιλητές της περίληψης θα ενημερωθούν αυτόματα', + 'Οι ομιλητές στη σύνοψη θα ενημερώνονται αυτόματα', // Meeting模块 - 云端同步相关 'syncToCloud': 'Συγχρονισμός στο cloud', diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index e64d06e47..6258ec09b 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -631,6 +631,9 @@ const Map enUS = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Apply to All Paragraphs of This Speaker', + 'summaryWillBeUpdatedAutomatically': + 'Summary will be updated automatically based on the template', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/es_es.dart b/lib/core/translations/language/es_es.dart index 3bac21821..819e80f2f 100644 --- a/lib/core/translations/language/es_es.dart +++ b/lib/core/translations/language/es_es.dart @@ -612,6 +612,9 @@ const Map esES = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Aplicar a todos los párrafos de este orador', + 'summaryWillBeUpdatedAutomatically': + 'El resumen se actualizará automáticamente según la plantilla', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/et_ee.dart b/lib/core/translations/language/et_ee.dart index 56923f71c..bf056fb8b 100644 --- a/lib/core/translations/language/et_ee.dart +++ b/lib/core/translations/language/et_ee.dart @@ -607,6 +607,9 @@ const Map etEE = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Rakenda selle kõneleja kõigile lõikudele', + 'summaryWillBeUpdatedAutomatically': + 'Kokkuvõtet uuendatakse automaatselt malli alusel', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/fa_ir.dart b/lib/core/translations/language/fa_ir.dart index d59fa840b..df69222d8 100644 --- a/lib/core/translations/language/fa_ir.dart +++ b/lib/core/translations/language/fa_ir.dart @@ -611,6 +611,9 @@ const Map faIR = { 'exportingPDF': 'در حال خروجی گرفتن PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'اعمال روی همه پاراگراف‌های این گوینده', + 'summaryWillBeUpdatedAutomatically': + 'خلاصه بر اساس قالب به‌صورت خودکار به‌روزرسانی می‌شود', 'nameCurrentSpeaker': 'نام‌گذاری گوینده فعلی', 'recentlyUsedNames': 'نام‌های اخیراً استفاده‌شده', 'applyToCurrentParagraph': 'اعمال به پاراگراف فعلی', diff --git a/lib/core/translations/language/fi_fi.dart b/lib/core/translations/language/fi_fi.dart index 059654e55..86c076509 100644 --- a/lib/core/translations/language/fi_fi.dart +++ b/lib/core/translations/language/fi_fi.dart @@ -612,6 +612,9 @@ const Map fiFI = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Käytä kaikkiin tämän puhujan kappaleisiin', + 'summaryWillBeUpdatedAutomatically': + 'Yhteenveto päivittyy automaattisesti mallin perusteella', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/fr_fr.dart b/lib/core/translations/language/fr_fr.dart index 8cb7dd6ae..d125b3000 100644 --- a/lib/core/translations/language/fr_fr.dart +++ b/lib/core/translations/language/fr_fr.dart @@ -614,6 +614,9 @@ const Map frFR = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Appliquer à tous les paragraphes de cet intervenant', + 'summaryWillBeUpdatedAutomatically': + 'Le résumé sera mis à jour automatiquement selon le modèle', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/hi_in.dart b/lib/core/translations/language/hi_in.dart index 8ada8fef2..cf5550af5 100644 --- a/lib/core/translations/language/hi_in.dart +++ b/lib/core/translations/language/hi_in.dart @@ -610,6 +610,9 @@ const Map hiIN = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'इस वक्ता के सभी अनुच्छेदों पर लागू करें', + 'summaryWillBeUpdatedAutomatically': + 'टेम्पलेट के आधार पर सारांश स्वतः अपडेट होगा', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/hr_hr.dart b/lib/core/translations/language/hr_hr.dart index 3dd043490..d154a3ae2 100644 --- a/lib/core/translations/language/hr_hr.dart +++ b/lib/core/translations/language/hr_hr.dart @@ -614,6 +614,9 @@ const Map hrHR = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Primijeni na sve odlomke ovog govornika', + 'summaryWillBeUpdatedAutomatically': + 'Sažetak će se automatski ažurirati prema predlošku', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/hu_hu.dart b/lib/core/translations/language/hu_hu.dart index df31ee0b4..0fd08fabe 100644 --- a/lib/core/translations/language/hu_hu.dart +++ b/lib/core/translations/language/hu_hu.dart @@ -614,6 +614,9 @@ const Map huHU = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Alkalmazás a beszélő összes bekezdésére', + 'summaryWillBeUpdatedAutomatically': + 'Az összefoglaló automatikusan frissül a sablon alapján', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/id_id.dart b/lib/core/translations/language/id_id.dart index fc3aebc32..da9e8d55a 100644 --- a/lib/core/translations/language/id_id.dart +++ b/lib/core/translations/language/id_id.dart @@ -610,6 +610,9 @@ const Map idID = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Terapkan ke semua paragraf pembicara ini', + 'summaryWillBeUpdatedAutomatically': + 'Ringkasan akan diperbarui otomatis berdasarkan template', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/is_is.dart b/lib/core/translations/language/is_is.dart index 8066513bf..bdd0b3e96 100644 --- a/lib/core/translations/language/is_is.dart +++ b/lib/core/translations/language/is_is.dart @@ -611,6 +611,9 @@ const Map isIS = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Beita á allar málsgreinar þessa ræðumanns', + 'summaryWillBeUpdatedAutomatically': + 'Samantektin verður uppfærð sjálfkrafa út frá sniðmátinu', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/it_it.dart b/lib/core/translations/language/it_it.dart index 37530321c..e318a50fc 100644 --- a/lib/core/translations/language/it_it.dart +++ b/lib/core/translations/language/it_it.dart @@ -617,6 +617,9 @@ const Map itIT = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Applica a tutti i paragrafi di questo relatore', + 'summaryWillBeUpdatedAutomatically': + 'Il riepilogo verrà aggiornato automaticamente in base al modello', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/ja_jp.dart b/lib/core/translations/language/ja_jp.dart index 33f8be765..cb5dff929 100644 --- a/lib/core/translations/language/ja_jp.dart +++ b/lib/core/translations/language/ja_jp.dart @@ -603,6 +603,9 @@ const Map jaJP = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'この話者のすべての段落に適用', + 'summaryWillBeUpdatedAutomatically': + '要約はテンプレートに基づいて自動的に更新されます', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/ko_kr.dart b/lib/core/translations/language/ko_kr.dart index fc03a6514..ce9b5231d 100644 --- a/lib/core/translations/language/ko_kr.dart +++ b/lib/core/translations/language/ko_kr.dart @@ -599,6 +599,8 @@ const Map koKR = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': '이 발언자의 모든 문단에 적용', + 'summaryWillBeUpdatedAutomatically': '요약은 템플릿에 따라 자동으로 업데이트됩니다', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/lt_lt.dart b/lib/core/translations/language/lt_lt.dart index 314934882..759f56d94 100644 --- a/lib/core/translations/language/lt_lt.dart +++ b/lib/core/translations/language/lt_lt.dart @@ -614,6 +614,9 @@ const Map ltLT = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Taikyti visoms šio kalbėtojo pastraipoms', + 'summaryWillBeUpdatedAutomatically': + 'Santrauka bus automatiškai atnaujinama pagal šabloną', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/lv_lv.dart b/lib/core/translations/language/lv_lv.dart index 15a67fc17..83983eef0 100644 --- a/lib/core/translations/language/lv_lv.dart +++ b/lib/core/translations/language/lv_lv.dart @@ -613,6 +613,9 @@ const Map lvLV = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Piemērot visām šī runātāja rindkopām', + 'summaryWillBeUpdatedAutomatically': + 'Kopsavilkums tiks automātiski atjaunināts, pamatojoties uz veidni', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/ms_my.dart b/lib/core/translations/language/ms_my.dart index 6b0bfe761..3d3f6cd5a 100644 --- a/lib/core/translations/language/ms_my.dart +++ b/lib/core/translations/language/ms_my.dart @@ -612,6 +612,9 @@ const Map msMY = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Guna pada semua perenggan penutur ini', + 'summaryWillBeUpdatedAutomatically': + 'Ringkasan akan dikemas kini secara automatik berdasarkan templat', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/nb_no.dart b/lib/core/translations/language/nb_no.dart index 07f931048..655a0023c 100644 --- a/lib/core/translations/language/nb_no.dart +++ b/lib/core/translations/language/nb_no.dart @@ -612,6 +612,9 @@ const Map nbNO = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Bruk på alle avsnitt for denne taleren', + 'summaryWillBeUpdatedAutomatically': + 'Sammendraget oppdateres automatisk basert på malen', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/nl_nl.dart b/lib/core/translations/language/nl_nl.dart index 8c7b3e90a..cc47b9760 100644 --- a/lib/core/translations/language/nl_nl.dart +++ b/lib/core/translations/language/nl_nl.dart @@ -613,6 +613,9 @@ const Map nlNL = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': "Toepassen op alle alinea's van deze spreker", + 'summaryWillBeUpdatedAutomatically': + 'De samenvatting wordt automatisch bijgewerkt op basis van de sjabloon', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/pl_pl.dart b/lib/core/translations/language/pl_pl.dart index 95eb2d547..136c5f236 100644 --- a/lib/core/translations/language/pl_pl.dart +++ b/lib/core/translations/language/pl_pl.dart @@ -614,6 +614,9 @@ const Map plPL = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Zastosuj do wszystkich akapitów tego mówcy', + 'summaryWillBeUpdatedAutomatically': + 'Podsumowanie będzie automatycznie aktualizowane na podstawie szablonu', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/pt_pt.dart b/lib/core/translations/language/pt_pt.dart index 3324adea9..55065ca5e 100644 --- a/lib/core/translations/language/pt_pt.dart +++ b/lib/core/translations/language/pt_pt.dart @@ -617,6 +617,9 @@ const Map ptPT = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Aplicar a todos os parágrafos deste orador', + 'summaryWillBeUpdatedAutomatically': + 'O resumo será atualizado automaticamente com base no modelo', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/ro_ro.dart b/lib/core/translations/language/ro_ro.dart index 66a0783cc..363e4327d 100644 --- a/lib/core/translations/language/ro_ro.dart +++ b/lib/core/translations/language/ro_ro.dart @@ -615,6 +615,9 @@ const Map roRO = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Aplică la toate paragrafele acestui vorbitor', + 'summaryWillBeUpdatedAutomatically': + 'Rezumatul va fi actualizat automat pe baza șablonului', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/ru_ru.dart b/lib/core/translations/language/ru_ru.dart index a7b06f671..c43a8c235 100644 --- a/lib/core/translations/language/ru_ru.dart +++ b/lib/core/translations/language/ru_ru.dart @@ -615,6 +615,9 @@ const Map ruRU = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Применить ко всем абзацам этого говорящего', + 'summaryWillBeUpdatedAutomatically': + 'Сводка будет автоматически обновляться на основе шаблона', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/sk_sk.dart b/lib/core/translations/language/sk_sk.dart index dc88afea1..6cd3b51ab 100644 --- a/lib/core/translations/language/sk_sk.dart +++ b/lib/core/translations/language/sk_sk.dart @@ -613,6 +613,9 @@ const Map skSK = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Použiť na všetky odseky tohto rečníka', + 'summaryWillBeUpdatedAutomatically': + 'Zhrnutie sa bude automaticky aktualizovať podľa šablóny', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/sl_si.dart b/lib/core/translations/language/sl_si.dart index 2b3a3f3df..8e6a0d55f 100644 --- a/lib/core/translations/language/sl_si.dart +++ b/lib/core/translations/language/sl_si.dart @@ -613,6 +613,9 @@ const Map slSI = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Uporabi za vse odstavke tega govorca', + 'summaryWillBeUpdatedAutomatically': + 'Povzetek se bo samodejno posodabljal glede na predlogo', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/sr_rs.dart b/lib/core/translations/language/sr_rs.dart index ba350d2ab..3ef7aeb82 100644 --- a/lib/core/translations/language/sr_rs.dart +++ b/lib/core/translations/language/sr_rs.dart @@ -612,6 +612,9 @@ const Map srRS = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Primeni na sve pasuse ovog govornika', + 'summaryWillBeUpdatedAutomatically': + 'Sažetak će se automatski ažurirati prema šablonu', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/sv_se.dart b/lib/core/translations/language/sv_se.dart index 341116fb0..984c14e14 100644 --- a/lib/core/translations/language/sv_se.dart +++ b/lib/core/translations/language/sv_se.dart @@ -612,6 +612,9 @@ const Map svSE = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Använd på alla stycken för denna talare', + 'summaryWillBeUpdatedAutomatically': + 'Sammanfattningen uppdateras automatiskt baserat på mallen', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/th_th.dart b/lib/core/translations/language/th_th.dart index 4046c535d..a15f16a44 100644 --- a/lib/core/translations/language/th_th.dart +++ b/lib/core/translations/language/th_th.dart @@ -606,6 +606,8 @@ const Map thTH = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'นำไปใช้กับทุกย่อหน้าของผู้พูดคนนี้', + 'summaryWillBeUpdatedAutomatically': 'สรุปจะอัปเดตอัตโนมัติตามเทมเพลต', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/tl_ph.dart b/lib/core/translations/language/tl_ph.dart index fa233db16..cfc036b70 100644 --- a/lib/core/translations/language/tl_ph.dart +++ b/lib/core/translations/language/tl_ph.dart @@ -614,6 +614,9 @@ const Map tlPH = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Ilapat sa lahat ng talata ng tagapagsalitang ito', + 'summaryWillBeUpdatedAutomatically': + 'Awtomatikong maa-update ang buod batay sa template', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/tr_tr.dart b/lib/core/translations/language/tr_tr.dart index e361eb949..51d84969e 100644 --- a/lib/core/translations/language/tr_tr.dart +++ b/lib/core/translations/language/tr_tr.dart @@ -610,6 +610,9 @@ const Map trTR = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Bu konuşmacının tüm paragraflarına uygula', + 'summaryWillBeUpdatedAutomatically': + 'Özet, şablona göre otomatik olarak güncellenecektir', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/uk_ua.dart b/lib/core/translations/language/uk_ua.dart index 9062d5d42..ed9bcab62 100644 --- a/lib/core/translations/language/uk_ua.dart +++ b/lib/core/translations/language/uk_ua.dart @@ -612,6 +612,9 @@ const Map ukUA = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Застосувати до всіх абзаців цього мовця', + 'summaryWillBeUpdatedAutomatically': + 'Підсумок буде автоматично оновлюватися на основі шаблону', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/vi_vn.dart b/lib/core/translations/language/vi_vn.dart index 6aaf28569..9656ae93d 100644 --- a/lib/core/translations/language/vi_vn.dart +++ b/lib/core/translations/language/vi_vn.dart @@ -609,6 +609,9 @@ const Map viVN = { 'exportingPDF': 'Exporting PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': 'Áp dụng cho tất cả đoạn của người nói này', + 'summaryWillBeUpdatedAutomatically': + 'Tóm tắt sẽ được cập nhật tự động dựa trên mẫu', 'nameCurrentSpeaker': 'Name Current Speaker', 'recentlyUsedNames': 'Recently Used Names', 'applyToCurrentParagraph': 'Apply to Current Paragraph', diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index 2c108c5f5..a96422446 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -588,6 +588,8 @@ const Map zhCN = { 'exportingPDF': '正在导出PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': '应用到这个发言者的所有段落', + 'summaryWillBeUpdatedAutomatically': '总结将根据模版自动更新', 'nameCurrentSpeaker': '命名当前发言者', 'recentlyUsedNames': '最近使用过的名称', 'applyToCurrentParagraph': '应用到当前段落', diff --git a/lib/core/translations/language/zh_hk.dart b/lib/core/translations/language/zh_hk.dart index cea725017..0cb58cd3e 100644 --- a/lib/core/translations/language/zh_hk.dart +++ b/lib/core/translations/language/zh_hk.dart @@ -572,6 +572,8 @@ const Map zhHK = { 'exportingPDF': '正在導出PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': '應用到這個發言者的所有段落', + 'summaryWillBeUpdatedAutomatically': '總結將根據模版自動更新', 'nameCurrentSpeaker': '命名當前發言者', 'recentlyUsedNames': '最近使用過的名稱', 'applyToCurrentParagraph': '應用到當前段落', diff --git a/lib/core/translations/language/zh_tw.dart b/lib/core/translations/language/zh_tw.dart index fb94954ef..10be5f74c 100644 --- a/lib/core/translations/language/zh_tw.dart +++ b/lib/core/translations/language/zh_tw.dart @@ -572,6 +572,8 @@ const Map zhTW = { 'exportingPDF': '正在導出PDF...', // Meeting模块 - 发言者相关 + 'applyToAllParagraphs': '應用到這個發言者的所有段落', + 'summaryWillBeUpdatedAutomatically': '總結將根據模版自動更新', 'nameCurrentSpeaker': '命名當前發言者', 'recentlyUsedNames': '最近使用過的名稱', 'applyToCurrentParagraph': '應用到當前段落', From 8a5a2647c3ed202d17dba5e66d74af1f1f668b28 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 16:36:21 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(meeting):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E7=AA=97=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加会话级别的权限请求状态管理,防止重复弹窗 增加权限请求冷却时间和对话框显示状态检查 添加设置跳转失败时的snackbar提示 --- lib/core/utils/overlay_permission_util.dart | 83 ++++++++++++------- .../meeting_record_controller.dart | 63 ++++++++++++-- 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/lib/core/utils/overlay_permission_util.dart b/lib/core/utils/overlay_permission_util.dart index a4beb20e9..298ba4638 100644 --- a/lib/core/utils/overlay_permission_util.dart +++ b/lib/core/utils/overlay_permission_util.dart @@ -13,6 +13,27 @@ enum OverlayPermissionScene { /// 悬浮窗权限管理工具类 class OverlayPermissionUtil { static const MethodChannel _channel = MethodChannel('overlay_permission'); + static bool _isRequestDialogShowing = false; + static DateTime? _lastRequestDialogAt; + static const Duration _requestDialogCooldown = Duration(seconds: 20); + + static Future openOverlayPermissionSettings() async { + if (!Platform.isAndroid) return true; + try { + final bool opened = + await _channel.invokeMethod('requestOverlayPermission'); + if (!opened) { + Get.snackbar( + 'permissionRequestError'.tr, + 'overlayPermissionOpenSettingsFailed'.tr, + ); + } + return opened; + } catch (e) { + Logger.error('跳转悬浮窗权限设置失败: $e'); + return false; + } + } /// 检查是否有悬浮窗权限 static Future hasOverlayPermission() async { @@ -49,38 +70,42 @@ class OverlayPermissionUtil { 'overlayPermissionRecordingDesc', // 为了在录音过程中显示录音悬浮条(计时/暂停/继续),需要悬浮窗权限。 }; - // 显示说明对话框 - bool? userAgreed = await Get.dialog( - AlertDialog( - title: Text('overlayPermissionTitle'.tr), // 悬浮窗权限 - content: Text(explanationKey.tr), // 按场景显示不同说明文案 - actions: [ - TextButton( - onPressed: () => Get.back(result: false), - child: Text('cancel'.tr), // 取消 - ), - ElevatedButton( - onPressed: () => Get.back(result: true), - child: Text('goToSettings'.tr), // 去设置 - ), - ], - ), - barrierDismissible: false, - ); + final now = DateTime.now(); + if (_isRequestDialogShowing) return false; + final lastAt = _lastRequestDialogAt; + if (lastAt != null && now.difference(lastAt) < _requestDialogCooldown) { + return false; + } + + _isRequestDialogShowing = true; + _lastRequestDialogAt = now; + bool? userAgreed; + try { + userAgreed = await Get.dialog( + AlertDialog( + title: Text('overlayPermissionTitle'.tr), + content: Text(explanationKey.tr), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: Text('cancel'.tr), + ), + ElevatedButton( + onPressed: () => Get.back(result: true), + child: Text('goToSettings'.tr), + ), + ], + ), + barrierDismissible: false, + ); + } finally { + _isRequestDialogShowing = false; + } if (userAgreed == true) { try { - // 跳转系统设置页 - final bool opened = - await _channel.invokeMethod('requestOverlayPermission'); - if (!opened) { - Get.snackbar( - 'permissionRequestError'.tr, - 'overlayPermissionOpenSettingsFailed' - .tr, // 无法自动跳转到系统设置,请手动前往系统设置开启悬浮窗权限。 - ); - return false; - } + final bool opened = await openOverlayPermissionSettings(); + if (!opened) return false; // 等待用户操作完成后重新检查权限 await Future.delayed(const Duration(seconds: 1)); return await hasOverlayPermission(); diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 83377d4a2..88cbc1fc1 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -41,6 +41,9 @@ class MeetingRecordController extends GetxController Timer? _routeMonitorTimer; StreamSubscription? _recordingFloatingActionSubscription; bool _floatingBarManuallyClosed = false; + bool _overlayPermissionPromptedThisSession = false; + bool _overlayPermissionSnackShownThisSession = false; + bool _overlayPermissionRequesting = false; AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; String _lastKnownRoute = ''; @@ -152,13 +155,8 @@ class MeetingRecordController extends GetxController if (!isRecording.value) return; if (_floatingBarManuallyClosed) return; - final hasPermission = await OverlayPermissionUtil.hasOverlayPermission(); - if (!hasPermission) { - final granted = await OverlayPermissionUtil.requestOverlayPermission( - scene: OverlayPermissionScene.recordingFloatingBar, - ); - if (!granted) return; - } + final granted = await _ensureAndroidOverlayPermissionForFloatingBar(); + if (!granted) return; final typeText = _getRecordingTypeText(); final durationText = _formatTimeForFloatingBar(milliseconds.value); @@ -191,6 +189,54 @@ class MeetingRecordController extends GetxController }); } + void _showOverlayPermissionSnackOnce() { + if (_overlayPermissionSnackShownThisSession) return; + _overlayPermissionSnackShownThisSession = true; + Get.snackbar( + 'overlayPermissionTitle'.tr, + 'overlayPermissionRecordingDesc'.tr, + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 4), + mainButton: TextButton( + onPressed: () { + unawaited(OverlayPermissionUtil.openOverlayPermissionSettings()); + }, + child: Text('goToSettings'.tr), + ), + ); + } + + Future _ensureAndroidOverlayPermissionForFloatingBar() async { + if (!Platform.isAndroid) return true; + if (!isRecording.value) return false; + + final hasPermission = await OverlayPermissionUtil.hasOverlayPermission(); + if (hasPermission) return true; + + if (_appLifecycleState != AppLifecycleState.resumed) { + return false; + } + + if (_overlayPermissionPromptedThisSession || _overlayPermissionRequesting) { + _showOverlayPermissionSnackOnce(); + return false; + } + + _overlayPermissionPromptedThisSession = true; + _overlayPermissionRequesting = true; + try { + final granted = await OverlayPermissionUtil.requestOverlayPermission( + scene: OverlayPermissionScene.recordingFloatingBar, + ); + if (!granted) { + _showOverlayPermissionSnackOnce(); + } + return granted; + } finally { + _overlayPermissionRequesting = false; + } + } + Future _stopAndroidRecordingFloatingBar() async { if (!Platform.isAndroid) return; _recordingFloatingBarTimer?.cancel(); @@ -513,6 +559,9 @@ class MeetingRecordController extends GetxController }); isRecording.value = true; + _overlayPermissionPromptedThisSession = false; + _overlayPermissionSnackShownThisSession = false; + _overlayPermissionRequesting = false; // 更新录音状态 fileName.value = fullFileName; newName.value = fullFileName; From ca9fbbbe2214428c480080ec95d787ec00252f4f Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Tue, 13 Jan 2026 20:56:22 +0800 Subject: [PATCH 10/11] =?UTF-8?q?feat(=E6=82=AC=E6=B5=AE=E7=AA=97):=20?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E6=82=AC=E6=B5=AE=E7=AA=97UI=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8A=A0=E8=BD=BD=E5=8A=A8=E7=94=BB=E5=92=8C?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重新设计悬浮窗UI样式,包括颜色、圆角和间距 - 添加加载动画效果,显示识别和翻译状态 - 支持双指缩放调整悬浮窗大小 - 优化最小化视图的显示内容 - 移除时间戳显示,改进语言信息展示 --- .../native_plugin/FloatingWindowService.kt | 369 ++++++++++++++---- .../native_plugin/RecordingFloatingService.kt | 1 + 2 files changed, 303 insertions(+), 67 deletions(-) diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt index 7e8a4b0da..e69e41144 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt @@ -4,10 +4,13 @@ import android.app.Service import android.content.Intent import android.graphics.Color import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.text.TextUtils import android.util.TypedValue import android.view.Gravity @@ -19,10 +22,8 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import io.flutter.plugin.common.MethodChannel -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +/// 悬浮窗口服务 class FloatingWindowService : Service() { companion object { const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" @@ -51,11 +52,23 @@ class FloatingWindowService : Service() { private var sourceTextView: TextView? = null private var translatedTextView: TextView? = null + private var sourceLoadingView: TextView? = null + private var translatedLoadingView: TextView? = null private var languageInfoView: TextView? = null - private var timestampView: TextView? = null private var minimizedView: View? = null private var expandedView: View? = null + private var minimizedTextView: TextView? = null private var closeButton: TextView? = null + private var expandedContainer: LinearLayout? = null + + private var baseExpandedWidth: Int = 0 + private var uiScale: Float = 1.0f + private val backgroundBaseColor: Int = Color.argb(150, 40, 40, 40) + private val uiHandler: Handler = Handler(Looper.getMainLooper()) + private var sourceDotsStep: Int = 0 + private var translatedDotsStep: Int = 0 + private var sourceDotsRunnable: Runnable? = null + private var translatedDotsRunnable: Runnable? = null override fun onCreate() { super.onCreate() @@ -76,6 +89,7 @@ class FloatingWindowService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { + stopAllLoading() removeFloatingWindow() serviceInstance = null super.onDestroy() @@ -129,39 +143,73 @@ class FloatingWindowService : Service() { } private fun createMinimizedView(): View { - val container = FrameLayout(this) - val lp = FrameLayout.LayoutParams(dp(60), dp(60)) + val container = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48)) val margin = dp(8) lp.setMargins(margin, margin, margin, margin) container.layoutParams = lp + container.setPadding(dp(10), dp(8), dp(12), dp(8)) + container.minimumWidth = dp(160) val bg = GradientDrawable().apply { - setColor(0xFFE3F2FD.toInt()) - cornerRadius = dp(30).toFloat() + setColor(backgroundBaseColor) + cornerRadius = dp(24).toFloat() + setStroke(dp(1), Color.argb(46, 255, 255, 255)) } container.background = bg - container.elevation = dp(8).toFloat() + container.elevation = dp(10).toFloat() + val iconBg = GradientDrawable().apply { + setColor(Color.argb(42, 255, 255, 255)) + cornerRadius = dp(14).toFloat() + } val icon = TextView(this).apply { - text = "译" - setTextColor(0xFF1976D2.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + text = "V" + setTextColor(0xFFFFFFFF.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) gravity = Gravity.CENTER typeface = Typeface.DEFAULT_BOLD + background = iconBg } - container.addView( - icon, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) + + val iconLp = LinearLayout.LayoutParams(dp(28), dp(28)) + container.addView(icon, iconLp) + + minimizedTextView = TextView(this).apply { + text = "Voitrans" + setTextColor(Color.argb(235, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + val textLp = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(10) + } + container.addView(minimizedTextView, textLp) + + val chevron = TextView(this).apply { + text = "›" + setTextColor(Color.argb(150, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + gravity = Gravity.CENTER + setPadding(dp(6), 0, 0, 0) + } + container.addView(chevron) return container } private fun createExpandedView(): View { val container = LinearLayout(this) - val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT) + val maxWidth = dp(360) + val minWidth = dp(280) + val horizontalMargins = dp(8) * 2 + val screenWidth = resources.displayMetrics.widthPixels + val targetWidth = (screenWidth - horizontalMargins).coerceIn(minWidth, maxWidth) + baseExpandedWidth = targetWidth + val lp = FrameLayout.LayoutParams(targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT) val margin = dp(8) lp.setMargins(margin, margin, margin, margin) container.layoutParams = lp @@ -169,10 +217,12 @@ class FloatingWindowService : Service() { container.setPadding(dp(16), dp(16), dp(16), dp(16)) container.visibility = View.GONE container.elevation = dp(12).toFloat() + expandedContainer = container val bg = GradientDrawable().apply { - setColor(Color.WHITE) + setColor(backgroundBaseColor) cornerRadius = dp(16).toFloat() + setStroke(dp(1), Color.argb(46, 255, 255, 255)) } container.background = bg @@ -185,62 +235,80 @@ class FloatingWindowService : Service() { ) } - val title = TextView(this).apply { - text = "实时翻译" - setTextColor(0xFF333333.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - typeface = Typeface.DEFAULT_BOLD - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - } - closeButton = TextView(this).apply { text = "✕" - setTextColor(0xFF666666.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setTextColor(Color.argb(210, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) gravity = Gravity.CENTER setPadding(dp(8), dp(8), dp(8), dp(8)) } - titleRow.addView(title) - titleRow.addView(closeButton) - languageInfoView = TextView(this).apply { - text = "中文 → 英文" - setTextColor(0xFF666666.toInt()) + text = "src → tgt" + setTextColor(Color.argb(235, 255, 255, 255)) setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - setPadding(0, dp(8), 0, dp(8)) + setPadding(dp(10), dp(6), dp(10), dp(6)) + } + val langBg = GradientDrawable().apply { + setColor(Color.argb(36, 255, 255, 255)) + cornerRadius = dp(10).toFloat() } + languageInfoView?.background = langBg + val languageInRowLp = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) sourceTextView = TextView(this).apply { - text = "正在识别..." - setTextColor(0xFF333333.toInt()) + text = "" + setTextColor(Color.argb(235, 255, 255, 255)) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setPadding(0, dp(8), 0, dp(4)) maxLines = 3 ellipsize = TextUtils.TruncateAt.END + setLineSpacing(dpF(2f), 1.1f) + visibility = View.GONE } translatedTextView = TextView(this).apply { - text = "翻译中..." - setTextColor(0xFF1976D2.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + text = "" + setTextColor(0xFF90CAF9.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) setPadding(0, dp(4), 0, dp(8)) maxLines = 3 ellipsize = TextUtils.TruncateAt.END + setLineSpacing(dpF(2f), 1.1f) + visibility = View.GONE } - timestampView = TextView(this).apply { + sourceLoadingView = TextView(this).apply { text = "" - setTextColor(0xFF999999.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) - gravity = Gravity.END + setTextColor(Color.argb(235, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, dp(8), 0, dp(4)) + visibility = View.GONE } + translatedLoadingView = TextView(this).apply { + text = "" + setTextColor(0xFF90CAF9.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setPadding(0, dp(4), 0, dp(8)) + visibility = View.GONE + } + + val spacer = View(this) + val spacerLp = LinearLayout.LayoutParams(0, 0, 1f) + titleRow.addView(languageInfoView, languageInRowLp) + titleRow.addView(spacer, spacerLp) + titleRow.addView(closeButton) + container.addView(titleRow) - container.addView(languageInfoView) container.addView(sourceTextView) + container.addView(sourceLoadingView) container.addView(translatedTextView) - container.addView(timestampView) + container.addView(translatedLoadingView) + applyUiScale(uiScale) return container } @@ -270,21 +338,50 @@ class FloatingWindowService : Service() { private var initialTouchX: Float = 0f private var initialTouchY: Float = 0f private var dragging: Boolean = false + private var scaling: Boolean = false + private var initialSpan: Float = 0f + private var initialScale: Float = 1.0f override fun onTouch(v: View, event: MotionEvent): Boolean { val lp = layoutParams ?: return false val wm = windowManager ?: return false - when (event.action) { + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { initialX = lp.x initialY = lp.y initialTouchX = event.rawX initialTouchY = event.rawY + + if (!isMinimized && isTouchingView(closeButton, event.rawX, event.rawY) + ) { + return false + } + dragging = false + scaling = false + return true + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2) { + scaling = true + dragging = false + initialSpan = pointerSpan(event) + initialScale = uiScale + return true + } return true } MotionEvent.ACTION_MOVE -> { + if (scaling && event.pointerCount >= 2) { + val span = pointerSpan(event) + if (initialSpan > 0f && span > 0f) { + val next = (initialScale * (span / initialSpan)).coerceIn(0.85f, 1.4f) + setUiScale(next) + } + return true + } val dx = (event.rawX - initialTouchX).toInt() val dy = (event.rawY - initialTouchY).toInt() if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { @@ -304,12 +401,21 @@ class FloatingWindowService : Service() { return true } + MotionEvent.ACTION_POINTER_UP -> { + val remainingPointers = event.pointerCount - 1 + if (scaling && remainingPointers < 2) { + scaling = false + return true + } + return false + } + MotionEvent.ACTION_UP -> { - if (!dragging) updateViewState(!isMinimized) + if (!dragging && !scaling) updateViewState(!isMinimized) dragging = false + scaling = false return true } - else -> return false } } @@ -320,6 +426,13 @@ class FloatingWindowService : Service() { isMinimized = minimized minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE + + if (minimized) { + stopAllLoading() + minimizedView?.scaleX = 1.0f + minimizedView?.scaleY = 1.0f + } + notifyStateChanged(if (minimized) "minimized" else "expanded") } @@ -328,26 +441,58 @@ class FloatingWindowService : Service() { val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) - val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L) val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) + val sourceValue = sourceText ?: "" + val translatedValue = translatedText ?: "" + val hasSource = !TextUtils.isEmpty(sourceValue) + val hasTranslated = !TextUtils.isEmpty(translatedValue) + languageInfoView?.let { view -> val langText = (sourceLang ?: "") + " → " + (targetLang ?: "") view.text = langText } sourceTextView?.let { view -> - val value = sourceText ?: "" - view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value + if (hasSource) { + view.text = sourceValue + view.visibility = View.VISIBLE + stopSourceLoading() + } else { + view.text = "" + view.visibility = View.GONE + if (isIntermediate) { + startSourceLoading() + } else { + stopSourceLoading() + } + } + view.alpha = if (isIntermediate) 0.85f else 1.0f } translatedTextView?.let { view -> - val value = translatedText ?: "" - view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value + if (hasTranslated) { + view.text = translatedValue + view.visibility = View.VISIBLE + stopTranslatedLoading() + } else { + view.text = "" + view.visibility = View.GONE + if (hasSource || isIntermediate) { + startTranslatedLoading() + } else { + stopTranslatedLoading() + } + } view.alpha = if (isIntermediate) 0.7f else 1.0f } - timestampView?.text = if (timestamp > 0) formatTimestamp(timestamp) else "" + minimizedTextView?.let { view -> + val primary = translatedText?.takeIf { !TextUtils.isEmpty(it) } + val secondary = sourceText?.takeIf { !TextUtils.isEmpty(it) } + view.text = primary ?: secondary ?: "Voitrans" + view.alpha = if (isIntermediate) 0.85f else 1.0f + } } private fun setPosition(intent: Intent) { @@ -386,18 +531,108 @@ class FloatingWindowService : Service() { channel.invokeMethod("onFloatingWindowClicked", null) } - private fun formatTimestamp(ts: Long): String { - if (ts <= 0) return "" - return try { - val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - sdf.format(Date(ts)) - } catch (_: Exception) { - "" + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private fun dpF(value: Float): Float { + return value * resources.displayMetrics.density + } + + private fun setUiScale(next: Float) { + uiScale = next + applyUiScale(uiScale) + } + + private fun applyUiScale(scale: Float) { + val container = expandedContainer ?: return + if (baseExpandedWidth <= 0) return + + languageInfoView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f * scale) + sourceTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) + translatedTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) + sourceLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) + translatedLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) + + val buttonTextSizeSp = (18f * scale).coerceIn(14f, 22f) + closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, buttonTextSizeSp) + + val closeHPad = (dp(8) * scale).toInt().coerceIn(dp(6), dp(16)) + val closeVPad = (dp(8) * scale).toInt().coerceIn(dp(4), dp(12)) + closeButton?.setPadding(closeHPad, closeVPad, closeHPad, closeVPad) + + val padding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24)) + container.setPadding(padding, padding, padding, padding) + + val maxLines = if (scale >= 1.15f) 4 else 3 + sourceTextView?.maxLines = maxLines + translatedTextView?.maxLines = maxLines + } + + private fun startSourceLoading() { + val view = sourceLoadingView ?: return + if (view.visibility == View.VISIBLE && sourceDotsRunnable != null) return + sourceDotsStep = 0 + view.visibility = View.VISIBLE + val runnable = object : Runnable { + override fun run() { + val count = (sourceDotsStep % 3) + 1 + view.text = "•".repeat(count) + sourceDotsStep++ + uiHandler.postDelayed(this, 350L) + } } + sourceDotsRunnable = runnable + uiHandler.post(runnable) } - private fun dp(value: Int): Int { - return (value * resources.displayMetrics.density).toInt() + private fun stopSourceLoading() { + sourceDotsRunnable?.let { uiHandler.removeCallbacks(it) } + sourceDotsRunnable = null + sourceLoadingView?.visibility = View.GONE + sourceLoadingView?.text = "" } -} + private fun startTranslatedLoading() { + val view = translatedLoadingView ?: return + if (view.visibility == View.VISIBLE && translatedDotsRunnable != null) return + translatedDotsStep = 0 + view.visibility = View.VISIBLE + val runnable = object : Runnable { + override fun run() { + val count = (translatedDotsStep % 3) + 1 + view.text = "•".repeat(count) + translatedDotsStep++ + uiHandler.postDelayed(this, 350L) + } + } + translatedDotsRunnable = runnable + uiHandler.post(runnable) + } + + private fun stopTranslatedLoading() { + translatedDotsRunnable?.let { uiHandler.removeCallbacks(it) } + translatedDotsRunnable = null + translatedLoadingView?.visibility = View.GONE + translatedLoadingView?.text = "" + } + + private fun stopAllLoading() { + stopSourceLoading() + stopTranslatedLoading() + } + + private fun isTouchingView(view: View?, x: Float, y: Float): Boolean { + if (view == null || view.visibility != View.VISIBLE) return false + val rect = Rect() + if (!view.getGlobalVisibleRect(rect)) return false + return rect.contains(x.toInt(), y.toInt()) + } + + private fun pointerSpan(event: MotionEvent): Float { + if (event.pointerCount < 2) return 0f + val dx = event.getX(0) - event.getX(1) + val dy = event.getY(0) - event.getY(1) + return kotlin.math.sqrt(dx * dx + dy * dy) + } +} diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt index 269dcea3a..b35eebf81 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt @@ -16,6 +16,7 @@ import android.view.WindowManager import android.widget.LinearLayout import android.widget.TextView +/// 录音悬浮条服务 class RecordingFloatingService : Service() { companion object { const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR" From fab60b68ba967503ebc9ad6518233df1e87a6cd9 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 14 Jan 2026 11:16:25 +0800 Subject: [PATCH 11/11] =?UTF-8?q?feat(=E6=82=AC=E6=B5=AE=E7=AA=97):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=AA=97=E5=8F=A3=E5=A4=A7=E5=B0=8F=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=94=B9=E8=BF=9B=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增右下角拖拽调整窗口大小的功能 - 改进onDestroy方法中的资源清理逻辑 - 添加窗口权限检查和安全异常处理 - 优化UI缩放和布局参数管理 --- .../saitong/voitrans/FloatingWindowService.kt | 5 +- .../native_plugin/FloatingWindowService.kt | 252 ++++++++++++++++-- 2 files changed, 230 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt index b6e861db2..296310038 100644 --- a/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt +++ b/android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt @@ -370,8 +370,9 @@ class FloatingWindowService : Service() { } override fun onDestroy() { - super.onDestroy() floatingView?.let { windowManager?.removeView(it) } serviceInstance = null + methodChannel = null + super.onDestroy() } -} \ No newline at end of file +} diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt index e69e41144..9fb2e5266 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt @@ -11,6 +11,7 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import android.provider.Settings import android.text.TextUtils import android.util.TypedValue import android.view.Gravity @@ -59,9 +60,13 @@ class FloatingWindowService : Service() { private var expandedView: View? = null private var minimizedTextView: TextView? = null private var closeButton: TextView? = null - private var expandedContainer: LinearLayout? = null + private var expandedContainer: ViewGroup? = null + private var expandedContentLayout: LinearLayout? = null + private var resizeHandleView: View? = null private var baseExpandedWidth: Int = 0 + private var lastEffectiveWidth: Int = 0 + private var lastEffectiveHeight: Int = 0 private var uiScale: Float = 1.0f private val backgroundBaseColor: Int = Color.argb(150, 40, 40, 40) private val uiHandler: Handler = Handler(Looper.getMainLooper()) @@ -90,14 +95,36 @@ class FloatingWindowService : Service() { override fun onDestroy() { stopAllLoading() + uiHandler.removeCallbacksAndMessages(null) removeFloatingWindow() + sourceTextView = null + translatedTextView = null + sourceLoadingView = null + translatedLoadingView = null + languageInfoView = null + minimizedView = null + expandedView = null + minimizedTextView = null + closeButton = null + resizeHandleView = null + expandedContentLayout = null + expandedContainer = null + layoutParams = null + windowManager = null serviceInstance = null + methodChannel = null super.onDestroy() } private fun createFloatingWindow() { windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + notifyStateChanged("hidden") + stopSelf() + return + } + floatingView = createFloatingLayout() initViews() setupClickListeners() @@ -122,7 +149,16 @@ class FloatingWindowService : Service() { y = dp(100) } - windowManager?.addView(floatingView, layoutParams) + try { + windowManager?.addView(floatingView, layoutParams) + } catch (_: SecurityException) { + notifyStateChanged("hidden") + stopSelf() + return + } catch (_: Exception) { + stopSelf() + return + } updateViewState(true) } @@ -202,7 +238,7 @@ class FloatingWindowService : Service() { } private fun createExpandedView(): View { - val container = LinearLayout(this) + val root = FrameLayout(this) val maxWidth = dp(360) val minWidth = dp(280) val horizontalMargins = dp(8) * 2 @@ -212,19 +248,28 @@ class FloatingWindowService : Service() { val lp = FrameLayout.LayoutParams(targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT) val margin = dp(8) lp.setMargins(margin, margin, margin, margin) - container.layoutParams = lp - container.orientation = LinearLayout.VERTICAL - container.setPadding(dp(16), dp(16), dp(16), dp(16)) - container.visibility = View.GONE - container.elevation = dp(12).toFloat() - expandedContainer = container + root.layoutParams = lp + root.setPadding(0, 0, 0, 0) + root.visibility = View.GONE + root.elevation = dp(12).toFloat() + expandedContainer = root val bg = GradientDrawable().apply { setColor(backgroundBaseColor) cornerRadius = dp(16).toFloat() setStroke(dp(1), Color.argb(46, 255, 255, 255)) } - container.background = bg + root.background = bg + + val contentLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(16), dp(16), dp(16)) + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + expandedContentLayout = contentLayout val titleRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL @@ -303,13 +348,101 @@ class FloatingWindowService : Service() { titleRow.addView(spacer, spacerLp) titleRow.addView(closeButton) - container.addView(titleRow) - container.addView(sourceTextView) - container.addView(sourceLoadingView) - container.addView(translatedTextView) - container.addView(translatedLoadingView) + contentLayout.addView(titleRow) + contentLayout.addView(sourceTextView) + contentLayout.addView(sourceLoadingView) + contentLayout.addView(translatedTextView) + contentLayout.addView(translatedLoadingView) + root.addView(contentLayout) + + val handleSize = dp(30) + val resizeHandle = TextView(this).apply { + text = "⤢" + setTextColor(Color.argb(190, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + gravity = Gravity.CENTER + background = null + setPadding(0, 0, 0, 0) + scaleX = -1f + } + resizeHandleView = resizeHandle + val handleLp = FrameLayout.LayoutParams(handleSize, handleSize, Gravity.BOTTOM or Gravity.END).apply { + setMargins(0, 0, 0, 0) + } + root.addView(resizeHandle, handleLp) + + resizeHandle.setOnTouchListener(object : View.OnTouchListener { + private var downRawX: Float = 0f + private var downRawY: Float = 0f + private var startWidth: Int = 0 + private var startHeight: Int = 0 + + override fun onTouch(v: View, event: MotionEvent): Boolean { + val expanded = expandedContainer ?: return false + val rootLp = (expanded.layoutParams as? FrameLayout.LayoutParams) ?: return false + val size = v.width.takeIf { it > 0 } ?: return false + val localX = event.x + val localY = event.y + if (!isInBottomRightTriangle(localX, localY, size.toFloat())) { + return false + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downRawX = event.rawX + downRawY = event.rawY + startWidth = if (rootLp.width > 0) rootLp.width else expanded.width.takeIf { it > 0 } ?: baseExpandedWidth + val resolvedHeight = if (rootLp.height > 0 && rootLp.height != ViewGroup.LayoutParams.WRAP_CONTENT) { + rootLp.height + } else { + expanded.height.takeIf { it > 0 } ?: dp(220) + } + rootLp.height = resolvedHeight + expanded.layoutParams = rootLp + startHeight = resolvedHeight + baseExpandedWidth = startWidth + return true + } + + MotionEvent.ACTION_MOVE -> { + val dx = (event.rawX - downRawX).toInt() + val dy = (event.rawY - downRawY).toInt() + val minW = dp(240) + val maxW = (resources.displayMetrics.widthPixels - dp(8) * 2).coerceAtLeast(minW) + val minH = dp(140) + val maxH = (resources.displayMetrics.heightPixels - dp(12) * 2).coerceAtLeast(minH) + + val nextW = (startWidth + dx).coerceIn(minW, maxW) + val nextH = (startHeight + dy).coerceIn(minH, maxH) + if (rootLp.width != nextW || rootLp.height != nextH) { + rootLp.width = nextW + rootLp.height = nextH + expanded.layoutParams = rootLp + baseExpandedWidth = nextW + floatingView?.post { + val wm = windowManager ?: return@post + val floatingLp = layoutParams ?: return@post + val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize() + val maxX = resources.displayMetrics.widthPixels - effectiveWidth + val maxY = resources.displayMetrics.heightPixels - effectiveHeight + if (maxX > 0) floatingLp.x = floatingLp.x.coerceIn(0, maxX) + if (maxY > 0) floatingLp.y = floatingLp.y.coerceIn(0, maxY) + wm.updateViewLayout(floatingView, floatingLp) + } + } + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> return true + + else -> return false + } + } + }) + applyUiScale(uiScale) - return container + return root } private fun initViews() { @@ -356,6 +489,9 @@ class FloatingWindowService : Service() { ) { return false } + if (!isMinimized && isTouchingView(resizeHandleView, event.rawX, event.rawY)) { + return false + } dragging = false scaling = false @@ -363,7 +499,17 @@ class FloatingWindowService : Service() { } MotionEvent.ACTION_POINTER_DOWN -> { - if (event.pointerCount >= 2) { + if (!isMinimized && event.pointerCount >= 2) { + expandedContainer?.let { expanded -> + val expandedLp = expanded.layoutParams as? FrameLayout.LayoutParams + if (expandedLp != null && expandedLp.height == ViewGroup.LayoutParams.WRAP_CONTENT) { + val lockedHeight = expanded.height.takeIf { it > 0 } ?: dp(220) + expandedLp.height = lockedHeight + expanded.layoutParams = expandedLp + } + val width = (expanded.layoutParams as? FrameLayout.LayoutParams)?.width ?: 0 + if (width > 0) baseExpandedWidth = width + } scaling = true dragging = false initialSpan = pointerSpan(event) @@ -379,18 +525,25 @@ class FloatingWindowService : Service() { if (initialSpan > 0f && span > 0f) { val next = (initialScale * (span / initialSpan)).coerceIn(0.85f, 1.4f) setUiScale(next) + + val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize() + val maxX = resources.displayMetrics.widthPixels - effectiveWidth + val maxY = resources.displayMetrics.heightPixels - effectiveHeight + if (maxX > 0) lp.x = lp.x.coerceIn(0, maxX) + if (maxY > 0) lp.y = lp.y.coerceIn(0, maxY) + wm.updateViewLayout(floatingView, lp) } return true } + val dx = (event.rawX - initialTouchX).toInt() val dy = (event.rawY - initialTouchY).toInt() if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { dragging = true } - val maxX = - resources.displayMetrics.widthPixels - (floatingView?.width ?: 0) - val maxY = - resources.displayMetrics.heightPixels - (floatingView?.height ?: 0) + val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize() + val maxX = resources.displayMetrics.widthPixels - effectiveWidth + val maxY = resources.displayMetrics.heightPixels - effectiveHeight var nextX = initialX + dx var nextY = initialY + dy if (maxX > 0) nextX = nextX.coerceIn(0, maxX) @@ -549,26 +702,70 @@ class FloatingWindowService : Service() { if (baseExpandedWidth <= 0) return languageInfoView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f * scale) + languageInfoView?.setPadding( + (dp(10) * scale).toInt().coerceIn(dp(6), dp(16)), + (dp(6) * scale).toInt().coerceIn(dp(4), dp(12)), + (dp(10) * scale).toInt().coerceIn(dp(6), dp(16)), + (dp(6) * scale).toInt().coerceIn(dp(4), dp(12)) + ) sourceTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) translatedTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) sourceLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) translatedLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) - val buttonTextSizeSp = (18f * scale).coerceIn(14f, 22f) - closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, buttonTextSizeSp) + val closeTextSizeSp = (18f * scale).coerceIn(14f, 22f) + closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, closeTextSizeSp) val closeHPad = (dp(8) * scale).toInt().coerceIn(dp(6), dp(16)) val closeVPad = (dp(8) * scale).toInt().coerceIn(dp(4), dp(12)) closeButton?.setPadding(closeHPad, closeVPad, closeHPad, closeVPad) - val padding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24)) - container.setPadding(padding, padding, padding, padding) + container.setPadding(0, 0, 0, 0) + val contentPadding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24)) + expandedContentLayout?.setPadding(contentPadding, contentPadding, contentPadding, contentPadding) val maxLines = if (scale >= 1.15f) 4 else 3 sourceTextView?.maxLines = maxLines translatedTextView?.maxLines = maxLines } + private fun getEffectiveFloatingSize(): Pair { + val view = floatingView + if (view != null) { + val w = view.width + val h = view.height + if (w > 0 && h > 0) { + lastEffectiveWidth = w + lastEffectiveHeight = h + return w to h + } + } + + layoutParams?.let { lp -> + val w = lp.width + val h = lp.height + if (w > 0 && h > 0) { + lastEffectiveWidth = w + lastEffectiveHeight = h + return w to h + } + } + + (expandedContainer?.layoutParams as? FrameLayout.LayoutParams)?.let { lp -> + val w = lp.width + val h = lp.height + if (w > 0 && h > 0) { + lastEffectiveWidth = w + lastEffectiveHeight = h + return w to h + } + } + + if (lastEffectiveWidth <= 0) lastEffectiveWidth = dp(280) + if (lastEffectiveHeight <= 0) lastEffectiveHeight = dp(220) + return lastEffectiveWidth to lastEffectiveHeight + } + private fun startSourceLoading() { val view = sourceLoadingView ?: return if (view.visibility == View.VISIBLE && sourceDotsRunnable != null) return @@ -635,4 +832,9 @@ class FloatingWindowService : Service() { val dy = event.getY(0) - event.getY(1) return kotlin.math.sqrt(dx * dx + dy * dy) } + + private fun isInBottomRightTriangle(x: Float, y: Float, size: Float): Boolean { + if (x < 0f || y < 0f || x > size || y > size) return false + return x >= size - y + } }