xworkmate-app/lib/app/app_shell_desktop.dart
2026-05-19 15:39:23 +08:00

557 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import '../features/mobile/mobile_shell.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/pane_resize_handle.dart';
import '../widgets/sidebar_navigation.dart';
import 'app_controller.dart';
import 'app_controller_desktop_thread_binding.dart';
import 'ui_feature_manifest.dart';
import 'workspace_page_registry.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key, required this.controller});
final AppController controller;
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
static const _sidebarMinWidth = 280.0;
static const _sidebarViewportPadding = 72.0;
static const _mainContentMinWidth = 640.0;
static const _sidebarExpandedBaseWidth = 336.0;
static const _desktopDestinations = <WorkspaceDestination>[
WorkspaceDestination.assistant,
WorkspaceDestination.settings,
];
double? _sidebarExpandedWidth;
static const _mobileDestinations = [
WorkspaceDestination.assistant,
WorkspaceDestination.settings,
];
double _clampSidebarWidth(double value, double viewportWidth) {
final responsiveMax =
(viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp(
_sidebarMinWidth,
viewportWidth - _sidebarViewportPadding,
);
return value.clamp(_sidebarMinWidth, responsiveMax).toDouble();
}
double _defaultSidebarWidth(AppLanguage language, double viewportWidth) {
return _clampSidebarWidth(_sidebarExpandedBaseWidth, viewportWidth);
}
List<SidebarTaskItem> _buildSidebarTaskItems(AppController controller) {
final currentSessionKey = controller.currentSessionKey.trim();
return controller.assistantSessions
.map((session) {
final sessionKey = session.key.trim();
final preview = session.lastMessagePreview?.trim() ?? '';
final thread = controller.taskThreadForSessionInternal(sessionKey);
return SidebarTaskItem(
sessionKey: sessionKey,
title: session.label.trim().isEmpty
? appText('新对话', 'New conversation')
: session.label.trim(),
preview: preview,
updatedAtMs: session.updatedAtMs,
executionTarget: controller.assistantExecutionTargetForSession(
sessionKey,
),
isCurrent: sessionKey == currentSessionKey,
pending: controller.assistantSessionHasPendingRun(sessionKey),
lifecycleStatus: thread?.lifecycleState.status ?? '',
lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
draft: sessionKey.startsWith('draft:'),
);
})
.toList(growable: false);
}
Future<void> _createSidebarConversation(
AppController controller,
List<AssistantExecutionTarget> visibleTargets,
) async {
final sessionKey = controller.createAssistantDraftSessionKeyInternal();
final target = pickDraftThreadExecutionTargetInternal(
currentTarget: controller.currentAssistantExecutionTarget,
visibleTargets: visibleTargets,
localWorkspaceAvailable: controller.settings.workspacePath
.trim()
.isNotEmpty,
);
controller.initializeAssistantThreadContext(
sessionKey,
title: appText('新对话', 'New conversation'),
executionTarget: target,
messageViewMode: controller.currentAssistantMessageViewMode,
);
controller.navigateTo(WorkspaceDestination.assistant);
await controller.switchSession(sessionKey);
}
void _toggleSidebarVisibility(AppController controller) {
controller.setSidebarState(
controller.sidebarState == AppSidebarState.hidden
? AppSidebarState.expanded
: AppSidebarState.hidden,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final controller = widget.controller;
final palette = context.palette;
return Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: [
if ((controller.startupTaskThreadWarning ?? '')
.trim()
.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: palette.accentMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.warning),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
controller.startupTaskThreadWarning!,
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(width: 12),
TextButton(
onPressed:
controller.dismissStartupTaskThreadWarning,
child: Text(appText('关闭', 'Dismiss')),
),
],
),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final platform = Theme.of(context).platform;
final isCompactMobile =
(platform == TargetPlatform.iOS ||
platform == TargetPlatform.android) &&
constraints.maxWidth < 900;
final isMobile = constraints.maxWidth < 900;
final sidebarState = controller.sidebarState;
final showSidebar =
sidebarState != AppSidebarState.hidden;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final visibleExecutionTargets = controller
.visibleAssistantExecutionTargets(
uiFeatures.availableExecutionTargets,
);
final sidebarTaskItems = _buildSidebarTaskItems(
controller,
);
final expandedSidebarWidth = _clampSidebarWidth(
_sidebarExpandedWidth ??
_defaultSidebarWidth(
controller.appLanguage,
constraints.maxWidth,
),
constraints.maxWidth,
);
final showPinnedDetail =
controller.detailPanel != null &&
constraints.maxWidth > 1280;
final mobileDestination = controller.destination;
final availableMobileDestinations = _mobileDestinations
.where(controller.capabilities.supportsDestination)
.toList(growable: false);
final resolvedMobileDestination =
availableMobileDestinations.contains(
mobileDestination,
)
? mobileDestination
: (availableMobileDestinations.isEmpty
? mobileDestination
: availableMobileDestinations.first);
void openMobileDetail(DetailPanelData detail) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return FractionallySizedBox(
heightFactor: 0.92,
child: DetailSheet(
data: detail,
onClose: () => Navigator.of(sheetContext).pop(),
),
);
},
);
}
if (isCompactMobile) {
return MobileShell(controller: controller);
}
if (isMobile) {
return Stack(
children: [
Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
12,
12,
12,
0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Container(
color: palette.canvas.withValues(
alpha: 0.18,
),
child: _pageForDestination(
resolvedMobileDestination,
openMobileDetail,
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
12,
10,
12,
12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: NavigationBar(
selectedIndex:
availableMobileDestinations.isEmpty
? 0
: availableMobileDestinations.indexOf(
resolvedMobileDestination,
),
onDestinationSelected: (index) {
controller.navigateTo(
availableMobileDestinations[index],
);
},
destinations: availableMobileDestinations
.map(
(destination) =>
NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(),
),
),
),
],
),
const SizedBox.shrink(),
],
);
}
return Stack(
children: [
Row(
children: [
if (showSidebar)
SidebarNavigation(
currentSection: controller.destination,
sidebarState: sidebarState,
appLanguage: controller.appLanguage,
themeMode: controller.themeMode,
onSectionChanged: (destination) {
if (destination ==
WorkspaceDestination.settings) {
controller.openSettings(
tab: SettingsTab.gateway,
);
return;
}
controller.navigateTo(destination);
},
onToggleLanguage:
controller.toggleAppLanguage,
onCycleSidebarState: () =>
_toggleSidebarVisibility(controller),
onExpandFromCollapsed: () =>
_toggleSidebarVisibility(controller),
onOpenHome: controller.navigateHome,
onOpenAccount: () => controller.openSettings(
tab: SettingsTab.gateway,
),
onOpenThemeToggle: () =>
controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
accountName:
controller.settings.accountUsername
.trim()
.isEmpty
? appText('本地操作员', 'Local Operator')
: controller.settings.accountUsername,
accountSubtitle:
controller.settings.accountWorkspace
.trim()
.isEmpty
? appText('账号', 'Account')
: controller.settings.accountWorkspace,
accountWorkspaceFollowed: controller
.settings
.accountWorkspaceFollowed,
onToggleAccountWorkspaceFollowed:
controller.toggleAccountWorkspaceFollowed,
onOpenOnlineWorkspace:
controller.openOnlineWorkspace,
expandedWidthOverride:
sidebarState == AppSidebarState.expanded
? expandedSidebarWidth
: null,
marginOverride: const EdgeInsets.fromLTRB(
4,
4,
4,
0,
),
favoriteDestinations: controller
.assistantNavigationDestinations
.toSet(),
onToggleFavorite: controller
.toggleAssistantNavigationDestination,
availableDestinations: controller
.capabilities
.allowedDestinations,
currentSettingsTab: controller.settingsTab,
availableSettingsTabs:
uiFeatures.availableSettingsTabs,
onSettingsTabChanged: (tab) =>
controller.openSettings(tab: tab),
taskItems: sidebarTaskItems,
visibleExecutionTargets:
visibleExecutionTargets,
assistantSkillCount:
controller.currentAssistantSkillCount,
onRefreshTasks: controller.refreshSessions,
onCreateTask: () =>
_createSidebarConversation(
controller,
visibleExecutionTargets,
),
onReturnToAssistant: () {
controller.navigateTo(
WorkspaceDestination.assistant,
);
},
onSelectTask: (sessionKey) async {
controller.navigateTo(
WorkspaceDestination.assistant,
);
await controller.switchSession(sessionKey);
},
onArchiveTask: (sessionKey) =>
controller.saveAssistantTaskArchived(
sessionKey,
true,
),
onRenameTask: (sessionKey, title) =>
controller.saveAssistantTaskTitle(
sessionKey,
title,
),
),
if (sidebarState == AppSidebarState.expanded)
PaneResizeHandle(
axis: Axis.horizontal,
extent: 8,
onDelta: (delta) {
setState(() {
_sidebarExpandedWidth =
_clampSidebarWidth(
expandedSidebarWidth + delta,
constraints.maxWidth,
);
});
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
0,
4,
4,
0,
),
child: AnimatedPadding(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(
right: showPinnedDetail ? 336 : 0,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: palette.canvas,
),
child: _buildCurrentPage(
controller.openDetail,
),
),
),
),
),
],
),
if (controller.detailPanel != null &&
!showPinnedDetail)
Positioned.fill(
child: GestureDetector(
onTap: controller.closeDetail,
child: Container(
color: Colors.black.withValues(alpha: 0.12),
),
),
),
if (controller.detailPanel != null)
Align(
alignment: Alignment.centerRight,
child: DetailDrawer(
data: controller.detailPanel!,
onClose: controller.closeDetail,
),
),
if (!showSidebar)
Positioned(
left: 8,
top: 8,
child: _SidebarRevealRail(
onExpand: () =>
_toggleSidebarVisibility(controller),
),
),
],
);
},
),
),
],
),
),
);
},
);
}
Widget _buildCurrentPage(ValueChanged<DetailPanelData> onOpenDetail) {
final currentDestination = _resolveDesktopDestination(
widget.controller.destination,
);
return IndexedStack(
index: _desktopDestinations.indexOf(currentDestination),
children: _desktopDestinations
.map((destination) => _pageForDestination(destination, onOpenDetail))
.toList(),
);
}
WorkspaceDestination _resolveDesktopDestination(
WorkspaceDestination destination,
) {
if (_desktopDestinations.contains(destination)) {
return destination;
}
return WorkspaceDestination.assistant;
}
Widget _pageForDestination(
WorkspaceDestination destination,
ValueChanged<DetailPanelData> onOpenDetail,
) {
return buildWorkspacePage(
destination: destination,
controller: widget.controller,
onOpenDetail: onOpenDetail,
surface: WorkspacePageSurface.desktop,
);
}
}
class _SidebarRevealRail extends StatefulWidget {
const _SidebarRevealRail({required this.onExpand});
final VoidCallback onExpand;
@override
State<_SidebarRevealRail> createState() => _SidebarRevealRailState();
}
class _SidebarRevealRailState extends State<_SidebarRevealRail> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Tooltip(
message: appText('展开导航', 'Expand sidebar'),
child: GestureDetector(
onTap: widget.onExpand,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: _hovered ? 40 : 32,
height: _hovered ? 40 : 32,
decoration: BoxDecoration(
color: _hovered ? palette.surfacePrimary : palette.chromeSurface,
borderRadius: BorderRadius.circular(999),
),
child: Icon(
Icons.keyboard_double_arrow_right_rounded,
size: 18,
color: palette.textSecondary,
),
),
),
),
);
}
}