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 createState() => _HomePageState(); } class _HomePageState extends State { /// 首页数据只在初始化时取一次。 late final HomeData _data = widget.repository.fetchHomeData(); /// 有对应锚点的分区,用于「点导航滚动过去」。 late final Map _sectionKeys = { for (final LinkSection section in _data.sections) section.id: GlobalKey(), }; final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); /// 已展开的分组。原型初始全部收起。 final Set _openGroupIds = {}; /// 侧栏是否折叠。原型初始是展开的(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` 没有对应元素, /// 对应 `` 点了不会有任何反应,这里保持一致。 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: [ Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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: [ 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, ), ), ], ), ); } }