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> {
|
||||
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,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(),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user