import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.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/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'home_sheet.dart'; /// 布局常量 const _headerHeight = 60.0; const _defaultPadding = 20.0; const _itemSpacing = 16.0; const _inputPadding = 16.0; const _iconSize = 24.0; const _messagePaddingH = 13.0; const _messagePaddingV = 9.0; const _cornerRadius = 12.0; const _inputMinHeight = 48.0; const _inputRadius = 24.0; const _scrollDurationMs = 300; const _rippleDurationMs = 1200; const _recordingDotSize = 10.0; const _transcribingSpinnerSize = 18.0; const _transcribingStrokeWidth = 2.0; const _inputActionButtonKey = ValueKey('home_input_action_button'); const _inputActionIconKey = ValueKey('home_input_action_icon'); /// 颜色常量 const _chatBgColor = Color(0xFFF8FAFC); const _userBubbleColor = Color(0xFFEAF1FB); 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; const HomeScreen({ super.key, this.voiceRecorder, this.onTranscribeAudio, this.onAutoSendTranscript, this.chatBloc, this.autoLoadHistory = true, }); @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 _isTranscribing = false; int _unreadCount = 0; bool get _hasMessage => _messageController.text.trim().isNotEmpty; @override void initState() { super.initState(); _messageController.addListener(_onMessageChanged); _chatBloc = widget.chatBloc ?? ChatBloc(); _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), ); 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: Column( children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), _buildInputContainer(context, state), ], ), ), ); }, ), ); } Widget _buildHeader(BuildContext context) { return SizedBox( height: _headerHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: _defaultPadding), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon( LucideIcons.settings, size: _iconSize, color: AppColors.slate900, ), onPressed: () => context.push('/settings'), ), Row( children: [ IconButton( icon: const Icon( LucideIcons.calendar, size: _iconSize, color: AppColors.slate900, ), onPressed: () => context.push('/calendar/dayweek?from=home'), ), const SizedBox(width: _itemSpacing), IconButton( icon: Stack( clipBehavior: Clip.none, children: [ const Icon( LucideIcons.messageSquare, size: _iconSize, color: AppColors.slate900, ), if (_unreadCount > 0) Positioned( right: -4, top: -4, child: Container( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), decoration: BoxDecoration( color: AppColors.red500, borderRadius: BorderRadius.circular(8), ), constraints: const BoxConstraints( minWidth: 16, minHeight: 16, ), child: Text( _unreadCount > 99 ? '99+' : _unreadCount.toString(), style: const TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.white, ), textAlign: TextAlign.center, ), ), ), ], ), onPressed: () => 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()); } if (state.items.isEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Expanded( child: Center( child: Text( '开始对话吧', style: TextStyle(fontSize: 16, color: AppColors.slate400), ), ), ), if (showWaitingIndicator) _buildWaitingIndicator(), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: RefreshIndicator( onRefresh: () => _onRefresh(context), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(_defaultPadding), 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) _buildWaitingIndicator(), ], ); } Widget _buildWaitingIndicator() { return Padding( padding: const EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, _defaultPadding, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: const [ SizedBox( width: _transcribingSpinnerSize, height: _transcribingSpinnerSize, child: CircularProgressIndicator( strokeWidth: _transcribingStrokeWidth, color: AppColors.blue600, ), ), SizedBox(width: 8), Text( '正在思考...', 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; return 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), ), ), ), ], ); } 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: 8), Text( item.toolName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.slate700, ), ), const SizedBox(width: 8), Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), if (item.toolName == 'front.navigate_to_route' && item.status == ToolCallStatus.pending) ...[ const SizedBox(width: 8), 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 _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, 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), ), ), 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, _isRecording || isWaitingAgent ? LucideIcons.square : _hasMessage ? LucideIcons.send : LucideIcons.mic, size: _iconSize, color: _isRecording || isWaitingAgent || _hasMessage ? AppColors.blue600 : AppColors.slate500, ), ), ], ), ), ), ], ), ); } Future _sendMessage(BuildContext context) async { final content = _messageController.text.trim(); if (content.isEmpty) return; FocusScope.of(context).unfocus(); _messageController.clear(); await context.read().sendMessage(content); WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); } }); } Future _onStopGenerating(BuildContext context) async { final canceled = await context.read().cancelCurrentRun(); if (!mounted) { return; } if (canceled) { Toast.show(context, '已停止等待回复', type: ToastType.info); } } Widget _buildListeningIndicator() { return SizedBox( height: _inputMinHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ AnimatedBuilder( animation: _listeningAnimationController, builder: (context, _) { final t = _listeningAnimationController.value; final waveA = 0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0)); final waveB = 0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0)); final waveC = 0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0)); return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildWaveDot(scale: waveA), const SizedBox(width: 6), _buildWaveDot(scale: waveB), const SizedBox(width: 6), _buildWaveDot(scale: waveC), ], ); }, ), const SizedBox(width: 10), const Text( '正在聆听...', style: TextStyle(fontSize: 14, color: AppColors.slate500), ), ], ), ); } 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), ), ), ), ); } Widget _buildWaveDot({required double scale}) { return Transform.scale( scale: scale, child: Container( width: _recordingDotSize, height: _recordingDotSize, decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.red600, ), ), ); } Future _startRecording() async { try { await _voiceRecorder.start(); _listeningAnimationController.repeat(); if (!mounted) { return; } setState(() { _isRecording = true; }); } 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; }); 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) => const HomeSheet(), ); } }