import 'dart:convert'; import 'dart:io'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:it0_app/l10n/app_localizations.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/robot_avatar.dart'; import '../../../../core/widgets/floating_robot_fab.dart'; import '../../domain/entities/chat_message.dart'; import '../providers/chat_providers.dart'; import '../widgets/timeline_event_node.dart'; import '../widgets/stream_text_widget.dart'; import '../widgets/approval_action_card.dart'; import '../widgets/conversation_drawer.dart'; import '../../../agent_call/presentation/pages/agent_call_page.dart'; import '../widgets/voice_mic_button.dart'; // --------------------------------------------------------------------------- // Chat page – Timeline workflow style (inspired by Claude Code VSCode) // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); final List _pendingAttachments = []; bool _sttLoading = false; // -- Send ------------------------------------------------------------------ void _send() { final text = _messageController.text.trim(); if (text.isEmpty && _pendingAttachments.isEmpty) return; _messageController.clear(); final attachments = _pendingAttachments.isNotEmpty ? List.from(_pendingAttachments) : null; if (_pendingAttachments.isNotEmpty) { setState(() => _pendingAttachments.clear()); } ref.read(chatProvider.notifier).sendMessage(text, attachments: attachments); _scrollToBottom(); } void _inject() { final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); ref.read(chatProvider.notifier).injectMessage(text); _scrollToBottom(); } Future _transcribeToInput(String audioPath) async { setState(() { _sttLoading = true; _messageController.text = AppLocalizations.of(context).chatRecognizingLabel; }); try { final text = await ref.read(chatProvider.notifier).transcribeAudio(audioPath); if (mounted) { setState(() { _messageController.text = text; _messageController.selection = TextSelection.collapsed( offset: text.length, ); }); } } catch (e) { if (mounted) { setState(() => _messageController.text = ''); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context).chatSpeechRecognitionError)), ); } } finally { if (mounted) setState(() => _sttLoading = false); } } void _scrollToBottom({bool jump = false}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) return; final target = _scrollController.position.maxScrollExtent + 80; if (jump) { _scrollController.jumpTo(target); // Second frame: layout may still be settling for large message lists WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo( _scrollController.position.maxScrollExtent + 80, ); } }); } else { _scrollController.animateTo( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } void _openVoiceCall() { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AgentCallPage()), ); } // -- Attachments ----------------------------------------------------------- void _showAttachmentOptions() { showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.photo_library), title: Text(AppLocalizations.of(context).chatSelectFromAlbum), subtitle: const Text('支持多选'), onTap: () { Navigator.pop(ctx); _pickMultipleImages(); }, ), ListTile( leading: const Icon(Icons.camera_alt), title: Text(AppLocalizations.of(context).chatTakePhoto), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, ), ListTile( leading: const Icon(Icons.attach_file), title: Text(AppLocalizations.of(context).chatSelectFile), subtitle: const Text('图片、PDF'), onTap: () { Navigator.pop(ctx); _pickFile(); }, ), ], ), ), ); } static const _maxAttachments = 5; Future _pickImage(ImageSource source) async { if (_pendingAttachments.length >= _maxAttachments) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('最多添加 $_maxAttachments 张图片')), ); } return; } final picker = ImagePicker(); final picked = await picker.pickImage( source: source, maxWidth: 1568, maxHeight: 1568, imageQuality: 85, ); if (picked == null) return; final bytes = await picked.readAsBytes(); final ext = picked.path.split('.').last.toLowerCase(); final mediaType = switch (ext) { 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif', _ => 'image/jpeg', }; setState(() { _pendingAttachments.add(ChatAttachment( base64Data: base64Encode(bytes), mediaType: mediaType, fileName: picked.name, )); }); } Future _pickMultipleImages() async { final remaining = _maxAttachments - _pendingAttachments.length; if (remaining <= 0) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('最多添加 $_maxAttachments 张图片')), ); } return; } final picker = ImagePicker(); final pickedList = await picker.pickMultiImage( maxWidth: 1568, maxHeight: 1568, imageQuality: 85, ); if (pickedList.isEmpty) return; final toAdd = pickedList.take(remaining); for (final picked in toAdd) { final bytes = await picked.readAsBytes(); final ext = picked.path.split('.').last.toLowerCase(); final mediaType = switch (ext) { 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif', _ => 'image/jpeg', }; _pendingAttachments.add(ChatAttachment( base64Data: base64Encode(bytes), mediaType: mediaType, fileName: picked.name, )); } setState(() {}); if (pickedList.length > remaining && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('已选择 $remaining 张,最多 $_maxAttachments 张')), ); } } Future _pickFile() async { final remaining = _maxAttachments - _pendingAttachments.length; if (remaining <= 0) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('最多添加 $_maxAttachments 个附件')), ); } return; } // Use FileType.any so the system picker shows subdirectories for navigation. // We validate file extensions after selection. final result = await FilePicker.platform.pickFiles( type: FileType.any, allowMultiple: true, ); if (result == null || result.files.isEmpty) return; const allowedExts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'}; int skipped = 0; final toAdd = result.files.take(remaining); for (final file in toAdd) { if (file.path == null) continue; final ext = (file.extension ?? '').toLowerCase(); if (!allowedExts.contains(ext)) { skipped++; continue; } final bytes = await File(file.path!).readAsBytes(); final String mediaType; if (ext == 'pdf') { mediaType = 'application/pdf'; } else { mediaType = switch (ext) { 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif', _ => 'image/jpeg', }; } _pendingAttachments.add(ChatAttachment( base64Data: base64Encode(bytes), mediaType: mediaType, fileName: file.name, )); } setState(() {}); if (skipped > 0 && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('仅支持图片(jpg/png/gif/webp)和PDF文件')), ); } else if (result.files.length > remaining && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('已选择 $remaining 个,最多 $_maxAttachments 个')), ); } } Widget _buildAttachmentPreview() { return SizedBox( height: 80, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _pendingAttachments.length, itemBuilder: (ctx, i) { final att = _pendingAttachments[i]; final isImage = att.mediaType.startsWith('image/'); return Stack( children: [ Padding( padding: const EdgeInsets.all(4), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: isImage ? Image.memory( base64Decode(att.base64Data), width: 72, height: 72, fit: BoxFit.cover, cacheWidth: 144, cacheHeight: 144, ) : Container( width: 72, height: 72, color: AppColors.surfaceLight, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.description, size: 28, color: AppColors.textSecondary), const SizedBox(height: 2), Text( att.fileName?.split('.').last.toUpperCase() ?? 'FILE', style: const TextStyle(fontSize: 10, color: AppColors.textMuted), overflow: TextOverflow.ellipsis, ), ], ), ), ), ), Positioned( top: 0, right: 0, child: GestureDetector( onTap: () => setState(() => _pendingAttachments.removeAt(i)), child: Container( decoration: const BoxDecoration( color: Colors.black54, shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 16, color: Colors.white), ), ), ), ], ); }, ), ); } /// Whether to show a virtual "working" node at the bottom of the timeline. /// True when the agent is streaming but no assistant message has appeared yet. bool _needsWorkingNode(ChatState chatState) { if (!chatState.isStreaming) return false; if (chatState.messages.isEmpty) return false; // Show working node if the last message is still the user's prompt return chatState.messages.last.role == MessageRole.user; } // -- Timeline node builder ------------------------------------------------ Widget _buildTimelineNode( ChatMessage message, ChatState chatState, { required bool isFirst, required bool isLast, }) { final isLastMessage = chatState.messages.last.id == message.id; final isStreamingNow = isLastMessage && chatState.isStreaming; // User message — special "prompt" node if (message.role == MessageRole.user) { return TimelineEventNode( status: NodeStatus.completed, label: message.content, isFirst: isFirst, isLast: isLast, icon: Icons.person_outline, content: message.attachments != null && message.attachments!.isNotEmpty ? Wrap( spacing: 4, runSpacing: 4, children: message.attachments!.map((att) { final bytes = base64Decode(att.base64Data); return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory(bytes, width: 120, height: 120, fit: BoxFit.cover, cacheWidth: 240, cacheHeight: 240), ); }).toList(), ) : null, ); } switch (message.type) { case MessageType.thinking: return TimelineEventNode( status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, label: AppLocalizations.of(context).chatThinkingLabel, isFirst: isFirst, isLast: isLast, content: _CollapsibleThinking( text: message.content, isStreaming: isStreamingNow, ), ); case MessageType.toolUse: final tool = message.toolExecution; final toolName = tool?.toolName ?? 'unknown'; final isExecuting = tool?.status == ToolStatus.executing; return TimelineEventNode( status: isExecuting ? NodeStatus.active : NodeStatus.completed, label: toolName, isFirst: isFirst, isLast: isLast, icon: isExecuting ? null : Icons.check_circle_outline, content: tool != null && tool.input.isNotEmpty ? CodeBlock(text: tool.input, maxLines: 1) : null, ); case MessageType.toolResult: final tool = message.toolExecution; final isError = tool?.status == ToolStatus.error; return TimelineEventNode( status: isError ? NodeStatus.error : NodeStatus.completed, label: isError ? AppLocalizations.of(context).chatExecutionFailedLabel : AppLocalizations.of(context).chatExecutionResultLabel, isFirst: isFirst, isLast: isLast, icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline, content: tool?.output != null && tool!.output!.isNotEmpty ? _CollapsibleCodeBlock( text: tool.output!, textColor: isError ? AppColors.error : null, ) : null, ); case MessageType.approval: return TimelineEventNode( status: NodeStatus.warning, label: AppLocalizations.of(context).chatNeedsApprovalLabel, isFirst: isFirst, isLast: isLast, icon: Icons.shield_outlined, content: message.approvalRequest != null ? ApprovalActionCard( approvalRequest: message.approvalRequest!, onApprove: () => ref.read(chatProvider.notifier) .approveCommand(message.approvalRequest!.taskId), onReject: (reason) => ref.read(chatProvider.notifier) .rejectCommand(message.approvalRequest!.taskId, reason: reason), ) : null, ); case MessageType.standingOrderDraft: return TimelineEventNode( status: NodeStatus.warning, label: AppLocalizations.of(context).chatStandingOrderDraftLabel, isFirst: isFirst, isLast: isLast, icon: Icons.schedule, content: _StandingOrderContent( draft: message.metadata ?? {}, onConfirm: () => ref.read(chatProvider.notifier) .confirmStandingOrder(message.metadata ?? {}), ), ); case MessageType.interrupted: return TimelineEventNode( status: NodeStatus.warning, label: message.content, isFirst: isFirst, isLast: isLast, icon: Icons.cancel_outlined, ); case MessageType.oauthPrompt: final url = message.metadata?['url'] as String? ?? ''; final instanceName = message.metadata?['instanceName'] as String? ?? '小龙虾'; return TimelineEventNode( status: NodeStatus.warning, label: '钉钉授权', isFirst: isFirst, isLast: isLast, icon: Icons.link, content: _OAuthPromptCard(url: url, instanceName: instanceName), ); case MessageType.text: default: return TimelineEventNode( status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, label: isStreamingNow ? AppLocalizations.of(context).chatReplyingLabel : AppLocalizations.of(context).chatReplyLabel, isFirst: isFirst, isLast: isLast, icon: isStreamingNow ? null : Icons.check_circle_outline, content: StreamTextWidget( text: message.content, isStreaming: isStreamingNow, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, ), ), ); } } // -- Build ----------------------------------------------------------------- @override Widget build(BuildContext context) { final chatState = ref.watch(chatProvider); // Auto-scroll when messages change ref.listen(chatProvider, (prev, next) { // Jump (no animation) when loading a conversation history final wasEmpty = prev?.messages.isEmpty ?? true; final nowHasMany = next.messages.length > 1; _scrollToBottom(jump: wasEmpty && nowHasMany); }); return Scaffold( drawer: const ConversationDrawer(), appBar: AppBar( title: Consumer( builder: (context, ref, _) { final robotState = ref.watch(robotStateProvider); return Row( mainAxisSize: MainAxisSize.min, children: [ RobotAvatar(state: robotState, size: 32), const SizedBox(width: 8), Text(AppLocalizations.of(context).appTitle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600)), ], ); }, ), actions: [ IconButton( icon: const Icon(Icons.edit_outlined, size: 20), tooltip: AppLocalizations.of(context).chatNewConversationTooltip, visualDensity: VisualDensity.compact, onPressed: () => ref.read(chatProvider.notifier).startNewChat(), ), if (chatState.isStreaming) IconButton( icon: const Icon(Icons.stop_circle_outlined, size: 20), tooltip: AppLocalizations.of(context).chatStopTooltip, visualDensity: VisualDensity.compact, onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ), IconButton( icon: const Icon(Icons.call, size: 20), tooltip: AppLocalizations.of(context).chatVoiceCallTooltip, visualDensity: VisualDensity.compact, onPressed: _openVoiceCall, ), const SizedBox(width: 4), ], ), body: SafeArea( top: false, child: Column( children: [ // Error banner if (chatState.error != null) Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), color: AppColors.error.withOpacity(0.1), child: Row( children: [ const Icon(Icons.error_outline, size: 16, color: AppColors.error), const SizedBox(width: 8), Expanded( child: Text( chatState.error!, style: const TextStyle(color: AppColors.error, fontSize: 13), ), ), GestureDetector( onTap: () => ref.read(chatProvider.notifier).clearChat(), child: const Icon(Icons.close, size: 16, color: AppColors.error), ), ], ), ), // Timeline message list + floating input Expanded( child: Stack( children: [ // Messages fill the entire area chatState.messages.isEmpty ? _buildEmptyState() : ListView.builder( controller: _scrollController, // Bottom padding leaves room for the floating input pill padding: const EdgeInsets.fromLTRB(12, 8, 12, 80), itemCount: chatState.messages.length + (_needsWorkingNode(chatState) ? 1 : 0), itemBuilder: (context, index) { if (index == chatState.messages.length && _needsWorkingNode(chatState)) { return TimelineEventNode( status: NodeStatus.active, label: AppLocalizations.of(context).chatProcessingLabel, isFirst: false, isLast: false, ); } final isRealLast = index == chatState.messages.length - 1; return _buildTimelineNode( chatState.messages[index], chatState, isFirst: index == 0, isLast: isRealLast && !chatState.isStreaming && !_needsWorkingNode(chatState), ); }, ), // Floating input pill at bottom Positioned( left: 12, right: 12, bottom: 8, child: _buildInputArea(chatState), ), ], ), ), ], ), ), ); } Widget _buildEmptyState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted), const SizedBox(height: 16), Text( AppLocalizations.of(context).chatStartConversationPrompt, style: const TextStyle(color: AppColors.textSecondary, fontSize: 16), ), const SizedBox(height: 8), Text( AppLocalizations.of(context).chatInputInstructionHint, style: const TextStyle(color: AppColors.textMuted, fontSize: 13), ), const SizedBox(height: 24), OutlinedButton.icon( onPressed: _openVoiceCall, icon: const Icon(Icons.call), label: const Text('语音通话'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.success, side: const BorderSide(color: AppColors.success), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), ], ), ); } Widget _buildInputArea(ChatState chatState) { final isAwaitingApproval = chatState.agentStatus == AgentStatus.awaitingApproval; final isStreaming = chatState.isStreaming && !isAwaitingApproval; return Container( decoration: BoxDecoration( color: AppColors.surface.withOpacity(0.92), borderRadius: BorderRadius.circular(28), border: Border.all(color: AppColors.surfaceLight.withOpacity(0.6)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 2), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_pendingAttachments.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: _buildAttachmentPreview(), ), Row( children: [ if (!isStreaming) Padding( padding: const EdgeInsets.only(left: 4), child: IconButton( icon: const Icon(Icons.add_circle_outline, size: 22), tooltip: AppLocalizations.of(context).chatAddImageTooltip, onPressed: isAwaitingApproval ? null : _showAttachmentOptions, ), ), Expanded( child: TextField( controller: _messageController, decoration: InputDecoration( hintText: isStreaming ? AppLocalizations.of(context).chatAdditionalInstructionHint : AppLocalizations.of(context).chatInstructionHint, hintStyle: TextStyle(color: AppColors.textMuted), border: InputBorder.none, contentPadding: EdgeInsets.only( left: isStreaming ? 16 : 4, right: 4, top: 12, bottom: 12, ), ), textInputAction: TextInputAction.send, onSubmitted: (_) => isStreaming ? _inject() : _send(), enabled: !isAwaitingApproval, ), ), if (isStreaming) Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.send, color: AppColors.info, size: 20), tooltip: AppLocalizations.of(context).chatInjectionTooltip, onPressed: _inject, ), Padding( padding: const EdgeInsets.only(right: 4), child: IconButton( icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20), tooltip: AppLocalizations.of(context).chatStopTooltip, onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), ), ), ], ) else Row( mainAxisSize: MainAxisSize.min, children: [ VoiceMicButton( disabled: isAwaitingApproval || _sttLoading, onAudioReady: _transcribeToInput, ), Padding( padding: const EdgeInsets.only(right: 4), child: IconButton( icon: const Icon(Icons.send, size: 20), onPressed: isAwaitingApproval ? null : _send, ), ), ], ), ], ), ], ), ); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); super.dispose(); } } // --------------------------------------------------------------------------- // Standing order content (embedded in timeline node) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // OAuth prompt card — shown in timeline when agent triggers DingTalk binding // --------------------------------------------------------------------------- // Stateful so it can activate an audio session before opening the browser, // keeping the app's network connections alive on iOS while in the background. class _OAuthPromptCard extends StatefulWidget { final String url; final String instanceName; const _OAuthPromptCard({required this.url, required this.instanceName}); @override State<_OAuthPromptCard> createState() => _OAuthPromptCardState(); } class _OAuthPromptCardState extends State<_OAuthPromptCard> with WidgetsBindingObserver { bool _keepAliveActive = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _deactivateKeepAlive(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Deactivate when the user returns to the app after OAuth if (state == AppLifecycleState.resumed && _keepAliveActive) { _deactivateKeepAlive(); } } Future _activateKeepAlive() async { if (_keepAliveActive) return; _keepAliveActive = true; try { final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playback, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers, avAudioSessionMode: AVAudioSessionMode.defaultMode, androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.music, usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, )); await session.setActive(true); } catch (_) { // Non-fatal — proceed even if audio session fails _keepAliveActive = false; } } Future _deactivateKeepAlive() async { if (!_keepAliveActive) return; _keepAliveActive = false; try { final session = await AudioSession.instance; await session.setActive(false); } catch (_) {} } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '点击下方按钮,在钉钉中为「${widget.instanceName}」完成一键授权绑定。', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), ), const SizedBox(height: 10), SizedBox( width: double.infinity, child: ElevatedButton.icon( icon: const Text('🦞', style: TextStyle(fontSize: 16)), label: const Text('立即授权'), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1677FF), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: () async { final uri = Uri.tryParse(widget.url); if (uri != null) { // Keep app alive in background while browser is open await _activateKeepAlive(); await launchUrl(uri, mode: LaunchMode.externalApplication); } }, ), ), ], ); } } // --------------------------------------------------------------------------- // Collapsible code block for tool results – collapsed by default, tap to expand // --------------------------------------------------------------------------- class _CollapsibleCodeBlock extends StatefulWidget { final String text; final Color? textColor; const _CollapsibleCodeBlock({ required this.text, this.textColor, }); @override State<_CollapsibleCodeBlock> createState() => _CollapsibleCodeBlockState(); } class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> { bool _expanded = false; @override Widget build(BuildContext context) { final lineCount = '\n'.allMatches(widget.text).length + 1; // Short results (≤3 lines): always show fully if (lineCount <= 3) { return CodeBlock(text: widget.text, textColor: widget.textColor); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRect( child: AnimatedCrossFade( firstChild: CodeBlock(text: widget.text, textColor: widget.textColor), secondChild: CodeBlock( text: widget.text, textColor: widget.textColor, maxLines: 3, ), crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), sizeCurve: Curves.easeInOut, ), ), GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Padding( padding: const EdgeInsets.only(top: 4), child: Text( _expanded ? AppLocalizations.of(context).chatCollapseLabel : '展开 ($lineCount 行)', style: TextStyle( color: AppColors.info, fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ), ], ); } } // --------------------------------------------------------------------------- // Collapsible thinking content – expanded while streaming, collapsed when done // --------------------------------------------------------------------------- class _CollapsibleThinking extends StatefulWidget { final String text; final bool isStreaming; const _CollapsibleThinking({ required this.text, required this.isStreaming, }); @override State<_CollapsibleThinking> createState() => _CollapsibleThinkingState(); } class _CollapsibleThinkingState extends State<_CollapsibleThinking> { bool _expanded = true; @override void didUpdateWidget(covariant _CollapsibleThinking oldWidget) { super.didUpdateWidget(oldWidget); // Auto-collapse when streaming ends if (oldWidget.isStreaming && !widget.isStreaming) { setState(() => _expanded = false); } } @override Widget build(BuildContext context) { // While streaming, always show expanded content if (widget.isStreaming) { return StreamTextWidget( text: widget.text, isStreaming: true, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13, fontStyle: FontStyle.italic, ), ); } // Completed – show collapsible toggle return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'Thinking', style: TextStyle( color: AppColors.textMuted, fontSize: 12, fontWeight: FontWeight.w500, ), ), const SizedBox(width: 4), AnimatedRotation( turns: _expanded ? 0.5 : 0.0, duration: const Duration(milliseconds: 200), child: Icon( Icons.expand_more, size: 16, color: AppColors.textMuted, ), ), ], ), ), ClipRect( child: AnimatedCrossFade( firstChild: Padding( padding: const EdgeInsets.only(top: 6), child: StreamTextWidget( text: widget.text, isStreaming: false, style: const TextStyle( color: AppColors.textSecondary, fontSize: 13, fontStyle: FontStyle.italic, ), ), ), secondChild: const SizedBox.shrink(), crossFadeState: _expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), sizeCurve: Curves.easeInOut, ), ), ], ); } } // --------------------------------------------------------------------------- // Standing order content (embedded in timeline node) // --------------------------------------------------------------------------- class _StandingOrderContent extends StatelessWidget { final Map draft; final VoidCallback onConfirm; const _StandingOrderContent({ required this.draft, required this.onConfirm, }); @override Widget build(BuildContext context) { final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令'; final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? ''; final command = draft['command'] as String? ?? draft['prompt'] as String? ?? ''; final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? []; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), if (cron.isNotEmpty) ...[ const SizedBox(height: 4), Text( cron, style: const TextStyle( color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace', ), ), ], if (command.isNotEmpty) ...[ const SizedBox(height: 6), CodeBlock(text: command), ], if (targets.isNotEmpty) ...[ const SizedBox(height: 4), Text( '目标: ${targets.join(", ")}', style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), ), ], const SizedBox(height: 8), Row( children: [ OutlinedButton( onPressed: () {}, style: OutlinedButton.styleFrom( foregroundColor: AppColors.textSecondary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), minimumSize: Size.zero, ), child: Text(AppLocalizations.of(context).cancelButton, style: const TextStyle(fontSize: 12)), ), const SizedBox(width: 8), FilledButton( onPressed: onConfirm, style: FilledButton.styleFrom( backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), minimumSize: Size.zero, ), child: const Text('确认', style: TextStyle(fontSize: 12)), ), ], ), ], ); } }