Initial project

This commit is contained in:
Cody
2026-03-06 14:56:17 +08:00
parent 977b627b15
commit bf9e099747
1180 changed files with 50973 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/ui/base/app_theme.dart';
import 'di/app_providers.dart';
import 'di/network_provider.dart';
import 'router/app_router.dart';
/// 应用根组件
///
/// 职责:
/// - 路由配置go_router含登录守卫
/// - 主题配置(亮色 / 暗色 / 跟随系统)
/// - App 生命周期监听(前后台切换 → WebSocket 断连/重连)
/// - 启动初始化([AppInitializer] 两阶段串行队列)
///
/// ## 启动初始化
///
/// 通过 [appInitializerProvider] 声明任务,两阶段串行执行:
/// - Critical首帧前只放必须阻塞的任务
/// - Deferred首帧后不争抢资源、不卡 UI
///
/// 详见 [AppInitializer] 文档。
class IMApp extends ConsumerStatefulWidget {
const IMApp({super.key});
@override
ConsumerState<IMApp> createState() => _IMAppState();
}
class _IMAppState extends ConsumerState<IMApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
ref.read(appInitializerProvider).run();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final socketManager = ref.read(socketManagerProvider);
switch (state) {
case AppLifecycleState.resumed:
socketManager.onEnterForeground();
case AppLifecycleState.paused:
socketManager.onEnterBackground();
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
break;
}
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'IM App', // TODO: 接入国际化
theme: AppTheme.theme,
darkTheme: AppTheme.darkTheme,
themeMode: ref.watch(themeModeProvider),
routerConfig: ref.watch(routerProvider),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void bootstrap() {
WidgetsFlutterBinding.ensureInitialized();
// ProviderScope 包裹全局network_provider 等 Provider 懒加载单例
runApp(
const ProviderScope(
child: IMApp(),
),
);
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/app_initializer.dart';
import 'network_provider.dart';
// ── 认证 ──────────────────────────────────────────────────────────────────────
/// 登录状态管理
///
/// 同时继承 [ChangeNotifier],作为 go_router [GoRouter.refreshListenable] 使用,
/// 登录 / 退出时 go_router 自动重新执行 redirect无需手动触发。
///
/// ## 当前状态
///
/// Demo 实现无持久化。storage_sdk 就绪后替换为:
/// - `build`:从安全存储读取 token有则视为已登录
/// - `login` / `logout`:同步更新安全存储
class AuthNotifier extends ChangeNotifier {
bool _isLoggedIn = false;
bool get isLoggedIn => _isLoggedIn;
void login() {
_isLoggedIn = true;
notifyListeners();
}
void logout() {
_isLoggedIn = false;
notifyListeners();
}
}
/// 登录状态 Provider
///
/// 使用 [Provider] 持有 [AuthNotifier] 单例。
/// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier]ChangeNotifier
/// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。
final authNotifierProvider = Provider<AuthNotifier>(
(ref) => AuthNotifier(),
);
// ── 主题 ──────────────────────────────────────────────────────────────────────
/// 主题模式 Notifier — 控制应用全局亮 / 暗主题
///
/// 启动时从持久化存储读取上次保存的主题模式,无则默认跟随系统。
/// 切换时先更新内存状态,再写入持久化存储。
///
/// ## storage_sdk 接入步骤
///
/// 1. 在 `build()` 解开 TODO读取存储值作为初始模式
/// 2. 在 `setMode()` 解开 TODO每次切换后写入存储
/// 3. 若存储接口是异步的,将 `Notifier<ThemeMode>` 改为
/// `AsyncNotifier<ThemeMode>``build()` 改为 `Future<ThemeMode>`
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
// TODO: storage_sdk 就绪后从持久化读取初始值:
// final saved = ref.read(themeStorageProvider).readThemeMode();
// return saved ?? ThemeMode.system;
return ThemeMode.system;
}
void setMode(ThemeMode mode) {
state = mode;
// TODO: storage_sdk 就绪后写入持久化:
// ref.read(themeStorageProvider).saveThemeMode(mode);
}
}
/// 主题模式 Provider
///
/// ## Setting 页切换(只需一行)
///
/// ```dart
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.system);
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.light);
/// ref.read(themeModeProvider.notifier).setMode(ThemeMode.dark);
/// ```
///
/// ## 持久化storage_sdk TODO
///
/// 读取和写入的 TODO 均在 [ThemeModeNotifier] 内,接入 storage_sdk 后解开即可。
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(
ThemeModeNotifier.new,
);
// ── 启动初始化 ────────────────────────────────────────────────────────────────
/// AppInitializer Provider
///
/// 集中声明所有启动初始化任务app.dart 只需一行 `.run()`。
///
/// ## 任务分类规则
///
/// 问自己:「这个任务不完成,用户能正常看到首页吗?」
/// - **不能** → 放 critical谨慎每多一个都拖慢启动
/// - **能** → 放 deferred绝大多数情况
///
/// ## 当前任务清单
///
/// | 阶段 | 任务 | 说明 |
/// |---|---|---|
/// | Critical | NetworkMonitor | 后续 HTTP、WebSocket 都依赖网络状态 |
/// | Deferred | (待扩展) | 推送注册、登录态恢复、缓存预热等 |
final appInitializerProvider = Provider<AppInitializer>((ref) {
return AppInitializer(
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'AppInit'}] $message');
},
critical: [
// 网络监听必须最先就绪(后续 HTTP、WebSocket 都依赖它)
InitTask(
name: 'NetworkMonitor',
task: () => ref.read(networkMonitorProvider).initialize(),
),
],
deferred: [
// TODO: 推送注册
// InitTask(
// name: 'PushNotification',
// task: () => ref.read(pushServiceProvider).register(),
// ),
//
// TODO: 登录态恢复(从安全存储读取 token → 自动登录)
// InitTask(
// name: 'AuthRestore',
// task: () => ref.read(authRestoreUseCaseProvider).execute(),
// ),
//
// TODO: 缓存预热
// InitTask(
// name: 'CacheWarmup',
// task: () => ref.read(cacheServiceProvider).warmup(),
// ),
],
);
});

