feat(home): 实现按住说话功能
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -40,6 +40,7 @@ const _attachmentPreviewRadius = 10.0;
|
|||||||
const _attachmentPreviewGap = 8.0;
|
const _attachmentPreviewGap = 8.0;
|
||||||
const _inputActionButtonKey = ValueKey('home_input_action_button');
|
const _inputActionButtonKey = ValueKey('home_input_action_button');
|
||||||
const _inputActionIconKey = ValueKey('home_input_action_icon');
|
const _inputActionIconKey = ValueKey('home_input_action_icon');
|
||||||
|
const _holdToSpeakKey = ValueKey('home_hold_to_speak_button');
|
||||||
|
|
||||||
/// 颜色常量
|
/// 颜色常量
|
||||||
const _chatBgColor = AppColors.slate50;
|
const _chatBgColor = AppColors.slate50;
|
||||||
@@ -76,6 +77,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
late final Future<void> Function(String transcript) _autoSendTranscript;
|
late final Future<void> Function(String transcript) _autoSendTranscript;
|
||||||
late final AnimationController _listeningAnimationController;
|
late final AnimationController _listeningAnimationController;
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
|
bool _isHoldToSpeakMode = false;
|
||||||
bool _isTranscribing = false;
|
bool _isTranscribing = false;
|
||||||
int _unreadCount = 0;
|
int _unreadCount = 0;
|
||||||
final List<XFile> _selectedImages = [];
|
final List<XFile> _selectedImages = [];
|
||||||
@@ -86,7 +88,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_messageController.addListener(_onMessageChanged);
|
_messageController.addListener(_onMessageChanged);
|
||||||
_chatBloc = widget.chatBloc ?? ChatBloc();
|
_chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl());
|
||||||
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
|
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
|
||||||
_inboxApi = sl<InboxApi>();
|
_inboxApi = sl<InboxApi>();
|
||||||
_transcribeAudio =
|
_transcribeAudio =
|
||||||
@@ -710,113 +712,235 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInputContainer(BuildContext context, ChatState state) {
|
Widget _buildInputContainer(BuildContext context, ChatState state) {
|
||||||
final isWaitingAgent =
|
|
||||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(_inputPadding),
|
padding: const EdgeInsets.all(_inputPadding),
|
||||||
color: _chatBgColor,
|
color: _chatBgColor,
|
||||||
child: Row(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: _isRecording
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
? _stopRecording
|
children: [
|
||||||
: () => _showBottomSheet(context),
|
GestureDetector(
|
||||||
child: Container(
|
onTap: _isRecording
|
||||||
width: 36,
|
? _stopRecording
|
||||||
height: 36,
|
: () => _showBottomSheet(context),
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: AppColors.white,
|
width: 36,
|
||||||
shape: BoxShape.circle,
|
height: 36,
|
||||||
border: Border.all(color: AppColors.slate300),
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.white,
|
||||||
child: Icon(
|
shape: BoxShape.circle,
|
||||||
_isRecording ? LucideIcons.square : LucideIcons.plus,
|
border: Border.all(color: AppColors.slate300),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
child: Icon(
|
||||||
GestureDetector(
|
_isRecording ? LucideIcons.square : LucideIcons.plus,
|
||||||
key: _inputActionButtonKey,
|
size: 20,
|
||||||
onTap: _isTranscribing
|
color: _isRecording ? AppColors.red600 : AppColors.slate500,
|
||||||
? 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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
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 {
|
Future<void> _sendMessage(BuildContext context) async {
|
||||||
final content = _messageController.text.trim();
|
final content = _messageController.text.trim();
|
||||||
if (content.isEmpty && _selectedImages.isEmpty) return;
|
if (content.isEmpty && _selectedImages.isEmpty) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user