diff --git a/it0_app/lib/features/chat/presentation/widgets/voice_mic_button.dart b/it0_app/lib/features/chat/presentation/widgets/voice_mic_button.dart index ad07429..862ff16 100644 --- a/it0_app/lib/features/chat/presentation/widgets/voice_mic_button.dart +++ b/it0_app/lib/features/chat/presentation/widgets/voice_mic_button.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; @@ -6,11 +7,12 @@ import '../../../../core/theme/app_colors.dart'; /// WhatsApp-style press-and-hold mic button. /// -/// • Press and hold → records audio to a temp file. +/// • Press and hold (≥500 ms) → records audio to a temp file. /// • Release → stops recording and calls [onAudioReady] with the file path. /// • Slide up while holding → cancels recording without sending. /// -/// Requires microphone permission (handled by the `record` package). +/// Uses [Listener] (raw pointer events) instead of [GestureDetector] so that +/// the inner [IconButton] tooltip/InkWell never steals the gesture. class VoiceMicButton extends StatefulWidget { /// Called with the temp file path when the user releases the button. final void Function(String audioPath) onAudioReady; @@ -33,6 +35,8 @@ class _VoiceMicButtonState extends State final _recorder = AudioRecorder(); bool _isRecording = false; bool _cancelled = false; + bool _pointerDown = false; + Timer? _longPressTimer; // Slide-up cancel threshold (pixels above press origin) static const double _cancelThreshold = 60.0; @@ -55,12 +59,14 @@ class _VoiceMicButtonState extends State @override void dispose() { + _longPressTimer?.cancel(); _recorder.dispose(); _pulseController.dispose(); super.dispose(); } Future _startRecording() async { + if (!mounted) return; final hasPermission = await _recorder.hasPermission(); if (!hasPermission) { if (mounted) { @@ -72,18 +78,21 @@ class _VoiceMicButtonState extends State } final dir = await getTemporaryDirectory(); - final path = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; + final path = + '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; await _recorder.start( const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), path: path, ); - setState(() { - _isRecording = true; - _cancelled = false; - }); - _pulseController.repeat(reverse: true); + if (mounted) { + setState(() { + _isRecording = true; + _cancelled = false; + }); + _pulseController.repeat(reverse: true); + } } Future _stopRecording({required bool cancel}) async { @@ -93,11 +102,11 @@ class _VoiceMicButtonState extends State _pulseController.reset(); final path = await _recorder.stop(); - setState(() => _isRecording = false); + if (mounted) setState(() => _isRecording = false); if (cancel || path == null) return; - // Ignore empty recordings (< ~0.3s) + // Ignore empty recordings (< ~0.3 s) try { final size = await File(path).length(); if (size < 2048) return; @@ -108,79 +117,84 @@ class _VoiceMicButtonState extends State widget.onAudioReady(path); } - void _onLongPressStart(LongPressStartDetails details) { + void _onPointerDown(PointerDownEvent event) { if (widget.disabled) return; - _pressOrigin = details.globalPosition; - _startRecording(); + _pressOrigin = event.position; + _pointerDown = true; + _cancelled = false; + _longPressTimer?.cancel(); + // Start recording after 500 ms hold + _longPressTimer = Timer(const Duration(milliseconds: 500), () { + if (_pointerDown && mounted) _startRecording(); + }); } - void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + void _onPointerMove(PointerMoveEvent event) { if (_pressOrigin == null || !_isRecording) return; - final dy = _pressOrigin!.dy - details.globalPosition.dy; + final dy = _pressOrigin!.dy - event.position.dy; setState(() => _cancelled = dy > _cancelThreshold); } - void _onLongPressEnd(LongPressEndDetails details) { + void _onPointerUp(PointerUpEvent event) { + _pointerDown = false; + _longPressTimer?.cancel(); _stopRecording(cancel: _cancelled); _pressOrigin = null; } - void _onLongPressCancel() { + void _onPointerCancel(PointerCancelEvent event) { + _pointerDown = false; + _longPressTimer?.cancel(); _stopRecording(cancel: true); _pressOrigin = null; } @override Widget build(BuildContext context) { - if (_isRecording) { - return _buildRecordingButton(); - } - return GestureDetector( - onLongPressStart: _onLongPressStart, - onLongPressMoveUpdate: _onLongPressMoveUpdate, - onLongPressEnd: _onLongPressEnd, - onLongPressCancel: _onLongPressCancel, - child: IconButton( - icon: Icon( - Icons.mic_none, - size: 22, - color: widget.disabled ? AppColors.textMuted : null, - ), - tooltip: '按住录音', - onPressed: widget.disabled ? null : () {}, - ), + return Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, + child: _isRecording ? _buildRecordingWidget() : _buildIdleWidget(), ); } - Widget _buildRecordingButton() { - final isCancelling = _cancelled; - return GestureDetector( - onLongPressMoveUpdate: _onLongPressMoveUpdate, - onLongPressEnd: _onLongPressEnd, - onLongPressCancel: _onLongPressCancel, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ScaleTransition( - scale: _pulseAnimation, - child: Icon( - Icons.mic, - size: 22, - color: isCancelling ? AppColors.textMuted : AppColors.error, - ), + Widget _buildIdleWidget() { + return IconButton( + icon: Icon( + Icons.mic_none, + size: 22, + color: widget.disabled ? AppColors.textMuted : null, + ), + tooltip: '按住录音', + onPressed: widget.disabled ? null : () {}, + ); + } + + Widget _buildRecordingWidget() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ScaleTransition( + scale: _pulseAnimation, + child: Icon( + Icons.mic, + size: 22, + color: _cancelled ? AppColors.textMuted : AppColors.error, ), - const SizedBox(width: 4), - Text( - isCancelling ? '松开取消' : '松开发送', - style: TextStyle( - fontSize: 12, - color: isCancelling ? AppColors.textMuted : AppColors.error, - ), + ), + const SizedBox(width: 4), + Text( + _cancelled ? '松开取消' : '松开发送', + style: TextStyle( + fontSize: 12, + color: _cancelled ? AppColors.textMuted : AppColors.error, ), - ], - ), + ), + ], ), ); }