feat: add focused navigation favorites
This commit is contained in:
parent
2e467fa39e
commit
30360fe8ba
@ -219,6 +219,10 @@ class AppController extends ChangeNotifier {
|
||||
_settingsController.buildSecretReferences();
|
||||
List<SecretAuditEntry> get secretAuditTrail => _settingsController.auditTrail;
|
||||
List<RuntimeLogEntry> get runtimeLogs => _runtime.logs;
|
||||
List<WorkspaceDestination> get assistantNavigationDestinations =>
|
||||
normalizeAssistantNavigationDestinations(
|
||||
settings.assistantNavigationDestinations,
|
||||
);
|
||||
|
||||
List<GatewayChatMessage> get chatMessages {
|
||||
final items = List<GatewayChatMessage>.from(_chatController.messages);
|
||||
@ -627,6 +631,22 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleAssistantNavigationDestination(
|
||||
WorkspaceDestination destination,
|
||||
) async {
|
||||
if (!kAssistantNavigationDestinationCandidates.contains(destination)) {
|
||||
return;
|
||||
}
|
||||
final current = assistantNavigationDestinations;
|
||||
final next = current.contains(destination)
|
||||
? current.where((item) => item != destination).toList(growable: false)
|
||||
: <WorkspaceDestination>[...current, destination];
|
||||
await saveSettings(
|
||||
settings.copyWith(assistantNavigationDestinations: next),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> testOllamaConnection({required bool cloud}) {
|
||||
return _settingsController.testOllamaConnection(cloud: cloud);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import '../features/tasks/tasks_page.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../widgets/assistant_focus_panel.dart';
|
||||
import '../widgets/detail_drawer.dart';
|
||||
import '../widgets/pane_resize_handle.dart';
|
||||
import '../widgets/sidebar_navigation.dart';
|
||||
@ -235,6 +236,11 @@ class _AppShellState extends State<AppShell> {
|
||||
sidebarState == AppSidebarState.expanded
|
||||
? expandedSidebarWidth
|
||||
: null,
|
||||
favoriteDestinations: controller
|
||||
.assistantNavigationDestinations
|
||||
.toSet(),
|
||||
onToggleFavorite:
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
@ -329,35 +335,7 @@ class _AppShellState extends State<AppShell> {
|
||||
navigationPanelBuilder:
|
||||
widget.controller.sidebarState == AppSidebarState.hidden
|
||||
? null
|
||||
: (contentWidth) => SidebarNavigation(
|
||||
currentSection: widget.controller.destination,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: widget.controller.appLanguage,
|
||||
themeMode: widget.controller.themeMode,
|
||||
onSectionChanged: widget.controller.navigateTo,
|
||||
onToggleLanguage: widget.controller.toggleAppLanguage,
|
||||
onCycleSidebarState: widget.controller.cycleSidebarState,
|
||||
onExpandFromCollapsed: () =>
|
||||
widget.controller.setSidebarState(AppSidebarState.expanded),
|
||||
onOpenAccount: () =>
|
||||
widget.controller.navigateTo(WorkspaceDestination.account),
|
||||
onOpenThemeToggle: () => widget.controller.setThemeMode(
|
||||
widget.controller.themeMode == ThemeMode.dark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
),
|
||||
accountName:
|
||||
widget.controller.settings.accountUsername.trim().isEmpty
|
||||
? appText('本地操作员', 'Local Operator')
|
||||
: widget.controller.settings.accountUsername,
|
||||
accountSubtitle:
|
||||
widget.controller.settings.accountWorkspace.trim().isEmpty
|
||||
? appText('账号', 'Account')
|
||||
: widget.controller.settings.accountWorkspace,
|
||||
expandedWidthOverride: contentWidth,
|
||||
marginOverride: EdgeInsets.zero,
|
||||
showCollapseControl: false,
|
||||
),
|
||||
: (_) => AssistantFocusPanel(controller: widget.controller),
|
||||
showStandaloneTaskRail: false,
|
||||
unifiedPaneStartsCollapsed:
|
||||
widget.controller.sidebarState == AppSidebarState.collapsed,
|
||||
|
||||
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/app_language.dart';
|
||||
|
||||
enum WorkspaceDestination {
|
||||
enum WorkspaceDestination {
|
||||
assistant,
|
||||
tasks,
|
||||
skills,
|
||||
@ -14,7 +14,7 @@ import '../i18n/app_language.dart';
|
||||
aiGateway,
|
||||
settings,
|
||||
account,
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkspaceDestinationCopy on WorkspaceDestination {
|
||||
String get label => switch (this) {
|
||||
@ -91,6 +91,55 @@ extension WorkspaceDestinationCopy on WorkspaceDestination {
|
||||
'Identity, workspace switching, and session management.',
|
||||
),
|
||||
};
|
||||
|
||||
static WorkspaceDestination? fromJsonValue(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final item in WorkspaceDestination.values) {
|
||||
if (item.name == value.trim()) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const List<WorkspaceDestination> kAssistantNavigationDestinationDefaults =
|
||||
<WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.nodes,
|
||||
WorkspaceDestination.agents,
|
||||
WorkspaceDestination.aiGateway,
|
||||
];
|
||||
|
||||
const List<WorkspaceDestination> kAssistantNavigationDestinationCandidates =
|
||||
<WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.nodes,
|
||||
WorkspaceDestination.agents,
|
||||
WorkspaceDestination.mcpServer,
|
||||
WorkspaceDestination.clawHub,
|
||||
WorkspaceDestination.secrets,
|
||||
WorkspaceDestination.aiGateway,
|
||||
WorkspaceDestination.settings,
|
||||
];
|
||||
|
||||
List<WorkspaceDestination> normalizeAssistantNavigationDestinations(
|
||||
Iterable<WorkspaceDestination> destinations,
|
||||
) {
|
||||
final allowed = kAssistantNavigationDestinationCandidates.toSet();
|
||||
final seen = <WorkspaceDestination>{};
|
||||
final normalized = <WorkspaceDestination>[];
|
||||
for (final destination in destinations) {
|
||||
if (!allowed.contains(destination) || !seen.add(destination)) {
|
||||
continue;
|
||||
}
|
||||
normalized.add(destination);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
enum StatusTone { neutral, accent, success, warning, danger }
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
|
||||
enum RuntimeConnectionMode { unconfigured, local, remote }
|
||||
|
||||
@ -527,6 +528,7 @@ class SettingsSnapshot {
|
||||
required this.accountLocalMode,
|
||||
required this.assistantExecutionTarget,
|
||||
required this.assistantPermissionLevel,
|
||||
required this.assistantNavigationDestinations,
|
||||
});
|
||||
|
||||
final AppLanguage appLanguage;
|
||||
@ -554,6 +556,7 @@ class SettingsSnapshot {
|
||||
final bool accountLocalMode;
|
||||
final AssistantExecutionTarget assistantExecutionTarget;
|
||||
final AssistantPermissionLevel assistantPermissionLevel;
|
||||
final List<WorkspaceDestination> assistantNavigationDestinations;
|
||||
|
||||
factory SettingsSnapshot.defaults() {
|
||||
return SettingsSnapshot(
|
||||
@ -582,6 +585,7 @@ class SettingsSnapshot {
|
||||
accountLocalMode: true,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
|
||||
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
|
||||
);
|
||||
}
|
||||
|
||||
@ -611,6 +615,7 @@ class SettingsSnapshot {
|
||||
bool? accountLocalMode,
|
||||
AssistantExecutionTarget? assistantExecutionTarget,
|
||||
AssistantPermissionLevel? assistantPermissionLevel,
|
||||
List<WorkspaceDestination>? assistantNavigationDestinations,
|
||||
}) {
|
||||
return SettingsSnapshot(
|
||||
appLanguage: appLanguage ?? this.appLanguage,
|
||||
@ -640,6 +645,9 @@ class SettingsSnapshot {
|
||||
assistantExecutionTarget ?? this.assistantExecutionTarget,
|
||||
assistantPermissionLevel:
|
||||
assistantPermissionLevel ?? this.assistantPermissionLevel,
|
||||
assistantNavigationDestinations:
|
||||
assistantNavigationDestinations ??
|
||||
this.assistantNavigationDestinations,
|
||||
);
|
||||
}
|
||||
|
||||
@ -670,10 +678,26 @@ class SettingsSnapshot {
|
||||
'accountLocalMode': accountLocalMode,
|
||||
'assistantExecutionTarget': assistantExecutionTarget.name,
|
||||
'assistantPermissionLevel': assistantPermissionLevel.name,
|
||||
'assistantNavigationDestinations': assistantNavigationDestinations
|
||||
.map((item) => item.name)
|
||||
.toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
factory SettingsSnapshot.fromJson(Map<String, dynamic> json) {
|
||||
final rawAssistantNavigationDestinations =
|
||||
json['assistantNavigationDestinations'];
|
||||
final assistantNavigationDestinations =
|
||||
rawAssistantNavigationDestinations is List
|
||||
? normalizeAssistantNavigationDestinations(
|
||||
rawAssistantNavigationDestinations
|
||||
.map(
|
||||
(item) =>
|
||||
WorkspaceDestinationCopy.fromJsonValue(item?.toString()),
|
||||
)
|
||||
.whereType<WorkspaceDestination>(),
|
||||
)
|
||||
: kAssistantNavigationDestinationDefaults;
|
||||
return SettingsSnapshot(
|
||||
appLanguage: AppLanguageCopy.fromJsonValue(
|
||||
json['appLanguage'] as String?,
|
||||
@ -735,6 +759,7 @@ class SettingsSnapshot {
|
||||
assistantPermissionLevel: AssistantPermissionLevelCopy.fromJsonValue(
|
||||
json['assistantPermissionLevel'] as String?,
|
||||
),
|
||||
assistantNavigationDestinations: assistantNavigationDestinations,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
256
lib/widgets/assistant_focus_panel.dart
Normal file
256
lib/widgets/assistant_focus_panel.dart
Normal file
@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'surface_card.dart';
|
||||
|
||||
class AssistantFocusPanel extends StatelessWidget {
|
||||
const AssistantFocusPanel({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
final favorites = controller.assistantNavigationDestinations;
|
||||
final available = kAssistantNavigationDestinationCandidates
|
||||
.where((item) => !favorites.contains(item))
|
||||
.toList(growable: false);
|
||||
|
||||
return SurfaceCard(
|
||||
borderRadius: 16,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('关注入口', 'Focused navigation'),
|
||||
key: const Key('assistant-focus-panel-title'),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
appText(
|
||||
'把常看的功能菜单放到这里。左侧菜单点亮星标,也会加入这个关注面板。',
|
||||
'Pin the destinations you care about here. Starred menu items also appear in this focused panel.',
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: palette.strokeSoft),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(10, 12, 10, 10),
|
||||
children: [
|
||||
Text(
|
||||
appText('已关注', 'Following'),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (favorites.isEmpty)
|
||||
_AssistantFocusEmptyState(
|
||||
message: appText(
|
||||
'还没有关注入口。给左侧菜单点星标,或从下面添加。',
|
||||
'No focused entries yet. Star a menu item on the left or add one below.',
|
||||
),
|
||||
)
|
||||
else
|
||||
...favorites.map(
|
||||
(destination) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _AssistantFocusTile(
|
||||
destination: destination,
|
||||
selected: controller.destination == destination,
|
||||
onOpen: () => controller.navigateTo(destination),
|
||||
onToggleFavorite: () async {
|
||||
await controller.toggleAssistantNavigationDestination(
|
||||
destination,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
appText('添加入口', 'Add destinations'),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (available.isEmpty)
|
||||
_AssistantFocusEmptyState(
|
||||
message: appText(
|
||||
'候选菜单都已经加入关注入口了。',
|
||||
'All available destinations are already pinned.',
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: available
|
||||
.map(
|
||||
(destination) => ActionChip(
|
||||
key: ValueKey<String>(
|
||||
'assistant-focus-add-${destination.name}',
|
||||
),
|
||||
avatar: Icon(destination.icon, size: 16),
|
||||
label: Text(destination.label),
|
||||
onPressed: () async {
|
||||
await controller
|
||||
.toggleAssistantNavigationDestination(
|
||||
destination,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantFocusTile extends StatelessWidget {
|
||||
const _AssistantFocusTile({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
required this.onOpen,
|
||||
required this.onToggleFavorite,
|
||||
});
|
||||
|
||||
final WorkspaceDestination destination;
|
||||
final bool selected;
|
||||
final VoidCallback onOpen;
|
||||
final Future<void> Function() onToggleFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
|
||||
return Material(
|
||||
color: selected
|
||||
? palette.accentMuted.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
key: ValueKey<String>('assistant-focus-item-${destination.name}'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onOpen,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 10, 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? palette.accent : palette.strokeSoft,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
destination.icon,
|
||||
size: 18,
|
||||
color: selected ? palette.accent : palette.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
destination.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: ValueKey<String>(
|
||||
'assistant-focus-remove-${destination.name}',
|
||||
),
|
||||
tooltip: appText('取消关注', 'Remove from focused panel'),
|
||||
onPressed: () async {
|
||||
await onToggleFavorite();
|
||||
},
|
||||
icon: Icon(Icons.star_rounded, color: palette.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantFocusEmptyState extends StatelessWidget {
|
||||
const _AssistantFocusEmptyState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
this.expandedWidthOverride,
|
||||
this.marginOverride,
|
||||
this.showCollapseControl = true,
|
||||
this.favoriteDestinations = const <WorkspaceDestination>{},
|
||||
this.onToggleFavorite,
|
||||
});
|
||||
|
||||
final WorkspaceDestination currentSection;
|
||||
@ -40,6 +42,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
final double? expandedWidthOverride;
|
||||
final EdgeInsetsGeometry? marginOverride;
|
||||
final bool showCollapseControl;
|
||||
final Set<WorkspaceDestination> favoriteDestinations;
|
||||
final Future<void> Function(WorkspaceDestination section)? onToggleFavorite;
|
||||
|
||||
static const _primarySections = <WorkspaceDestination>[
|
||||
WorkspaceDestination.assistant,
|
||||
@ -111,6 +115,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.primary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
@ -120,6 +126,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
],
|
||||
@ -132,6 +140,8 @@ class SidebarNavigation extends StatelessWidget {
|
||||
currentSection: currentSection,
|
||||
collapsed: isCollapsed,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favoriteDestinations: favoriteDestinations,
|
||||
onToggleFavorite: onToggleFavorite,
|
||||
onSectionChanged: onSectionChanged,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
@ -213,6 +223,8 @@ class _SidebarSectionGroup extends StatelessWidget {
|
||||
required this.currentSection,
|
||||
required this.collapsed,
|
||||
required this.emphasis,
|
||||
required this.favoriteDestinations,
|
||||
this.onToggleFavorite,
|
||||
required this.onSectionChanged,
|
||||
});
|
||||
|
||||
@ -221,6 +233,8 @@ class _SidebarSectionGroup extends StatelessWidget {
|
||||
final WorkspaceDestination currentSection;
|
||||
final bool collapsed;
|
||||
final _SidebarItemEmphasis emphasis;
|
||||
final Set<WorkspaceDestination> favoriteDestinations;
|
||||
final Future<void> Function(WorkspaceDestination section)? onToggleFavorite;
|
||||
final ValueChanged<WorkspaceDestination> onSectionChanged;
|
||||
|
||||
@override
|
||||
@ -250,6 +264,16 @@ class _SidebarSectionGroup extends StatelessWidget {
|
||||
selected: currentSection == section,
|
||||
collapsed: collapsed,
|
||||
emphasis: emphasis,
|
||||
favorite: favoriteDestinations.contains(section),
|
||||
showFavoriteToggle:
|
||||
!collapsed &&
|
||||
onToggleFavorite != null &&
|
||||
kAssistantNavigationDestinationCandidates.contains(section),
|
||||
onToggleFavorite: onToggleFavorite == null
|
||||
? null
|
||||
: () async {
|
||||
await onToggleFavorite!(section);
|
||||
},
|
||||
onTap: () => onSectionChanged(section),
|
||||
),
|
||||
),
|
||||
@ -265,6 +289,9 @@ class _SidebarNavItem extends StatefulWidget {
|
||||
required this.selected,
|
||||
required this.collapsed,
|
||||
required this.emphasis,
|
||||
required this.favorite,
|
||||
required this.showFavoriteToggle,
|
||||
this.onToggleFavorite,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@ -272,6 +299,9 @@ class _SidebarNavItem extends StatefulWidget {
|
||||
final bool selected;
|
||||
final bool collapsed;
|
||||
final _SidebarItemEmphasis emphasis;
|
||||
final bool favorite;
|
||||
final bool showFavoriteToggle;
|
||||
final Future<void> Function()? onToggleFavorite;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@ -352,6 +382,29 @@ class _SidebarNavItemState extends State<_SidebarNavItem> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showFavoriteToggle)
|
||||
IconButton(
|
||||
key: ValueKey<String>(
|
||||
'sidebar-favorite-${widget.section.name}',
|
||||
),
|
||||
tooltip: widget.favorite
|
||||
? appText('取消关注', 'Remove from focused panel')
|
||||
: appText('加入关注', 'Add to focused panel'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
splashRadius: 16,
|
||||
onPressed: () async {
|
||||
await widget.onToggleFavorite?.call();
|
||||
},
|
||||
icon: Icon(
|
||||
widget.favorite
|
||||
? Icons.star_rounded
|
||||
: Icons.star_outline_rounded,
|
||||
size: 18,
|
||||
color: widget.favorite
|
||||
? palette.accent
|
||||
: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -494,6 +547,8 @@ class SidebarFooter extends StatelessWidget {
|
||||
selected: currentSection == WorkspaceDestination.settings,
|
||||
collapsed: false,
|
||||
emphasis: _SidebarItemEmphasis.secondary,
|
||||
favorite: false,
|
||||
showFavoriteToggle: false,
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
|
||||
62
test/runtime/app_controller_navigation_favorites_test.dart
Normal file
62
test/runtime/app_controller_navigation_favorites_test.dart
Normal file
@ -0,0 +1,62 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
|
||||
void main() {
|
||||
test('AppController toggles focused navigation destinations', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantNavigationDestinations: const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
],
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.toggleAssistantNavigationDestination(
|
||||
WorkspaceDestination.aiGateway,
|
||||
);
|
||||
expect(
|
||||
controller.assistantNavigationDestinations,
|
||||
const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.aiGateway,
|
||||
],
|
||||
);
|
||||
|
||||
await controller.toggleAssistantNavigationDestination(
|
||||
WorkspaceDestination.tasks,
|
||||
);
|
||||
expect(
|
||||
controller.assistantNavigationDestinations,
|
||||
const <WorkspaceDestination>[
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.aiGateway,
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _waitFor(
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!condition()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
throw TimeoutException('condition not met within $timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
@ -17,6 +18,11 @@ void main() {
|
||||
accountWorkspace: 'QA',
|
||||
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
|
||||
codexCliPath: '/opt/homebrew/bin/codex',
|
||||
assistantNavigationDestinations: const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.aiGateway,
|
||||
WorkspaceDestination.secrets,
|
||||
],
|
||||
gateway: GatewayConnectionProfile.defaults().copyWith(
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
@ -39,6 +45,14 @@ void main() {
|
||||
CodeAgentRuntimeMode.externalCli,
|
||||
);
|
||||
expect(loadedSnapshot.codexCliPath, '/opt/homebrew/bin/codex');
|
||||
expect(
|
||||
loadedSnapshot.assistantNavigationDestinations,
|
||||
const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.aiGateway,
|
||||
WorkspaceDestination.secrets,
|
||||
],
|
||||
);
|
||||
expect(loadedSnapshot.gateway.host, 'gateway.example.com');
|
||||
expect(loadedSnapshot.gateway.port, 9443);
|
||||
expect(secureRefs['gateway_token'], 'token-secret');
|
||||
|
||||
46
test/widgets/assistant_focus_panel_test.dart
Normal file
46
test/widgets/assistant_focus_panel_test.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/widgets/assistant_focus_panel.dart';
|
||||
|
||||
import '../test_support.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'AssistantFocusPanel renders focused and available destinations',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await createTestController(tester);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantNavigationDestinations: const <WorkspaceDestination>[
|
||||
WorkspaceDestination.tasks,
|
||||
],
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantFocusPanel(controller: controller),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-focus-panel-title')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-focus-item-tasks')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-focus-add-aiGateway')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('assistant-focus-remove-tasks')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,7 @@ void main() {
|
||||
var themeToggled = 0;
|
||||
var sidebarCycled = 0;
|
||||
var accountOpened = 0;
|
||||
var favoriteToggled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -32,6 +33,14 @@ void main() {
|
||||
onOpenThemeToggle: () => themeToggled++,
|
||||
accountName: 'Tester',
|
||||
accountSubtitle: 'Workspace',
|
||||
favoriteDestinations: const <WorkspaceDestination>{
|
||||
WorkspaceDestination.tasks,
|
||||
},
|
||||
onToggleFavorite: (value) async {
|
||||
if (value == WorkspaceDestination.tasks) {
|
||||
favoriteToggled++;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -45,6 +54,12 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, WorkspaceDestination.tasks);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-favorite-tasks')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(favoriteToggled, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('切换语言'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(languageToggled, 1);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user