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 '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<VoiceMicButton>
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<VoiceMicButton>
@override
void dispose() {
_longPressTimer?.cancel();
_recorder.dispose();
_pulseController.dispose();
super.dispose();
}
Future<void> _startRecording() async {
if (!mounted) return;
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) {
if (mounted) {
@ -72,18 +78,21 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
}
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<void> _stopRecording({required bool cancel}) async {
@ -93,11 +102,11 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
_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<VoiceMicButton>
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,
),
],
),
),
],
),
);
}