refactor(web): align shell and focused entry chrome

This commit is contained in:
Haitao Pan 2026-03-24 18:26:39 +08:00
parent c67db9472b
commit 37ee2729e0
4 changed files with 427 additions and 380 deletions

View File

@ -96,6 +96,12 @@ class AppController extends ChangeNotifier {
bool get hasPendingSettingsApply => _pendingSettingsApply;
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
AppLanguage get appLanguage => _settings.appLanguage;
AssistantPermissionLevel get assistantPermissionLevel =>
_settings.assistantPermissionLevel;
List<WorkspaceDestination> get assistantNavigationDestinations =>
_settings.assistantNavigationDestinations
.where(capabilities.supportsDestination)
.toList(growable: false);
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
bool get relayBusy => _relayBusy;
bool get aiGatewayBusy => _aiGatewayBusy;
@ -643,6 +649,27 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
Future<void> toggleAssistantNavigationDestination(
WorkspaceDestination destination,
) async {
if (!kAssistantNavigationDestinationCandidates.contains(destination) ||
!capabilities.supportsDestination(destination)) {
return;
}
final current = assistantNavigationDestinations;
final next = current.contains(destination)
? current.where((item) => item != destination).toList(growable: false)
: <WorkspaceDestination>[...current, destination];
_settings = _settings.copyWith(assistantNavigationDestinations: next);
if (_settingsDraftInitialized) {
_settingsDraft = settingsDraft.copyWith(
assistantNavigationDestinations: next,
);
}
notifyListeners();
await _persistSettings();
}
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) {
return;
@ -1829,9 +1856,15 @@ class AppController extends ChangeNotifier {
}
SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) {
final allowedDestinations = featuresFor(UiFeaturePlatform.web)
.allowedDestinations;
final target = featuresFor(UiFeaturePlatform.web).sanitizeExecutionTarget(
_sanitizeTarget(snapshot.assistantExecutionTarget),
);
final assistantNavigationDestinations =
normalizeAssistantNavigationDestinations(
snapshot.assistantNavigationDestinations,
).where(allowedDestinations.contains).toList(growable: false);
final normalizedSessionBaseUrl =
RemoteWebSessionRepository.normalizeBaseUrl(
snapshot.webSessionPersistence.remoteBaseUrl,
@ -1862,7 +1895,7 @@ class AppController extends ChangeNotifier {
webSessionPersistence: snapshot.webSessionPersistence.copyWith(
remoteBaseUrl: normalizedSessionBaseUrl,
),
assistantNavigationDestinations: const <WorkspaceDestination>[],
assistantNavigationDestinations: assistantNavigationDestinations,
);
}

View File

@ -1,11 +1,8 @@
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../theme/app_palette.dart';
import '../web/web_assistant_page.dart';
import '../web/web_settings_page.dart';
import '../widgets/app_brand_logo.dart';
import 'app_controller_web.dart';
class AppShell extends StatelessWidget {
@ -75,87 +72,9 @@ class AppShell extends StatelessWidget {
);
}
final palette = context.palette;
return Row(
children: [
Container(
width: 76,
margin: const EdgeInsets.fromLTRB(4, 4, 0, 4),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.94),
palette.chromeSurface.withValues(alpha: 0.92),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: palette.chromeStroke),
boxShadow: [palette.chromeShadowAmbient],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 12, 10, 10),
child: Column(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: const Center(
child: AppBrandLogo(size: 28, borderRadius: 8),
),
),
const SizedBox(height: 18),
...availableDestinations.map(
(destination) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _WebNavRailButton(
key: Key('web-shell-nav-${destination.name}'),
destination: destination,
selected: currentDestination == destination,
onTap: () =>
controller.navigateTo(destination),
),
),
),
const Spacer(),
_WebUtilityButton(
key: const Key('web-shell-language-toggle'),
tooltip: controller.appLanguage == AppLanguage.zh
? '中文'
: 'English',
icon: Icons.translate_rounded,
onTap: controller.toggleAppLanguage,
),
const SizedBox(height: 8),
_WebUtilityButton(
key: const Key('web-shell-theme-toggle'),
tooltip: _themeLabel(controller.themeMode),
icon: controller.themeMode == ThemeMode.dark
? Icons.dark_mode_rounded
: Icons.light_mode_rounded,
onTap: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
),
],
),
),
),
Expanded(
child: _buildPage(
controller,
destination: currentDestination,
),
),
],
return _buildPage(
controller,
destination: currentDestination,
);
},
),
@ -175,92 +94,3 @@ class AppShell extends StatelessWidget {
};
}
}
class _WebNavRailButton extends StatelessWidget {
const _WebNavRailButton({
super.key,
required this.destination,
required this.selected,
required this.onTap,
});
final WorkspaceDestination destination;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Tooltip(
message: destination.label,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: 52,
height: 52,
decoration: BoxDecoration(
color: selected ? palette.accentMuted : palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? palette.accent.withValues(alpha: 0.28)
: palette.strokeSoft,
),
boxShadow: selected ? [palette.chromeShadowLift] : null,
),
child: Icon(
destination.icon,
size: 22,
color: selected ? palette.accent : palette.textSecondary,
),
),
),
);
}
}
class _WebUtilityButton extends StatelessWidget {
const _WebUtilityButton({
super.key,
required this.tooltip,
required this.icon,
required this.onTap,
});
final String tooltip;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Container(
width: 52,
height: 44,
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(icon, size: 20, color: palette.textSecondary),
),
),
);
}
}
String _themeLabel(ThemeMode mode) {
return switch (mode) {
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.system => appText('跟随系统', 'System'),
ThemeMode.light => appText('浅色', 'Light'),
};
}

