import 'dart:io'; 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/chat_bloc.dart'; import '../../../chat/data/tools/route_navigation_tool.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.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 = 140.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; class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; final Future Function(String transcript)? onAutoSendTranscript; final ChatBloc? chatBloc; final bool autoLoadHistory; final List initialSelectedImages; const HomeScreen({ super.key, this.voiceRecorder, this.onTranscribeAudio, this.onAutoSendTranscript, this.chatBloc, this.autoLoadHistory = true, this.initialSelectedImages = const [], }); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); 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 Future Function(String transcript) _autoSendTranscript; late final AnimationController _listeningAnimationController; bool _isRecording = false; bool _isHoldToSpeakMode = false; bool _isTranscribing = false; bool _isCancelGestureActive = false; int _unreadCount = 0; final List _selectedImages = []; bool get _hasMessage => _messageController.text.trim().isNotEmpty; @override void initState() { super.initState(); _messageController.addListener(_onMessageChanged); _chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl()); _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _inboxApi = sl(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; _autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage; _listeningAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); if (widget.autoLoadHistory) { _chatBloc.loadHistory(); } _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.removeListener(_onMessageChanged); _messageController.dispose(); _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); if (widget.chatBloc == null) { _chatBloc.close(); } RouteNavigationTool.instance.clearNavigator(); super.dispose(); } void _onMessageChanged() { setState(() {}); } @override Widget build(BuildContext context) { RouteNavigationTool.instance.bindNavigator((target, {replace = false}) { if (!mounted) { return; } if (replace) { context.go(target); } else { context.push(target); } }); return BlocProvider.value( value: _chatBloc, child: BlocConsumer( listener: (context, state) { if (state.error != null) { Toast.show(context, state.error!, type: ToastType.error); } }, 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 = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; if (state.isLoadingHistory && state.items.isEmpty) { return const Center(child: CircularProgressIndicator()); } 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( onRefresh: () => _onRefresh(context), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(top: AppSpacing.sm), 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), ), ], ), ), ); } Widget _buildWaitingIndicator({required AgentStage? currentStage}) { final label = switch (currentStage) { AgentStage.intent => '意图识别中', AgentStage.execution => '任务执行中', AgentStage.report => '结果总结中', null => '正在思考...', }; return Padding( padding: const EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, _defaultPadding, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: _transcribingSpinnerSize, height: _transcribingSpinnerSize, child: CircularProgressIndicator( strokeWidth: _transcribingStrokeWidth, color: AppColors.blue600, ), ), 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 SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 1.5, color: AppColors.slate400, ), ) : const Text( '查看历史', style: TextStyle(fontSize: 12, color: AppColors.slate400), ), ), ); } Future _onRefresh(BuildContext context) async { await context.read().loadMoreHistory(); } void _onLoadMore(BuildContext context) { context.read().loadMoreHistory(); } 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 url = attachment['url']; final mimeType = attachment['mimeType']; return url is String && url.isNotEmpty && mimeType is String && mimeType.startsWith('image/'); } Widget _buildHistoryAttachmentTile(Map attachment) { final url = attachment['url']; if (url is! String || url.isEmpty) { return const SizedBox.shrink(); } return ClipRRect( borderRadius: BorderRadius.circular(_attachmentPreviewRadius), child: Container( width: _attachmentPreviewSize, height: _attachmentPreviewSize, color: AppColors.slate100, child: Image.network( url, fit: BoxFit.cover, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return const Center( child: SizedBox( width: _transcribingSpinnerSize, height: _transcribingSpinnerSize, child: CircularProgressIndicator( strokeWidth: _transcribingStrokeWidth, ), ), ); }, errorBuilder: (context, error, stackTrace) { return const Center( child: Icon( LucideIcons.imageOff, size: _iconSize, color: AppColors.slate500, ), ); }, ), ), ); } 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( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: AppColors.blue50, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.slate300), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(statusIcon, size: 16, color: statusColor), const SizedBox(width: AppSpacing.sm), Text( item.toolName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.slate700, ), ), const SizedBox(width: AppSpacing.sm), Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), if (item.toolName == 'front.navigate_to_route' && item.status == ToolCallStatus.pending) ...[ const SizedBox(width: AppSpacing.sm), GestureDetector( onTap: () => _chatBloc.approveToolCall(item.callId), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.blue600, borderRadius: BorderRadius.circular(6), ), child: const Text( '同意', style: TextStyle(fontSize: 11, color: AppColors.white), ), ), ), ], ], ), ); } Widget _buildToolResultItem(ToolResultItem item) { return UiSchemaRenderer.render(item.uiCard); } 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 = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; 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, 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, ), onSubmitted: (_) => _sendMessage(context), ), ), ); } void _onRightActionTap(BuildContext context, ChatState state) { if (_isTranscribing || _isRecording) { return; } final isWaitingAgent = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; if (isWaitingAgent) { _onStopGenerating(); return; } if (_hasMessage) { _sendMessage(context); return; } _toggleHoldToSpeakMode(); } void _toggleHoldToSpeakMode() { if (_isRecording || _isTranscribing) { return; } setState(() { _isHoldToSpeakMode = !_isHoldToSpeakMode; }); } void _onHoldToSpeakStart() { HapticFeedback.heavyImpact(); HapticFeedback.vibrate(); setState(() { _isCancelGestureActive = false; }); _startRecording(); } void _onHoldToSpeakEnd() { if (_isCancelGestureActive) { HapticFeedback.selectionClick(); _cancelRecording(showToast: false); return; } HapticFeedback.mediumImpact(); _stopRecording(autoSendAfterTranscribe: true); } void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) { final willCancel = details.offsetFromOrigin.dy < _cancelThreshold; if (willCancel != _isCancelGestureActive && mounted) { HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = willCancel; }); } } void _onHoldToSpeakCancel() { _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 { final content = _messageController.text.trim(); if (content.isEmpty && _selectedImages.isEmpty) return; final images = List.from(_selectedImages); FocusScope.of(context).unfocus(); _messageController.clear(); setState(() { _selectedImages.clear(); }); await context.read().sendMessage(content, images: images); 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 topColor = _isCancelGestureActive ? AppColors.warningBackground : AppColors.blue50; final bottomColor = _isCancelGestureActive ? AppColors.red400 : AppColors.blue400; final labelColor = _isCancelGestureActive ? AppColors.red600 : AppColors.white; final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消'; 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 { try { await _voiceRecorder.start(); _listeningAnimationController.repeat(); if (!mounted) { return; } setState(() { _isRecording = true; _isCancelGestureActive = false; }); } catch (error) { if (!mounted) { return; } 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 = transcript; _messageController.selection = TextSelection.fromPosition( TextPosition(offset: transcript.length), ); if (autoSendAfterTranscribe) { _messageController.clear(); await _autoSendTranscript(normalizedTranscript); } } 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), ], ), ), ), ), ); } }