Files
Navius/frontend/lib/features/home/presentation/widgets/sidebar_nav.dart
CNWei e2cc3ac30f feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-13 22:49:02 +08:00

465 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NavNode> navNodes;
final String? activeSectionId;
final bool collapsed;
final Set<String> openGroupIds;
final ValueChanged<String> onToggleGroup;
final ValueChanged<String> 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: <Widget>[
_SidebarHeader(siteName: siteName, collapsed: collapsed),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
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<String> onToggleGroup;
final ValueChanged<String> 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: <Widget>[
_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: <Widget>[
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: <Widget>[
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);
}
}