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