网络请求打通,ws 打通
This commit is contained in:
@@ -2,8 +2,8 @@ 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';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
part 'chat_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
|
||||
|
||||
import '../../../core/ui/components/app_button.dart';
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
|
||||
class ChatDbTestPage extends ConsumerStatefulWidget {
|
||||
const ChatDbTestPage({super.key});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 会话详情页(路由传参 Demo)
|
||||
///
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import 'package:im_app/core/ui/components/app_button.dart';
|
||||
import 'package:im_app/features/chat/presentation/chat_view_model.dart';
|
||||
|
||||
/// 聊天页(Demo 按钮)
|
||||
///
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
import 'package:im_app/app/di/network_provider.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/data/repositories/auth_repository_impl.dart';
|
||||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||||
import 'package:im_app/features/login/usecases/login_usecase.dart';
|
||||
|
||||
/// ## DI 装配:Auth Feature 层
|
||||
///
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../domain/entities/user.dart';
|
||||
|
||||
part 'login_state.freezed.dart';
|
||||
|
||||
/// 登录流程的当前步骤
|
||||
enum LoginStep {
|
||||
/// 步骤 1:输入手机号
|
||||
phone,
|
||||
|
||||
/// 步骤 2:输入验证码
|
||||
otp,
|
||||
}
|
||||
|
||||
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString)
|
||||
///
|
||||
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
|
||||
@@ -12,22 +19,43 @@ part 'login_state.freezed.dart';
|
||||
/// ## 状态流转
|
||||
///
|
||||
/// ```
|
||||
/// 初始 → 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
|
||||
/// 初始
|
||||
/// → LoginState() step: phone, isLoading: false
|
||||
/// 点击"获取验证码"
|
||||
/// → state.copyWith(isLoading: true)
|
||||
/// → 成功: state.copyWith(step: otp, contact: phone, isLoading: false)
|
||||
/// → 失败: state.copyWith(error: '...', isLoading: false)
|
||||
/// 点击"登录"
|
||||
/// → state.copyWith(isLoading: true)
|
||||
/// → 成功: authNotifierProvider.login() → 路由守卫重定向
|
||||
/// → 失败: state.copyWith(error: '...', isLoading: false)
|
||||
/// ```
|
||||
@freezed
|
||||
sealed class LoginState with _$LoginState {
|
||||
const factory LoginState({
|
||||
/// 登录成功后的用户信息(null = 未登录)
|
||||
User? user,
|
||||
const LoginState._();
|
||||
|
||||
/// 是否正在请求中(控制 loading 状态 / 按钮禁用)
|
||||
const factory LoginState({
|
||||
/// 当前步骤(手机号输入 or 验证码输入)
|
||||
@Default(LoginStep.phone) LoginStep step,
|
||||
|
||||
/// 国家代码(默认 +65,暂不支持切换)
|
||||
@Default('+65') String countryCode,
|
||||
|
||||
/// 已提交的手机号(步骤 2 用于显示和构建请求)
|
||||
@Default('') String contact,
|
||||
|
||||
/// 是否正在请求中
|
||||
@Default(false) bool isLoading,
|
||||
|
||||
/// 错误信息(null = 无错误)
|
||||
String? error,
|
||||
}) = _LoginState;
|
||||
|
||||
/// 步骤 2 显示的脱敏手机号,如 "138****0000"
|
||||
String get maskedContact {
|
||||
if (contact.length <= 4) return contact;
|
||||
final tail = contact.substring(contact.length - 4);
|
||||
final stars = '*' * (contact.length - 4);
|
||||
return '$stars$tail';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:im_app/app/di/db_provider.dart';
|
||||
import 'package:im_app/app/di/user_provider.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:networks_sdk/networks_sdk.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';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/login/di/auth_providers.dart';
|
||||
import 'package:im_app/features/login/presentation/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);
|
||||
/// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
|
||||
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
|
||||
/// ```
|
||||
///
|
||||
/// ## 手动 vs 自动 Provider 对比
|
||||
/// ## DI 链路
|
||||
///
|
||||
/// ```
|
||||
/// loginViewModelProvider ← @riverpod 自动生成(本文件)
|
||||
/// loginViewModelProvider ← @riverpod 自动生成
|
||||
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配
|
||||
/// → ref.read(authRepositoryProvider) ← di/ 手动装配
|
||||
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
|
||||
/// ```
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
|
||||
/// → ★ LoginViewModel.login() ★ ← 你在这里
|
||||
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
|
||||
/// → AuthRepository.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginResponse → User
|
||||
/// ← User
|
||||
/// → state = state.copyWith(user: user) ← 更新状态
|
||||
/// View: ref.watch → 自动 rebuild ← UI 刷新
|
||||
/// ```
|
||||
@riverpod
|
||||
class LoginViewModel extends _$LoginViewModel {
|
||||
@override
|
||||
LoginState build() => const LoginState();
|
||||
|
||||
/// Demo 登录(跳过 API,直接设置登录状态)
|
||||
/// 步骤 1:发送手机验证码
|
||||
///
|
||||
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
|
||||
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
|
||||
Future<void> demoLogin() async {
|
||||
// 防止连点重入:第一次调用未完成前忽略后续调用
|
||||
/// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
|
||||
Future<void> sendOtp(String countryCode, String contact) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
final storageApi = ref.read(storageSdkProvider);
|
||||
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
||||
final repositoryProvider = ref.read(userRepositoryProvider);
|
||||
final provider = ref.read(authNotifierProvider);
|
||||
|
||||
try {
|
||||
// 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
|
||||
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
|
||||
final raw = await rootBundle.loadString('assets/loginData.json');
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
final profile = data['profile'] as Map<String, dynamic>;
|
||||
// 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。
|
||||
// Demo 场景直接从 JSON 字段构建 User,不依赖生成的 fromJson。
|
||||
final user = User(
|
||||
uid: profile['uid'] as int,
|
||||
uuid: profile['uuid'] as String,
|
||||
lastOnline: profile['last_online'] as int,
|
||||
profilePic: profile['profile_pic'] as String,
|
||||
profilePicGaussian: profile['profile_pic_gaussian'] as String,
|
||||
nickname: profile['nickname'] as String,
|
||||
contact: profile['contact'] as String,
|
||||
countryCode: profile['country_code'] as String,
|
||||
email: profile['email'] as String,
|
||||
recoveryEmail: profile['recovery_email'] as String,
|
||||
username: profile['username'] as String,
|
||||
bio: profile['bio'] as String,
|
||||
relationship: profile['relationship'] as int,
|
||||
userAlias: profile['user_alias'] as String?,
|
||||
hint: profile['hint'] as String,
|
||||
await ref
|
||||
.read(loginUseCaseProvider)
|
||||
.sendOtp(countryCode: countryCode, contact: contact);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
step: LoginStep.otp,
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
||||
await storageLifeCycle.openDatabase(user.uid);
|
||||
// Save user to DB via repository
|
||||
await repositoryProvider.saveUser(user);
|
||||
|
||||
// Trigger auth state
|
||||
provider.login();
|
||||
} catch (e) {
|
||||
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 设置 loading 状态(UI 显示加载指示器、禁用按钮)
|
||||
/// 2. 调 UseCase(格式校验 → 登录 → 返回 User)
|
||||
/// 3. 成功:写入 user;失败:写入 error
|
||||
Future<void> login(String email, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
final provider = ref.read(loginUseCaseProvider);
|
||||
|
||||
try {
|
||||
final user = await provider.execute(email: email, password: password);
|
||||
state = state.copyWith(user: user, isLoading: false);
|
||||
} on FormatException catch (e) {
|
||||
// 格式校验失败(UseCase 层抛出)
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
// 网络 / 服务端错误(Repository → SDK 透传)
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
// 兜底:防止未预期的异常导致 isLoading 死锁
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤 2+3:校验验证码并完成登录
|
||||
///
|
||||
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向,provider 随即被 dispose。
|
||||
Future<void> verifyAndLogin(String code) async {
|
||||
if (state.isLoading) return;
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await ref.read(loginUseCaseProvider).verifyAndLogin(
|
||||
countryCode: state.countryCode,
|
||||
contact: state.contact,
|
||||
code: code,
|
||||
);
|
||||
|
||||
// 成功后触发路由守卫重定向。
|
||||
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state。
|
||||
if (!ref.mounted) return;
|
||||
ref.read(authNotifierProvider).login();
|
||||
} on FormatException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message, isLoading: false);
|
||||
} on ApiError catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回手机号输入步骤(用户想修改手机号)
|
||||
void backToPhone() {
|
||||
state = state.copyWith(step: LoginStep.phone, error: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
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';
|
||||
import 'package:im_app/core/services/socket_manager.dart';
|
||||
import 'package:im_app/domain/entities/user.dart';
|
||||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||||
|
||||
/// 登录用例
|
||||
///
|
||||
/// 封装登录的完整业务流程:
|
||||
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
|
||||
/// - sendOtp:格式校验 → 发短信
|
||||
/// - verifyAndLogin:格式校验 → 校验验证码 → 登录 → 初始化 WebSocket → 打开本地数据库
|
||||
///
|
||||
/// ## 为什么需要 UseCase?
|
||||
///
|
||||
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
|
||||
/// - 格式校验(不发无效请求,省流量、减少服务端压力)
|
||||
/// - 登录后初始化 WebSocket 连接
|
||||
/// - 登录后按 user id 打开对应的本地数据库
|
||||
///
|
||||
/// 把这些规则封装在 UseCase 里,ViewModel 只需一行调用。
|
||||
/// 登录有明确的多步业务规则,UseCase 把这些规则集中封装,
|
||||
/// ViewModel 只需一行调用。
|
||||
///
|
||||
/// ## 数据流位置
|
||||
///
|
||||
/// ```
|
||||
/// LoginViewModel.login(email, password)
|
||||
/// → ★ LoginUseCase.execute() ★ ← 你在这里
|
||||
/// → 格式校验(邮箱 + 密码)
|
||||
/// → AuthRepository.login()
|
||||
/// → AuthRepositoryImpl.login()
|
||||
/// → _client.executeRequest(LoginRequest)
|
||||
/// ← LoginResponse(SDK 已拆包 envelope)
|
||||
/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
|
||||
/// ← LoginResponse.toEntity() → User
|
||||
/// → SocketManager.connect(token) ← 登录后连接 WebSocket
|
||||
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
|
||||
/// LoginViewModel.sendOtp(countryCode, contact)
|
||||
/// → ★ LoginUseCase.sendOtp() ★ ← 你在这里(步骤 1)
|
||||
/// → 格式校验(手机号)
|
||||
/// → AuthRepository.sendOtp()
|
||||
///
|
||||
/// LoginViewModel.verifyAndLogin(code)
|
||||
/// → ★ LoginUseCase.verifyAndLogin() ★ ← 你在这里(步骤 2+3)
|
||||
/// → 格式校验(验证码)
|
||||
/// → AuthRepository.verifyOtp() → vcode_token
|
||||
/// → AuthRepository.login() → User + token
|
||||
/// → SocketManager.connect(token)
|
||||
/// → StorageSdkApi.openDatabase(uid)
|
||||
/// ← User
|
||||
/// ```
|
||||
class LoginUseCase {
|
||||
@@ -54,62 +52,84 @@ class LoginUseCase {
|
||||
_apiConfig = apiConfig,
|
||||
_storageApi = storageApi;
|
||||
|
||||
/// 执行登录
|
||||
///
|
||||
/// 1. 格式校验 → 不合法直接抛 [FormatException]
|
||||
/// 2. 调 Repository 登录 → 拿到 User(token 写入由 Repository 处理)
|
||||
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
|
||||
/// 4. 按 user id 打开本地数据库
|
||||
/// 步骤 1:发送手机验证码
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 邮箱或密码格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误(由 Repository 透传)
|
||||
Future<User> execute({
|
||||
required String email,
|
||||
required String password,
|
||||
/// - [FormatException] — 手机号格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误
|
||||
Future<void> sendOtp({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
}) async {
|
||||
// ── 1. 格式校验 ──
|
||||
_validateEmail(email);
|
||||
_validatePassword(password);
|
||||
_validatePhone(contact);
|
||||
await _authRepository.sendOtp(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 登录 ──
|
||||
final user = await _authRepository.login(email: email, password: password);
|
||||
/// 步骤 2+3:校验验证码并完成登录,返回 [User]
|
||||
///
|
||||
/// 内部串行:verifyOtp → login → connectWebSocket → openDatabase
|
||||
///
|
||||
/// 抛出:
|
||||
/// - [FormatException] — 验证码格式不合法
|
||||
/// - [ApiError] — 网络/服务端错误
|
||||
Future<User> verifyAndLogin({
|
||||
required String countryCode,
|
||||
required String contact,
|
||||
required String code,
|
||||
}) async {
|
||||
_validateCode(code);
|
||||
|
||||
// ── 3. 连接 WebSocket ──
|
||||
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
|
||||
// 此处直接读取,避免改动现有接口。
|
||||
// 校验验证码,换取 vcode_token
|
||||
final vcodeToken = await _authRepository.verifyOtp(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
code: code,
|
||||
);
|
||||
|
||||
// 用 vcode_token 登录(token 写入由 Repository._onTokenUpdate 回调处理)
|
||||
final user = await _authRepository.login(
|
||||
countryCode: countryCode,
|
||||
contact: contact,
|
||||
vcodeToken: vcodeToken,
|
||||
);
|
||||
|
||||
// 连接 WebSocket(token 已由 Repository 写入 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);
|
||||
// 按用户 uid 打开本地数据库
|
||||
await _storageLifeCycle.openDatabase(user.uid);
|
||||
|
||||
// TODO: 后续扩展点
|
||||
// - 同步联系人列表
|
||||
// - 注册推送 token
|
||||
// TODO: 扩展点 — 同步联系人列表、注册推送 token
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
void _validateEmail(String email) {
|
||||
if (email.trim().isEmpty) {
|
||||
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
|
||||
void _validatePhone(String contact) {
|
||||
final trimmed = contact.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('手机号不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
if (!emailRegex.hasMatch(email.trim())) {
|
||||
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
|
||||
if (trimmed.length < 7 || trimmed.length > 15) {
|
||||
throw const FormatException('手机号长度不正确'); // TODO: 接入国际化
|
||||
}
|
||||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||||
throw const FormatException('手机号只能包含数字'); // TODO: 接入国际化
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePassword(String password) {
|
||||
if (password.isEmpty) {
|
||||
throw const FormatException('密码不能为空'); // TODO: 接入国际化
|
||||
void _validateCode(String code) {
|
||||
final trimmed = code.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('验证码不能为空'); // TODO: 接入国际化
|
||||
}
|
||||
if (password.length < 6) {
|
||||
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
|
||||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||||
throw const FormatException('验证码只能包含数字'); // TODO: l10n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
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';
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
import 'package:im_app/features/login/presentation/login_view_model.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_otp_step.dart';
|
||||
import 'package:im_app/features/login/view/widgets/login_phone_step.dart';
|
||||
|
||||
/// 登录页(Demo)
|
||||
/// 登录页 — 两步流程:手机号 → 验证码
|
||||
///
|
||||
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin]
|
||||
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
|
||||
/// 步骤 1 [LoginStep.phone]:[LoginPhoneStep] — 输入国家代码 + 手机号
|
||||
/// 步骤 2 [LoginStep.otp]:[LoginOtpStep] — 输入验证码完成登录
|
||||
///
|
||||
/// 正式实现时替换为完整登录流程(email/password 输入 → LoginViewModel.login)。
|
||||
class LoginPage extends ConsumerWidget {
|
||||
/// 页面本身只持有两个 TextEditingController 和三个回调方法,
|
||||
/// 具体 UI 由 widgets/ 下的子组件负责。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者)
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
// demo 预填,上线前去掉
|
||||
final _phoneCtrl = TextEditingController(text: '83465308');
|
||||
final _otpCtrl = TextEditingController(text: '0000');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_otpCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendOtp(LoginState state) {
|
||||
ref
|
||||
.read(loginViewModelProvider.notifier)
|
||||
.sendOtp(state.countryCode, _phoneCtrl.text.trim());
|
||||
}
|
||||
|
||||
void _verifyAndLogin() {
|
||||
ref
|
||||
.read(loginViewModelProvider.notifier)
|
||||
.verifyAndLogin(_otpCtrl.text.trim());
|
||||
}
|
||||
|
||||
void _backToPhone() {
|
||||
_otpCtrl.clear();
|
||||
ref.read(loginViewModelProvider.notifier).backToPhone();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(loginViewModelProvider);
|
||||
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: state.isLoading
|
||||
? null
|
||||
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
|
||||
child: const Text('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('登录')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: state.step == LoginStep.phone
|
||||
? LoginPhoneStep(
|
||||
phoneCtrl: _phoneCtrl,
|
||||
state: state,
|
||||
onSendOtp: () => _sendOtp(state),
|
||||
)
|
||||
: LoginOtpStep(
|
||||
otpCtrl: _otpCtrl,
|
||||
state: state,
|
||||
onVerifyAndLogin: _verifyAndLogin,
|
||||
onBackToPhone: _backToPhone,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
/// 登录步骤 2 — 输入验证码并完成登录
|
||||
///
|
||||
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// LoginOtpStep(
|
||||
/// otpCtrl: _otpCtrl,
|
||||
/// state: state,
|
||||
/// onVerifyAndLogin: _verifyAndLogin,
|
||||
/// onBackToPhone: _backToPhone,
|
||||
/// )
|
||||
/// ```
|
||||
class LoginOtpStep extends StatelessWidget {
|
||||
const LoginOtpStep({
|
||||
super.key,
|
||||
required this.otpCtrl,
|
||||
required this.state,
|
||||
required this.onVerifyAndLogin,
|
||||
required this.onBackToPhone,
|
||||
});
|
||||
|
||||
final TextEditingController otpCtrl;
|
||||
final LoginState state;
|
||||
final VoidCallback onVerifyAndLogin;
|
||||
final VoidCallback onBackToPhone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'输入验证码',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'验证码已发送至 ${state.countryCode} ${state.maskedContact}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: otpCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '4 位验证码',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: state.isLoading ? null : onVerifyAndLogin,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: state.isLoading ? null : onBackToPhone,
|
||||
child: const Text('返回修改手机号'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:im_app/features/login/presentation/login_state.dart';
|
||||
|
||||
/// 登录步骤 1 — 输入国家代码 + 手机号
|
||||
///
|
||||
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// LoginPhoneStep(
|
||||
/// phoneCtrl: _phoneCtrl,
|
||||
/// state: state,
|
||||
/// onSendOtp: () => _sendOtp(state),
|
||||
/// )
|
||||
/// ```
|
||||
class LoginPhoneStep extends StatelessWidget {
|
||||
const LoginPhoneStep({
|
||||
super.key,
|
||||
required this.phoneCtrl,
|
||||
required this.state,
|
||||
required this.onSendOtp,
|
||||
});
|
||||
|
||||
final TextEditingController phoneCtrl;
|
||||
final LoginState state;
|
||||
final VoidCallback onSendOtp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'手机号登录',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
state.countryCode,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '手机号',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofillHints: const [AutofillHints.telephoneNumber],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
state.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: state.isLoading ? null : onSendOtp,
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('获取验证码'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../usecases/set_theme_usecase.dart';
|
||||
import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
|
||||
|
||||
/// Settings feature DI 装配
|
||||
///
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import 'package:im_app/app/router/app_route_name.dart';
|
||||
|
||||
part 'settings_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../app/di/app_providers.dart';
|
||||
import '../di/settings_providers.dart';
|
||||
import 'package:im_app/app/di/app_providers.dart';
|
||||
import 'package:im_app/features/settings/di/settings_providers.dart';
|
||||
|
||||
part 'theme_view_model.g.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../presentation/settings_view_model.dart';
|
||||
import 'package:im_app/features/settings/presentation/settings_view_model.dart';
|
||||
|
||||
/// 设置页
|
||||
///
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
import 'package:im_app/features/settings/presentation/theme_view_model.dart';
|
||||
import 'package:im_app/features/settings/view/widgets/settings_section_header.dart';
|
||||
import 'package:im_app/features/settings/view/widgets/theme_option_tile.dart';
|
||||
|
||||
/// 主题选择页
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 设置页分组标题
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/ui/base/context_theme_ext.dart';
|
||||
import 'package:im_app/core/ui/base/context_theme_ext.dart';
|
||||
|
||||
/// 单个主题选项行
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user