feat: 进行功能分区方便后期维护
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
+32
-11
@@ -1,17 +1,38 @@
|
||||
# frontend
|
||||
# Navius 前端(Flutter Web)
|
||||
|
||||
A new Flutter project.
|
||||
导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据全部来自本地 mock,
|
||||
不发起网络请求;后端(Rust)尚未接入。
|
||||
|
||||
## Getting Started
|
||||
界面基准是仓库根目录的 `index3.html`:可折叠的层级侧栏 + 居中的顶部控制栏
|
||||
(折叠按钮 + 搜索框)+ 分组卡片网格 + 右下角圆形回到顶部。
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
## 常用命令
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
```bash
|
||||
flutter pub get # 拉取依赖
|
||||
flutter run -d chrome # 本地运行
|
||||
flutter analyze # 静态分析
|
||||
flutter test # 单元测试与组件测试
|
||||
flutter build web # 构建产物输出到 build/web
|
||||
```
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
## 目录约定
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
- `lib/core/`:与业务无关的基础设施——主题、尺寸常量、断点计算、打开链接的能力。
|
||||
- `lib/features/<功能>/`:按功能划分,内部再分 `data`(数据来源与仓库)、`domain`(模型与规则)、`presentation`(页面与组件)。
|
||||
- `test/`:目录结构与 `lib/` 保持一致。
|
||||
|
||||
依赖方向固定为 `features/ → core/`:`core` 不引用任何功能代码,
|
||||
功能之间也不互相引用。
|
||||
|
||||
## 接后端的位置
|
||||
|
||||
页面只依赖仓库接口,不关心数据来自 mock 还是 Rust 后端:
|
||||
|
||||
- 首页:`lib/features/home/data/home_repository.dart`
|
||||
|
||||
接入时新增一个实现类,并把 `fetchHomeData` 的返回类型改成 `Future<HomeData>` 即可,
|
||||
`domain/` 与 `presentation/` 基本不需要改动。
|
||||
|
||||
完整的目录树、设计令牌映射、响应式规则与实现取舍见
|
||||
[docs/frontend-architecture.md](../docs/frontend-architecture.md)。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'core/services/web_url_opener.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/home/data/mock_home_repository.dart';
|
||||
import 'features/home/presentation/pages/home_page.dart';
|
||||
|
||||
/// 应用根组件。
|
||||
///
|
||||
/// 只负责三件事:配置全局主题、装配依赖、指定首页。
|
||||
/// 这里是「具体实现」接起来的地方,所以只有它需要知道
|
||||
/// [MockHomeRepository] 与 [WebUrlOpener] 的存在。
|
||||
class NaviusApp extends StatelessWidget {
|
||||
const NaviusApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
// 与 HomeData.siteName 保持一致;接入后端后这一层由接口返回的数据驱动。
|
||||
title: 'Suxing Nav',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
home: const HomePage(
|
||||
repository: MockHomeRepository(),
|
||||
urlOpener: WebUrlOpener(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 网格列数计算。
|
||||
///
|
||||
/// `index3.html` 的 `.grid` 用的是 CSS Grid 的
|
||||
/// `repeat(auto-fill, minmax(220px, 1fr))`;Flutter 没有等价写法,
|
||||
/// 这里把它的规则换算成「给定可用宽度能放几列」,写成纯函数便于单测。
|
||||
///
|
||||
/// 原型没有媒体查询,所以这里也不做断点分支——宽度由调用方传入。
|
||||
abstract final class AppBreakpoints {
|
||||
/// 给定可用宽度与最小单元宽度,算出网格能放几列。
|
||||
///
|
||||
/// 等价于 CSS:`列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))`。
|
||||
static int gridColumnsFor(
|
||||
double availableWidth, {
|
||||
double minItemWidth = 220,
|
||||
double gap = 16,
|
||||
}) {
|
||||
if (availableWidth <= minItemWidth) {
|
||||
return 1;
|
||||
}
|
||||
final int columns =
|
||||
((availableWidth + gap) / (minItemWidth + gap)).floor();
|
||||
return columns < 1 ? 1 : columns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 尺寸与动效常量。
|
||||
///
|
||||
/// 数值逐条对应 `index3.html` 的 CSS 变量与规则,组件里不再出现魔法数字。
|
||||
abstract final class AppSizes {
|
||||
// ---- 侧栏 ----
|
||||
|
||||
/// 侧栏展开宽度(`--sidebar-width`,230px)。
|
||||
static const double sidebarWidth = 230;
|
||||
|
||||
/// 侧栏折叠宽度(`--sidebar-collapsed-width`,64px)。
|
||||
static const double sidebarCollapsedWidth = 64;
|
||||
|
||||
/// 侧栏内边距(`padding: 20px 12px 12px 12px`)。
|
||||
static const double sidebarPaddingTop = 20;
|
||||
static const double sidebarPaddingHorizontal = 12;
|
||||
static const double sidebarPaddingBottom = 12;
|
||||
|
||||
/// 折叠后的左右内边距(`padding: 20px 8px 12px 8px`)。
|
||||
static const double sidebarCollapsedPaddingHorizontal = 8;
|
||||
|
||||
/// logo 区域高度与下方间距(`.sidebar-header`,`height:40px; margin-bottom:24px`)。
|
||||
static const double sidebarHeaderHeight = 40;
|
||||
static const double sidebarHeaderGap = 24;
|
||||
|
||||
/// 侧栏展开 / 折叠的动画(`width .3s cubic-bezier(.4, 0, .2, 1)`)。
|
||||
///
|
||||
/// 该贝塞尔曲线就是 Material 的 standard easing,Flutter 里对应
|
||||
/// [Curves.fastOutSlowIn]。
|
||||
static const Duration sidebarTransition = Duration(milliseconds: 300);
|
||||
|
||||
// ---- 导航项 ----
|
||||
|
||||
/// 导航项圆角(`border-radius: 8px`)。
|
||||
static const double navItemRadius = 8;
|
||||
|
||||
/// 导航项内边距(`.nav-link`,`padding: 11px 16px`)。
|
||||
static const double navItemPaddingVertical = 11;
|
||||
static const double navItemPaddingHorizontal = 16;
|
||||
|
||||
/// 导航项之间的间距(`.nav-item-wrapper`,`margin-bottom: 4px`)。
|
||||
static const double navItemGap = 4;
|
||||
|
||||
/// 折叠状态下导航项的内边距(`padding: 12px 0`)。
|
||||
static const double navItemCollapsedPaddingVertical = 12;
|
||||
|
||||
/// 子菜单项内边距与字号(`.sub-menu .nav-link`,`padding: 8px 16px`)。
|
||||
static const double subNavItemPaddingVertical = 8;
|
||||
static const double subNavItemPaddingHorizontal = 16;
|
||||
|
||||
/// 子菜单相对父级的缩进(`.sub-menu`,`padding-left: 16px`)。
|
||||
static const double subMenuIndent = 16;
|
||||
|
||||
/// 子菜单展开 / 收起的动画时长(`transition: .3s`)。
|
||||
static const Duration subMenuTransition = Duration(milliseconds: 300);
|
||||
|
||||
/// 折叠箭头的点击热区宽度。
|
||||
static const double navArrowBoxWidth = 18;
|
||||
|
||||
// ---- 侧栏底部 ----
|
||||
|
||||
/// 底部分隔线与内边距(`.sidebar-footer`)。
|
||||
static const double sidebarFooterPaddingTop = 12;
|
||||
static const double sidebarFooterMarginTop = 8;
|
||||
|
||||
/// 提交按钮内边距(`.submit-btn`,`padding: 10px 16px`)。
|
||||
static const double submitButtonPaddingVertical = 10;
|
||||
static const double submitButtonPaddingHorizontal = 16;
|
||||
|
||||
// ---- 主内容区 ----
|
||||
|
||||
/// 主内容区内边距(`padding: 24px 40px 40px 40px`)。
|
||||
static const double contentPaddingTop = 24;
|
||||
static const double contentPaddingHorizontal = 40;
|
||||
static const double contentPaddingBottom = 40;
|
||||
|
||||
// ---- 顶部控制栏 ----
|
||||
|
||||
/// 顶栏最大宽度与垂直间距(`.top-bar`,`max-width: 750px; margin-bottom: 36px`)。
|
||||
static const double topBarMaxWidth = 750;
|
||||
static const double topBarGap = 16;
|
||||
static const double topBarMarginBottom = 36;
|
||||
|
||||
/// 折叠按钮尺寸与圆角(`.toggle-btn`,44×44,圆角 12px)。
|
||||
static const double toggleButtonSize = 44;
|
||||
static const double toggleButtonRadius = 12;
|
||||
|
||||
// ---- 搜索框 ----
|
||||
|
||||
/// 搜索框高度与内边距(`.search-box`,`height:44px; padding: 0 20px`)。
|
||||
static const double searchHeight = 44;
|
||||
static const double searchPaddingHorizontal = 20;
|
||||
|
||||
/// 搜索框圆角(`border-radius: 12px`)。
|
||||
static const double searchRadius = 12;
|
||||
|
||||
/// 聚焦光晕宽度(`0 0 0 3px`)。
|
||||
static const double focusRingWidth = 3;
|
||||
|
||||
// ---- 分组与卡片 ----
|
||||
|
||||
/// 分组标题与网格之间的间距(`margin-bottom: 16px`)。
|
||||
static const double sectionTitleGap = 16;
|
||||
|
||||
/// 标题前的竖条尺寸(`width: 4px; height: 16px`)。
|
||||
static const double sectionAccentWidth = 4;
|
||||
static const double sectionAccentHeight = 16;
|
||||
|
||||
/// 竖条与标题文字的间距(`margin-right: 8px`)。
|
||||
static const double sectionAccentGap = 8;
|
||||
|
||||
/// 竖条圆角(`border-radius: 2px`)。
|
||||
static const double sectionAccentRadius = 2;
|
||||
|
||||
/// 分组之间的间距(`.grid` 的 `margin-bottom: 36px`)。
|
||||
static const double sectionGap = 36;
|
||||
|
||||
/// 网格间距(`gap: 16px`)。
|
||||
static const double gridGap = 16;
|
||||
|
||||
/// 网格单元最小宽度(`minmax(220px, 1fr)`)。
|
||||
static const double gridMinItemWidth = 220;
|
||||
|
||||
/// 卡片圆角(`border-radius: 10px`)。
|
||||
static const double cardRadius = 10;
|
||||
|
||||
/// 卡片内边距(`padding: 14px 16px`)。
|
||||
static const double cardPaddingVertical = 14;
|
||||
static const double cardPaddingHorizontal = 16;
|
||||
|
||||
/// 卡片悬停时上移的距离(`translateY(-2px)`)。
|
||||
static const double cardHoverLift = 2;
|
||||
|
||||
/// 卡片图标尺寸(`.card-icon`,36px)。
|
||||
static const double cardIconSize = 36;
|
||||
|
||||
/// 卡片图标圆角(`border-radius: 8px`)。
|
||||
static const double cardIconRadius = 8;
|
||||
|
||||
/// 图标与文字之间的间距(`margin-right: 12px`)。
|
||||
static const double cardIconGap = 12;
|
||||
|
||||
/// 卡片标题与描述的间距(`margin-top: 2px`)。
|
||||
static const double cardTitleGap = 2;
|
||||
|
||||
// ---- 回到顶部 ----
|
||||
|
||||
/// 按钮尺寸(`.back-to-top`,44×44,圆角 50%)。
|
||||
static const double backToTopSize = 44;
|
||||
|
||||
/// 距视口右下角的距离(`bottom: 30px; right: 30px`)。
|
||||
static const double backToTopOffset = 30;
|
||||
|
||||
/// 悬停时上移距离(`translateY(-3px)`)。
|
||||
static const double backToTopHoverLift = 3;
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
/// 通用过渡时长(CSS 里的 `transition: 0.2s`)。
|
||||
static const Duration transition = Duration(milliseconds: 200);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// 打开外部链接的能力抽象。
|
||||
///
|
||||
/// 界面层只依赖这个接口:一来把「打开链接」这种平台相关行为挡在 UI 之外,
|
||||
/// 二来测试里可以注入假实现,避免真的弹出浏览器。
|
||||
abstract interface class UrlOpener {
|
||||
/// 打开 [url]。
|
||||
void open(String url);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'url_opener.dart';
|
||||
|
||||
/// [UrlOpener] 的 Web 实现:在新标签页打开链接。
|
||||
///
|
||||
/// 当前项目只面向 Web,因此这是唯一的实现;
|
||||
/// 以后要支持其它平台,只需新增实现类,界面代码无需改动。
|
||||
class WebUrlOpener implements UrlOpener {
|
||||
const WebUrlOpener();
|
||||
|
||||
@override
|
||||
void open(String url) {
|
||||
web.window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局配色。
|
||||
///
|
||||
/// 取值逐条对应 `index.html` 里 `:root` 的 CSS 变量,
|
||||
/// 组件只引用这里的语义名,不再出现十六进制字面量。
|
||||
/// 将来调色只需要改这个文件。
|
||||
abstract final class AppColors {
|
||||
// ---- 基础色 ----
|
||||
|
||||
/// 页面底色(`--bg-color`)。
|
||||
static const Color background = Color(0xFFF7F9FB);
|
||||
|
||||
/// 卡片 / 侧栏底色(`--card-bg`)。
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
|
||||
/// 主色(`--primary-color`):logo、导航选中态、图标文字、焦点边框。
|
||||
static const Color primary = Color(0xFF3B82F6);
|
||||
|
||||
/// 主要文字(`--text-main`):标题与卡片标题。
|
||||
static const Color textPrimary = Color(0xFF1E293B);
|
||||
|
||||
/// 次要文字(`--text-sub`):描述、未选中的导航项。
|
||||
static const Color textSecondary = Color(0xFF64748B);
|
||||
|
||||
/// 描边(`--border-color`):侧栏右边界、卡片边框、搜索框边框。
|
||||
static const Color border = Color(0xFFE2E8F0);
|
||||
|
||||
/// 导航项悬停 / 选中底色(`.nav-item:hover`)。
|
||||
static const Color primaryFaint = Color(0xFFEFF6FF);
|
||||
|
||||
/// 导航项悬停底色(`.nav-link:hover`,比选中态更浅)。
|
||||
static const Color navItemHover = Color(0xFFF8FAFC);
|
||||
|
||||
/// 提交按钮悬停底色(`.submit-btn:hover`),与卡片图标底色同值。
|
||||
static const Color navItemHoverDeep = Color(0xFFF1F5F9);
|
||||
|
||||
/// 折叠箭头颜色(`.arrow-icon`)。
|
||||
static const Color navArrow = Color(0xFF94A3B8);
|
||||
|
||||
/// 卡片图标底色(`.card-icon`)。
|
||||
static const Color iconSurface = Color(0xFFF1F5F9);
|
||||
|
||||
/// 搜索框聚焦时的外发光(`0 0 0 3px rgba(59,130,246,.15)`)。
|
||||
static const Color focusRing = Color(0x263B82F6);
|
||||
|
||||
// ---- 阴影 ----
|
||||
|
||||
/// 卡片默认阴影(`--shadow`)。
|
||||
static const List<BoxShadow> cardShadow = <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x08000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
];
|
||||
|
||||
/// 卡片悬停阴影(`--shadow-hover`)。
|
||||
static const List<BoxShadow> cardShadowHover = <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 15,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x0A000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 语义化文字样式。
|
||||
///
|
||||
/// 字号与字重对应 `index.html` 的 CSS;字体族不显式指定,
|
||||
/// 沿用 HTML 里的系统字体栈(`-apple-system, Segoe UI, ...`)。
|
||||
/// 颜色留空的样式由调用方按状态着色(例如导航项的选中 / 未选中)。
|
||||
abstract final class AppTextStyles {
|
||||
/// 侧栏 logo 文字(`.logo`,18px / 700)。
|
||||
static const TextStyle logo = TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 侧栏折叠后的 logo 图标(`.logo-icon`,20px / 800)。
|
||||
static const TextStyle logoIcon = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 侧栏一级导航项(`.nav-link`,14px / 500)。
|
||||
static const TextStyle navItem = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 侧栏二级导航项(`.sub-menu .nav-link`,13px / 500)。
|
||||
static const TextStyle subNavItem = TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 折叠箭头(`.arrow-icon`,14px)。
|
||||
static const TextStyle navArrow = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.navArrow,
|
||||
fontFamily: 'monospace',
|
||||
height: 1,
|
||||
);
|
||||
|
||||
/// 提交按钮文字(`.submit-btn`,14px / 500)。
|
||||
static const TextStyle submitButton = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 分组标题(`.section-title`,16px / 600)。
|
||||
static const TextStyle sectionTitle = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 搜索框正文(`.search-box`,15px)。
|
||||
static const TextStyle searchInput = TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 搜索框占位文字。
|
||||
static const TextStyle searchHint = TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 卡片标题(`.card-title`,14px / 600)。
|
||||
static const TextStyle cardTitle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 卡片描述(`.card-desc`,12px)。
|
||||
static const TextStyle cardDesc = TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 卡片图标里的首字母(`.card-icon`)。
|
||||
///
|
||||
/// 原型没有给 .card-icon 设 font-size,它继承 body 的浏览器默认字号 16px。
|
||||
static const TextStyle cardIcon = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 空状态主文案。
|
||||
static const TextStyle emptyTitle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 空状态补充说明。
|
||||
static const TextStyle emptyMessage = TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 全局主题。
|
||||
abstract final class AppTheme {
|
||||
/// 浅色主题(当前唯一主题,取值来自 index.html 的设计变量)。
|
||||
static ThemeData get light {
|
||||
return ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
primary: AppColors.primary,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
dividerColor: AppColors.border,
|
||||
iconTheme: const IconThemeData(color: AppColors.textSecondary, size: 20),
|
||||
// 搜索框自己绘制边框与聚焦光晕,这里只负责光标颜色。
|
||||
textSelectionTheme: const TextSelectionThemeData(
|
||||
cursorColor: AppColors.primary,
|
||||
selectionColor: AppColors.focusRing,
|
||||
selectionHandleColor: AppColors.primary,
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: TextStyle(color: AppColors.surface, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// 取字符串的第一个字符。
|
||||
///
|
||||
/// 用 `runes` 而不是 `substring(0, 1)`:中文与 emoji 不会被截成半个字符。
|
||||
/// 侧栏折叠后的短标签、卡片左上角的字母图标都依赖这条规则,
|
||||
/// 放在这里保证三处(logo、导航、卡片)表现一致。
|
||||
String firstChar(String text, {String fallback = '·'}) {
|
||||
if (text.isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCharCode(text.runes.first);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import '../models/nav_item.dart';
|
||||
import '../models/category.dart';
|
||||
|
||||
class NavData {
|
||||
// 获取所有分类数据
|
||||
static List<Category> getCategories() {
|
||||
return [
|
||||
Category(
|
||||
id: 'default',
|
||||
name: '常用导航',
|
||||
isSystem: true,
|
||||
entries: [
|
||||
NavItem(
|
||||
id: 'github',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
icon: '💻',
|
||||
isSystem: true,
|
||||
),
|
||||
NavItem(
|
||||
id: 'google',
|
||||
title: 'Google',
|
||||
url: 'https://www.google.com',
|
||||
icon: '🔍',
|
||||
isSystem: true,
|
||||
),
|
||||
NavItem(
|
||||
id: 'stackoverflow',
|
||||
title: 'Stack Overflow',
|
||||
url: 'https://stackoverflow.com',
|
||||
icon: '📚',
|
||||
isSystem: true,
|
||||
),
|
||||
NavItem(
|
||||
id: 'bilibili',
|
||||
title: 'B站',
|
||||
url: 'https://www.bilibili.com',
|
||||
icon: '📺',
|
||||
isSystem: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Category(
|
||||
id: 'dev',
|
||||
name: '开发工具',
|
||||
isSystem: false,
|
||||
entries: [
|
||||
NavItem(
|
||||
id: 'rust_doc',
|
||||
title: 'Rust 文档',
|
||||
url: 'https://doc.rust-lang.org',
|
||||
icon: '🦀',
|
||||
isSystem: false,
|
||||
),
|
||||
NavItem(
|
||||
id: 'flutter_doc',
|
||||
title: 'Flutter 文档',
|
||||
url: 'https://docs.flutter.dev',
|
||||
icon: '📱',
|
||||
isSystem: false,
|
||||
),
|
||||
NavItem(
|
||||
id: 'dartpad',
|
||||
title: 'DartPad',
|
||||
url: 'https://dartpad.dev',
|
||||
icon: '🎯',
|
||||
isSystem: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
Category(
|
||||
id: 'ai',
|
||||
name: 'AI 工具',
|
||||
isSystem: false,
|
||||
entries: [
|
||||
NavItem(
|
||||
id: 'chatgpt',
|
||||
title: 'ChatGPT',
|
||||
url: 'https://chat.openai.com',
|
||||
icon: '🤖',
|
||||
isSystem: false,
|
||||
),
|
||||
NavItem(
|
||||
id: 'claude',
|
||||
title: 'Claude',
|
||||
url: 'https://claude.ai',
|
||||
icon: '🧠',
|
||||
isSystem: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-242
@@ -1,245 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/nav_item.dart';
|
||||
import 'models/category.dart';
|
||||
import 'data/mock_data.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'data/nav_data.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Navius 导航',
|
||||
theme: AppTheme.lightTheme,
|
||||
home: NaviusHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NaviusHomePage extends StatelessWidget {
|
||||
const NaviusHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 获取分类数据
|
||||
final categories = MockData.getCategories();
|
||||
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(120),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppTheme.appBarGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(30),
|
||||
bottomRight: Radius.circular(30),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 标题
|
||||
const Text(
|
||||
'Navius 导航',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${categories.length} 个分类 · ${_getTotalEntries(categories)} 个导航',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
return CategorySection(category: category);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
// 计算总导航数
|
||||
static int _getTotalEntries(List<Category> categories) {
|
||||
int total = 0;
|
||||
for (var category in categories) {
|
||||
total += category.entries.length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// 分类区块组件
|
||||
class CategorySection extends StatelessWidget {
|
||||
final Category category;
|
||||
|
||||
const CategorySection({super.key, required this.category});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 分类标题(美化版)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 分类名称
|
||||
Text(
|
||||
category.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1E293B),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 条目数量
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.systemTagColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${category.entries.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.systemTagTextColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 系统标签
|
||||
if (category.isSystem)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.systemTagColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'系统分类',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.systemTagTextColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 导航网格(4列)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
childAspectRatio: 1.0,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: category.entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = category.entries[index];
|
||||
return NavItemCard(item: item);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 单个导航卡片组件
|
||||
class NavItemCard extends StatelessWidget {
|
||||
final NavItem item;
|
||||
|
||||
const NavItemCard({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
web.window.open(item.url, '_blank');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
hoverColor: AppTheme.primaryColor.withOpacity(0.05),
|
||||
splashColor: AppTheme.primaryColor.withOpacity(0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标(增大)
|
||||
Text(
|
||||
item.icon,
|
||||
style: const TextStyle(fontSize: 36),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 标题
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 系统标签(改进样式)
|
||||
if (item.isSystem)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.systemTagColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'默认',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: AppTheme.systemTagTextColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
runApp(const NaviusApp());
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'nav_item.dart';
|
||||
|
||||
// 导航分类模型
|
||||
class Category {
|
||||
final String id; // 分类唯一标识
|
||||
final String name; // 分类名称
|
||||
final bool isSystem; // 是否系统分类(true=不可修改)
|
||||
final List<NavItem> entries; // 该分类下的导航条目
|
||||
|
||||
Category({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.isSystem,
|
||||
required this.entries,
|
||||
});
|
||||
|
||||
// 从 JSON 创建
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
entries: (json['entries'] as List)
|
||||
.map((e) => NavItem.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 导航条目模型
|
||||
class NavItem {
|
||||
final String id; // 唯一标识
|
||||
final String title; // 显示名称
|
||||
final String url; // 链接地址
|
||||
final String icon; // 图标(emoji 或文字)
|
||||
final bool isSystem; // 是否系统默认(true=不可修改)
|
||||
|
||||
// 构造函数
|
||||
NavItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.icon,
|
||||
required this.isSystem,
|
||||
});
|
||||
|
||||
// 从 JSON 创建对象(后续从后端获取数据时使用)
|
||||
factory NavItem.fromJson(Map<String, dynamic> json) {
|
||||
return NavItem(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
url: json['url'],
|
||||
icon: json['icon'] ?? '🔗',
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
// 颜色方案
|
||||
static const Color primaryColor = Color(0xFF2563EB);
|
||||
static const Color secondaryColor = Color(0xFF7C3AED);
|
||||
static const Color backgroundColor = Color(0xFFF8FAFC);
|
||||
static const Color cardColor = Colors.white;
|
||||
static const Color systemTagColor = Color(0xFFE2E8F0);
|
||||
static const Color systemTagTextColor = Color(0xFF64748B);
|
||||
|
||||
// 渐变背景
|
||||
static final LinearGradient appBarGradient = LinearGradient(
|
||||
colors: [primaryColor, secondaryColor],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
);
|
||||
|
||||
// 卡片阴影
|
||||
static final List<BoxShadow> cardShadow = [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
];
|
||||
|
||||
// 主题数据
|
||||
static ThemeData lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
background: backgroundColor,
|
||||
),
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
color: cardColor,
|
||||
shadowColor: Colors.transparent,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/core/constants/app_breakpoints.dart';
|
||||
|
||||
void main() {
|
||||
group('AppBreakpoints.gridColumnsFor', () {
|
||||
test('宽度不足一个单元时退化为单列', () {
|
||||
expect(AppBreakpoints.gridColumnsFor(200), 1);
|
||||
expect(AppBreakpoints.gridColumnsFor(220), 1);
|
||||
});
|
||||
|
||||
test('刚好放下两列与放不下时的边界', () {
|
||||
// 两列 = 220 * 2 + 16 = 456
|
||||
expect(AppBreakpoints.gridColumnsFor(456), 2);
|
||||
expect(AppBreakpoints.gridColumnsFor(455), 1);
|
||||
});
|
||||
|
||||
test('宽度越大列数越多', () {
|
||||
expect(AppBreakpoints.gridColumnsFor(900), 3);
|
||||
expect(AppBreakpoints.gridColumnsFor(1200), 5);
|
||||
});
|
||||
|
||||
test('与 CSS 的 auto-fill 规则一致', () {
|
||||
// 列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))
|
||||
for (final double width in <double>[300, 520, 740, 1080, 1440]) {
|
||||
final int columns = AppBreakpoints.gridColumnsFor(width);
|
||||
final double used = columns * 220 + (columns - 1) * 16;
|
||||
expect(used, lessThanOrEqualTo(width));
|
||||
expect(used + 236, greaterThan(width));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/features/home/data/mock_home_repository.dart';
|
||||
import 'package:frontend/features/home/domain/models/home_data.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_item.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_section.dart';
|
||||
import 'package:frontend/features/home/domain/models/nav_node.dart';
|
||||
|
||||
void main() {
|
||||
const MockHomeRepository repository = MockHomeRepository();
|
||||
final HomeData data = repository.fetchHomeData();
|
||||
|
||||
group('导航树(对照 index3.html 的 nav-group)', () {
|
||||
test('三个一级条目:一个直接跳转,两个可展开分组', () {
|
||||
expect(
|
||||
data.navNodes.map((NavNode node) => node.title),
|
||||
<String>['常用推荐', '素材资源', '开发工具'],
|
||||
);
|
||||
expect(data.navNodes[0].isGroup, isFalse);
|
||||
expect(data.navNodes[1].children.length, 3);
|
||||
expect(data.navNodes[2].children.length, 2);
|
||||
});
|
||||
|
||||
test('叶子 id 与原型里的锚点一致', () {
|
||||
expect(
|
||||
data.navLeaves.map((NavNode node) => node.id),
|
||||
<String>['common', 'design', 'icon', 'stock', 'dev', 'api'],
|
||||
);
|
||||
});
|
||||
|
||||
test('active 固定取第一个条目', () {
|
||||
expect(data.activeSectionId, 'common');
|
||||
});
|
||||
});
|
||||
|
||||
group('内容区(对照 index3.html 的两段内容)', () {
|
||||
test('只有两个分区', () {
|
||||
expect(
|
||||
data.sections.map((LinkSection s) => s.title),
|
||||
<String>['常用推荐', '素材资源 - UI 设计'],
|
||||
);
|
||||
});
|
||||
|
||||
test('两个分区共五张卡片,与原型的卡片数一致', () {
|
||||
expect(data.sectionById('common')!.items, hasLength(3));
|
||||
expect(data.sectionById('design')!.items, hasLength(2));
|
||||
expect(data.itemCount, 5);
|
||||
expect(data.allItems, hasLength(5));
|
||||
});
|
||||
|
||||
test('原型里没有对应元素的锚点取不到分区', () {
|
||||
for (final String id in <String>['icon', 'stock', 'dev', 'api']) {
|
||||
expect(data.sectionById(id), isNull, reason: '$id 不应该有内容');
|
||||
}
|
||||
});
|
||||
|
||||
test('分区 id 不重复', () {
|
||||
final Set<String> ids = data.sections
|
||||
.map((LinkSection s) => s.id)
|
||||
.toSet();
|
||||
expect(ids, hasLength(data.sections.length));
|
||||
});
|
||||
|
||||
test('条目字段完整且地址合法', () {
|
||||
for (final LinkItem item in data.allItems) {
|
||||
expect(item.id, isNotEmpty);
|
||||
expect(item.title, isNotEmpty);
|
||||
expect(item.description, isNotEmpty);
|
||||
expect(item.url, startsWith('https://'));
|
||||
expect(item.host, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('条目 id 全局唯一', () {
|
||||
final List<String> ids = data.allItems
|
||||
.map((LinkItem item) => item.id)
|
||||
.toList();
|
||||
expect(ids.toSet(), hasLength(ids.length));
|
||||
});
|
||||
});
|
||||
|
||||
group('LinkItem.initial', () {
|
||||
test('英文取首字母并大写', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: 'github', url: 'https://a.com').initial,
|
||||
'G',
|
||||
);
|
||||
});
|
||||
|
||||
test('中文取第一个字,不会被截成半个字符', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: '知乎', url: 'https://a.com').initial,
|
||||
'知',
|
||||
);
|
||||
});
|
||||
|
||||
test('空标题有兜底字符', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: '', url: 'https://a.com').initial,
|
||||
'·',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('NavNode.shortLabel', () {
|
||||
test('取首字', () {
|
||||
expect(const NavNode(id: 'x', title: '素材资源').shortLabel, '素');
|
||||
expect(const NavNode(id: 'x', title: 'GitHub').shortLabel, 'G');
|
||||
expect(const NavNode(id: 'x', title: '').shortLabel, '·');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/core/constants/app_sizes.dart';
|
||||
import 'package:frontend/core/theme/app_colors.dart';
|
||||
import 'package:frontend/features/home/data/mock_home_repository.dart';
|
||||
import 'package:frontend/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
|
||||
|
||||
import '../../../helpers/fake_url_opener.dart';
|
||||
|
||||
void main() {
|
||||
late FakeUrlOpener urlOpener;
|
||||
|
||||
setUp(() {
|
||||
urlOpener = FakeUrlOpener();
|
||||
});
|
||||
|
||||
Future<void> pumpPage(
|
||||
WidgetTester tester, {
|
||||
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
|
||||
// 「回到顶部」这类用例也就没有意义。
|
||||
Size size = const Size(900, 420),
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomePage(
|
||||
repository: const MockHomeRepository(),
|
||||
urlOpener: urlOpener,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
|
||||
Finder navText(String label) {
|
||||
return find.descendant(
|
||||
of: find.byType(SidebarNav),
|
||||
matching: find.text(label),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内容区的分区标题。
|
||||
Finder sectionTitle(String label) {
|
||||
return find.descendant(
|
||||
of: find.byType(LinkSectionView),
|
||||
matching: find.text(label),
|
||||
);
|
||||
}
|
||||
|
||||
double sidebarWidth(WidgetTester tester) {
|
||||
return tester.getSize(find.byType(SidebarNav)).width;
|
||||
}
|
||||
|
||||
Color? navColor(WidgetTester tester, String label) {
|
||||
return tester.widget<Text>(navText(label)).style?.color;
|
||||
}
|
||||
|
||||
/// 一级条目当前的底色(透明表示没有高亮)。
|
||||
List<String> highlightedRows(WidgetTester tester) {
|
||||
final List<String> rows = <String>[];
|
||||
for (final String label in <String>['常用推荐', '素材资源', '开发工具']) {
|
||||
final AnimatedContainer container = tester.widget<AnimatedContainer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: navText(label),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final Color? color = (container.decoration! as BoxDecoration).color;
|
||||
if (color != null && color.toARGB32() != 0) {
|
||||
rows.add(label);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 主内容区的滚动控制器。
|
||||
///
|
||||
/// 侧栏的导航容器也是一个 SingleChildScrollView,所以这里用
|
||||
/// 「搜索框的祖先」来精确定位主滚动视图,而不是靠 `.first` / `.last`。
|
||||
ScrollController mainController(WidgetTester tester) {
|
||||
final Finder mainScroll = find.ancestor(
|
||||
of: find.byType(TextField),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
return tester.widget<SingleChildScrollView>(mainScroll).controller!;
|
||||
}
|
||||
|
||||
double scrollOffset(WidgetTester tester) => mainController(tester).offset;
|
||||
|
||||
Future<void> expandMaterials(WidgetTester tester) async {
|
||||
await tester.tap(navText('素材资源'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 折叠态下导航项是空行(没有文字也没有图标),只能按位置定位:
|
||||
/// 0 是侧栏本身,1~3 是三条一级导航。
|
||||
Finder collapsedRow(int index) {
|
||||
return find
|
||||
.descendant(
|
||||
of: find.byType(SidebarNav),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.at(index);
|
||||
}
|
||||
|
||||
group('布局', () {
|
||||
testWidgets('侧栏展开常驻,渲染 logo、三条导航与底部提交入口', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(SidebarNav), findsOneWidget);
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(find.text('Suxing Nav'), findsOneWidget);
|
||||
|
||||
expect(navText('常用推荐'), findsOneWidget);
|
||||
expect(navText('素材资源'), findsOneWidget);
|
||||
expect(navText('开发工具'), findsOneWidget);
|
||||
expect(navText('提交网站'), findsOneWidget);
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('子菜单默认收起', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(navText('UI 设计'), findsNothing);
|
||||
expect(navText('图标库'), findsNothing);
|
||||
expect(navText('前端框架'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('两个分区与五张卡片,与原型一致', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
expect(find.byType(LinkCard), findsNWidgets(5));
|
||||
expect(sectionTitle('常用推荐'), findsOneWidget);
|
||||
expect(sectionTitle('素材资源 - UI 设计'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('侧栏折叠(对应 #toggleBtn)', () {
|
||||
testWidgets('点折叠按钮后宽度变为 64,logo 变成首字', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
|
||||
expect(find.text('Suxing Nav'), findsNothing);
|
||||
expect(find.text('S'), findsOneWidget);
|
||||
// 折叠态下 .nav-text 与 .submit-text 都被隐藏,只剩空行。
|
||||
expect(navText('常用推荐'), findsNothing);
|
||||
expect(navText('提交网站'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('再点一次可以展开回来', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(find.text('Suxing Nav'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('分组子菜单(对应 .has-sub 的脚本)', () {
|
||||
testWidgets('点击分组展开子菜单,再点收起', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await expandMaterials(tester);
|
||||
expect(navText('UI 设计'), findsOneWidget);
|
||||
expect(navText('图标库'), findsOneWidget);
|
||||
expect(navText('高清壁纸'), findsOneWidget);
|
||||
|
||||
await tester.tap(navText('素材资源'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(navText('UI 设计'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('折叠状态下点分组会先展开侧栏', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
|
||||
|
||||
await tester.tap(collapsedRow(2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(navText('UI 设计'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('导航与内容', () {
|
||||
testWidgets('高亮固定写在第一个条目上', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(navColor(tester, '常用推荐'), AppColors.primary);
|
||||
expect(navColor(tester, '素材资源'), AppColors.textSecondary);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('点有对应分区的导航会滚过去,高亮不变', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
final double before = scrollOffset(tester);
|
||||
await expandMaterials(tester);
|
||||
await tester.tap(navText('UI 设计'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), greaterThan(before));
|
||||
expect(navColor(tester, '常用推荐'), AppColors.primary);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('点没有对应分区的导航不会有任何反应', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await expandMaterials(tester);
|
||||
await tester.tap(navText('UI 设计'));
|
||||
await tester.pumpAndSettle();
|
||||
final double afterScroll = scrollOffset(tester);
|
||||
|
||||
// #icon 在原型里没有对应元素。
|
||||
await tester.tap(navText('图标库'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), afterScroll);
|
||||
});
|
||||
|
||||
testWidgets('搜索框只接收输入,不过滤内容', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'GitHub');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
expect(find.byType(LinkCard), findsNWidgets(5));
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller?.text,
|
||||
'GitHub',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('滚动', () {
|
||||
testWidgets('滚动主内容不会重建侧栏,也不会改动高亮', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
final ScrollController controller = mainController(tester);
|
||||
|
||||
AnimatedContainer firstRow() {
|
||||
return tester.widget<AnimatedContainer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: navText('常用推荐'),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
}
|
||||
|
||||
final AnimatedContainer beforeScroll = firstRow();
|
||||
|
||||
controller.jumpTo(controller.position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
identical(beforeScroll, firstRow()),
|
||||
isTrue,
|
||||
reason: '滚动主内容时侧栏被重建了',
|
||||
);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('回到顶部把偏移归零', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
final ScrollController controller = mainController(tester);
|
||||
|
||||
controller.jumpTo(controller.position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(scrollOffset(tester), greaterThan(0));
|
||||
|
||||
await tester.tap(find.byTooltip('回到顶部'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('其它', () {
|
||||
testWidgets('点击卡片交由 UrlOpener 打开对应地址', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.text('GitHub'));
|
||||
await tester.pump();
|
||||
|
||||
expect(urlOpener.openedUrls, <String>['https://github.com']);
|
||||
});
|
||||
|
||||
testWidgets('点提交网站不会有任何反应', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(navText('提交网站'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/core/constants/app_sizes.dart';
|
||||
import 'package:frontend/core/theme/app_colors.dart';
|
||||
import 'package:frontend/features/home/domain/models/link_item.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
|
||||
|
||||
const LinkItem _item = LinkItem(
|
||||
id: 'github',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
description: '代码托管与开源社区',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late int openCount;
|
||||
|
||||
setUp(() {
|
||||
openCount = 0;
|
||||
});
|
||||
|
||||
Future<void> pumpCard(
|
||||
WidgetTester tester, {
|
||||
LinkItem item = _item,
|
||||
Size size = const Size(280, 200),
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: LinkCard(item: item, onOpen: () => openCount++),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('渲染首字母图标、名称与描述', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
expect(find.text('G'), findsOneWidget);
|
||||
expect(find.text('GitHub'), findsOneWidget);
|
||||
expect(find.text('代码托管与开源社区'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('没有描述时不报错', (WidgetTester tester) async {
|
||||
await pumpCard(
|
||||
tester,
|
||||
item: const LinkItem(id: 'x', title: '示例', url: 'https://example.com'),
|
||||
);
|
||||
|
||||
expect(find.text('示'), findsOneWidget);
|
||||
expect(find.text('示例'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('点击触发打开回调', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
await tester.tap(find.text('GitHub'));
|
||||
await tester.pump();
|
||||
|
||||
expect(openCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('悬停时上浮', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
// AnimatedContainer 目标 transform 的 y 分量就是上浮距离:
|
||||
// 直接断言它,不受 Tooltip 等外层 RenderObject 的影响。
|
||||
double lift() {
|
||||
final AnimatedContainer container = tester.widget<AnimatedContainer>(
|
||||
find.descendant(
|
||||
of: find.byType(LinkCard),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
),
|
||||
);
|
||||
return container.transform?.getTranslation().y ?? 0;
|
||||
}
|
||||
|
||||
expect(lift(), 0);
|
||||
|
||||
final TestGesture gesture = await tester.createGesture(
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await gesture.addPointer(location: tester.getCenter(find.byType(LinkCard)));
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.moveTo(tester.getCenter(find.byType(LinkCard)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(lift(), -AppSizes.cardHoverLift);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:frontend/core/services/url_opener.dart';
|
||||
|
||||
/// [UrlOpener] 的测试替身:只记录调用,不真的打开浏览器。
|
||||
class FakeUrlOpener implements UrlOpener {
|
||||
/// 依次记录的链接。
|
||||
final List<String> openedUrls = <String>[];
|
||||
|
||||
@override
|
||||
void open(String url) {
|
||||
openedUrls.add(url);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:frontend/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+21
-22
@@ -1,45 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
如果应用部署在子路径下,把下面的 href 改成对应前缀,
|
||||
必须以 "/" 开头和结尾。构建时会由 flutter build 的 --base-href 替换。
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta name="description" content="Suxing Nav —— 极简导航站,把常用站点收进一个页面,随手可用。">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<!-- 浏览器地址栏与移动端状态栏配色,与 AppColors.primary 保持一致 -->
|
||||
<meta name="theme-color" content="#3B82F6">
|
||||
|
||||
<!-- iOS 添加到主屏幕时使用的元信息 -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="frontend">
|
||||
<meta name="apple-mobile-web-app-title" content="Suxing Nav">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>frontend</title>
|
||||
<title>Suxing Nav</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<style>
|
||||
/* Flutter 引擎加载完成前 body 是空的,先把底色铺成 AppColors.background,
|
||||
避免首屏闪一下白。 */
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #F7F9FB;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
flutter_bootstrap.js 可以自定义,用来给 Flutter 加载器传参,
|
||||
或在初始化期间给用户反馈。详见 Flutter 官方 Web 初始化文档。
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"short_name": "frontend",
|
||||
"name": "Suxing Nav",
|
||||
"short_name": "Suxing Nav",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#F7F9FB",
|
||||
"theme_color": "#3B82F6",
|
||||
"description": "极简导航站,把常用站点收进一个页面,随手可用。",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user