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 createState() => _MobileShellState(); } class _MobileShellState extends State { 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( 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 = [ 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('mobile-shell-workspace') : ValueKey( '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 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 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 = [ 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 = [ 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 _confirmMobileAction( BuildContext context, { required String title, required String message, }) { return showDialog( 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 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 tabs; final ValueChanged 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), ); } }