Browse Source
feat(message): 添加语音输入和展示功能
feat(message): 添加语音输入和展示功能
- 新增语音输入视图,支持长按录音、上滑取消 - 实现录音权限申请与音频录制功能 - 添加录音时的动态波形可视化效果 - 创建语音消息展示组件,支持播放按钮和时长显示 - 实现语音消息的波形图形渲染 - 支持发送方与接收方消息样式的区分- 添加消息时间戳显示功能ios
2 changed files with 593 additions and 0 deletions
Split View
Diff Options
@ -0,0 +1,428 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:record/record.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
|
|||
class VoiceInputView extends StatefulWidget { |
|||
final bool isVisible; |
|||
final Function(String filePath, int seconds)? onVoiceRecorded; |
|||
|
|||
const VoiceInputView({ |
|||
required this.isVisible, |
|||
this.onVoiceRecorded, |
|||
super.key, |
|||
}); |
|||
|
|||
@override |
|||
State<VoiceInputView> createState() => _VoiceInputViewState(); |
|||
} |
|||
|
|||
class _VoiceInputViewState extends State<VoiceInputView> { |
|||
final AudioRecorder _audioRecorder = AudioRecorder(); |
|||
Timer? _timer; |
|||
int _seconds = 0; |
|||
bool _isRecording = false; |
|||
bool _isCanceling = false; |
|||
Offset _panStartPosition = Offset.zero; |
|||
String? _recordingPath; |
|||
|
|||
@override |
|||
void dispose() { |
|||
_timer?.cancel(); |
|||
_audioRecorder.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
// 格式化时间显示 |
|||
String _formatTime(int seconds) { |
|||
final minutes = seconds ~/ 60; |
|||
final secs = seconds % 60; |
|||
return '$minutes:${secs.toString().padLeft(2, '0')}'; |
|||
} |
|||
|
|||
// 开始录音 |
|||
Future<void> _startRecording() async { |
|||
// 请求麦克风权限 |
|||
final status = await Permission.microphone.request(); |
|||
if (!status.isGranted) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
if (await _audioRecorder.hasPermission()) { |
|||
// 获取临时目录 |
|||
final directory = await getTemporaryDirectory(); |
|||
final timestamp = DateTime.now().millisecondsSinceEpoch; |
|||
final path = '${directory.path}/voice_$timestamp.m4a'; |
|||
|
|||
await _audioRecorder.start(const RecordConfig(), path: path); |
|||
|
|||
setState(() { |
|||
_isRecording = true; |
|||
_seconds = 0; |
|||
_recordingPath = path; |
|||
_isCanceling = false; |
|||
_panStartPosition = Offset.zero; |
|||
}); |
|||
|
|||
// 启动计时器 |
|||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { |
|||
setState(() { |
|||
_seconds++; |
|||
}); |
|||
}); |
|||
} |
|||
} catch (e) { |
|||
print('开始录音失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 停止录音 |
|||
Future<void> _stopRecording({bool cancel = false}) async { |
|||
_timer?.cancel(); |
|||
_timer = null; |
|||
|
|||
if (_isRecording) { |
|||
try { |
|||
// 在重置状态前保存录音信息 |
|||
final finalSeconds = _seconds; |
|||
final finalPath = _recordingPath; |
|||
|
|||
await _audioRecorder.stop(); |
|||
setState(() { |
|||
_isRecording = false; |
|||
_seconds = 0; |
|||
_isCanceling = false; |
|||
_panStartPosition = Offset.zero; |
|||
_recordingPath = null; |
|||
}); |
|||
|
|||
// 如果不是取消,回传音频文件地址和秒数 |
|||
if (!cancel && finalPath != null && finalSeconds > 0) { |
|||
// 延迟一下,确保状态更新完成后再回调 |
|||
Future.microtask(() { |
|||
widget.onVoiceRecorded?.call(finalPath, finalSeconds); |
|||
}); |
|||
} |
|||
} catch (e) { |
|||
print('停止录音失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 处理长按开始 |
|||
void _onLongPressStart(LongPressStartDetails details) { |
|||
_panStartPosition = details.globalPosition; |
|||
_startRecording(); |
|||
} |
|||
|
|||
// 处理长按结束 |
|||
void _onLongPressEnd() { |
|||
if (_isCanceling) { |
|||
_stopRecording(cancel: true); |
|||
} else { |
|||
_stopRecording(cancel: false); |
|||
} |
|||
} |
|||
|
|||
// 生成音频波形(模拟),计时器嵌入在中间 |
|||
Widget _buildWaveformWithTimer() { |
|||
// 左侧波形条数量 |
|||
const int leftBars = 15; |
|||
// 右侧波形条数量 |
|||
const int rightBars = 15; |
|||
// 计时器左右两侧的紫色条数量(各2个) |
|||
const int purpleBarsPerSide = 2; |
|||
|
|||
return Container( |
|||
height: 30.h, |
|||
width: double.infinity, // 填充整个宽度 |
|||
// 移除内部 padding,让波形条延伸到边缘 |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
crossAxisAlignment: CrossAxisAlignment.center, |
|||
children: [ |
|||
// 左侧波形 |
|||
...List.generate(leftBars, (index) { |
|||
// 计算是否在紫色区域(靠近计时器的最后2个) |
|||
final isNearTimer = index >= leftBars - purpleBarsPerSide; |
|||
final isPurple = _isRecording && isNearTimer; |
|||
|
|||
if (!_isRecording) { |
|||
return Container( |
|||
width: 2.5.w, |
|||
height: 6.h, |
|||
margin: EdgeInsets.symmetric(horizontal: 0.8.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey.withOpacity(0.3), |
|||
borderRadius: BorderRadius.circular(1.25.w), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 录音时的动态效果 |
|||
final random = (index * 7 + _seconds * 3) % 10; |
|||
final isActive = isPurple || random > 5; |
|||
final baseHeight = isActive |
|||
? (isPurple ? 12 + random : 8 + random) |
|||
: 6; |
|||
final height = (baseHeight.clamp(6, 20)).h; |
|||
final color = isPurple |
|||
? const Color(0xFF8359FF) |
|||
: isActive |
|||
? Colors.grey.withOpacity(0.5) |
|||
: Colors.grey.withOpacity(0.3); |
|||
|
|||
return AnimatedContainer( |
|||
duration: const Duration(milliseconds: 100), |
|||
curve: Curves.easeOut, |
|||
width: 2.5.w, |
|||
height: height, |
|||
margin: EdgeInsets.symmetric(horizontal: 0.8.w), |
|||
decoration: BoxDecoration( |
|||
color: color, |
|||
borderRadius: BorderRadius.circular(1.25.w), |
|||
), |
|||
); |
|||
}), |
|||
// 计时器(嵌入在波形中间) |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|||
child: Text( |
|||
_isRecording ? _formatTime(_seconds) : '0:00', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: _isRecording ? Colors.black87 : Colors.grey, |
|||
), |
|||
), |
|||
), |
|||
// 右侧波形 |
|||
...List.generate(rightBars, (index) { |
|||
// 计算是否在紫色区域(靠近计时器的前2个) |
|||
final isNearTimer = index < purpleBarsPerSide; |
|||
final isPurple = _isRecording && isNearTimer; |
|||
|
|||
if (!_isRecording) { |
|||
return Container( |
|||
width: 2.5.w, |
|||
height: 6.h, |
|||
margin: EdgeInsets.symmetric(horizontal: 0.8.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey.withOpacity(0.3), |
|||
borderRadius: BorderRadius.circular(1.25.w), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 录音时的动态效果 |
|||
final random = (index * 7 + _seconds * 3) % 10; |
|||
final isActive = isPurple || random > 5; |
|||
final baseHeight = isActive |
|||
? (isPurple ? 12 + random : 8 + random) |
|||
: 6; |
|||
final height = (baseHeight.clamp(6, 20)).h; |
|||
final color = isPurple |
|||
? const Color(0xFF8359FF) |
|||
: isActive |
|||
? Colors.grey.withOpacity(0.5) |
|||
: Colors.grey.withOpacity(0.3); |
|||
|
|||
return AnimatedContainer( |
|||
duration: const Duration(milliseconds: 100), |
|||
curve: Curves.easeOut, |
|||
width: 2.5.w, |
|||
height: height, |
|||
margin: EdgeInsets.symmetric(horizontal: 0.8.w), |
|||
decoration: BoxDecoration( |
|||
color: color, |
|||
borderRadius: BorderRadius.circular(1.25.w), |
|||
), |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return AnimatedContainer( |
|||
duration: const Duration(milliseconds: 300), |
|||
height: widget.isVisible ? 180.h : 0, |
|||
color: Colors.white, |
|||
child: widget.isVisible |
|||
? Container( |
|||
width: 1.sw, |
|||
color: Colors.white, |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 顶部:音频波形(计时器嵌入在中间)- 只在录音且未取消时显示 |
|||
if (_isRecording && !_isCanceling) |
|||
Positioned( |
|||
top: 0, |
|||
left: -16.w, // 抵消容器的 horizontal padding |
|||
right: -16.w, // 抵消容器的 horizontal padding |
|||
child: _buildWaveformWithTimer(), |
|||
), |
|||
// 取消按钮(上滑时显示在麦克风按钮上方居中) |
|||
if (_isCanceling) |
|||
Positioned( |
|||
top: 0, |
|||
left: 0, |
|||
right: 0, |
|||
child: Center( |
|||
child: Container( |
|||
width: 38.w, |
|||
height: 38.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.red.shade400, |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.red.withOpacity(0.3), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 5), |
|||
), |
|||
], |
|||
), |
|||
child: Center( |
|||
child: Icon( |
|||
Icons.close_rounded, |
|||
color: Colors.white, |
|||
size: 20.w, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
// 中间:麦克风按钮和提示文字,固定在中间位置 |
|||
Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
SizedBox(height: 16.h), |
|||
// 麦克风按钮 |
|||
Listener( |
|||
onPointerDown: (details) { |
|||
// 记录初始位置,用于后续拖拽检测 |
|||
_panStartPosition = details.position; |
|||
}, |
|||
onPointerMove: (details) { |
|||
// 在录音状态下检测拖拽 |
|||
if (_isRecording) { |
|||
// 如果还没有初始位置,使用当前位置作为初始位置 |
|||
if (_panStartPosition == Offset.zero) { |
|||
_panStartPosition = details.position; |
|||
return; |
|||
} |
|||
|
|||
final deltaY = |
|||
_panStartPosition.dy - details.position.dy; |
|||
// 如果向上滑动超过一定距离(60像素),标记为取消 |
|||
final shouldCancel = deltaY > 60; |
|||
|
|||
if (_isCanceling != shouldCancel) { |
|||
setState(() { |
|||
_isCanceling = shouldCancel; |
|||
}); |
|||
} |
|||
} |
|||
}, |
|||
onPointerUp: (_) { |
|||
if (_isRecording) { |
|||
_onLongPressEnd(); |
|||
} |
|||
}, |
|||
child: GestureDetector( |
|||
onLongPressStart: _onLongPressStart, |
|||
onLongPressEnd: (_) => _onLongPressEnd(), |
|||
behavior: HitTestBehavior.opaque, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 外圈边框(长按时显示,取消时隐藏) |
|||
if (_isRecording && !_isCanceling) |
|||
Container( |
|||
width: 80.w, |
|||
height: 80.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Color(0xFF8359FF).withOpacity(0.3), |
|||
), |
|||
), |
|||
// 主按钮 |
|||
Container( |
|||
width: _isRecording ? 70.w : 80.w, |
|||
height: _isRecording ? 70.w : 80.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
gradient: _isCanceling |
|||
? LinearGradient( |
|||
colors: [ |
|||
Colors.grey.shade300, |
|||
Colors.grey.shade400, |
|||
], |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
) |
|||
: LinearGradient( |
|||
colors: const [ |
|||
Color(0xFF8359FF), |
|||
Color(0xFF3D8AE0), |
|||
], |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: _isCanceling |
|||
? Colors.grey.withOpacity(0.3) |
|||
: const Color( |
|||
0xFF8359FF, |
|||
).withOpacity(0.3), |
|||
blurRadius: 20, |
|||
offset: const Offset(0, 10), |
|||
), |
|||
], |
|||
), |
|||
child: Center( |
|||
child: Icon( |
|||
Icons.mic_none_rounded, |
|||
color: Colors.white, |
|||
size: 50.w, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
SizedBox(height: 12.h), |
|||
// 提示文字 |
|||
Text( |
|||
_isCanceling |
|||
? '松开取消' |
|||
: _isRecording |
|||
? '松开发送,上滑取消' |
|||
: '按住说话', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: _isCanceling |
|||
? Colors.red |
|||
: Colors.black.withOpacity(0.6), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
) |
|||
: null, |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,165 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:im_flutter_sdk/im_flutter_sdk.dart'; |
|||
|
|||
import '../../../generated/assets.dart'; |
|||
|
|||
class VoiceItem extends StatelessWidget { |
|||
final EMVoiceMessageBody voiceBody; |
|||
final bool isSentByMe; |
|||
final bool showTime; |
|||
final String formattedTime; |
|||
|
|||
const VoiceItem({ |
|||
required this.voiceBody, |
|||
required this.isSentByMe, |
|||
required this.showTime, |
|||
required this.formattedTime, |
|||
super.key, |
|||
}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
// 获取语音时长(秒) |
|||
final duration = voiceBody.duration; |
|||
final durationText = '${duration}s'; |
|||
|
|||
return Column( |
|||
children: [ |
|||
// 显示时间 |
|||
if (showTime) _buildTimeLabel(), |
|||
Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
child: Row( |
|||
mainAxisAlignment: isSentByMe |
|||
? MainAxisAlignment.end |
|||
: MainAxisAlignment.start, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
if (!isSentByMe) _buildAvatar(), |
|||
if (!isSentByMe) SizedBox(width: 8.w), |
|||
Container( |
|||
margin: EdgeInsets.only(top: 10.h), |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), |
|||
decoration: BoxDecoration( |
|||
color: isSentByMe ? Color(0xff8E7BF6) : Colors.white, |
|||
borderRadius: BorderRadius.only( |
|||
topLeft: isSentByMe |
|||
? Radius.circular(12.w) |
|||
: Radius.circular(0), |
|||
topRight: isSentByMe |
|||
? Radius.circular(0) |
|||
: Radius.circular(12.w), |
|||
bottomLeft: Radius.circular(12.w), |
|||
bottomRight: Radius.circular(12.w), |
|||
), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
// 播放按钮 |
|||
GestureDetector( |
|||
onTap: () { |
|||
// TODO: 处理播放/暂停逻辑 |
|||
}, |
|||
child: Container( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: isSentByMe ? Colors.white : Colors.black, |
|||
), |
|||
child: Icon( |
|||
Icons.play_arrow, |
|||
color: isSentByMe ? Color(0xff8E7BF6) : Colors.white, |
|||
size: 16.w, |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
// 时长文本 |
|||
Text( |
|||
durationText, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isSentByMe ? Colors.white : Colors.black, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
// 音频波形 |
|||
_buildWaveform(), |
|||
], |
|||
), |
|||
), |
|||
if (isSentByMe) SizedBox(width: 8.w), |
|||
if (isSentByMe) _buildAvatar(), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 构建时间标签 |
|||
Widget _buildTimeLabel() { |
|||
return Container( |
|||
alignment: Alignment.center, |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|||
child: Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w), |
|||
child: Text( |
|||
formattedTime, |
|||
style: TextStyle(fontSize: 12.sp, color: Colors.grey), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建头像 |
|||
Widget _buildAvatar() { |
|||
return Container( |
|||
width: 40.w, |
|||
height: 40.w, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(20.w), |
|||
image: DecorationImage( |
|||
image: AssetImage(Assets.imagesAvatarsExample), |
|||
fit: BoxFit.cover, |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建音频波形 |
|||
Widget _buildWaveform() { |
|||
// 根据时长生成波形条数量(最多20个) |
|||
final barCount = (voiceBody.duration / 2).ceil().clamp(5, 20); |
|||
|
|||
return SizedBox( |
|||
height: 16.h, |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
crossAxisAlignment: CrossAxisAlignment.center, |
|||
children: List.generate(barCount, (index) { |
|||
// 模拟波形高度变化 |
|||
final random = (index * 7) % 5; |
|||
final baseHeight = 6 + random * 2; |
|||
final height = (baseHeight.clamp(4, 16)).h; |
|||
|
|||
return Container( |
|||
width: 2.w, |
|||
height: height, |
|||
margin: EdgeInsets.symmetric(horizontal: 1.w), |
|||
decoration: BoxDecoration( |
|||
color: isSentByMe |
|||
? Colors.white.withOpacity(0.8) |
|||
: Colors.grey.withOpacity(0.6), |
|||
borderRadius: BorderRadius.circular(1.w), |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save