xworkmate-app/lib/features/mobile/mobile_shell.dart
2026-03-20 15:39:33 +08:00

803 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import '../../app/app_controller.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 '../../widgets/detail_drawer.dart';
import '../../widgets/gateway_connect_dialog.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:
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 _showConnectSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return FractionallySizedBox(
heightFactor: 0.94,
child: GatewayConnectDialog(
controller: widget.controller,
onDone: () => Navigator.of(sheetContext).pop(),
),
);
},
);
}
Widget _buildCurrentPage() {
if (_showWorkspaceHub) {
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 currentTab = _showWorkspaceHub
? MobileShellTab.workspace
: _tabForDestination(widget.controller.destination);
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: 220,
color: palette.accentMuted.withValues(
alpha: isDark ? 0.36 : 0.6,
),
),
),
Positioned(
right: -90,
bottom: 220,
child: _GlowOrb(
size: 260,
color: palette.chromeHighlight.withValues(
alpha: isDark ? 0.16 : 0.4,
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(
alpha: isDark ? 0.16 : 0.9,
),
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: currentTab,
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 _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 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),
),
];
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.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
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(24),
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(24),
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.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
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: 14, vertical: 12),
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(16),
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(24),
child: Ink(
padding: const EdgeInsets.all(20),
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(24),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: entry.iconBackground,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
entry.destination.icon,
color: entry.iconColor,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.destination.label,
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w700,
color: palette.textPrimary,
),
),
const SizedBox(height: 6),
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(14),
),
child: FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
),
child: Text(label),
),
);
}
}
class _BottomPillNav extends StatelessWidget {
const _BottomPillNav({required this.currentTab, required this.onChanged});
final MobileShellTab currentTab;
final ValueChanged<MobileShellTab> onChanged;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: palette.surfacePrimary.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: Row(
children: MobileShellTab.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
? palette.surfaceSecondary
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
tab.icon,
size: 24,
color: currentTab == tab
? palette.accent
: palette.textPrimary,
),
const SizedBox(height: 4),
Text(
tab.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
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),
);
}
}