Add tri-state desktop sidebar behavior

This commit is contained in:
Haitao Pan 2026-03-11 16:13:06 +08:00
parent 390e14beef
commit d34f022e85
4 changed files with 155 additions and 42 deletions

View File

@ -40,7 +40,7 @@ class AppController extends ChangeNotifier {
WorkspaceDestination _destination = WorkspaceDestination.assistant;
ThemeMode _themeMode = ThemeMode.light;
bool _sidebarExpanded = true;
AppSidebarState _sidebarState = AppSidebarState.expanded;
DetailPanelData? _detailPanel;
bool _initializing = true;
String? _bootstrapError;
@ -48,7 +48,7 @@ class AppController extends ChangeNotifier {
WorkspaceDestination get destination => _destination;
ThemeMode get themeMode => _themeMode;
bool get sidebarExpanded => _sidebarExpanded;
AppSidebarState get sidebarState => _sidebarState;
DetailPanelData? get detailPanel => _detailPanel;
bool get initializing => _initializing;
String? get bootstrapError => _bootstrapError;
@ -111,8 +111,20 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
void toggleSidebar() {
_sidebarExpanded = !_sidebarExpanded;
void cycleSidebarState() {
_sidebarState = switch (_sidebarState) {
AppSidebarState.expanded => AppSidebarState.collapsed,
AppSidebarState.collapsed => AppSidebarState.hidden,
AppSidebarState.hidden => AppSidebarState.expanded,
};
notifyListeners();
}
void setSidebarState(AppSidebarState state) {
if (_sidebarState == state) {
return;
}
_sidebarState = state;
notifyListeners();
}

View File

@ -7,6 +7,7 @@ import '../features/modules/modules_page.dart';
import '../features/secrets/secrets_page.dart';
import '../features/settings/settings_page.dart';
import '../features/tasks/tasks_page.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../theme/app_palette.dart';
import '../widgets/detail_drawer.dart';
@ -39,8 +40,8 @@ class AppShell extends StatelessWidget {
Theme.of(context).platform == TargetPlatform.iOS &&
constraints.maxWidth < 900;
final isMobile = constraints.maxWidth < 900;
final collapsed =
!controller.sidebarExpanded || constraints.maxWidth < 1120;
final sidebarState = controller.sidebarState;
final showSidebar = sidebarState != AppSidebarState.hidden;
final showPinnedDetail =
controller.detailPanel != null &&
constraints.maxWidth > 1460;
@ -159,23 +160,26 @@ class AppShell extends StatelessWidget {
children: [
Row(
children: [
SidebarNavigation(
currentSection: controller.destination,
isCollapsed: collapsed,
appLanguage: controller.appLanguage,
themeMode: controller.themeMode,
onSectionChanged: controller.navigateTo,
onToggleLanguage: controller.toggleAppLanguage,
onToggleCollapsed: controller.toggleSidebar,
onOpenAccount: () => controller.navigateTo(
WorkspaceDestination.account,
if (showSidebar)
SidebarNavigation(
currentSection: controller.destination,
sidebarState: sidebarState,
appLanguage: controller.appLanguage,
themeMode: controller.themeMode,
onSectionChanged: controller.navigateTo,
onToggleLanguage: controller.toggleAppLanguage,
onCycleSidebarState: controller.cycleSidebarState,
onExpandFromCollapsed: () => controller
.setSidebarState(AppSidebarState.expanded),
onOpenAccount: () => controller.navigateTo(
WorkspaceDestination.account,
),
onOpenThemeToggle: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
),
onOpenThemeToggle: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
@ -215,6 +219,17 @@ class AppShell extends StatelessWidget {
onClose: controller.closeDetail,
),
),
if (!showSidebar)
Positioned(
left: 0,
top: 18,
bottom: 18,
child: _SidebarRevealRail(
onExpand: () => controller.setSidebarState(
AppSidebarState.expanded,
),
),
),
],
);
},
@ -260,3 +275,52 @@ class AppShell extends StatelessWidget {
};
}
}
class _SidebarRevealRail extends StatefulWidget {
const _SidebarRevealRail({required this.onExpand});
final VoidCallback onExpand;
@override
State<_SidebarRevealRail> createState() => _SidebarRevealRailState();
}
class _SidebarRevealRailState extends State<_SidebarRevealRail> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Tooltip(
message: appText('展开导航', 'Expand sidebar'),
child: GestureDetector(
onTap: widget.onExpand,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: _hovered ? 22 : 10,
decoration: BoxDecoration(
color: _hovered ? palette.surfaceSecondary : Colors.transparent,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(14),
),
border: Border.all(
color: _hovered ? palette.strokeSoft : Colors.transparent,
),
),
child: _hovered
? Icon(
Icons.keyboard_double_arrow_right_rounded,
size: 16,
color: palette.textMuted,
)
: null,
),
),
),
);
}
}

View File

