|
|
|
@ -1,5 +1,5 @@ |
|
|
|
import 'dart:async'; |
|
|
|
import 'dart:math' as math; |
|
|
|
import 'dart:ui' as ui; |
|
|
|
import 'package:audio_waveforms/audio_waveforms.dart'; |
|
|
|
import 'package:flutter/material.dart'; |
|
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
|
import 'package:get/get.dart'; |
|
|
|
@ -108,16 +108,21 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
// 使用LayoutBuilder获取准确的屏幕宽度 |
|
|
|
final screenWidth = constraints.maxWidth; |
|
|
|
|
|
|
|
// 初始化最大可见波形条数量 |
|
|
|
controller.setMaxVisibleBars(screenWidth); |
|
|
|
|
|
|
|
return Column( |
|
|
|
children: [ |
|
|
|
SizedBox(height: 24.h), |
|
|
|
SizedBox(height: 16.h), |
|
|
|
Padding( |
|
|
|
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|
|
|
child: Align( |
|
|
|
alignment: Alignment.centerLeft, |
|
|
|
child: _buildRecordTypeBadge(isDarkMode), |
|
|
|
), |
|
|
|
), |
|
|
|
SizedBox(height: 12.h), |
|
|
|
// 实时流动波形显示区域 |
|
|
|
Container( |
|
|
|
height: 200.h, |
|
|
|
margin: EdgeInsets.symmetric(horizontal: 16.w), |
|
|
|
margin: EdgeInsets.zero, |
|
|
|
decoration: BoxDecoration( |
|
|
|
gradient: LinearGradient( |
|
|
|
begin: Alignment.topCenter, |
|
|
|
@ -155,12 +160,49 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
borderRadius: BorderRadius.circular(12.r), |
|
|
|
child: Stack( |
|
|
|
children: [ |
|
|
|
// 实时流动波形绘制组件 |
|
|
|
_WaveformWidget( |
|
|
|
controller: controller, |
|
|
|
isDarkMode: isDarkMode, |
|
|
|
size: Size(screenWidth - 32.w, 200.h), |
|
|
|
), |
|
|
|
Obx(() { |
|
|
|
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( |
|
|
|
size: size, |
|
|
|
recorderController: controller.waveformController, |
|
|
|
backgroundColor: Colors.transparent, |
|
|
|
waveStyle: WaveStyle( |
|
|
|
waveThickness: 1.w, |
|
|
|
spacing: 2.w, |
|
|
|
waveCap: StrokeCap.round, |
|
|
|
showMiddleLine: true, |
|
|
|
middleLineColor: Colors.redAccent.withValues( |
|
|
|
alpha: controller.timerStatus.value == |
|
|
|
TimerStatus.running |
|
|
|
? 0.9 |
|
|
|
: 0.0, |
|
|
|
), |
|
|
|
middleLineThickness: 1.w, |
|
|
|
showTop: true, |
|
|
|
showBottom: true, |
|
|
|
extendWaveform: true, |
|
|
|
// gradient: shader, |
|
|
|
waveColor: waveColor, |
|
|
|
scaleFactor: 90, |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
}), |
|
|
|
|
|
|
|
// 录音状态指示器 |
|
|
|
Obx(() { |
|
|
|
@ -217,7 +259,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
fontWeight: FontWeight.normal, |
|
|
|
color: isDarkMode ? Colors.white : Colors.black87, |
|
|
|
fontFeatures: const [ |
|
|
|
FontFeature.tabularFigures() |
|
|
|
ui.FontFeature.tabularFigures() |
|
|
|
], // 使用等宽数字,避免跳动 |
|
|
|
), |
|
|
|
)), |
|
|
|
@ -250,6 +292,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
tooltip: controller.isMarked.value |
|
|
|
? 'unmarkFavorite'.tr |
|
|
|
: 'markFavorite'.tr, |
|
|
|
isDarkMode: isDarkMode, |
|
|
|
), |
|
|
|
// 录音按钮 |
|
|
|
_buildActionButton( |
|
|
|
@ -265,6 +308,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
tooltip: controller.timerStatus.value == TimerStatus.running |
|
|
|
? 'pauseRecording'.tr |
|
|
|
: 'startRecording'.tr, |
|
|
|
isDarkMode: isDarkMode, |
|
|
|
), |
|
|
|
// 完成按钮 |
|
|
|
_buildActionButton( |
|
|
|
@ -276,6 +320,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
), |
|
|
|
radius: 32.w, |
|
|
|
tooltip: 'finishRecording'.tr, |
|
|
|
isDarkMode: isDarkMode, |
|
|
|
), |
|
|
|
], |
|
|
|
), |
|
|
|
@ -288,11 +333,14 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
required Widget child, |
|
|
|
required double radius, |
|
|
|
required String tooltip, |
|
|
|
required bool isDarkMode, |
|
|
|
}) { |
|
|
|
final Color bgColor = isDarkMode ? const Color(0xFF1E1E1E) : Colors.white; |
|
|
|
|
|
|
|
return Tooltip( |
|
|
|
message: tooltip, |
|
|
|
child: Material( |
|
|
|
color: Colors.white, |
|
|
|
color: bgColor, |
|
|
|
shape: const CircleBorder(), |
|
|
|
elevation: 4, |
|
|
|
child: InkWell( |
|
|
|
@ -300,7 +348,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
customBorder: const CircleBorder(), |
|
|
|
child: CircleAvatar( |
|
|
|
radius: radius, |
|
|
|
backgroundColor: Colors.white, |
|
|
|
backgroundColor: bgColor, |
|
|
|
child: child, |
|
|
|
), |
|
|
|
), |
|
|
|
@ -308,6 +356,60 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
Widget _buildRecordTypeBadge(bool isDarkMode) { |
|
|
|
return Obx(() { |
|
|
|
String label; |
|
|
|
IconData icon; |
|
|
|
Color color; |
|
|
|
|
|
|
|
switch (controller.audioType.value) { |
|
|
|
case 0: |
|
|
|
label = 'liveRecording'.tr; |
|
|
|
icon = Icons.mic_rounded; |
|
|
|
color = Colors.red; |
|
|
|
break; |
|
|
|
case 1: |
|
|
|
label = 'audioVideoRecording'.tr; |
|
|
|
icon = Icons.videocam_rounded; |
|
|
|
color = Colors.green; |
|
|
|
break; |
|
|
|
case 2: |
|
|
|
default: |
|
|
|
label = 'callRecording'.tr; |
|
|
|
icon = Icons.phone_in_talk_rounded; |
|
|
|
color = Colors.orange; |
|
|
|
break; |
|
|
|
} |
|
|
|
|
|
|
|
return Container( |
|
|
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h), |
|
|
|
decoration: BoxDecoration( |
|
|
|
color: color.withValues(alpha: isDarkMode ? 0.25 : 0.12), |
|
|
|
borderRadius: BorderRadius.circular(999.r), |
|
|
|
), |
|
|
|
child: Row( |
|
|
|
mainAxisSize: MainAxisSize.min, |
|
|
|
children: [ |
|
|
|
Icon( |
|
|
|
icon, |
|
|
|
size: 18.sp, |
|
|
|
color: isDarkMode ? Colors.white : color, |
|
|
|
), |
|
|
|
SizedBox(width: 6.w), |
|
|
|
Text( |
|
|
|
label, |
|
|
|
style: TextStyle( |
|
|
|
fontSize: 13.sp, |
|
|
|
fontWeight: FontWeight.w500, |
|
|
|
color: isDarkMode ? Colors.white : Colors.black87, |
|
|
|
), |
|
|
|
), |
|
|
|
], |
|
|
|
), |
|
|
|
); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
// 底部编辑弹窗 |
|
|
|
void _bottomSheet(BuildContext context, bool isDarkMode) { |
|
|
|
Get.bottomSheet( |
|
|
|
@ -450,218 +552,3 @@ class MeetingRecordView extends GetView<MeetingRecordController> { |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 简化的实时流动波形绘制器 |
|
|
|
class RealTimeWaveformPainter extends CustomPainter { |
|
|
|
final List<double> waveData; |
|
|
|
final bool isDarkMode; |
|
|
|
final bool isRecording; |
|
|
|
final double animationValue; // 动画值,用于流动效果 |
|
|
|
|
|
|
|
RealTimeWaveformPainter({ |
|
|
|
required this.waveData, |
|
|
|
required this.isDarkMode, |
|
|
|
required this.isRecording, |
|
|
|
this.animationValue = 0.0, |
|
|
|
}); |
|
|
|
|
|
|
|
@override |
|
|
|
void paint(Canvas canvas, Size size) { |
|
|
|
final centerY = size.height / 2; |
|
|
|
|
|
|
|
// 绘制中央基准线 |
|
|
|
_drawCenterLine(canvas, size, centerY); |
|
|
|
|
|
|
|
// 只在录音状态下且有波形数据时才绘制波形 |
|
|
|
if (isRecording && waveData.isNotEmpty) { |
|
|
|
_drawWaveform(canvas, size, centerY); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 绘制中央基准线 |
|
|
|
void _drawCenterLine(Canvas canvas, Size size, double centerY) { |
|
|
|
final linePaint = Paint() |
|
|
|
..color = |
|
|
|
(isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) |
|
|
|
..strokeWidth = 1.0; |
|
|
|
|
|
|
|
canvas.drawLine( |
|
|
|
Offset(0, centerY), |
|
|
|
Offset(size.width, centerY), |
|
|
|
linePaint, |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
/// 绘制简化的流动波形 |
|
|
|
void _drawWaveform(Canvas canvas, Size size, double centerY) { |
|
|
|
const double barWidth = 3.0; // 波形条宽度 |
|
|
|
const double barSpacing = 1.0; // 波形条间距 |
|
|
|
const double totalBarWidth = barWidth + barSpacing; |
|
|
|
|
|
|
|
// 计算可显示的波形条数量 |
|
|
|
final maxBars = (size.width / totalBarWidth).floor(); |
|
|
|
final visibleBars = waveData.length > maxBars ? maxBars : waveData.length; |
|
|
|
|
|
|
|
// 计算流动偏移量(从右向左移动) |
|
|
|
final flowOffset = animationValue * totalBarWidth; |
|
|
|
|
|
|
|
// 从右到左绘制波形条 |
|
|
|
for (int i = 0; i < visibleBars; i++) { |
|
|
|
final dataIndex = waveData.length - 1 - i; |
|
|
|
final amplitude = waveData[dataIndex]; |
|
|
|
|
|
|
|
// 计算波形条的x位置(从右侧开始,向左排列) |
|
|
|
final x = size.width - (i + 1) * totalBarWidth + flowOffset; |
|
|
|
|
|
|
|
// 跳过屏幕外的波形条 |
|
|
|
if (x < -barWidth || x > size.width) continue; |
|
|
|
|
|
|
|
_drawWaveBar( |
|
|
|
canvas, x, centerY, amplitude, i.toDouble(), visibleBars.toDouble()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 绘制单个波形条 |
|
|
|
void _drawWaveBar(Canvas canvas, double x, double centerY, double amplitude, |
|
|
|
double index, double totalBars) { |
|
|
|
const double barWidth = 2.0; |
|
|
|
const double maxHeight = 200.0; |
|
|
|
const double minHeight = 4.0; // 最小基础高度 |
|
|
|
const double containerPadding = 10.0; // 容器内边距,防止超出边界 |
|
|
|
|
|
|
|
// 计算可用的最大高度(考虑容器边界) |
|
|
|
final availableHeight = (centerY - containerPadding) * 2; // 上下对称 |
|
|
|
final effectiveMaxHeight = math.min(maxHeight, availableHeight); |
|
|
|
|
|
|
|
// 优化波形条高度计算:确保有基础高度 + 振幅变化 |
|
|
|
final normalizedAmplitude = (amplitude / 100.0).clamp(0.0, 1.0); |
|
|
|
final dynamicHeight = |
|
|
|
normalizedAmplitude * (effectiveMaxHeight - minHeight); |
|
|
|
final barHeight = minHeight + dynamicHeight; |
|
|
|
|
|
|
|
// 计算透明度(越新的波形条越不透明) |
|
|
|
//final opacity = (1.0 - (index / totalBars) * 0.6).clamp(0.3, 1.0); |
|
|
|
|
|
|
|
// 创建波形条矩形 |
|
|
|
final rect = RRect.fromRectAndRadius( |
|
|
|
Rect.fromCenter( |
|
|
|
center: Offset(x + barWidth / 2, centerY), |
|
|
|
width: barWidth, |
|
|
|
height: barHeight, // 使用优化后的高度 |
|
|
|
), |
|
|
|
const Radius.circular(1.5), |
|
|
|
); |
|
|
|
|
|
|
|
// 创建渐变颜色 |
|
|
|
final Color baseColor = isDarkMode |
|
|
|
? Colors.grey[500]! // 深灰色 |
|
|
|
: Colors.grey[600]!; // 更深灰色 |
|
|
|
|
|
|
|
final paint = Paint() |
|
|
|
..color = baseColor |
|
|
|
..style = PaintingStyle.fill; |
|
|
|
|
|
|
|
canvas.drawRRect(rect, paint); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { |
|
|
|
// 只有在以下情况才重绘 |
|
|
|
if (oldDelegate.isDarkMode != isDarkMode || |
|
|
|
oldDelegate.isRecording != isRecording) { |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
// 数据长度变化才重绘 |
|
|
|
if (oldDelegate.waveData.length != waveData.length) { |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
// 数据内容变化(比较最后一个值) |
|
|
|
if (waveData.isNotEmpty && |
|
|
|
oldDelegate.waveData.isNotEmpty && |
|
|
|
(waveData.last - oldDelegate.waveData.last).abs() > 0.1) { |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
// 动画值每 0.1 变化才重绘(避免每帧都重绘) |
|
|
|
return (animationValue - oldDelegate.animationValue).abs() > 0.1; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// 优化的波形Widget,避免双重响应式更新 |
|
|
|
class _WaveformWidget extends StatefulWidget { |
|
|
|
final MeetingRecordController controller; |
|
|
|
final bool isDarkMode; |
|
|
|
final Size size; |
|
|
|
|
|
|
|
const _WaveformWidget({ |
|
|
|
required this.controller, |
|
|
|
required this.isDarkMode, |
|
|
|
required this.size, |
|
|
|
}); |
|
|
|
|
|
|
|
@override |
|
|
|
State<_WaveformWidget> createState() => _WaveformWidgetState(); |
|
|
|
} |
|
|
|
|
|
|
|
class _WaveformWidgetState extends State<_WaveformWidget> { |
|
|
|
late List<double> _waveData; |
|
|
|
late bool _isRecording; |
|
|
|
StreamSubscription? _waveDataSubscription; |
|
|
|
StreamSubscription? _isRecordingSubscription; |
|
|
|
|
|
|
|
@override |
|
|
|
void initState() { |
|
|
|
super.initState(); |
|
|
|
_waveData = List.from(widget.controller.waveData); |
|
|
|
_isRecording = widget.controller.isRecording.value; |
|
|
|
|
|
|
|
// 监听 waveData 变化 |
|
|
|
_waveDataSubscription = widget.controller.waveData.listen((data) { |
|
|
|
if (mounted) { |
|
|
|
setState(() { |
|
|
|
_waveData = List.from(data); |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
// 监听 isRecording 变化 |
|
|
|
_isRecordingSubscription = widget.controller.isRecording.listen((value) { |
|
|
|
if (mounted) { |
|
|
|
setState(() { |
|
|
|
_isRecording = value; |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
void dispose() { |
|
|
|
_waveDataSubscription?.cancel(); |
|
|
|
_isRecordingSubscription?.cancel(); |
|
|
|
super.dispose(); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
Widget build(BuildContext context) { |
|
|
|
return RepaintBoundary( |
|
|
|
child: AnimatedBuilder( |
|
|
|
animation: widget.controller.waveformAnimation, |
|
|
|
builder: (context, child) { |
|
|
|
return CustomPaint( |
|
|
|
size: widget.size, |
|
|
|
painter: RealTimeWaveformPainter( |
|
|
|
waveData: _waveData, |
|
|
|
isDarkMode: widget.isDarkMode, |
|
|
|
isRecording: _isRecording, |
|
|
|
animationValue: widget.controller.waveformAnimation.value, |
|
|
|
), |
|
|
|
isComplex: true, // 告诉 Flutter 这个绘制较复杂,可缓存 |
|
|
|
willChange: false, // 数据不会频繁变化(我们已优化) |
|
|
|
); |
|
|
|
}, |
|
|
|
), |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|