From 0cac4049ae0c8fb7aa81155b04c7eb6b44079916 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Mon, 12 Jan 2026 19:41:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BD=95=E9=9F=B3):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BD=95=E9=9F=B3=E6=82=AC=E6=B5=AE=E7=AA=97=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=BD=95=E9=9F=B3=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA=EF=BC=8C=E6=AD=A4=E5=A4=96=E9=83=BD?= =?UTF-8?q?=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,