import 'package:flutter/material.dart'; import '../../../../core/constants/app_sizes.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_text_styles.dart'; import '../../../../core/utils/text.dart'; import '../../domain/models/nav_node.dart'; /// 侧栏:logo + 层级导航 + 底部提交入口[cite: 1]。 class SidebarNav extends StatelessWidget { const SidebarNav({ super.key, required this.siteName, required this.navNodes, required this.activeSectionId, required this.collapsed, required this.openGroupIds, required this.onToggleGroup, required this.onSectionSelected, required this.onSubmitSite, required this.onToggleCollapse, }); final String siteName; final List navNodes; final String? activeSectionId; final bool collapsed; final Set openGroupIds; final ValueChanged onToggleGroup; final ValueChanged onSectionSelected; final VoidCallback onSubmitSite; final VoidCallback onToggleCollapse; @override Widget build(BuildContext context) { final double horizontalPadding = collapsed ? AppSizes.sidebarCollapsedPaddingHorizontal : AppSizes.sidebarPaddingHorizontal; return AnimatedContainer( duration: AppSizes.sidebarTransition, curve: Curves.fastOutSlowIn, width: collapsed ? AppSizes.sidebarCollapsedWidth : AppSizes.sidebarWidth, decoration: const BoxDecoration( color: AppColors.surface, border: Border(right: BorderSide(color: AppColors.border)), ), child: ClipRect( child: Padding( padding: EdgeInsets.fromLTRB( horizontalPadding, AppSizes.sidebarPaddingTop, horizontalPadding, AppSizes.sidebarPaddingBottom, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SidebarHeader(siteName: siteName, collapsed: collapsed), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final NavNode node in navNodes) _NavEntry( key: ValueKey(node.id), node: node, collapsed: collapsed, activeSectionId: activeSectionId, open: openGroupIds.contains(node.id), onToggleGroup: onToggleGroup, onSectionSelected: onSectionSelected, onToggleCollapse: onToggleCollapse, ), ], ), ), ), _SidebarFooter( collapsed: collapsed, onSubmitSite: onSubmitSite, ), ], ), ), ), ); } } class _SidebarHeader extends StatelessWidget { const _SidebarHeader({required this.siteName, required this.collapsed}); final String siteName; final bool collapsed; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: AppSizes.sidebarHeaderGap), child: SizedBox( height: AppSizes.sidebarHeaderHeight, child: Center( child: AnimatedCrossFade( duration: AppSizes.sidebarTransition, crossFadeState: collapsed ? CrossFadeState.showSecond : CrossFadeState.showFirst, firstChild: Text( siteName, style: AppTextStyles.logo, maxLines: 1, overflow: TextOverflow.ellipsis, ), secondChild: Text( firstChar(siteName), style: AppTextStyles.logoIcon, ), ), ), ), ); } } class _NavEntry extends StatelessWidget { const _NavEntry({ super.key, required this.node, required this.collapsed, required this.activeSectionId, required this.open, required this.onToggleGroup, required this.onSectionSelected, required this.onToggleCollapse, }); final NavNode node; final bool collapsed; final String? activeSectionId; final bool open; final ValueChanged onToggleGroup; final ValueChanged onSectionSelected; final VoidCallback onToggleCollapse; @override Widget build(BuildContext context) { if (!node.isGroup) { return Padding( padding: const EdgeInsets.only(bottom: AppSizes.navItemGap), child: _NavRow( title: node.title, collapsed: collapsed, selected: node.id == activeSectionId, onTap: () => onSectionSelected(node.id), ), ); } return Padding( padding: const EdgeInsets.only(bottom: AppSizes.navItemGap), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _NavRow( title: node.title, collapsed: collapsed, selected: false, onTap: () { if (collapsed) { onToggleCollapse(); } onToggleGroup(node.id); }, trailing: _ArrowIcon(open: open), ), if (!collapsed) AnimatedSize( duration: AppSizes.subMenuTransition, curve: open ? Curves.easeInOut : const Cubic(0, 1, 0, 1), alignment: Alignment.topCenter, child: open ? Padding( padding: const EdgeInsets.only( left: AppSizes.subMenuIndent, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final NavNode child in node.children) _SubNavItem( key: ValueKey(child.id), title: child.title, active: child.id == activeSectionId, onTap: () => onSectionSelected(child.id), ), ], ), ) : const SizedBox(height: 0), ), ], ), ); } } class _ArrowIcon extends StatelessWidget { const _ArrowIcon({required this.open}); final bool open; @override Widget build(BuildContext context) { return AnimatedRotation( duration: AppSizes.subMenuTransition, curve: Curves.ease, turns: open ? 0.25 : 0, child: const Text('›', style: AppTextStyles.navArrow), ); } } class _NavRow extends StatefulWidget { const _NavRow({ super.key, required this.title, required this.collapsed, required this.selected, required this.onTap, this.trailing, }); final String title; final bool collapsed; final bool selected; final VoidCallback onTap; final Widget? trailing; @override State<_NavRow> createState() => _NavRowState(); } class _NavRowState extends State<_NavRow> { bool _hovered = false; @override Widget build(BuildContext context) { final Color foreground = widget.selected || _hovered ? AppColors.primary : AppColors.textSecondary; final Color backgroundColor = widget.selected ? AppColors.primaryFaint : (_hovered ? AppColors.navItemHover : Colors.transparent); final Widget rowContent = MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Container( padding: EdgeInsets.symmetric( horizontal: widget.collapsed ? 0 : AppSizes.navItemPaddingHorizontal, vertical: widget.collapsed ? AppSizes.navItemCollapsedPaddingVertical : AppSizes.navItemPaddingVertical, ), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(AppSizes.navItemRadius), ), child: widget.collapsed ? Center( child: Text( firstChar(widget.title), style: AppTextStyles.navItem.copyWith( color: foreground, fontWeight: FontWeight.bold, ), ), ) : Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( widget.title, style: AppTextStyles.navItem.copyWith( color: foreground, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (widget.trailing != null) widget.trailing!, ], ), ), ), ); if (widget.collapsed) { return Tooltip(message: widget.title, child: rowContent); } return rowContent; } } class _SubNavItem extends StatefulWidget { const _SubNavItem({ super.key, required this.title, required this.active, required this.onTap, }); final String title; final bool active; final VoidCallback onTap; @override State<_SubNavItem> createState() => _SubNavItemState(); } class _SubNavItemState extends State<_SubNavItem> { bool _hovered = false; @override Widget build(BuildContext context) { final Color foreground = widget.active || _hovered ? AppColors.primary : AppColors.textSecondary; final Color backgroundColor = widget.active ? AppColors.primaryFaint : (_hovered ? AppColors.navItemHover : Colors.transparent); return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: AppSizes.subNavItemPaddingHorizontal, vertical: AppSizes.subNavItemPaddingVertical, ), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(AppSizes.navItemRadius), ), child: Text( widget.title, style: AppTextStyles.subNavItem.copyWith(color: foreground), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ); } } class _SidebarFooter extends StatelessWidget { const _SidebarFooter({ required this.collapsed, required this.onSubmitSite, }); final bool collapsed; final VoidCallback onSubmitSite; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: AppSizes.sidebarFooterMarginTop), padding: const EdgeInsets.only(top: AppSizes.sidebarFooterPaddingTop), decoration: const BoxDecoration( border: Border(top: BorderSide(color: AppColors.border)), ), child: _SubmitButton( collapsed: collapsed, onTap: onSubmitSite, ), ); } } class _SubmitButton extends StatefulWidget { const _SubmitButton({required this.collapsed, required this.onTap}); final bool collapsed; final VoidCallback onTap; @override State<_SubmitButton> createState() => _SubmitButtonState(); } class _SubmitButtonState extends State<_SubmitButton> { bool _hovered = false; @override Widget build(BuildContext context) { final Color backgroundColor = _hovered ? AppColors.navItemHover : Colors.transparent; final Widget button = MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Container( padding: EdgeInsets.symmetric( horizontal: widget.collapsed ? 0 : AppSizes.submitButtonPaddingHorizontal, vertical: widget.collapsed ? AppSizes.navItemCollapsedPaddingVertical : AppSizes.submitButtonPaddingVertical, ), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(AppSizes.navItemRadius), ), child: widget.collapsed ? Center( child: Text( '+', style: AppTextStyles.submitButton.copyWith( color: _hovered ? AppColors.primary : AppColors.textSecondary, fontWeight: FontWeight.bold, ), ), ) : Text( '提交网站', style: AppTextStyles.submitButton.copyWith( color: _hovered ? AppColors.primary : AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ); return Tooltip(message: '提交网站', child: button); } }