feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user