fix(chat): fix VoiceMicButton gesture conflict with IconButton tooltip

GestureDetector was fighting with IconButton's inner Tooltip gesture
recognizer — onLongPressStart was never called (only vibration from
tooltip). Replaced with Listener (raw pointer events) + manual 500ms
Timer, which bypasses the gesture arena entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-06 07:47:48 -08:00
parent 73eb4350fb
commit 72584182df
1 changed files with 75 additions and 61 deletions

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.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. /// 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. /// Release stops recording and calls [onAudioReady] with the file path.
/// Slide up while holding cancels recording without sending. /// 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 { class VoiceMicButton extends StatefulWidget {
/// Called with the temp file path when the user releases the button. /// Called with the temp file path when the user releases the button.
final void Function(String audioPath) onAudioReady; final void Function(String audioPath) onAudioReady;
@ -33,6 +35,8 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
final _recorder = AudioRecorder(); final _recorder = AudioRecorder();
bool _isRecording = false; bool _isRecording = false;
bool _cancelled = false; bool _cancelled = false;
bool _pointerDown = false;
Timer? _longPressTimer;
// Slide-up cancel threshold (pixels above press origin) // Slide-up cancel threshold (pixels above press origin)
static const double _cancelThreshold = 60.0; static const double _cancelThreshold = 60.0;
@ -55,12 +59,14 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
@override @override
void dispose() { void dispose() {
_longPressTimer?.cancel();
_recorder.dispose(); _recorder.dispose();
_pulseController.dispose(); _pulseController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _startRecording() async { Future<void> _startRecording() async {
if (!mounted) return;
final hasPermission = await _recorder.hasPermission(); final hasPermission = await _recorder.hasPermission();
if (!hasPermission) { if (!hasPermission) {
if (mounted) { if (mounted) {
@ -72,18 +78,21 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
} }
final dir = await getTemporaryDirectory(); 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( await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path, path: path,
); );
setState(() { if (mounted) {
_isRecording = true; setState(() {
_cancelled = false; _isRecording = true;
}); _cancelled = false;
_pulseController.repeat(reverse: true); });
_pulseController.repeat(reverse: true);
}
} }
Future<void> _stopRecording({required bool cancel}) async { Future<void> _stopRecording({required bool cancel}) async {
@ -93,11 +102,11 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
_pulseController.reset(); _pulseController.reset();
final path = await _recorder.stop(); final path = await _recorder.stop();
setState(() => _isRecording = false); if (mounted) setState(() => _isRecording = false);
if (cancel || path == null) return; if (cancel || path == null) return;
// Ignore empty recordings (< ~0.3s) // Ignore empty recordings (< ~0.3 s)
try { try {
final size = await File(path).length(); final size = await File(path).length();
if (size < 2048) return; if (size < 2048) return;
@ -108,79 +117,84 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
widget.onAudioReady(path); widget.onAudioReady(path);
} }
void _onLongPressStart(LongPressStartDetails details) { void _onPointerDown(PointerDownEvent event) {
if (widget.disabled) return; if (widget.disabled) return;
_pressOrigin = details.globalPosition; _pressOrigin = event.position;
_startRecording(); _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; if (_pressOrigin == null || !_isRecording) return;
final dy = _pressOrigin!.dy - details.globalPosition.dy; final dy = _pressOrigin!.dy - event.position.dy;
setState(() => _cancelled = dy > _cancelThreshold); setState(() => _cancelled = dy > _cancelThreshold);
} }
void _onLongPressEnd(LongPressEndDetails details) { void _onPointerUp(PointerUpEvent event) {
_pointerDown = false;
_longPressTimer?.cancel();
_stopRecording(cancel: _cancelled); _stopRecording(cancel: _cancelled);
_pressOrigin = null; _pressOrigin = null;
} }
void _onLongPressCancel() { void _onPointerCancel(PointerCancelEvent event) {
_pointerDown = false;
_longPressTimer?.cancel();
_stopRecording(cancel: true); _stopRecording(cancel: true);
_pressOrigin = null; _pressOrigin = null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isRecording) { return Listener(
return _buildRecordingButton(); onPointerDown: _onPointerDown,
} onPointerMove: _onPointerMove,
return GestureDetector( onPointerUp: _onPointerUp,
onLongPressStart: _onLongPressStart, onPointerCancel: _onPointerCancel,
onLongPressMoveUpdate: _onLongPressMoveUpdate, child: _isRecording ? _buildRecordingWidget() : _buildIdleWidget(),
onLongPressEnd: _onLongPressEnd,
onLongPressCancel: _onLongPressCancel,
child: IconButton(
icon: Icon(
Icons.mic_none,
size: 22,
color: widget.disabled ? AppColors.textMuted : null,
),
tooltip: '按住录音',
onPressed: widget.disabled ? null : () {},
),
); );
} }
Widget _buildRecordingButton() { Widget _buildIdleWidget() {
final isCancelling = _cancelled; return IconButton(
return GestureDetector( icon: Icon(
onLongPressMoveUpdate: _onLongPressMoveUpdate, Icons.mic_none,
onLongPressEnd: _onLongPressEnd, size: 22,
onLongPressCancel: _onLongPressCancel, color: widget.disabled ? AppColors.textMuted : null,
child: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), tooltip: '按住录音',
child: Row( onPressed: widget.disabled ? null : () {},
mainAxisSize: MainAxisSize.min, );
children: [ }
ScaleTransition(
scale: _pulseAnimation, Widget _buildRecordingWidget() {
child: Icon( return Padding(
Icons.mic, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
size: 22, child: Row(
color: isCancelling ? AppColors.textMuted : AppColors.error, mainAxisSize: MainAxisSize.min,
), children: [
ScaleTransition(
scale: _pulseAnimation,
child: Icon(
Icons.mic,
size: 22,
color: _cancelled ? AppColors.textMuted : AppColors.error,
), ),
const SizedBox(width: 4), ),
Text( const SizedBox(width: 4),
isCancelling ? '松开取消' : '松开发送', Text(
style: TextStyle( _cancelled ? '松开取消' : '松开发送',
fontSize: 12, style: TextStyle(
color: isCancelling ? AppColors.textMuted : AppColors.error, fontSize: 12,
), color: _cancelled ? AppColors.textMuted : AppColors.error,
), ),
], ),
), ],
), ),
); );
} }