refactor: simplify sidebar to flat task-first layout
This commit is contained in:
parent
aa040bd7c4
commit
7721589719
File diff suppressed because it is too large
Load Diff
289
lib/widgets/sidebar_navigation_footer.dart
Normal file
289
lib/widgets/sidebar_navigation_footer.dart
Normal file
@ -0,0 +1,289 @@
|
||||
part of 'sidebar_navigation.dart';
|
||||
|
||||
class SidebarFooter extends StatelessWidget {
|
||||
const SidebarFooter({
|
||||
super.key,
|
||||
required this.isCollapsed,
|
||||
required this.currentSection,
|
||||
required this.appLanguage,
|
||||
required this.themeMode,
|
||||
required this.onToggleLanguage,
|
||||
required this.onOpenThemeToggle,
|
||||
required this.onOpenSettings,
|
||||
required this.showSettingsButton,
|
||||
required this.sidebarState,
|
||||
required this.onCycleSidebarState,
|
||||
required this.onOpenAccount,
|
||||
required this.showAccountButton,
|
||||
required this.accountSelected,
|
||||
required this.showCollapseControl,
|
||||
});
|
||||
|
||||
final bool isCollapsed;
|
||||
final WorkspaceDestination currentSection;
|
||||
final AppLanguage appLanguage;
|
||||
final ThemeMode themeMode;
|
||||
final VoidCallback onToggleLanguage;
|
||||
final VoidCallback onOpenThemeToggle;
|
||||
final VoidCallback onOpenSettings;
|
||||
final bool showSettingsButton;
|
||||
final AppSidebarState sidebarState;
|
||||
final VoidCallback onCycleSidebarState;
|
||||
final VoidCallback onOpenAccount;
|
||||
final bool showAccountButton;
|
||||
final bool accountSelected;
|
||||
final bool showCollapseControl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final actions = <Widget>[
|
||||
if (showSettingsButton)
|
||||
_SidebarFooterButton(
|
||||
key: const ValueKey<String>('sidebar-footer-settings'),
|
||||
icon: currentSection == WorkspaceDestination.settings
|
||||
? Icons.settings_rounded
|
||||
: Icons.settings_outlined,
|
||||
label: appText('设置', 'Settings'),
|
||||
tooltip: appText('打开设置页', 'Open settings'),
|
||||
selected: currentSection == WorkspaceDestination.settings,
|
||||
collapsed: isCollapsed,
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
if (showAccountButton)
|
||||
_SidebarFooterButton(
|
||||
key: const ValueKey<String>('sidebar-footer-account'),
|
||||
icon: accountSelected
|
||||
? Icons.account_circle_rounded
|
||||
: Icons.account_circle_outlined,
|
||||
label: appText('账户', 'Account'),
|
||||
tooltip: appText('打开账号页', 'Open account'),
|
||||
selected: accountSelected,
|
||||
collapsed: isCollapsed,
|
||||
onTap: onOpenAccount,
|
||||
),
|
||||
_SidebarFooterButton(
|
||||
key: const ValueKey<String>('sidebar-footer-language'),
|
||||
icon: Icons.translate_rounded,
|
||||
label: appText('语言', 'Language'),
|
||||
tooltip: appText('切换语言', 'Toggle language'),
|
||||
collapsed: isCollapsed,
|
||||
trailingLabel: isCollapsed ? null : _languageBadge(appLanguage),
|
||||
onTap: onToggleLanguage,
|
||||
),
|
||||
_SidebarFooterButton(
|
||||
key: const ValueKey<String>('sidebar-footer-theme'),
|
||||
icon: _themeIcon(themeMode),
|
||||
label: appText('主题', 'Theme'),
|
||||
tooltip: appText('切换主题', 'Toggle theme'),
|
||||
collapsed: isCollapsed,
|
||||
trailingLabel: isCollapsed ? null : _themeBadge(themeMode),
|
||||
onTap: onOpenThemeToggle,
|
||||
),
|
||||
if (showCollapseControl)
|
||||
_SidebarFooterButton(
|
||||
key: const ValueKey<String>('sidebar-footer-collapse'),
|
||||
icon: _sidebarStateIcon(sidebarState),
|
||||
label: _sidebarStateLabel(sidebarState),
|
||||
tooltip: _sidebarStateTooltip(sidebarState),
|
||||
collapsed: isCollapsed,
|
||||
onTap: onCycleSidebarState,
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 1,
|
||||
color: palette.chromeStroke.withValues(alpha: 0.9),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
actions[index],
|
||||
if (index != actions.length - 1) const SizedBox(height: AppSpacing.xs),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _sidebarStateIcon(AppSidebarState state) {
|
||||
return switch (state) {
|
||||
AppSidebarState.expanded => Icons.keyboard_double_arrow_left_rounded,
|
||||
AppSidebarState.collapsed => Icons.keyboard_double_arrow_right_rounded,
|
||||
AppSidebarState.hidden => Icons.keyboard_double_arrow_right_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
String _sidebarStateLabel(AppSidebarState state) {
|
||||
return switch (state) {
|
||||
AppSidebarState.expanded => appText('折叠', 'Collapse'),
|
||||
AppSidebarState.collapsed => appText('展开', 'Expand'),
|
||||
AppSidebarState.hidden => appText('展开', 'Expand'),
|
||||
};
|
||||
}
|
||||
|
||||
String _sidebarStateTooltip(AppSidebarState state) {
|
||||
return switch (state) {
|
||||
AppSidebarState.expanded => appText('收起侧边栏', 'Collapse sidebar'),
|
||||
AppSidebarState.collapsed => appText('展开侧边栏', 'Expand sidebar'),
|
||||
AppSidebarState.hidden => appText('展开侧边栏', 'Expand sidebar'),
|
||||
};
|
||||
}
|
||||
|
||||
IconData _themeIcon(ThemeMode mode) {
|
||||
return switch (mode) {
|
||||
ThemeMode.light => Icons.light_mode_rounded,
|
||||
ThemeMode.dark => Icons.dark_mode_rounded,
|
||||
ThemeMode.system => Icons.brightness_auto_rounded,
|
||||
};
|
||||
}
|
||||
|
||||
String _themeBadge(ThemeMode mode) {
|
||||
return switch (mode) {
|
||||
ThemeMode.light => appText('浅色', 'Light'),
|
||||
ThemeMode.dark => appText('深色', 'Dark'),
|
||||
ThemeMode.system => appText('跟随', 'Auto'),
|
||||
};
|
||||
}
|
||||
|
||||
String _languageBadge(AppLanguage language) {
|
||||
return switch (language) {
|
||||
AppLanguage.zh => '中',
|
||||
AppLanguage.en => 'EN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarFooterButton extends StatefulWidget {
|
||||
const _SidebarFooterButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
required this.collapsed,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.trailingLabel,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String tooltip;
|
||||
final bool collapsed;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final String? trailingLabel;
|
||||
|
||||
@override
|
||||
State<_SidebarFooterButton> createState() => _SidebarFooterButtonState();
|
||||
}
|
||||
|
||||
class _SidebarFooterButtonState extends State<_SidebarFooterButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
final active = widget.selected || _hovered;
|
||||
final background = widget.selected
|
||||
? palette.surfacePrimary
|
||||
: _hovered
|
||||
? palette.chromeSurfacePressed
|
||||
: Colors.transparent;
|
||||
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? background.withValues(alpha: 0.98) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
border: Border.all(
|
||||
color: active ? palette.strokeSoft : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||
onTap: widget.onTap,
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: widget.collapsed
|
||||
? Center(
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: AppSizes.sidebarIconSize,
|
||||
color: active
|
||||
? palette.textPrimary
|
||||
: palette.textSecondary,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: AppSizes.sidebarIconSize,
|
||||
color: active
|
||||
? palette.textPrimary
|
||||
: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: active
|
||||
? palette.textPrimary
|
||||
: palette.textSecondary,
|
||||
fontWeight: active
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.trailingLabel != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Text(
|
||||
widget.trailingLabel!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
526
lib/widgets/sidebar_navigation_task_section.dart
Normal file
526
lib/widgets/sidebar_navigation_task_section.dart
Normal file
@ -0,0 +1,526 @@
|
||||
part of 'sidebar_navigation.dart';
|
||||
|
||||
class SidebarTaskItem {
|
||||
const SidebarTaskItem({
|
||||
required this.sessionKey,
|
||||
required this.title,
|
||||
required this.preview,
|
||||
required this.updatedAtMs,
|
||||
required this.executionTarget,
|
||||
required this.isCurrent,
|
||||
required this.pending,
|
||||
this.draft = false,
|
||||
});
|
||||
|
||||
final String sessionKey;
|
||||
final String title;
|
||||
final String preview;
|
||||
final double? updatedAtMs;
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final bool isCurrent;
|
||||
final bool pending;
|
||||
final bool draft;
|
||||
}
|
||||
|
||||
class SidebarTaskSection extends StatefulWidget {
|
||||
const SidebarTaskSection({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.skillCount,
|
||||
this.onRefreshTasks,
|
||||
this.onCreateTask,
|
||||
this.onSelectTask,
|
||||
this.onArchiveTask,
|
||||
this.onRenameTask,
|
||||
});
|
||||
|
||||
final List<SidebarTaskItem> items;
|
||||
final int skillCount;
|
||||
final Future<void> Function()? onRefreshTasks;
|
||||
final Future<void> Function()? onCreateTask;
|
||||
final Future<void> Function(String sessionKey)? onSelectTask;
|
||||
final Future<void> Function(String sessionKey)? onArchiveTask;
|
||||
final Future<void> Function(String sessionKey, String title)? onRenameTask;
|
||||
|
||||
@override
|
||||
State<SidebarTaskSection> createState() => _SidebarTaskSectionState();
|
||||
}
|
||||
|
||||
class _SidebarTaskSectionState extends State<SidebarTaskSection> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Set<AssistantExecutionTarget> _expandedTargets =
|
||||
<AssistantExecutionTarget>{};
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncExpandedTargets();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SidebarTaskSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.items != widget.items) {
|
||||
_syncExpandedTargets();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
final filteredItems = _filteredItems();
|
||||
final groups = _groupedItems(filteredItems);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: TextField(
|
||||
key: const Key('workspace-sidebar-task-search'),
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_query = value.trim().toLowerCase();
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: appText('搜索任务', 'Search tasks'),
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: appText('清除搜索', 'Clear search'),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_query = '';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const Key('workspace-sidebar-new-task-button'),
|
||||
onPressed: widget.onCreateTask == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onCreateTask!();
|
||||
},
|
||||
icon: const Icon(Icons.edit_note_rounded),
|
||||
label: Text(appText('新对话', 'New conversation')),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 40),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 6),
|
||||
child: Text(
|
||||
appText('任务列表', 'Task list'),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 4),
|
||||
children: [
|
||||
for (final group in groups) ...[
|
||||
_SidebarTaskGroupHeader(
|
||||
executionTarget: group.executionTarget,
|
||||
count: group.items.length,
|
||||
expanded: _expandedTargets.contains(group.executionTarget),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_expandedTargets.contains(group.executionTarget)) {
|
||||
_expandedTargets.remove(group.executionTarget);
|
||||
} else {
|
||||
_expandedTargets.add(group.executionTarget);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_expandedTargets.contains(group.executionTarget)) ...[
|
||||
if (group.items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 8, 6),
|
||||
child: Text(
|
||||
appText('当前分组没有任务。', 'No tasks in this group.'),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final item in group.items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: _SidebarTaskTile(
|
||||
item: item,
|
||||
onTap: widget.onSelectTask == null
|
||||
? null
|
||||
: () async {
|
||||
await widget.onSelectTask!(item.sessionKey);
|
||||
},
|
||||
onArchive:
|
||||
widget.onArchiveTask == null || item.pending
|
||||
? null
|
||||
: () async {
|
||||
await widget.onArchiveTask!(item.sessionKey);
|
||||
},
|
||||
onRename: widget.onRenameTask == null
|
||||
? null
|
||||
: () async {
|
||||
final renamed = await _promptRenameTask(
|
||||
context,
|
||||
item.title,
|
||||
);
|
||||
if (!mounted || renamed == null) {
|
||||
return;
|
||||
}
|
||||
await widget.onRenameTask!(
|
||||
item.sessionKey,
|
||||
renamed,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<SidebarTaskItem> _filteredItems() {
|
||||
if (_query.isEmpty) {
|
||||
return widget.items;
|
||||
}
|
||||
return widget.items.where((item) {
|
||||
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
|
||||
.toLowerCase();
|
||||
return haystack.contains(_query);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
List<_SidebarTaskGroup> _groupedItems(List<SidebarTaskItem> items) {
|
||||
final grouped = <AssistantExecutionTarget, List<SidebarTaskItem>>{
|
||||
for (final target in AssistantExecutionTarget.values)
|
||||
target: <SidebarTaskItem>[],
|
||||
};
|
||||
for (final item in items) {
|
||||
grouped[item.executionTarget]!.add(item);
|
||||
}
|
||||
return AssistantExecutionTarget.values
|
||||
.map(
|
||||
(target) => _SidebarTaskGroup(
|
||||
executionTarget: target,
|
||||
items: grouped[target]!,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String?> _promptRenameTask(
|
||||
BuildContext context,
|
||||
String currentTitle,
|
||||
) async {
|
||||
final input = TextEditingController(text: currentTitle);
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(appText('重命名任务', 'Rename task')),
|
||||
content: TextField(
|
||||
key: const Key('workspace-sidebar-task-rename-input'),
|
||||
controller: input,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('任务名称', 'Task name'),
|
||||
hintText: appText('留空后恢复默认名称', 'Leave empty to restore default'),
|
||||
),
|
||||
onSubmitted: (value) => Navigator.of(context).pop(value.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(appText('取消', 'Cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(input.text.trim()),
|
||||
child: Text(appText('保存', 'Save')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
input.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
void _syncExpandedTargets() {
|
||||
if (_expandedTargets.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
_expandedTargets.addAll(AssistantExecutionTarget.values);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarTaskGroup {
|
||||
const _SidebarTaskGroup({
|
||||
required this.executionTarget,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final List<SidebarTaskItem> items;
|
||||
}
|
||||
|
||||
class _SidebarTaskGroupHeader extends StatelessWidget {
|
||||
const _SidebarTaskGroupHeader({
|
||||
required this.executionTarget,
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: ValueKey<String>('workspace-sidebar-task-group-${executionTarget.name}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
expanded
|
||||
? Icons.keyboard_arrow_down_rounded
|
||||
: Icons.keyboard_arrow_right_rounded,
|
||||
size: 16,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_sidebarTaskTargetIcon(executionTarget),
|
||||
size: 14,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
executionTarget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarTaskTile extends StatelessWidget {
|
||||
const _SidebarTaskTile({
|
||||
required this.item,
|
||||
this.onTap,
|
||||
this.onArchive,
|
||||
this.onRename,
|
||||
});
|
||||
|
||||
final SidebarTaskItem item;
|
||||
final Future<void> Function()? onTap;
|
||||
final Future<void> Function()? onArchive;
|
||||
final Future<void> Function()? onRename;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: item.isCurrent ? palette.surfacePrimary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
key: ValueKey<String>('workspace-sidebar-task-item-${item.sessionKey}'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () async {
|
||||
await onTap!();
|
||||
},
|
||||
onLongPress: onRename == null
|
||||
? null
|
||||
: () async {
|
||||
await onRename!();
|
||||
},
|
||||
onSecondaryTap: onRename == null
|
||||
? null
|
||||
: () async {
|
||||
await onRename!();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isCurrent ? palette.surfaceSecondary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: item.isCurrent ? palette.strokeSoft : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: item.pending
|
||||
? palette.accentMuted.withValues(alpha: 0.88)
|
||||
: palette.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
item.draft
|
||||
? Icons.edit_note_rounded
|
||||
: item.pending
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.task_alt_rounded,
|
||||
size: 15,
|
||||
color: item.pending ? palette.accent : palette.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: item.isCurrent
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (item.preview.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.preview.trim(),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_sidebarTaskUpdatedAtLabel(item.updatedAtMs),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
if (onArchive != null)
|
||||
IconButton(
|
||||
key: ValueKey<String>(
|
||||
'workspace-sidebar-task-archive-${item.sessionKey}',
|
||||
),
|
||||
tooltip: appText('归档任务', 'Archive task'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
splashRadius: 12,
|
||||
onPressed: () async {
|
||||
await onArchive!();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.archive_outlined,
|
||||
size: 18,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) {
|
||||
if (updatedAtMs == null) {
|
||||
return '';
|
||||
}
|
||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round());
|
||||
final now = DateTime.now();
|
||||
final delta = now.difference(timestamp);
|
||||
if (delta.inMinutes < 1) {
|
||||
return appText('刚刚', 'Just now');
|
||||
}
|
||||
if (delta.inHours < 1) {
|
||||
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
|
||||
}
|
||||
if (delta.inDays < 1) {
|
||||
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
|
||||
}
|
||||
if (delta.inDays < 7) {
|
||||
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
|
||||
}
|
||||
return '${timestamp.month}/${timestamp.day}';
|
||||
}
|
||||
|
||||
IconData _sidebarTaskTargetIcon(AssistantExecutionTarget target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
|
||||
AssistantExecutionTarget.local => Icons.computer_outlined,
|
||||
AssistantExecutionTarget.remote => Icons.cloud_outlined,
|
||||
};
|
||||
}
|
||||
@ -31,6 +31,9 @@ void main() {
|
||||
find.byKey(const Key('workspace-sidebar-new-task-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('自动化'), findsNothing);
|
||||
expect(find.text('MCP Hub'), findsNothing);
|
||||
expect(find.text('ClawHub'), findsNothing);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-workspace-chrome-toggle')),
|
||||
findsOneWidget,
|
||||
@ -92,49 +95,20 @@ void main() {
|
||||
expect(find.byKey(const Key('assistant-side-pane-tab-quick')), findsNothing);
|
||||
expect(find.byKey(const Key('assistant-focus-panel-title')), findsNothing);
|
||||
|
||||
await tester.tap(find.text('设置').last);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SidebarNavigation), findsOneWidget);
|
||||
await tester.ensureVisible(find.text('自动化'));
|
||||
await tester.tap(find.text('自动化').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('任务工作台'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('设置').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(
|
||||
find.byKey(const ValueKey('web-settings-search-field')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('OpenClaw Gateway'), findsWidgets);
|
||||
expect(find.text('LLM 接入点'), findsWidgets);
|
||||
expect(find.textContaining('浏览器本地存储'), findsOneWidget);
|
||||
expect(find.textContaining('Local Gateway'), findsWidgets);
|
||||
expect(find.textContaining('Remote Gateway'), findsWidgets);
|
||||
|
||||
await tester.ensureVisible(
|
||||
find.byKey(const ValueKey('web-external-acp-provider-add-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Claude'), findsNothing);
|
||||
expect(find.text('Gemini'), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('web-external-acp-provider-add-button')),
|
||||
findsOneWidget,
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('添加更多自定义配置'), findsOneWidget);
|
||||
expect(find.text('标志'), findsNothing);
|
||||
expect(find.text('Badge'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@ -52,7 +52,6 @@ void main() {
|
||||
var themeToggled = 0;
|
||||
var sidebarCycled = 0;
|
||||
var accountOpened = 0;
|
||||
var workspaceFollowToggled = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -72,152 +71,58 @@ void main() {
|
||||
onOpenThemeToggle: () => themeToggled++,
|
||||
accountName: 'Tester',
|
||||
accountSubtitle: 'Workspace',
|
||||
onToggleAccountWorkspaceFollowed: () async {
|
||||
workspaceFollowToggled++;
|
||||
},
|
||||
favoriteDestinations: const <AssistantFocusEntry>{
|
||||
AssistantFocusEntry.skills,
|
||||
},
|
||||
onToggleFavorite: (_) async {},
|
||||
onToggleAccountWorkspaceFollowed: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('工具'), findsOneWidget);
|
||||
expect(find.text('MCP Hub'), findsOneWidget);
|
||||
expect(find.text('工具'), findsNothing);
|
||||
expect(find.text('工作区'), findsNothing);
|
||||
expect(find.text('自动化'), findsNothing);
|
||||
expect(find.text('MCP Hub'), findsNothing);
|
||||
expect(find.text('ClawHub'), findsNothing);
|
||||
expect(find.text('回到 APP首页'), findsNothing);
|
||||
expect(find.text('设置'), findsOneWidget);
|
||||
expect(find.text('账户'), findsOneWidget);
|
||||
expect(find.text('语言'), findsOneWidget);
|
||||
expect(find.text('主题'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('自动化'));
|
||||
await tester.tap(find.text('自动化').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, WorkspaceDestination.tasks);
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-favorite-skills')),
|
||||
findsNothing,
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, WorkspaceDestination.settings);
|
||||
|
||||
await tester.tap(find.byTooltip('切换语言'));
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-language')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(languageToggled, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('切换深色'));
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-theme')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(themeToggled, 1);
|
||||
|
||||
await tester.tap(find.byTooltip('收起侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sidebarCycled, 1);
|
||||
|
||||
await tester.tap(find.text('Tester'));
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-account')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(accountOpened, 1);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-account-follow')),
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-collapse')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(workspaceFollowToggled, 1);
|
||||
expect(sidebarCycled, 1);
|
||||
});
|
||||
|
||||
testWidgets('SidebarNavigation toggles footer quick action favorites', (
|
||||
testWidgets('SidebarNavigation no longer expands settings sub navigation in sidebar', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final toggled = <AssistantFocusEntry>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: SidebarNavigation(
|
||||
currentSection: WorkspaceDestination.assistant,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: AppLanguage.zh,
|
||||
themeMode: ThemeMode.light,
|
||||
onSectionChanged: (_) {},
|
||||
onToggleLanguage: () {},
|
||||
onCycleSidebarState: () {},
|
||||
onExpandFromCollapsed: () {},
|
||||
onOpenHome: () {},
|
||||
onOpenAccount: () {},
|
||||
onOpenThemeToggle: () {},
|
||||
accountName: 'Tester',
|
||||
accountSubtitle: 'Workspace',
|
||||
onToggleAccountWorkspaceFollowed: () async {},
|
||||
favoriteDestinations: const <AssistantFocusEntry>{
|
||||
AssistantFocusEntry.language,
|
||||
},
|
||||
onToggleFavorite: (value) async => toggled.add(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-favorite-language')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-favorite-theme')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(toggled, const <AssistantFocusEntry>[
|
||||
AssistantFocusEntry.language,
|
||||
AssistantFocusEntry.theme,
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'SidebarNavigation shows app home shortcut copy on settings page',
|
||||
(WidgetTester tester) async {
|
||||
var selected = WorkspaceDestination.settings;
|
||||
var homeOpened = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: SidebarNavigation(
|
||||
currentSection: selected,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: AppLanguage.zh,
|
||||
themeMode: ThemeMode.light,
|
||||
onSectionChanged: (value) => selected = value,
|
||||
onToggleLanguage: () {},
|
||||
onCycleSidebarState: () {},
|
||||
onExpandFromCollapsed: () {},
|
||||
onOpenHome: () => homeOpened++,
|
||||
onOpenAccount: () {},
|
||||
onOpenThemeToggle: () {},
|
||||
accountName: 'Tester',
|
||||
accountSubtitle: 'Workspace',
|
||||
onToggleAccountWorkspaceFollowed: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('回到 APP首页'), findsOneWidget);
|
||||
expect(find.text('新对话'), findsWidgets);
|
||||
|
||||
await tester.ensureVisible(find.text('回到 APP首页'));
|
||||
await tester.tap(find.text('回到 APP首页').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(homeOpened, 1);
|
||||
expect(selected, WorkspaceDestination.settings);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('SidebarNavigation exposes settings sub navigation in sidebar', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final changedTabs = <SettingsTab>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@ -227,13 +132,6 @@ void main() {
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: AppLanguage.zh,
|
||||
themeMode: ThemeMode.light,
|
||||
currentSettingsTab: SettingsTab.general,
|
||||
availableSettingsTabs: const <SettingsTab>[
|
||||
SettingsTab.general,
|
||||
SettingsTab.workspace,
|
||||
SettingsTab.gateway,
|
||||
],
|
||||
onSettingsTabChanged: changedTabs.add,
|
||||
onSectionChanged: (_) {},
|
||||
onToggleLanguage: () {},
|
||||
onCycleSidebarState: () {},
|
||||
@ -252,23 +150,16 @@ void main() {
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-general')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-workspace')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('sidebar-settings-tab-gateway')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changedTabs, <SettingsTab>[SettingsTab.gateway]);
|
||||
});
|
||||
|
||||
testWidgets('SidebarNavigation merges task controls into the global left bar', (
|
||||
@ -321,7 +212,83 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('任务列表'), findsOneWidget);
|
||||
expect(find.text('自动化'), findsOneWidget);
|
||||
expect(find.text('自动化'), findsNothing);
|
||||
expect(find.text('MCP Hub'), findsNothing);
|
||||
expect(find.text('新的任务'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey<String>('workspace-sidebar-task-group-singleAgent'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('SidebarNavigation keeps footer pinned while task list scrolls', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1280, 900);
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final items = List<SidebarTaskItem>.generate(
|
||||
18,
|
||||
(index) => SidebarTaskItem(
|
||||
sessionKey: 'session-$index',
|
||||
title: '任务 $index',
|
||||
preview: '预览 $index',
|
||||
updatedAtMs: 1710000000000 + index.toDouble(),
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
isCurrent: index == 0,
|
||||
pending: false,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
height: 560,
|
||||
child: SidebarNavigation(
|
||||
currentSection: WorkspaceDestination.assistant,
|
||||
sidebarState: AppSidebarState.expanded,
|
||||
appLanguage: AppLanguage.zh,
|
||||
themeMode: ThemeMode.light,
|
||||
onSectionChanged: (_) {},
|
||||
onToggleLanguage: () {},
|
||||
onCycleSidebarState: () {},
|
||||
onExpandFromCollapsed: () {},
|
||||
onOpenHome: () {},
|
||||
onOpenAccount: () {},
|
||||
onOpenThemeToggle: () {},
|
||||
accountName: 'Tester',
|
||||
accountSubtitle: 'Workspace',
|
||||
onToggleAccountWorkspaceFollowed: () async {},
|
||||
taskItems: items,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final footerBefore = tester.getTopLeft(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
|
||||
);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -240));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final footerAfter = tester.getTopLeft(
|
||||
find.byKey(const ValueKey<String>('sidebar-footer-settings')),
|
||||
);
|
||||
|
||||
expect(footerAfter.dy, footerBefore.dy);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user