Initial project
This commit is contained in:
71
apps/im_app/lib/app/app.dart
Normal file
71
apps/im_app/lib/app/app.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/im_app/lib/app/bootstrap.dart
Normal file
15
apps/im_app/lib/app/bootstrap.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
141
apps/im_app/lib/app/di/app_providers.dart
Normal file
141
apps/im_app/lib/app/di/app_providers.dart
Normal 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(),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
});
|
||||
24
apps/im_app/lib/app/di/db_provider.dart
Normal file
24
apps/im_app/lib/app/di/db_provider.dart
Normal 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 负责 schema(AppDatabase + 各业务表)。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```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),
|
||||
);
|
||||
});
|
||||
404
apps/im_app/lib/app/di/network_provider.dart
Normal file
404
apps/im_app/lib/app/di/network_provider.dart
Normal 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.onStatusChanged(true / false)
|
||||
// → SocketManager.handleNetworkStatusChanged()
|
||||
// → 断网: disconnect()
|
||||
// → 恢复: connect(token: lastToken)
|
||||
//
|
||||
// 前后台事件驱动链路:
|
||||
//
|
||||
// WidgetsBindingObserver(App 层 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: 定义 Request(data/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: Repository(data/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));
|
||||
// });
|
||||
//
|
||||
// // --- ViewModel(features/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)
|
||||
// → 返回 LoginData(DTO)
|
||||
// → _onTokenUpdate(token) 回调写入 Token(Provider 层组合:内存 + 持久化)
|
||||
// → LoginData.toEntity() → User(Domain 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 不走标准信封
|
||||
//
|
||||
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