网络请求打通,ws 打通

This commit is contained in:
Cody
2026-03-09 19:05:55 +08:00
parent 997d821447
commit 3c1976b343
60 changed files with 1392 additions and 552 deletions

View File

@@ -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';

View File

@@ -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});

View File

@@ -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
///

View File

@@ -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 按钮)
///

View File

@@ -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 层
///

View File

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

View File

@@ -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);
}
}

View File

@@ -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)
/// LoginResponseSDK 已拆包 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 登录 → 拿到 Usertoken 写入由 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,
);
// 连接 WebSockettoken 已由 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
}
}
}

View File

@@ -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,
),
),
);
}

View File

@@ -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('返回修改手机号'),
),
],
);
}
}

View File

@@ -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('获取验证码'),
),
],
);
}
}

View File

@@ -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 装配
///

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';
/// 设置页
///

View File

@@ -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';
/// 主题选择页
///

View File

@@ -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';
/// 设置页分组标题
///

View File

@@ -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';
/// 单个主题选项行
///