0661016827
- 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
216 lines
5.4 KiB
Dart
216 lines
5.4 KiB
Dart
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/auth_response.dart';
|
|
import '../../../../core/form_inputs/form_inputs.dart';
|
|
|
|
class LoginState extends Equatable {
|
|
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.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 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({
|
|
String? dialCode,
|
|
Phone? phone,
|
|
VerificationCode? code,
|
|
bool? codeSent,
|
|
bool? isSendingCode,
|
|
int? resendCooldownSeconds,
|
|
FormzSubmissionStatus? status,
|
|
String? errorMessage,
|
|
}) {
|
|
return LoginState(
|
|
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 => [
|
|
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 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 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 {
|
|
if (!state.isValid) return null;
|
|
|
|
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
|
|
|
|
try {
|
|
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(
|
|
status: FormzSubmissionStatus.failure,
|
|
errorMessage: message,
|
|
),
|
|
);
|
|
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();
|
|
}
|
|
}
|