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

1709 lines
60 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:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/ui_feature_manifest.dart';
import '../../app/workspace_page_registry.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/detail_drawer.dart';
enum MobileShellTab { assistant, tasks, workspace, secrets, settings }
extension on MobileShellTab {
String get label => switch (this) {
MobileShellTab.assistant => appText('助手', 'Assistant'),
MobileShellTab.tasks => appText('任务', 'Tasks'),
MobileShellTab.workspace => appText('工作区', 'Workspace'),
MobileShellTab.secrets => appText('密钥', 'Secrets'),
MobileShellTab.settings => appText('设置', 'Settings'),
};
IconData get icon => switch (this) {
MobileShellTab.assistant => Icons.chat_bubble_outline_rounded,
MobileShellTab.tasks => Icons.layers_rounded,
MobileShellTab.workspace => Icons.grid_view_rounded,
MobileShellTab.secrets => Icons.key_rounded,
MobileShellTab.settings => Icons.settings_rounded,
};
}
const _tealSoft = Color(0xFFDDF3EF);
const _tealLine = Color(0xFF49A892);
const _violetSoft = Color(0xFFECE2FF);
const _violetLine = Color(0xFF7A61B6);
class MobileShell extends StatefulWidget {
const MobileShell({super.key, required this.controller});
final AppController controller;
@override
State<MobileShell> createState() => _MobileShellState();
}
class _MobileShellState extends State<MobileShell> {
bool _showWorkspaceHub = false;
late WorkspaceDestination _lastDestination;
@override
void initState() {
super.initState();
_lastDestination = widget.controller.destination;
widget.controller.addListener(_handleControllerChanged);
}
@override
void didUpdateWidget(covariant MobileShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller == widget.controller) {
return;
}
oldWidget.controller.removeListener(_handleControllerChanged);
_lastDestination = widget.controller.destination;
widget.controller.addListener(_handleControllerChanged);
}
@override
void dispose() {
widget.controller.removeListener(_handleControllerChanged);
super.dispose();
}
void _handleControllerChanged() {
final destination = widget.controller.destination;
if (destination == _lastDestination) {
return;
}
_lastDestination = destination;
if (_showWorkspaceHub && mounted) {
setState(() {
_showWorkspaceHub = false;
});
}
}
MobileShellTab _tabForDestination(WorkspaceDestination destination) {
return switch (destination) {
WorkspaceDestination.assistant => MobileShellTab.assistant,
WorkspaceDestination.tasks => MobileShellTab.tasks,
WorkspaceDestination.skills ||
WorkspaceDestination.nodes ||
WorkspaceDestination.agents ||
WorkspaceDestination.mcpServer ||
WorkspaceDestination.clawHub ||
WorkspaceDestination.aiGateway ||
WorkspaceDestination.account => MobileShellTab.workspace,
WorkspaceDestination.secrets => MobileShellTab.secrets,
WorkspaceDestination.settings => MobileShellTab.settings,
};
}
void _selectTab(MobileShellTab tab) {
switch (tab) {
case MobileShellTab.assistant:
setState(() => _showWorkspaceHub = false);
widget.controller.navigateTo(WorkspaceDestination.assistant);
return;
case MobileShellTab.tasks:
setState(() => _showWorkspaceHub = false);
widget.controller.navigateTo(WorkspaceDestination.tasks);
return;
case MobileShellTab.workspace:
_prefetchMobileSafeState();
setState(() => _showWorkspaceHub = true);
return;
case MobileShellTab.secrets:
setState(() => _showWorkspaceHub = false);
widget.controller.navigateTo(WorkspaceDestination.secrets);
return;
case MobileShellTab.settings:
setState(() => _showWorkspaceHub = false);
widget.controller.navigateTo(WorkspaceDestination.settings);
return;
}
}
void _openWorkspaceDestination(WorkspaceDestination destination) {
setState(() => _showWorkspaceHub = false);
widget.controller.navigateTo(destination);
}
void _openDetailSheet(DetailPanelData detail) {
widget.controller.openDetail(detail);
}
void _prefetchMobileSafeState() {
if (!widget.controller.runtime.isConnected) {
return;
}
unawaited(widget.controller.refreshGatewayHealth());
unawaited(widget.controller.refreshDevices(quiet: true));
}
void _showConnectSheet() {
widget.controller.openSettings(
detail: SettingsDetailPage.gatewayConnection,
navigationContext: SettingsNavigationContext(
rootLabel: appText('移动端', 'Mobile'),
destination: WorkspaceDestination.settings,
sectionLabel: appText('集成', 'Integrations'),
),
);
}
void _showMobileSafeSheet() {
_prefetchMobileSafeState();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return FractionallySizedBox(
heightFactor: 0.94,
child: _MobileSafeSheet(
controller: widget.controller,
onClose: () => Navigator.of(sheetContext).pop(),
onOpenGatewayConnect: () {
Navigator.of(sheetContext).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showConnectSheet();
}
});
},
),
);
},
);
}
Widget _buildCurrentPage() {
final features = widget.controller.featuresFor(UiFeaturePlatform.mobile);
if (_showWorkspaceHub && features.showsWorkspaceHub) {
return _MobileWorkspaceLauncher(
controller: widget.controller,
onOpenGatewayConnect: _showConnectSheet,
onSelectDestination: _openWorkspaceDestination,
);
}
final destination = widget.controller.destination;
return buildWorkspacePage(
destination: destination,
controller: widget.controller,
onOpenDetail: _openDetailSheet,
surface: WorkspacePageSurface.mobile,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final features = widget.controller.featuresFor(
UiFeaturePlatform.mobile,
);
final availableTabs = <MobileShellTab>[
if (features.isEnabledPath(UiFeatureKeys.navigationAssistant))
MobileShellTab.assistant,
if (features.isEnabledPath(UiFeatureKeys.navigationTasks))
MobileShellTab.tasks,
if (features.showsWorkspaceHub) MobileShellTab.workspace,
if (features.isEnabledPath(UiFeatureKeys.navigationSecrets))
MobileShellTab.secrets,
if (features.isEnabledPath(UiFeatureKeys.navigationSettings))
MobileShellTab.settings,
];
final currentTab = _showWorkspaceHub
? MobileShellTab.workspace
: _tabForDestination(widget.controller.destination);
final resolvedCurrentTab = availableTabs.contains(currentTab)
? currentTab
: (availableTabs.isEmpty ? currentTab : availableTabs.first);
final destinationKey = _showWorkspaceHub
? const ValueKey<String>('mobile-shell-workspace')
: ValueKey<String>(
'mobile-shell-${widget.controller.destination.name}',
);
final detailPanel = widget.controller.detailPanel;
final palette = context.palette;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: palette.canvas,
body: Stack(
children: [
Positioned(
top: 100,
left: -80,
child: _GlowOrb(
size: 180,
color: palette.accentMuted.withValues(
alpha: isDark ? 0.22 : 0.42,
),
),
),
Positioned(
right: -90,
bottom: 220,
child: _GlowOrb(
size: 210,
color: palette.chromeHighlight.withValues(
alpha: isDark ? 0.12 : 0.28,
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
children: [
_MobileSafeStrip(
controller: widget.controller,
onOpenSafeSheet: _showMobileSafeSheet,
onOpenGatewayConnect: _showConnectSheet,
),
const SizedBox(height: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppRadius.sidebar,
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(
alpha: isDark ? 0.14 : 0.72,
),
palette.chromeSurface.withValues(alpha: 0.94),
],
),
border: Border.all(color: palette.chromeStroke),
boxShadow: [palette.chromeShadowAmbient],
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: KeyedSubtree(
key: destinationKey,
child: _buildCurrentPage(),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(6, 12, 6, 18),
child: _BottomPillNav(
currentTab: resolvedCurrentTab,
tabs: availableTabs,
onChanged: _selectTab,
),
),
],
),
),
),
if (detailPanel != null)
Positioned.fill(
child: GestureDetector(
onTap: widget.controller.closeDetail,
child: Container(
color: Colors.black.withValues(alpha: 0.14),
),
),
),
if (detailPanel != null)
Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
heightFactor: 0.92,
child: DetailSheet(
data: detailPanel,
onClose: widget.controller.closeDetail,
),
),
),
],
),
);
},
);
}
}
class _MobileSafeStrip extends StatelessWidget {
const _MobileSafeStrip({
required this.controller,
required this.onOpenSafeSheet,
required this.onOpenGatewayConnect,
});
final AppController controller;
final VoidCallback onOpenSafeSheet;
final VoidCallback onOpenGatewayConnect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final connection = controller.connection;
final devices = controller.devices;
final hasPendingRun =
controller.hasAssistantPendingRun || controller.activeRunId != null;
final securePathLabel = _mobileSecurePathLabel(
profile: controller.settings.gateway,
connection: connection,
);
Future<void> handlePrimaryConnect() async {
if (controller.canQuickConnectGateway) {
await controller.connectSavedGateway();
await controller.refreshDevices(quiet: true);
return;
}
onOpenGatewayConnect();
}
return Container(
key: const ValueKey('mobile-safe-strip'),
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
decoration: BoxDecoration(
color: palette.surfacePrimary.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(AppRadius.dialog),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mobile-safe',
style: theme.textTheme.titleLarge?.copyWith(
color: palette.textPrimary,
),
),
const SizedBox(height: 4),
Text(
appText(
'结构化审批、配对和安全运行入口',
'Structured approvals, pairing, and run-safe controls',
),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
const SizedBox(width: 10),
_MobileFactChip(
icon: connection.status == RuntimeConnectionStatus.connected
? Icons.verified_outlined
: Icons.shield_outlined,
label: connection.status.label,
color: connection.status == RuntimeConnectionStatus.connected
? palette.success
: palette.textSecondary,
background:
connection.status == RuntimeConnectionStatus.connected
? palette.success.withValues(alpha: 0.14)
: palette.surfaceSecondary,
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MobileFactChip(
icon: Icons.lock_outline_rounded,
label: securePathLabel,
color: palette.accent,
background: palette.accentMuted,
),
_MobileFactChip(
icon: Icons.computer_outlined,
label: _mobileTargetLabel(controller),
color: palette.textPrimary,
background: palette.surfaceSecondary,
),
if (devices.pending.isNotEmpty)
_MobileFactChip(
icon: Icons.approval_outlined,
label: appText(
'${devices.pending.length} 个待审批',
'${devices.pending.length} pending',
),
color: palette.warning,
background: palette.warning.withValues(alpha: 0.12),
),
if (devices.paired.isNotEmpty)
_MobileFactChip(
icon: Icons.devices_outlined,
label: appText(
'${devices.paired.length} 台已配对',
'${devices.paired.length} paired',
),
color: palette.success,
background: palette.success.withValues(alpha: 0.12),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
key: const ValueKey('mobile-safe-open-button'),
onPressed: onOpenSafeSheet,
child: Text(appText('安全审批', 'Mobile-safe')),
),
if (controller.runtime.isConnected)
OutlinedButton(
key: const ValueKey('mobile-safe-refresh-button'),
onPressed: () async {
await controller.refreshGatewayHealth();
await controller.refreshDevices(quiet: true);
},
child: Text(appText('刷新', 'Refresh')),
)
else
FilledButton(
key: const ValueKey('mobile-safe-connect-button'),
onPressed: handlePrimaryConnect,
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText('连接 Gateway', 'Connect Gateway'),
),
),
if (hasPendingRun)
OutlinedButton(
key: const ValueKey('mobile-safe-stop-run-button'),
onPressed: controller.abortRun,
child: Text(appText('停止运行', 'Stop Run')),
),
],
),
],
),
);
}
}
class _MobileSafeSheet extends StatelessWidget {
const _MobileSafeSheet({
required this.controller,
required this.onClose,
required this.onOpenGatewayConnect,
});
final AppController controller;
final VoidCallback onClose;
final VoidCallback onOpenGatewayConnect;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Material(
color: Colors.transparent,
child: Container(
key: const ValueKey('mobile-safe-sheet'),
margin: const EdgeInsets.fromLTRB(12, 12, 12, 12),
decoration: BoxDecoration(
color: palette.surfacePrimary.withValues(alpha: 0.98),
borderRadius: BorderRadius.circular(AppRadius.dialog + 2),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: SafeArea(
top: false,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final theme = Theme.of(context);
final connection = controller.connection;
final devices = controller.devices;
final hasPendingRun =
controller.hasAssistantPendingRun ||
controller.activeRunId != null;
final securePathLabel = _mobileSecurePathLabel(
profile: controller.settings.gateway,
connection: connection,
);
final localDeviceLabel =
connection.deviceId ?? appText('未初始化', 'Not initialized');
final devicesError = controller.devicesController.error;
Future<void> handleConnect() async {
if (controller.canQuickConnectGateway) {
await controller.connectSavedGateway();
await controller.refreshDevices(quiet: true);
return;
}
onOpenGatewayConnect();
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mobile-safe',
style: theme.textTheme.headlineSmall?.copyWith(
color: palette.textPrimary,
),
),
const SizedBox(height: 6),
Text(
appText(
'移动端只提供结构化审批、配对管理和运行保护动作,不暴露全局 shell 放权。',
'Mobile only exposes structured approvals, pairing controls, and run-safe actions. No global shell approvals.',
),
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
const SizedBox(width: 12),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 16),
_MobileSafeSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('安全直连', 'Secure Direct'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MobileFactChip(
icon: Icons.lock_outline_rounded,
label: securePathLabel,
color: palette.accent,
background: palette.accentMuted,
),
_MobileFactChip(
icon: Icons.monitor_heart_outlined,
label: connection.status.label,
color:
connection.status ==
RuntimeConnectionStatus.connected
? palette.success
: palette.textSecondary,
background:
connection.status ==
RuntimeConnectionStatus.connected
? palette.success.withValues(alpha: 0.14)
: palette.surfaceSecondary,
),
],
),
const SizedBox(height: 10),
Text(
_mobileTargetLabel(controller),
style: theme.textTheme.titleSmall?.copyWith(
color: palette.textPrimary,
),
),
const SizedBox(height: 4),
Text(
appText(
'本机设备 ID$localDeviceLabel',
'Local device ID: $localDeviceLabel',
),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (controller.runtime.isConnected) ...[
OutlinedButton(
onPressed: () async {
await controller.refreshGatewayHealth();
await controller.refreshDevices(
quiet: true,
);
},
child: Text(appText('刷新', 'Refresh')),
),
OutlinedButton(
onPressed: controller.disconnectGateway,
child: Text(appText('断开', 'Disconnect')),
),
] else
FilledButton(
key: const ValueKey(
'mobile-safe-sheet-connect-button',
),
onPressed: handleConnect,
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText('打开集成设置', 'Open Integrations'),
),
),
if (hasPendingRun)
FilledButton.tonal(
onPressed: controller.abortRun,
child: Text(appText('停止运行', 'Stop Run')),
),
],
),
],
),
),
if (connection.pairingRequired) ...[
const SizedBox(height: 12),
_MobileSafetyNotice(
tone: palette.warning.withValues(alpha: 0.12),
borderColor: palette.warning.withValues(alpha: 0.32),
icon: Icons.approval_outlined,
title: appText('需要设备审批', 'Pairing Required'),
message: appText(
'当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批,然后重新连接。',
'This device already requested pairing. Approve it from an authorized operator device, then reconnect.',
),
),
] else if (connection.gatewayTokenMissing) ...[
const SizedBox(height: 12),
_MobileSafetyNotice(
tone: palette.danger.withValues(alpha: 0.1),
borderColor: palette.danger.withValues(alpha: 0.2),
icon: Icons.key_off_outlined,
title: appText('缺少共享 Token', 'Shared Token Missing'),
message: appText(
'首次连接需要共享 Token配对完成后可继续使用 device token。',
'The first connection needs a shared token; after pairing, the device token can continue.',
),
),
],
if ((devicesError ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
_MobileSafetyNotice(
tone: palette.danger.withValues(alpha: 0.1),
borderColor: palette.danger.withValues(alpha: 0.2),
icon: Icons.error_outline_rounded,
title: appText('设备列表错误', 'Devices Error'),
message: devicesError!,
),
],
const SizedBox(height: 18),
Text(
appText('待审批请求', 'Pending Requests'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (!controller.runtime.isConnected)
Text(
appText(
'连接 Gateway 后加载待审批设备与已配对设备。',
'Connect the gateway to load pending and paired devices.',
),
style: theme.textTheme.bodyMedium,
)
else if (devices.pending.isEmpty)
Text(
appText('当前没有待审批设备。', 'No pending pairing requests.'),
style: theme.textTheme.bodyMedium,
)
else
Column(
key: const ValueKey('mobile-safe-pending-section'),
children: devices.pending
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _MobilePendingApprovalCard(
controller: controller,
item: item,
),
),
)
.toList(),
),
const SizedBox(height: 18),
Text(
appText('已配对设备', 'Paired Devices'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (!controller.runtime.isConnected)
Text(
appText(
'连接 Gateway 后可查看 paired device并在移动端直接吊销。',
'Connect the gateway to view paired devices and revoke them from mobile.',
),
style: theme.textTheme.bodyMedium,
)
else if (devices.paired.isEmpty)
Text(
appText('当前没有已配对设备。', 'No paired devices yet.'),
style: theme.textTheme.bodyMedium,
)
else
Column(
key: const ValueKey('mobile-safe-paired-section'),
children: devices.paired
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _MobilePairedDeviceCard(
controller: controller,
item: item,
),
),
)
.toList(),
),
],
),
);
},
),
),
),
);
}
}
class _MobileSafeSection extends StatelessWidget {
const _MobileSafeSection({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: child,
);
}
}
class _MobileFactChip extends StatelessWidget {
const _MobileFactChip({
required this.icon,
required this.label,
required this.color,
required this.background,
});
final IconData icon;
final String label;
final Color color;
final Color background;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
),
);
}
}
class _MobileSafetyNotice extends StatelessWidget {
const _MobileSafetyNotice({
required this.tone,
required this.borderColor,
required this.icon,
required this.title,
required this.message,
});
final Color tone;
final Color borderColor;
final IconData icon;
final String title;
final String message;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: palette.textPrimary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(message, style: theme.textTheme.bodySmall),
],
),
),
],
),
);
}
}
class _MobilePendingApprovalCard extends StatelessWidget {
const _MobilePendingApprovalCard({
required this.controller,
required this.item,
});
final AppController controller;
final GatewayPendingDevice item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final metadata = <String>[
if ((item.role ?? '').isNotEmpty) 'role: ${item.role}',
if (item.scopes.isNotEmpty) item.scopes.join(', '),
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
_mobileRelativeTime(item.requestedAtMs),
if (item.isRepair) appText('修复请求', 'repair'),
];
return _MobileSafeSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
item.deviceId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
if (item.isRepair)
_MobileFactChip(
icon: Icons.build_circle_outlined,
label: appText('修复', 'Repair'),
color: palette.warning,
background: palette.warning.withValues(alpha: 0.12),
),
],
),
const SizedBox(height: 8),
Text(
metadata.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () =>
controller.approveDevicePairing(item.requestId),
child: Text(appText('批准配对', 'Approve Pairing')),
),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmMobileAction(
context,
title: appText('拒绝配对请求', 'Reject Pairing Request'),
message: appText(
'确定拒绝 ${item.label} 的配对请求吗?',
'Reject the pairing request from ${item.label}?',
),
);
if (confirmed == true) {
await controller.rejectDevicePairing(item.requestId);
}
},
child: Text(appText('拒绝配对', 'Reject Pairing')),
),
],
),
],
),
);
}
}
class _MobilePairedDeviceCard extends StatelessWidget {
const _MobilePairedDeviceCard({required this.controller, required this.item});
final AppController controller;
final GatewayPairedDevice item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final metadata = <String>[
if (item.roles.isNotEmpty) 'roles: ${item.roles.join(', ')}',
if (item.scopes.isNotEmpty) 'scopes: ${item.scopes.join(', ')}',
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
if (item.currentDevice) appText('当前设备', 'current device'),
];
return _MobileSafeSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
item.deviceId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
if (item.currentDevice)
_MobileFactChip(
icon: Icons.smartphone_outlined,
label: appText('当前设备', 'Current'),
color: palette.success,
background: palette.success.withValues(alpha: 0.12),
),
],
),
const SizedBox(height: 8),
Text(
metadata.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
if (item.tokens.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
appText(
'角色令牌:${item.tokens.first.role}',
'Role token: ${item.tokens.first.role}',
),
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 10),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmMobileAction(
context,
title: appText('吊销已配对设备', 'Revoke Paired Device'),
message: appText(
'确定吊销 ${item.label} 吗?该设备之后需要重新配对。',
'Revoke ${item.label}? The device will need pairing again.',
),
);
if (confirmed == true) {
await controller.removePairedDevice(item.deviceId);
}
},
child: Text(appText('吊销设备', 'Revoke Device')),
),
],
),
);
}
}
Future<bool?> _confirmMobileAction(
BuildContext context, {
required String title,
required String message,
}) {
return showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(appText('确认', 'Confirm')),
),
],
);
},
);
}
String _mobileSecurePathLabel({
required GatewayConnectionProfile profile,
required GatewayConnectionSnapshot connection,
}) {
final mode = connection.mode == RuntimeConnectionMode.unconfigured
? profile.mode
: connection.mode;
return switch (mode) {
RuntimeConnectionMode.local => appText('Loopback WS', 'Loopback WS'),
RuntimeConnectionMode.remote =>
profile.tls
? appText('Secure Direct TLS', 'Secure Direct TLS')
: appText('Remote Non-TLS', 'Remote Non-TLS'),
RuntimeConnectionMode.unconfigured => appText(
'Gateway 未配置',
'Gateway Not Configured',
),
};
}
String _mobileTargetLabel(AppController controller) {
final connection = controller.connection;
if ((connection.remoteAddress ?? '').isNotEmpty) {
return connection.remoteAddress!;
}
final profile = controller.settings.gateway;
final host = profile.host.trim();
if (host.isNotEmpty && profile.port > 0) {
return '$host:${profile.port}';
}
return appText('未连接目标', 'No target');
}
String _mobileRelativeTime(int? timestampMs) {
if (timestampMs == null || timestampMs <= 0) {
return appText('刚刚', 'just now');
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(timestampMs),
);
if (delta.inMinutes < 1) {
return appText('刚刚', 'just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}
class _MobileWorkspaceLauncher extends StatelessWidget {
const _MobileWorkspaceLauncher({
required this.controller,
required this.onOpenGatewayConnect,
required this.onSelectDestination,
});
final AppController controller;
final VoidCallback onOpenGatewayConnect;
final ValueChanged<WorkspaceDestination> onSelectDestination;
@override
Widget build(BuildContext context) {
final connection = controller.connection;
final palette = context.palette;
final features = controller.featuresFor(UiFeaturePlatform.mobile);
final entries =
<_WorkspaceEntry>[
_WorkspaceEntry(
destination: WorkspaceDestination.skills,
subtitle: appText('技能包与依赖状态', 'Packages and dependency status'),
iconColor: palette.accent,
iconBackground: palette.accentMuted,
),
_WorkspaceEntry(
destination: WorkspaceDestination.nodes,
subtitle: appText('边缘节点与实例', 'Edge nodes and instances'),
iconColor: _tealLine,
iconBackground: _tealSoft,
),
_WorkspaceEntry(
destination: WorkspaceDestination.agents,
subtitle: appText('代理运行态与配置', 'Agent state and configuration'),
iconColor: palette.warning,
iconBackground: palette.warning.withValues(alpha: 0.12),
),
_WorkspaceEntry(
destination: WorkspaceDestination.mcpServer,
subtitle: appText('MCP 连接与工具注册', 'MCP endpoints and tools'),
iconColor: palette.accent,
iconBackground: palette.accentMuted,
),
_WorkspaceEntry(
destination: WorkspaceDestination.clawHub,
subtitle: appText('技能与模板市场', 'Marketplace and templates'),
iconColor: _violetLine,
iconBackground: _violetSoft,
),
_WorkspaceEntry(
destination: WorkspaceDestination.aiGateway,
subtitle: appText('模型与代理网关', 'Models and agent gateway'),
iconColor: palette.accent,
iconBackground: palette.accentMuted,
),
_WorkspaceEntry(
destination: WorkspaceDestination.account,
subtitle: appText(
'身份、工作区与会话',
'Identity, workspace and sessions',
),
iconColor: palette.success,
iconBackground: palette.success.withValues(alpha: 0.12),
),
]
.where(
(entry) =>
features.allowedDestinations.contains(entry.destination),
)
.toList(growable: false);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_LauncherHeader(
title: appText('工作区', 'Workspace'),
subtitle: appText(
'Android 与 iOS 统一移动入口,集中访问全部核心模块。',
'Shared mobile entry for Android and iOS with access to all core modules.',
),
primaryLabel: connection.status == RuntimeConnectionStatus.connected
? appText('查看连接', 'Connection')
: appText('连接 Gateway', 'Connect Gateway'),
secondaryLabel: appText('返回助手', 'Open Assistant'),
onPrimaryPressed: onOpenGatewayConnect,
onSecondaryPressed: () =>
onSelectDestination(WorkspaceDestination.assistant),
),
const SizedBox(height: 18),
_WorkspaceHero(
connection: connection,
activeAgentName: controller.activeAgentName,
sessionCount: controller.sessions.length,
runningTaskCount: controller.tasksController.running.length,
),
const SizedBox(height: 18),
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth >= 760 ? 2 : 1;
final width = columns == 2
? (constraints.maxWidth - 16) / 2
: constraints.maxWidth;
return Wrap(
spacing: 16,
runSpacing: 16,
children: entries
.map(
(entry) => SizedBox(
width: width,
child: _WorkspaceShortcutCard(
entry: entry,
onTap: () => onSelectDestination(entry.destination),
),
),
)
.toList(),
);
},
),
],
),
);
}
}
class _WorkspaceEntry {
const _WorkspaceEntry({
required this.destination,
required this.subtitle,
required this.iconColor,
required this.iconBackground,
});
final WorkspaceDestination destination;
final String subtitle;
final Color iconColor;
final Color iconBackground;
}
class _LauncherHeader extends StatelessWidget {
const _LauncherHeader({
required this.title,
required this.subtitle,
required this.primaryLabel,
required this.secondaryLabel,
required this.onPrimaryPressed,
required this.onSecondaryPressed,
});
final String title;
final String subtitle;
final String primaryLabel;
final String secondaryLabel;
final VoidCallback onPrimaryPressed;
final VoidCallback onSecondaryPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodyLarge?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_GradientActionButton(
label: primaryLabel,
onPressed: onPrimaryPressed,
),
OutlinedButton.icon(
onPressed: onSecondaryPressed,
icon: const Icon(Icons.arrow_outward_rounded),
label: Text(secondaryLabel),
),
],
),
],
);
}
}
class _WorkspaceHero extends StatelessWidget {
const _WorkspaceHero({
required this.connection,
required this.activeAgentName,
required this.sessionCount,
required this.runningTaskCount,
});
final GatewayConnectionSnapshot connection;
final String activeAgentName;
final int sessionCount;
final int runningTaskCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final statusLabel = connection.status == RuntimeConnectionStatus.connected
? appText('会话已就绪', 'Session Ready')
: appText('等待接入', 'Awaiting Connection');
final statusColor = connection.status == RuntimeConnectionStatus.connected
? palette.success
: palette.textSecondary;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.86),
palette.surfacePrimary.withValues(alpha: 0.94),
],
),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusLabel,
style: theme.textTheme.labelLarge?.copyWith(color: statusColor),
),
const SizedBox(height: 10),
Text(
connection.remoteAddress ?? 'xworkmate.svc.plus',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
),
const SizedBox(height: 8),
Text(
activeAgentName,
style: theme.textTheme.bodyLarge?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 18),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_HeroMetric(
label: appText('会话', 'Sessions'),
value: '$sessionCount',
icon: Icons.chat_bubble_outline_rounded,
),
_HeroMetric(
label: appText('运行任务', 'Running'),
value: '$runningTaskCount',
icon: Icons.play_circle_outline_rounded,
),
_HeroMetric(
label: appText('状态', 'Status'),
value: connection.status.label,
icon: Icons.monitor_heart_outlined,
),
],
),
],
),
);
}
}
class _HeroMetric extends StatelessWidget {
const _HeroMetric({
required this.label,
required this.value,
required this.icon,
});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: palette.accent),
const SizedBox(width: 8),
Text(
'$label · $value',
style: theme.textTheme.labelLarge?.copyWith(
color: palette.textPrimary,
),
),
],
),
);
}
}
class _WorkspaceShortcutCard extends StatelessWidget {
const _WorkspaceShortcutCard({required this.entry, required this.onTap});
final _WorkspaceEntry entry;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.card),
child: Ink(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.84),
palette.surfacePrimary.withValues(alpha: 0.94),
],
),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: entry.iconBackground,
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Icon(
entry.destination.icon,
color: entry.iconColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.destination.label,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
),
const SizedBox(height: 4),
Text(
entry.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
const SizedBox(width: 12),
Icon(Icons.chevron_right_rounded, color: palette.textSecondary),
],
),
),
),
);
}
}
class _GradientActionButton extends StatelessWidget {
const _GradientActionButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [palette.accent, palette.accentHover],
),
borderRadius: BorderRadius.circular(AppRadius.button),
),
child: FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
minimumSize: const Size(0, AppSizes.buttonHeightMobile),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
),
child: Text(label),
),
);
}
}
class _BottomPillNav extends StatelessWidget {
const _BottomPillNav({
required this.currentTab,
required this.tabs,
required this.onChanged,
});
final MobileShellTab currentTab;
final List<MobileShellTab> tabs;
final ValueChanged<MobileShellTab> onChanged;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: palette.surfacePrimary.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(AppRadius.dialog),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: Row(
children: tabs
.map(
(tab) => Expanded(
child: GestureDetector(
onTap: () => onChanged(tab),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: currentTab == tab
? palette.surfaceSecondary
: Colors.transparent,
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
tab.icon,
size: 20,
color: currentTab == tab
? palette.accent
: palette.textPrimary,
),
const SizedBox(height: 4),
Text(
tab.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: currentTab == tab
? palette.accent
: palette.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),
);
}
}