import 'dart:io'; import 'package:flutter/foundation.dart'; 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'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/api/api_exception.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../chat/data/models/chat_list_item.dart'; import '../../../chat/presentation/bloc/agent_stage.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/message_composer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'home_sheet.dart'; import '../widgets/home_attachment_strip.dart'; import '../widgets/home_background_field.dart'; import '../widgets/home_floating_header.dart'; /// 布局常量 const _defaultPadding = 20.0; const _itemSpacing = 16.0; const _iconSize = 24.0; const _messagePaddingH = 13.0; const _messagePaddingV = 9.0; const _cornerRadius = 12.0; const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg; const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl); const _scrollDurationMs = 300; const _rippleDurationMs = 1200; const _transcribingSpinnerSize = 18.0; const _transcribingStrokeWidth = 2.0; const _attachmentPreviewSize = 88.0; const _attachmentPreviewRadius = 10.0; const _attachmentPreviewGap = 8.0; const _bottomStackReservedHeight = 116.0; const _toolResultWidthFactor = 0.9; const _pullRefreshMinVisibleMs = 450; const _waitingIndicatorReservedHeight = 42.0; const homeConversationStageKey = ValueKey('home_conversation_stage'); const homeBottomInputStackKey = ValueKey('home_bottom_input_stack'); const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient'); /// 颜色常量 const _chatBgColor = AppColors.slate50; const _userBubbleColor = AppColors.blue50; /// 录制状态颜色 const _recordingCancelTopColor = AppColors.warningBackground; const _recordingCancelBottomColor = AppColors.red400; const _recordingCancelLabelColor = AppColors.red600; const _recordingActiveTopColor = AppColors.blue50; const _recordingActiveBottomColor = AppColors.blue400; const _recordingActiveLabelColor = AppColors.white; class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; final ChatBloc? chatBloc; final bool autoLoadHistory; final List initialSelectedImages; const HomeScreen({ super.key, this.voiceRecorder, this.onTranscribeAudio, this.chatBloc, this.autoLoadHistory = true, this.initialSelectedImages = const [], }); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final FocusNode _messageFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; late final VoiceRecorder _voiceRecorder; late final InboxApi _inboxApi; late final Future Function(String filePath) _transcribeAudio; late final AnimationController _listeningAnimationController; bool _isRecording = false; bool _isRecordingStarting = false; bool _isHoldToSpeakMode = true; bool _isTranscribing = false; bool _isCancelGestureActive = false; bool _shouldCancelWhenStartCompletes = false; bool _shouldStopWhenStartCompletes = false; bool _isSendingMessage = false; bool _isPullRefreshing = false; bool _isHistoryPaginationInFlight = false; int _unreadCount = 0; final List _selectedImages = []; int _lastObservedItemCount = 0; bool _lastObservedWaiting = false; double? _historyViewportPixels; double? _historyViewportMaxExtent; @override void initState() { super.initState(); final providedChatBloc = widget.chatBloc; if (providedChatBloc != null) { _chatBloc = providedChatBloc; } else { _chatBloc = context.read(); } _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _inboxApi = sl(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; _listeningAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); if (widget.autoLoadHistory) { _chatBloc.loadHistory(); } _lastObservedItemCount = _chatBloc.state.items.length; _lastObservedWaiting = _isAgentWaiting(_chatBloc.state); _loadUnreadCount(); } Future _loadUnreadCount() async { try { final messages = await _inboxApi.getMessages(isRead: false); if (mounted) { setState(() => _unreadCount = messages.length); } } catch (_) { // Ignore errors } } @override void dispose() { _messageController.dispose(); _messageFocusNode.dispose(); _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return BlocProvider.value( value: _chatBloc, child: BlocConsumer( listener: (context, state) { if (state.error != null) { Toast.show(context, state.error!, type: ToastType.error); } final isWaitingNow = _isAgentWaiting(state); final hasItemCountChanged = state.items.length != _lastObservedItemCount; final waitingStateChanged = isWaitingNow != _lastObservedWaiting; final shouldAutoScroll = !_isHistoryPaginationInFlight && !state.isLoadingHistory && (hasItemCountChanged || waitingStateChanged); if (shouldAutoScroll) { _scheduleAutoScroll(animated: hasItemCountChanged); } _lastObservedItemCount = state.items.length; _lastObservedWaiting = isWaitingNow; }, builder: (context, state) { return Scaffold( backgroundColor: _chatBgColor, body: SafeArea( child: Stack( children: [ const Positioned.fill(child: HomeBackgroundField()), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), ], ), _buildBottomInputStack(context, state), if (_isRecording) _buildRecordingGestureOverlay(), ], ), ), ); }, ), ); } Widget _buildHeader(BuildContext context) { return HomeFloatingHeader( unreadCount: _unreadCount, onTapSettings: () => context.push('/settings'), onTapCalendar: () => context.push('/calendar/dayweek?from=home'), onTapMessages: () => context.push('/messages/invites'), ); } Widget _buildChatArea(BuildContext context, ChatState state) { final showWaitingIndicator = _isAgentWaiting(state); if (state.isLoadingHistory && state.items.isEmpty) { return const Center( child: AppLoadingIndicator(variant: AppLoadingVariant.surface), ); } return Padding( padding: const EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, _bottomStackReservedHeight, ), child: KeyedSubtree( key: homeConversationStageKey, child: Stack( children: [ if (state.items.isEmpty) const Positioned.fill(child: _HomeEmptyStateAmbient()) else Positioned.fill( child: RefreshIndicator.noSpinner( onRefresh: () => _onRefresh(context), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only( top: AppSpacing.sm, bottom: showWaitingIndicator ? _waitingIndicatorReservedHeight : AppSpacing.none, ), itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), itemBuilder: (context, index) { if (index == 0 && state.hasEarlierHistory) { return _buildLoadMoreButton( context, state.isLoadingHistory, ); } final itemIndex = state.hasEarlierHistory ? index - 1 : index; final item = state.items[itemIndex]; final showDateDivider = itemIndex == 0 || !_isSameDay( state.items[itemIndex - 1].timestamp, item.timestamp, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showDateDivider) _buildDateDivider(item.timestamp), Padding( padding: const EdgeInsets.only( bottom: _itemSpacing, ), child: _buildChatItem(item), ), ], ); }, ), ), ), if (showWaitingIndicator) Align( alignment: Alignment.bottomLeft, child: _buildWaitingIndicator(currentStage: state.currentStage), ), Align( alignment: Alignment.topCenter, child: AppPullRefreshFeedback(visible: _isPullRefreshing), ), ], ), ), ); } Widget _buildWaitingIndicator({required AgentStage? currentStage}) { final label = stageLabel(currentStage); return Padding( padding: const EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, _defaultPadding, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: _transcribingSpinnerSize, height: _transcribingSpinnerSize, child: const AppLoadingIndicator( variant: AppLoadingVariant.inline, size: _transcribingSpinnerSize, strokeWidth: _transcribingStrokeWidth, color: AppColors.blue600, trackColor: AppColors.blue100, ), ), SizedBox(width: AppSpacing.sm), Text( label, style: TextStyle(fontSize: 14, color: AppColors.slate500), ), ], ), ); } bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } Widget _buildDateDivider(DateTime date) { final now = DateTime.now(); final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; final weekday = weekdays[date.weekday - 1]; // For all dates (today/yesterday/this year), use the same format // Only add year prefix for dates from previous years final label = date.year == now.year ? '${date.month}月${date.day}日 $weekday' : '${date.year}年${date.month}月${date.day}日 $weekday'; return Container( padding: const EdgeInsets.symmetric(vertical: 12), alignment: Alignment.center, child: Text( label, style: const TextStyle(fontSize: 12, color: AppColors.slate400), ), ); } Widget _buildLoadMoreButton(BuildContext context, bool isLoading) { return GestureDetector( onTap: isLoading ? null : () => _onLoadMore(context), child: Container( padding: const EdgeInsets.symmetric(vertical: 8), alignment: Alignment.center, child: isLoading ? const AppLoadingIndicator( variant: AppLoadingVariant.inline, size: 14, strokeWidth: 1.5, color: AppColors.slate400, trackColor: AppColors.slate200, ) : const Text( '查看历史', style: TextStyle(fontSize: 12, color: AppColors.slate400), ), ), ); } Future _onRefresh(BuildContext context) async { if (_isPullRefreshing) { return; } final chatBloc = context.read(); if (chatBloc.state.isLoadingHistory || _isHistoryPaginationInFlight) { return; } final hasEarlierHistory = chatBloc.state.hasEarlierHistory; if (mounted) { setState(() => _isPullRefreshing = true); } final startedAt = DateTime.now(); try { if (hasEarlierHistory) { await _loadMoreHistoryPreservingViewport(chatBloc); } else { Toast.show(context, '没有更早的历史记录了', type: ToastType.info); } } finally { final elapsed = DateTime.now().difference(startedAt); final minDuration = const Duration( milliseconds: _pullRefreshMinVisibleMs, ); if (elapsed < minDuration) { await Future.delayed(minDuration - elapsed); } if (mounted) { setState(() => _isPullRefreshing = false); } } } Future _onLoadMore(BuildContext context) async { final chatBloc = context.read(); await _loadMoreHistoryPreservingViewport(chatBloc); } Future _loadMoreHistoryPreservingViewport(ChatBloc chatBloc) async { if (_isHistoryPaginationInFlight) { return; } _captureHistoryViewportAnchor(); if (mounted) { setState(() { _isHistoryPaginationInFlight = true; }); } try { await chatBloc.loadMoreHistory(); } finally { _restoreHistoryViewportAnchor(); if (mounted) { setState(() { _isHistoryPaginationInFlight = false; }); } } } void _captureHistoryViewportAnchor() { if (!_scrollController.hasClients) { _historyViewportPixels = null; _historyViewportMaxExtent = null; return; } final position = _scrollController.position; _historyViewportPixels = position.pixels; _historyViewportMaxExtent = position.maxScrollExtent; } void _restoreHistoryViewportAnchor() { final previousPixels = _historyViewportPixels; final previousMaxExtent = _historyViewportMaxExtent; _historyViewportPixels = null; _historyViewportMaxExtent = null; if (previousPixels == null || previousMaxExtent == null) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) { return; } final position = _scrollController.position; final extentDelta = position.maxScrollExtent - previousMaxExtent; final targetOffset = (previousPixels + extentDelta) .clamp(position.minScrollExtent, position.maxScrollExtent) .toDouble(); _scrollController.jumpTo(targetOffset); }); } bool _isAgentWaiting(ChatState state) { return state.isWaitingFirstToken || state.isStreaming || state.isCancelling; } void _scheduleAutoScroll({required bool animated}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) { return; } final maxExtent = _scrollController.position.maxScrollExtent; if (animated) { _scrollController.animateTo( maxExtent, duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); return; } _scrollController.jumpTo(maxExtent); }); } Widget _buildChatItem(ChatListItem item) { switch (item.type) { case ChatItemType.message: return _buildMessageItem(item as TextMessageItem); case ChatItemType.toolCall: return _buildToolCallItem(item as ToolCallItem); case ChatItemType.toolResult: return _buildToolResultItem(item as ToolResultItem); } } Widget _buildMessageItem(TextMessageItem item) { final isUser = item.sender == MessageSender.user; final imageAttachments = _collectRenderableImageAttachments( item.attachments, ); final hasRenderableAttachments = imageAttachments.isNotEmpty; return Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( child: Container( padding: const EdgeInsets.symmetric( horizontal: _messagePaddingH, vertical: _messagePaddingV, ), decoration: BoxDecoration( color: isUser ? _userBubbleColor : AppColors.white, borderRadius: BorderRadius.only( topLeft: const Radius.circular(_cornerRadius), topRight: const Radius.circular(_cornerRadius), bottomLeft: Radius.circular(isUser ? _cornerRadius : 0), bottomRight: Radius.circular(isUser ? 0 : _cornerRadius), ), border: isUser ? null : Border.all(color: AppColors.slate300), ), child: Text( item.content, style: const TextStyle( fontSize: 14, color: AppColors.slate900, ), ), ), ), ], ), if (hasRenderableAttachments) Padding( padding: const EdgeInsets.only(top: _attachmentPreviewGap), child: _buildHistoryAttachmentPreviews( item.attachments, imageAttachments: imageAttachments, ), ), ], ); } Widget _buildHistoryAttachmentPreviews( List> attachments, { List>? imageAttachments, }) { final renderableAttachments = imageAttachments ?? _collectRenderableImageAttachments(attachments); if (renderableAttachments.isEmpty) { return const SizedBox.shrink(); } return Wrap( spacing: _attachmentPreviewGap, runSpacing: _attachmentPreviewGap, crossAxisAlignment: WrapCrossAlignment.start, children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(), ); } List> _collectRenderableImageAttachments( List> attachments, ) { return attachments.where(_isRenderableImageAttachment).toList(); } bool _isRenderableImageAttachment(Map attachment) { final path = attachment['path']; final url = attachment['url']; final mimeType = attachment['mimeType']; final hasRenderableSource = (url is String && url.isNotEmpty) || (path is String && path.isNotEmpty); return hasRenderableSource && mimeType is String && mimeType.startsWith('image/'); } Widget _buildHistoryAttachmentTile(Map attachment) { final path = attachment['path']; final url = attachment['url']; final isUploading = attachment['uploading'] == true; final Widget image; if (url is String && url.isNotEmpty) { image = Image.network( url, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return const Center( child: AppLoadingIndicator( variant: AppLoadingVariant.inline, size: _transcribingSpinnerSize, strokeWidth: _transcribingStrokeWidth, ), ); }, errorBuilder: (context, error, stackTrace) { return const Center( child: Icon( LucideIcons.imageOff, size: _iconSize, color: AppColors.slate500, ), ); }, ); } else if (path is String && path.isNotEmpty) { image = Image.file( File(path), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return const Center( child: Icon( LucideIcons.imageOff, size: _iconSize, color: AppColors.slate500, ), ); }, ); } else { return const SizedBox.shrink(); } return ClipRRect( borderRadius: BorderRadius.circular(_attachmentPreviewRadius), child: Container( width: _attachmentPreviewSize, height: _attachmentPreviewSize, color: AppColors.slate100, child: Stack( fit: StackFit.expand, children: [ image, if (isUploading) ColoredBox( color: AppColors.slate900.withValues(alpha: 0.2), child: const Center( child: AppLoadingIndicator( variant: AppLoadingVariant.inline, size: _transcribingSpinnerSize, strokeWidth: _transcribingStrokeWidth, color: AppColors.white, trackColor: AppColors.slate200, ), ), ), ], ), ), ); } Widget _buildToolCallItem(ToolCallItem item) { final (statusText, statusColor, statusIcon) = switch (item.status) { ToolCallStatus.pending => ( '工具准备中', AppColors.slate500, LucideIcons.clock, ), ToolCallStatus.executing => ( '任务执行中', AppColors.blue600, LucideIcons.loader, ), ToolCallStatus.error => ( item.errorMessage ?? '执行失败', AppColors.red600, LucideIcons.alertCircle, ), ToolCallStatus.completed => ( '已完成', AppColors.emerald600, LucideIcons.checkCircle, ), }; return Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: AppColors.surfaceInfoLight, borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all(color: AppColors.borderTertiary), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(AppRadius.full), border: Border.all(color: AppColors.borderTertiary), ), child: Icon(statusIcon, size: 14, color: statusColor), ), const SizedBox(width: AppSpacing.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.toolName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.slate800, ), ), const SizedBox(height: AppSpacing.xs), Text( statusText, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: statusColor, ), ), ], ), ), ], ), ); } Widget _buildToolResultItem(ToolResultItem item) { final rootNode = item.uiSchema['root']; final appearance = rootNode is Map ? rootNode['appearance'] as String? : null; final needsOuterCard = appearance == null || appearance == 'plain'; final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema); final wrappedContent = needsOuterCard ? Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all(color: AppColors.homeConversationBorder), ), child: schemaContent, ) : schemaContent; return Align( alignment: Alignment.centerLeft, child: FractionallySizedBox( widthFactor: _toolResultWidthFactor, child: wrappedContent, ), ); } Widget _buildBottomInputStack(BuildContext context, ChatState state) { return Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: KeyedSubtree( key: homeBottomInputStackKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ HomeAttachmentStrip( images: _selectedImages, onRemove: _removeImage, ), if (_selectedImages.isNotEmpty) const SizedBox(height: AppSpacing.sm), _buildInputContainer(context, state), ], ), ), ), ); } void _removeImage(int index) { setState(() { _selectedImages.removeAt(index); }); } Widget _buildInputContainer(BuildContext context, ChatState state) { final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); return ValueListenableBuilder( valueListenable: _messageController, builder: (context, value, child) { final hasMessage = value.text.trim().isNotEmpty; return Container( padding: EdgeInsets.zero, child: MessageComposer( mode: _isHoldToSpeakMode ? MessageComposerMode.holdToSpeak : MessageComposerMode.text, process: _composerProcess, hasMessage: hasMessage, isWaitingAgent: isWaitingAgent, iconSize: _iconSize, composerMinHeight: _inputMinHeight, onTapPlus: _isRecording ? () => _stopRecording(autoSendAfterTranscribe: false) : () => _showBottomSheet(context), onTapRightAction: () => _onRightActionTap(context, state), onHoldToSpeakStart: _onHoldToSpeakStart, onHoldToSpeakEnd: _onHoldToSpeakEnd, onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, onHoldToSpeakCancel: _onHoldToSpeakCancel, textInputChild: _buildTextInputContent(context), recordingAnimation: const SizedBox.shrink(), recordingText: _isCancelGestureActive ? '松手取消' : '松手发送', recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消', showRecordingInlineFeedback: false, ), ); }, ); } MessageComposerProcess get _composerProcess { if (_isRecording) { return MessageComposerProcess.recording; } if (_isTranscribing) { return MessageComposerProcess.transcribing; } return MessageComposerProcess.idle; } Widget _buildTextInputContent(BuildContext context) { if (_isTranscribing) { return _buildTranscribingIndicator(); } return SizedBox.expand( child: Align( alignment: Alignment.centerLeft, child: TextField( controller: _messageController, focusNode: _messageFocusNode, minLines: 1, maxLines: 1, style: const TextStyle( fontSize: AppSpacing.lg, height: 1, color: AppColors.slate900, ), textAlignVertical: TextAlignVertical.center, decoration: const InputDecoration( hintText: '输入消息...', hintStyle: TextStyle( fontSize: AppSpacing.lg, height: 1, color: AppColors.slate400, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, isCollapsed: true, contentPadding: EdgeInsets.zero, filled: false, ), onTap: _onTextFieldTap, onSubmitted: (_) => _sendMessage(context), ), ), ); } void _onTextFieldTap() { final alreadyFocused = _messageFocusNode.hasFocus; if (!alreadyFocused) { _messageFocusNode.requestFocus(); return; } if (!_supportsProgrammaticKeyboardShow()) { return; } SystemChannels.textInput.invokeMethod('TextInput.show'); } bool _supportsProgrammaticKeyboardShow() { if (kIsWeb) { return false; } return defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; } void _onRightActionTap(BuildContext context, ChatState state) { if (_isTranscribing || _isRecording) { return; } if (_isSendingMessage || _isAgentWaiting(state)) { _onStopGenerating(); return; } if (_messageController.text.trim().isNotEmpty) { _sendMessage(context); return; } _toggleHoldToSpeakMode(); } void _toggleHoldToSpeakMode() { if (_isRecording || _isTranscribing) { return; } final willSwitchToText = _isHoldToSpeakMode; setState(() { _isHoldToSpeakMode = !willSwitchToText; }); if (!willSwitchToText) { _messageFocusNode.unfocus(); } } void _onHoldToSpeakStart() { HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = false; }); _startRecording(); } void _onHoldToSpeakEnd() { if (_isRecordingStarting) { _shouldCancelWhenStartCompletes = false; _shouldStopWhenStartCompletes = true; return; } if (!_isRecording) { return; } if (_isCancelGestureActive) { HapticFeedback.selectionClick(); _cancelRecording(showToast: false); return; } HapticFeedback.selectionClick(); _stopRecording(autoSendAfterTranscribe: true); } void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) { final willCancel = details.offsetFromOrigin.dy < _cancelThreshold; if (willCancel != _isCancelGestureActive && mounted) { HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = willCancel; }); } } void _onHoldToSpeakCancel() { if (_isRecordingStarting) { _shouldStopWhenStartCompletes = false; _shouldCancelWhenStartCompletes = true; return; } _cancelRecording(showToast: false); } Future _cancelRecording({bool showToast = true}) async { try { await _voiceRecorder.stop(); _listeningAnimationController.stop(); } catch (_) {} if (!mounted) return; setState(() { _isRecording = false; _isCancelGestureActive = false; }); if (showToast) { Toast.show(context, '已取消', type: ToastType.info); } } Future _sendMessage(BuildContext context) async { if (_isSendingMessage) { return; } final content = _messageController.text.trim(); if (content.isEmpty && _selectedImages.isEmpty) return; final images = List.from(_selectedImages); FocusScope.of(context).unfocus(); _messageController.clear(); setState(() { _isSendingMessage = true; _selectedImages.clear(); }); try { await _chatBloc.sendMessage(content, images: images); } finally { if (mounted) { setState(() { _isSendingMessage = false; }); } } WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); } }); } Future _onStopGenerating() async { final canceled = await _chatBloc.cancelCurrentRun(); if (!mounted) { return; } if (canceled) { Toast.show(context, '已停止等待回复', type: ToastType.info); } } Widget _buildWaveDots() { return AnimatedBuilder( animation: _listeningAnimationController, builder: (context, _) { final t = _listeningAnimationController.value; final barCount = (AppSpacing.xxl * 2).toInt(); final barColor = _isCancelGestureActive ? AppColors.red500 : AppColors.blue500; return SizedBox( height: AppSpacing.lg, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: List.generate(barCount, (index) { final phase = (index / barCount + t) % 1; final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0); return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: Container( width: AppSpacing.xs / 2, height: AppSpacing.sm + AppSpacing.xs * active, decoration: BoxDecoration( color: barColor.withValues(alpha: 0.35 + active * 0.65), borderRadius: BorderRadius.circular(AppRadius.full), ), ), ); }), ), ); }, ); } Widget _buildRecordingGestureOverlay() { final isCancel = _isCancelGestureActive; final topColor = isCancel ? _recordingCancelTopColor : _recordingActiveTopColor; final bottomColor = isCancel ? _recordingCancelBottomColor : _recordingActiveBottomColor; final labelColor = isCancel ? _recordingCancelLabelColor : _recordingActiveLabelColor; final label = isCancel ? '松手取消' : '松手发送,上移取消'; return IgnorePointer( child: Align( alignment: Alignment.bottomCenter, child: Container( width: double.infinity, constraints: const BoxConstraints(minHeight: AppSpacing.xxl * 7), padding: const EdgeInsets.fromLTRB( AppSpacing.xl, AppSpacing.xxl, AppSpacing.xl, AppSpacing.xxl, ), decoration: BoxDecoration( borderRadius: const BorderRadius.only( topLeft: Radius.circular(AppRadius.xxl), topRight: Radius.circular(AppRadius.xxl), ), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [topColor, bottomColor], ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( label, style: TextStyle( fontSize: AppSpacing.xl, color: labelColor, fontWeight: FontWeight.w500, ), ), const SizedBox(height: AppSpacing.md), _buildWaveDots(), ], ), ), ), ); } Widget _buildTranscribingIndicator() { return TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 180), builder: (context, value, child) { return Opacity(opacity: value, child: child); }, child: const SizedBox( key: ValueKey('transcribing_indicator'), height: _inputMinHeight, child: Align( alignment: Alignment.centerLeft, child: Text( '语音识别中...', style: TextStyle(fontSize: 14, color: AppColors.slate500), ), ), ), ); } Future _startRecording() async { if (_isRecording || _isRecordingStarting) { return; } if (mounted) { setState(() { _isRecordingStarting = true; _shouldCancelWhenStartCompletes = false; _shouldStopWhenStartCompletes = false; }); } try { await _voiceRecorder.start(); _listeningAnimationController.repeat(); if (!mounted) { return; } if (_shouldStopWhenStartCompletes || _shouldCancelWhenStartCompletes) { final shouldCancelAfterStart = _shouldCancelWhenStartCompletes || _isCancelGestureActive; setState(() { _isRecordingStarting = false; _shouldCancelWhenStartCompletes = false; _shouldStopWhenStartCompletes = false; _isRecording = true; _isCancelGestureActive = false; }); if (shouldCancelAfterStart) { await _cancelRecording(showToast: false); return; } await _stopRecording(autoSendAfterTranscribe: true); return; } setState(() { _isRecordingStarting = false; _isRecording = true; _isCancelGestureActive = false; }); } catch (error) { if (!mounted) { return; } setState(() { _isRecordingStarting = false; _shouldCancelWhenStartCompletes = false; _shouldStopWhenStartCompletes = false; }); Toast.show(context, _readableError(error), type: ToastType.error); } } Future _stopRecording({bool autoSendAfterTranscribe = false}) async { String? audioPath; try { audioPath = await _voiceRecorder.stop(); _listeningAnimationController.stop(); if (!mounted) { return; } setState(() { _isRecording = false; _isTranscribing = true; _isCancelGestureActive = false; }); if (audioPath == null || audioPath.isEmpty) { throw StateError('录音失败,请重试'); } final transcript = await _transcribeAudio(audioPath); if (!mounted) { return; } final normalizedTranscript = transcript.trim(); if (normalizedTranscript.isEmpty) { Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error); return; } _messageController.text = normalizedTranscript; _messageController.selection = TextSelection.fromPosition( TextPosition(offset: normalizedTranscript.length), ); if (autoSendAfterTranscribe) { setState(() { _isTranscribing = false; }); await _sendMessage(context); } } catch (error) { if (!mounted) { return; } Toast.show(context, _readableError(error), type: ToastType.error); } finally { try { if (audioPath != null) { final file = File(audioPath); if (await file.exists()) { await file.delete(); } } } catch (_) { // Ignore temp file cleanup errors to avoid blocking UI state recovery. } if (mounted) { setState(() { _isTranscribing = false; }); } } } String _readableError(Object error) { if (error is ApiException) { return error.message; } final raw = error.toString(); if (raw.startsWith('Instance of')) { return '请求失败,请稍后重试'; } return raw.replaceFirst('Bad state: ', ''); } void _showBottomSheet(BuildContext context) { showModalBottomSheet( context: context, backgroundColor: Colors.transparent, isScrollControlled: true, builder: (context) => HomeSheet( onImagesSelected: (images) { setState(() { final remaining = 3 - _selectedImages.length; if (remaining > 0) { _selectedImages.addAll(images.take(remaining)); } }); }, ), ); } } class _HomeEmptyStateAmbient extends StatelessWidget { const _HomeEmptyStateAmbient(); @override Widget build(BuildContext context) { return Center( child: IgnorePointer( child: Container( key: homeEmptyStateAmbientKey, width: double.infinity, height: AppSpacing.xxl * 6, margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12), AppColors.homeBackgroundGlow.withValues(alpha: 0.08), AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12), ], ), ), ), ), ); } }