feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/services/url_opener.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../data/home_repository.dart';
|
||||
import '../../domain/models/home_data.dart';
|
||||
import '../../domain/models/link_item.dart';
|
||||
import '../../domain/models/link_section.dart';
|
||||
import '../widgets/back_to_top_button.dart';
|
||||
import '../widgets/link_section_view.dart';
|
||||
import '../widgets/sidebar_nav.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
|
||||
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
|
||||
///
|
||||
/// 完全按项目根目录的 `index3.html` 还原:结构、样式、交互都以那份文件为准,
|
||||
/// 不额外增加原型里没有的行为。原型里没有的东西也照样不实现——
|
||||
/// 搜索框不参与过滤(原型没有对应脚本)、`active` 固定写在第一个导航条目上、
|
||||
/// 没有对应锚点的导航项点击后不会有任何反应。
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.urlOpener,
|
||||
});
|
||||
|
||||
/// 数据来源(当前是 mock 实现)。
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 打开链接的能力(当前是 Web 实现)。
|
||||
final UrlOpener urlOpener;
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
/// 首页数据只在初始化时取一次。
|
||||
late final HomeData _data = widget.repository.fetchHomeData();
|
||||
|
||||
/// 有对应锚点的分区,用于「点导航滚动过去」。
|
||||
late final Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{
|
||||
for (final LinkSection section in _data.sections) section.id: GlobalKey(),
|
||||
};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
/// 已展开的分组。原型初始全部收起。
|
||||
final Set<String> _openGroupIds = <String>{};
|
||||
|
||||
/// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。
|
||||
bool _collapsed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---- 交互 ----
|
||||
|
||||
/// `#toggleBtn`:切换 `sidebar-collapsed`。
|
||||
void _toggleSidebar() {
|
||||
setState(() => _collapsed = !_collapsed);
|
||||
}
|
||||
|
||||
/// `.has-sub` 的点击:折叠时先展开侧栏,再切换该分组的 open。
|
||||
void _toggleGroup(String groupId) {
|
||||
setState(() {
|
||||
if (_collapsed) {
|
||||
_collapsed = false;
|
||||
_openGroupIds.add(groupId);
|
||||
return;
|
||||
}
|
||||
if (!_openGroupIds.remove(groupId)) {
|
||||
_openGroupIds.add(groupId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 点导航叶子:滚到对应锚点。
|
||||
///
|
||||
/// 原型里 `#icon`、`#stock`、`#dev`、`#api` 没有对应元素,
|
||||
/// 对应 `<a href="#...">` 点了不会有任何反应,这里保持一致。
|
||||
void _goToSection(String sectionId) {
|
||||
final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext;
|
||||
if (sectionContext == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
sectionContext,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
|
||||
|
||||
void _backToTop() {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 布局 ----
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Stack(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
SidebarNav(
|
||||
siteName: _data.siteName,
|
||||
navNodes: _data.navNodes,
|
||||
// 原型把 active 写死在第一个条目上,切换页面也不变。
|
||||
activeSectionId: _data.activeSectionId,
|
||||
collapsed: _collapsed,
|
||||
openGroupIds: _openGroupIds,
|
||||
onToggleGroup: _toggleGroup,
|
||||
onSectionSelected: _goToSection,
|
||||
// 原型指向不存在的 #submit,点了没有任何反应。
|
||||
onSubmitSite: () {},
|
||||
// 补上这行,将页面的切换折叠方法传进去
|
||||
onToggleCollapse: _toggleSidebar,
|
||||
),
|
||||
Expanded(child: _buildMain()),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: AppSizes.backToTopOffset,
|
||||
bottom: AppSizes.backToTopOffset,
|
||||
child: BackToTopButton(onTap: _backToTop),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMain() {
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSizes.contentPaddingHorizontal,
|
||||
AppSizes.contentPaddingTop,
|
||||
AppSizes.contentPaddingHorizontal,
|
||||
AppSizes.contentPaddingBottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
TopBar(
|
||||
searchController: _searchController,
|
||||
searchHint: _data.searchHint,
|
||||
sidebarCollapsed: _collapsed,
|
||||
onToggleSidebar: _toggleSidebar,
|
||||
),
|
||||
for (final LinkSection section in _data.sections)
|
||||
Padding(
|
||||
// 原型里每个 .grid 都带 margin-bottom: 36px。
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.sectionGap),
|
||||
child: LinkSectionView(
|
||||
key: _sectionKeys[section.id],
|
||||
section: section,
|
||||
onOpen: _openItem,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// 右下角的圆形「回到顶部」按钮(`.back-to-top`)。
|
||||
///
|
||||
/// 原型的按钮是一直显示的,这里保持一致;悬停时上移 3px、
|
||||
/// 边框与图标变主色、阴影加深。
|
||||
class BackToTopButton extends StatefulWidget {
|
||||
const BackToTopButton({super.key, required this.onTap});
|
||||
|
||||
/// 点击回调。
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<BackToTopButton> createState() => _BackToTopButtonState();
|
||||
}
|
||||
|
||||
class _BackToTopButtonState extends State<BackToTopButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: '回到顶部',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.back-to-top { transition: all 0.2s ease }`。
|
||||
curve: Curves.ease,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
_hovered ? -AppSizes.backToTopHoverLift : 0,
|
||||
0,
|
||||
),
|
||||
width: AppSizes.backToTopSize,
|
||||
height: AppSizes.backToTopSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _hovered ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: _hovered
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
// 原型这里就是一个 ↑ 字符(font-size: 18px)。
|
||||
child: Text(
|
||||
'↑',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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 '../../domain/models/link_item.dart';
|
||||
|
||||
/// 站点卡片,对应 `index.html` 里的 `.card`。
|
||||
///
|
||||
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
|
||||
class LinkCard extends StatefulWidget {
|
||||
const LinkCard({super.key, required this.item, required this.onOpen});
|
||||
|
||||
/// 站点数据。
|
||||
final LinkItem item;
|
||||
|
||||
/// 打开回调。
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
State<LinkCard> createState() => _LinkCardState();
|
||||
}
|
||||
|
||||
class _LinkCardState extends State<LinkCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onOpen,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.card { transition: transform .2s, box-shadow .2s }`。
|
||||
curve: Curves.ease,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
_hovered ? -AppSizes.cardHoverLift : 0,
|
||||
0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.cardPaddingHorizontal,
|
||||
vertical: AppSizes.cardPaddingVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
|
||||
boxShadow: _hovered
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_CardIcon(initial: widget.item.initial),
|
||||
const SizedBox(width: AppSizes.cardIconGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: AppTextStyles.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSizes.cardTitleGap),
|
||||
Text(
|
||||
widget.item.description,
|
||||
style: AppTextStyles.cardDesc,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片左侧的字母方块(`.card-icon`)。
|
||||
class _CardIcon extends StatelessWidget {
|
||||
const _CardIcon({required this.initial});
|
||||
|
||||
final String initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: AppSizes.cardIconSize,
|
||||
height: AppSizes.cardIconSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconSurface,
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardIconRadius),
|
||||
),
|
||||
child: Text(initial, style: AppTextStyles.cardIcon),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 搜索框。
|
||||
///
|
||||
/// 对应 `index3.html` 的 `.search-box`:固定 44px 高、白色底、1px 描边、
|
||||
/// 12px 圆角,聚焦时边框变主色并外扩 3px 的浅色光晕。
|
||||
///
|
||||
/// 用 [FocusNode] 而不是 `InputDecoration` 的 `focusedBorder`:
|
||||
/// 那条光晕是「向外扩散」的矩形阴影,靠边框宽度画不出来。
|
||||
class LinkSearchField extends StatefulWidget {
|
||||
const LinkSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
});
|
||||
|
||||
/// 文本控制器。
|
||||
final TextEditingController controller;
|
||||
|
||||
/// 占位文案。
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
State<LinkSearchField> createState() => _LinkSearchFieldState();
|
||||
}
|
||||
|
||||
class _LinkSearchFieldState extends State<LinkSearchField> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _focused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode
|
||||
..removeListener(_handleFocusChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (_focusNode.hasFocus != _focused) {
|
||||
setState(() => _focused = _focusNode.hasFocus);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.search-box { transition: border-color .2s, box-shadow .2s }`。
|
||||
curve: Curves.ease,
|
||||
height: AppSizes.searchHeight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.searchPaddingHorizontal,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _focused ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
|
||||
// 原型 `.search-box:focus` 的 box-shadow 会整体覆盖掉默认阴影,
|
||||
// 聚焦时只剩这一圈光晕,不是「默认阴影 + 光晕」。
|
||||
boxShadow: <BoxShadow>[
|
||||
if (_focused)
|
||||
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
|
||||
else
|
||||
...AppColors.cardShadow,
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
style: AppTextStyles.searchInput,
|
||||
cursorColor: AppColors.primary,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: widget.hintText,
|
||||
hintStyle: AppTextStyles.searchHint,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_breakpoints.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import '../../domain/models/link_item.dart';
|
||||
import '../../domain/models/link_section.dart';
|
||||
import 'link_card.dart';
|
||||
|
||||
/// 一个分组:标题 + 自适应网格。
|
||||
class LinkSectionView extends StatelessWidget {
|
||||
const LinkSectionView({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
/// 分组数据。
|
||||
final LinkSection section;
|
||||
|
||||
/// 打开某个站点。
|
||||
final ValueChanged<LinkItem> onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_SectionTitle(title: section.title),
|
||||
const SizedBox(height: AppSizes.sectionTitleGap),
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final int columns = AppBreakpoints.gridColumnsFor(
|
||||
constraints.maxWidth,
|
||||
minItemWidth: AppSizes.gridMinItemWidth,
|
||||
gap: AppSizes.gridGap,
|
||||
);
|
||||
final double itemWidth =
|
||||
(constraints.maxWidth - AppSizes.gridGap * (columns - 1)) /
|
||||
columns;
|
||||
|
||||
return Wrap(
|
||||
spacing: AppSizes.gridGap,
|
||||
runSpacing: AppSizes.gridGap,
|
||||
children: <Widget>[
|
||||
for (final LinkItem item in section.items)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: LinkCard(item: item, onOpen: () => onOpen(item)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组标题:左侧一条 4×16 的主色竖条 + 文字(`.section-title::before`)。
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: AppSizes.sectionAccentWidth,
|
||||
height: AppSizes.sectionAccentHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppSizes.sectionAccentRadius),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSizes.sectionAccentGap),
|
||||
// 用 Flexible 包住:窄屏(侧栏展开后主内容只剩一百多像素)时
|
||||
// 「素材资源 - UI 设计」这类长标题会溢出,这里退化成省略号。
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTextStyles.sectionTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import 'link_search_field.dart';
|
||||
|
||||
/// 顶部控制栏:折叠按钮 + 搜索框。
|
||||
///
|
||||
/// 对应 `index3.html` 的 `.top-bar`:整体宽 750px 并且**水平居中**
|
||||
/// (`margin: 0 auto 36px auto`),下方与第一组卡片留 36px。
|
||||
class TopBar extends StatelessWidget {
|
||||
const TopBar({
|
||||
super.key,
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
required this.sidebarCollapsed,
|
||||
required this.onToggleSidebar,
|
||||
});
|
||||
|
||||
/// 搜索框控制器。
|
||||
final TextEditingController searchController;
|
||||
|
||||
/// 搜索框占位文案。
|
||||
final String searchHint;
|
||||
|
||||
/// 侧栏当前是否折叠,用于按钮的提示文案。
|
||||
final bool sidebarCollapsed;
|
||||
|
||||
/// 折叠 / 展开侧栏。
|
||||
final VoidCallback onToggleSidebar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.topBarMarginBottom),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: AppSizes.topBarMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_ToggleButton(
|
||||
collapsed: sidebarCollapsed,
|
||||
onTap: onToggleSidebar,
|
||||
),
|
||||
const SizedBox(width: AppSizes.topBarGap),
|
||||
Expanded(
|
||||
child: LinkSearchField(
|
||||
controller: searchController,
|
||||
hintText: searchHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠按钮(`.toggle-btn`)。
|
||||
class _ToggleButton extends StatefulWidget {
|
||||
const _ToggleButton({required this.collapsed, required this.onTap});
|
||||
|
||||
final bool collapsed;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_ToggleButton> createState() => _ToggleButtonState();
|
||||
}
|
||||
|
||||
class _ToggleButtonState extends State<_ToggleButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color foreground = _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary;
|
||||
|
||||
return Tooltip(
|
||||
// 原型写的是 title="展开/折叠侧边栏",文案固定。
|
||||
message: '展开/折叠侧边栏',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.toggle-btn { transition: all 0.2s }`。
|
||||
curve: Curves.ease,
|
||||
width: AppSizes.toggleButtonSize,
|
||||
height: AppSizes.toggleButtonSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _hovered ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.toggleButtonRadius),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
// 原型这里就是一个 ☰ 字符(font-size: 18px)。
|
||||
child: Text(
|
||||
'☰',
|
||||
style: TextStyle(fontSize: 18, color: foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user