flutter架 构项目基础
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 => '班级',
|
||||
_ => '我的',
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 全局根导航 key:
|
||||
/// 1. 给 GoRouter 作为根 Navigator 使用;
|
||||
/// 2. 供启动隐私 Gate 这类"路由外层"页面直接打开子页面。
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
Reference in New Issue
Block a user