Files
social-app/apps/lib/features/auth/presentation/cubits/login_cubit.dart
T

228 lines
5.9 KiB
Dart
Raw Normal View History

import 'dart:async';
2026-02-25 15:09:29 +08:00
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:equatable/equatable.dart';
import '../../../../data/network/api_exception.dart';
import '../../../../core/l10n/l10n.dart';
2026-04-01 14:28:30 +08:00
import '../../../../core/logging/logger.dart';
import '../../data/repositories/auth_repository.dart';
2026-02-25 15:09:29 +08:00
import '../../data/models/auth_response.dart';
import '../../../../shared/forms/inputs.dart';
2026-02-25 15:09:29 +08:00
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;
2026-02-25 15:09:29 +08:00
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,
2026-02-25 15:09:29 +08:00
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;
2026-02-25 15:09:29 +08:00
LoginState copyWith({
String? dialCode,
Phone? phone,
VerificationCode? code,
bool? codeSent,
bool? isSendingCode,
int? resendCooldownSeconds,
2026-02-25 15:09:29 +08:00
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,
2026-02-25 15:09:29 +08:00
status: status ?? this.status,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [
dialCode,
phone,
code,
codeSent,
isSendingCode,
resendCooldownSeconds,
status,
errorMessage,
];
2026-02-25 15:09:29 +08:00
}
class LoginCubit extends Cubit<LoginState> {
final AuthRepository _repository;
2026-04-01 14:28:30 +08:00
final Logger _logger = getLogger('features.auth.login');
Timer? _resendTimer;
2026-02-25 15:09:29 +08:00
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,
),
);
2026-02-25 15:09:29 +08:00
}
void codeChanged(String value) {
emit(state.copyWith(code: VerificationCode.dirty(value)));
}
Future<bool> sendCode() async {
if (!state.phone.isValid) {
emit(state.copyWith(errorMessage: L10n.current.authInvalidPhone));
return false;
}
if (!state.canSendCode) {
return false;
}
final requestPhone = state.e164Phone;
emit(state.copyWith(isSendingCode: true, errorMessage: null));
_startResendCooldown();
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,
),
);
return true;
2026-04-01 14:28:30 +08:00
} catch (e, stackTrace) {
if (isClosed) {
return false;
}
2026-04-01 14:28:30 +08:00
_logger.error(
message: 'Failed to send OTP',
error: e,
stackTrace: stackTrace,
extra: {'phone': requestPhone},
);
final message = e is ApiException
? e.message
: L10n.current.authSendCodeFailed;
emit(state.copyWith(isSendingCode: false, errorMessage: message));
return false;
}
2026-02-25 15:09:29 +08:00
}
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,
2026-02-25 15:09:29 +08:00
);
if (isClosed) {
return null;
}
2026-02-25 15:09:29 +08:00
emit(state.copyWith(status: FormzSubmissionStatus.success));
return response;
2026-04-01 14:28:30 +08:00
} catch (e, stackTrace) {
if (isClosed) {
return null;
}
2026-04-01 14:28:30 +08:00
_logger.error(message: 'Login failed', error: e, stackTrace: stackTrace);
final message = e is ApiException ? e.message : e.toString();
2026-02-25 15:09:29 +08:00
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: message,
2026-02-25 15:09:29 +08:00
),
);
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();
}
2026-02-25 15:09:29 +08:00
}