Initial project
This commit is contained in:
64
apps/im_app/lib/features/login/di/auth_providers.dart
Normal file
64
apps/im_app/lib/features/login/di/auth_providers.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/di/network_provider.dart';
|
||||
import '../../../app/di/db_provider.dart';
|
||||
import '../../../data/repositories/auth_repository_impl.dart';
|
||||
import '../../../domain/repositories/auth_repository.dart';
|
||||
import '../usecases/login_usecase.dart';
|
||||
|
||||
/// ## DI 装配:Auth Feature 层
|
||||
///
|
||||
/// di/ 目录只放**需要手动装配的 Provider**(构造注入、回调组合等)。
|
||||
/// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。
|
||||
///
|
||||
/// Auth 模块的 DI 链路:Repository → UseCase(按需)。
|
||||
/// app/di/ 只提供 SDK 基础设施(apiConfig / apiClient / socketManager / storageApi),
|
||||
/// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。
|
||||
///
|
||||
/// ```
|
||||
/// LoginViewModel ← @riverpod 自动生成
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(socketManagerProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(apiConfigProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
|
||||
/// → ref.read(storageSdkProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 认证 Repository Provider
|
||||
///
|
||||
/// 注入 domain 接口类型 [AuthRepository],
|
||||
/// ViewModel 通过此 Provider 获取依赖,不感知具体实现。
|
||||
///
|
||||
/// [onTokenUpdate] 是复合回调:
|
||||
/// 1. 写入 ApiConfig 内存 → 后续请求自动携带 token
|
||||
/// 2. TODO: 持久化到安全存储(crypto_sdk)→ App 重启后恢复
|
||||
/// 两个 SDK 互不依赖,由 App 层在此组合。
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
final apiConfig = ref.read(apiConfigProvider);
|
||||
// TODO: final secureStorage = ref.read(secureStorageProvider);
|
||||
|
||||
return AuthRepositoryImpl(
|
||||
client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient
|
||||
onTokenUpdate: (token) {
|
||||
apiConfig.updateToken(token); // 内存(network_sdk)
|
||||
// TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk)
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── UseCase ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 登录用例 Provider
|
||||
///
|
||||
/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库
|
||||
final loginUseCaseProvider = Provider<LoginUseCase>((ref) {
|
||||
return LoginUseCase(
|
||||
authRepository: ref.read(authRepositoryProvider),
|
||||
socketManager: ref.read(socketManagerProvider),
|
||||
apiConfig: ref.read(apiConfigProvider),
|
||||
storageApi: ref.read(storageSdkProvider),
|
||||
);
|
||||
});
|
||||
33
apps/im_app/lib/features/login/presentation/login_state.dart
Normal file
33
apps/im_app/lib/features/login/presentation/login_state.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../domain/entities/user.dart';
|
||||
|
||||
part 'login_state.freezed.dart';
|
||||
|
||||
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString)
|
||||
///
|
||||
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
|
||||
/// View 通过 `ref.watch(loginViewModelProvider)` 自动响应变化。
|
||||
///
|
||||
/// ## 状态流转
|
||||
///
|
||||
/// ```
|
||||
/// 初始 → LoginState() isLoading: false, user: null, error: null
|
||||
/// 点击登录 → state.copyWith(isLoading: true) isLoading: true
|
||||
/// 登录成功 → state.copyWith(user: user) isLoading: false, user: User
|
||||
/// 格式错误 → state.copyWith(error: '邮箱格式不正确') isLoading: false, error: String
|
||||
/// 网络错误 → state.copyWith(error: '网络错误') isLoading: false, error: String
|
||||
/// ```
|
||||
@freezed
|
||||
sealed class LoginState with _$LoginState {
|
||||
const factory LoginState({
|
||||
/// 登录成功后的用户信息(null = 未登录)
|
||||
User? user,
|
||||
|
||||
/// 是否正在请求中(控制 loading 状态 / 按钮禁用)
|
||||
@Default(false) bool isLoading,
|
||||
|
||||
/// 错误信息(null = 无错误)
|
||||
String? error,
|
||||
}) = _LoginState;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:storage_sdk/storage_sdk.dart';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../di/auth_providers.dart';
|
||||
import 'login_state.dart';
|
||||
|
||||
part 'login_view_model.g.dart';
|
||||
|
||||
/// 登录 ViewModel(@riverpod 自动生成 `loginViewModelProvider`)
|
||||
///
|
||||
/// `@riverpod` 注解 → build_runner 自动生成 `login_view_model.g.dart`,
|
||||
/// 其中包含 `loginViewModelProvider`。View 层直接使用:
|
||||
///
|
||||
/// ```dart
|
||||
/// // View 层读取状态
|
||||
/// final state = ref.watch(loginViewModelProvider);
|
||||
///
|
||||
/// // View 层调用方法
|
||||
/// ref.read(loginViewModelProvider.notifier).login(email, password);
|
||||
/// ```
|
||||
///
|
||||
/// ## 手动 vs 自动 Provider 对比
|
||||
///
|
||||
/// ```
|
||||
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(apiClientProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
|
||||
/// → ★ LoginViewModel.login() ★ ← 你在这里
|
||||
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
|
||||
/// → AuthRepository.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginData → User
|
||||
/// ← User
|
||||
/// → state = state.copyWith(user: user) ← 更新状态
|
||||
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
||||
/// ```
|
||||
@riverpod
|
||||
class LoginViewModel extends _$LoginViewModel {
|
||||
@override
|
||||
LoginState build() => const LoginState();
|
||||
|
||||
/// Demo 登录(跳过 API,直接设置登录状态)
|
||||
///
|
||||
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
||||
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
||||
Future<void> demoLogin() async {
|
||||
final storageApi = ref.read(storageSdkProvider);
|
||||
///TODO: StorageSDKLifeCycle 需要只在主项目暴露
|
||||
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
||||
ref.read(authNotifierProvider).login();
|
||||
|
||||
await storageLifeCycle.openDatabase(1234567);
|
||||
final rows = await storageApi.rawQuery("PRAGMA table_info('user')");
|
||||
for (final row in rows) {
|
||||
debugPrint('Schema: ${row.data}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 设置 loading 状态(UI 显示加载指示器、禁用按钮)
|
||||
/// 2. 调 UseCase(格式校验 → 登录 → 返回 User)
|
||||
/// 3. 成功:写入 user;失败:写入 error
|
||||
Future<void> login(String email, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final user = await ref.read(loginUseCaseProvider).execute(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
state = state.copyWith(user: user, isLoading: false);
|
||||
} on FormatException catch (e) {
|
||||
// 格式校验失败(UseCase 层抛出)
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
// 网络 / 服务端错误(Repository → SDK 透传)
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
// 兜底:防止未预期的异常导致 isLoading 死锁
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
apps/im_app/lib/features/login/usecases/login_usecase.dart
Normal file
117
apps/im_app/lib/features/login/usecases/login_usecase.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:storage_sdk/storage_sdk.dart';
|
||||
|
||||
import '../../../core/services/socket_manager.dart';
|
||||
import '../../../domain/entities/user.dart';
|
||||
import '../../../domain/repositories/auth_repository.dart';
|
||||
|
||||
/// 登录用例
|
||||
///
|
||||
/// 封装登录的完整业务流程:
|
||||
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
|
||||
///
|
||||
/// ## 为什么需要 UseCase?
|
||||
///
|
||||
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
|
||||
/// - 格式校验(不发无效请求,省流量、减少服务端压力)
|
||||
/// - 登录后初始化 WebSocket 连接
|
||||
/// - 登录后按 user id 打开对应的本地数据库
|
||||
///
|
||||
/// 把这些规则封装在 UseCase 里,ViewModel 只需一行调用。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// LoginViewModel.login(email, password)
|
||||
/// → ★ LoginUseCase.execute() ★ ← 你在这里
|
||||
/// → 格式校验(邮箱 + 密码)
|
||||
/// → AuthRepository.login()
|
||||
/// → AuthRepositoryImpl.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginData(DTO)
|
||||
/// → _onTokenUpdate(token) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||
/// ← LoginData.toEntity() → User
|
||||
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
||||
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
||||
/// ← User
|
||||
/// ```
|
||||
class LoginUseCase {
|
||||
final AuthRepository _authRepository;
|
||||
final SocketManager _socketManager;
|
||||
final ApiConfig _apiConfig;
|
||||
final StorageSdkApi _storageApi;
|
||||
|
||||
StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle;
|
||||
|
||||
LoginUseCase({
|
||||
required AuthRepository authRepository,
|
||||
required SocketManager socketManager,
|
||||
required ApiConfig apiConfig,
|
||||
required StorageSdkApi storageApi,
|
||||
}) : _authRepository = authRepository,
|
||||
_socketManager = socketManager,
|
||||
_apiConfig = apiConfig,
|
||||
_storageApi = storageApi;
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 格式校验 → 不合法直接抛 [FormatException]
|
||||
/// 2. 调 Repository 登录 → 拿到 User(token 写入由 Repository 处理)
|
||||
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
|
||||
/// 4. 按 user id 打开本地数据库
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 邮箱或密码格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误(由 Repository 透传)
|
||||
Future<User> execute({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
// ── 1. 格式校验 ──
|
||||
_validateEmail(email);
|
||||
_validatePassword(password);
|
||||
|
||||
// ── 2. 登录 ──
|
||||
final user = await _authRepository.login(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// ── 3. 连接 WebSocket ──
|
||||
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
||||
// 此处直接读取,避免改动现有接口。
|
||||
final token = _apiConfig.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await _socketManager.connect(token: token);
|
||||
}
|
||||
|
||||
// ── 4. 打开数据库 ──
|
||||
// TODO: 当服务端返回整型 uid 时,换成 user.uid;目前用 hashCode 作为临时标识。
|
||||
await _storageLifeCycle.openDatabase(user.hashCode);
|
||||
|
||||
// TODO: 后续扩展点
|
||||
// - 同步联系人列表
|
||||
// - 注册推送 token
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
void _validateEmail(String email) {
|
||||
if (email.trim().isEmpty) {
|
||||
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
if (!emailRegex.hasMatch(email.trim())) {
|
||||
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePassword(String password) {
|
||||
if (password.isEmpty) {
|
||||
throw const FormatException('密码不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/im_app/lib/features/login/view/login_page.dart
Normal file
45
apps/im_app/lib/features/login/view/login_page.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
import '../presentation/login_view_model.dart';
|
||||
|
||||
/// 登录页(Demo)
|
||||
///
|
||||
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin]
|
||||
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
|
||||
///
|
||||
/// 正式实现时替换为完整登录流程(email/password 输入 → LoginViewModel.login)。
|
||||
class LoginPage extends ConsumerWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final s = context.styles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('登录'),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('IM_Demo', style: s.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'未登录时任意路由均被重定向到此页 \n 主要是为了展示路由守卫的功能 \n 后续路由守卫专门处理各种跳转前的逻辑判断',
|
||||
style: s.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||
child: const Text('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user