View File

@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../data/local/drift/app_database.dart';
/// 全局单例 StorageSdkApi整个 App 生命周期内唯一实例。
///
/// storage_sdk 负责数据库连接生命周期和 CRUD 机制;
/// im_app 负责 schemaAppDatabase + 各业务表)。
///
/// 用法:
/// ```dart
/// // 登录后开库
/// await ref.read(storageSdkProvider).openDatabase(user.id);
///
/// // CRUD 示例
/// final db = ref.read(storageSdkProvider);
/// await db.insertOrReplace(appDb.users, companion);
/// ```
final storageSdkProvider = Provider<StorageSdkApi>((ref) {
return StorageSdkApi(
databaseFactory: (executor) => AppDatabase(executor),
);
});

View File

@@ -0,0 +1,404 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
import '../../core/foundation/api_paths.dart';
import '../../core/foundation/config.dart';
import '../../core/foundation/constants.dart';
import '../../core/services/network_monitor.dart';
import '../../core/services/socket_manager.dart';
// ── 网络状态监听 ──────────────────────────────────────────────────────────────
/// 网络状态监听 Provider全局单例
///
/// 基于 connectivity_plus 监听平台网络变化,
/// 作为公共服务供多个模块使用:
/// - SocketManager网络变化时自动断连/重连 WebSocket
/// - HTTP 层:请求前检查网络可用性
/// - UI 层:显示网络状态提示
///
/// ## 使用
///
/// ```dart
/// // 查询当前状态
/// final isConnected = ref.read(networkMonitorProvider).isConnected;
///
/// // 监听状态变化
/// ref.listen(networkMonitorProvider, (prev, monitor) {
/// monitor.onStatusChanged.listen((isAvailable) {
/// // 处理网络变化
/// });
/// });
/// ```
final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
final monitor = NetworkMonitor(
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
);
ref.onDispose(() {
monitor.dispose();
});
return monitor;
});
// ── HTTP 基础设施 ─────────────────────────────────────────────────────────────
/// API 配置 Provider全局单例
///
/// 从 [AppConfig.apiBaseUrl]config.json → --dart-define-from-file读取 baseURL
/// 注入到 Network SDK 作为所有 HTTP 请求的基础 URL。
///
/// [onCheckNetworkAvailable] 由 [networkMonitorProvider](公共服务)注入,
/// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。
final apiConfigProvider = Provider<ApiConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
'Platform': 'Android', // TODO: 运行时从平台 API 获取
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
},
tokenExpiredCodes: {30002, 30003, 30124},
forceLogoutCodes: {30125},
onForceLogout: () {
// TODO: 清除登录态,跳转登录页
},
onTokenRefresh: () async {
// TODO: App 层刷新 token 逻辑
return null;
},
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
);
});
/// API 客户端 Provider全局单例
///
/// 含拦截器Auth / Retry / Logging、超时配置。
final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
final config = ref.read(apiConfigProvider);
return NetworksSdkApi()..initialize(config);
});
// ── WebSocket 基础设施 ────────────────────────────────────────────────────────
/// SocketConfig Provider全局单例
///
/// 与 apiConfigProvider 对称,通过回调注入 App 层能力,
/// SDK 内部不调用其他 SDK。
final socketConfigProvider = Provider<SocketConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Socket'}] $message');
},
onCheckNetworkAvailable: () async {
return networkMonitor.isConnected;
},
);
});
/// SocketClient Provider全局单例
///
/// 与 apiClientProvider 对称。
final socketClientProvider = Provider<NetworksMessagingApi>((ref)
{
final config = ref.read(socketConfigProvider);
return NetworksMessagingApi()..initialize(config);
});
/// SocketManager Provider
///
/// 封装连接生命周期、网络/前后台事件响应、操作前置检查、消息预处理。
/// 业务模块通过此 Provider 访问 WebSocket 能力。
///
/// ## 前置检查
///
/// connect / send 前先检查网络可用性 + 后台状态,
/// 无效操作直接跳过,避免无意义的网络请求。
/// 与 HTTP 层 [ApiClient.executeRequest] 的网络前置检查对称。
///
/// ## 事件驱动
///
/// 网络状态变化由 [networkMonitorProvider](公共服务)驱动,
/// 自动触发断连/重连。
///
/// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式:
/// 后续接入加解密 SDK 时,在此注入解密回调,
/// SDK 内部不调用其他 SDK。
final socketManagerProvider = Provider<SocketManager>((ref) {
final client = ref.read(socketClientProvider);
final networkMonitor = ref.read(networkMonitorProvider);
final manager = SocketManager(
client: client,
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'SocketManager'}] $message');
},
);
// 监听网络状态变化 → 驱动 SocketManager 断连/重连
final subscription = networkMonitor.onStatusChanged.listen((isAvailable) {
manager.handleNetworkStatusChanged(isAvailable: isAvailable);
});
ref.onDispose(() {
subscription.cancel();
unawaited(manager.dispose());
});
return manager;
});
// ── 辅助 ──────────────────────────────────────────────────────────────────────
/// HTTP baseURL → WebSocket URL 转换
///
/// https://api.example.com → wss://api.example.com/ws
/// http://api.example.com → ws://api.example.com/ws
String _buildWsUrl(String httpBaseUrl) {
String base = httpBaseUrl;
if (base.startsWith('https://')) {
base = base.replaceFirst('https://', 'wss://');
} else if (base.startsWith('http://')) {
base = base.replaceFirst('http://', 'ws://');
}
return '$base${ApiPaths.wsConnect}';
}
// ══════════════════════════════════════════════════════════════════════════════
// 本文件的职责
// ══════════════════════════════════════════════════════════════════════════════
//
// 提供所有网络基础设施 Provider网络监听 + HTTP + WebSocket。
// 业务模块的 DI 链路Repository → UseCase 按需)
// 内聚在 features/{模块}/di/{模块}_providers.dart 中。
//
// di/ 目录只放「需要手动装配的 Provider」构造注入、回调组合等
// ViewModel Provider 由 @riverpod 注解自动生成,不在 di/ 中。
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ DI 架构 │
// ├──────────────────────────────────────────────────────────────────────────┤
// │ app/di/ ← 手动装配SDK 基础设施 │
// │ ├── app_providers.dart → 主题 + 启动初始化 │
// │ └── network_provider.dart → 网络监听 + HTTP + WebSocket │
// │ │
// │ features/{模块}/di/ ← 手动装配:业务模块 DI 链路 │
// │ └── auth_providers.dart → Repository → UseCase按需
// │ chat_providers.dart 每个模块 DI 链路内聚在一个文件 │
// │ │
// │ features/{模块}/presentation/ ← @riverpod 自动生成ViewModel │
// │ └── login_view_model.dart → loginViewModelProvider代码生成
// └──────────────────────────────────────────────────────────────────────────┘
//
// Provider 链路:
//
// networkMonitorProvider公共服务HTTP + WS 共用)
// ├── apiConfigProvider → apiClientProvider ← HTTP 层
// └── socketConfigProvider → socketClientProvider ← WS 层
// → socketManagerProvider
//
// 网络事件驱动链路:
//
// connectivity_plus平台网络事件
// → NetworkMonitor.onStatusChangedtrue / false
// → SocketManager.handleNetworkStatusChanged()
// → 断网: disconnect()
// → 恢复: connect(token: lastToken)
//
// 前后台事件驱动链路:
//
// WidgetsBindingObserverApp 层 app.dart
// → SocketManager.onEnterBackground() → disconnect
// → SocketManager.onEnterForeground() → reconnect
//
// Repository 直接注入 ApiClient通过回调注入其他 SDK 能力:
//
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存network_sdk
// secureStorage.saveToken(token); // 持久化crypto_sdk
// }
//
// 这样 network_sdk 和 crypto_sdk 互不依赖App 层是唯一知道两者的地方。
//
// ══════════════════════════════════════════════════════════════════════════════
// 新增接口的完整流程(以登录为例)
// ══════════════════════════════════════════════════════════════════════════════
//
// 「一个接口 = 一个 Request 文件」,严格按层调用,禁止跳层。
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ 文件 & 职责总览 │
// ├──────────────────────────────────────────────────────────────────────────┤
// │ login_request.dart Request + Response DTO一个端点一个文件
// │ auth_repository_impl.dart executeRequest → DTO → Entity + 回调写 Token│
// │ login_usecase.dart 格式校验 → 调 Repository按需非必须
// │ auth_providers.dart DI 装配Repository → UseCase 按需) │
// │ login_view_model.dart ref.read(authRepositoryProvider).login() │
// │ 或 ref.read(loginUseCaseProvider).execute() │
// │ login_page.dart ref.watch(loginViewModelProvider) │
// └──────────────────────────────────────────────────────────────────────────┘
//
// ─────────────────────────────────────────────────────────────────────────
// Step 1: 定义 Requestdata/remote/login_request.dart
// ─────────────────────────────────────────────────────────────────────────
//
// 一个文件包含两部分Response DTO + Request 类。
//
// @JsonSerializable()
// class LoginData { // Response DTO
// final String token;
// final String userId;
// factory LoginData.fromJson(Map<String, dynamic> json) => _$LoginDataFromJson(json);
// User toEntity() => User(id: userId, ...); // DTO → Domain Entity
// }
//
// @ApiRequest(path: ApiPaths.authLogin, method: HttpMethod.post,
// responseType: LoginData, requestType: ApiRequestType.login)
// @JsonSerializable()
// class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi {
// final String email;
// final String password;
// LoginRequest({required this.email, required this.password});
// @override Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
// }
//
// build_runner 生成 _$LoginRequestApi mixin → 自动提供 path / method / fromJson 注册。
//
// ─────────────────────────────────────────────────────────────────────────
// Step 2: Repositorydata/repositories/auth_repository_impl.dart
// ─────────────────────────────────────────────────────────────────────────
//
// class AuthRepositoryImpl implements AuthRepository {
// final ApiClient _client; // ← 直接注入 ApiClient
// final void Function(String?) _onTokenUpdate; // ← 回调,由 Provider 层组合
//
// Future<User> login({required String email, required String password}) async {
// final dto = await _client.executeRequest(
// LoginRequest(email: email, password: password),
// );
// _onTokenUpdate(dto!.token); // 回调写入 Token
// return dto.toEntity(); // DTO → Domain Entity
// }
// }
//
// ─────────────────────────────────────────────────────────────────────────
// Step 3: Provider 装配 + ViewModel
// ─────────────────────────────────────────────────────────────────────────
//
// // --- Provider 装配features/auth/di/auth_providers.dart ---
//
// // Repository直接注入 ApiClient + 回调组合多个 SDK 能力)
// final authRepositoryProvider = Provider((ref) {
// final apiConfig = ref.read(apiConfigProvider);
// return AuthRepositoryImpl(
// client: ref.read(apiClientProvider), // 直接注入
// onTokenUpdate: (token) {
// apiConfig.updateToken(token); // 内存network_sdk
// // secureStorage.saveToken(token); // 持久化crypto_sdk
// },
// );
// });
//
// // UseCase按需 — 登录有多步编排,所以需要)
// final loginUseCaseProvider = Provider((ref) {
// return LoginUseCase(authRepository: ref.read(authRepositoryProvider));
// });
//
// // --- ViewModelfeatures/auth/presentation/login_view_model.dart ---
//
// // 常规写法ViewModel 直接调 Repository
// @riverpod
// class LoginViewModel extends _$LoginViewModel {
// Future<void> login(String email, String password) async {
// state = state.copyWith(isLoading: true);
// try {
// final user = await ref.read(authRepositoryProvider).login(
// email: email, password: password,
// );
// state = state.copyWith(user: user, isLoading: false);
// } on ApiError catch (e) {
// state = state.copyWith(error: e.displayMessage, isLoading: false);
// }
// }
// }
//
// // 进阶写法:有 UseCase 时(格式校验 + 多步编排)
// // final user = await ref.read(loginUseCaseProvider).execute(
// // email: email, password: password,
// // );
//
// ═════════════════════════════════════════════════════════════════════════
// 内部执行链路(点击登录按钮后发生了什么)
// ═════════════════════════════════════════════════════════════════════════
//
// View: vm.login(email, password)
// → ViewModel: ref.read(authRepositoryProvider).login(...) ← 常规路径
// → ViewModel: ref.read(loginUseCaseProvider).execute(...) ← 进阶路径(有 UseCase 时)
// → UseCase: 格式校验(邮箱 + 密码)
// → UseCase/ViewModel: authRepository.login(...)
// → Repository: _client.executeRequest(LoginRequest(...))
// → ApiClient.executeRequest(request)
// 1. 拼 URL: baseURL + "/auth/login"
// 2. request.parameters 触发 fromJson 自动注册
// 3. AuthInterceptor: 注入 token + platform headers
// 4. Dio.request(url, data: {email, password})
// 5. RetryInterceptor: token 过期 → 刷新 → 自动重试
// 6. LoggingInterceptor: 打印请求/响应日志
// → request.decodeResponse(response)
// 1. ApiResponseWrapper.fromJson: 拆 { code, message, data }
// 2. 检查 code != 0 → 抛 ApiError
// 3. fromJsonRegistry[LoginData] → LoginData.fromJson(data)
// → 返回 LoginDataDTO
// → _onTokenUpdate(token) 回调写入 TokenProvider 层组合:内存 + 持久化)
// → LoginData.toEntity() → UserDomain Entity
// → state.copyWith(user: user) 更新状态
// View: ref.watch → 自动 rebuild UI
//
// ═════════════════════════════════════════════════════════════════════════
// 各 HTTP 方法速查 — 新增接口时参照
// ═════════════════════════════════════════════════════════════════════════
//
// GET参数走 URL query string
// @ApiRequest(path: ..., method: HttpMethod.get, responseType: ProfileData)
// class GetProfileRequest extends ApiRequestable<ProfileData> with _$... { }
//
// POST参数走 JSON body
// 见上方 LoginRequest 示例。
//
// DELETE / PUT / PATCH与 POST 相同,只改 method
// @ApiRequest(path: ..., method: HttpMethod.delete, responseType: ...)
//
// POST 无响应数据(如 logout
// class LogoutRequest extends ApiRequestable<void> { ... }
// // → 返回 null
//
// Upload A: FormData 上传到自有后端
// @override Object? get uploadData => FormData.fromMap({ 'file': ... });
//
// Upload B: 二进制上传到 S3 presigned URL
// @override String get path => presignedURL; // 完整 URL不拼 baseURL
// @override Object? get uploadData => bytes; // Uint8List
// @override decodeResponse(response) { ... } // S3 不走标准信封
//

View 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';
}

View 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 ChatDetailPageextra 传参)
/// /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 NavigatorShell 不会被盖住TabBar 仍然可见。
///
/// 设置 `parentNavigatorKey: _rootKey` 后,路由强制放到 Root Navigator
/// 盖住整个 ShellTabBar 消失,表现为真正的全屏页面。
///
/// ## 登录守卫
///
/// [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 确保路由覆盖 ShellTabBar 消失
//
// 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(),
),
],
);
});

View 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;
}
}