diff --git a/lib/widget/message/voice_input_view.dart b/lib/widget/message/voice_input_view.dart new file mode 100644 index 0000000..c3d4ee1 --- /dev/null +++ b/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 createState() => _VoiceInputViewState(); +} + +class _VoiceInputViewState extends State { + 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 _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 _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, + ); + } +} diff --git a/lib/widget/message/voice_item.dart b/lib/widget/message/voice_item.dart new file mode 100644 index 0000000..c9f6e3c --- /dev/null +++ b/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), + ), + ); + }), + ), + ); + } +}