Browse Source

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

- 使用GetSingleTickerProviderStateMixin替换SingleGetTickerProviderMixin
- 移除未使用的变量和TabController
- 新增波形流动动画控制器和动画效果
- 优化音频数据处理逻辑和波形显示效果
- 改进录音状态管理和资源清理
- 更新视图层以适配新的控制器功能
weicu
lxm 1 year ago
parent
commit
86225017f6
  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
/// Handles recording state, audio processing, transcription, and UI interactions
class MeetingRecordController extends GetxController
with SingleGetTickerProviderMixin {
with GetSingleTickerProviderStateMixin {
// Services
final AsrService _asrService = Get.find<AsrService>();
final BleManager _bleManager = Get.find<BleManager>();
@ -57,8 +57,7 @@ class MeetingRecordController extends GetxController
Timer? _timer;
int _elapsedMilliseconds = 0;
int? _lastStartTime;
bool _audioSourceType = false;
late TabController tabController;
// 移除了未使用的变量和TabController
StreamSubscription<Uint8List>? _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<double> 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<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
void _setupFromArguments() {
final Map<String, dynamic> 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<void> _pauseRecording() async {
await _asrService.pauseRecord();
_stopWaveformAnimation(); // 暂停时停止动画
pauseTimer();
}
/// Resumes paused recording session
Future<void> _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<void> 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(

806
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<MeetingRecordController> {
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<MeetingRecordController> {
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<MeetingRecordController> {
),
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<MeetingRecordController> {
}
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<double> data;
final double cursorPosition;
/// 实时流动波形绘制器
class RealTimeWaveformPainter extends CustomPainter {
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
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<Color> _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,
);
});
},
);
}
}

Loading…
Cancel
Save