View File

@ -807,109 +807,359 @@ class _AssistantQuickPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
final favorites = controller.assistantNavigationDestinations
.where((item) => item == WorkspaceDestination.settings)
.toList(growable: false);
final canAddSettings =
controller.capabilities.supportsDestination(
WorkspaceDestination.settings,
) &&
!favorites.contains(WorkspaceDestination.settings);
final palette = context.palette;
return SurfaceCard(
borderRadius: 10,
tone: SurfaceCardTone.chrome,
padding: EdgeInsets.zero,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 10, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('关注入口', 'Focused navigation'),
key: const Key('assistant-focus-panel-title'),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
appText(
'添加后的入口会直接出现在最左侧侧板。这里负责管理关注项和查看摘要,需要完整页面时再单独打开。',
'Added entries appear directly in the far-left rail. Manage focused destinations and review summaries here, then open the full page only when needed.',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.35,
),
),
],
),
),
if (canAddSettings)
Tooltip(
message: appText('添加关注入口', 'Add focused destination'),
child: InkWell(
key: const Key('assistant-focus-add-menu'),
borderRadius: BorderRadius.circular(12),
onTap: () {
controller.toggleAssistantNavigationDestination(
WorkspaceDestination.settings,
);
},
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.94),
palette.chromeSurfacePressed,
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: palette.chromeStroke),
boxShadow: [palette.chromeShadowLift],
),
child: Icon(
Icons.add_rounded,
size: 18,
color: palette.textSecondary,
),
),
),
),
],
),
),
Divider(height: 1, color: palette.strokeSoft),
Expanded(
child: favorites.isEmpty
? _FocusedNavigationEmptyState(
canAddSettings: canAddSettings,
onAddSettings: () {
controller.toggleAssistantNavigationDestination(
WorkspaceDestination.settings,
);
},
)
: ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
children: [
_FocusedSettingsCard(
controller: controller,
onOpenPage: () =>
controller.openSettings(tab: SettingsTab.general),
onRemoveFavorite: () {
controller.toggleAssistantNavigationDestination(
WorkspaceDestination.settings,
);
},
),
],
),
),
],
),
);
}
}
class _FocusedNavigationEmptyState extends StatelessWidget {
const _FocusedNavigationEmptyState({
required this.canAddSettings,
required this.onAddSettings,
});
final bool canAddSettings;
final VoidCallback onAddSettings;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Text(
appText(
'还没有关注入口。给功能菜单点星标,或从右上角添加一个入口,加入最左侧侧板。',
'No focused entries yet. Star a destination or add one from the top-right menu to place it in the far-left rail.',
),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.35,
),
),
),
if (canAddSettings) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: ActionChip(
key: const ValueKey<String>('assistant-focus-add-settings'),
avatar: const Icon(Icons.tune_rounded, size: 16),
label: Text(WorkspaceDestination.settings.label),
onPressed: onAddSettings,
),
),
],
],
);
}
}
class _FocusedSettingsCard extends StatelessWidget {
const _FocusedSettingsCard({
required this.controller,
required this.onOpenPage,
required this.onRemoveFavorite,
});
final AppController controller;
final VoidCallback onOpenPage;
final VoidCallback onRemoveFavorite;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final languageLabel = controller.appLanguage == AppLanguage.zh
? appText('中文', 'Chinese')
: 'English';
final themeLabel = switch (controller.themeMode) {
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.light => appText('浅色', 'Light'),
ThemeMode.system => appText('跟随系统', 'System'),
};
return Container(
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 10, 10),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
WorkspaceDestination.settings.icon,
size: 18,
color: palette.accent,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
WorkspaceDestination.settings.label,
key: const ValueKey<String>(
'assistant-focus-active-title-settings',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
WorkspaceDestination.settings.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.3,
),
),
],
),
),
IconButton(
key: const ValueKey<String>('assistant-focus-open-page-settings'),
tooltip: appText('打开全页', 'Open full page'),
onPressed: onOpenPage,
icon: const Icon(Icons.open_in_new_rounded, size: 18),
),
IconButton(
key: const ValueKey<String>('assistant-focus-remove-settings'),
tooltip: appText('取消关注', 'Remove from focused panel'),
onPressed: onRemoveFavorite,
icon: Icon(Icons.star_rounded, color: palette.accent),
),
],
),
),
Divider(height: 1, color: palette.strokeSoft),
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Column(
children: [
_FocusedPreviewTile(
title: appText('语言', 'Language'),
subtitle: appText('当前界面语言', 'Current interface language'),
trailing: languageLabel,
),
const SizedBox(height: 8),
_FocusedPreviewTile(
title: appText('主题', 'Theme'),
subtitle: appText('当前显示模式', 'Current display mode'),
trailing: themeLabel,
),
const SizedBox(height: 8),
_FocusedPreviewTile(
title: appText('执行目标', 'Execution target'),
subtitle: appText(
'Assistant 默认运行位置',
'Default assistant execution target',
),
trailing: controller.assistantExecutionTarget.label,
),
const SizedBox(height: 8),
_FocusedPreviewTile(
title: appText('权限', 'Permissions'),
subtitle: appText(
'Assistant 默认权限级别',
'Default assistant permission level',
),
trailing: controller.assistantPermissionLevel.label,
),
],
),
),
],
),
);
}
}
class _FocusedPreviewTile extends StatelessWidget {
const _FocusedPreviewTile({
required this.title,
required this.subtitle,
required this.trailing,
});
final String title;
final String subtitle;
final String trailing;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('关注入口', 'Focused navigation'),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
const SizedBox(height: 4),
Text(
appText(
'这里放首页常用设置与运行态摘要;需要完整配置时再进入 Settings。',
'Use this rail for homepage settings and runtime summaries, then open full Settings when needed.',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.palette.textSecondary,
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.3,
),
),
const SizedBox(height: 12),
_QuickInfoCard(
icon: WorkspaceDestination.settings.icon,
title: WorkspaceDestination.settings.label,
description: WorkspaceDestination.settings.description,
trailing: IconButton(
tooltip: appText('打开全页', 'Open full page'),
onPressed: () =>
controller.openSettings(tab: SettingsTab.general),
icon: const Icon(Icons.open_in_new_rounded),
),
),
const SizedBox(height: 10),
_QuickValueCard(
title: appText('语言', 'Language'),
subtitle: appText('当前界面语言', 'Current interface language'),
value: controller.appLanguage == AppLanguage.zh ? '中文' : 'English',
),
const SizedBox(height: 10),
_QuickValueCard(
title: appText('主题', 'Theme'),
subtitle: appText('当前显示模式', 'Current display mode'),
value: switch (controller.themeMode) {
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.system => appText('跟随系统', 'System'),
ThemeMode.light => appText('浅色', 'Light'),
},
),
const SizedBox(height: 10),
_QuickValueCard(
title: appText('执行目标', 'Execution target'),
subtitle: appText('Assistant 默认运行位置', 'Assistant default target'),
value: _targetLabel(controller.assistantExecutionTarget),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: context.palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('权限', 'Permissions'),
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
appText(
'Assistant 默认权限级别',
'Assistant default permission level',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.palette.textSecondary,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: context.palette.surfaceSecondary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.palette.strokeSoft),
),
child: _CompactDropdown<AssistantPermissionLevel>(
value: permissionLevel,
items: AssistantPermissionLevel.values,
labelBuilder: (item) => item.label,
onChanged: (value) {
if (value != null) {
onPermissionChanged(value);
}
},
),
),
],
const SizedBox(height: 8),
Text(
trailing,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: palette.textPrimary,
),
),
],
@ -1709,118 +1959,6 @@ class _MetaChip extends StatelessWidget {
}
}
class _QuickInfoCard extends StatelessWidget {
const _QuickInfoCard({
required this.icon,
required this.title,
required this.description,
this.trailing = const SizedBox.shrink(),
});
final IconData icon;
final String title;
final String description;
final Widget trailing;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, size: 18, color: palette.accent),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
description,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
],
),
),
trailing,
],
),
);
}
}
class _QuickValueCard extends StatelessWidget {
const _QuickValueCard({
required this.title,
required this.subtitle,
required this.value,
});
final String title;
final String subtitle;
final String value;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
const SizedBox(height: 10),
Text(
value,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
}
class _CompactDropdown<T> extends StatelessWidget {
const _CompactDropdown({

View File

@ -21,14 +21,60 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('助手'), findsWidgets);
expect(find.byKey(const Key('web-shell-nav-assistant')), findsOneWidget);
expect(find.byKey(const Key('web-shell-nav-settings')), findsOneWidget);
expect(find.byKey(const Key('web-shell-nav-assistant')), findsNothing);
expect(find.byKey(const Key('web-shell-nav-settings')), findsNothing);
expect(find.byKey(const Key('web-shell-language-toggle')), findsNothing);
expect(find.byKey(const Key('web-shell-theme-toggle')), findsNothing);
expect(find.text('Tasks'), findsNothing);
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
expect(
find.byKey(const Key('assistant-attachment-menu-button')),
findsOneWidget,
);
expect(find.byKey(const Key('assistant-focus-panel-title')), findsNothing);
await tester.tap(find.byKey(const Key('assistant-side-pane-tab-quick')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('assistant-focus-panel-title')), findsOneWidget);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-settings')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-tasks')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-skills')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-nodes')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-secrets')),
findsNothing,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-add-aiGateway')),
findsNothing,
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-focus-add-settings')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey<String>('assistant-focus-open-page-settings')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-focus-remove-settings')),
findsOneWidget,
);
await tester.tap(find.text('连接设置'));
await tester.pumpAndSettle();