flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/providers/privacy_provider.dart';
import 'package:new_learn/core/router/router_keys.dart';
import 'package:new_learn/features/auth/legal_document_page.dart';
import 'package:new_learn/features/auth/legal_documents.dart';
import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/features/auth/privacy_consent_page.dart';
import 'package:new_learn/features/class/class_page.dart';
import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/recite/recite_page.dart';
import 'package:new_learn/features/task/task_page.dart';
import 'package:new_learn/core/router/main_tab_page.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/providers/user_provider.dart';
/// ============================================================
/// 【AppRouter】全局路由表(对标 Vue 的 router/index.ts)
///
/// 用户点退出
/// → ref.read(currentUserProvider.notifier).set(null)
/// → state = AsyncData(null) // 清空用户信息
/// → authTick.value++ // 0 变成 1
/// → GoRouter 收到通知,执行 refresh()
/// → 重新跑 _guardLogin()
/// → 发现 accessToken == null(登出时已清除)
/// → 当前不在 /login
/// → return '/login' // 自动跳转到登录页
///
/// 一、为什么用 go_router(Flutter 官方推荐声明式路由):
/// 页面以后会很多,集中管路由表比散落各页面的 Navigator.push
/// 更可控,且天然支持登录守卫 / tab 嵌套 / 深链。
///
/// 二、与 vue-router 的对应关系:
/// routes 路由表 = vue-router 的 routes
/// redirect 登录守卫 = 路由前置守卫 beforeEach
/// ShellRoute 4 tab 壳 = uni-app 的 tabBar + 子页面
/// context.push('/x') = router.push('/x')
/// context.go('/x') = router.replace('/x')(不留历史,如登录后)
///
/// 三、路由结构:
/// /login → 登录页(无 token 时被 redirect 强制到这里)
/// / → 任务页(4 tab 第 1 个)
/// /recite /class /my → 其余 3 个 tab(由 ShellRoute 包住,共用一个底部导航)
/// 以后新业务页:在 routes 里加一行 GoRoute 即可。
///
/// 四、登录守卫说明:
/// 判断依据是 DioClient.accessToken(内存)——main() 里已 restoreToken
/// 回填,所以启动时第一个跳转就能正确判断。
/// ⚠ redirect 只在【导航发生时】触发,登录/登出不导航守卫不会动,
/// 所以配了 refreshListenable: authTick —— 登录态一变(user_provider
/// 的 set() 里 authTick.value++)就主动 GoRouter.refresh() 重算一次,
/// 未登录访问受限页 → 自动踢 /login,已登录访问 /login → 踢回 /。
/// 业务代码永远不用手动跳登录页(这就是"响应式登录守卫")。
///
/// 五、Web 地址栏同步(go_router 17.x 大坑):
/// 17.x 起 optionURLReflectsImperativeAPIs 默认 false:context.push/go
/// 导航时页面正常切换,但浏览器地址栏不更新(刷新会退回上一个路由)。
/// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。
/// ============================================================
/// 全局路由表(对标 Vue 的 router/index.ts)
final GoRouter appRouter = GoRouter(
navigatorKey:
rootNavigatorKey, // 告诉 GoRouter:这是根 Navigator 作用: 在拿不到 BuildContext 的地方,也能操作导航和弹窗
redirect: _guardLogin, // 登录守卫:未登录访问受限页 → 自动跳转到登录页
// 两类全局门禁都要能触发路由重算:
// 1. authTick:登录/登出变化;
// 2. privacyAcceptedNotifier:隐私协议同意状态变化。
// 响应式登录守卫的核心:监听登录态变化信号(user_provider 的 set()
// 里自增),变化时自动 GoRouter.refresh() 重算 redirect。
// 没有它,登出后只有下一次手动导航才会被拦截。
refreshListenable: Listenable.merge([
authTick,
privacyAcceptedNotifier,
]), // 响应式登录守卫:监听登录态变化信号,变化时自动重算 redirect
routes: [
// ---------- 4 tab 主框架(ShellRoute:子页面共享这个壳) ----------
ShellRoute(
builder: (context, state, child) => MainTabPage(child: child),
routes: [
GoRoute(path: '/', builder: (_, _) => const TaskPage()),
GoRoute(path: '/recite', builder: (_, _) => const RecitePage()),
GoRoute(path: '/class', builder: (_, _) => const ClassPage()),
GoRoute(path: '/my', builder: (_, _) => const MyPage()),
],
),
// ---------- 首次启动隐私协议页 ----------
GoRoute(
path: '/privacy-consent',
builder: (_, _) => const PrivacyConsentPage(),
),
GoRoute(
path: '/user-agreement',
builder: (_, _) => const LegalDocumentPage(
title: kUserAgreementTitle,
content: kUserAgreementContent,
),
),
GoRoute(
path: '/privacy-policy',
builder: (_, _) => const LegalDocumentPage(
title: kPrivacyPolicyTitle,
content: kPrivacyPolicyContent,
),
),
// ---------- 登录页 ----------
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
// ---------- 业务子页(从 tab 页 push 进来,不占 tab) ----------
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
// ---------- 以后新业务页在这里加,一行一个 ----------
],
);
/// 登录守卫(对标 vue-router beforeEach):
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页
String? _guardLogin(BuildContext context, GoRouterState state) {
final acceptedPrivacy = privacyAcceptedNotifier.value;
final loggedIn = DioClient.instance.accessToken != null;
final atPrivacyConsent = state.matchedLocation == '/privacy-consent';
final atLegalDocument =
state.matchedLocation == '/user-agreement' ||
state.matchedLocation == '/privacy-policy';
final atLogin = state.matchedLocation == '/login';
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
return '/privacy-consent';
}
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
if (!loggedIn && !atLogin && !atLegalDocument) return '/login';
if (loggedIn && atLogin) return '/';
return null;
}
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/components/app_update_dialog.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/version/app_version_service.dart';
/// ============================================================
/// 【MainTabPage】App 主框架 —— 底部 4 tab 壳(go_router ShellRoute 模式)
///
/// 一、结构(go_router 标准写法):
/// app_router.dart 里用 ShellRoute 包住本组件,ShellRoute 提供 child
/// (当前 tab 页面),本组件负责画壳:
/// Scaffold
/// ├── body: child(来自 ShellRoute,随路由切换)
/// └── bottomNavigationBar: BottomNavigationBar(4 项,PNG 图标)
///
/// 二、tab 高亮原理:
/// 通过 GoRouterState.of(context) 读当前路由 path 反推 tab 序号;
/// 点底部导航 → context.go('/recite') 等,路由变了壳自动重绘。
/// 对比旧版自己管理 IndexedStack + _index:现在页面栈交给 go_router,
/// 切换 tab 的状态保留由 go_router 的页面缓存机制负责。
///
/// 三、图标方案(重点):
/// 两套 PNG,标准做法:BottomNavigationBarItem 的
/// - icon = 未选中图(assets/img/tabbar/*.png)
/// - activeIcon = 选中图(assets/img/tabbar/default/*Choose.png)
/// BottomNavigationBar 会根据 currentIndex 自动切换,无需手动处理。
/// ⚠ 不要用 ColorFiltered 染色:会把多色/渐变/镂空压平成一团色块。
///
/// 四、高频坑:
/// 1. BottomNavigationBarItem.icon 必须提供,activeIcon 可选;
/// 2. 4 个 tab 时 type 默认 fixed(label 都会显示);
/// 3. Image.asset 图片要先把 assets 目录注册进 pubspec.yaml
/// (本工程已注册整个 assets/ 目录);
/// 4. 在 tab 内 push 子页面(如 /task/1)时,ShellRoute 保证
/// 底部导航不消失、子页面盖在内容区上——这是 ShellRoute 的价值。
/// ============================================================
class MainTabPage extends StatefulWidget {
const MainTabPage({super.key, required this.child});
/// 当前 tab 页面(由 go_router ShellRoute 提供)
final Widget child;
@override
State<MainTabPage> createState() => _MainTabPageState();
}
class _MainTabPageState extends State<MainTabPage> {
/// 标记:本页面生命周期只执行一次冷启动版本检查
bool _versionChecked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
// addPostFrameCallback:保证页面首帧渲染完成,context 可用再弹窗
if (_versionChecked) return;
_versionChecked = true;
await _checkVersion();
});
}
/// 版本检查流程:初始化 → 拉取远端 → 比对 → 弹窗
/// 仅冷启动执行一次,不回前台重复检查(避免骚扰用户)
///
/// ⚠ 前置校验登录态:路由守卫 redirect 理论上会拦住未登录用户,
/// 但 MainTabPage 可能在 redirect 生效前就被构建(首帧回调时序),
/// 所以这里兜底:没登录就不弹窗,避免未登录用户看到版本更新弹窗。
Future<void> _checkVersion() async {
// 未登录 → 路由守卫即将重定向到 /login,版本弹窗没有意义
if (DioClient.instance.accessToken == null) return;
try {
final service = AppVersionService.instance;
await service.init();
final update = await service.checkForUpdate();
// 二次校验:异步等待期间可能已被路由守卫重定向到登录页
if (update == null || !mounted) return;
if (DioClient.instance.accessToken == null) return;
final isForce = service.isForceUpdate();
await AppUpdateDialog.show(context, update, isForce: isForce);
} catch (e) {
debugPrint('▶ 【版本】检查更新异常:$e');
}
}
/// 未选中态图标(assets/img/tabbar/ 根目录)
static const List<String> _icons = [
'assets/img/tabbar/index.png', // 任务
'assets/img/tabbar/recite.png', // 背诵
'assets/img/tabbar/class.png', // 班级
'assets/img/tabbar/my.png', // 我的
];
/// 选中态图标(assets/img/tabbar/default/ 下的 *Choose.png)
static const List<String> _activeIcons = [
'assets/img/tabbar/default/indexChoose.png',
'assets/img/tabbar/default/reciteChoose.png',
'assets/img/tabbar/default/classChoose.png',
'assets/img/tabbar/default/myChoose.png',
];
/// 当前路由 path → tab 序号(默认第 1 个"任务")
int _indexOf(String path) {
return switch (path) {
'/recite' => 1,
'/class' => 2,
'/my' => 3,
_ => 0,
};
}
/// tab 序号 → 路由 path(点击底部导航时跳转)
String _pathOf(int index) {
return switch (index) {
1 => '/recite',
2 => '/class',
3 => '/my',
_ => '/',
};
}
@override
Widget build(BuildContext context) {
// 从 go_router 拿当前路由,反推底部高亮 tab
final path = GoRouterState.of(context).uri.path;
final currentIndex = _indexOf(path);
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: (i) => context.go(_pathOf(i)),
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF3B82F6),
unselectedItemColor: const Color(0xFF9E9E9E),
selectedFontSize: 11.sp,
unselectedFontSize: 11.sp,
iconSize: 24.w,
items: [
for (int i = 0; i < _icons.length; i++)
BottomNavigationBarItem(
// icon = 未选中图,activeIcon = 选中图,两套图自动切换
icon: Image.asset(_icons[i], width: 24.w, height: 24.h),
activeIcon: Image.asset(
_activeIcons[i],
width: 24.w,
height: 24.h,
),
label: switch (i) {
0 => '任务',
1 => '背诵',
2 => '班级',
_ => '我的',
},
),
],
),
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter/widgets.dart';
/// 全局根导航 key:
/// 1. 给 GoRouter 作为根 Navigator 使用;
/// 2. 供启动隐私 Gate 这类"路由外层"页面直接打开子页面。
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();