Initial project
This commit is contained in:
0
apps/im_app/lib/features/app_tab/di/.gitkeep
Normal file
0
apps/im_app/lib/features/app_tab/di/.gitkeep
Normal file
0
apps/im_app/lib/features/app_tab/usecases/.gitkeep
Normal file
0
apps/im_app/lib/features/app_tab/usecases/.gitkeep
Normal file
52
apps/im_app/lib/features/app_tab/view/app_tab.dart
Normal file
52
apps/im_app/lib/features/app_tab/view/app_tab.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 主 Tab 容器(Shell 层)
|
||||
///
|
||||
/// 由 [StatefulShellRoute.indexedStack] 驱动,不持有任何状态。
|
||||
/// Tab 切换通过 [navigationShell.goBranch] 完成,go_router 负责保持各 Tab 的导航栈。
|
||||
///
|
||||
/// Tabs(index 顺序):
|
||||
/// - 0:聊天
|
||||
/// - 1:联系人
|
||||
/// - 2:设置
|
||||
class AppTab extends StatelessWidget {
|
||||
const AppTab({
|
||||
super.key,
|
||||
required this.navigationShell,
|
||||
});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onTap: (index) => navigationShell.goBranch(
|
||||
index,
|
||||
// 再次点击已激活的 Tab 时回到该 Tab 的初始路由
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
activeIcon: Icon(Icons.chat_bubble),
|
||||
label: '聊天',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.people_outline),
|
||||
activeIcon: Icon(Icons.people),
|
||||
label: '联系人',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings),
|
||||
label: '设置',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
apps/im_app/lib/features/chat/di/.gitkeep
Normal file
0
apps/im_app/lib/features/chat/di/.gitkeep
Normal file
0
apps/im_app/lib/features/chat/presentation/.gitkeep
Normal file
0
apps/im_app/lib/features/chat/presentation/.gitkeep
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../../../app/router/app_route_name.dart';
|
||||
|
||||
part 'chat_view_model.g.dart';
|
||||
|
||||
/// 聊天页 ViewModel(@riverpod 自动生成 `chatViewModelProvider`)
|
||||
///
|
||||
/// 当前 chat 页面为 Demo,无需从服务端加载数据,状态为 void。
|
||||
/// 后续接入会话列表时,将 build() 改为返回会话列表状态,并在此加载数据。
|
||||
///
|
||||
/// ## 数据流
|
||||
///
|
||||
/// ```
|
||||
/// ChatPage
|
||||
/// → ref.read(chatViewModelProvider.notifier).someMethod(context)
|
||||
/// → ★ ChatViewModel ★ ← 你在这里
|
||||
/// → 导航 / 业务逻辑
|
||||
/// ```
|
||||
@riverpod
|
||||
class ChatViewModel extends _$ChatViewModel {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
// ── 导航(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
|
||||
|
||||
/// 切换到联系人 Tab。
|
||||
void goToContact(BuildContext context) {
|
||||
context.go(AppRouteName.contact.path);
|
||||
}
|
||||
|
||||
/// 带 extra 参数 push 聊天详情页(extra 传 Dart Record)。
|
||||
void pushChatDetailWithExtra(BuildContext context) {
|
||||
context.push(
|
||||
AppRouteName.chatDetail.path,
|
||||
extra: (conversationId: '42', title: 'extra 传参'),
|
||||
);
|
||||
}
|
||||
|
||||
/// 带路径参数 push 聊天详情页(id 内嵌在 URL 中)。
|
||||
void pushChatDetailById(BuildContext context) {
|
||||
context.push(AppRouteName.chatDetailByIdPath('99'));
|
||||
}
|
||||
|
||||
/// 无参 push(演示 push 导航)。
|
||||
void pushSettingsTheme(BuildContext context) {
|
||||
context.push(AppRouteName.settingsTheme.path);
|
||||
}
|
||||
|
||||
/// 切换到设置 Tab。
|
||||
void goToSettings(BuildContext context) {
|
||||
context.go(AppRouteName.settings.path);
|
||||
}
|
||||
|
||||
// ── 业务 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 退出登录
|
||||
///
|
||||
/// 调用 [AuthNotifier.logout] 清除登录状态,go_router 守卫检测到后
|
||||
/// 自动重定向到登录页,无需手动导航。
|
||||
void logout() {
|
||||
ref.read(authNotifierProvider).logout();
|
||||
}
|
||||
}
|
||||
0
apps/im_app/lib/features/chat/usecases/.gitkeep
Normal file
0
apps/im_app/lib/features/chat/usecases/.gitkeep
Normal file
43
apps/im_app/lib/features/chat/view/chat_detail_page.dart
Normal file
43
apps/im_app/lib/features/chat/view/chat_detail_page.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 会话详情页(路由传参 Demo)
|
||||
///
|
||||
/// 通过 go_router 的 `extra` 接收上一页传入的数据,
|
||||
/// 由 [app_router.dart] 的 builder 解包后以构造参数注入,
|
||||
/// 本页不感知 GoRouter 任何实现细节。
|
||||
///
|
||||
/// ## 正式开发
|
||||
///
|
||||
/// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。
|
||||
/// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。
|
||||
class ChatDetailPage extends StatelessWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.conversationId,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String conversationId;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.styles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('会话 ID', style: s.labelMuted),
|
||||
Text(conversationId, style: s.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
apps/im_app/lib/features/chat/view/chat_page.dart
Normal file
67
apps/im_app/lib/features/chat/view/chat_page.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/ui/components/app_button.dart';
|
||||
import '../presentation/chat_view_model.dart';
|
||||
|
||||
/// 聊天页(Demo 按钮)
|
||||
///
|
||||
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
|
||||
/// - 「切换 Tab」 — go,替换历史,不可返回
|
||||
/// - 「有参 push(extra)」 — push + extra(Dart Record),可返回
|
||||
/// - 「有参 push(路径参数)」— push + URL 内嵌 id,可返回
|
||||
/// - 「无参 push」 — push,可返回
|
||||
/// - 「退出登录」 — 守卫自动重定向到 /login
|
||||
///
|
||||
/// 所有操作通过 [ChatViewModel] 处理,View 不直接调用路由。
|
||||
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
|
||||
class ChatPage extends ConsumerWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(chatViewModelProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('聊天')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
// 切换 Tab:用 go,替换整个历史栈,不可返回
|
||||
AppButton.inverse(
|
||||
label: '切换 Tab(go)',
|
||||
onPressed: () => vm.goToContact(context),
|
||||
),
|
||||
// 带参数 push:extra 传 Dart Record,适合已有对象的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(extra)',
|
||||
onPressed: () => vm.pushChatDetailWithExtra(context),
|
||||
),
|
||||
// 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景
|
||||
AppButton.inverse(
|
||||
label: '有参 push(路径参数)',
|
||||
onPressed: () => vm.pushChatDetailById(context),
|
||||
),
|
||||
// 无参 push:压栈,自动显示返回按钮,不切 Tab
|
||||
AppButton.inverse(
|
||||
label: '无参 push',
|
||||
onPressed: () => vm.pushSettingsTheme(context),
|
||||
),
|
||||
// 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回
|
||||
AppButton.inverse(
|
||||
label: '无参 go',
|
||||
onPressed: () => vm.goToSettings(context),
|
||||
),
|
||||
AppButton.secondary(
|
||||
label: '退出登录',
|
||||
fullWidth: false,
|
||||
onPressed: () => vm.logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
apps/im_app/lib/features/contact/di/.gitkeep
Normal file
0
apps/im_app/lib/features/contact/di/.gitkeep
Normal file
0
apps/im_app/lib/features/contact/usecases/.gitkeep
Normal file
0
apps/im_app/lib/features/contact/usecases/.gitkeep
Normal file
13
apps/im_app/lib/features/contact/view/contact_page.dart
Normal file
13
apps/im_app/lib/features/contact/view/contact_page.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 联系人页占位
|
||||
///
|
||||
/// 待 contact 功能开发后替换为实际内容。
|
||||
class ContactPage extends StatelessWidget {
|
||||
const ContactPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
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('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
apps/im_app/lib/features/settings/di/settings_providers.dart
Normal file
21
apps/im_app/lib/features/settings/di/settings_providers.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../usecases/set_theme_usecase.dart';
|
||||
|
||||
/// Settings feature DI 装配
|
||||
///
|
||||
/// 手动装配 UseCase Provider,ViewModel 通过此处获取依赖。
|
||||
///
|
||||
/// ```
|
||||
/// ThemeViewModel
|
||||
/// → ref.read(setThemeUseCaseProvider) ← 此处装配
|
||||
/// → SetThemeUseCase(幂等校验)
|
||||
/// → onApply → ThemeModeNotifier.setMode()(内存 + 持久化 TODO)
|
||||
/// ```
|
||||
|
||||
// ── UseCase ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 设置主题用例 Provider
|
||||
final setThemeUseCaseProvider = Provider<SetThemeUseCase>(
|
||||
(_) => const SetThemeUseCase(),
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../app/router/app_route_name.dart';
|
||||
|
||||
part 'settings_view_model.g.dart';
|
||||
|
||||
/// 设置页 ViewModel
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// SettingsPage
|
||||
/// → ref.read(settingsViewModelProvider.notifier).navigateToTheme(context)
|
||||
/// → ★ SettingsViewModel.navigateToTheme() ★ ← 你在这里
|
||||
/// → context.push(AppRouteName.settingsTheme.path)
|
||||
/// ```
|
||||
///
|
||||
/// 导航意图由 ViewModel 统一管理,View 不直接调用路由。
|
||||
@riverpod
|
||||
class SettingsViewModel extends _$SettingsViewModel {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
/// 跳转到主题设置页。
|
||||
void navigateToTheme(BuildContext context) {
|
||||
context.push(AppRouteName.settingsTheme.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../di/settings_providers.dart';
|
||||
|
||||
part 'theme_view_model.g.dart';
|
||||
|
||||
/// 主题 ViewModel
|
||||
///
|
||||
/// View 层只感知此 ViewModel,不直接依赖 app 级 Provider。
|
||||
///
|
||||
/// ## 数据流
|
||||
///
|
||||
/// ```
|
||||
/// ThemeView
|
||||
/// → ref.watch(themeViewModelProvider) ← 当前 ThemeMode
|
||||
/// → ref.read(themeViewModelProvider.notifier).setMode(mode)
|
||||
/// → ★ ThemeViewModel.setMode() ★ ← 你在这里
|
||||
/// → SetThemeUseCase.execute()
|
||||
/// → 幂等校验(相同模式直接返回)
|
||||
/// → onApply → ThemeModeNotifier.setMode() ← 更新内存状态
|
||||
/// → TODO: 持久化(storage_sdk)
|
||||
/// ```
|
||||
@riverpod
|
||||
class ThemeViewModel extends _$ThemeViewModel {
|
||||
@override
|
||||
ThemeMode build() => ref.watch(themeModeProvider);
|
||||
|
||||
void setMode(ThemeMode mode) {
|
||||
ref.read(setThemeUseCaseProvider).execute(
|
||||
current: state,
|
||||
requested: mode,
|
||||
onApply: (m) => ref.read(themeModeProvider.notifier).setMode(m),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 设置主题用例
|
||||
///
|
||||
/// 职责:幂等校验——当前模式与目标模式相同时直接返回,不触发任何变更。
|
||||
///
|
||||
/// 持久化由 [ThemeModeNotifier.setMode] 负责(在 `onApply` 被调用后执行),
|
||||
/// UseCase 不感知存储细节。
|
||||
///
|
||||
/// ## 数据流
|
||||
///
|
||||
/// ```
|
||||
/// ThemeViewModel.setMode(mode)
|
||||
/// → ★ SetThemeUseCase.execute() ★ ← 你在这里
|
||||
/// → 幂等校验(相同模式 → 直接返回)
|
||||
/// → onApply(mode)
|
||||
/// → ThemeModeNotifier.setMode() ← 更新内存 + 写入持久化(TODO)
|
||||
/// ```
|
||||
class SetThemeUseCase {
|
||||
const SetThemeUseCase();
|
||||
|
||||
/// 执行主题切换
|
||||
///
|
||||
/// [current] 当前生效的主题模式
|
||||
/// [requested] 用户选择的目标模式
|
||||
/// [onApply] 校验通过后回调,由 ViewModel 负责调用 ThemeModeNotifier
|
||||
void execute({
|
||||
required ThemeMode current,
|
||||
required ThemeMode requested,
|
||||
required void Function(ThemeMode mode) onApply,
|
||||
}) {
|
||||
if (current == requested) return;
|
||||
onApply(requested);
|
||||
}
|
||||
}
|
||||
31
apps/im_app/lib/features/settings/view/settings_page.dart
Normal file
31
apps/im_app/lib/features/settings/view/settings_page.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../presentation/settings_view_model.dart';
|
||||
|
||||
/// 设置页
|
||||
///
|
||||
/// 所有用户操作通过 [SettingsViewModel] 处理,View 不直接调用路由。
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('设置'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('主题'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => ref
|
||||
.read(settingsViewModelProvider.notifier)
|
||||
.navigateToTheme(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
apps/im_app/lib/features/settings/view/theme_view.dart
Normal file
53
apps/im_app/lib/features/settings/view/theme_view.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../presentation/theme_view_model.dart';
|
||||
import 'widgets/settings_section_header.dart';
|
||||
import 'widgets/theme_option_tile.dart';
|
||||
|
||||
/// 主题选择页
|
||||
///
|
||||
/// 通过 [ThemeViewModel] 读写主题状态,不直接感知 app 级 Provider。
|
||||
class ThemeView extends ConsumerWidget {
|
||||
const ThemeView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(themeViewModelProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('主题'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SettingsSectionHeader(title: '外观'),
|
||||
ThemeOptionTile(
|
||||
label: '跟随系统',
|
||||
mode: ThemeMode.system,
|
||||
current: current,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.system),
|
||||
),
|
||||
ThemeOptionTile(
|
||||
label: '黑色模式',
|
||||
mode: ThemeMode.dark,
|
||||
current: current,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.dark),
|
||||
),
|
||||
ThemeOptionTile(
|
||||
label: '白色模式',
|
||||
mode: ThemeMode.light,
|
||||
current: current,
|
||||
onTap: () => ref
|
||||
.read(themeViewModelProvider.notifier)
|
||||
.setMode(ThemeMode.light),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 设置页分组标题
|
||||
///
|
||||
/// 用于在列表中区分配置分组,如「外观」、「通知」。
|
||||
/// 文字颜色跟随 [ColorScheme.primary],自带上下留白。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// const SettingsSectionHeader(title: '外观')
|
||||
/// ```
|
||||
class SettingsSectionHeader extends StatelessWidget {
|
||||
const SettingsSectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.styles;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 4),
|
||||
child: Text(title, style: s.sectionLabel),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 单个主题选项行
|
||||
///
|
||||
/// 纯展示 + 事件透传,不感知任何 Provider。
|
||||
/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// ThemeOptionTile(
|
||||
/// label: '黑色模式',
|
||||
/// mode: ThemeMode.dark,
|
||||
/// current: current,
|
||||
/// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark),
|
||||
/// )
|
||||
/// ```
|
||||
class ThemeOptionTile extends StatelessWidget {
|
||||
const ThemeOptionTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.mode,
|
||||
required this.current,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final ThemeMode mode;
|
||||
final ThemeMode current;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.styles;
|
||||
final isSelected = current == mode;
|
||||
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
trailing: isSelected ? Icon(Icons.check, color: s.primary) : null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user