Browse Source
feat(message): 添加语音输入和展示功能
feat(message): 添加语音输入和展示功能
- 新增语音输入视图,支持长按录音、上滑取消 - 实现录音权限申请与音频录制功能 - 添加录音时的动态波形可视化效果 - 创建语音消息展示组件,支持播放按钮和时长显示 - 实现语音消息的波形图形渲染 - 支持发送方与接收方消息样式的区分- 添加消息时间戳显示功能ios
2 changed files with 593 additions and 0 deletions
Unified 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