feat(auth): transition from email to phone-based OTP authentication
- Replace Email+Password login with Phone+OTP flow - Remove RegisterCubit and registration screens (email verification) - Remove ResetPasswordCubit and reset password screens - Add phone normalization and international dial code support - Update LoginCubit with sendCode/resend cooldown logic - Add new widgets: phone prefix selector, confirm sheet - Update all auth API endpoints: /otp/send, /phone-session - Update form inputs: Email -> Phone with E.164 validation - Update tests for new auth flow
This commit is contained in:
@@ -13,16 +13,17 @@ class Username extends FormzInput<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Email extends FormzInput<String, String> {
|
class Phone extends FormzInput<String, String> {
|
||||||
const Email.pure() : super.pure('');
|
const Phone.pure() : super.pure('');
|
||||||
const Email.dirty([super.value = '']) : super.dirty();
|
const Phone.dirty([super.value = '']) : super.dirty();
|
||||||
|
|
||||||
static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$');
|
static final _regex = RegExp(r'^\d{7,14}$');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? validator(String value) {
|
String? validator(String value) {
|
||||||
if (value.isEmpty) return '请输入邮箱';
|
final normalized = value.replaceAll(RegExp(r'\s+'), '');
|
||||||
if (!_regex.hasMatch(value)) return '邮箱格式不正确';
|
if (normalized.isEmpty) return '请输入手机号';
|
||||||
|
if (!_regex.hasMatch(normalized)) return '手机号格式不正确';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart';
|
|||||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||||
import 'app_routes.dart';
|
import 'app_routes.dart';
|
||||||
import 'go_router_refresh_stream.dart';
|
import 'go_router_refresh_stream.dart';
|
||||||
import '../../features/auth/ui/screens/login_screen.dart';
|
|
||||||
import '../../features/auth/ui/screens/auth_boot_screen.dart';
|
import '../../features/auth/ui/screens/auth_boot_screen.dart';
|
||||||
import '../../features/auth/ui/screens/register_screen.dart';
|
import '../../features/auth/ui/screens/login_screen.dart';
|
||||||
import '../../features/auth/ui/screens/register_verification_screen.dart';
|
|
||||||
import '../../features/auth/ui/screens/reset_password_screen.dart';
|
|
||||||
import '../../features/home/ui/screens/home_screen.dart';
|
import '../../features/home/ui/screens/home_screen.dart';
|
||||||
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
|
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
|
||||||
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
|
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
|
||||||
@@ -28,7 +25,6 @@ import '../../features/settings/ui/screens/settings_screen.dart';
|
|||||||
import '../../features/settings/ui/screens/features_screen.dart';
|
import '../../features/settings/ui/screens/features_screen.dart';
|
||||||
import '../../features/settings/ui/screens/memory_screen.dart';
|
import '../../features/settings/ui/screens/memory_screen.dart';
|
||||||
import '../../features/settings/ui/screens/account_screen.dart';
|
import '../../features/settings/ui/screens/account_screen.dart';
|
||||||
import '../../features/settings/ui/screens/change_password_screen.dart';
|
|
||||||
import '../../features/settings/ui/screens/edit_profile_screen.dart';
|
import '../../features/settings/ui/screens/edit_profile_screen.dart';
|
||||||
|
|
||||||
final _protectedRoutes = [
|
final _protectedRoutes = [
|
||||||
@@ -43,7 +39,6 @@ final _protectedRoutes = [
|
|||||||
AppRoutes.settingsFeatures,
|
AppRoutes.settingsFeatures,
|
||||||
AppRoutes.settingsMemory,
|
AppRoutes.settingsMemory,
|
||||||
AppRoutes.settingsAccount,
|
AppRoutes.settingsAccount,
|
||||||
AppRoutes.settingsChangePassword,
|
|
||||||
AppRoutes.settingsEditProfile,
|
AppRoutes.settingsEditProfile,
|
||||||
AppRoutes.messageInviteList,
|
AppRoutes.messageInviteList,
|
||||||
];
|
];
|
||||||
@@ -61,8 +56,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
|||||||
final isBootRoute = state.matchedLocation == AppRoutes.authBoot;
|
final isBootRoute = state.matchedLocation == AppRoutes.authBoot;
|
||||||
final isAuthRoute =
|
final isAuthRoute =
|
||||||
state.matchedLocation == AppRoutes.authLogin ||
|
state.matchedLocation == AppRoutes.authLogin ||
|
||||||
state.matchedLocation.startsWith('/login') ||
|
state.matchedLocation.startsWith('/login');
|
||||||
state.matchedLocation.startsWith('/register');
|
|
||||||
final isProtected = _protectedRoutes.any(
|
final isProtected = _protectedRoutes.any(
|
||||||
(route) => state.matchedLocation.startsWith(route),
|
(route) => state.matchedLocation.startsWith(route),
|
||||||
);
|
);
|
||||||
@@ -86,10 +80,6 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
|||||||
path: AppRoutes.authBoot,
|
path: AppRoutes.authBoot,
|
||||||
builder: (context, state) => const AuthBootScreen(),
|
builder: (context, state) => const AuthBootScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.authLogin,
|
|
||||||
builder: (context, state) => const LoginScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.calendarEventCreate,
|
path: AppRoutes.calendarEventCreate,
|
||||||
builder: (context, state) => CalendarEventCreateScreen(
|
builder: (context, state) => CalendarEventCreateScreen(
|
||||||
@@ -112,16 +102,8 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
|||||||
CalendarEventShareScreen(eventId: state.pathParameters['id']!),
|
CalendarEventShareScreen(eventId: state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.authRegister,
|
path: AppRoutes.authLogin,
|
||||||
builder: (context, state) => const RegisterScreen(),
|
builder: (context, state) => const LoginScreen(),
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.authRegisterVerification,
|
|
||||||
builder: (context, state) => const RegisterVerificationScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.authResetPassword,
|
|
||||||
builder: (context, state) => const ResetPasswordScreen(),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.homeMain,
|
path: AppRoutes.homeMain,
|
||||||
@@ -196,10 +178,6 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
|||||||
path: AppRoutes.settingsAccount,
|
path: AppRoutes.settingsAccount,
|
||||||
builder: (context, state) => const AccountScreen(),
|
builder: (context, state) => const AccountScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.settingsChangePassword,
|
|
||||||
builder: (context, state) => const ChangePasswordScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.settingsEditProfile,
|
path: AppRoutes.settingsEditProfile,
|
||||||
builder: (context, state) => const EditProfileScreen(),
|
builder: (context, state) => const EditProfileScreen(),
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ class AppRoutes {
|
|||||||
|
|
||||||
static const authBoot = '/boot';
|
static const authBoot = '/boot';
|
||||||
static const authLogin = '/';
|
static const authLogin = '/';
|
||||||
static const authRegister = '/register';
|
|
||||||
static const authRegisterVerification = '/register/verification';
|
|
||||||
static const authResetPassword = '/reset-password';
|
|
||||||
|
|
||||||
static const homeMain = '/home';
|
static const homeMain = '/home';
|
||||||
|
|
||||||
@@ -31,6 +28,5 @@ class AppRoutes {
|
|||||||
static const settingsFeatures = '/settings/features';
|
static const settingsFeatures = '/settings/features';
|
||||||
static const settingsMemory = '/settings/memory';
|
static const settingsMemory = '/settings/memory';
|
||||||
static const settingsAccount = '/settings/account';
|
static const settingsAccount = '/settings/account';
|
||||||
static const settingsChangePassword = '/change-password';
|
|
||||||
static const settingsEditProfile = '/edit-profile';
|
static const settingsEditProfile = '/edit-profile';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,34 +9,13 @@ class AuthApi {
|
|||||||
|
|
||||||
AuthApi(this._client);
|
AuthApi(this._client);
|
||||||
|
|
||||||
Future<VerificationCreateResponse> createVerification(
|
Future<void> sendOtp(OtpSendRequest request) async {
|
||||||
SignupStartRequest request,
|
await _client.post('$_prefix/otp/send', data: request.toJson());
|
||||||
) async {
|
|
||||||
final response = await _client.post(
|
|
||||||
'$_prefix/verifications',
|
|
||||||
data: request.toJson(),
|
|
||||||
);
|
|
||||||
return VerificationCreateResponse.fromJson(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
|
Future<AuthResponse> createPhoneSession(LoginRequest request) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
'$_prefix/verify',
|
'$_prefix/phone-session',
|
||||||
data: {'type': 'signup', ...request.toJson()},
|
|
||||||
);
|
|
||||||
return AuthResponse.fromJson(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> resendVerification(SignupResendRequest request) async {
|
|
||||||
await _client.post(
|
|
||||||
'$_prefix/resend',
|
|
||||||
data: {'type': 'signup', ...request.toJson()},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuthResponse> createSession(LoginRequest request) async {
|
|
||||||
final response = await _client.post(
|
|
||||||
'$_prefix/sessions',
|
|
||||||
data: request.toJson(),
|
data: request.toJson(),
|
||||||
);
|
);
|
||||||
return AuthResponse.fromJson(response.data);
|
return AuthResponse.fromJson(response.data);
|
||||||
@@ -53,27 +32,4 @@ class AuthApi {
|
|||||||
Future<void> deleteSession(LogoutRequest request) async {
|
Future<void> deleteSession(LogoutRequest request) async {
|
||||||
await _client.delete('$_prefix/sessions', data: request.toJson());
|
await _client.delete('$_prefix/sessions', data: request.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestPasswordReset(String email) async {
|
|
||||||
await _client.post(
|
|
||||||
'$_prefix/resend',
|
|
||||||
data: {'type': 'recovery', 'email': email},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> confirmPasswordReset({
|
|
||||||
required String email,
|
|
||||||
required String token,
|
|
||||||
required String newPassword,
|
|
||||||
}) async {
|
|
||||||
await _client.post(
|
|
||||||
'$_prefix/verify',
|
|
||||||
data: {
|
|
||||||
'type': 'recovery',
|
|
||||||
'email': email,
|
|
||||||
'token': token,
|
|
||||||
'new_password': newPassword,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
import 'package:social_app/features/auth/data/models/signup_request.dart';
|
|
||||||
import 'package:social_app/features/auth/data/models/login_request.dart';
|
|
||||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||||
|
|
||||||
abstract class AuthRepository {
|
abstract class AuthRepository {
|
||||||
Future<VerificationCreateResponse> createVerification(
|
Future<void> sendOtp(String phone);
|
||||||
SignupStartRequest request,
|
Future<AuthResponse> createPhoneSession({
|
||||||
);
|
required String phone,
|
||||||
Future<AuthResponse> verifyVerification(SignupVerifyRequest request);
|
required String token,
|
||||||
Future<void> resendVerification(SignupResendRequest request);
|
});
|
||||||
Future<AuthResponse> createSession(LoginRequest request);
|
|
||||||
Future<AuthResponse> refreshSession(String refreshToken);
|
Future<AuthResponse> refreshSession(String refreshToken);
|
||||||
Future<void> deleteSession();
|
Future<void> deleteSession();
|
||||||
Future<void> clearSessionLocalOnly();
|
Future<void> clearSessionLocalOnly();
|
||||||
Future<String?> getAccessToken();
|
Future<String?> getAccessToken();
|
||||||
Future<String?> getRefreshToken();
|
Future<String?> getRefreshToken();
|
||||||
Future<bool> isAuthenticated();
|
Future<bool> isAuthenticated();
|
||||||
Future<void> requestPasswordReset(String email);
|
|
||||||
Future<void> confirmPasswordReset({
|
|
||||||
required String email,
|
|
||||||
required String token,
|
|
||||||
required String newPassword,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,30 +19,18 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
_onLogout = onLogout;
|
_onLogout = onLogout;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationCreateResponse> createVerification(
|
Future<void> sendOtp(String phone) {
|
||||||
SignupStartRequest request,
|
return _api.sendOtp(OtpSendRequest(phone: phone));
|
||||||
) {
|
|
||||||
return _api.createVerification(request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
|
Future<AuthResponse> createPhoneSession({
|
||||||
final response = await _api.verifyVerification(request);
|
required String phone,
|
||||||
await _tokenStorage.saveTokens(
|
required String token,
|
||||||
access: response.accessToken,
|
}) async {
|
||||||
refresh: response.refreshToken,
|
final response = await _api.createPhoneSession(
|
||||||
|
LoginRequest(phone: phone, token: token),
|
||||||
);
|
);
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> resendVerification(SignupResendRequest request) {
|
|
||||||
return _api.resendVerification(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AuthResponse> createSession(LoginRequest request) async {
|
|
||||||
final response = await _api.createSession(request);
|
|
||||||
await _tokenStorage.saveTokens(
|
await _tokenStorage.saveTokens(
|
||||||
access: response.accessToken,
|
access: response.accessToken,
|
||||||
refresh: response.refreshToken,
|
refresh: response.refreshToken,
|
||||||
@@ -94,22 +82,4 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
final token = await _tokenStorage.getAccessToken();
|
final token = await _tokenStorage.getAccessToken();
|
||||||
return token != null;
|
return token != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> requestPasswordReset(String email) {
|
|
||||||
return _api.requestPasswordReset(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> confirmPasswordReset({
|
|
||||||
required String email,
|
|
||||||
required String token,
|
|
||||||
required String newPassword,
|
|
||||||
}) {
|
|
||||||
return _api.confirmPasswordReset(
|
|
||||||
email: email,
|
|
||||||
token: token,
|
|
||||||
newPassword: newPassword,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
class AuthUser {
|
class AuthUser {
|
||||||
final String id;
|
final String id;
|
||||||
final String email;
|
final String phone;
|
||||||
|
|
||||||
const AuthUser({required this.id, required this.email});
|
const AuthUser({required this.id, required this.phone});
|
||||||
|
|
||||||
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
||||||
return AuthUser(id: json['id'] as String, email: json['email'] as String);
|
return AuthUser(id: json['id'] as String, phone: json['phone'] as String);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +34,3 @@ class AuthResponse {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerificationCreateResponse {
|
|
||||||
final String email;
|
|
||||||
|
|
||||||
const VerificationCreateResponse({required this.email});
|
|
||||||
|
|
||||||
factory VerificationCreateResponse.fromJson(Map<String, dynamic> json) {
|
|
||||||
return VerificationCreateResponse(email: json['email'] as String);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
class LoginRequest {
|
class LoginRequest {
|
||||||
final String email;
|
final String phone;
|
||||||
final String password;
|
final String token;
|
||||||
|
|
||||||
const LoginRequest({required this.email, required this.password});
|
const LoginRequest({required this.phone, required this.token});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'email': email, 'password': password};
|
Map<String, dynamic> toJson() => {
|
||||||
|
'phone': _normalizePhone(phone),
|
||||||
|
'token': token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizePhone(String input) {
|
||||||
|
var normalized = input.trim();
|
||||||
|
normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), '');
|
||||||
|
if (normalized.startsWith('00') && normalized.length > 2) {
|
||||||
|
return '+${normalized.substring(2)}';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RefreshRequest {
|
class RefreshRequest {
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
class SignupStartRequest {
|
class OtpSendRequest {
|
||||||
final String username;
|
final String phone;
|
||||||
final String email;
|
|
||||||
final String password;
|
|
||||||
final String? inviteCode;
|
|
||||||
|
|
||||||
const SignupStartRequest({
|
const OtpSendRequest({required this.phone});
|
||||||
required this.username,
|
|
||||||
required this.email,
|
Map<String, dynamic> toJson() => {'phone': _normalizePhone(phone)};
|
||||||
required this.password,
|
}
|
||||||
this.inviteCode,
|
|
||||||
});
|
class PhoneSessionRequest {
|
||||||
|
final String phone;
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
const PhoneSessionRequest({required this.phone, required this.token});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'username': username,
|
'phone': _normalizePhone(phone),
|
||||||
'email': email,
|
'token': token,
|
||||||
'password': password,
|
|
||||||
if (inviteCode != null) 'invite_code': inviteCode,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class SignupVerifyRequest {
|
String _normalizePhone(String input) {
|
||||||
final String email;
|
var normalized = input.trim();
|
||||||
final String token;
|
normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), '');
|
||||||
|
if (normalized.startsWith('00') && normalized.length > 2) {
|
||||||
const SignupVerifyRequest({required this.email, required this.token});
|
return '+${normalized.substring(2)}';
|
||||||
|
}
|
||||||
Map<String, dynamic> toJson() => {'email': email, 'token': token};
|
return normalized;
|
||||||
}
|
|
||||||
|
|
||||||
class SignupResendRequest {
|
|
||||||
final String email;
|
|
||||||
|
|
||||||
const SignupResendRequest({required this.email});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {'email': email};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
final response = await _repository.refreshSession(refreshToken);
|
final response = await _repository.refreshSession(refreshToken);
|
||||||
emit(
|
emit(
|
||||||
AuthAuthenticated(
|
AuthAuthenticated(
|
||||||
user: AuthUser(id: response.user.id, email: response.user.email),
|
user: AuthUser(id: response.user.id, phone: response.user.phone),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,56 +1,161 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:formz/formz.dart';
|
import 'package:formz/formz.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../../../../core/api/api_exception.dart';
|
import '../../../../core/api/api_exception.dart';
|
||||||
import '../../data/auth_repository.dart';
|
import '../../data/auth_repository.dart';
|
||||||
import '../../data/models/login_request.dart';
|
|
||||||
import '../../data/models/auth_response.dart';
|
import '../../data/models/auth_response.dart';
|
||||||
import '../../../../core/form_inputs/form_inputs.dart';
|
import '../../../../core/form_inputs/form_inputs.dart';
|
||||||
|
|
||||||
class LoginState extends Equatable {
|
class LoginState extends Equatable {
|
||||||
final Email email;
|
static const defaultDialCode = '+86';
|
||||||
final Password password;
|
static final _e164Regex = RegExp(r'^\+[1-9]\d{7,14}$');
|
||||||
|
|
||||||
|
final String dialCode;
|
||||||
|
final Phone phone;
|
||||||
|
final VerificationCode code;
|
||||||
|
final bool codeSent;
|
||||||
|
final bool isSendingCode;
|
||||||
|
final int resendCooldownSeconds;
|
||||||
final FormzSubmissionStatus status;
|
final FormzSubmissionStatus status;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
const LoginState({
|
const LoginState({
|
||||||
this.email = const Email.pure(),
|
this.dialCode = defaultDialCode,
|
||||||
this.password = const Password.pure(),
|
this.phone = const Phone.pure(),
|
||||||
|
this.code = const VerificationCode.pure(),
|
||||||
|
this.codeSent = false,
|
||||||
|
this.isSendingCode = false,
|
||||||
|
this.resendCooldownSeconds = 0,
|
||||||
this.status = FormzSubmissionStatus.initial,
|
this.status = FormzSubmissionStatus.initial,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isValid => email.isValid && password.isValid;
|
bool get isPhoneValidForApi =>
|
||||||
|
phone.isValid && _e164Regex.hasMatch(e164Phone);
|
||||||
|
bool get isValid => isPhoneValidForApi && code.isValid;
|
||||||
|
String get e164Phone => '$dialCode${phone.value}';
|
||||||
|
bool get canSendCode =>
|
||||||
|
isPhoneValidForApi && !isSendingCode && resendCooldownSeconds == 0;
|
||||||
|
|
||||||
LoginState copyWith({
|
LoginState copyWith({
|
||||||
Email? email,
|
String? dialCode,
|
||||||
Password? password,
|
Phone? phone,
|
||||||
|
VerificationCode? code,
|
||||||
|
bool? codeSent,
|
||||||
|
bool? isSendingCode,
|
||||||
|
int? resendCooldownSeconds,
|
||||||
FormzSubmissionStatus? status,
|
FormzSubmissionStatus? status,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return LoginState(
|
return LoginState(
|
||||||
email: email ?? this.email,
|
dialCode: dialCode ?? this.dialCode,
|
||||||
password: password ?? this.password,
|
phone: phone ?? this.phone,
|
||||||
|
code: code ?? this.code,
|
||||||
|
codeSent: codeSent ?? this.codeSent,
|
||||||
|
isSendingCode: isSendingCode ?? this.isSendingCode,
|
||||||
|
resendCooldownSeconds:
|
||||||
|
resendCooldownSeconds ?? this.resendCooldownSeconds,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [email, password, status, errorMessage];
|
List<Object?> get props => [
|
||||||
|
dialCode,
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
codeSent,
|
||||||
|
isSendingCode,
|
||||||
|
resendCooldownSeconds,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginCubit extends Cubit<LoginState> {
|
class LoginCubit extends Cubit<LoginState> {
|
||||||
final AuthRepository _repository;
|
final AuthRepository _repository;
|
||||||
|
Timer? _resendTimer;
|
||||||
|
|
||||||
LoginCubit(this._repository) : super(const LoginState());
|
LoginCubit(this._repository) : super(const LoginState());
|
||||||
|
|
||||||
void emailChanged(String value) {
|
void phoneChanged(String value) {
|
||||||
emit(state.copyWith(email: Email.dirty(value)));
|
final nextPhone = Phone.dirty(value);
|
||||||
|
if (nextPhone.value == state.phone.value) {
|
||||||
|
emit(state.copyWith(phone: nextPhone, errorMessage: null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_resendTimer?.cancel();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
phone: nextPhone,
|
||||||
|
code: const VerificationCode.pure(),
|
||||||
|
codeSent: false,
|
||||||
|
resendCooldownSeconds: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void passwordChanged(String value) {
|
void dialCodeChanged(String value) {
|
||||||
emit(state.copyWith(password: Password.dirty(value)));
|
if (value == state.dialCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_resendTimer?.cancel();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
dialCode: value,
|
||||||
|
code: const VerificationCode.pure(),
|
||||||
|
codeSent: false,
|
||||||
|
resendCooldownSeconds: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void codeChanged(String value) {
|
||||||
|
emit(state.copyWith(code: VerificationCode.dirty(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> sendCode() async {
|
||||||
|
if (!state.phone.isValid) {
|
||||||
|
emit(state.copyWith(errorMessage: '请输入有效手机号'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!state.canSendCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final requestPhone = state.e164Phone;
|
||||||
|
emit(state.copyWith(isSendingCode: true, errorMessage: null));
|
||||||
|
try {
|
||||||
|
await _repository.sendOtp(requestPhone);
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.e164Phone != requestPhone) {
|
||||||
|
emit(state.copyWith(isSendingCode: false));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isSendingCode: false,
|
||||||
|
codeSent: true,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_startResendCooldown();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final message = e is ApiException ? e.message : '验证码发送失败';
|
||||||
|
emit(state.copyWith(isSendingCode: false, errorMessage: message));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthResponse?> submit() async {
|
Future<AuthResponse?> submit() async {
|
||||||
@@ -59,12 +164,19 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _repository.createSession(
|
final response = await _repository.createPhoneSession(
|
||||||
LoginRequest(email: state.email.value, password: state.password.value),
|
phone: state.e164Phone,
|
||||||
|
token: state.code.value,
|
||||||
);
|
);
|
||||||
|
if (isClosed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isClosed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final message = e is ApiException ? e.message : e.toString();
|
final message = e is ApiException ? e.message : e.toString();
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -75,4 +187,29 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startResendCooldown() {
|
||||||
|
_resendTimer?.cancel();
|
||||||
|
emit(state.copyWith(resendCooldownSeconds: 60));
|
||||||
|
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (isClosed) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.resendCooldownSeconds <= 1) {
|
||||||
|
timer.cancel();
|
||||||
|
emit(state.copyWith(resendCooldownSeconds: 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(resendCooldownSeconds: state.resendCooldownSeconds - 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_resendTimer?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import '../../../../core/api/api_exception.dart';
|
|
||||||
import '../../../../core/form_inputs/form_inputs.dart';
|
|
||||||
import '../../data/auth_repository.dart';
|
|
||||||
import '../../data/models/signup_request.dart';
|
|
||||||
import '../../data/models/auth_response.dart';
|
|
||||||
|
|
||||||
class RegisterState extends Equatable {
|
|
||||||
final Username username;
|
|
||||||
final Email email;
|
|
||||||
final Password password;
|
|
||||||
final VerificationCode verificationCode;
|
|
||||||
final String inviteCode;
|
|
||||||
final FormzSubmissionStatus status;
|
|
||||||
final String? errorMessage;
|
|
||||||
final String? pendingEmail;
|
|
||||||
final bool codeSent;
|
|
||||||
final bool isSending;
|
|
||||||
|
|
||||||
const RegisterState({
|
|
||||||
this.username = const Username.pure(),
|
|
||||||
this.email = const Email.pure(),
|
|
||||||
this.password = const Password.pure(),
|
|
||||||
this.verificationCode = const VerificationCode.pure(),
|
|
||||||
this.inviteCode = '',
|
|
||||||
this.status = FormzSubmissionStatus.initial,
|
|
||||||
this.errorMessage,
|
|
||||||
this.pendingEmail,
|
|
||||||
this.codeSent = false,
|
|
||||||
this.isSending = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get isStep1Valid =>
|
|
||||||
username.isValid && email.isValid && password.isValid;
|
|
||||||
bool get isStep2Valid => verificationCode.isValid;
|
|
||||||
|
|
||||||
RegisterState copyWith({
|
|
||||||
Username? username,
|
|
||||||
Email? email,
|
|
||||||
Password? password,
|
|
||||||
VerificationCode? verificationCode,
|
|
||||||
String? inviteCode,
|
|
||||||
FormzSubmissionStatus? status,
|
|
||||||
String? errorMessage,
|
|
||||||
String? pendingEmail,
|
|
||||||
bool? codeSent,
|
|
||||||
bool? isSending,
|
|
||||||
}) {
|
|
||||||
return RegisterState(
|
|
||||||
username: username ?? this.username,
|
|
||||||
email: email ?? this.email,
|
|
||||||
password: password ?? this.password,
|
|
||||||
verificationCode: verificationCode ?? this.verificationCode,
|
|
||||||
inviteCode: inviteCode ?? this.inviteCode,
|
|
||||||
status: status ?? this.status,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
pendingEmail: pendingEmail ?? this.pendingEmail,
|
|
||||||
codeSent: codeSent ?? this.codeSent,
|
|
||||||
isSending: isSending ?? this.isSending,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
verificationCode,
|
|
||||||
inviteCode,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
pendingEmail,
|
|
||||||
codeSent,
|
|
||||||
isSending,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
class RegisterCubit extends Cubit<RegisterState> {
|
|
||||||
final AuthRepository _repository;
|
|
||||||
|
|
||||||
RegisterCubit(this._repository) : super(const RegisterState());
|
|
||||||
|
|
||||||
void usernameChanged(String value) {
|
|
||||||
emit(state.copyWith(username: Username.dirty(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void emailChanged(String value) {
|
|
||||||
emit(state.copyWith(email: Email.dirty(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void passwordChanged(String value) {
|
|
||||||
emit(state.copyWith(password: Password.dirty(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void verificationCodeChanged(String value) {
|
|
||||||
emit(state.copyWith(verificationCode: VerificationCode.dirty(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
void inviteCodeChanged(String value) {
|
|
||||||
emit(state.copyWith(inviteCode: value));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> submitStep1() async {
|
|
||||||
if (!state.isStep1Valid) return false;
|
|
||||||
|
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await _repository.createVerification(
|
|
||||||
SignupStartRequest(
|
|
||||||
username: state.username.value,
|
|
||||||
email: state.email.value,
|
|
||||||
password: state.password.value,
|
|
||||||
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.success,
|
|
||||||
pendingEmail: response.email,
|
|
||||||
codeSent: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
final message = e is ApiException ? e.message : e.toString();
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuthResponse?> submitStep2() async {
|
|
||||||
if (!state.isStep2Valid || state.pendingEmail == null) return null;
|
|
||||||
|
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await _repository.verifyVerification(
|
|
||||||
SignupVerifyRequest(
|
|
||||||
email: state.pendingEmail!,
|
|
||||||
token: state.verificationCode.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.success));
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
final message = e is ApiException ? e.message : e.toString();
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> resendCode() async {
|
|
||||||
if (state.pendingEmail == null) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '验证码发送失败,请返回上一步重试',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _repository.resendVerification(
|
|
||||||
SignupResendRequest(email: state.pendingEmail!),
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: FormzSubmissionStatus.success, codeSent: true),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
final message = e is ApiException ? e.message : '验证码发送失败,请重试';
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> sendCodeSilently() async {
|
|
||||||
if (!state.isStep1Valid || state.isSending) return;
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
isSending: true,
|
|
||||||
status: FormzSubmissionStatus.inProgress,
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await _repository.createVerification(
|
|
||||||
SignupStartRequest(
|
|
||||||
username: state.username.value,
|
|
||||||
email: state.email.value,
|
|
||||||
password: state.password.value,
|
|
||||||
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
isSending: false,
|
|
||||||
status: FormzSubmissionStatus.success,
|
|
||||||
pendingEmail: response.email,
|
|
||||||
codeSent: true,
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
final message = e is ApiException ? e.message : '验证码发送失败,请重试';
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
isSending: false,
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import '../../../../core/form_inputs/form_inputs.dart';
|
|
||||||
import '../../data/auth_repository.dart';
|
|
||||||
|
|
||||||
class ResetPasswordState extends Equatable {
|
|
||||||
final Email email;
|
|
||||||
final VerificationCode code;
|
|
||||||
final Password newPassword;
|
|
||||||
final Password confirmPassword;
|
|
||||||
final FormzSubmissionStatus status;
|
|
||||||
final String? errorMessage;
|
|
||||||
final bool isSuccess;
|
|
||||||
final int resendCountdown;
|
|
||||||
final bool codeSent;
|
|
||||||
|
|
||||||
const ResetPasswordState({
|
|
||||||
this.email = const Email.pure(),
|
|
||||||
this.code = const VerificationCode.pure(),
|
|
||||||
this.newPassword = const Password.pure(),
|
|
||||||
this.confirmPassword = const Password.pure(),
|
|
||||||
this.status = FormzSubmissionStatus.initial,
|
|
||||||
this.errorMessage,
|
|
||||||
this.isSuccess = false,
|
|
||||||
this.resendCountdown = 0,
|
|
||||||
this.codeSent = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool get canSubmit {
|
|
||||||
if (!codeSent) {
|
|
||||||
return email.isValid && status != FormzSubmissionStatus.inProgress;
|
|
||||||
}
|
|
||||||
return email.isValid &&
|
|
||||||
code.isValid &&
|
|
||||||
newPassword.isValid &&
|
|
||||||
confirmPassword.isValid &&
|
|
||||||
newPassword.value == confirmPassword.value &&
|
|
||||||
status != FormzSubmissionStatus.inProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResetPasswordState copyWith({
|
|
||||||
Email? email,
|
|
||||||
VerificationCode? code,
|
|
||||||
Password? newPassword,
|
|
||||||
Password? confirmPassword,
|
|
||||||
FormzSubmissionStatus? status,
|
|
||||||
String? errorMessage,
|
|
||||||
bool? isSuccess,
|
|
||||||
int? resendCountdown,
|
|
||||||
bool? codeSent,
|
|
||||||
}) {
|
|
||||||
return ResetPasswordState(
|
|
||||||
email: email ?? this.email,
|
|
||||||
code: code ?? this.code,
|
|
||||||
newPassword: newPassword ?? this.newPassword,
|
|
||||||
confirmPassword: confirmPassword ?? this.confirmPassword,
|
|
||||||
status: status ?? this.status,
|
|
||||||
errorMessage: errorMessage,
|
|
||||||
isSuccess: isSuccess ?? this.isSuccess,
|
|
||||||
resendCountdown: resendCountdown ?? this.resendCountdown,
|
|
||||||
codeSent: codeSent ?? this.codeSent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
email,
|
|
||||||
code,
|
|
||||||
newPassword,
|
|
||||||
confirmPassword,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isSuccess,
|
|
||||||
resendCountdown,
|
|
||||||
codeSent,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResetPasswordCubit extends Cubit<ResetPasswordState> {
|
|
||||||
final AuthRepository _repository;
|
|
||||||
Timer? _resendTimer;
|
|
||||||
|
|
||||||
ResetPasswordCubit(this._repository) : super(const ResetPasswordState());
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> close() {
|
|
||||||
_resendTimer?.cancel();
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
void emailChanged(String value) {
|
|
||||||
emit(state.copyWith(email: Email.dirty(value), errorMessage: null));
|
|
||||||
}
|
|
||||||
|
|
||||||
void codeChanged(String value) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(code: VerificationCode.dirty(value), errorMessage: null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void newPasswordChanged(String value) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(newPassword: Password.dirty(value), errorMessage: null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmPasswordChanged(String value) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
confirmPassword: Password.dirty(value),
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> sendCode() async {
|
|
||||||
if (state.status == FormzSubmissionStatus.inProgress ||
|
|
||||||
state.resendCountdown > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.email.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.inProgress,
|
|
||||||
codeSent: true,
|
|
||||||
resendCountdown: 60,
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_startResendCountdown();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _repository.requestPasswordReset(state.email.value);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.success,
|
|
||||||
errorMessage: 'CODE_SENT_SUCCESS',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_cancelResendCountdown();
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
codeSent: false,
|
|
||||||
resendCountdown: 0,
|
|
||||||
errorMessage: '网络错误,请稍后重试',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cancelResendCountdown() {
|
|
||||||
_resendTimer?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startResendCountdown() {
|
|
||||||
_cancelResendCountdown();
|
|
||||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
||||||
final newCountdown = state.resendCountdown - 1;
|
|
||||||
if (newCountdown <= 0) {
|
|
||||||
timer.cancel();
|
|
||||||
emit(state.copyWith(resendCountdown: 0));
|
|
||||||
} else {
|
|
||||||
emit(state.copyWith(resendCountdown: newCountdown));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> resendCode() async {
|
|
||||||
if (state.resendCountdown > 0 ||
|
|
||||||
state.status == FormzSubmissionStatus.inProgress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.email.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.inProgress,
|
|
||||||
codeSent: true,
|
|
||||||
resendCountdown: 60,
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_startResendCountdown();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _repository.requestPasswordReset(state.email.value);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.success,
|
|
||||||
errorMessage: 'CODE_SENT_SUCCESS',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_cancelResendCountdown();
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
resendCountdown: 0,
|
|
||||||
errorMessage: '网络错误,请稍后重试',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> submit() async {
|
|
||||||
if (!state.codeSent) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '请先获取验证码',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.email.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '请输入有效的邮箱地址',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.code.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '请输入6位验证码',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.newPassword.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '新密码至少6位',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.confirmPassword.isValid) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '请输入确认密码',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.newPassword.value != state.confirmPassword.value) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '两次密码输入不一致',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.inProgress,
|
|
||||||
errorMessage: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _repository.confirmPasswordReset(
|
|
||||||
email: state.email.value,
|
|
||||||
token: state.code.value,
|
|
||||||
newPassword: state.newPassword.value,
|
|
||||||
);
|
|
||||||
emit(
|
|
||||||
state.copyWith(status: FormzSubmissionStatus.success, isSuccess: true),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
status: FormzSubmissionStatus.failure,
|
|
||||||
errorMessage: '密码重置失败,请检查验证码',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:formz/formz.dart';
|
import 'package:formz/formz.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -8,7 +9,9 @@ import '../../../../core/router/app_routes.dart';
|
|||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
import '../../../../shared/widgets/banner/app_banner.dart';
|
||||||
|
import '../../../../shared/widgets/confirm_sheet.dart';
|
||||||
import '../../../../shared/widgets/link_button.dart';
|
import '../../../../shared/widgets/link_button.dart';
|
||||||
|
import '../../../../shared/widgets/phone_prefix_selector.dart';
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||||
import '../../data/auth_repository.dart';
|
import '../../data/auth_repository.dart';
|
||||||
import '../../presentation/bloc/auth_bloc.dart';
|
import '../../presentation/bloc/auth_bloc.dart';
|
||||||
@@ -16,7 +19,6 @@ import '../../presentation/bloc/auth_event.dart';
|
|||||||
import '../../presentation/cubits/login_cubit.dart';
|
import '../../presentation/cubits/login_cubit.dart';
|
||||||
import '../widgets/auth_field.dart';
|
import '../widgets/auth_field.dart';
|
||||||
import '../widgets/auth_page_scaffold.dart';
|
import '../widgets/auth_page_scaffold.dart';
|
||||||
import '../widgets/password_field.dart';
|
|
||||||
|
|
||||||
class LoginScreen extends StatelessWidget {
|
class LoginScreen extends StatelessWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
@@ -38,20 +40,23 @@ class LoginView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LoginViewState extends State<LoginView> {
|
class _LoginViewState extends State<LoginView> {
|
||||||
final _emailController = TextEditingController();
|
static const _dialCodes = <String>['+86', '+1', '+44', '+81', '+65'];
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
final _codeController = TextEditingController();
|
||||||
|
bool _agreedToTerms = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_phoneController.dispose();
|
||||||
_passwordController.dispose();
|
_codeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
final cubit = context.read<LoginCubit>();
|
final cubit = context.read<LoginCubit>();
|
||||||
cubit.emailChanged(_emailController.text);
|
cubit.phoneChanged(_phoneController.text);
|
||||||
cubit.passwordChanged(_passwordController.text);
|
cubit.codeChanged(_codeController.text);
|
||||||
|
|
||||||
if (!cubit.state.isValid) {
|
if (!cubit.state.isValid) {
|
||||||
return;
|
return;
|
||||||
@@ -64,65 +69,202 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSendCode() async {
|
||||||
|
if (!_agreedToTerms) {
|
||||||
|
final confirmed = await _showAgreementDialog();
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
setState(() => _agreedToTerms = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cubit = context.read<LoginCubit>();
|
||||||
|
cubit.phoneChanged(_phoneController.text);
|
||||||
|
final sent = await cubit.sendCode();
|
||||||
|
if (!mounted || !sent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _showAgreementDialog() async {
|
||||||
|
return await showConfirmSheet(
|
||||||
|
context,
|
||||||
|
title: '请先同意协议',
|
||||||
|
message: '在使用我们的服务之前,请先阅读并同意《用户协议》和《隐私政策》。\n\n只有您同意上述协议,我们才能为您提供服务。',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAgreementCheckbox() {
|
||||||
|
return Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
label: '同意用户协议与隐私政策',
|
||||||
|
checked: _agreedToTerms,
|
||||||
|
button: true,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
|
onTap: () => setState(() => _agreedToTerms = !_agreedToTerms),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.only(right: AppSpacing.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _agreedToTerms
|
||||||
|
? AppColors.blue600
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: _agreedToTerms
|
||||||
|
? AppColors.blue600
|
||||||
|
: AppColors.slate400,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _agreedToTerms
|
||||||
|
? const Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.white,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppColors.slate600),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: '我已同意'),
|
||||||
|
TextSpan(
|
||||||
|
text: '《用户协议》',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.blue600,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: '与'),
|
||||||
|
TextSpan(
|
||||||
|
text: '《隐私政策》',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.blue600,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AuthPageScaffold(
|
return AuthPageScaffold(
|
||||||
|
resizeOnKeyboard: false,
|
||||||
mainContentKey: const Key('login_main_content'),
|
mainContentKey: const Key('login_main_content'),
|
||||||
footerKey: const Key('login_footer'),
|
mainContent: LayoutBuilder(
|
||||||
mainContent: Padding(
|
builder: (context, constraints) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.lg,
|
||||||
|
0,
|
||||||
|
AppSpacing.lg,
|
||||||
|
bottomInset + AppSpacing.lg,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const AuthHeroHeader(showBrand: true),
|
const AuthHeroHeader(showBrand: true),
|
||||||
SizedBox(height: AppSpacing.xxl),
|
SizedBox(height: AppSpacing.xxl),
|
||||||
BlocBuilder<LoginCubit, LoginState>(
|
BlocBuilder<LoginCubit, LoginState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final fieldError = state.email.displayError != null
|
final fieldError = state.phone.displayError != null
|
||||||
? state.email.error
|
? state.phone.error
|
||||||
: state.password.displayError != null
|
: state.code.displayError != null
|
||||||
? state.password.error
|
? state.code.error
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return AuthSurfaceCard(
|
return Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
|
||||||
'登录账号',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.xs),
|
|
||||||
SizedBox(height: AppSpacing.xl),
|
|
||||||
AuthSection(
|
AuthSection(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
AuthField(
|
AuthField(
|
||||||
label: '邮箱',
|
hint: '输入手机号',
|
||||||
hint: 'name@example.com',
|
controller: _phoneController,
|
||||||
controller: _emailController,
|
onChanged: (value) {
|
||||||
keyboardType: TextInputType.emailAddress,
|
context.read<LoginCubit>().phoneChanged(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
prefix: PhonePrefixSelector(
|
||||||
|
value: state.dialCode,
|
||||||
|
items: _dialCodes,
|
||||||
|
onChanged: (value) {
|
||||||
|
context
|
||||||
|
.read<LoginCubit>()
|
||||||
|
.dialCodeChanged(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(14),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.lg),
|
SizedBox(height: AppSpacing.lg),
|
||||||
PasswordField(
|
AuthField(
|
||||||
controller: _passwordController,
|
hint: '输入验证码',
|
||||||
label: '密码',
|
controller: _codeController,
|
||||||
hint: '请输入密码',
|
onChanged: (value) {
|
||||||
|
context.read<LoginCubit>().codeChanged(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
suffixIcon: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
right: AppSpacing.md,
|
||||||
|
),
|
||||||
|
child: LinkButton(
|
||||||
|
text: state.resendCooldownSeconds > 0
|
||||||
|
? '${state.resendCooldownSeconds}s'
|
||||||
|
: '发送验证码',
|
||||||
|
onTap: state.canSendCode
|
||||||
|
? _handleSendCode
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.errorMessage != null ||
|
if (state.errorMessage != null ||
|
||||||
fieldError != null)
|
fieldError != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: AppSpacing.lg,
|
top: AppSpacing.md,
|
||||||
),
|
),
|
||||||
child: AppBanner(
|
child: AppBanner(
|
||||||
message:
|
message:
|
||||||
@@ -138,27 +280,21 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.xl),
|
SizedBox(height: AppSpacing.xxl),
|
||||||
AppButton(
|
AppButton(
|
||||||
text: '登录',
|
text: '登录/注册',
|
||||||
onPressed:
|
onPressed:
|
||||||
state.status == FormzSubmissionStatus.inProgress
|
state.status ==
|
||||||
|
FormzSubmissionStatus.inProgress
|
||||||
? null
|
? null
|
||||||
: _handleLogin,
|
: _handleLogin,
|
||||||
isLoading:
|
isLoading:
|
||||||
state.status ==
|
state.status ==
|
||||||
FormzSubmissionStatus.inProgress,
|
FormzSubmissionStatus.inProgress,
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.sm),
|
SizedBox(height: AppSpacing.xxl * 2),
|
||||||
Align(
|
_buildAgreementCheckbox(),
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: LinkButton(
|
|
||||||
text: '忘记密码?',
|
|
||||||
onTap: () => context.push('/reset-password'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -167,16 +303,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
footer: Row(
|
);
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
},
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'还没有账号?',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
|
|
||||||
),
|
|
||||||
LinkButton(text: '去注册', onTap: () => context.push('/register')),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../../core/di/injection.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
|
||||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
|
||||||
import '../../../../shared/widgets/fixed_length_code_input.dart';
|
|
||||||
import '../../../../shared/widgets/link_button.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
||||||
import '../../data/auth_repository.dart';
|
|
||||||
import '../../presentation/cubits/register_cubit.dart';
|
|
||||||
import '../widgets/auth_field.dart';
|
|
||||||
import '../widgets/auth_page_scaffold.dart';
|
|
||||||
import '../widgets/password_field.dart';
|
|
||||||
|
|
||||||
class RegisterScreen extends StatelessWidget {
|
|
||||||
const RegisterScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider(
|
|
||||||
create: (context) => RegisterCubit(sl<AuthRepository>()),
|
|
||||||
child: const RegisterView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RegisterView extends StatefulWidget {
|
|
||||||
const RegisterView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<RegisterView> createState() => _RegisterViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RegisterViewState extends State<RegisterView> {
|
|
||||||
static const _inviteCodeLength = 4;
|
|
||||||
static const _inviteAllowedChars = <String>{
|
|
||||||
'A',
|
|
||||||
'B',
|
|
||||||
'C',
|
|
||||||
'D',
|
|
||||||
'E',
|
|
||||||
'F',
|
|
||||||
'G',
|
|
||||||
'H',
|
|
||||||
'J',
|
|
||||||
'K',
|
|
||||||
'M',
|
|
||||||
'N',
|
|
||||||
'P',
|
|
||||||
'Q',
|
|
||||||
'R',
|
|
||||||
'S',
|
|
||||||
'T',
|
|
||||||
'U',
|
|
||||||
'V',
|
|
||||||
'W',
|
|
||||||
'X',
|
|
||||||
'Y',
|
|
||||||
'Z',
|
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
};
|
|
||||||
|
|
||||||
final _nicknameController = TextEditingController();
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _inviteCodeController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nicknameController.dispose();
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_inviteCodeController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleNext() async {
|
|
||||||
final cubit = context.read<RegisterCubit>();
|
|
||||||
final inviteCode = _inviteCodeController.text.trim().toUpperCase();
|
|
||||||
final normalizedInviteCode = inviteCode.length == _inviteCodeLength
|
|
||||||
? inviteCode
|
|
||||||
: '';
|
|
||||||
|
|
||||||
cubit.usernameChanged(_nicknameController.text);
|
|
||||||
cubit.emailChanged(_emailController.text);
|
|
||||||
cubit.passwordChanged(_passwordController.text);
|
|
||||||
cubit.inviteCodeChanged(normalizedInviteCode);
|
|
||||||
|
|
||||||
if (!cubit.state.isStep1Valid || cubit.state.isSending) {
|
|
||||||
String? errorMsg;
|
|
||||||
if (!cubit.state.username.isValid) {
|
|
||||||
errorMsg = '请输入有效的昵称(3-30个字符)';
|
|
||||||
} else if (!cubit.state.email.isValid) {
|
|
||||||
errorMsg = '请输入有效的邮箱地址';
|
|
||||||
} else if (!cubit.state.password.isValid) {
|
|
||||||
errorMsg = '密码至少需要6个字符';
|
|
||||||
}
|
|
||||||
if (errorMsg != null && mounted) {
|
|
||||||
Toast.show(context, errorMsg, type: ToastType.warning);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteCode.isNotEmpty && normalizedInviteCode.isEmpty && mounted) {
|
|
||||||
Toast.show(
|
|
||||||
context,
|
|
||||||
'邀请码需为 4 位,且仅支持 A-H/J-N/P-Z 与 2-9;已按无邀请码继续注册',
|
|
||||||
type: ToastType.warning,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
context.push('/register/verification', extra: cubit);
|
|
||||||
}
|
|
||||||
|
|
||||||
unawaited(cubit.sendCodeSilently());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AuthPageScaffold(
|
|
||||||
mainContent: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 388),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const AuthHeroHeader(showBrand: true),
|
|
||||||
SizedBox(height: AppSpacing.xxl),
|
|
||||||
BlocBuilder<RegisterCubit, RegisterState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
return AuthSurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'创建账号',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AuthSection(
|
|
||||||
title: '基础信息',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AuthField(
|
|
||||||
label: '昵称',
|
|
||||||
hint: '请输入昵称(3-30字符)',
|
|
||||||
controller: _nicknameController,
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AuthField(
|
|
||||||
label: '邮箱',
|
|
||||||
hint: 'name@example.com',
|
|
||||||
controller: _emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
PasswordField(
|
|
||||||
controller: _passwordController,
|
|
||||||
label: '密码',
|
|
||||||
hint: '请输入至少 6 位密码',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.md),
|
|
||||||
AuthSection(
|
|
||||||
title: '邀请码(选填)',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
FixedLengthCodeInput(
|
|
||||||
controller: _inviteCodeController,
|
|
||||||
length: _inviteCodeLength,
|
|
||||||
semanticLabel: '邀请码输入框',
|
|
||||||
uppercase: true,
|
|
||||||
allowedCharacters: _inviteAllowedChars,
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<RegisterCubit>()
|
|
||||||
.inviteCodeChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.md),
|
|
||||||
AppBanner(
|
|
||||||
title: '邀请码',
|
|
||||||
message: '4 位,支持 A-H/J-N/P-Z 与 2-9。',
|
|
||||||
type: ToastType.info,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.errorMessage != null) ...[
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AppBanner(
|
|
||||||
title: '注册失败',
|
|
||||||
message: state.errorMessage!,
|
|
||||||
type: ToastType.error,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.authPrimaryButton,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppRadius.full,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: AppSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.authSectionBorder,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppRadius.full,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.sm),
|
|
||||||
const Text(
|
|
||||||
'第 1 步:完善基础信息',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.authLinkMuted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AppButton(
|
|
||||||
text: '下一步',
|
|
||||||
onPressed:
|
|
||||||
state.status ==
|
|
||||||
FormzSubmissionStatus.inProgress ||
|
|
||||||
state.isSending
|
|
||||||
? null
|
|
||||||
: _handleNext,
|
|
||||||
isLoading: state.isSending,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
footer: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'已有账号?',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
|
|
||||||
),
|
|
||||||
LinkButton(text: '去登录', onTap: () => context.pop()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../../core/router/app_routes.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
|
||||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
|
||||||
import '../../../../shared/widgets/fixed_length_code_input.dart';
|
|
||||||
import '../../../../shared/widgets/link_button.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
||||||
import '../../presentation/bloc/auth_bloc.dart';
|
|
||||||
import '../../presentation/bloc/auth_event.dart';
|
|
||||||
import '../../presentation/cubits/register_cubit.dart';
|
|
||||||
import '../widgets/auth_page_scaffold.dart';
|
|
||||||
|
|
||||||
class RegisterVerificationScreen extends StatelessWidget {
|
|
||||||
const RegisterVerificationScreen({super.key, this.cubit});
|
|
||||||
|
|
||||||
final RegisterCubit? cubit;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final registerCubit =
|
|
||||||
cubit ?? (GoRouterState.of(context).extra as RegisterCubit?);
|
|
||||||
|
|
||||||
if (registerCubit == null) {
|
|
||||||
return const Scaffold(
|
|
||||||
body: Center(child: Text('RegisterCubit not found')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BlocProvider.value(
|
|
||||||
value: registerCubit,
|
|
||||||
child: const RegisterVerificationView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RegisterVerificationView extends StatefulWidget {
|
|
||||||
const RegisterVerificationView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<RegisterVerificationView> createState() =>
|
|
||||||
_RegisterVerificationViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
|
||||||
final _codeController = TextEditingController();
|
|
||||||
Timer? _countdownTimer;
|
|
||||||
int _countdown = 0;
|
|
||||||
bool _firstSendCompleted = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_countdownTimer?.cancel();
|
|
||||||
_codeController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startCountdown() {
|
|
||||||
setState(() {
|
|
||||||
_countdown = 60;
|
|
||||||
});
|
|
||||||
_countdownTimer?.cancel();
|
|
||||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
||||||
if (_countdown > 0) {
|
|
||||||
setState(() {
|
|
||||||
_countdown--;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleComplete() async {
|
|
||||||
final cubit = context.read<RegisterCubit>();
|
|
||||||
cubit.verificationCodeChanged(_codeController.text);
|
|
||||||
|
|
||||||
if (!cubit.state.isStep2Valid) {
|
|
||||||
Toast.show(
|
|
||||||
context,
|
|
||||||
_codeController.text.isEmpty ? '请输入验证码' : '验证码必须是 6 位数字',
|
|
||||||
type: ToastType.warning,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await cubit.submitStep2();
|
|
||||||
if (response != null && mounted) {
|
|
||||||
context.read<AuthBloc>().add(AuthLoggedIn(user: response.user));
|
|
||||||
context.go(AppRoutes.homeMain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleResendCode() async {
|
|
||||||
final cubit = context.read<RegisterCubit>();
|
|
||||||
final success = await cubit.resendCode();
|
|
||||||
|
|
||||||
if (success && mounted) {
|
|
||||||
_startCountdown();
|
|
||||||
Toast.show(context, '验证码已发送', type: ToastType.success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AuthPageScaffold(
|
|
||||||
mainContent: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 388),
|
|
||||||
child: BlocConsumer<RegisterCubit, RegisterState>(
|
|
||||||
listener: (context, state) {
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status == FormzSubmissionStatus.failure &&
|
|
||||||
state.errorMessage != null) {
|
|
||||||
Toast.show(context, state.errorMessage!, type: ToastType.error);
|
|
||||||
|
|
||||||
if (!_firstSendCompleted) {
|
|
||||||
_firstSendCompleted = true;
|
|
||||||
setState(() {
|
|
||||||
_countdown = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status == FormzSubmissionStatus.success &&
|
|
||||||
!_firstSendCompleted) {
|
|
||||||
_firstSendCompleted = true;
|
|
||||||
_startCountdown();
|
|
||||||
Toast.show(context, '验证码已发送', type: ToastType.info);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
builder: (context, state) {
|
|
||||||
final isSubmitting =
|
|
||||||
state.status == FormzSubmissionStatus.inProgress;
|
|
||||||
final canResend = _countdown == 0 && !isSubmitting;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const AuthHeroHeader(showBrand: true),
|
|
||||||
SizedBox(height: AppSpacing.xl),
|
|
||||||
AuthSurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'验证邮箱',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AuthSection(
|
|
||||||
title: '验证码',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
FixedLengthCodeInput(
|
|
||||||
controller: _codeController,
|
|
||||||
length: 6,
|
|
||||||
semanticLabel: '邮箱验证码输入框',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
allowedCharacters: const {
|
|
||||||
'0',
|
|
||||||
'1',
|
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<RegisterCubit>()
|
|
||||||
.verificationCodeChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.md),
|
|
||||||
AppBanner(
|
|
||||||
title: '验证码',
|
|
||||||
message: canResend
|
|
||||||
? '6 位数字验证码。'
|
|
||||||
: '$_countdown 秒后可重新发送。',
|
|
||||||
type: ToastType.info,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AppButton(
|
|
||||||
text: '完成注册',
|
|
||||||
onPressed: isSubmitting ? null : _handleComplete,
|
|
||||||
isLoading: isSubmitting,
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.sm),
|
|
||||||
Center(
|
|
||||||
child: LinkButton(
|
|
||||||
text: canResend ? '重新发送验证码' : '$_countdown 秒后重发',
|
|
||||||
onTap: canResend ? _handleResendCode : null,
|
|
||||||
enabled: canResend,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
footer: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'已有账号?',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
|
|
||||||
),
|
|
||||||
LinkButton(text: '去登录', onTap: () => context.go('/')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../../core/di/injection.dart';
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
|
||||||
import '../../../../shared/widgets/banner/app_banner.dart';
|
|
||||||
import '../../../../shared/widgets/fixed_length_code_input.dart';
|
|
||||||
import '../../../../shared/widgets/link_button.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
||||||
import '../../data/auth_repository.dart';
|
|
||||||
import '../../presentation/cubits/reset_password_cubit.dart';
|
|
||||||
import '../widgets/auth_field.dart';
|
|
||||||
import '../widgets/auth_page_scaffold.dart';
|
|
||||||
import '../widgets/password_field.dart';
|
|
||||||
|
|
||||||
class ResetPasswordScreen extends StatelessWidget {
|
|
||||||
const ResetPasswordScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider(
|
|
||||||
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
|
|
||||||
child: const ResetPasswordView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResetPasswordView extends StatefulWidget {
|
|
||||||
const ResetPasswordView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ResetPasswordView> createState() => _ResetPasswordViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ResetPasswordViewState extends State<ResetPasswordView> {
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _codeController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailController.dispose();
|
|
||||||
_codeController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSubmit() async {
|
|
||||||
final cubit = context.read<ResetPasswordCubit>();
|
|
||||||
cubit.emailChanged(_emailController.text);
|
|
||||||
cubit.codeChanged(_codeController.text);
|
|
||||||
cubit.newPasswordChanged(_passwordController.text);
|
|
||||||
cubit.confirmPasswordChanged(_confirmPasswordController.text);
|
|
||||||
|
|
||||||
await cubit.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSendCode(ResetPasswordState state) {
|
|
||||||
if (state.codeSent) {
|
|
||||||
context.read<ResetPasswordCubit>().resendCode();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.read<ResetPasswordCubit>().sendCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
|
|
||||||
listenWhen: (previous, current) =>
|
|
||||||
previous.status != current.status ||
|
|
||||||
previous.errorMessage != current.errorMessage ||
|
|
||||||
previous.codeSent != current.codeSent,
|
|
||||||
listener: (context, state) {
|
|
||||||
if (state.status == FormzSubmissionStatus.success && state.isSuccess) {
|
|
||||||
Toast.show(context, '密码重置成功,请使用新密码登录', type: ToastType.success);
|
|
||||||
context.go('/');
|
|
||||||
} else if (state.status == FormzSubmissionStatus.success &&
|
|
||||||
state.codeSent &&
|
|
||||||
state.errorMessage == 'CODE_SENT_SUCCESS') {
|
|
||||||
Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success);
|
|
||||||
} else if (state.status == FormzSubmissionStatus.failure &&
|
|
||||||
state.errorMessage != null &&
|
|
||||||
state.errorMessage != '' &&
|
|
||||||
state.errorMessage != 'CODE_SENT_SUCCESS') {
|
|
||||||
Toast.show(context, state.errorMessage!, type: ToastType.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: AuthPageScaffold(
|
|
||||||
mainContent: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 392),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const AuthHeroHeader(title: '忘记密码'),
|
|
||||||
SizedBox(height: AppSpacing.xxl),
|
|
||||||
BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
final isSending =
|
|
||||||
state.status == FormzSubmissionStatus.inProgress &&
|
|
||||||
!state.codeSent;
|
|
||||||
final isSubmitting =
|
|
||||||
state.status == FormzSubmissionStatus.inProgress &&
|
|
||||||
state.codeSent;
|
|
||||||
|
|
||||||
return AuthSurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'找回访问权限',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.xs),
|
|
||||||
SizedBox(height: AppSpacing.xl),
|
|
||||||
AuthSection(
|
|
||||||
title: '第 1 步:验证邮箱',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AuthField(
|
|
||||||
label: '邮箱',
|
|
||||||
hint: 'name@example.com',
|
|
||||||
controller: _emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<ResetPasswordCubit>()
|
|
||||||
.emailChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AppButton(
|
|
||||||
text: state.resendCountdown > 0
|
|
||||||
? '${state.resendCountdown} 秒后可重发'
|
|
||||||
: state.codeSent
|
|
||||||
? '重新发送验证码'
|
|
||||||
: '发送验证码',
|
|
||||||
onPressed:
|
|
||||||
state.resendCountdown > 0 || isSending
|
|
||||||
? null
|
|
||||||
: () => _handleSendCode(state),
|
|
||||||
isOutlined: state.codeSent,
|
|
||||||
isLoading: isSending,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.codeSent) ...[
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
AuthSection(
|
|
||||||
title: '第 2 步:输入验证码并设置新密码',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AppBanner(
|
|
||||||
title: '验证码已发送',
|
|
||||||
message: state.resendCountdown > 0
|
|
||||||
? '如未收到邮件,可在 ${state.resendCountdown} 秒后重新发送。'
|
|
||||||
: '如果没有收到邮件,可以再次发送验证码。',
|
|
||||||
type: ToastType.info,
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
const Text(
|
|
||||||
'验证码',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.slate700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.sm),
|
|
||||||
FixedLengthCodeInput(
|
|
||||||
controller: _codeController,
|
|
||||||
length: 6,
|
|
||||||
semanticLabel: '重置密码验证码输入框',
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
allowedCharacters: const {
|
|
||||||
'0',
|
|
||||||
'1',
|
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<ResetPasswordCubit>()
|
|
||||||
.codeChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
PasswordField(
|
|
||||||
controller: _passwordController,
|
|
||||||
label: '新密码',
|
|
||||||
hint: '请输入新密码(至少 6 位)',
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<ResetPasswordCubit>()
|
|
||||||
.newPasswordChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.lg),
|
|
||||||
PasswordField(
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
label: '确认密码',
|
|
||||||
hint: '请再次输入新密码',
|
|
||||||
onChanged: (value) {
|
|
||||||
context
|
|
||||||
.read<ResetPasswordCubit>()
|
|
||||||
.confirmPasswordChanged(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: AppSpacing.xl),
|
|
||||||
AppButton(
|
|
||||||
text: '重置密码',
|
|
||||||
onPressed: isSubmitting ? null : _handleSubmit,
|
|
||||||
isLoading: isSubmitting,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
footer: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'想起密码了?',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
|
|
||||||
),
|
|
||||||
LinkButton(text: '返回登录', onTap: () => context.go('/')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
|
|
||||||
class AuthField extends StatelessWidget {
|
class AuthField extends StatelessWidget {
|
||||||
const AuthField({
|
const AuthField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
this.label,
|
||||||
required this.hint,
|
required this.hint,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.prefix,
|
||||||
|
this.inputFormatters,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String? label;
|
||||||
final String hint;
|
final String hint;
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final TextInputType? keyboardType;
|
final TextInputType? keyboardType;
|
||||||
final bool obscureText;
|
final bool obscureText;
|
||||||
final Widget? suffixIcon;
|
final Widget? suffixIcon;
|
||||||
final ValueChanged<String>? onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
|
final Widget? prefix;
|
||||||
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (label != null) ...[
|
||||||
Text(
|
Text(
|
||||||
label,
|
label!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -36,6 +42,7 @@ class AuthField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.sm),
|
SizedBox(height: AppSpacing.sm),
|
||||||
|
],
|
||||||
Semantics(
|
Semantics(
|
||||||
label: label,
|
label: label,
|
||||||
textField: true,
|
textField: true,
|
||||||
@@ -44,6 +51,7 @@ class AuthField extends StatelessWidget {
|
|||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
|
inputFormatters: inputFormatters,
|
||||||
style: const TextStyle(fontSize: 16, color: AppColors.slate900),
|
style: const TextStyle(fontSize: 16, color: AppColors.slate900),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
@@ -57,6 +65,7 @@ class AuthField extends StatelessWidget {
|
|||||||
horizontal: AppSpacing.lg,
|
horizontal: AppSpacing.lg,
|
||||||
vertical: AppSpacing.lg,
|
vertical: AppSpacing.lg,
|
||||||
),
|
),
|
||||||
|
prefixIcon: prefix,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ class AuthPageScaffold extends StatelessWidget {
|
|||||||
this.footer,
|
this.footer,
|
||||||
this.mainContentKey,
|
this.mainContentKey,
|
||||||
this.footerKey,
|
this.footerKey,
|
||||||
|
this.resizeOnKeyboard = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget mainContent;
|
final Widget mainContent;
|
||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
final Key? mainContentKey;
|
final Key? mainContentKey;
|
||||||
final Key? footerKey;
|
final Key? footerKey;
|
||||||
|
final bool resizeOnKeyboard;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -22,6 +24,7 @@ class AuthPageScaffold extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.authBackgroundBottom,
|
backgroundColor: AppColors.authBackgroundBottom,
|
||||||
|
resizeToAvoidBottomInset: resizeOnKeyboard,
|
||||||
body: DecoratedBox(
|
body: DecoratedBox(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -37,8 +40,33 @@ class AuthPageScaffold extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const _AuthBackgroundOrbs(),
|
const _AuthBackgroundOrbs(),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
maintainBottomViewPadding: !resizeOnKeyboard,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
if (!resizeOnKeyboard) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.lg,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
KeyedSubtree(
|
||||||
|
key: mainContentKey,
|
||||||
|
child: mainContent,
|
||||||
|
),
|
||||||
|
if (footer != null) ...[
|
||||||
|
SizedBox(height: AppSpacing.md),
|
||||||
|
KeyedSubtree(key: footerKey, child: footer!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
keyboardDismissBehavior:
|
keyboardDismissBehavior:
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ class PasswordField extends StatefulWidget {
|
|||||||
const PasswordField({
|
const PasswordField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.label,
|
this.label,
|
||||||
required this.hint,
|
required this.hint,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String label;
|
final String? label;
|
||||||
final String hint;
|
final String hint;
|
||||||
final ValueChanged<String>? onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
class UserResponse {
|
class UserResponse {
|
||||||
final String id;
|
final String id;
|
||||||
final String username;
|
final String username;
|
||||||
final String? email;
|
final String? phone;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final String? bio;
|
final String? bio;
|
||||||
|
|
||||||
const UserResponse({
|
const UserResponse({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.username,
|
required this.username,
|
||||||
this.email,
|
this.phone,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.bio,
|
this.bio,
|
||||||
});
|
});
|
||||||
@@ -17,7 +17,7 @@ class UserResponse {
|
|||||||
return UserResponse(
|
return UserResponse(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
username: json['username'] as String,
|
username: json['username'] as String,
|
||||||
email: json['email'] as String?,
|
phone: json['phone'] as String?,
|
||||||
avatarUrl: json['avatar_url'] as String?,
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
bio: json['bio'] as String?,
|
bio: json['bio'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
class Validators {
|
class Validators {
|
||||||
Validators._();
|
Validators._();
|
||||||
|
|
||||||
static String? email(String? value) {
|
static String? phone(String? value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return '请输入邮箱';
|
return '请输入手机号';
|
||||||
}
|
}
|
||||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
final phoneRegex = RegExp(r'^\+861[3-9]\d{9}$');
|
||||||
if (!emailRegex.hasMatch(value)) {
|
if (!phoneRegex.hasMatch(value)) {
|
||||||
return '请输入有效的邮箱地址';
|
return '请输入有效的 +86 手机号';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ void main() {
|
|||||||
|
|
||||||
await bootstrapper.syncForAuthState(
|
await bootstrapper.syncForAuthState(
|
||||||
const AuthAuthenticated(
|
const AuthAuthenticated(
|
||||||
user: AuthUser(id: 'u1', email: 'a@test.com'),
|
user: AuthUser(id: 'u1', phone: 'a@test.com'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ void main() {
|
|||||||
|
|
||||||
await bootstrapper.syncForAuthState(
|
await bootstrapper.syncForAuthState(
|
||||||
const AuthAuthenticated(
|
const AuthAuthenticated(
|
||||||
user: AuthUser(id: 'u1', email: 'a@test.com'),
|
user: AuthUser(id: 'u1', phone: 'a@test.com'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ void main() {
|
|||||||
|
|
||||||
await bootstrapper.syncForAuthState(
|
await bootstrapper.syncForAuthState(
|
||||||
const AuthAuthenticated(
|
const AuthAuthenticated(
|
||||||
user: AuthUser(id: 'u1', email: 'a@test.com'),
|
user: AuthUser(id: 'u1', phone: 'a@test.com'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ import 'package:social_app/features/auth/data/models/signup_request.dart';
|
|||||||
import 'package:social_app/features/auth/data/models/login_request.dart';
|
import 'package:social_app/features/auth/data/models/login_request.dart';
|
||||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||||
import 'package:social_app/core/storage/token_storage.dart';
|
import 'package:social_app/core/storage/token_storage.dart';
|
||||||
import 'package:social_app/core/api/api_client.dart';
|
|
||||||
|
|
||||||
class MockAuthApi extends Mock implements AuthApi {}
|
class MockAuthApi extends Mock implements AuthApi {}
|
||||||
|
|
||||||
class MockTokenStorage extends Mock implements TokenStorage {}
|
class MockTokenStorage extends Mock implements TokenStorage {}
|
||||||
|
|
||||||
class MockApiClient extends Mock implements ApiClient {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AuthRepositoryImpl repository;
|
late AuthRepositoryImpl repository;
|
||||||
late MockAuthApi mockApi;
|
late MockAuthApi mockApi;
|
||||||
@@ -23,43 +20,29 @@ void main() {
|
|||||||
mockApi = MockAuthApi();
|
mockApi = MockAuthApi();
|
||||||
mockStorage = MockTokenStorage();
|
mockStorage = MockTokenStorage();
|
||||||
repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage);
|
repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage);
|
||||||
registerFallbackValue(
|
registerFallbackValue(const OtpSendRequest(phone: ''));
|
||||||
const SignupStartRequest(username: '', email: '', password: ''),
|
registerFallbackValue(const LoginRequest(phone: '', token: ''));
|
||||||
);
|
|
||||||
registerFallbackValue(const LoginRequest(email: '', password: ''));
|
|
||||||
registerFallbackValue(const SignupVerifyRequest(email: '', token: ''));
|
|
||||||
registerFallbackValue(const SignupResendRequest(email: ''));
|
|
||||||
registerFallbackValue(const LogoutRequest(refreshToken: ''));
|
registerFallbackValue(const LogoutRequest(refreshToken: ''));
|
||||||
registerFallbackValue(const RefreshRequest(refreshToken: ''));
|
registerFallbackValue(const RefreshRequest(refreshToken: ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
group('AuthRepositoryImpl', () {
|
group('AuthRepositoryImpl', () {
|
||||||
test('createVerification calls api and returns response', () async {
|
test('sendOtp calls api', () async {
|
||||||
when(() => mockApi.createVerification(any())).thenAnswer(
|
when(() => mockApi.sendOtp(any())).thenAnswer((_) async {});
|
||||||
(_) async =>
|
|
||||||
const VerificationCreateResponse(email: 'test@example.com'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await repository.createVerification(
|
await repository.sendOtp('+8613812345678');
|
||||||
const SignupStartRequest(
|
|
||||||
username: 'testuser',
|
|
||||||
email: 'test@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.email, 'test@example.com');
|
verify(() => mockApi.sendOtp(any())).called(1);
|
||||||
verify(() => mockApi.createVerification(any())).called(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createSession calls api and saves tokens', () async {
|
test('createPhoneSession calls api and saves tokens', () async {
|
||||||
when(() => mockApi.createSession(any())).thenAnswer(
|
when(() => mockApi.createPhoneSession(any())).thenAnswer(
|
||||||
(_) async => AuthResponse(
|
(_) async => AuthResponse(
|
||||||
accessToken: 'access_token',
|
accessToken: 'access_token',
|
||||||
refreshToken: 'refresh_token',
|
refreshToken: 'refresh_token',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
tokenType: 'bearer',
|
tokenType: 'bearer',
|
||||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(
|
when(
|
||||||
@@ -69,8 +52,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
final result = await repository.createSession(
|
final result = await repository.createPhoneSession(
|
||||||
const LoginRequest(email: 'test@example.com', password: 'password123'),
|
phone: '+8613812345678',
|
||||||
|
token: '123456',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.accessToken, 'access_token');
|
expect(result.accessToken, 'access_token');
|
||||||
@@ -117,7 +101,7 @@ void main() {
|
|||||||
refreshToken: 'new_refresh',
|
refreshToken: 'new_refresh',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
tokenType: 'bearer',
|
tokenType: 'bearer',
|
||||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(
|
when(
|
||||||
|
|||||||
@@ -4,33 +4,32 @@ import 'package:social_app/features/auth/data/models/login_request.dart';
|
|||||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('SignupStartRequest', () {
|
group('OtpSendRequest', () {
|
||||||
test('serializes to JSON', () {
|
test('serializes e164 phone to JSON', () {
|
||||||
final request = SignupStartRequest(
|
final request = OtpSendRequest(phone: '+14155552671');
|
||||||
username: 'testuser',
|
|
||||||
email: 'test@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
);
|
|
||||||
|
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
|
|
||||||
expect(json['username'], 'testuser');
|
expect(json['phone'], '+14155552671');
|
||||||
expect(json['email'], 'test@example.com');
|
});
|
||||||
expect(json['password'], 'password123');
|
|
||||||
|
test('normalizes 00 prefix to plus', () {
|
||||||
|
final request = OtpSendRequest(phone: '0014155552671');
|
||||||
|
|
||||||
|
final json = request.toJson();
|
||||||
|
|
||||||
|
expect(json['phone'], '+14155552671');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LoginRequest', () {
|
group('LoginRequest', () {
|
||||||
test('serializes to JSON', () {
|
test('serializes e164 to JSON', () {
|
||||||
final request = LoginRequest(
|
final request = LoginRequest(phone: '+14155552671', token: '123456');
|
||||||
email: 'test@example.com',
|
|
||||||
password: 'password123',
|
|
||||||
);
|
|
||||||
|
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
|
|
||||||
expect(json['email'], 'test@example.com');
|
expect(json['phone'], '+14155552671');
|
||||||
expect(json['password'], 'password123');
|
expect(json['token'], '123456');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ void main() {
|
|||||||
'refresh_token': 'test_refresh',
|
'refresh_token': 'test_refresh',
|
||||||
'expires_in': 3600,
|
'expires_in': 3600,
|
||||||
'token_type': 'bearer',
|
'token_type': 'bearer',
|
||||||
'user': {'id': '123', 'email': 'test@example.com'},
|
'user': {'id': '123', 'phone': '+8613812345678'},
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = AuthResponse.fromJson(json);
|
final response = AuthResponse.fromJson(json);
|
||||||
@@ -50,7 +49,7 @@ void main() {
|
|||||||
expect(response.refreshToken, 'test_refresh');
|
expect(response.refreshToken, 'test_refresh');
|
||||||
expect(response.expiresIn, 3600);
|
expect(response.expiresIn, 3600);
|
||||||
expect(response.user.id, '123');
|
expect(response.user.id, '123');
|
||||||
expect(response.user.email, 'test@example.com');
|
expect(response.user.phone, '+8613812345678');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ void main() {
|
|||||||
refreshToken: 'new_refresh',
|
refreshToken: 'new_refresh',
|
||||||
expiresIn: 3600,
|
expiresIn: 3600,
|
||||||
tokenType: 'bearer',
|
tokenType: 'bearer',
|
||||||
user: const AuthUser(id: '123', email: 'test@example.com'),
|
user: const AuthUser(id: '123', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return authBloc;
|
return authBloc;
|
||||||
@@ -107,7 +107,7 @@ void main() {
|
|||||||
build: () => authBloc,
|
build: () => authBloc,
|
||||||
act: (bloc) => bloc.add(
|
act: (bloc) => bloc.add(
|
||||||
AuthLoggedIn(
|
AuthLoggedIn(
|
||||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
expect: () => [isA<AuthAuthenticated>()],
|
expect: () => [isA<AuthAuthenticated>()],
|
||||||
@@ -120,7 +120,7 @@ void main() {
|
|||||||
return authBloc;
|
return authBloc;
|
||||||
},
|
},
|
||||||
seed: () => AuthAuthenticated(
|
seed: () => AuthAuthenticated(
|
||||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
act: (bloc) => bloc.add(AuthLoggedOut()),
|
act: (bloc) => bloc.add(AuthLoggedOut()),
|
||||||
expect: () => [
|
expect: () => [
|
||||||
@@ -137,7 +137,7 @@ void main() {
|
|||||||
return authBloc;
|
return authBloc;
|
||||||
},
|
},
|
||||||
seed: () => AuthAuthenticated(
|
seed: () => AuthAuthenticated(
|
||||||
user: const AuthUser(id: '1', email: 'test@example.com'),
|
user: const AuthUser(id: '1', phone: '+8613812345678'),
|
||||||
),
|
),
|
||||||
act: (bloc) => bloc.add(
|
act: (bloc) => bloc.add(
|
||||||
const AuthSessionInvalidated(
|
const AuthSessionInvalidated(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:bloc_test/bloc_test.dart';
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:formz/formz.dart';
|
import 'package:formz/formz.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
@@ -26,17 +27,68 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
blocTest<LoginCubit, LoginState>(
|
blocTest<LoginCubit, LoginState>(
|
||||||
'emailChanged updates email',
|
'phoneChanged updates phone',
|
||||||
build: () => cubit,
|
build: () => cubit,
|
||||||
act: (c) => c.emailChanged('test@example.com'),
|
act: (c) => c.phoneChanged('+8613812345678'),
|
||||||
expect: () => [isA<LoginState>()],
|
expect: () => [isA<LoginState>()],
|
||||||
);
|
);
|
||||||
|
|
||||||
blocTest<LoginCubit, LoginState>(
|
blocTest<LoginCubit, LoginState>(
|
||||||
'passwordChanged updates password',
|
'codeChanged updates code',
|
||||||
build: () => cubit,
|
build: () => cubit,
|
||||||
act: (c) => c.passwordChanged('password123'),
|
act: (c) => c.codeChanged('123456'),
|
||||||
expect: () => [isA<LoginState>()],
|
expect: () => [isA<LoginState>()],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('sendCode success starts 60s cooldown', () {
|
||||||
|
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
cubit.phoneChanged('13812345678');
|
||||||
|
|
||||||
|
cubit.sendCode();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(cubit.state.resendCooldownSeconds, 60);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 1));
|
||||||
|
expect(cubit.state.resendCooldownSeconds, 59);
|
||||||
|
|
||||||
|
async.elapse(const Duration(seconds: 59));
|
||||||
|
expect(cubit.state.resendCooldownSeconds, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendCode is blocked during cooldown', () async {
|
||||||
|
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||||
|
cubit.phoneChanged('13812345678');
|
||||||
|
|
||||||
|
final first = await cubit.sendCode();
|
||||||
|
final second = await cubit.sendCode();
|
||||||
|
|
||||||
|
expect(first, isTrue);
|
||||||
|
expect(second, isFalse);
|
||||||
|
verify(() => mockRepository.sendOtp(any())).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone change resets cooldown and code state', () {
|
||||||
|
when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
cubit.phoneChanged('13812345678');
|
||||||
|
cubit.codeChanged('123456');
|
||||||
|
cubit.sendCode();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(cubit.state.resendCooldownSeconds, 60);
|
||||||
|
expect(cubit.state.codeSent, isTrue);
|
||||||
|
|
||||||
|
cubit.phoneChanged('14155552671');
|
||||||
|
|
||||||
|
expect(cubit.state.resendCooldownSeconds, 0);
|
||||||
|
expect(cubit.state.codeSent, isFalse);
|
||||||
|
expect(cubit.state.code.value, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import 'package:bloc_test/bloc_test.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
import 'package:social_app/core/form_inputs/form_inputs.dart';
|
|
||||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
|
||||||
import 'package:social_app/features/auth/data/models/auth_response.dart';
|
|
||||||
import 'package:social_app/features/auth/data/models/signup_request.dart';
|
|
||||||
import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart';
|
|
||||||
import 'package:social_app/core/api/api_exception.dart';
|
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late RegisterCubit cubit;
|
|
||||||
late MockAuthRepository mockRepository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
mockRepository = MockAuthRepository();
|
|
||||||
cubit = RegisterCubit(mockRepository);
|
|
||||||
registerFallbackValue(
|
|
||||||
SignupStartRequest(username: '', email: '', password: ''),
|
|
||||||
);
|
|
||||||
registerFallbackValue(SignupResendRequest(email: ''));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() {
|
|
||||||
cubit.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
group('RegisterCubit', () {
|
|
||||||
test('initial state has pure status', () {
|
|
||||||
expect(cubit.state.status, FormzSubmissionStatus.initial);
|
|
||||||
});
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'usernameChanged updates username',
|
|
||||||
build: () => cubit,
|
|
||||||
act: (c) => c.usernameChanged('testuser'),
|
|
||||||
expect: () => [isA<RegisterState>()],
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'emailChanged updates email',
|
|
||||||
build: () => cubit,
|
|
||||||
act: (c) => c.emailChanged('test@example.com'),
|
|
||||||
expect: () => [isA<RegisterState>()],
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'passwordChanged updates password',
|
|
||||||
build: () => cubit,
|
|
||||||
act: (c) => c.passwordChanged('password123'),
|
|
||||||
expect: () => [isA<RegisterState>()],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('sendCodeSilently', () {
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'sets isSending to true then false on success',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(
|
|
||||||
username: const Username.dirty('testuser'),
|
|
||||||
email: const Email.dirty('test@example.com'),
|
|
||||||
password: const Password.dirty('password123'),
|
|
||||||
),
|
|
||||||
setUp: () {
|
|
||||||
when(() => mockRepository.createVerification(any())).thenAnswer(
|
|
||||||
(_) async => VerificationCreateResponse(email: 'test@example.com'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
act: (c) => c.sendCodeSilently(),
|
|
||||||
expect: () => [
|
|
||||||
predicate<RegisterState>((state) => state.isSending == true),
|
|
||||||
predicate<RegisterState>(
|
|
||||||
(state) =>
|
|
||||||
state.isSending == false &&
|
|
||||||
state.codeSent == true &&
|
|
||||||
state.pendingEmail == 'test@example.com' &&
|
|
||||||
state.errorMessage == null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
verify: (_) {
|
|
||||||
verify(() => mockRepository.createVerification(any())).called(1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'restores isSending to false and sets errorMessage and failure status on error',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(
|
|
||||||
username: const Username.dirty('testuser'),
|
|
||||||
email: const Email.dirty('test@example.com'),
|
|
||||||
password: const Password.dirty('password123'),
|
|
||||||
),
|
|
||||||
setUp: () {
|
|
||||||
when(
|
|
||||||
() => mockRepository.createVerification(any()),
|
|
||||||
).thenThrow(ServerException('Network error'));
|
|
||||||
},
|
|
||||||
act: (c) => c.sendCodeSilently(),
|
|
||||||
expect: () => [
|
|
||||||
predicate<RegisterState>((state) => state.isSending == true),
|
|
||||||
predicate<RegisterState>(
|
|
||||||
(state) =>
|
|
||||||
state.isSending == false &&
|
|
||||||
state.errorMessage == 'Network error' &&
|
|
||||||
state.status == FormzSubmissionStatus.failure,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
verify: (_) {
|
|
||||||
verify(() => mockRepository.createVerification(any())).called(1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'does not call createVerification when input is invalid',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(
|
|
||||||
username: const Username.dirty(''),
|
|
||||||
email: const Email.dirty('invalid'),
|
|
||||||
password: const Password.dirty(''),
|
|
||||||
),
|
|
||||||
act: (c) => c.sendCodeSilently(),
|
|
||||||
expect: () => [],
|
|
||||||
verify: (_) {
|
|
||||||
verifyNever(() => mockRepository.createVerification(any()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'returns early when isSending is true',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(
|
|
||||||
username: const Username.dirty('testuser'),
|
|
||||||
email: const Email.dirty('test@example.com'),
|
|
||||||
password: const Password.dirty('password123'),
|
|
||||||
isSending: true,
|
|
||||||
),
|
|
||||||
act: (c) => c.sendCodeSilently(),
|
|
||||||
expect: () => [],
|
|
||||||
verify: (_) {
|
|
||||||
verifyNever(() => mockRepository.createVerification(any()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('resendCode', () {
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'returns false and sets failure status when pendingEmail is null',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(pendingEmail: null),
|
|
||||||
act: (c) => c.resendCode(),
|
|
||||||
expect: () => [
|
|
||||||
isA<RegisterState>()
|
|
||||||
.having((s) => s.status, 'status', FormzSubmissionStatus.failure)
|
|
||||||
.having((s) => s.errorMessage, 'errorMessage', '验证码发送失败,请返回上一步重试'),
|
|
||||||
],
|
|
||||||
verify: (_) {
|
|
||||||
verifyNever(() => mockRepository.resendVerification(any()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'returns true and sets status on success',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(pendingEmail: 'test@example.com'),
|
|
||||||
setUp: () {
|
|
||||||
when(
|
|
||||||
() => mockRepository.resendVerification(any()),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
},
|
|
||||||
act: (c) => c.resendCode(),
|
|
||||||
expect: () => [
|
|
||||||
isA<RegisterState>().having(
|
|
||||||
(s) => s.status,
|
|
||||||
'status',
|
|
||||||
FormzSubmissionStatus.inProgress,
|
|
||||||
),
|
|
||||||
isA<RegisterState>().having(
|
|
||||||
(s) => s.status,
|
|
||||||
'status',
|
|
||||||
FormzSubmissionStatus.success,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
verify: (_) {
|
|
||||||
verify(() => mockRepository.resendVerification(any())).called(1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
blocTest<RegisterCubit, RegisterState>(
|
|
||||||
'returns false on error',
|
|
||||||
build: () => cubit,
|
|
||||||
seed: () => RegisterState(pendingEmail: 'test@example.com'),
|
|
||||||
setUp: () {
|
|
||||||
when(
|
|
||||||
() => mockRepository.resendVerification(any()),
|
|
||||||
).thenThrow(ServerException('Network error'));
|
|
||||||
},
|
|
||||||
act: (c) => c.resendCode(),
|
|
||||||
expect: () => [
|
|
||||||
isA<RegisterState>().having(
|
|
||||||
(s) => s.status,
|
|
||||||
'status',
|
|
||||||
FormzSubmissionStatus.inProgress,
|
|
||||||
),
|
|
||||||
isA<RegisterState>().having(
|
|
||||||
(s) => s.status,
|
|
||||||
'status',
|
|
||||||
FormzSubmissionStatus.failure,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:formz/formz.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
import 'package:social_app/features/auth/data/auth_repository.dart';
|
|
||||||
import 'package:social_app/features/auth/presentation/cubits/reset_password_cubit.dart';
|
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late ResetPasswordCubit cubit;
|
|
||||||
late MockAuthRepository mockRepository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
mockRepository = MockAuthRepository();
|
|
||||||
cubit = ResetPasswordCubit(mockRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await cubit.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'sendCode enters countdown immediately and prevents duplicate clicks',
|
|
||||||
() async {
|
|
||||||
final completer = Completer<void>();
|
|
||||||
when(
|
|
||||||
() => mockRepository.requestPasswordReset(any()),
|
|
||||||
).thenAnswer((_) => completer.future);
|
|
||||||
|
|
||||||
cubit.emailChanged('test@example.com');
|
|
||||||
|
|
||||||
final firstRequest = cubit.sendCode();
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
expect(cubit.state.status, FormzSubmissionStatus.inProgress);
|
|
||||||
expect(cubit.state.codeSent, isTrue);
|
|
||||||
expect(cubit.state.resendCountdown, 60);
|
|
||||||
|
|
||||||
await cubit.sendCode();
|
|
||||||
verify(
|
|
||||||
() => mockRepository.requestPasswordReset('test@example.com'),
|
|
||||||
).called(1);
|
|
||||||
|
|
||||||
completer.complete();
|
|
||||||
await firstRequest;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('sendCode failure cancels countdown and restores retry state', () async {
|
|
||||||
when(
|
|
||||||
() => mockRepository.requestPasswordReset(any()),
|
|
||||||
).thenThrow(Exception('network error'));
|
|
||||||
|
|
||||||
cubit.emailChanged('test@example.com');
|
|
||||||
|
|
||||||
await cubit.sendCode();
|
|
||||||
|
|
||||||
expect(cubit.state.status, FormzSubmissionStatus.failure);
|
|
||||||
expect(cubit.state.codeSent, isFalse);
|
|
||||||
expect(cubit.state.resendCountdown, 0);
|
|
||||||
expect(cubit.state.errorMessage, '网络错误,请稍后重试');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user