Files
nj/app/lib/core/routing/app_router.dart
iven 181bfb1f3e fix(app): 对齐 Open Design spec — 字体/Token/首页/Tab栏/路由/Discover页
针对 docs/opendesign/warm-notes-design-spec.md 全面审查的修复:

## 🔴 阻断级修复(商用合规)
- 下载真实 Quicksand/Nunito 字体文件(原 0 字节)
- 添加 OFL.txt 许可证文件,履行 SIL Open Font License 分发义务

## 🟠 设计 Token 偏差
- AppRadius: 删除非规范的 xs=8px,所有引用迁移至 sm=10px
- AppColors.moodColors: 对齐 spec §3.6
  - happy #FFD93D → secondary #81B29A
  - calm #81B29A → tertiary #F2CC8F
  - sad #7B9CC4 → #5B7DB1
  - thinking #B8A9C9(淡紫,spec 无)→ #8B7E74
- AppShadows: blurRadius/alpha 精确对齐 spec §1 (12/20/32 + 0.06/0.08/0.12)
- DesignTokens: 补 spacing40 + 新增 safe-top/safe-bottom/tab-height/touch-min 常量

## 🟠 首页 §3.4 完全重构
- 新增问候语头部(xx好,小暖 + accent 色高亮名字)
- 新增 streak-badge pill 徽章(tertiary-soft + #B8860B 暖金)
- 心情选择器卡片背景从 primaryContainer 改为 surface(spec 规定 #FFFFFF)
- 心情卡片圆角 lg(22) → md(16) 对齐 spec
- 新增 today-card 渐变卡片 + 浮动右下圆形写按钮
- 新增 quick-stats 三栏统计(本月日记/连续天数/总日记数)
- 移除 AppBar 多余的贴纸/模板按钮,搜索按钮改路由到 /search
- HomeBloc 扩展 monthCount/totalCount 字段
- 日记卡片:72×72 预览图 + 标签摘要 + 心情圆点

## 🟠 路由 §3.12 + §3.13 拆分
- 新建 DiscoverPage (features/discover/views/discover_page.dart)
  - 搜索框(跳转 /search)
  - 每日推荐渐变卡片
  - 热门话题横向 chips(前 3 个 accent 高亮)
  - 精选模板 2 列网格
  - 达人日记列表
- /discover 路由从指向 SearchPage 改为 DiscoverPage
- 新增 /search 路由(全屏无 Tab)指向 SearchPage

## 🟠 Tab 栏 §2.2 重构
- 高度从 64px 改为 56+bottomPadding(含 safe-bottom,约 90px)
- 中心按钮从 CircularNotchedRectangle 凹槽改为 margin-top:-16px 凸起
- FAB 尺寸从默认改为 48×48 spec 规格
- FAB 图标从 edit_rounded 改为 add_rounded(spec §2.2)
- 删除未使用的 _navItems 旧常量

## 🟡 登录页圆角统一
- 移除 3 处 InputBorder 显式 mdBorder(16px) 覆盖
- 全局主题 smBorder(10px) 生效,对齐 spec
- 提交按钮圆角改为 pill(spec §2.6 Primary 按钮)

## 验证
- flutter analyze: 0 errors (剩余 40 个 warning/info 全为预存)
- flutter test: 84/85 通过(widget smoke test 预存失败,与本次无关)
2026-06-02 09:11:46 +08:00

325 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 暖记路由表 — go_router + 认证守卫
// 对齐 Open Design: TabBar = 首页/日历/发现/我的,中心 FAB = 写日记
//
// 路由守卫逻辑:
// - 未认证用户访问受保护路由 → 重定向到 /login
// - 已认证用户访问 /login → 重定向到 /home
// - 需要角色选择 → 重定向到 /role-selection
// - 需要班级码 → 重定向到 /class-code
export '../../widgets/responsive_scaffold.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../widgets/responsive_scaffold.dart';
import '../../features/home/views/home_page.dart';
import '../../features/calendar/views/calendar_page.dart';
import '../../features/mood/views/mood_page.dart';
import '../../features/search/views/search_page.dart';
import '../../features/discover/views/discover_page.dart';
import '../../features/calendar/views/weekly_page.dart';
import '../../features/calendar/views/monthly_page.dart';
import '../../features/profile/views/profile_page.dart';
import '../../features/editor/views/editor_page.dart';
import '../../features/auth/views/login_page.dart';
import '../../features/auth/views/role_selection_page.dart';
import '../../features/auth/views/class_code_join_page.dart';
import '../../features/onboarding/views/splash_page.dart';
import '../../features/onboarding/views/onboarding_page.dart';
import '../../features/class_/views/class_page.dart';
import '../../features/teacher/views/teacher_page.dart';
import '../../features/parent/views/parent_page.dart';
import '../../features/parent/bloc/parent_bloc.dart';
import '../../features/achievement/views/achievement_page.dart';
import '../../features/stickers/views/sticker_library_page.dart';
import '../../features/templates/views/template_gallery_page.dart';
import '../../features/settings/views/settings_page.dart';
import '../../features/auth/bloc/auth_bloc.dart';
import '../../features/search/bloc/search_bloc.dart';
import '../../data/repositories/journal_repository.dart';
import '../../data/remote/api_client.dart';
// Shell 分支键
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// 不需要认证的白名单路径
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/class-code'];
/// 创建路由配置 — 需要注入 AuthBloc
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/splash',
debugLogDiagnostics: true,
// ===== 认证路由守卫 =====
redirect: (context, state) {
final authState = authBloc.state;
final currentPath = state.uri.path;
// 加载中 → 不做重定向
if (authState is AuthInitial || authState is AuthLoading) {
return null;
}
final isAuthenticated = authState is Authenticated;
final isPublicPath = _publicPaths.contains(currentPath);
// 已认证 + 访问公开页面 → 根据状态重定向
if (isAuthenticated && isPublicPath) {
if (authState.needsRoleSelection) return '/role-selection';
if (authState.needsClassCode) return '/class-code';
return '/home';
}
// 已认证 + 访问受保护页面 → 检查是否需要额外步骤
if (isAuthenticated) {
if (authState.needsRoleSelection && currentPath != '/role-selection') {
return '/role-selection';
}
if (authState.needsClassCode &&
currentPath != '/class-code' &&
currentPath != '/role-selection') {
return '/class-code';
}
return null;
}
// 未认证 + 访问公开页面 → 放行
if (isPublicPath) return null;
// 未认证 + 访问受保护页面 → 重定向到登录
return '/login';
},
// 监听认证状态变化,自动触发重定向
refreshListenable: _AuthListenable(authBloc),
routes: [
// 启动页 & 引导页(无 Shell无需认证
GoRoute(
path: '/splash',
name: 'splash',
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: '/onboarding',
name: 'onboarding',
builder: (context, state) => const OnboardingPage(),
),
// 认证路由(无 Shell
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/role-selection',
name: 'roleSelection',
builder: (context, state) => const RoleSelectionPage(),
),
GoRoute(
path: '/class-code',
name: 'classCode',
builder: (context, state) => const ClassCodeJoinPage(),
),
// 主 Shell 路由(底部导航: 首页/日历/发现/我的)
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) {
final index = _selectedIndexFromLocation(state.uri.path);
return _AppShell(
selectedIndex: index,
child: child,
);
},
routes: [
GoRoute(
path: '/home',
name: 'home',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/calendar',
name: 'calendar',
builder: (context, state) => const CalendarPage(),
),
// 发现页 — 灵感、话题、达人日记spec §3.12
GoRoute(
path: '/discover',
name: 'discover',
builder: (context, state) => const DiscoverPage(),
),
// 个人中心
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const ProfilePage(),
),
],
),
// 搜索页 — 全屏无 Tabspec §3.13
GoRoute(
path: '/search',
name: 'search',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final journalRepo = context.read<JournalRepository>();
return BlocProvider(
create: (_) => SearchBloc(journalRepository: journalRepo),
child: const SearchPage(),
);
},
),
// 全屏页面(无底部导航)
GoRoute(
path: '/editor',
name: 'editor',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final journalId = state.uri.queryParameters['id'];
final templateId = state.uri.queryParameters['template'];
return EditorPage(journalId: journalId, templateId: templateId);
},
),
// 心情追踪(全屏,从首页心情卡片进入)
GoRoute(
path: '/mood',
name: 'mood',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MoodPage(),
),
// 周概览(全屏,从日历页进入)
GoRoute(
path: '/weekly',
name: 'weekly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const WeeklyPage(),
),
// 月度概览(全屏,从日历页进入)
GoRoute(
path: '/monthly',
name: 'monthly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MonthlyPage(),
),
GoRoute(
path: '/class',
name: 'class',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const ClassPage(),
),
GoRoute(
path: '/teacher',
name: 'teacher',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const TeacherPage(),
),
GoRoute(
path: '/parent',
name: 'parent',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
return BlocProvider(
create: (_) => ParentBloc(api: context.read<ApiClient>()),
child: const ParentPage(),
);
},
),
GoRoute(
path: '/achievements',
name: 'achievements',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const AchievementPage(),
),
GoRoute(
path: '/stickers',
name: 'stickers',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const StickerLibraryPage(),
),
GoRoute(
path: '/templates',
name: 'templates',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const TemplateGalleryPage(),
),
GoRoute(
path: '/settings',
name: 'settings',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const SettingsPage(),
),
],
);
}
/// 路径 → Tab index 映射4 项: 首页=0, 日历=1, 发现=2, 我的=3
int _selectedIndexFromLocation(String location) {
if (location.startsWith('/calendar')) return 1;
if (location.startsWith('/discover')) return 2;
if (location.startsWith('/profile')) return 3;
return 0; // /home 或未知路径
}
/// AuthBloc 变化监听器 — 驱动 GoRouter refreshListenable
class _AuthListenable extends ChangeNotifier {
_AuthListenable(AuthBloc authBloc) {
_subscription = authBloc.stream.listen((_) {
notifyListeners();
});
}
late final StreamSubscription<AuthState> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
/// App Shell — 包裹 ResponsiveScaffold
/// TabBar: 首页(0) / 日历(1) / 发现(2) / 我的(3)
/// 中心 FAB: 写日记
class _AppShell extends StatelessWidget {
const _AppShell({
required this.selectedIndex,
required this.child,
});
final int selectedIndex;
final Widget child;
@override
Widget build(BuildContext context) {
return ResponsiveScaffold(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/home');
case 1:
context.go('/calendar');
case 2:
context.go('/discover');
case 3:
context.go('/profile');
}
},
body: child,
onCenterButtonPressed: () => context.push('/editor'),
);
}
}