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 '../../../../core/theme/app_text_styles.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'; /// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。 /// /// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败 /// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。 class HomePage extends StatefulWidget { const HomePage({ super.key, required this.repository, required this.urlOpener, }); /// 数据来源(当前是 HTTP 实现,测试里注入 mock)。 final HomeRepository repository; /// 打开链接的能力(当前是 Web 实现)。 final UrlOpener urlOpener; @override State createState() => _HomePageState(); } class _HomePageState extends State { HomeData? _data; String? _error; bool _loading = true; /// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。 Map _sectionKeys = {}; final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); /// 已展开的分组。原型初始全部收起。 final Set _openGroupIds = {}; /// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。 bool _collapsed = false; @override void initState() { super.initState(); unawaited(_load()); } @override void dispose() { _scrollController.dispose(); _searchController.dispose(); super.dispose(); } /// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。 /// /// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容, /// 失败了只提示一句,不会把已经渲染好的页面换成错误页。 Future _load({bool showLoading = true}) async { if (showLoading) { setState(() { _loading = true; _error = null; }); } try { final HomeData data = await widget.repository.fetchHomeData(); if (!mounted) { return; } setState(() { _data = data; _loading = false; _error = null; _sectionKeys = { for (final LinkSection section in data.sections) section.id: GlobalKey(), }; }); } catch (error) { if (!mounted) { return; } if (showLoading) { setState(() { _loading = false; _error = error.toString(); }); } else { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('刷新失败:$error'))); } } } // ---- 交互 ---- /// `#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); } }); } /// 点导航叶子:滚到对应分区。 /// /// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素, /// 点了不会有任何反应——和原型里那几个空锚点一致。 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); /// 「提交网站」跳到后端配好的收集入口(Mattermost 之类)。 /// /// 地址在 `data/nav.json` 的 `site.submitUrl`;没配就什么都不做。 void _openSubmitUrl(HomeData data) { if (data.submitUrl.isEmpty) { return; } widget.urlOpener.open(data.submitUrl); } void _backToTop() { _scrollController.animateTo( 0, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic, ); } // ---- 布局 ---- @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: _buildBody(), ); } Widget _buildBody() { if (_loading) { return const _StatusView(message: '正在加载…', loading: true); } final String? error = _error; if (error != null) { return _StatusView(title: '加载失败', message: error, onRetry: _load); } return _buildLoaded(_data!); } Widget _buildLoaded(HomeData data) { return 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, onSubmitSite: () => _openSubmitUrl(data), onToggleCollapse: _toggleSidebar, ), Expanded(child: _buildMain(data)), ], ), Positioned( right: AppSizes.backToTopOffset, bottom: AppSizes.backToTopOffset, child: BackToTopButton(onTap: _backToTop), ), ], ); } Widget _buildMain(HomeData data) { 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, ), ), ], ), ); } } /// 加载中 / 加载失败时占满内容区的提示。 class _StatusView extends StatelessWidget { const _StatusView({ required this.message, this.title, this.loading = false, this.onRetry, }); final String message; final String? title; final bool loading; final VoidCallback? onRetry; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(AppSizes.contentPaddingHorizontal), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (loading) ...[ const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(height: AppSizes.gridGap), ], if (title != null) ...[ Text(title!, style: AppTextStyles.emptyTitle), const SizedBox(height: 6), ], Text( message, style: AppTextStyles.emptyMessage, textAlign: TextAlign.center, ), if (onRetry != null) ...[ const SizedBox(height: AppSizes.gridGap), _RetryButton(onTap: onRetry!), ], ], ), ), ); } } /// 「重试」按钮:跟前端其它次级按钮同一套描边样式。 class _RetryButton extends StatefulWidget { const _RetryButton({required this.onTap}); final VoidCallback onTap; @override State<_RetryButton> createState() => _RetryButtonState(); } class _RetryButtonState extends State<_RetryButton> { 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.onTap, child: AnimatedContainer( duration: AppSizes.transition, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: _hovered ? AppColors.primaryFaint : Colors.transparent, border: Border.all(color: AppColors.primary), borderRadius: BorderRadius.circular(AppSizes.navItemRadius), ), child: Text( '重试', style: AppTextStyles.navItem.copyWith(color: AppColors.primary), ), ), ), ); } }