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:
parent
73eb4350fb
commit
72584182df
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue