Browse Source

feat(录音控制): 重构录音控制器并优化波形动画效果

- 使用GetSingleTickerProviderStateMixin替换SingleGetTickerProviderMixin
- 移除未使用的变量和TabController
- 新增波形流动动画控制器和动画效果
- 优化音频数据处理逻辑和波形显示效果
- 改进录音状态管理和资源清理
- 更新视图层以适配新的控制器功能
newdev_shunjiawei
lxm 1 year ago
parent
commit
c4c38c8a59
  1. 154
      lib/modules/meeting/controllers/meeting_record_controller.dart
  2. 806
      lib/modules/meeting/views/meeting_record_view.dart

154
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -23,7 +23,7 @@ enum TimerStatus {
/// Controller for meeting recording screen /// Controller for meeting recording screen
/// Handles recording state, audio processing, transcription, and UI interactions /// Handles recording state, audio processing, transcription, and UI interactions
class MeetingRecordController extends GetxController class MeetingRecordController extends GetxController
with SingleGetTickerProviderMixin { with GetSingleTickerProviderStateMixin {
// Services // Services
final AsrService _asrService = Get.find<AsrService>(); final AsrService _asrService = Get.find<AsrService>();
final BleManager _bleManager = Get.find<BleManager>(); final BleManager _bleManager = Get.find<BleManager>();
@ -57,8 +57,7 @@ class MeetingRecordController extends GetxController
Timer? _timer; Timer? _timer;
int _elapsedMilliseconds = 0; int _elapsedMilliseconds = 0;
int? _lastStartTime; int? _lastStartTime;
bool _audioSourceType = false; // 移除了未使用的变量和TabController
late TabController tabController;
StreamSubscription<Uint8List>? _audioDataSubscription; StreamSubscription<Uint8List>? _audioDataSubscription;
// 添加波形放大倍数变量 // 添加波形放大倍数变量
@ -67,6 +66,10 @@ class MeetingRecordController extends GetxController
final RxInt _currentPosition = 0.obs; // 原始位置索引 final RxInt _currentPosition = 0.obs; // 原始位置索引
int maxPoints = 500; // 最大显示点数(根据屏幕宽度优化) int maxPoints = 500; // 最大显示点数(根据屏幕宽度优化)
int sampleRate = 16000; // 假设音频采样率(根据实际情况调整) int sampleRate = 16000; // 假设音频采样率(根据实际情况调整)
// 波形流动动画控制器
late AnimationController waveformAnimationController;
late Animation<double> waveformAnimation;
// 设置最大点数(根据屏幕宽度) // 设置最大点数(根据屏幕宽度)
void setMaxPoints(double screenWidth) { void setMaxPoints(double screenWidth) {
maxPoints = screenWidth ~/ 2; // 每2像素一个数据点 maxPoints = screenWidth ~/ 2; // 每2像素一个数据点
@ -77,12 +80,14 @@ class MeetingRecordController extends GetxController
super.onInit(); super.onInit();
_initializeComponents(); _initializeComponents();
_setupFromArguments(); _setupFromArguments();
generateWaveData(); _initializeWaveformAnimation();
//generateWaveData();
} }
@override @override
void onClose() { void onClose() {
_cleanupResources(); _cleanupResources();
waveformAnimationController.dispose();
super.onClose(); super.onClose();
} }
@ -111,12 +116,36 @@ class MeetingRecordController extends GetxController
/// Initializes UI controllers and focus nodes /// Initializes UI controllers and focus nodes
void _initializeComponents() { void _initializeComponents() {
tabController = TabController(length: 1, vsync: this);
titleEditingController = TextEditingController(text: fileName.value); titleEditingController = TextEditingController(text: fileName.value);
titleFocusNode = FocusNode(); titleFocusNode = FocusNode();
titleFocusNode.addListener(_handleTitleFocusChange); titleFocusNode.addListener(_handleTitleFocusChange);
} }
/// 初始化波形流动动画
void _initializeWaveformAnimation() {
// 设置动画持续时间,控制流动速度
waveformAnimationController = AnimationController(
duration: const Duration(milliseconds: 100), // 更快的流动速度
vsync: this,
);
waveformAnimation = Tween<double>(
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 /// Sets up initial state from navigation arguments
void _setupFromArguments() { void _setupFromArguments() {
final Map<String, dynamic> args = Get.arguments ?? {}; final Map<String, dynamic> args = Get.arguments ?? {};
@ -126,15 +155,12 @@ class MeetingRecordController extends GetxController
switch (audioType.value) { switch (audioType.value) {
case 0: case 0:
fileName.value = "现场录音"; fileName.value = "现场录音";
_audioSourceType = false;
break; break;
case 1: case 1:
fileName.value = "音、视频录音"; fileName.value = "音、视频录音";
_audioSourceType = true;
break; break;
case 2: case 2:
fileName.value = "通话录音"; fileName.value = "通话录音";
_audioSourceType = true;
break; break;
} }
newName.value = fileName.value; newName.value = fileName.value;
@ -145,6 +171,8 @@ class MeetingRecordController extends GetxController
_timer?.cancel(); _timer?.cancel();
_recognitionSubscription?.cancel(); _recognitionSubscription?.cancel();
_recognitionSubscription = null; _recognitionSubscription = null;
_audioDataSubscription?.cancel();
_audioDataSubscription = null;
titleEditingController.dispose(); titleEditingController.dispose();
titleFocusNode.dispose(); titleFocusNode.dispose();
if (audioType.value != 0) { if (audioType.value != 0) {
@ -242,7 +270,7 @@ class MeetingRecordController extends GetxController
// 1. 实时音频可视化 // 1. 实时音频可视化
// 2. 音频质量检测 // 2. 音频质量检测
// 3. 发送到其他服务 // 3. 发送到其他服务
print('接收到音频数据: ${audioData.length} 字节'); Logger.d("AudioData", "接收到音频数据: ${audioData.length} 字节");
// 处理音频数据的逻辑 // 处理音频数据的逻辑
_processAudioData(audioData); _processAudioData(audioData);
@ -254,6 +282,9 @@ class MeetingRecordController extends GetxController
newName.value = fullFileName; newName.value = fullFileName;
titleEditingController.text = fullFileName; titleEditingController.text = fullFileName;
// 启动波形动画
_startWaveformAnimation();
// Start hardware components // Start hardware components
_startHardwareServices(); _startHardwareServices();
startTimer(); startTimer();
@ -279,31 +310,17 @@ class MeetingRecordController extends GetxController
/// Pauses current recording session /// Pauses current recording session
Future<void> _pauseRecording() async { Future<void> _pauseRecording() async {
await _asrService.pauseRecord(); await _asrService.pauseRecord();
_stopWaveformAnimation(); // 暂停时停止动画
pauseTimer(); pauseTimer();
} }
/// Resumes paused recording session /// Resumes paused recording session
Future<void> _resumeRecording() async { Future<void> _resumeRecording() async {
await _asrService.resumeRecord(); await _asrService.resumeRecord();
_startWaveformAnimation(); // 恢复时启动动画
startTimer(); 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音频数据 /// 处理原始Uint8List音频数据
void _processAudioData(Uint8List audioData) { void _processAudioData(Uint8List audioData) {
// 将Uint8List转换为Float32List(PCM16 -> 浮点) // 将Uint8List转换为Float32List(PCM16 -> 浮点)
@ -360,70 +377,55 @@ class MeetingRecordController extends GetxController
return _currentPosition.value / waveData.length; return _currentPosition.value / waveData.length;
} }
/// 添加处理后的音频数据 /// 添加处理后的音频数据 - 优化流动效果
void _addAudioData(Float32List newAudio) { void _addAudioData(Float32List newAudio) {
// 1. 降采样:只保留部分数据点 if (!isRecording.value) return;
final downsampled = _downsampleAudio(newAudio);
waveData.addAll(downsampled); // 1. 降采样处理
final downsampled = _downsampleAudio(newAudio);
if (downsampled.isEmpty) return;
// 3. 更新光标位置到最新点 // 2. 逐个添加数据点,确保平滑流动
_currentPosition.value = waveData.length; for (final sample in downsampled) {
waveData.add(sample);
// 4. 限制数据长度,移除旧数据 //维护合理的数据长度 - 保持滑动窗口效果
if (waveData.length > maxPoints * 2) { if (waveData.length > maxPoints) {
waveData.removeRange(0, waveData.length - maxPoints); waveData.removeAt(0); // 移除最旧的数据
_currentPosition.value = waveData.length; }
} }
// 3. 确保动画持续运行
_ensureAnimationRunning();
// 4. 更新位置信息
_currentPosition.value = waveData.length;
} }
/// Formats transcript with speaker identification /// 启动波形流动动画
String _formatTranscript(RecognitionEvent event) { void _startWaveformAnimation() {
final buffer = StringBuffer(); if (isRecording.value && !waveformAnimationController.isAnimating) {
waveformAnimationController.forward();
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}');
} }
}
return buffer.toString(); /// 停止波形动画
void _stopWaveformAnimation() {
waveformAnimationController.stop();
waveformAnimationController.reset();
} }
/// Checks if character is Chinese/English punctuation /// 确保动画持续运行
bool _isPunctuation(String char) { void _ensureAnimationRunning() {
const punctuations = { if (isRecording.value && !waveformAnimationController.isAnimating) {
',', waveformAnimationController.forward();
'。', }
'!',
'?',
';',
':',
',',
'.',
':',
';',
'!',
'?'
};
return punctuations.contains(char);
} }
/// Saves recording and cleans up resources /// Saves recording and cleans up resources
Future<void> saveRecording() async { Future<void> saveRecording() async {
resetTimer(); resetTimer();
_stopWaveformAnimation(); // 停止波形动画
if (audioType.value != 0) { if (audioType.value != 0) {
_bleManager.closeCodec(); _bleManager.closeCodec();
} }
@ -436,11 +438,11 @@ class MeetingRecordController extends GetxController
final dir = Directory("${appDir.path}/MeetingAudio/"); final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true); if (!await dir.exists()) await dir.create(recursive: true);
if (isMarked.value) { if (isMarked.value) {
final Newdir = Directory("${appDir.path}/DestAudio/"); final newDir = Directory("${appDir.path}/DestAudio/");
if (!await Newdir.exists()) await Newdir.create(recursive: true); if (!await newDir.exists()) await newDir.create(recursive: true);
await _asrService.moveFile( await _asrService.moveFile(
"${dir.path}${fileName.value}.wav", "${dir.path}${fileName.value}.wav",
"${Newdir.path}${fileName.value}.wav", "${newDir.path}${fileName.value}.wav",
); );
} else { } else {
await _asrService.renameFile( await _asrService.renameFile(

806
lib/modules/meeting/views/meeting_record_view.dart

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../controllers/meeting_record_controller.dart'; import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
@ -14,17 +13,22 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return WillPopScope( return PopScope(
onWillPop: () async { onPopInvokedWithResult: (didPop, result) async {
// 在返回时执行保存操作 if (didPop) {
controller.saveTitleChange(); // 在返回时执行保存操作
await controller.saveRecording(); controller.saveTitleChange();
return true; // 允许退出 //await controller.saveRecording();
}
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( 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 { onPressed: () async {
// 在返回按钮点击时执行保存操作 // 在返回按钮点击时执行保存操作
controller.saveTitleChange(); controller.saveTitleChange();
@ -48,7 +52,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
isDense: true, isDense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
border: InputBorder.none, border: InputBorder.none,
hintText: '输入文件名', hintText: 'enterFileName'.tr,
hintStyle: TextStyle( hintStyle: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54, color: isDarkMode ? Colors.white70 : Colors.black54,
), ),
@ -88,24 +92,9 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
body: Column( body: Column(
children: [ children: [
TabBar( // 简化为单一音频界面,移除不必要的TabBar
controller: controller.tabController,
indicatorColor: Colors.red,
labelColor: isDarkMode ? Colors.white : Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: '音频'),
// Tab(text: '文本'),
],
),
Expanded( Expanded(
child: TabBarView( child: _buildAudioTab(context, isDarkMode),
controller: controller.tabController,
children: [
_buildAudioTab(context, isDarkMode),
// _buildTextTab(context, isDarkMode),
],
),
), ),
], ],
), ),
@ -114,339 +103,574 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
} }
Widget _buildAudioTab(BuildContext context, bool isDarkMode) { Widget _buildAudioTab(BuildContext context, bool isDarkMode) {
return Obx(() { return LayoutBuilder(
final screenWidth = MediaQuery.of(context).size.width; builder: (context, constraints) {
// 初始化最大点数 // 使用LayoutBuilder获取准确的屏幕宽度
if (controller.maxPoints == 0) { final screenWidth = constraints.maxWidth;
controller.setMaxPoints(screenWidth);
} // 初始化最大点数
final cursorPos = controller.cursorPositionValue; if (controller.maxPoints == 0) {
return Column( controller.setMaxPoints(screenWidth);
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),
],
);
});
}
Widget _buildTextTab(BuildContext context, bool isDarkMode) { return Column(
return Obx(() => Column(
children: [ children: [
const SizedBox(height: 48), SizedBox(height: 24.h),
Center( // 专业级实时流动波形显示区域
child: Column( Container(
children: [ height: 120.h,
const Icon(Icons.graphic_eq, color: Colors.red, size: 32), margin: EdgeInsets.symmetric(horizontal: 16.w),
const SizedBox(height: 8), decoration: BoxDecoration(
Text( gradient: LinearGradient(
controller.formatTime(controller.seconds.value), begin: Alignment.topCenter,
style: const TextStyle( end: Alignment.bottomCenter,
fontSize: 36, fontWeight: FontWeight.bold), 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),
), ),
], ],
), ),
), child: ClipRRect(
const SizedBox(height: 24), borderRadius: BorderRadius.circular(12.r),
// 新增:转文本提示区域 child: Stack(
if (!controller.showTextContent.value) ...[ children: [
const Text('无文本', // 实时流动波形绘制组件
style: TextStyle( _WaveformWidget(
color: Colors.grey, controller: controller,
fontSize: 16, isDarkMode: isDarkMode,
)), size: Size(screenWidth - 32.w, 120.h),
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),
), ),
),
// 录音状态指示器
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(), const Spacer(),
_buildBottomBar(context, isDarkMode), _buildBottomBar(context, isDarkMode),
const SizedBox(height: 32), SizedBox(height: 32.h),
], ],
)); );
},
);
} }
// 底部操作栏 // 底部操作栏
Widget _buildBottomBar(BuildContext context, bool isDarkMode) { Widget _buildBottomBar(BuildContext context, bool isDarkMode) {
return Row( return Padding(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, padding: EdgeInsets.symmetric(horizontal: 20.w),
children: [ child: Row(
// 收藏按钮 mainAxisAlignment: MainAxisAlignment.spaceEvenly,
GestureDetector( children: [
onTap: controller.toggleMarked, // 收藏按钮
child: Obx(() => CircleAvatar( _buildActionButton(
radius: 32, onTap: controller.toggleMarked,
backgroundColor: Colors.white, child: Obx(() => Icon(
child: Icon(
controller.isMarked.value ? Icons.star : Icons.star_border, controller.isMarked.value ? Icons.star : Icons.star_border,
size: 36, size: 36.sp,
color: color:
controller.isMarked.value ? Colors.orange : Colors.grey, controller.isMarked.value ? Colors.orange : Colors.grey,
), )),
)), radius: 32.w,
), tooltip: controller.isMarked.value
// 录音按钮 ? 'unmarkFavorite'.tr
GestureDetector( : 'markFavorite'.tr,
onTap: controller.toggleRecording, ),
child: Obx(() => CircleAvatar( // 录音按钮
radius: 40, _buildActionButton(
backgroundColor: Colors.white, onTap: controller.toggleRecording,
child: Icon( child: Obx(() => Icon(
controller.timerStatus.value == TimerStatus.running controller.timerStatus.value == TimerStatus.running
? Icons.pause ? Icons.pause
: Icons.play_arrow, : Icons.play_arrow,
size: 44, size: 44.sp,
color: Colors.red, color: Colors.red,
), )),
)), radius: 40.w,
), tooltip: controller.timerStatus.value == TimerStatus.running
// 完成按钮 ? 'pauseRecording'.tr
GestureDetector( : 'startRecording'.tr,
onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数 ),
child: const CircleAvatar( // 完成按钮
radius: 32, _buildActionButton(
backgroundColor: Colors.white, onTap: () => _bottomSheet(context, isDarkMode),
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 36, size: 36.sp,
color: Colors.green, 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) { void _bottomSheet(BuildContext context, bool isDarkMode) {
Get.bottomSheet( Get.bottomSheet(
Padding( Container(
padding: MediaQuery.of(context).viewInsets, padding: EdgeInsets.only(
child: Container( left: 20.w,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w), right: 20.w,
decoration: BoxDecoration( top: 20.w,
color: isDarkMode ? AppColors.darkBackground : Colors.grey[50], bottom: MediaQuery.of(context).viewInsets.bottom + 20.w,
borderRadius: BorderRadius.vertical(top: Radius.circular(20.w)), ),
), decoration: BoxDecoration(
child: Column( color: isDarkMode ? AppColors.darkBackground : Colors.white,
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
children: [ boxShadow: [
// 标题 BoxShadow(
Padding( color: Colors.black.withValues(alpha: 0.1),
padding: EdgeInsets.only(bottom: 20.w), blurRadius: 10,
child: Text( offset: const Offset(0, -2),
'editMeetingTitle'.tr, ),
style: TextStyle( ],
color: isDarkMode ? Colors.white : Colors.black87, ),
fontSize: 18.sp, child: Column(
fontWeight: FontWeight.bold, 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,
),
),
// 输入框 SizedBox(height: 16.h),
Container(
decoration: BoxDecoration( // 输入框
color: isDarkMode ? Colors.white10 : Colors.white, Container(
borderRadius: BorderRadius.circular(12.w), 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, child: TextField(
autofocus: true, // 自动弹出键盘 controller: controller.titleEditingController,
maxLength: 50, // 添加字符限制 autofocus: true,
style: TextStyle( maxLength: 50,
color: isDarkMode ? Colors.white : Colors.black87, style: TextStyle(
fontSize: 16.sp, color: isDarkMode ? Colors.white : Colors.black87,
), fontSize: 16.sp,
decoration: InputDecoration( ),
hintText: 'maxCharacters'.tr, decoration: InputDecoration(
hintStyle: TextStyle( hintText: 'enterFileName'.tr,
color: isDarkMode ? Colors.white60 : Colors.black45, hintStyle: TextStyle(
), color: isDarkMode ? Colors.white60 : Colors.grey[600],
border: InputBorder.none,
contentPadding: EdgeInsets.all(16.w),
counterText: '', // 隐藏默认计数器
), ),
border: InputBorder.none,
contentPadding: EdgeInsets.all(16.w),
counterText: '',
), ),
textInputAction: TextInputAction.done,
onSubmitted: (_) async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back();
},
), ),
),
Padding( SizedBox(height: 24.h),
padding: EdgeInsets.only(top: 30.w),
child: Row( // 按钮组
children: [ Row(
// 取消按钮 children: [
Expanded( Expanded(
child: TextButton( child: OutlinedButton(
onPressed: Get.back, // onPressed: Get.back,
style: TextButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: foregroundColor:
isDarkMode ? AppColors.primary : Colors.blue, isDarkMode ? Colors.white70 : Colors.grey[700],
padding: EdgeInsets.symmetric(vertical: 12.w), side: BorderSide(
shape: RoundedRectangleBorder( color: isDarkMode ? Colors.white30 : Colors.grey[400]!,
borderRadius: BorderRadius.circular(12.w), ),
side: BorderSide( padding: EdgeInsets.symmetric(vertical: 14.h),
color: shape: RoundedRectangleBorder(
isDarkMode ? AppColors.primary : Colors.blue, borderRadius: BorderRadius.circular(12.r),
width: 2.w,
),
),
),
child: Text('cancel'.tr),
), ),
), ),
child: Text('cancel'.tr),
SizedBox(width: 20.w), ),
),
// 确认按钮 SizedBox(width: 16.w),
Expanded( Expanded(
child: TextButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
await controller.saveTitleChange(); await controller.saveTitleChange();
await controller.saveRecording(); await controller.saveRecording();
Get.back(); Get.back();
}, },
style: TextButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor:
isDarkMode ? AppColors.primary : Colors.blue, isDarkMode ? AppColors.primary : Colors.blue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12.w), padding: EdgeInsets.symmetric(vertical: 14.h),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w), borderRadius: BorderRadius.circular(12.r),
),
),
child: Text('confirm'.tr),
), ),
elevation: 2,
), ),
], child: Text('confirm'.tr),
),
), ),
), ],
], ),
), ],
), ),
), ),
isScrollControlled: true, // 防止键盘遮挡 isScrollControlled: true,
backgroundColor: Colors.transparent, // 移除默认背景 backgroundColor: Colors.transparent,
isDismissible: true,
enableDrag: true,
); );
} }
} }
class WaveformPainter extends CustomPainter { /// 实时流动波形绘制器
final List<double> data; class RealTimeWaveformPainter extends CustomPainter {
final double cursorPosition; final List<double> 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 @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final paint = Paint() final centerY = size.height / 2;
..color = Colors.blue[300]! final centerX = size.width / 2;
..strokeWidth = 2
..strokeCap = StrokeCap.round; // 绘制中央基准线
_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 ..color = Colors.red
..strokeWidth = 2; ..style = PaintingStyle.fill;
final double barWidth = size.width / (data.length * 1.2); // 增加条宽 canvas.drawPath(trianglePath, trianglePaint);
final centerY = size.height / 2; }
// 改为绘制矩形条,增加视觉冲击力 /// 绘制实时流动波形
for (int i = 0; i < data.length; i++) { void _drawWaveform(Canvas canvas, Size size, double centerX, double centerY) {
final x = i * barWidth; final barWidth = 2.5; // 波形条宽度
final height = min(data[i], 50); // 限制最大高度 final barSpacing = 0.5; // 波形条间距
final rect = Rect.fromLTWH( final totalBarWidth = barWidth + barSpacing;
x,
centerY - height / 2, // 计算中央参考线左侧可以显示多少个波形条
barWidth * 0.8, // 条宽稍小于间隔 final maxBarsOnLeft = (centerX / totalBarWidth).floor();
height.toDouble(),
); // 绘制左侧流动波形(历史数据,从右向左流动)
_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 amplitude = waveData[dataIndex];
final gradient = LinearGradient(
colors: [Colors.blue[400]!, Colors.blue[200]!], // 计算透明度:越远的波形条越透明
begin: Alignment.topCenter, final opacity = (1.0 - (i / displayCount) * 0.6).clamp(0.2, 1.0);
end: Alignment.bottomCenter,
_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; ..style = PaintingStyle.fill;
canvas.drawRRect( canvas.drawRRect(highlightRect, highlightPaint);
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
rectPaint,
);
} }
}
final cursorX = size.width * cursorPosition; /// 获取渐变颜色
canvas.drawLine( List<Color> _getGradientColors(double opacity) {
Offset(cursorX, 0), return isDarkMode
Offset(cursorX, size.height), ? [
cursorPaint, 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 @override
bool shouldRepaint(covariant WaveformPainter oldDelegate) { bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) {
return oldDelegate.data != data || return oldDelegate.waveData != waveData ||
oldDelegate.cursorPosition != cursorPosition; 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,
);
});
},
);
} }
} }

Loading…
Cancel
Save