@ -67,6 +67,8 @@ class StatusInfo {
final StatusTone tone;
}
enum AppSidebarState { expanded, collapsed, hidden }
enum AssistantMode { code, office }
extension AssistantModeCopy on AssistantMode {

View File

@ -9,23 +9,25 @@ class SidebarNavigation extends StatelessWidget {
const SidebarNavigation({
super.key,
required this.currentSection,
required this.isCollapsed,
required this.sidebarState,
required this.appLanguage,
required this.themeMode,
required this.onSectionChanged,
required this.onToggleLanguage,
required this.onToggleCollapsed,
required this.onCycleSidebarState,
required this.onExpandFromCollapsed,
required this.onOpenAccount,
required this.onOpenThemeToggle,
});
final WorkspaceDestination currentSection;
final bool isCollapsed;
final AppSidebarState sidebarState;
final AppLanguage appLanguage;
final ThemeMode themeMode;
final ValueChanged<WorkspaceDestination> onSectionChanged;
final VoidCallback onToggleLanguage;
final VoidCallback onToggleCollapsed;
final VoidCallback onCycleSidebarState;
final VoidCallback onExpandFromCollapsed;
final VoidCallback onOpenAccount;
final VoidCallback onOpenThemeToggle;
@ -40,6 +42,7 @@ class SidebarNavigation extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = context.palette;
final isCollapsed = sidebarState == AppSidebarState.collapsed;
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
@ -61,7 +64,10 @@ class SidebarNavigation extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidebarHeader(isCollapsed: isCollapsed),
SidebarHeader(
isCollapsed: isCollapsed,
onTap: isCollapsed ? onExpandFromCollapsed : null,
),
const SizedBox(height: 12),
Container(height: 1, color: palette.sidebarBorder),
const SizedBox(height: 12),
@ -72,6 +78,7 @@ class SidebarNavigation extends StatelessWidget {
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _mainSections
.map(
(section) => Padding(
@ -99,7 +106,8 @@ class SidebarNavigation extends StatelessWidget {
onOpenThemeToggle: onOpenThemeToggle,
onOpenSettings: () =>
onSectionChanged(WorkspaceDestination.settings),
onToggleCollapsed: onToggleCollapsed,
sidebarState: sidebarState,
onCycleSidebarState: onCycleSidebarState,
onOpenAccount: onOpenAccount,
accountSelected:
currentSection == WorkspaceDestination.account,
@ -115,15 +123,16 @@ class SidebarNavigation extends StatelessWidget {
}
class SidebarHeader extends StatelessWidget {
const SidebarHeader({super.key, required this.isCollapsed});
const SidebarHeader({super.key, required this.isCollapsed, this.onTap});
final bool isCollapsed;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Row(
final content = Row(
children: [
Container(
width: 38,
@ -159,6 +168,22 @@ class SidebarHeader extends StatelessWidget {
],
],
);
if (onTap == null) {
return content;
}
return Tooltip(
message: appText('展开导航', 'Expand sidebar'),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: content,
),
),
);
}
}
@ -197,6 +222,7 @@ class _SidebarNavItemState extends State<SidebarNavItem> {
final item = AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
width: widget.collapsed ? null : double.infinity,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(10),
@ -216,7 +242,7 @@ class _SidebarNavItemState extends State<SidebarNavItem> {
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
Icon(widget.section.icon, color: foreground, size: 20),
Icon(widget.section.icon, color: foreground, size: 18),
if (!widget.collapsed) ...[
const SizedBox(width: 10),
Text(
@ -248,23 +274,25 @@ class SidebarFooter extends StatelessWidget {
const SidebarFooter({
super.key,
required this.isCollapsed,
required this.sidebarState,
required this.appLanguage,
required this.themeMode,
required this.onToggleLanguage,
required this.onOpenThemeToggle,
required this.onOpenSettings,
required this.onToggleCollapsed,
required this.onCycleSidebarState,
required this.onOpenAccount,
required this.accountSelected,
});
final bool isCollapsed;
final AppSidebarState sidebarState;
final AppLanguage appLanguage;
final ThemeMode themeMode;
final VoidCallback onToggleLanguage;
final VoidCallback onOpenThemeToggle;
final VoidCallback onOpenSettings;
final VoidCallback onToggleCollapsed;
final VoidCallback onCycleSidebarState;
final VoidCallback onOpenAccount;
final bool accountSelected;
@ -285,6 +313,7 @@ class SidebarFooter extends StatelessWidget {
? appText('切换浅色', 'Switch to light')
: appText('切换深色', 'Switch to dark'),
child: IconButton(
iconSize: 20,
onPressed: onOpenThemeToggle,
icon: Icon(
themeMode == ThemeMode.dark
@ -297,29 +326,35 @@ class SidebarFooter extends StatelessWidget {
final settingsButton = Tooltip(
message: appText('打开设置', 'Open settings'),
child: IconButton(
iconSize: 20,
onPressed: onOpenSettings,
icon: const Icon(Icons.settings_rounded),
),
);
final collapseButton = Tooltip(
message: isCollapsed
? appText('展开导航', 'Expand sidebar')
: appText('折叠导航', 'Collapse sidebar'),
message: switch (sidebarState) {
AppSidebarState.expanded => appText('折叠导航', 'Collapse sidebar'),
AppSidebarState.collapsed => appText('隐藏导航', 'Hide sidebar'),
AppSidebarState.hidden => appText('展开导航', 'Expand sidebar'),
},
child: IconButton(
onPressed: onToggleCollapsed,
icon: Icon(
isCollapsed
? Icons.menu_open_rounded
: Icons.keyboard_double_arrow_left_rounded,
),
iconSize: 20,
onPressed: onCycleSidebarState,
icon: Icon(switch (sidebarState) {
AppSidebarState.expanded => Icons.keyboard_double_arrow_left_rounded,
AppSidebarState.collapsed => Icons.visibility_off_outlined,
AppSidebarState.hidden => Icons.keyboard_double_arrow_right_rounded,
}),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isCollapsed)
Column(
mainAxisSize: MainAxisSize.min,
children: [
themeButton,
const SizedBox(height: 6),