From 86225017f6169402b03ce240c1640485682291ad Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 15 Aug 2025 18:06:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BD=95=E9=9F=B3=E6=8E=A7=E5=88=B6):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=BD=95=E9=9F=B3=E6=8E=A7=E5=88=B6=E5=99=A8?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B3=A2=E5=BD=A2=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用GetSingleTickerProviderStateMixin替换SingleGetTickerProviderMixin - 移除未使用的变量和TabController - 新增波形流动动画控制器和动画效果 - 优化音频数据处理逻辑和波形显示效果 - 改进录音状态管理和资源清理 - 更新视图层以适配新的控制器功能 --- .../meeting_record_controller.dart | 154 ++-- .../meeting/views/meeting_record_view.dart | 806 +++++++++++------- 2 files changed, 593 insertions(+), 367 deletions(-) diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 11177bb1b..b30651e28 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -23,7 +23,7 @@ enum TimerStatus { /// Controller for meeting recording screen /// Handles recording state, audio processing, transcription, and UI interactions class MeetingRecordController extends GetxController - with SingleGetTickerProviderMixin { + with GetSingleTickerProviderStateMixin { // Services final AsrService _asrService = Get.find(); final BleManager _bleManager = Get.find(); @@ -57,8 +57,7 @@ class MeetingRecordController extends GetxController Timer? _timer; int _elapsedMilliseconds = 0; int? _lastStartTime; - bool _audioSourceType = false; - late TabController tabController; + // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; // 添加波形放大倍数变量 @@ -67,6 +66,10 @@ class MeetingRecordController extends GetxController final RxInt _currentPosition = 0.obs; // 原始位置索引 int maxPoints = 500; // 最大显示点数(根据屏幕宽度优化) int sampleRate = 16000; // 假设音频采样率(根据实际情况调整) + + // 波形流动动画控制器 + late AnimationController waveformAnimationController; + late Animation waveformAnimation; // 设置最大点数(根据屏幕宽度) void setMaxPoints(double screenWidth) { maxPoints = screenWidth ~/ 2; // 每2像素一个数据点 @@ -77,12 +80,14 @@ class MeetingRecordController extends GetxController super.onInit(); _initializeComponents(); _setupFromArguments(); - generateWaveData(); + _initializeWaveformAnimation(); + //generateWaveData(); } @override void onClose() { _cleanupResources(); + waveformAnimationController.dispose(); super.onClose(); } @@ -111,12 +116,36 @@ class MeetingRecordController extends GetxController /// Initializes UI controllers and focus nodes void _initializeComponents() { - tabController = TabController(length: 1, vsync: this); titleEditingController = TextEditingController(text: fileName.value); titleFocusNode = FocusNode(); titleFocusNode.addListener(_handleTitleFocusChange); } + /// 初始化波形流动动画 + void _initializeWaveformAnimation() { + // 设置动画持续时间,控制流动速度 + waveformAnimationController = AnimationController( + duration: const Duration(milliseconds: 100), // 更快的流动速度 + vsync: this, + ); + + waveformAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: waveformAnimationController, + curve: Curves.linear, // 线性动画确保匀速流动 + )); + + // 监听动画完成,实现连续循环 + waveformAnimationController.addStatusListener((status) { + if (status == AnimationStatus.completed && isRecording.value) { + waveformAnimationController.reset(); + waveformAnimationController.forward(); + } + }); + } + /// Sets up initial state from navigation arguments void _setupFromArguments() { final Map args = Get.arguments ?? {}; @@ -126,15 +155,12 @@ class MeetingRecordController extends GetxController switch (audioType.value) { case 0: fileName.value = "现场录音"; - _audioSourceType = false; break; case 1: fileName.value = "音、视频录音"; - _audioSourceType = true; break; case 2: fileName.value = "通话录音"; - _audioSourceType = true; break; } newName.value = fileName.value; @@ -145,6 +171,8 @@ class MeetingRecordController extends GetxController _timer?.cancel(); _recognitionSubscription?.cancel(); _recognitionSubscription = null; + _audioDataSubscription?.cancel(); + _audioDataSubscription = null; titleEditingController.dispose(); titleFocusNode.dispose(); if (audioType.value != 0) { @@ -242,7 +270,7 @@ class MeetingRecordController extends GetxController // 1. 实时音频可视化 // 2. 音频质量检测 // 3. 发送到其他服务 - print('接收到音频数据: ${audioData.length} 字节'); + Logger.d("AudioData", "接收到音频数据: ${audioData.length} 字节"); // 处理音频数据的逻辑 _processAudioData(audioData); @@ -254,6 +282,9 @@ class MeetingRecordController extends GetxController newName.value = fullFileName; titleEditingController.text = fullFileName; + // 启动波形动画 + _startWaveformAnimation(); + // Start hardware components _startHardwareServices(); startTimer(); @@ -279,31 +310,17 @@ class MeetingRecordController extends GetxController /// Pauses current recording session Future _pauseRecording() async { await _asrService.pauseRecord(); + _stopWaveformAnimation(); // 暂停时停止动画 pauseTimer(); } /// Resumes paused recording session Future _resumeRecording() async { await _asrService.resumeRecord(); - + _startWaveformAnimation(); // 恢复时启动动画 startTimer(); } - /// Handles speech recognition events - void _handleRecognitionEvent(RecognitionEvent event) { - switch (event.type) { - case RecognitionEventType.intermediateResult: - intermediateContent.value = event.text; - break; - case RecognitionEventType.finalResult: - finalContent.value += _formatTranscript(event); - intermediateContent.value = ''; - break; - default: - break; - } - } - /// 处理原始Uint8List音频数据 void _processAudioData(Uint8List audioData) { // 将Uint8List转换为Float32List(PCM16 -> 浮点) @@ -360,70 +377,55 @@ class MeetingRecordController extends GetxController return _currentPosition.value / waveData.length; } - /// 添加处理后的音频数据 + /// 添加处理后的音频数据 - 优化流动效果 void _addAudioData(Float32List newAudio) { - // 1. 降采样:只保留部分数据点 - final downsampled = _downsampleAudio(newAudio); + if (!isRecording.value) return; - waveData.addAll(downsampled); + // 1. 降采样处理 + final downsampled = _downsampleAudio(newAudio); + if (downsampled.isEmpty) return; - // 3. 更新光标位置到最新点 - _currentPosition.value = waveData.length; + // 2. 逐个添加数据点,确保平滑流动 + for (final sample in downsampled) { + waveData.add(sample); - // 4. 限制数据长度,移除旧数据 - if (waveData.length > maxPoints * 2) { - waveData.removeRange(0, waveData.length - maxPoints); - _currentPosition.value = waveData.length; + //维护合理的数据长度 - 保持滑动窗口效果 + if (waveData.length > maxPoints) { + waveData.removeAt(0); // 移除最旧的数据 + } } + + // 3. 确保动画持续运行 + _ensureAnimationRunning(); + + // 4. 更新位置信息 + _currentPosition.value = waveData.length; } - /// Formats transcript with speaker identification - String _formatTranscript(RecognitionEvent event) { - final buffer = StringBuffer(); - - if (finalContent.value.isNotEmpty) { - if (event.role.isNotEmpty) { - // Add line break before new speaker - if (_isPunctuation(event.text[0])) { - buffer.write('${event.text[0]}\n'); - buffer.write('说话人${event.role}:'); - buffer.write(event.text.substring(1)); - } else { - buffer.write('\n说话人${event.role}:'); - buffer.write(event.text); - } - } else { - buffer.write(event.text); - } - } else { - buffer.write('说话人${event.role}:${event.text}'); + /// 启动波形流动动画 + void _startWaveformAnimation() { + if (isRecording.value && !waveformAnimationController.isAnimating) { + waveformAnimationController.forward(); } + } - return buffer.toString(); + /// 停止波形动画 + void _stopWaveformAnimation() { + waveformAnimationController.stop(); + waveformAnimationController.reset(); } - /// Checks if character is Chinese/English punctuation - bool _isPunctuation(String char) { - const punctuations = { - ',', - '。', - '!', - '?', - ';', - ':', - ',', - '.', - ':', - ';', - '!', - '?' - }; - return punctuations.contains(char); + /// 确保动画持续运行 + void _ensureAnimationRunning() { + if (isRecording.value && !waveformAnimationController.isAnimating) { + waveformAnimationController.forward(); + } } /// Saves recording and cleans up resources Future saveRecording() async { resetTimer(); + _stopWaveformAnimation(); // 停止波形动画 if (audioType.value != 0) { _bleManager.closeCodec(); } @@ -436,11 +438,11 @@ class MeetingRecordController extends GetxController 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); + 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", + "${newDir.path}${fileName.value}.wav", ); } else { await _asrService.renameFile( diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 4de8a3320..3784e9673 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import 'package:lottie/lottie.dart'; import '../controllers/meeting_record_controller.dart'; import '../../../core/theme/app_colors.dart'; @@ -14,17 +13,22 @@ class MeetingRecordView extends GetView { Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return WillPopScope( - onWillPop: () async { - // 在返回时执行保存操作 - controller.saveTitleChange(); - await controller.saveRecording(); - return true; // 允许退出 + return PopScope( + onPopInvokedWithResult: (didPop, result) async { + if (didPop) { + // 在返回时执行保存操作 + controller.saveTitleChange(); + //await controller.saveRecording(); + } }, child: Scaffold( appBar: AppBar( leading: IconButton( - icon: const Icon(Icons.arrow_back), + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), onPressed: () async { // 在返回按钮点击时执行保存操作 controller.saveTitleChange(); @@ -48,7 +52,7 @@ class MeetingRecordView extends GetView { isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, - hintText: '输入文件名', + hintText: 'enterFileName'.tr, hintStyle: TextStyle( color: isDarkMode ? Colors.white70 : Colors.black54, ), @@ -88,24 +92,9 @@ class MeetingRecordView extends GetView { ), body: Column( children: [ - TabBar( - controller: controller.tabController, - indicatorColor: Colors.red, - labelColor: isDarkMode ? Colors.white : Colors.black, - unselectedLabelColor: Colors.grey, - tabs: const [ - Tab(text: '音频'), - // Tab(text: '文本'), - ], - ), + // 简化为单一音频界面,移除不必要的TabBar Expanded( - child: TabBarView( - controller: controller.tabController, - children: [ - _buildAudioTab(context, isDarkMode), - // _buildTextTab(context, isDarkMode), - ], - ), + child: _buildAudioTab(context, isDarkMode), ), ], ), @@ -114,339 +103,574 @@ class MeetingRecordView extends GetView { } Widget _buildAudioTab(BuildContext context, bool isDarkMode) { - return Obx(() { - final screenWidth = MediaQuery.of(context).size.width; - // 初始化最大点数 - if (controller.maxPoints == 0) { - controller.setMaxPoints(screenWidth); - } - final cursorPos = controller.cursorPositionValue; - return Column( - children: [ - const SizedBox(height: 24), - SizedBox( - height: 120, - child: Stack( - alignment: Alignment.center, - children: [ - // 波形绘制 - CustomPaint( - painter: WaveformPainter( - controller.waveData, - cursorPosition: cursorPos, - // color: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - ), - size: Size(screenWidth, 120), - ), - // 光标指示器 - Positioned( - left: MediaQuery.of(context).size.width * cursorPos, - child: Container( - width: 2, - height: 120, - color: Colors.red, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Text( - controller.formatTime(controller.seconds.value), - style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), - ), - const Spacer(), - _buildBottomBar(context, isDarkMode), - const SizedBox(height: 32), - ], - ); - }); - } + return LayoutBuilder( + builder: (context, constraints) { + // 使用LayoutBuilder获取准确的屏幕宽度 + final screenWidth = constraints.maxWidth; + + // 初始化最大点数 + if (controller.maxPoints == 0) { + controller.setMaxPoints(screenWidth); + } - Widget _buildTextTab(BuildContext context, bool isDarkMode) { - return Obx(() => Column( + return Column( children: [ - const SizedBox(height: 48), - Center( - child: Column( - children: [ - const Icon(Icons.graphic_eq, color: Colors.red, size: 32), - const SizedBox(height: 8), - Text( - controller.formatTime(controller.seconds.value), - style: const TextStyle( - fontSize: 36, fontWeight: FontWeight.bold), + SizedBox(height: 24.h), + // 专业级实时流动波形显示区域 + Container( + height: 120.h, + margin: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isDarkMode + ? [ + const Color(0xFF1A1A1A), + const Color(0xFF2A2A2A), + const Color(0xFF1A1A1A), + ] + : [ + const Color(0xFFF8F9FA), + const Color(0xFFFFFFFF), + const Color(0xFFF8F9FA), + ], + ), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isDarkMode + ? Colors.white.withValues(alpha: 0.1) + : Colors.black.withValues(alpha: 0.05), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), - ), - const SizedBox(height: 24), - // 新增:转文本提示区域 - if (!controller.showTextContent.value) ...[ - const Text('无文本', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - )), - const SizedBox(height: 16), - ElevatedButton( - onPressed: controller.toggleTextDisplay, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 8, - ), - ), - child: const Text('转文本'), - ), - ], - // 文本内容区域(转文本后显示) - if (controller.showTextContent.value) - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: SingleChildScrollView( - child: Text( - '${controller.finalContent.value}${controller.intermediateContent.value}', - style: const TextStyle(fontSize: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: Stack( + children: [ + // 实时流动波形绘制组件 + _WaveformWidget( + controller: controller, + isDarkMode: isDarkMode, + size: Size(screenWidth - 32.w, 120.h), ), - ), + + // 录音状态指示器 + Obx(() { + if (controller.timerStatus.value == TimerStatus.running) { + return Positioned( + top: 8.h, + right: 8.w, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: 2.h, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 4.w, + height: 4.h, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 3.w), + Text( + 'REC', + style: TextStyle( + color: Colors.white, + fontSize: 8.sp, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } + return const SizedBox.shrink(); + }), + ], ), ), + ), + SizedBox(height: 24.h), + // 时间显示 - 只包装需要响应式更新的Text widget + Obx(() => Text( + controller.formatTime(controller.seconds.value), + style: TextStyle( + fontSize: 48.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black87, + ), + )), const Spacer(), _buildBottomBar(context, isDarkMode), - const SizedBox(height: 32), + SizedBox(height: 32.h), ], - )); + ); + }, + ); } // 底部操作栏 Widget _buildBottomBar(BuildContext context, bool isDarkMode) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // 收藏按钮 - GestureDetector( - onTap: controller.toggleMarked, - child: Obx(() => CircleAvatar( - radius: 32, - backgroundColor: Colors.white, - child: Icon( + return Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // 收藏按钮 + _buildActionButton( + onTap: controller.toggleMarked, + child: Obx(() => Icon( controller.isMarked.value ? Icons.star : Icons.star_border, - size: 36, + size: 36.sp, color: controller.isMarked.value ? Colors.orange : Colors.grey, - ), - )), - ), - // 录音按钮 - GestureDetector( - onTap: controller.toggleRecording, - child: Obx(() => CircleAvatar( - radius: 40, - backgroundColor: Colors.white, - child: Icon( + )), + radius: 32.w, + tooltip: controller.isMarked.value + ? 'unmarkFavorite'.tr + : 'markFavorite'.tr, + ), + // 录音按钮 + _buildActionButton( + onTap: controller.toggleRecording, + child: Obx(() => Icon( controller.timerStatus.value == TimerStatus.running ? Icons.pause : Icons.play_arrow, - size: 44, + size: 44.sp, color: Colors.red, - ), - )), - ), - // 完成按钮 - GestureDetector( - onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数 - child: const CircleAvatar( - radius: 32, - backgroundColor: Colors.white, + )), + radius: 40.w, + tooltip: controller.timerStatus.value == TimerStatus.running + ? 'pauseRecording'.tr + : 'startRecording'.tr, + ), + // 完成按钮 + _buildActionButton( + onTap: () => _bottomSheet(context, isDarkMode), child: Icon( Icons.check, - size: 36, + size: 36.sp, color: Colors.green, ), + radius: 32.w, + tooltip: 'finishRecording'.tr, + ), + ], + ), + ); + } + + // 统一的操作按钮组件 + Widget _buildActionButton({ + required VoidCallback onTap, + required Widget child, + required double radius, + required String tooltip, + }) { + return Tooltip( + message: tooltip, + child: Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 4, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: CircleAvatar( + radius: radius, + backgroundColor: Colors.white, + child: child, ), ), - ], + ), ); } -// 底部编辑弹窗 + // 底部编辑弹窗 void _bottomSheet(BuildContext context, bool isDarkMode) { Get.bottomSheet( - Padding( - padding: MediaQuery.of(context).viewInsets, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w), - decoration: BoxDecoration( - color: isDarkMode ? AppColors.darkBackground : Colors.grey[50], - borderRadius: BorderRadius.vertical(top: Radius.circular(20.w)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 标题 - Padding( - padding: EdgeInsets.only(bottom: 20.w), - child: Text( - 'editMeetingTitle'.tr, - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black87, - fontSize: 18.sp, - fontWeight: FontWeight.bold, - ), + Container( + padding: EdgeInsets.only( + left: 20.w, + right: 20.w, + top: 20.w, + bottom: MediaQuery.of(context).viewInsets.bottom + 20.w, + ), + decoration: BoxDecoration( + color: isDarkMode ? AppColors.darkBackground : Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 拖拽指示器 + Center( + child: Container( + width: 40.w, + height: 4.h, + margin: EdgeInsets.only(bottom: 20.h), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2.r), ), ), + ), + + // 标题 + Text( + 'editMeetingTitle'.tr, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), - // 输入框 - Container( - decoration: BoxDecoration( - color: isDarkMode ? Colors.white10 : Colors.white, - borderRadius: BorderRadius.circular(12.w), + SizedBox(height: 16.h), + + // 输入框 + Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.white10 : Colors.grey[100], + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isDarkMode ? Colors.white24 : Colors.grey[300]!, + width: 1, ), - child: TextField( - controller: controller.titleEditingController, - autofocus: true, // 自动弹出键盘 - maxLength: 50, // 添加字符限制 - style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black87, - fontSize: 16.sp, - ), - decoration: InputDecoration( - hintText: 'maxCharacters'.tr, - hintStyle: TextStyle( - color: isDarkMode ? Colors.white60 : Colors.black45, - ), - border: InputBorder.none, - contentPadding: EdgeInsets.all(16.w), - counterText: '', // 隐藏默认计数器 + ), + child: TextField( + controller: controller.titleEditingController, + autofocus: true, + maxLength: 50, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 16.sp, + ), + decoration: InputDecoration( + hintText: 'enterFileName'.tr, + hintStyle: TextStyle( + color: isDarkMode ? Colors.white60 : Colors.grey[600], ), + border: InputBorder.none, + contentPadding: EdgeInsets.all(16.w), + counterText: '', ), + textInputAction: TextInputAction.done, + onSubmitted: (_) async { + await controller.saveTitleChange(); + await controller.saveRecording(); + Get.back(); + }, ), + ), - Padding( - padding: EdgeInsets.only(top: 30.w), - child: Row( - children: [ - // 取消按钮 - Expanded( - child: TextButton( - onPressed: Get.back, // - style: TextButton.styleFrom( - foregroundColor: - isDarkMode ? AppColors.primary : Colors.blue, - padding: EdgeInsets.symmetric(vertical: 12.w), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.w), - side: BorderSide( - color: - isDarkMode ? AppColors.primary : Colors.blue, - width: 2.w, - ), - ), - ), - child: Text('cancel'.tr), + SizedBox(height: 24.h), + + // 按钮组 + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: Get.back, + style: OutlinedButton.styleFrom( + foregroundColor: + isDarkMode ? Colors.white70 : Colors.grey[700], + side: BorderSide( + color: isDarkMode ? Colors.white30 : Colors.grey[400]!, + ), + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), ), ), - - SizedBox(width: 20.w), - - // 确认按钮 - Expanded( - child: TextButton( - onPressed: () async { - await controller.saveTitleChange(); - await controller.saveRecording(); - Get.back(); - }, - style: TextButton.styleFrom( - backgroundColor: - isDarkMode ? AppColors.primary : Colors.blue, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 12.w), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.w), - ), - ), - child: Text('confirm'.tr), + child: Text('cancel'.tr), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: ElevatedButton( + onPressed: () async { + await controller.saveTitleChange(); + await controller.saveRecording(); + Get.back(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: + isDarkMode ? AppColors.primary : Colors.blue, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), ), + elevation: 2, ), - ], + child: Text('confirm'.tr), + ), ), - ), - ], - ), + ], + ), + ], ), ), - isScrollControlled: true, // 防止键盘遮挡 - backgroundColor: Colors.transparent, // 移除默认背景 + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: true, + enableDrag: true, ); } } -class WaveformPainter extends CustomPainter { - final List data; - final double cursorPosition; +/// 实时流动波形绘制器 +class RealTimeWaveformPainter extends CustomPainter { + final List waveData; + final bool isDarkMode; + final bool isRecording; + final double animationValue; // 动画值,用于流动效果 - WaveformPainter(this.data, {required this.cursorPosition}); + RealTimeWaveformPainter({ + required this.waveData, + required this.isDarkMode, + required this.isRecording, + this.animationValue = 0.0, + }); @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.blue[300]! - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round; + final centerY = size.height / 2; + final centerX = size.width / 2; + + // 绘制中央基准线 + _drawCenterLine(canvas, size, centerX, centerY); + + // 只在录音状态下且有波形数据时才绘制波形 + if (isRecording && waveData.isNotEmpty) { + _drawWaveform(canvas, size, centerX, centerY); + } + } - final cursorPaint = Paint() + /// 绘制中央时间参考线 + void _drawCenterLine( + Canvas canvas, Size size, double centerX, double centerY) { + // 绘制水平中线 + final horizontalLinePaint = Paint() + ..color = + (isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) + ..strokeWidth = 1.0; + + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + horizontalLinePaint, + ); + + // 绘制垂直时间参考线(红色,更醒目) + final centerLinePaint = Paint() + ..color = Colors.red.withValues(alpha: 0.8) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + canvas.drawLine( + Offset(centerX, centerY - size.height * 0.4), + Offset(centerX, centerY + size.height * 0.4), + centerLinePaint, + ); + + // 在参考线顶部绘制小三角形指示器 + final trianglePath = Path(); + final triangleSize = 4.0; + trianglePath.moveTo(centerX, centerY - size.height * 0.4); + trianglePath.lineTo( + centerX - triangleSize, centerY - size.height * 0.4 - triangleSize); + trianglePath.lineTo( + centerX + triangleSize, centerY - size.height * 0.4 - triangleSize); + trianglePath.close(); + + final trianglePaint = Paint() ..color = Colors.red - ..strokeWidth = 2; + ..style = PaintingStyle.fill; - final double barWidth = size.width / (data.length * 1.2); // 增加条宽 - final centerY = size.height / 2; + canvas.drawPath(trianglePath, trianglePaint); + } - // 改为绘制矩形条,增加视觉冲击力 - for (int i = 0; i < data.length; i++) { - final x = i * barWidth; - final height = min(data[i], 50); // 限制最大高度 - final rect = Rect.fromLTWH( - x, - centerY - height / 2, - barWidth * 0.8, // 条宽稍小于间隔 - height.toDouble(), - ); + /// 绘制实时流动波形 + void _drawWaveform(Canvas canvas, Size size, double centerX, double centerY) { + final barWidth = 2.5; // 波形条宽度 + final barSpacing = 0.5; // 波形条间距 + final totalBarWidth = barWidth + barSpacing; + + // 计算中央参考线左侧可以显示多少个波形条 + final maxBarsOnLeft = (centerX / totalBarWidth).floor(); + + // 绘制左侧流动波形(历史数据,从右向左流动) + _drawLeftWaveform( + canvas, centerX, centerY, barWidth, totalBarWidth, maxBarsOnLeft); + } + + /// 绘制左侧历史波形(从右向左流动) + void _drawLeftWaveform(Canvas canvas, double centerX, double centerY, + double barWidth, double totalBarWidth, int maxBars) { + final dataLength = waveData.length; + if (dataLength == 0) return; + + // 计算流动偏移量 + final flowOffset = animationValue * totalBarWidth; + + // 绘制最近的波形条(最多显示maxBars个) + final displayCount = min(dataLength, maxBars); + + for (int i = 0; i < displayCount; i++) { + // 计算波形条的x位置:从参考线开始,向左排列,加上流动偏移 + final x = centerX - (i + 1) * totalBarWidth + flowOffset; + + // 如果波形条完全移出屏幕左侧,跳过绘制 + if (x + barWidth < 0) continue; + + // 获取对应的音频数据(最新的数据在数组末尾) + final dataIndex = dataLength - 1 - i; + if (dataIndex < 0) continue; - // 添加渐变效果增强视觉 - final gradient = LinearGradient( - colors: [Colors.blue[400]!, Colors.blue[200]!], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + final amplitude = waveData[dataIndex]; + + // 计算透明度:越远的波形条越透明 + final opacity = (1.0 - (i / displayCount) * 0.6).clamp(0.2, 1.0); + + _drawWaveBar(canvas, x, centerY, barWidth, amplitude, opacity); + } + } + + /// 绘制单个上下对称的波形条 + void _drawWaveBar(Canvas canvas, double x, double centerY, double barWidth, + double amplitude, double opacity) { + // 将振幅转换为显示高度(上下对称) + final maxHeight = 50.0; // 最大高度 + final barHeight = (amplitude / 100.0) * maxHeight; + + // 创建上下对称的圆角矩形 + final rect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(x + barWidth / 2, centerY), + width: barWidth, + height: barHeight * 2, // 上下对称,总高度是barHeight的2倍 + ), + Radius.circular(barWidth / 2), + ); + + // 创建渐变效果 + final gradient = LinearGradient( + colors: _getGradientColors(opacity), + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + final paint = Paint() + ..shader = gradient.createShader(rect.outerRect) + ..style = PaintingStyle.fill; + + canvas.drawRRect(rect, paint); + + // 可选:添加高光效果(仅对较新的波形条) + if (opacity > 0.7) { + final highlightRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(x + barWidth / 2, centerY), + width: barWidth * 0.4, + height: barHeight * 2 * 0.8, + ), + Radius.circular(barWidth / 4), ); - final rectPaint = Paint() - ..shader = gradient.createShader(rect) + + final highlightPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.2 * opacity) ..style = PaintingStyle.fill; - canvas.drawRRect( - RRect.fromRectAndRadius(rect, const Radius.circular(4)), - rectPaint, - ); + canvas.drawRRect(highlightRect, highlightPaint); } + } - final cursorX = size.width * cursorPosition; - canvas.drawLine( - Offset(cursorX, 0), - Offset(cursorX, size.height), - cursorPaint, - ); + /// 获取渐变颜色 + List _getGradientColors(double opacity) { + return isDarkMode + ? [ + Colors.blue[400]!.withValues(alpha: opacity), + Colors.blue[200]!.withValues(alpha: opacity * 0.7), + Colors.blue[100]!.withValues(alpha: opacity * 0.4), + ] + : [ + Colors.blue[500]!.withValues(alpha: opacity), + Colors.blue[300]!.withValues(alpha: opacity * 0.7), + Colors.blue[100]!.withValues(alpha: opacity * 0.4), + ]; } @override - bool shouldRepaint(covariant WaveformPainter oldDelegate) { - return oldDelegate.data != data || - oldDelegate.cursorPosition != cursorPosition; + bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { + return oldDelegate.waveData != waveData || + oldDelegate.isDarkMode != isDarkMode || + oldDelegate.isRecording != isRecording || + oldDelegate.animationValue != animationValue; + } +} + +/// 专门的波形Widget,正确管理GetX响应式状态 +class _WaveformWidget extends StatelessWidget { + final MeetingRecordController controller; + final bool isDarkMode; + final Size size; + + const _WaveformWidget({ + required this.controller, + required this.isDarkMode, + required this.size, + }); + + @override + Widget build(BuildContext context) { + // 使用AnimatedBuilder监听动画变化 + return AnimatedBuilder( + animation: controller.waveformAnimation, + builder: (context, child) { + // 使用Obx只监听需要响应式更新的数据 + return Obx(() { + return CustomPaint( + painter: RealTimeWaveformPainter( + waveData: controller.waveData, + isDarkMode: isDarkMode, + isRecording: controller.isRecording.value, + animationValue: controller.waveformAnimation.value, + ), + size: size, + ); + }); + }, + ); } }