Browse Source

feat(message): 添加语音输入和展示功能

- 新增语音输入视图,支持长按录音、上滑取消
- 实现录音权限申请与音频录制功能
- 添加录音时的动态波形可视化效果
- 创建语音消息展示组件,支持播放按钮和时长显示
- 实现语音消息的波形图形渲染
- 支持发送方与接收方消息样式的区分- 添加消息时间戳显示功能
ios
Jolie 4 months ago
parent
commit
5576c35810
2 changed files with 593 additions and 0 deletions
  1. 428
      lib/widget/message/voice_input_view.dart
  2. 165
      lib/widget/message/voice_item.dart

428
lib/widget/message/voice_input_view.dart

@ -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,
);
}
}

165
lib/widget/message/voice_item.dart

@ -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),
),
);
}),
),
);
}
}
Loading…
Cancel
Save