xworkmate-app/lib/features/mobile/ios_mobile_shell.dart

2314 lines
72 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 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../runtime/runtime_models.dart';
import '../../widgets/gateway_connect_dialog.dart';
import '../../widgets/section_tabs.dart';
enum IosMobileTab { home, overview, tasks, account, settings }
extension on IosMobileTab {
String get label => switch (this) {
IosMobileTab.home => '首页',
IosMobileTab.overview => '总览',
IosMobileTab.tasks => '任务',
IosMobileTab.account => '账号登录',
IosMobileTab.settings => '设置',
};
IconData get icon => switch (this) {
IosMobileTab.home => Icons.home_rounded,
IosMobileTab.overview => Icons.monitor_heart_outlined,
IosMobileTab.tasks => Icons.layers_rounded,
IosMobileTab.account => Icons.account_circle_outlined,
IosMobileTab.settings => Icons.settings_rounded,
};
}
const _background = Color(0xFFF3EFF6);
const _surface = Colors.white;
const _surfaceSoft = Color(0xFFF7F4FB);
const _stroke = Color(0xFFE3DDEE);
const _textPrimary = Color(0xFF101113);
const _textSecondary = Color(0xFF8A8694);
const _accentStart = Color(0xFF7C88F8);
const _accentEnd = Color(0xFF6757EF);
const _accentSoft = Color(0xFFD9D5FA);
const _blueSoft = Color(0xFFDCE4F1);
const _blueLine = Color(0xFF6285A6);
const _greenSoft = Color(0xFFDCEFE2);
const _greenLine = Color(0xFF62C56A);
const _orangeSoft = Color(0xFFF5E7D9);
const _orangeLine = Color(0xFFE1913E);
class IosMobileShell extends StatefulWidget {
const IosMobileShell({super.key, required this.controller});
final AppController controller;
@override
State<IosMobileShell> createState() => _IosMobileShellState();
}
class _IosMobileShellState extends State<IosMobileShell> {
IosMobileTab _tab = IosMobileTab.home;
String _taskTab = 'Running';
bool _deviceInfoExpanded = false;
late final TextEditingController _accountBaseUrlController;
late final TextEditingController _accountUsernameController;
final TextEditingController _accountPasswordController =
TextEditingController();
@override
void initState() {
super.initState();
final settings = widget.controller.settings;
_accountBaseUrlController = TextEditingController(
text: settings.accountBaseUrl,
);
_accountUsernameController = TextEditingController(
text: settings.accountUsername,
);
}
@override
void dispose() {
_accountBaseUrlController.dispose();
_accountUsernameController.dispose();
_accountPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _background,
body: Stack(
children: [
const Positioned(
top: 100,
left: -80,
child: _GlowOrb(size: 220, color: Color(0x1A8C89FF)),
),
const Positioned(
right: -90,
bottom: 220,
child: _GlowOrb(size: 260, color: Color(0x143AB08F)),
),
SafeArea(
child: Column(
children: [
Expanded(
child: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
return _MobilePageFrame(
child: switch (_tab) {
IosMobileTab.home => _buildHomePage(),
IosMobileTab.overview => _buildOverviewPage(),
IosMobileTab.tasks => _buildTasksPage(),
IosMobileTab.account => _buildAccountPage(),
IosMobileTab.settings => _buildSettingsPage(),
},
);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 18),
child: _BottomPillNav(
currentTab: _tab,
onChanged: (tab) => setState(() => _tab = tab),
),
),
],
),
),
],
),
);
}
Widget _buildHomePage() {
final controller = widget.controller;
final connection = controller.connection;
final title = connection.remoteAddress ?? 'xworkmate.svc.plus';
final hero = _HeroCardData(
badge: connection.status == RuntimeConnectionStatus.connected
? '会话已就绪'
: '等待接入',
badgeColor: connection.status == RuntimeConnectionStatus.connected
? _blueLine
: _textSecondary,
icon: Icons.forum_outlined,
iconTint: _blueLine,
iconBackground: _blueSoft,
title: title,
subtitle: connection.status == RuntimeConnectionStatus.connected
? controller.currentSessionKey
: 'Connect OpenClaw gateway to start',
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MobileHeader(
breadcrumb: const ['首页'],
title: title,
secondaryIcon: Icons.list_rounded,
onPrimaryPressed: _showConnectSheet,
onSecondaryPressed: () =>
setState(() => _tab = IosMobileTab.settings),
),
const SizedBox(height: 22),
_HeroCard(data: hero),
const SizedBox(height: 18),
GridView.count(
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
shrinkWrap: true,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 0.76,
children: [
_ShortcutCard(
icon: Icons.chat_bubble_outline_rounded,
iconColor: _blueLine,
iconBackground: _blueSoft,
title: '进入对话',
subtitle: '继续当前会话',
onTap: _openChatSheet,
),
_ShortcutCard(
icon: Icons.monitor_heart_outlined,
iconColor: const Color(0xFF5CC9B7),
iconBackground: const Color(0xFFDDF3EF),
title: '状态总览',
subtitle: '查看监控和使用状态',
onTap: () => setState(() => _tab = IosMobileTab.overview),
),
_ShortcutCard(
icon: Icons.layers_outlined,
iconColor: const Color(0xFF6B5CF2),
iconBackground: _accentSoft,
title: '任务查看',
subtitle: '查看 queue 与历史',
onTap: () => setState(() => _tab = IosMobileTab.tasks),
),
_ShortcutCard(
icon: Icons.account_circle_outlined,
iconColor: _orangeLine,
iconBackground: _orangeSoft,
title: '账号登录',
subtitle: '统一账户入口',
onTap: () => setState(() => _tab = IosMobileTab.account),
),
],
),
const SizedBox(height: 22),
const _SectionTitle('当前状态'),
const SizedBox(height: 14),
_StatusCard(
title: controller.activeAgentName,
value: connection.status.label,
subtitle: connection.remoteAddress ?? 'No gateway target',
trailing: controller.currentSessionKey,
),
],
);
}
Widget _buildOverviewPage() {
final controller = widget.controller;
final connection = controller.connection;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MobileHeader(
breadcrumb: const ['首页', '总览'],
title: connection.remoteAddress ?? 'Gateway',
secondaryIcon: Icons.settings_outlined,
onPrimaryPressed: _showConnectSheet,
onSecondaryPressed: () =>
setState(() => _tab = IosMobileTab.settings),
),
const SizedBox(height: 22),
_HeroCard(
data: _HeroCardData(
badge: connection.status == RuntimeConnectionStatus.connected
? '运行状态'
: '等待接入',
badgeColor: connection.status == RuntimeConnectionStatus.connected
? _greenLine
: _textSecondary,
icon: Icons.monitor_heart_outlined,
iconTint: _greenLine,
iconBackground: _greenSoft,
title: connection.remoteAddress ?? 'No gateway target',
subtitle: controller.activeAgentName,
),
),
const SizedBox(height: 22),
const _SectionTitle('监控概览'),
const SizedBox(height: 14),
_InfoPanel(
items: [
('网关状态', connection.status.label),
('当前地址', connection.remoteAddress ?? 'Offline'),
('可用代理', '${controller.agents.length}'),
('会话数量', '${controller.sessions.length}'),
('运行任务', '${controller.tasksController.running.length}'),
],
),
const SizedBox(height: 22),
const _SectionTitle('快速操作'),
const SizedBox(height: 14),
_ActionPanel(
primaryLabel: '重新拉取状态',
secondaryLabel: '打开连接设置',
onPrimaryPressed: () async {
await controller.refreshGatewayHealth();
await controller.refreshAgents();
await controller.refreshSessions();
},
onSecondaryPressed: _showConnectSheet,
),
],
);
}
Widget _buildTasksPage() {
final controller = widget.controller;
final items = controller.taskItemsForTab(_taskTab);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MobileHeader(
breadcrumb: const ['首页', '任务'],
title: '任务',
secondaryIcon: Icons.refresh_rounded,
onPrimaryPressed: _openChatSheet,
onSecondaryPressed: controller.refreshSessions,
),
const SizedBox(height: 22),
SectionTabs(
items: const ['Queue', 'Running', 'History', 'Failed', 'Scheduled'],
value: _taskTab,
size: SectionTabsSize.small,
onChanged: (value) => setState(() => _taskTab = value),
),
const SizedBox(height: 18),
_StatGrid(
items: [
_MiniStat('Total', '${controller.tasksController.totalCount}'),
_MiniStat(
'Running',
'${controller.tasksController.running.length}',
),
_MiniStat('Failed', '${controller.tasksController.failed.length}'),
_MiniStat('Sessions', '${controller.sessions.length}'),
],
),
const SizedBox(height: 20),
if (_taskTab == 'Scheduled' && items.isEmpty)
const _MessageCard(
text: 'Scheduled 任务将在自动化管理包接入后展示,本轮只显示 Gateway 派生任务。',
)
else if (items.isEmpty)
const _MessageCard(text: '当前没有匹配的任务项。')
else
...items.map(
(task) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _TaskCard(task: task),
),
),
],
);
}
Widget _buildAccountPage() {
final controller = widget.controller;
final settings = controller.settings;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MobileHeader(
breadcrumb: const ['首页', '账号登录'],
title: '账号',
secondaryIcon: Icons.list_rounded,
onPrimaryPressed: _showConnectSheet,
onSecondaryPressed: () => setState(() => _tab = IosMobileTab.home),
),
const SizedBox(height: 18),
Center(
child: Column(
children: [
const SizedBox(height: 24),
const Icon(Icons.cloud_outlined, size: 132, color: _accentEnd),
const SizedBox(height: 24),
const Text(
'账号登录',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
const SizedBox(height: 12),
Text(
settings.accountLocalMode ? '保存账号入口信息' : '统一账户地址已配置',
style: const TextStyle(fontSize: 22, color: _textSecondary),
),
],
),
),
const SizedBox(height: 34),
const _FieldLabel('服务地址'),
const SizedBox(height: 12),
_RoundedTextField(
controller: _accountBaseUrlController,
icon: Icons.dns_outlined,
onSubmitted: (value) =>
controller.saveSettings(settings.copyWith(accountBaseUrl: value)),
),
const SizedBox(height: 22),
const _FieldLabel('邮箱或账号'),
const SizedBox(height: 12),
_RoundedTextField(
controller: _accountUsernameController,
icon: Icons.person_outline_rounded,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(accountUsername: value),
),
),
const SizedBox(height: 22),
const _FieldLabel('密码'),
const SizedBox(height: 12),
TextField(
controller: _accountPasswordController,
obscureText: true,
decoration: _roundedInputDecoration(
hintText: '',
icon: Icons.lock_outline_rounded,
),
),
const SizedBox(height: 26),
_PrimaryWideButton(
label: settings.accountLocalMode ? '保存本地入口' : '登录',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
FocusScope.of(context).unfocus();
await controller.saveSettings(
settings.copyWith(
accountBaseUrl: _accountBaseUrlController.text.trim(),
accountUsername: _accountUsernameController.text.trim(),
accountLocalMode: true,
),
);
if (!context.mounted) {
return;
}
messenger.showSnackBar(const SnackBar(content: Text('账号入口配置已保存。')));
},
),
],
);
}
Widget _buildSettingsPage() {
final controller = widget.controller;
final settings = controller.settings;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MobileHeader(
breadcrumb: const ['首页', '设置'],
title: '设置',
secondaryIcon: Icons.close_rounded,
onPrimaryPressed: _showConnectSheet,
onSecondaryPressed: () => setState(() => _tab = IosMobileTab.home),
),
const SizedBox(height: 18),
_HeroCard(
data: _HeroCardData(
badge: '偏好、连接与设备能力',
badgeColor: _textSecondary,
icon: Icons.settings_outlined,
iconTint: _accentEnd,
iconBackground: _accentSoft,
title: connectionTitle(controller),
subtitle:
controller.connection.remoteAddress ?? 'xworkmate.svc.plus',
),
compact: true,
),
const SizedBox(height: 22),
const _SectionTitle('连接与网关'),
const SizedBox(height: 14),
_GroupCard(
children: [
_GroupedRow(
title: 'Gateway',
subtitle:
controller.connection.remoteAddress ?? settings.gateway.host,
leadingDotColor:
controller.connection.status ==
RuntimeConnectionStatus.connected
? Colors.green
: _textSecondary,
onTap: _showConnectSheet,
),
_DividerLine(),
_GroupedRow(
title: 'Selected Agent',
subtitle: controller.activeAgentName,
onTap: _openAgentPicker,
),
],
),
const SizedBox(height: 22),
const _SectionTitle('模型与模块 Provider'),
const SizedBox(height: 14),
_GroupCard(
children: [
_GroupedRow(
title: 'Ollama Local',
subtitle: settings.ollamaLocal.endpoint,
onTap: () => _openSettingsEditor(
title: 'Ollama Local',
child: _MobileSettingsEditor(
title: 'Ollama Local',
fields: [
_EditorFieldData(
label: 'Endpoint',
initialValue: settings.ollamaLocal.endpoint,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
ollamaLocal: settings.ollamaLocal.copyWith(
endpoint: value,
),
),
),
),
_EditorFieldData(
label: 'Default Model',
initialValue: settings.ollamaLocal.defaultModel,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
ollamaLocal: settings.ollamaLocal.copyWith(
defaultModel: value,
),
),
),
),
],
footer: OutlinedButton(
onPressed: () =>
controller.testOllamaConnection(cloud: false),
child: Text(
'Test · ${controller.settingsController.ollamaStatus}',
),
),
),
),
),
_DividerLine(),
_GroupedRow(
title: 'Ollama Cloud',
subtitle: settings.ollamaCloud.baseUrl,
onTap: () => _openSettingsEditor(
title: 'Ollama Cloud',
child: _MobileSettingsEditor(
title: 'Ollama Cloud',
fields: [
_EditorFieldData(
label: 'Base URL',
initialValue: settings.ollamaCloud.baseUrl,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
ollamaCloud: settings.ollamaCloud.copyWith(
baseUrl: value,
),
),
),
),
_EditorFieldData(
label: 'Default Model',
initialValue: settings.ollamaCloud.defaultModel,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
ollamaCloud: settings.ollamaCloud.copyWith(
defaultModel: value,
),
),
),
),
],
footer: OutlinedButton(
onPressed: () =>
controller.testOllamaConnection(cloud: true),
child: Text(
'Test · ${controller.settingsController.ollamaStatus}',
),
),
),
),
),
],
),
const SizedBox(height: 22),
const _SectionTitle('Secret / Vault'),
const SizedBox(height: 14),
_GroupCard(
children: [
_GroupedRow(
title: 'Vault Server',
subtitle: settings.vault.address,
onTap: () => _openSettingsEditor(
title: 'Vault Server',
child: _MobileSettingsEditor(
title: 'Vault Server',
fields: [
_EditorFieldData(
label: 'Address',
initialValue: settings.vault.address,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
vault: settings.vault.copyWith(address: value),
),
),
),
_EditorFieldData(
label: 'Namespace',
initialValue: settings.vault.namespace,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
vault: settings.vault.copyWith(namespace: value),
),
),
),
_EditorFieldData(
label: 'Token Ref',
initialValue: settings.vault.tokenRef,
onSubmitted: (value) => controller.saveSettings(
settings.copyWith(
vault: settings.vault.copyWith(tokenRef: value),
),
),
),
],
footer: OutlinedButton(
onPressed: controller.testVaultConnection,
child: Text(
'Test · ${controller.settingsController.vaultStatus}',
),
),
),
),
),
],
),
const SizedBox(height: 22),
const _SectionTitle('AI Gateway'),
const SizedBox(height: 14),
_GroupCard(
children: [
_GroupedRow(
title: settings.aiGateway.name,
subtitle:
'${settings.aiGateway.baseUrl.isEmpty ? 'Not configured' : settings.aiGateway.baseUrl} · ${settings.aiGateway.syncState}',
onTap: () => _openSettingsEditor(
title: 'AI Gateway',
child: _AiGatewayEditor(
controller: controller,
profile: settings.aiGateway,
),
),
),
],
),
const SizedBox(height: 22),
const _SectionTitle('设备与诊断'),
const SizedBox(height: 14),
_GroupCard(
children: [
const _GroupedRow(
title: 'Features',
subtitle: 'Gateway / Chat / Tasks / Modules',
),
_DividerLine(),
_GroupedExpandableRow(
title: 'Device Info',
expanded: _deviceInfoExpanded,
onToggle: () =>
setState(() => _deviceInfoExpanded = !_deviceInfoExpanded),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
child: Column(
children: [
_DeviceInfoLine(
label: 'Device',
value: widget.controller.runtime.deviceInfo.deviceFamily,
),
_DeviceInfoLine(
label: 'Platform',
value: widget.controller.runtime.deviceInfo.platformLabel,
),
_DeviceInfoLine(
label: 'XWorkmate',
value:
'${widget.controller.runtime.packageInfo.version} (${widget.controller.runtime.packageInfo.buildNumber})',
),
],
),
),
),
],
),
],
);
}
String connectionTitle(AppController controller) {
return controller.connection.remoteAddress ?? 'xworkmate.svc.plus';
}
void _showConnectSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.94,
child: Container(
decoration: const BoxDecoration(
color: _surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(36)),
),
child: SafeArea(
top: false,
child: GatewayConnectDialog(
controller: widget.controller,
compact: true,
onDone: () => Navigator.of(context).pop(),
),
),
),
),
);
}
void _openChatSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.96,
child: _MobileChatSheet(controller: widget.controller),
),
);
}
void _openAgentPicker() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: const BoxDecoration(
color: _surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select Agent',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: const Text('Main'),
trailing: widget.controller.selectedAgentId.isEmpty
? const Icon(Icons.check_rounded)
: null,
onTap: () async {
final navigator = Navigator.of(context);
await widget.controller.selectAgent('');
navigator.pop();
},
),
...widget.controller.agents.map(
(agent) => ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(agent.name),
subtitle: Text(agent.id),
trailing: widget.controller.selectedAgentId == agent.id
? const Icon(Icons.check_rounded)
: null,
onTap: () async {
final navigator = Navigator.of(context);
await widget.controller.selectAgent(agent.id);
navigator.pop();
},
),
),
],
),
),
),
),
);
}
void _openSettingsEditor({required String title, required Widget child}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.92,
child: Container(
decoration: const BoxDecoration(
color: _surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(36)),
),
child: SafeArea(
top: false,
child: Padding(padding: const EdgeInsets.all(24), child: child),
),
),
),
);
}
}
class _MobileChatSheet extends StatefulWidget {
const _MobileChatSheet({required this.controller});
final AppController controller;
@override
State<_MobileChatSheet> createState() => _MobileChatSheetState();
}
class _MobileChatSheetState extends State<_MobileChatSheet> {
late final TextEditingController _inputController;
@override
void initState() {
super.initState();
_inputController = TextEditingController();
}
@override
void dispose() {
_inputController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: _surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(36)),
),
child: SafeArea(
top: false,
child: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final controller = widget.controller;
final connected =
controller.connection.status ==
RuntimeConnectionStatus.connected;
final messages = controller.chatMessages;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'对话',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(
controller.currentSessionKey,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: _textSecondary),
),
],
),
),
IconButton.filledTonal(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 16),
if (controller.sessions.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: controller.currentSessionKey,
decoration: const InputDecoration(labelText: 'Session'),
items: controller.sessions
.map(
(session) => DropdownMenuItem<String>(
value: session.key,
child: Text(session.label),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
controller.switchSession(value);
}
},
),
const SizedBox(height: 16),
Expanded(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _surfaceSoft,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: _stroke),
),
child: !connected
? const Center(
child: Text(
'Connect a gateway first to enter the chat.',
),
)
: messages.isEmpty
? const Center(
child: Text('当前 session 还没有消息,发送第一条指令即可开始。'),
)
: ListView.separated(
itemCount: messages.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final message = messages[index];
final isUser = message.role == 'user';
return Align(
alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
constraints: const BoxConstraints(
maxWidth: 320,
),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isUser
? const Color(0xFFE8E1FF)
: Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: _stroke),
),
child: Text(
message.text.isEmpty
? 'Pending'
: message.text,
),
),
);
},
),
),
),
const SizedBox(height: 16),
TextField(
controller: _inputController,
minLines: 2,
maxLines: 4,
decoration: _roundedInputDecoration(
hintText: connected
? 'Ask XWorkmate anything…'
: 'Connect a gateway first…',
icon: Icons.edit_outlined,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _PrimaryWideButton(
label: controller.chatController.hasPendingRun
? '停止'
: '发送',
onPressed: connected
? () async {
if (controller.chatController.hasPendingRun) {
await controller.abortRun();
return;
}
final text = _inputController.text;
await controller.sendChatMessage(text);
if (mounted && text.trim().isNotEmpty) {
_inputController.clear();
}
}
: () {},
),
),
],
),
],
),
);
},
),
),
);
}
}
class _AiGatewayEditor extends StatefulWidget {
const _AiGatewayEditor({required this.controller, required this.profile});
final AppController controller;
final AiGatewayProfile profile;
@override
State<_AiGatewayEditor> createState() => _AiGatewayEditorState();
}
class _AiGatewayEditorState extends State<_AiGatewayEditor> {
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _apiKeyRefController;
late final TextEditingController _apiKeyController;
late final TextEditingController _modelSearchController;
bool _testing = false;
bool _syncing = false;
String _testState = 'idle';
String _testMessage = '';
String _testEndpoint = '';
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.profile.name);
_urlController = TextEditingController(text: widget.profile.baseUrl);
_apiKeyRefController = TextEditingController(
text: widget.profile.apiKeyRef,
);
_apiKeyController = TextEditingController();
_modelSearchController = TextEditingController();
}
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_apiKeyRefController.dispose();
_apiKeyController.dispose();
_modelSearchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final profile = widget.controller.settings.aiGateway;
final selectedModels = profile.selectedModels.isNotEmpty
? profile.selectedModels
: profile.availableModels.take(5).toList(growable: false);
final filteredModels = _filterModels(profile.availableModels);
final feedbackTheme = _feedbackTheme(
_testMessage.isEmpty ? profile.syncState : _testState,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI Gateway',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
decoration: _roundedInputDecoration(
hintText: 'Profile Name',
icon: Icons.tag_rounded,
),
onSubmitted: (_) => _saveDraft(),
),
const SizedBox(height: 12),
TextField(
controller: _urlController,
decoration: _roundedInputDecoration(
hintText: 'Gateway URL',
icon: Icons.link_rounded,
),
onSubmitted: (_) => _saveDraft(),
),
const SizedBox(height: 12),
TextField(
controller: _apiKeyRefController,
decoration: _roundedInputDecoration(
hintText: 'API Key Ref',
icon: Icons.vpn_key_outlined,
),
onSubmitted: (_) => _saveDraft(),
),
const SizedBox(height: 12),
TextField(
controller: _apiKeyController,
obscureText: true,
decoration: _roundedInputDecoration(
hintText: 'API Key',
icon: Icons.password_rounded,
),
onSubmitted:
widget.controller.settingsController.saveAiGatewayApiKey,
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton(
onPressed: _testing || _syncing ? null : _saveDraft,
child: const Text('保存草稿'),
),
OutlinedButton(
onPressed: _testing || _syncing ? null : _testConnection,
child: Text(_testing ? '测试中...' : '测试连接'),
),
FilledButton.tonal(
onPressed: _testing || _syncing ? null : _syncModels,
child: Text(_syncing ? '同步中...' : profile.syncState),
),
],
),
const SizedBox(height: 12),
Text(
profile.syncMessage,
style: const TextStyle(color: _textSecondary),
),
if (_testMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: feedbackTheme.$1,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: feedbackTheme.$2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_testMessage,
style: TextStyle(
color: feedbackTheme.$3,
fontWeight: FontWeight.w600,
),
),
if (_testEndpoint.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_testEndpoint,
style: TextStyle(color: feedbackTheme.$3),
),
],
],
),
),
],
if (profile.availableModels.isNotEmpty) ...[
const SizedBox(height: 16),
TextField(
controller: _modelSearchController,
decoration: _roundedInputDecoration(
hintText: 'Search models',
icon: Icons.search_rounded,
suffixIcon: _modelSearchController.text.trim().isEmpty
? null
: IconButton(
onPressed: () {
_modelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.close_rounded),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
Text(
'已选 ${selectedModels.length} / ${profile.availableModels.length}',
style: const TextStyle(color: _textSecondary),
),
OutlinedButton(
onPressed: filteredModels.isEmpty
? null
: () async {
await widget.controller.updateAiGatewaySelection(
<String>{
...selectedModels,
...filteredModels,
}.toList(growable: false),
);
},
child: const Text('选择筛选结果'),
),
OutlinedButton(
onPressed: () async {
await widget.controller.updateAiGatewaySelection(
profile.availableModels.take(5).toList(growable: false),
);
},
child: const Text('恢复默认 5 个'),
),
],
),
const SizedBox(height: 12),
if (filteredModels.isEmpty)
const Text('没有匹配的模型。', style: TextStyle(color: _textSecondary))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: filteredModels
.map((modelId) {
final selected = selectedModels.contains(modelId);
return FilterChip(
label: Text(modelId),
selected: selected,
onSelected: (_) async {
final nextSelection = selected
? selectedModels
.where((item) => item != modelId)
.toList(growable: true)
: <String>[...selectedModels, modelId];
await widget.controller.updateAiGatewaySelection(
nextSelection,
);
},
);
})
.toList(growable: false),
),
],
],
);
},
);
}
AiGatewayProfile get _draftProfile {
return widget.controller.settings.aiGateway.copyWith(
name: _nameController.text.trim(),
baseUrl: _urlController.text.trim(),
apiKeyRef: _apiKeyRefController.text.trim(),
);
}
Future<void> _saveDraft() async {
final apiKey = _apiKeyController.text.trim();
if (apiKey.isNotEmpty) {
await widget.controller.settingsController.saveAiGatewayApiKey(apiKey);
}
await widget.controller.saveSettings(
widget.controller.settings.copyWith(aiGateway: _draftProfile),
);
}
Future<void> _testConnection() async {
final messenger = ScaffoldMessenger.of(context);
final apiKey = _apiKeyController.text.trim();
setState(() => _testing = true);
try {
final result = await widget.controller.settingsController
.testAiGatewayConnection(_draftProfile, apiKeyOverride: apiKey);
if (!mounted) {
return;
}
setState(() {
_testState = result.state;
_testMessage = result.message;
_testEndpoint = result.endpoint;
});
messenger.showSnackBar(SnackBar(content: Text(result.message)));
} finally {
if (mounted) {
setState(() => _testing = false);
}
}
}
Future<void> _syncModels() async {
final messenger = ScaffoldMessenger.of(context);
final apiKey = _apiKeyController.text.trim();
setState(() => _syncing = true);
try {
if (apiKey.isNotEmpty) {
await widget.controller.settingsController.saveAiGatewayApiKey(apiKey);
}
await _saveDraft();
final result = await widget.controller.syncAiGatewayCatalog(
_draftProfile,
apiKeyOverride: apiKey,
);
if (!mounted) {
return;
}
setState(() {
_testState = result.syncState;
_testMessage =
'Catalog synced · ${result.availableModels.length} model(s) ready';
_testEndpoint = _previewEndpoint(_draftProfile.baseUrl);
});
messenger.showSnackBar(SnackBar(content: Text(result.syncMessage)));
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
List<String> _filterModels(List<String> models) {
final query = _modelSearchController.text.trim().toLowerCase();
if (query.isEmpty) {
return models;
}
return models
.where((modelId) => modelId.toLowerCase().contains(query))
.toList(growable: false);
}
String _previewEndpoint(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.isEmpty) {
return '';
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return '';
}
final pathSegments = uri.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.last != 'models') {
pathSegments.add('models');
}
return uri
.replace(pathSegments: pathSegments, query: null, fragment: null)
.toString();
}
(Color, Color, Color) _feedbackTheme(String state) {
return switch (state) {
'ready' => (
const Color(0xFFDCEFE2),
const Color(0xFF62C56A),
_textPrimary,
),
'empty' => (
const Color(0xFFF5E7D9),
const Color(0xFFE1913E),
_textPrimary,
),
'error' || 'invalid' => (
const Color(0xFFF8D9DE),
const Color(0xFFD14C68),
_textPrimary,
),
_ => (_surfaceSoft, _stroke, _textPrimary),
};
}
}
class _MobileSettingsEditor extends StatelessWidget {
const _MobileSettingsEditor({
required this.title,
required this.fields,
this.footer,
});
final String title;
final List<_EditorFieldData> fields;
final Widget? footer;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
...fields.expand(
(field) => [
_RoundedTextField(
initialValue: field.initialValue,
icon: Icons.edit_outlined,
hintText: field.label,
onSubmitted: field.onSubmitted,
),
const SizedBox(height: 12),
],
),
...?footer == null ? null : <Widget>[footer!],
],
);
}
}
class _EditorFieldData {
const _EditorFieldData({
required this.label,
required this.initialValue,
required this.onSubmitted,
});
final String label;
final String initialValue;
final ValueChanged<String> onSubmitted;
}
class _MobilePageFrame extends StatelessWidget {
const _MobilePageFrame({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 0),
child: child,
);
}
}
class _MobileHeader extends StatelessWidget {
const _MobileHeader({
required this.breadcrumb,
required this.title,
required this.secondaryIcon,
required this.onPrimaryPressed,
required this.onSecondaryPressed,
});
final List<String> breadcrumb;
final String title;
final IconData secondaryIcon;
final VoidCallback onPrimaryPressed;
final VoidCallback onSecondaryPressed;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
breadcrumb.join(' '),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _textSecondary,
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
],
),
),
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [_accentStart, _accentEnd]),
borderRadius: BorderRadius.circular(999),
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: onPrimaryPressed,
icon: const Icon(
Icons.add_rounded,
color: Colors.white,
size: 32,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: onSecondaryPressed,
icon: Icon(secondaryIcon, color: Colors.white, size: 30),
),
],
),
),
],
);
}
}
class _HeroCardData {
const _HeroCardData({
required this.badge,
required this.badgeColor,
required this.icon,
required this.iconTint,
required this.iconBackground,
required this.title,
required this.subtitle,
});
final String badge;
final Color badgeColor;
final IconData icon;
final Color iconTint;
final Color iconBackground;
final String title;
final String subtitle;
}
class _HeroCard extends StatelessWidget {
const _HeroCard({required this.data, this.compact = false});
final _HeroCardData data;
final bool compact;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(compact ? 24 : 26),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(34),
border: Border.all(color: _stroke, width: 1.4),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: compact ? 78 : 100,
height: compact ? 78 : 100,
decoration: BoxDecoration(
color: data.iconBackground,
borderRadius: BorderRadius.circular(32),
),
child: Icon(
data.icon,
color: data.iconTint,
size: compact ? 38 : 46,
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.badge,
style: TextStyle(
fontSize: compact ? 18 : 20,
fontWeight: FontWeight.w700,
color: data.badgeColor,
),
),
const SizedBox(height: 10),
Text(
data.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: compact ? 26 : 32,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
const SizedBox(height: 8),
Text(
data.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18, color: _textSecondary),
),
],
),
),
],
),
);
}
}
class _ShortcutCard extends StatelessWidget {
const _ShortcutCard({
required this.icon,
required this.iconColor,
required this.iconBackground,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final Color iconColor;
final Color iconBackground;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(34),
child: Ink(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(34),
border: Border.all(color: _stroke, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: iconBackground,
borderRadius: BorderRadius.circular(26),
),
child: Icon(icon, color: iconColor, size: 38),
),
const Spacer(),
Text(
title,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: const TextStyle(fontSize: 18, color: _textSecondary),
),
],
),
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
);
}
}
class _InfoPanel extends StatelessWidget {
const _InfoPanel({required this.items});
final List<(String, String)> items;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(34),
border: Border.all(color: _stroke, width: 1.2),
),
child: Column(
children: items
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Row(
children: [
Expanded(
child: Text(
item.$1,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _textSecondary,
),
),
),
const SizedBox(width: 16),
Flexible(
child: Text(
item.$2,
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
),
],
),
),
)
.toList(),
),
);
}
}
class _ActionPanel extends StatelessWidget {
const _ActionPanel({
required this.primaryLabel,
required this.secondaryLabel,
required this.onPrimaryPressed,
required this.onSecondaryPressed,
});
final String primaryLabel;
final String secondaryLabel;
final VoidCallback onPrimaryPressed;
final VoidCallback onSecondaryPressed;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(34),
border: Border.all(color: _stroke, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PrimaryWideButton(label: primaryLabel, onPressed: onPrimaryPressed),
const SizedBox(height: 14),
TextButton(
onPressed: onSecondaryPressed,
child: Text(
secondaryLabel,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _textPrimary,
),
),
),
],
),
);
}
}
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.title,
required this.value,
required this.subtitle,
required this.trailing,
});
final String title;
final String value;
final String subtitle;
final String trailing;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(34),
border: Border.all(color: _stroke, width: 1.2),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 18, color: _textSecondary),
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
const SizedBox(height: 6),
Text(subtitle, style: const TextStyle(color: _textSecondary)),
],
),
),
const SizedBox(width: 18),
Text(
trailing,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _textPrimary,
),
),
],
),
);
}
}
class _BottomPillNav extends StatelessWidget {
const _BottomPillNav({required this.currentTab, required this.onChanged});
final IosMobileTab currentTab;
final ValueChanged<IosMobileTab> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xF8FFFFFF),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _stroke),
),
child: Row(
children: IosMobileTab.values
.map(
(tab) => Expanded(
child: GestureDetector(
onTap: () => onChanged(tab),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: currentTab == tab
? _surfaceSoft
: Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
tab.icon,
size: 32,
color: currentTab == tab ? _blueLine : _textPrimary,
),
const SizedBox(height: 4),
Text(
tab.label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: currentTab == tab ? _blueLine : _textPrimary,
),
),
],
),
),
),
),
)
.toList(),
),
);
}
}
class _GlowOrb extends StatelessWidget {
const _GlowOrb({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
class _RoundedTextField extends StatelessWidget {
const _RoundedTextField({
this.initialValue,
this.controller,
required this.icon,
this.hintText = '',
this.onSubmitted,
}) : assert(
initialValue == null || controller == null,
'Use either initialValue or controller.',
);
final String? initialValue;
final TextEditingController? controller;
final IconData icon;
final String hintText;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: initialValue,
controller: controller,
decoration: _roundedInputDecoration(hintText: hintText, icon: icon),
onFieldSubmitted: onSubmitted,
);
}
}
InputDecoration _roundedInputDecoration({
required String hintText,
required IconData icon,
Widget? suffixIcon,
}) {
return InputDecoration(
hintText: hintText,
prefixIcon: Icon(icon, color: _textSecondary, size: 30),
suffixIcon: suffixIcon,
filled: true,
fillColor: _surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: const BorderSide(color: _stroke, width: 1.5),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: const BorderSide(color: _stroke, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(32),
borderSide: const BorderSide(color: _accentEnd, width: 1.8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 22),
);
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Text(
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _textSecondary,
),
);
}
}
class _PrimaryWideButton extends StatelessWidget {
const _PrimaryWideButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _accentEnd,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 22),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)),
),
onPressed: onPressed,
child: Text(
label,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
),
);
}
}
class _GroupCard extends StatelessWidget {
const _GroupCard({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(32),
border: Border.all(color: _stroke, width: 1.2),
),
child: Column(children: children),
);
}
}
class _GroupedRow extends StatelessWidget {
const _GroupedRow({
required this.title,
required this.subtitle,
this.leadingDotColor,
this.onTap,
});
final String title;
final String subtitle;
final Color? leadingDotColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 22),
child: Row(
children: [
if (leadingDotColor != null) ...[
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: leadingDotColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 16),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _textPrimary,
),
),
),
const SizedBox(width: 16),
Flexible(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 20, color: _textSecondary),
),
),
const SizedBox(width: 12),
const Icon(
Icons.chevron_right_rounded,
size: 32,
color: _textPrimary,
),
],
),
);
if (onTap == null) {
return row;
}
return InkWell(
borderRadius: BorderRadius.circular(32),
onTap: onTap,
child: row,
);
}
}
class _GroupedExpandableRow extends StatelessWidget {
const _GroupedExpandableRow({
required this.title,
required this.expanded,
required this.onToggle,
required this.child,
});
final String title;
final bool expanded;
final VoidCallback onToggle;
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
children: [
InkWell(
borderRadius: BorderRadius.circular(32),
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 22),
child: Row(
children: [
const Expanded(
child: Text(
'Device Info',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _textPrimary,
),
),
),
Icon(
expanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 32,
color: _textPrimary,
),
],
),
),
),
if (expanded) child,
],
);
}
}
class _DividerLine extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Divider(height: 1, color: _stroke),
);
}
}
class _DeviceInfoLine extends StatelessWidget {
const _DeviceInfoLine({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _textPrimary,
),
),
),
Text(
value,
style: const TextStyle(fontSize: 20, color: _textSecondary),
),
],
),
);
}
}
class _MessageCard extends StatelessWidget {
const _MessageCard({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: _stroke),
),
child: Text(
text,
style: const TextStyle(fontSize: 18, color: _textPrimary),
),
);
}
}
class _MiniStat {
const _MiniStat(this.label, this.value);
final String label;
final String value;
}
class _StatGrid extends StatelessWidget {
const _StatGrid({required this.items});
final List<_MiniStat> items;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
children: items
.map(
(item) => Container(
width: 160,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(26),
border: Border.all(color: _stroke),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.label,
style: const TextStyle(color: _textSecondary),
),
const SizedBox(height: 8),
Text(
item.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
],
),
),
)
.toList(),
);
}
}
class _TaskCard extends StatelessWidget {
const _TaskCard({required this.task});
final DerivedTaskItem task;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: _stroke),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
task.title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: _textPrimary,
),
),
),
Text(
task.status,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: _textSecondary,
),
),
],
),
const SizedBox(height: 8),
Text(task.summary, style: const TextStyle(color: _textSecondary)),
const SizedBox(height: 10),
Text(
'${task.owner} · ${task.startedAtLabel}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textSecondary,
),
),
],
),
);
}
}