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(); _timer = null; // 如果正在录音,停止录音 if (_isRecording) { _audioRecorder.stop().catchError((e) { // 忽略停止录音时的错误 print('停止录音时出错(可忽略): $e'); return null; // 返回 null 以符合 catchError 的返回类型要求 }); } // 释放录音器 _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(); // print(status); // 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); // 检查 mounted 状态再更新 UI if (!mounted) return; setState(() { _isRecording = true; _seconds = 0; _recordingPath = path; _isCanceling = false; _panStartPosition = Offset.zero; }); // 启动计时器 _timer = Timer.periodic(const Duration(seconds: 1), (timer) { // 检查 mounted 状态,避免在 dispose 后调用 setState if (!mounted) { timer.cancel(); return; } 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(); // 检查 mounted 状态再更新 UI if (mounted) { setState(() { _isRecording = false; _seconds = 0; _isCanceling = false; _panStartPosition = Offset.zero; _recordingPath = null; }); } // 如果不是取消,回传音频文件地址和秒数 if (!cancel && finalPath != null && finalSeconds > 0) { // 延迟一下,确保状态更新完成后再回调 Future.microtask(() { // 检查 mounted 状态,避免在 dispose 后回调 if (mounted) { widget.onVoiceRecorded?.call(finalPath, finalSeconds); } }); } } catch (e) { print('停止录音失败: $e'); } } } // 处理长按开始 void _onLongPressStart(LongPressStartDetails details) { print(11111); _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) { print(123123); // 记录初始位置,用于后续拖拽检测 _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 && mounted) { 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, ); } }