feat: add focused navigation favorites

This commit is contained in:
Haitao Pan 2026-03-15 10:14:33 +08:00
parent 2e467fa39e
commit 30360fe8ba
10 changed files with 551 additions and 31 deletions

View File

@ -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);
}

View File

@ -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,

View File

@ -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 }

View File

@ -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,
);
}

View 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,
),
),
);
}
}

View File

@ -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),

View 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));
}
}

View File

@ -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');

View 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,
);
},
);
}

View File

@ -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);