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 createState() => _AppShellState(); } class _AppShellState extends State { static const _sidebarMinWidth = 280.0; static const _sidebarViewportPadding = 72.0; static const _mainContentMinWidth = 640.0; static const _sidebarExpandedBaseWidth = 336.0; static const _desktopDestinations = [ 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 _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 _createSidebarConversation( AppController controller, List 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( 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 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 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, ), ), ), ), ); } }