Files
social-app/apps/lib/features/auth/presentation/cubits/login_cubit.dart
T
qzl 0661016827 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
2026-03-19 18:42:05 +08:00

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();
}
}