From 89d2722241ea72a2e164b772c479067809f4a340 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 15:05:29 +0800 Subject: [PATCH] feat(apps): add RegisterCubit for signup form --- .../presentation/cubits/register_cubit.dart | 209 ++++++++++++++++++ .../cubits/register_cubit_test.dart | 49 ++++ 2 files changed, 258 insertions(+) create mode 100644 apps/lib/features/auth/presentation/cubits/register_cubit.dart create mode 100644 apps/test/features/auth/presentation/cubits/register_cubit_test.dart diff --git a/apps/lib/features/auth/presentation/cubits/register_cubit.dart b/apps/lib/features/auth/presentation/cubits/register_cubit.dart new file mode 100644 index 0000000..7f367b6 --- /dev/null +++ b/apps/lib/features/auth/presentation/cubits/register_cubit.dart @@ -0,0 +1,209 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:equatable/equatable.dart'; +import '../../data/auth_repository.dart'; +import '../../data/models/signup_request.dart'; +import '../../data/models/auth_response.dart'; + +class Username extends FormzInput { + const Username.pure() : super.pure(''); + const Username.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Username is required'; + if (value.length < 3) return 'Username must be at least 3 characters'; + if (value.length > 30) return 'Username must be at most 30 characters'; + return null; + } +} + +class Email extends FormzInput { + const Email.pure() : super.pure(''); + const Email.dirty([super.value = '']) : super.dirty(); + + static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Email is required'; + if (!_regex.hasMatch(value)) return 'Invalid email format'; + return null; + } +} + +class Password extends FormzInput { + const Password.pure() : super.pure(''); + const Password.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Password must be at least 6 characters'; + return null; + } +} + +class VerificationCode extends FormzInput { + const VerificationCode.pure() : super.pure(''); + const VerificationCode.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Code is required'; + if (!RegExp(r'^\d{6}$').hasMatch(value)) return 'Code must be 6 digits'; + return null; + } +} + +class RegisterState extends Equatable { + final Username username; + final Email email; + final Password password; + final VerificationCode verificationCode; + final FormzSubmissionStatus status; + final String? errorMessage; + final String? pendingEmail; + final bool codeSent; + + const RegisterState({ + this.username = const Username.pure(), + this.email = const Email.pure(), + this.password = const Password.pure(), + this.verificationCode = const VerificationCode.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + this.pendingEmail, + this.codeSent = 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, + FormzSubmissionStatus? status, + String? errorMessage, + String? pendingEmail, + bool? codeSent, + }) { + return RegisterState( + username: username ?? this.username, + email: email ?? this.email, + password: password ?? this.password, + verificationCode: verificationCode ?? this.verificationCode, + status: status ?? this.status, + errorMessage: errorMessage, + pendingEmail: pendingEmail ?? this.pendingEmail, + codeSent: codeSent ?? this.codeSent, + ); + } + + @override + List get props => [ + username, + email, + password, + verificationCode, + status, + errorMessage, + pendingEmail, + codeSent, + ]; +} + +class RegisterCubit extends Cubit { + 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))); + } + + Future submitStep1() async { + if (!state.isStep1Valid) return false; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupStart( + SignupStartRequest( + username: state.username.value, + email: state.email.value, + password: state.password.value, + ), + ); + emit( + state.copyWith( + status: FormzSubmissionStatus.success, + pendingEmail: response.email, + codeSent: true, + ), + ); + return true; + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + ), + ); + return false; + } + } + + Future submitStep2() async { + if (!state.isStep2Valid || state.pendingEmail == null) return null; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupVerify( + SignupVerifyRequest( + email: state.pendingEmail!, + token: state.verificationCode.value, + ), + ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + return response; + } catch (e) { + emit( + state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + ), + ); + return null; + } + } + + Future resendCode() async { + if (state.pendingEmail == null) return; + + try { + await _repository.signupResend( + SignupResendRequest(email: state.pendingEmail!), + ); + emit(state.copyWith(codeSent: true)); + } catch (e) { + emit(state.copyWith(errorMessage: e.toString())); + } + } +} diff --git a/apps/test/features/auth/presentation/cubits/register_cubit_test.dart b/apps/test/features/auth/presentation/cubits/register_cubit_test.dart new file mode 100644 index 0000000..a2bfc56 --- /dev/null +++ b/apps/test/features/auth/presentation/cubits/register_cubit_test.dart @@ -0,0 +1,49 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late RegisterCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = RegisterCubit(mockRepository); + }); + + tearDown(() { + cubit.close(); + }); + + group('RegisterCubit', () { + test('initial state has pure status', () { + expect(cubit.state.status, FormzSubmissionStatus.initial); + }); + + blocTest( + 'usernameChanged updates username', + build: () => cubit, + act: (c) => c.usernameChanged('testuser'), + expect: () => [isA()], + ); + + blocTest( + 'emailChanged updates email', + build: () => cubit, + act: (c) => c.emailChanged('test@example.com'), + expect: () => [isA()], + ); + + blocTest( + 'passwordChanged updates password', + build: () => cubit, + act: (c) => c.passwordChanged('password123'), + expect: () => [isA()], + ); + }); +}