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:
qzl
2026-03-19 18:42:05 +08:00
parent 636b37ee5a
commit 0661016827
29 changed files with 615 additions and 2030 deletions
+7 -6
View File
@@ -13,16 +13,17 @@ class Username extends FormzInput<String, String> {
}
}
class Email extends FormzInput<String, String> {
const Email.pure() : super.pure('');
const Email.dirty([super.value = '']) : super.dirty();
class Phone extends FormzInput<String, String> {
const Phone.pure() : super.pure('');
const Phone.dirty([super.value = '']) : super.dirty();
static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$');
static final _regex = RegExp(r'^\d{7,14}$');
@override
String? validator(String value) {
if (value.isEmpty) return '请输入邮箱';
if (!_regex.hasMatch(value)) return '邮箱格式不正确';
final normalized = value.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) return '请输入手机号';
if (!_regex.hasMatch(normalized)) return '手机号格式不正确';
return null;
}
}
+4 -26
View File
@@ -4,11 +4,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import 'app_routes.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/register_screen.dart';
import '../../features/auth/ui/screens/register_verification_screen.dart';
import '../../features/auth/ui/screens/reset_password_screen.dart';
import '../../features/auth/ui/screens/login_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_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/memory_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';
final _protectedRoutes = [
@@ -43,7 +39,6 @@ final _protectedRoutes = [
AppRoutes.settingsFeatures,
AppRoutes.settingsMemory,
AppRoutes.settingsAccount,
AppRoutes.settingsChangePassword,
AppRoutes.settingsEditProfile,
AppRoutes.messageInviteList,
];
@@ -61,8 +56,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
final isBootRoute = state.matchedLocation == AppRoutes.authBoot;
final isAuthRoute =
state.matchedLocation == AppRoutes.authLogin ||
state.matchedLocation.startsWith('/login') ||
state.matchedLocation.startsWith('/register');
state.matchedLocation.startsWith('/login');
final isProtected = _protectedRoutes.any(
(route) => state.matchedLocation.startsWith(route),
);
@@ -86,10 +80,6 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: AppRoutes.authBoot,
builder: (context, state) => const AuthBootScreen(),
),
GoRoute(
path: AppRoutes.authLogin,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.calendarEventCreate,
builder: (context, state) => CalendarEventCreateScreen(
@@ -112,16 +102,8 @@ GoRouter createAppRouter(AuthBloc authBloc) {
CalendarEventShareScreen(eventId: state.pathParameters['id']!),
),
GoRoute(
path: AppRoutes.authRegister,
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: AppRoutes.authRegisterVerification,
builder: (context, state) => const RegisterVerificationScreen(),
),
GoRoute(
path: AppRoutes.authResetPassword,
builder: (context, state) => const ResetPasswordScreen(),
path: AppRoutes.authLogin,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.homeMain,
@@ -196,10 +178,6 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: AppRoutes.settingsAccount,
builder: (context, state) => const AccountScreen(),
),
GoRoute(
path: AppRoutes.settingsChangePassword,
builder: (context, state) => const ChangePasswordScreen(),
),
GoRoute(
path: AppRoutes.settingsEditProfile,
builder: (context, state) => const EditProfileScreen(),
-4
View File
@@ -3,9 +3,6 @@ class AppRoutes {
static const authBoot = '/boot';
static const authLogin = '/';
static const authRegister = '/register';
static const authRegisterVerification = '/register/verification';
static const authResetPassword = '/reset-password';
static const homeMain = '/home';
@@ -31,6 +28,5 @@ class AppRoutes {
static const settingsFeatures = '/settings/features';
static const settingsMemory = '/settings/memory';
static const settingsAccount = '/settings/account';
static const settingsChangePassword = '/change-password';
static const settingsEditProfile = '/edit-profile';
}
+4 -48
View File
@@ -9,34 +9,13 @@ class AuthApi {
AuthApi(this._client);
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
) async {
final response = await _client.post(
'$_prefix/verifications',
data: request.toJson(),
);
return VerificationCreateResponse.fromJson(response.data);
Future<void> sendOtp(OtpSendRequest request) async {
await _client.post('$_prefix/otp/send', data: request.toJson());
}
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
Future<AuthResponse> createPhoneSession(LoginRequest request) async {
final response = await _client.post(
'$_prefix/verify',
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',
'$_prefix/phone-session',
data: request.toJson(),
);
return AuthResponse.fromJson(response.data);
@@ -53,27 +32,4 @@ class AuthApi {
Future<void> deleteSession(LogoutRequest request) async {
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';
abstract class AuthRepository {
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
);
Future<AuthResponse> verifyVerification(SignupVerifyRequest request);
Future<void> resendVerification(SignupResendRequest request);
Future<AuthResponse> createSession(LoginRequest request);
Future<void> sendOtp(String phone);
Future<AuthResponse> createPhoneSession({
required String phone,
required String token,
});
Future<AuthResponse> refreshSession(String refreshToken);
Future<void> deleteSession();
Future<void> clearSessionLocalOnly();
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
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;
@override
Future<VerificationCreateResponse> createVerification(
SignupStartRequest request,
) {
return _api.createVerification(request);
Future<void> sendOtp(String phone) {
return _api.sendOtp(OtpSendRequest(phone: phone));
}
@override
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
final response = await _api.verifyVerification(request);
await _tokenStorage.saveTokens(
access: response.accessToken,
refresh: response.refreshToken,
Future<AuthResponse> createPhoneSession({
required String phone,
required String token,
}) async {
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(
access: response.accessToken,
refresh: response.refreshToken,
@@ -94,22 +82,4 @@ class AuthRepositoryImpl implements AuthRepository {
final token = await _tokenStorage.getAccessToken();
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 {
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) {
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 {
final String email;
final String password;
final String phone;
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 {
@@ -1,37 +1,28 @@
class SignupStartRequest {
final String username;
final String email;
final String password;
final String? inviteCode;
class OtpSendRequest {
final String phone;
const SignupStartRequest({
required this.username,
required this.email,
required this.password,
this.inviteCode,
});
const OtpSendRequest({required this.phone});
Map<String, dynamic> toJson() => {'phone': _normalizePhone(phone)};
}
class PhoneSessionRequest {
final String phone;
final String token;
const PhoneSessionRequest({required this.phone, required this.token});
Map<String, dynamic> toJson() => {
'username': username,
'email': email,
'password': password,
if (inviteCode != null) 'invite_code': inviteCode,
'phone': _normalizePhone(phone),
'token': token,
};
}
class SignupVerifyRequest {
final String email;
final String token;
const SignupVerifyRequest({required this.email, required this.token});
Map<String, dynamic> toJson() => {'email': email, 'token': token};
}
class SignupResendRequest {
final String email;
const SignupResendRequest({required this.email});
Map<String, dynamic> toJson() => {'email': email};
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;
}
@@ -21,7 +21,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final response = await _repository.refreshSession(refreshToken);
emit(
AuthAuthenticated(
user: AuthUser(id: response.user.id, email: response.user.email),
user: AuthUser(id: response.user.id, phone: response.user.phone),
),
);
return;
@@ -1,56 +1,161 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/api/api_exception.dart';
import '../../data/auth_repository.dart';
import '../../data/models/login_request.dart';
import '../../data/models/auth_response.dart';
import '../../../../core/form_inputs/form_inputs.dart';
class LoginState extends Equatable {
final Email email;
final Password password;
static const defaultDialCode = '+86';
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 String? errorMessage;
const LoginState({
this.email = const Email.pure(),
this.password = const Password.pure(),
this.dialCode = defaultDialCode,
this.phone = const Phone.pure(),
this.code = const VerificationCode.pure(),
this.codeSent = false,
this.isSendingCode = false,
this.resendCooldownSeconds = 0,
this.status = FormzSubmissionStatus.initial,
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({
Email? email,
Password? password,
String? dialCode,
Phone? phone,
VerificationCode? code,
bool? codeSent,
bool? isSendingCode,
int? resendCooldownSeconds,
FormzSubmissionStatus? status,
String? errorMessage,
}) {
return LoginState(
email: email ?? this.email,
password: password ?? this.password,
dialCode: dialCode ?? this.dialCode,
phone: phone ?? this.phone,
code: code ?? this.code,
codeSent: codeSent ?? this.codeSent,
isSendingCode: isSendingCode ?? this.isSendingCode,
resendCooldownSeconds:
resendCooldownSeconds ?? this.resendCooldownSeconds,
status: status ?? this.status,
errorMessage: errorMessage,
);
}
@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> {
final AuthRepository _repository;
Timer? _resendTimer;
LoginCubit(this._repository) : super(const LoginState());
void emailChanged(String value) {
emit(state.copyWith(email: Email.dirty(value)));
void phoneChanged(String 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) {
emit(state.copyWith(password: Password.dirty(value)));
void dialCodeChanged(String 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 {
@@ -59,12 +164,19 @@ class LoginCubit extends Cubit<LoginState> {
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
try {
final response = await _repository.createSession(
LoginRequest(email: state.email.value, password: state.password.value),
final response = await _repository.createPhoneSession(
phone: state.e164Phone,
token: state.code.value,
);
if (isClosed) {
return null;
}
emit(state.copyWith(status: FormzSubmissionStatus.success));
return response;
} catch (e) {
if (isClosed) {
return null;
}
final message = e is ApiException ? e.message : e.toString();
emit(
state.copyWith(
@@ -75,4 +187,29 @@ class LoginCubit extends Cubit<LoginState> {
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/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.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 '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/banner/app_banner.dart';
import '../../../../shared/widgets/confirm_sheet.dart';
import '../../../../shared/widgets/link_button.dart';
import '../../../../shared/widgets/phone_prefix_selector.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/auth_repository.dart';
import '../../presentation/bloc/auth_bloc.dart';
@@ -16,7 +19,6 @@ import '../../presentation/bloc/auth_event.dart';
import '../../presentation/cubits/login_cubit.dart';
import '../widgets/auth_field.dart';
import '../widgets/auth_page_scaffold.dart';
import '../widgets/password_field.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@@ -38,20 +40,23 @@ class LoginView extends StatefulWidget {
}
class _LoginViewState extends State<LoginView> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
static const _dialCodes = <String>['+86', '+1', '+44', '+81', '+65'];
final _phoneController = TextEditingController();
final _codeController = TextEditingController();
bool _agreedToTerms = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_phoneController.dispose();
_codeController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final cubit = context.read<LoginCubit>();
cubit.emailChanged(_emailController.text);
cubit.passwordChanged(_passwordController.text);
cubit.phoneChanged(_phoneController.text);
cubit.codeChanged(_codeController.text);
if (!cubit.state.isValid) {
return;
@@ -64,120 +69,243 @@ class _LoginViewState extends State<LoginView> {
}
}
@override
Widget build(BuildContext context) {
return AuthPageScaffold(
mainContentKey: const Key('login_main_content'),
footerKey: const Key('login_footer'),
mainContent: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const AuthHeroHeader(showBrand: true),
SizedBox(height: AppSpacing.xxl),
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final fieldError = state.email.displayError != null
? state.email.error
: state.password.displayError != null
? state.password.error
: null;
Future<void> _handleSendCode() async {
if (!_agreedToTerms) {
final confirmed = await _showAgreementDialog();
if (!confirmed || !mounted) return;
setState(() => _agreedToTerms = true);
}
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthField(
label: '邮箱',
hint: 'name@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: AppSpacing.lg),
PasswordField(
controller: _passwordController,
label: '密码',
hint: '请输入密码',
),
if (state.errorMessage != null ||
fieldError != null)
Padding(
padding: const EdgeInsets.only(
top: AppSpacing.lg,
),
child: AppBanner(
message:
state.errorMessage ?? fieldError!,
type: state.errorMessage != null
? ToastType.error
: ToastType.warning,
title: state.errorMessage != null
? '登录失败'
: '请检查输入',
),
),
],
),
),
SizedBox(height: AppSpacing.xl),
AppButton(
text: '登录',
onPressed:
state.status == FormzSubmissionStatus.inProgress
? null
: _handleLogin,
isLoading:
state.status ==
FormzSubmissionStatus.inProgress,
),
SizedBox(height: AppSpacing.sm),
Align(
alignment: Alignment.centerRight,
child: LinkButton(
text: '忘记密码?',
onTap: () => context.push('/reset-password'),
),
),
],
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,
),
),
],
),
),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'还没有账号?',
style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted),
),
LinkButton(text: '去注册', onTap: () => context.push('/register')),
],
),
);
}
@override
Widget build(BuildContext context) {
return AuthPageScaffold(
resizeOnKeyboard: false,
mainContentKey: const Key('login_main_content'),
mainContent: LayoutBuilder(
builder: (context, constraints) {
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: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AuthHeroHeader(showBrand: true),
SizedBox(height: AppSpacing.xxl),
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
final fieldError = state.phone.displayError != null
? state.phone.error
: state.code.displayError != null
? state.code.error
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthSection(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
AuthField(
hint: '输入手机号',
controller: _phoneController,
onChanged: (value) {
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),
AuthField(
hint: '输入验证码',
controller: _codeController,
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 ||
fieldError != null)
Padding(
padding: const EdgeInsets.only(
top: AppSpacing.md,
),
child: AppBanner(
message:
state.errorMessage ?? fieldError!,
type: state.errorMessage != null
? ToastType.error
: ToastType.warning,
title: state.errorMessage != null
? '登录失败'
: '请检查输入',
),
),
],
),
),
SizedBox(height: AppSpacing.xxl),
AppButton(
text: '登录/注册',
onPressed:
state.status ==
FormzSubmissionStatus.inProgress
? null
: _handleLogin,
isLoading:
state.status ==
FormzSubmissionStatus.inProgress,
),
SizedBox(height: AppSpacing.xxl * 2),
_buildAgreementCheckbox(),
],
);
},
),
],
),
),
),
),
);
},
),
);
}
}
@@ -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,41 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/theme/design_tokens.dart';
class AuthField extends StatelessWidget {
const AuthField({
super.key,
required this.label,
this.label,
required this.hint,
required this.controller,
this.keyboardType,
this.obscureText = false,
this.suffixIcon,
this.onChanged,
this.prefix,
this.inputFormatters,
});
final String label;
final String? label;
final String hint;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool obscureText;
final Widget? suffixIcon;
final ValueChanged<String>? onChanged;
final Widget? prefix;
final List<TextInputFormatter>? inputFormatters;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
if (label != null) ...[
Text(
label!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
),
SizedBox(height: AppSpacing.sm),
SizedBox(height: AppSpacing.sm),
],
Semantics(
label: label,
textField: true,
@@ -44,6 +51,7 @@ class AuthField extends StatelessWidget {
keyboardType: keyboardType,
obscureText: obscureText,
onChanged: onChanged,
inputFormatters: inputFormatters,
style: const TextStyle(fontSize: 16, color: AppColors.slate900),
decoration: InputDecoration(
hintText: hint,
@@ -57,6 +65,7 @@ class AuthField extends StatelessWidget {
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
prefixIcon: prefix,
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
@@ -9,12 +9,14 @@ class AuthPageScaffold extends StatelessWidget {
this.footer,
this.mainContentKey,
this.footerKey,
this.resizeOnKeyboard = true,
});
final Widget mainContent;
final Widget? footer;
final Key? mainContentKey;
final Key? footerKey;
final bool resizeOnKeyboard;
@override
Widget build(BuildContext context) {
@@ -22,6 +24,7 @@ class AuthPageScaffold extends StatelessWidget {
return Scaffold(
backgroundColor: AppColors.authBackgroundBottom,
resizeToAvoidBottomInset: resizeOnKeyboard,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -37,8 +40,33 @@ class AuthPageScaffold extends StatelessWidget {
children: [
const _AuthBackgroundOrbs(),
SafeArea(
maintainBottomViewPadding: !resizeOnKeyboard,
child: LayoutBuilder(
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(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
@@ -7,13 +7,13 @@ class PasswordField extends StatefulWidget {
const PasswordField({
super.key,
required this.controller,
required this.label,
this.label,
required this.hint,
this.onChanged,
});
final TextEditingController controller;
final String label;
final String? label;
final String hint;
final ValueChanged<String>? onChanged;
@@ -1,14 +1,14 @@
class UserResponse {
final String id;
final String username;
final String? email;
final String? phone;
final String? avatarUrl;
final String? bio;
const UserResponse({
required this.id,
required this.username,
this.email,
this.phone,
this.avatarUrl,
this.bio,
});
@@ -17,7 +17,7 @@ class UserResponse {
return UserResponse(
id: json['id'] as String,
username: json['username'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
avatarUrl: json['avatar_url'] as String?,
bio: json['bio'] as String?,
);
+5 -5
View File
@@ -1,13 +1,13 @@
class Validators {
Validators._();
static String? email(String? value) {
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
return '请输入手机号';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return '请输入有效的邮箱地址';
final phoneRegex = RegExp(r'^\+861[3-9]\d{9}$');
if (!phoneRegex.hasMatch(value)) {
return '请输入有效的 +86 手机号';
}
return null;
}