Initial project
This commit is contained in:
97
apps/im_app/lib/app/router/app_route_name.dart
Normal file
97
apps/im_app/lib/app/router/app_route_name.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
/// 应用路由枚举
|
||||
///
|
||||
/// 每个枚举值对应一个注册路由及其绝对路径。
|
||||
///
|
||||
/// ## 为什么用枚举而不是常量类
|
||||
///
|
||||
/// `auth_guard.dart` 对路由做 switch 分析,Dart 的枚举 switch 是穷举的:
|
||||
/// 新增路由时若没在 switch 里补 case,编译器直接报错,而不是等运行时漏掉。
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
/// ```dart
|
||||
/// // 无参数导航
|
||||
/// context.push(AppRouteName.settingsTheme.path);
|
||||
/// context.go(AppRouteName.chat.path);
|
||||
///
|
||||
/// // 带参数导航(extra 传对象,适合列表点入详情等已有数据的场景)
|
||||
/// context.push(
|
||||
/// AppRouteName.chatDetail.path,
|
||||
/// extra: (conversationId: '42', title: '技术支持'),
|
||||
/// );
|
||||
///
|
||||
/// // 带路径参数导航(路径中内嵌 id,适合需要直接链接或分享的场景)
|
||||
/// context.push(AppRouteName.chatDetailByIdPath('99'));
|
||||
///
|
||||
/// // 路由表定义
|
||||
/// GoRoute(path: AppRouteName.chat.path, ...)
|
||||
/// ```
|
||||
///
|
||||
/// ## 注意:子路由 path 是相对路径片段
|
||||
///
|
||||
/// go_router 在子路由中使用相对路径片段(不含父路径前缀),
|
||||
/// 这是框架规定,不是硬编码字符串:
|
||||
/// ```dart
|
||||
/// GoRoute(
|
||||
/// path: AppRouteName.settings.path, // '/settings'
|
||||
/// routes: [
|
||||
/// GoRoute(path: AppRouteName.settingsTheme.segment, ...), // 'theme'
|
||||
/// ],
|
||||
/// )
|
||||
/// ```
|
||||
/// 导航时仍用 `AppRouteName.settingsTheme.path`,与枚举保持一致。
|
||||
///
|
||||
/// 子路由声明使用 [segment](相对路径片段),避免在路由表中硬编码字符串:
|
||||
/// ```dart
|
||||
/// GoRoute(path: AppRouteName.settingsTheme.segment, ...) // 'theme'
|
||||
/// ```
|
||||
///
|
||||
/// ## 注意:含路径参数的路由
|
||||
///
|
||||
/// [chatDetailById] 的 [path] 包含占位符 `:id`,不能直接用于导航。
|
||||
/// 导航时使用 [chatDetailByIdPath] 传入实际 id:
|
||||
/// ```dart
|
||||
/// context.push(AppRouteName.chatDetailByIdPath('99'));
|
||||
/// ```
|
||||
enum AppRouteName {
|
||||
// ── Tab 根路由 ────────────────────────────────────────────────────────────
|
||||
chat('/chat'),
|
||||
contact('/contact'),
|
||||
settings('/settings'),
|
||||
|
||||
// ── Chat 子路由 ──────────────────────────────────────────────────────────
|
||||
// extra: ({String conversationId, String title})
|
||||
chatDetail('/chat/detail'),
|
||||
// 路径参数形式:导航用 AppRouteName.chatDetailByIdPath(id),不直接用 .path
|
||||
chatDetailById('/chat/:id'),
|
||||
|
||||
// ── Settings 子路由 ───────────────────────────────────────────────────────
|
||||
settingsTheme('/settings/theme'),
|
||||
|
||||
// ── 全屏页面(无底部导航栏)──────────────────────────────────────────────
|
||||
login('/login');
|
||||
|
||||
const AppRouteName(this.path);
|
||||
|
||||
/// 绝对路径,用于 [context.push] / [context.go] 导航及顶层路由表声明
|
||||
///
|
||||
/// 注意:[chatDetailById] 的 path 含占位符 `:id`,导航时用 [chatDetailByIdPath]。
|
||||
final String path;
|
||||
|
||||
/// 相对路径片段(path 的最后一段),用于 go_router 子路由的 [GoRoute.path] 声明
|
||||
///
|
||||
/// 例:`AppRouteName.settingsTheme.segment` → `'theme'`
|
||||
String get segment => path.split('/').last;
|
||||
|
||||
/// 从绝对路径查找枚举值,路径未注册时返回 null
|
||||
///
|
||||
/// 注意:含路径参数的路由(如 `/chat/99`)无法匹配,返回 null,
|
||||
/// auth_guard 会按受保护路由处理(未登录重定向到登录页)。
|
||||
static AppRouteName? fromPath(String path) =>
|
||||
AppRouteName.values.where((r) => r.path == path).firstOrNull;
|
||||
|
||||
/// 生成 [chatDetailById] 的实际导航路径,将 `:id` 替换为真实 id
|
||||
///
|
||||
/// 例:`AppRouteName.chatDetailByIdPath('99')` → `'/chat/99'`
|
||||
static String chatDetailByIdPath(String id) => '/chat/$id';
|
||||
}
|
||||
154
apps/im_app/lib/app/router/app_router.dart
Normal file
154
apps/im_app/lib/app/router/app_router.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/app_tab/view/app_tab.dart';
|
||||
import '../../features/chat/view/chat_detail_page.dart';
|
||||
import '../../features/chat/view/chat_page.dart';
|
||||
import '../../features/contact/view/contact_page.dart';
|
||||
import '../../features/login/view/login_page.dart';
|
||||
import '../../features/settings/view/settings_page.dart';
|
||||
import '../../features/settings/view/theme_view.dart';
|
||||
import '../di/app_providers.dart';
|
||||
import 'app_route_name.dart';
|
||||
import 'guards/auth_guard.dart';
|
||||
|
||||
/// 应用路由 Provider
|
||||
///
|
||||
/// 路由结构:
|
||||
/// ```
|
||||
/// StatefulShellRoute(底部导航栏持久容器)
|
||||
/// ├── /chat ChatPage
|
||||
/// ├── /contact ContactPage
|
||||
/// └── /settings SettingsPage
|
||||
///
|
||||
/// ── 全屏页面(无底部导航栏,parentNavigatorKey = _rootKey)──
|
||||
/// /chat/detail ChatDetailPage(extra 传参)
|
||||
/// /chat/:id ChatDetailPage(路径参数)
|
||||
/// /settings/theme ThemeView
|
||||
/// /login LoginPage
|
||||
/// ```
|
||||
///
|
||||
/// ## Shell 内 vs Shell 外
|
||||
///
|
||||
/// Shell 内路由(Tab 根路由)始终显示底部导航栏。
|
||||
/// Shell 外路由(详情页 / 子功能页)无底部导航栏,push 进入后有返回按钮。
|
||||
/// 这与 iOS / Android 主流 IM App 的交互一致(会话详情、设置子页均全屏)。
|
||||
///
|
||||
/// ## parentNavigatorKey 的作用
|
||||
///
|
||||
/// go_router push 时,路由默认放到"最近的 Navigator 祖先"上。
|
||||
/// 在 StatefulShellBranch 内 push,最近的 Navigator 是 Branch Navigator,
|
||||
/// 而不是 Root Navigator,Shell 不会被盖住,TabBar 仍然可见。
|
||||
///
|
||||
/// 设置 `parentNavigatorKey: _rootKey` 后,路由强制放到 Root Navigator,
|
||||
/// 盖住整个 Shell,TabBar 消失,表现为真正的全屏页面。
|
||||
///
|
||||
/// ## 登录守卫
|
||||
///
|
||||
/// [authGuard] 检查 [AuthNotifier.isLoggedIn],未登录时重定向到 /login。
|
||||
/// 登录 / 退出后 [AuthNotifier.notifyListeners] 触发 [refreshListenable],
|
||||
/// go_router 自动重新执行 redirect,无需手动跳转。
|
||||
///
|
||||
/// ## Tab 状态保持
|
||||
///
|
||||
/// [StatefulShellRoute.indexedStack] 为每个 Tab 维护独立的 Navigator 栈,
|
||||
/// 切换 Tab 时页面状态(滚动位置、输入内容等)不丢失。
|
||||
|
||||
// Root Navigator Key:供全屏路由声明 parentNavigatorKey,确保覆盖整个 Shell
|
||||
final _rootKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final authNotifier = ref.read(authNotifierProvider);
|
||||
|
||||
return GoRouter(
|
||||
// Root Navigator 的 Key,供全屏路由声明 parentNavigatorKey 使用,
|
||||
// 确保 push 时覆盖整个 Shell(包括 TabBar)
|
||||
navigatorKey: _rootKey,
|
||||
|
||||
// 冷启动默认落地页;authGuard 会在进入前检查登录状态并按需重定向
|
||||
initialLocation: AppRouteName.chat.path,
|
||||
|
||||
// 在控制台打印每次路由变化,方便开发期间调试;上线前设为 false
|
||||
debugLogDiagnostics: true,
|
||||
|
||||
// 监听 authNotifier 变化:登录 / 退出时自动触发 redirect 重新执行,
|
||||
// 无需在业务代码里手动 context.go,守卫统一负责跳转
|
||||
refreshListenable: authNotifier,
|
||||
|
||||
redirect: (context, state) => authGuard(authNotifier, state),
|
||||
routes: [
|
||||
// ── Shell 内:底部导航栏始终可见 ─────────────────────────────────────
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) {
|
||||
return AppTab(navigationShell: navigationShell);
|
||||
},
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRouteName.chat.path,
|
||||
builder: (context, state) => const ChatPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRouteName.contact.path,
|
||||
builder: (context, state) => const ContactPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRouteName.settings.path,
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── Shell 外:全屏页面,无底部导航栏 ─────────────────────────────────
|
||||
// parentNavigatorKey: _rootKey 确保路由覆盖 Shell,TabBar 消失
|
||||
//
|
||||
// extra 传参:接收 ({String conversationId, String title})
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.chatDetail.path,
|
||||
builder: (context, state) {
|
||||
final extra =
|
||||
state.extra as ({String conversationId, String title});
|
||||
return ChatDetailPage(
|
||||
conversationId: extra.conversationId,
|
||||
title: extra.title,
|
||||
);
|
||||
},
|
||||
),
|
||||
// 路径参数:id 内嵌在 URL 中(/chat/:id)
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.chatDetailById.path,
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return ChatDetailPage(
|
||||
conversationId: id,
|
||||
title: '路径参数详情',
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.settingsTheme.path,
|
||||
builder: (context, state) => const ThemeView(),
|
||||
),
|
||||
GoRoute(
|
||||
parentNavigatorKey: _rootKey,
|
||||
path: AppRouteName.login.path,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
0
apps/im_app/lib/app/router/guards/.gitkeep
Normal file
0
apps/im_app/lib/app/router/guards/.gitkeep
Normal file
47
apps/im_app/lib/app/router/guards/auth_guard.dart
Normal file
47
apps/im_app/lib/app/router/guards/auth_guard.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../di/app_providers.dart';
|
||||
import '../app_route_name.dart';
|
||||
|
||||
/// 登录守卫
|
||||
///
|
||||
/// 在 [GoRouter.redirect] 中调用,返回 null 表示放行,返回路径表示重定向目标。
|
||||
/// 接收 [AuthNotifier] 而非 [Ref],避免守卫内部依赖 Riverpod,便于单测。
|
||||
///
|
||||
/// ## 穷举保护
|
||||
///
|
||||
/// 使用 [AppRouteName] 枚举 + switch 分析路由权限,Dart 编译器保证穷举:
|
||||
/// 在 [AppRouteName] 新增枚举值后,此处 switch 未补 case 则编译报错。
|
||||
///
|
||||
/// ## 路由权限规则
|
||||
///
|
||||
/// | 路由 | 未登录 | 已登录 |
|
||||
/// |------|--------|--------|
|
||||
/// | login | 放行 | 重定向 → chat |
|
||||
/// | 其余 | 重定向 → login | 放行 |
|
||||
///
|
||||
/// ## storage_sdk 接入后
|
||||
///
|
||||
/// 将 [AuthNotifier] 内的 Demo 状态替换为持久化 token,守卫本身无需改动。
|
||||
String? authGuard(AuthNotifier authNotifier, GoRouterState state) {
|
||||
final isLoggedIn = authNotifier.isLoggedIn;
|
||||
final route = AppRouteName.fromPath(state.matchedLocation);
|
||||
|
||||
// 路径不在枚举中(理论上不应出现)→ 按受保护处理
|
||||
if (route == null) return isLoggedIn ? null : AppRouteName.login.path;
|
||||
|
||||
switch (route) {
|
||||
case AppRouteName.login:
|
||||
// 已登录还在登录页 → 跳聊天页
|
||||
return isLoggedIn ? AppRouteName.chat.path : null;
|
||||
|
||||
case AppRouteName.chat:
|
||||
case AppRouteName.chatDetail:
|
||||
case AppRouteName.chatDetailById:
|
||||
case AppRouteName.contact:
|
||||
case AppRouteName.settings:
|
||||
case AppRouteName.settingsTheme:
|
||||
// 受保护路由 → 未登录跳登录页
|
||||
return isLoggedIn ? null : AppRouteName.login.path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user