You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

467 lines
17 KiB

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;
// 最大录音时长(秒)
static const int maxRecordingDuration = 60;
@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) {
// 限制最大显示为60秒
final displaySeconds = seconds.clamp(0, maxRecordingDuration);
final minutes = displaySeconds ~/ 60;
final secs = displaySeconds % 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);
// 检查 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;
}
// 检查是否达到最大录音时长
if (_seconds >= maxRecordingDuration) {
// 达到60秒,自动停止录音并发送
timer.cancel();
_stopRecording(cancel: false);
return;
}
setState(() {
_seconds++;
});
});
}
} catch (e) {
print('开始录音失败: $e');
}
}
// 停止录音
Future<void> _stopRecording({bool cancel = false}) async {
_timer?.cancel();
_timer = null;
if (_isRecording) {
try {
// 在重置状态前保存录音信息,限制最大时长为60秒
final finalSeconds = _seconds.clamp(0, maxRecordingDuration);
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) {
_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 && 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,
);
}
}