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 '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,19 +78,22 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRecording = true;
|
_isRecording = true;
|
||||||
_cancelled = false;
|
_cancelled = false;
|
||||||
});
|
});
|
||||||
_pulseController.repeat(reverse: true);
|
_pulseController.repeat(reverse: true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _stopRecording({required bool cancel}) async {
|
Future<void> _stopRecording({required bool cancel}) async {
|
||||||
if (!_isRecording) return;
|
if (!_isRecording) return;
|
||||||
|
|
@ -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,39 +117,51 @@ 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,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerCancel,
|
||||||
|
child: _isRecording ? _buildRecordingWidget() : _buildIdleWidget(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return GestureDetector(
|
|
||||||
onLongPressStart: _onLongPressStart,
|
Widget _buildIdleWidget() {
|
||||||
onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
return IconButton(
|
||||||
onLongPressEnd: _onLongPressEnd,
|
|
||||||
onLongPressCancel: _onLongPressCancel,
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.mic_none,
|
Icons.mic_none,
|
||||||
size: 22,
|
size: 22,
|
||||||
|
|
@ -148,17 +169,11 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
|
||||||
),
|
),
|
||||||
tooltip: '按住录音',
|
tooltip: '按住录音',
|
||||||
onPressed: widget.disabled ? null : () {},
|
onPressed: widget.disabled ? null : () {},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRecordingButton() {
|
Widget _buildRecordingWidget() {
|
||||||
final isCancelling = _cancelled;
|
return Padding(
|
||||||
return GestureDetector(
|
|
||||||
onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
|
||||||
onLongPressEnd: _onLongPressEnd,
|
|
||||||
onLongPressCancel: _onLongPressCancel,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -168,20 +183,19 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.mic,
|
Icons.mic,
|
||||||
size: 22,
|
size: 22,
|
||||||
color: isCancelling ? AppColors.textMuted : AppColors.error,
|
color: _cancelled ? AppColors.textMuted : AppColors.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
isCancelling ? '松开取消' : '松开发送',
|
_cancelled ? '松开取消' : '松开发送',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isCancelling ? AppColors.textMuted : AppColors.error,
|
color: _cancelled ? AppColors.textMuted : AppColors.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue