From 231610d762f55f094a8b21cd50cdd8037ac78efc Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 12 Mar 2026 13:51:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(home):=20=E5=AE=9E=E7=8E=B0=E6=8C=89?= =?UTF-8?q?=E4=BD=8F=E8=AF=B4=E8=AF=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/home/ui/screens/home_screen.dart | 316 ++++++++++++------ 1 file changed, 220 insertions(+), 96 deletions(-) diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 6479c5e..b36c753 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -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 late final Future Function(String transcript) _autoSendTranscript; late final AnimationController _listeningAnimationController; bool _isRecording = false; + bool _isHoldToSpeakMode = false; bool _isTranscribing = false; int _unreadCount = 0; final List _selectedImages = []; @@ -86,7 +88,7 @@ class _HomeScreenState extends State void initState() { super.initState(); _messageController.addListener(_onMessageChanged); - _chatBloc = widget.chatBloc ?? ChatBloc(); + _chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl()); _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _inboxApi = sl(); _transcribeAudio = @@ -710,113 +712,235 @@ class _HomeScreenState extends State } 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 _cancelRecording() async { + try { + await _voiceRecorder.stop(); + _listeningAnimationController.stop(); + } catch (_) {} + if (!mounted) return; + setState(() { + _isRecording = false; + }); + Toast.show(context, '已取消', type: ToastType.info); + } + Future _sendMessage(BuildContext context) async { final content = _messageController.text.trim(); if (content.isEmpty && _selectedImages.isEmpty) return;