- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
185 lines
5.8 KiB
Dart
185 lines
5.8 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|