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.
456 lines
16 KiB
456 lines
16 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;
|
|
|
|
@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<void> _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<void> _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,
|
|
);
|
|
}
|
|
}
|