it0/it0_app/lib/features/chat/presentation/pages/chat_page.dart

1007 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import '../../../../core/theme/app_colors.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';
import '../../../settings/presentation/providers/settings_providers.dart';
// ---------------------------------------------------------------------------
// Chat page Timeline workflow style (inspired by Claude Code VSCode)
// ---------------------------------------------------------------------------
class ChatPage extends ConsumerStatefulWidget {
const ChatPage({super.key});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
final List<ChatAttachment> _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<ChatAttachment>.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<void> _transcribeToInput(String audioPath) async {
setState(() {
_sttLoading = true;
_messageController.text = '识别中…';
});
try {
final language = ref.read(settingsProvider).language;
final text = await ref.read(chatProvider.notifier).transcribeAudio(audioPath, language: language);
if (mounted) {
setState(() {
_messageController.text = text;
_messageController.selection = TextSelection.collapsed(
offset: text.length,
);
});
}
} catch (e) {
if (mounted) {
setState(() => _messageController.text = '');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('语音识别失败,请重试')),
);
}
} 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: const Text('从相册选择'),
subtitle: const Text('支持多选'),
onTap: () { Navigator.pop(ctx); _pickMultipleImages(); },
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: const Icon(Icons.attach_file),
title: const Text('选择文件'),
subtitle: const Text('图片、PDF'),
onTap: () { Navigator.pop(ctx); _pickFile(); },
),
],
),
),
);
}
static const _maxAttachments = 5;
Future<void> _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<void> _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<void> _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: '思考中...',
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 ? '执行失败' : '执行结果',
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: '需要审批',
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: '常驻指令草案',
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.text:
default:
return TimelineEventNode(
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
label: isStreamingNow ? '回复中...' : '回复',
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: const Text('iAgent', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
tooltip: '新对话',
visualDensity: VisualDensity.compact,
onPressed: () => ref.read(chatProvider.notifier).startNewChat(),
),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, size: 20),
tooltip: '停止',
visualDensity: VisualDensity.compact,
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
),
IconButton(
icon: const Icon(Icons.call, size: 20),
tooltip: '语音通话',
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: '处理中...',
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(
'开始与 iAgent 对话',
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'输入指令或拨打语音通话',
style: 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: '添加图片',
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
),
),
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: isStreaming ? '追加指令...' : '输入指令...',
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: '追加指令',
onPressed: _inject,
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20),
tooltip: '停止',
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)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 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 ? '收起' : '展开 ($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<String, dynamic> 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: const Text('取消', style: 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)),
),
],
),
],
);
}
}