feat: 进行功能分区方便后期维护

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-13 22:49:02 +08:00
parent 4075519075
commit e2cc3ac30f
40 changed files with 3379 additions and 515 deletions
@@ -0,0 +1,13 @@
import '../domain/models/home_data.dart';
/// 首页数据仓库契约。
///
/// 界面层只认这个接口,不关心数据来自本地 mock 还是 Rust 后端。
///
/// 目前是同步返回:mock 数据是恒定常量,加一层 Future 只会凭空多出
/// loading / error 分支。等真正走网络请求时,把返回值改成
/// `Future<HomeData>`,改动范围仅限实现类与页面的一次数据加载。
abstract interface class HomeRepository {
/// 读取首页需要的全部数据。
HomeData fetchHomeData();
}
@@ -0,0 +1,83 @@
import '../domain/models/home_data.dart';
import '../domain/models/link_item.dart';
import '../domain/models/link_section.dart';
import '../domain/models/nav_node.dart';
/// 侧栏导航树,与 `index3.html` 的 `<ul class="nav-group">` 一一对应。
const List<NavNode> mockNavNodes = <NavNode>[
NavNode(id: 'common', title: '常用推荐'),
NavNode(
id: 'materials',
title: '素材资源',
children: <NavNode>[
NavNode(id: 'design', title: 'UI 设计'),
NavNode(id: 'icon', title: '图标库'),
NavNode(id: 'stock', title: '高清壁纸'),
],
),
NavNode(
id: 'dev-tools',
title: '开发工具',
children: <NavNode>[
NavNode(id: 'dev', title: '前端框架'),
NavNode(id: 'api', title: '在线 API'),
],
),
];
/// 内容区分组,与 `index3.html` 的两段内容一一对应。
///
/// 原型只写了 `#common` 与 `#design` 两段;导航里的 `#icon`、`#stock`、
/// `#dev`、`#api` 在原型中没有对应元素,点击不会发生任何事——这里保持一致。
const List<LinkSection> mockLinkSections = <LinkSection>[
LinkSection(
id: 'common',
title: '常用推荐',
items: <LinkItem>[
LinkItem(
id: 'github',
title: 'GitHub',
url: 'https://github.com',
description: '代码托管与开源社区',
),
LinkItem(
id: 'zhihu',
title: '知乎',
url: 'https://www.zhihu.com',
description: '深度内容问答社区',
),
LinkItem(
id: 'bilibili',
title: '哔哩哔哩',
url: 'https://www.bilibili.com',
description: '弹幕视频分享网站',
),
],
),
LinkSection(
id: 'design',
title: '素材资源 - UI 设计',
items: <LinkItem>[
LinkItem(
id: 'figma',
title: 'Figma',
url: 'https://www.figma.com',
description: '协同界面设计工具',
),
LinkItem(
id: 'dribbble',
title: 'Dribbble',
url: 'https://dribbble.com',
description: '全球设计作品分享社区',
),
],
),
];
/// 首页聚合数据。
const HomeData mockHomeData = HomeData(
siteName: 'Suxing Nav',
searchHint: '输入关键字搜索全网资源...',
navNodes: mockNavNodes,
sections: mockLinkSections,
);
@@ -0,0 +1,14 @@
import '../domain/models/home_data.dart';
import 'home_repository.dart';
import 'mock_home_data.dart';
/// [HomeRepository] 的本地实现,返回 [mockHomeData]。
///
/// 单独建一个类而不是让页面直接读常量:页面因此只依赖接口,
/// 换成后端实现时不用碰界面代码,测试里也能注入任意数据集。
class MockHomeRepository implements HomeRepository {
const MockHomeRepository();
@override
HomeData fetchHomeData() => mockHomeData;
}
@@ -0,0 +1,59 @@
import 'link_item.dart';
import 'link_section.dart';
import 'nav_node.dart';
/// 首页一次性需要的全部数据。
///
/// 打包成一个聚合对象:页面只调用一次仓库、只持有一份状态,
/// 后续换成后端接口时也只需要在一个响应里组装出它。
class HomeData {
const HomeData({
required this.siteName,
required this.searchHint,
required this.navNodes,
required this.sections,
});
/// 站点名,显示在侧栏顶部与浏览器标题。
final String siteName;
/// 搜索框占位文案。
final String searchHint;
/// 侧栏导航树。
final List<NavNode> navNodes;
/// 内容区分组。
///
/// 数量可能少于导航叶子:原型里 `#icon`、`#stock`、`#dev`、`#api`
/// 没有对应元素,点击时什么也不会发生。
final List<LinkSection> sections;
/// 导航树里的全部叶子,按自上而下的顺序展开。
List<NavNode> get navLeaves => navNodes
.expand((NavNode node) => node.leaves)
.toList(growable: false);
/// 全部站点(扁平化)。
List<LinkItem> get allItems => <LinkItem>[
for (final LinkSection section in sections) ...section.items,
];
/// 站点总数。
int get itemCount =>
sections.fold(0, (int sum, LinkSection s) => sum + s.itemCount);
/// 原型把 `.active` 写死在第一个导航条目上,这里取同一个值的来源。
String? get activeSectionId =>
navLeaves.isEmpty ? null : navLeaves.first.id;
/// 按 id 取分区;原型里没有对应元素时返回 null。
LinkSection? sectionById(String id) {
for (final LinkSection section in sections) {
if (section.id == id) {
return section;
}
}
return null;
}
}
@@ -0,0 +1,63 @@
import '../../../../core/utils/text.dart';
/// 一张站点卡片,对应 `index.html` 里的 `.card`。
class LinkItem {
const LinkItem({
required this.id,
required this.title,
required this.url,
this.description = '',
});
/// 从 JSON 构造(将来接后端接口时使用)。
factory LinkItem.fromJson(Map<String, dynamic> json) {
return LinkItem(
id: json['id'] as String,
title: json['title'] as String,
url: json['url'] as String,
description: json['description'] as String? ?? '',
);
}
/// 唯一标识。
final String id;
/// 站点名称,同时用于生成卡片左上角的首字母图标。
final String title;
/// 跳转地址。
final String url;
/// 一句话描述。
final String description;
/// 卡片图标显示的首字符。
String get initial => firstChar(title).toUpperCase();
/// 展示用域名。
String get host => Uri.tryParse(url)?.host ?? url;
/// 序列化为 JSON。
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'title': title,
'url': url,
'description': description,
};
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is LinkItem &&
other.id == id &&
other.title == title &&
other.url == url &&
other.description == description;
}
@override
int get hashCode => Object.hash(id, title, url, description);
@override
String toString() => 'LinkItem(id: $id, title: $title)';
}
@@ -0,0 +1,75 @@
import 'link_item.dart';
/// 一个分组,对应 `index.html` 里带标题的一组卡片。
class LinkSection {
const LinkSection({
required this.id,
required this.title,
required this.items,
});
/// 从 JSON 构造。
///
/// `items` 做了类型防御:字段缺失或类型不对时退化成空列表,
/// 避免后端返回异常数据时整个页面直接崩掉。
factory LinkSection.fromJson(Map<String, dynamic> json) {
final Object? rawItems = json['items'];
final List<LinkItem> items = rawItems is List
? rawItems
.whereType<Map<String, dynamic>>()
.map(LinkItem.fromJson)
.toList(growable: false)
: const <LinkItem>[];
return LinkSection(
id: json['id'] as String,
title: json['title'] as String,
items: items,
);
}
/// 唯一标识,同时用作锚点。
final String id;
/// 分组标题。
final String title;
/// 分组下的站点。
final List<LinkItem> items;
/// 站点数量。
int get itemCount => items.length;
/// 序列化为 JSON。
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'title': title,
'items': items.map((LinkItem item) => item.toJson()).toList(growable: false),
};
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! LinkSection ||
other.id != id ||
other.title != title ||
other.items.length != items.length) {
return false;
}
for (int index = 0; index < items.length; index++) {
if (items[index] != other.items[index]) {
return false;
}
}
return true;
}
@override
int get hashCode =>
Object.hash(id, title, Object.hashAll(items));
@override
String toString() => 'LinkSection(id: $id, title: $title, items: $itemCount)';
}
@@ -0,0 +1,54 @@
import '../../../../core/utils/text.dart';
/// 侧栏导航节点。
///
/// `index3.html` 的侧栏是两层的:一级条目要么直接指向一个分区(如「常用推荐」),
/// 要么是一个可以展开的分组(如「素材资源」),分组下面是若干二级条目。
/// 这里用同一棵树表达两种形态——[children] 为空即为直接跳转的叶子。
class NavNode {
const NavNode({
required this.id,
required this.title,
this.children = const <NavNode>[],
});
/// 唯一标识;叶子节点同时也是内容区的锚点 id。
final String id;
/// 显示文字。
final String title;
/// 子节点;为空表示这是一个直接跳转的条目。
final List<NavNode> children;
/// 是否是可展开的分组。
bool get isGroup => children.isNotEmpty;
/// 该节点覆盖的所有叶子(自身是叶子时返回自身,否则递归收集后代)。
List<NavNode> get leaves {
if (children.isEmpty) {
return <NavNode>[this];
}
return children
.expand((NavNode child) => child.leaves)
.toList(growable: false);
}
/// 侧栏折叠时显示的短标签。
///
/// 取标题首字,与 logo 折叠成「S」是同一套规则;
/// 用 `runes` 保证中文与 emoji 不会被截成半个字符。
String get shortLabel => firstChar(title);
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is NavNode && other.id == id && other.title == title;
}
@override
int get hashCode => Object.hash(id, title);
@override
String toString() => 'NavNode(id: $id, title: $title, children: ${children.length})';
}
@@ -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),
),
),
),
),
);
}
}