feat(home): 实现按住说话功能

This commit is contained in:
qzl
2026-03-12 13:51:20 +08:00
parent 7b8865e256
commit 231610d762
@@ -1,7 +1,7 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
@@ -40,6 +40,7 @@ const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _inputActionButtonKey = ValueKey('home_input_action_button');
const _inputActionIconKey = ValueKey('home_input_action_icon');
const _holdToSpeakKey = ValueKey('home_hold_to_speak_button');
/// 颜色常量
const _chatBgColor = AppColors.slate50;
@@ -76,6 +77,7 @@ class _HomeScreenState extends State<HomeScreen>
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isHoldToSpeakMode = false;
bool _isTranscribing = false;
int _unreadCount = 0;
final List<XFile> _selectedImages = [];
@@ -86,7 +88,7 @@ class _HomeScreenState extends State<HomeScreen>
void initState() {
super.initState();
_messageController.addListener(_onMessageChanged);
_chatBloc = widget.chatBloc ?? ChatBloc();
_chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl());
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_inboxApi = sl<InboxApi>();
_transcribeAudio =
@@ -710,113 +712,235 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildInputContainer(BuildContext context, ChatState state) {
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return Container(
padding: const EdgeInsets.all(_inputPadding),
color: _chatBgColor,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _isRecording
? _stopRecording
: () => _showBottomSheet(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.white,
shape: BoxShape.circle,
border: Border.all(color: AppColors.slate300),
),
child: Icon(
_isRecording ? LucideIcons.square : LucideIcons.plus,
size: 20,
color: _isRecording ? AppColors.red600 : AppColors.slate500,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
constraints: const BoxConstraints(minHeight: _inputMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_inputRadius),
border: Border.all(color: AppColors.slate300),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _isRecording
? _buildListeningIndicator()
: _isTranscribing
? _buildTranscribingIndicator()
: TextField(
controller: _messageController,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入消息...',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: _isRecording
? _stopRecording
: () => _showBottomSheet(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.white,
shape: BoxShape.circle,
border: Border.all(color: AppColors.slate300),
),
const SizedBox(width: 8),
GestureDetector(
key: _inputActionButtonKey,
onTap: _isTranscribing
? null
: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: true)
: isWaitingAgent
? () => _onStopGenerating(context)
: _hasMessage
? () => _sendMessage(context)
: _startRecording,
child: _isTranscribing
? const SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
)
: Icon(
key: _inputActionIconKey,
isWaitingAgent
? LucideIcons.square
: _isRecording || _hasMessage
? LucideIcons.send
: LucideIcons.mic,
size: _iconSize,
color: isWaitingAgent || _isRecording || _hasMessage
? AppColors.blue600
: AppColors.slate500,
),
child: Icon(
_isRecording ? LucideIcons.square : LucideIcons.plus,
size: 20,
color: _isRecording ? AppColors.red600 : AppColors.slate500,
),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: _isHoldToSpeakMode
? _buildHoldToSpeakButton()
: _buildNormalInputField(state),
),
const SizedBox(width: 8),
_buildRightActionButton(state),
],
),
if (_isHoldToSpeakMode) ...[
const SizedBox(height: 8),
_buildHoldToSpeakHint(),
],
],
),
);
}
Widget _buildHoldToSpeakButton() {
return GestureDetector(
key: _holdToSpeakKey,
onLongPressStart: (_) => _onHoldToSpeakStart(),
onLongPressEnd: (_) => _onHoldToSpeakEnd(),
onLongPressMoveUpdate: (details) => _onHoldToSpeakMoveUpdate(details),
child: Container(
constraints: const BoxConstraints(minHeight: _inputMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_inputRadius),
border: Border.all(color: AppColors.slate300),
),
child: const Center(
child: Text(
'按住说话',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
),
),
);
}
Widget _buildNormalInputField(ChatState state) {
return Container(
constraints: const BoxConstraints(minHeight: _inputMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(_inputRadius),
border: Border.all(color: AppColors.slate300),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _isRecording
? _buildListeningIndicator()
: _isTranscribing
? _buildTranscribingIndicator()
: TextField(
controller: _messageController,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入消息...',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
),
const SizedBox(width: 8),
_buildInputActionIcon(state),
],
),
);
}
Widget _buildInputActionIcon(ChatState state) {
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return GestureDetector(
key: _inputActionButtonKey,
onTap: _isTranscribing
? null
: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: true)
: isWaitingAgent
? () => _onStopGenerating(context)
: _hasMessage
? () => _sendMessage(context)
: _startRecording,
child: _isTranscribing
? const SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
)
: Icon(
key: _inputActionIconKey,
isWaitingAgent
? LucideIcons.square
: _isRecording || _hasMessage
? LucideIcons.send
: LucideIcons.mic,
size: _iconSize,
color: isWaitingAgent || _isRecording || _hasMessage
? AppColors.blue600
: AppColors.slate500,
),
);
}
Widget _buildRightActionButton(ChatState state) {
return GestureDetector(
key: _inputActionButtonKey,
onTap: _isTranscribing
? null
: _isHoldToSpeakMode
? _toggleHoldToSpeakMode
: _startRecording,
child: _isTranscribing
? const SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
)
: Icon(
key: _inputActionIconKey,
_isHoldToSpeakMode ? LucideIcons.keyboard : LucideIcons.activity,
size: _iconSize,
color: AppColors.slate500,
),
);
}
Widget _buildHoldToSpeakHint() {
return Column(
children: [
if (_isRecording) _buildRecordingAnimation(),
const SizedBox(height: 4),
Text(
_isRecording ? '松开发送,上滑取消' : '按住说话',
style: const TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
);
}
Widget _buildRecordingAnimation() {
return _buildListeningIndicator();
}
void _toggleHoldToSpeakMode() {
setState(() {
_isHoldToSpeakMode = !_isHoldToSpeakMode;
});
}
void _onHoldToSpeakStart() {
HapticFeedback.lightImpact();
_startRecording();
}
void _onHoldToSpeakEnd() {
_stopRecording(autoSendAfterTranscribe: true);
}
void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) {
const cancelThreshold = -50.0;
if (details.offsetFromOrigin.dy < cancelThreshold) {
_cancelRecording();
}
}
Future<void> _cancelRecording() async {
try {
await _voiceRecorder.stop();
_listeningAnimationController.stop();
} catch (_) {}
if (!mounted) return;
setState(() {
_isRecording = false;
});
Toast.show(context, '已取消', type: ToastType.info);
}
Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim();
if (content.isEmpty && _selectedImages.isEmpty) return;