From 7e171300a35c1f0b51d21939f02f7b29377384db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:32:32 +0000 Subject: [PATCH 1/9] Initial plan From e33ed11e77ec00885d1656b84f7fd8c0d3a3bd88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:38:26 +0000 Subject: [PATCH 2/9] feat: Add phone authentication infrastructure Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../datasources/auth_remote_datasource.dart | 107 ++++++++++ .../repositories/auth_repository_impl.dart | 37 ++++ .../domain/repositories/auth_repository.dart | 2 + .../domain/usecases/sign_in_with_phone.dart | 15 ++ .../domain/usecases/verify_phone_code.dart | 17 ++ .../presentation/bloc/auth_bloc.dart | 37 ++++ .../presentation/bloc/auth_event.dart | 22 ++ .../presentation/bloc/auth_state.dart | 13 ++ .../presentation/pages/login_page.dart | 193 +++++++++++++++++- lib/l10n/app_de.arb | 12 ++ lib/l10n/app_en.arb | 12 ++ 11 files changed, 461 insertions(+), 6 deletions(-) create mode 100644 lib/features/authentication/domain/usecases/sign_in_with_phone.dart create mode 100644 lib/features/authentication/domain/usecases/verify_phone_code.dart diff --git a/lib/features/authentication/data/datasources/auth_remote_datasource.dart b/lib/features/authentication/data/datasources/auth_remote_datasource.dart index 35ef3c3..73f7ab5 100644 --- a/lib/features/authentication/data/datasources/auth_remote_datasource.dart +++ b/lib/features/authentication/data/datasources/auth_remote_datasource.dart @@ -10,6 +10,8 @@ import '../models/user_model.dart'; abstract class AuthRemoteDataSource { Future signInWithGoogle(); + Future signInWithPhone(String phoneNumber); + Future verifyPhoneCode(String verificationId, String smsCode); Future signOut(); Future getCurrentUser(); Stream get authStateChanges; @@ -132,6 +134,111 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { } } + @override + Future signInWithPhone(String phoneNumber) async { + try { + String verificationId = ''; + + await _firebaseAuth.verifyPhoneNumber( + phoneNumber: phoneNumber, + timeout: const Duration(seconds: 60), + verificationCompleted: (PhoneAuthCredential credential) async { + // Auto-verification on some devices + await _firebaseAuth.signInWithCredential(credential); + }, + verificationFailed: (FirebaseAuthException e) { + String errorMessage = 'phoneVerificationFailed'; + switch (e.code) { + case 'invalid-phone-number': + errorMessage = 'invalidPhoneNumber'; + break; + case 'too-many-requests': + errorMessage = 'tooManyRequests'; + break; + default: + errorMessage = e.message ?? 'phoneVerificationFailed'; + } + throw AuthException(errorMessage); + }, + codeSent: (String verId, int? resendToken) { + verificationId = verId; + }, + codeAutoRetrievalTimeout: (String verId) { + verificationId = verId; + }, + ); + + // Wait a bit to ensure verificationId is set + await Future.delayed(const Duration(milliseconds: 500)); + + if (verificationId.isEmpty) { + throw AuthException('phoneVerificationFailed'); + } + + return verificationId; + } on FirebaseAuthException catch (e) { + String errorMessage = 'phoneVerificationFailed'; + switch (e.code) { + case 'invalid-phone-number': + errorMessage = 'invalidPhoneNumber'; + break; + case 'too-many-requests': + errorMessage = 'tooManyRequests'; + break; + default: + errorMessage = e.message ?? 'phoneVerificationFailed'; + } + throw AuthException(errorMessage); + } catch (e) { + throw AuthException('phoneVerificationFailed: ${e.toString()}'); + } + } + + @override + Future verifyPhoneCode(String verificationId, String smsCode) async { + try { + final credential = PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + + final UserCredential userCredential = + await _firebaseAuth.signInWithCredential(credential); + + if (userCredential.user == null) { + throw AuthException('loginError'); + } + + // Check if user is admin + final isAdmin = await _checkIsAdmin(userCredential.user!.uid); + + final userModel = UserModel.fromFirebaseUser( + userCredential.user!, + isAdmin, + ); + + // Save user to registeredUsers collection for tracking + await _registeredUsersService.saveRegisteredUser(userCredential.user!); + + return userModel; + } on FirebaseAuthException catch (e) { + String errorMessage = 'loginError'; + switch (e.code) { + case 'invalid-verification-code': + errorMessage = 'invalidCredential'; + break; + case 'session-expired': + errorMessage = 'phoneVerificationFailed'; + break; + default: + errorMessage = e.message ?? 'unknownError'; + } + throw AuthException(errorMessage); + } catch (e) { + throw AuthException('loginError: ${e.toString()}'); + } + } + @override Future signOut() async { try { diff --git a/lib/features/authentication/data/repositories/auth_repository_impl.dart b/lib/features/authentication/data/repositories/auth_repository_impl.dart index abceda5..9b54f1f 100644 --- a/lib/features/authentication/data/repositories/auth_repository_impl.dart +++ b/lib/features/authentication/data/repositories/auth_repository_impl.dart @@ -35,6 +35,43 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future> signInWithPhone(String phoneNumber) async { + if (await networkInfo.isConnected) { + try { + final verificationId = await remoteDataSource.signInWithPhone(phoneNumber); + return Right(verificationId); + } on AuthException catch (e) { + return Left(AuthFailure(e.message)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } catch (e) { + return Left(AuthFailure('Unexpected error: ${e.toString()}')); + } + } else { + return const Left(NetworkFailure()); + } + } + + @override + Future> verifyPhoneCode( + String verificationId, String smsCode) async { + if (await networkInfo.isConnected) { + try { + final user = await remoteDataSource.verifyPhoneCode(verificationId, smsCode); + return Right(user); + } on AuthException catch (e) { + return Left(AuthFailure(e.message)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } catch (e) { + return Left(AuthFailure('Unexpected error: ${e.toString()}')); + } + } else { + return const Left(NetworkFailure()); + } + } + @override Future> signOut() async { try { diff --git a/lib/features/authentication/domain/repositories/auth_repository.dart b/lib/features/authentication/domain/repositories/auth_repository.dart index 512ca88..75912b7 100644 --- a/lib/features/authentication/domain/repositories/auth_repository.dart +++ b/lib/features/authentication/domain/repositories/auth_repository.dart @@ -4,6 +4,8 @@ import '../entities/user_entity.dart'; abstract class AuthRepository { Future> signInWithGoogle(); + Future> signInWithPhone(String phoneNumber); + Future> verifyPhoneCode(String verificationId, String smsCode); Future> signOut(); Future> getCurrentUser(); Stream get authStateChanges; diff --git a/lib/features/authentication/domain/usecases/sign_in_with_phone.dart b/lib/features/authentication/domain/usecases/sign_in_with_phone.dart new file mode 100644 index 0000000..5ab31d7 --- /dev/null +++ b/lib/features/authentication/domain/usecases/sign_in_with_phone.dart @@ -0,0 +1,15 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import '../../../../core/errors/failures.dart'; +import '../repositories/auth_repository.dart'; + +@injectable +class SignInWithPhone { + final AuthRepository repository; + + SignInWithPhone(this.repository); + + Future> call(String phoneNumber) async { + return await repository.signInWithPhone(phoneNumber); + } +} diff --git a/lib/features/authentication/domain/usecases/verify_phone_code.dart b/lib/features/authentication/domain/usecases/verify_phone_code.dart new file mode 100644 index 0000000..74591a0 --- /dev/null +++ b/lib/features/authentication/domain/usecases/verify_phone_code.dart @@ -0,0 +1,17 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import '../../../../core/errors/failures.dart'; +import '../entities/user_entity.dart'; +import '../repositories/auth_repository.dart'; + +@injectable +class VerifyPhoneCode { + final AuthRepository repository; + + VerifyPhoneCode(this.repository); + + Future> call( + String verificationId, String smsCode) async { + return await repository.verifyPhoneCode(verificationId, smsCode); + } +} diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index b56d502..0d78969 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -4,6 +4,8 @@ import 'package:injectable/injectable.dart'; import '../../domain/repositories/auth_repository.dart'; import '../../domain/usecases/sign_in_with_google.dart'; +import '../../domain/usecases/sign_in_with_phone.dart'; +import '../../domain/usecases/verify_phone_code.dart'; import '../../domain/usecases/sign_out.dart'; import '../../domain/usecases/get_current_user.dart'; import 'auth_event.dart'; @@ -12,6 +14,8 @@ import 'auth_state.dart'; @injectable class AuthBloc extends Bloc { final SignInWithGoogle _signInWithGoogle; + final SignInWithPhone _signInWithPhone; + final VerifyPhoneCode _verifyPhoneCode; final SignOut _signOut; final GetCurrentUser _getCurrentUser; final AuthRepository _authRepository; @@ -19,12 +23,16 @@ class AuthBloc extends Bloc { AuthBloc( this._signInWithGoogle, + this._signInWithPhone, + this._verifyPhoneCode, this._signOut, this._getCurrentUser, this._authRepository, ) : super(AuthInitial()) { on(_onAuthStarted); on(_onAuthSignInRequested); + on(_onAuthPhoneSignInRequested); + on(_onAuthVerifyPhoneCode); on(_onAuthSignOutRequested); on(_onAuthUserChanged); @@ -66,6 +74,35 @@ class AuthBloc extends Bloc { ); } + Future _onAuthPhoneSignInRequested( + AuthPhoneSignInRequested event, + Emitter emit, + ) async { + emit(AuthLoading()); + + final result = await _signInWithPhone(event.phoneNumber); + result.fold( + (failure) => emit(AuthError(failure.message)), + (verificationId) => emit(AuthPhoneCodeSent( + verificationId: verificationId, + phoneNumber: event.phoneNumber, + )), + ); + } + + Future _onAuthVerifyPhoneCode( + AuthVerifyPhoneCode event, + Emitter emit, + ) async { + emit(AuthLoading()); + + final result = await _verifyPhoneCode(event.verificationId, event.smsCode); + result.fold( + (failure) => emit(AuthError(failure.message)), + (user) => emit(AuthAuthenticated(user)), + ); + } + Future _onAuthSignOutRequested( AuthSignOutRequested event, Emitter emit, diff --git a/lib/features/authentication/presentation/bloc/auth_event.dart b/lib/features/authentication/presentation/bloc/auth_event.dart index 068bbc5..2a4ff21 100644 --- a/lib/features/authentication/presentation/bloc/auth_event.dart +++ b/lib/features/authentication/presentation/bloc/auth_event.dart @@ -10,6 +10,28 @@ class AuthStarted extends AuthEvent {} class AuthSignInRequested extends AuthEvent {} +class AuthPhoneSignInRequested extends AuthEvent { + final String phoneNumber; + + AuthPhoneSignInRequested(this.phoneNumber); + + @override + List get props => [phoneNumber]; +} + +class AuthVerifyPhoneCode extends AuthEvent { + final String verificationId; + final String smsCode; + + AuthVerifyPhoneCode({ + required this.verificationId, + required this.smsCode, + }); + + @override + List get props => [verificationId, smsCode]; +} + class AuthSignOutRequested extends AuthEvent {} class AuthUserChanged extends AuthEvent { diff --git a/lib/features/authentication/presentation/bloc/auth_state.dart b/lib/features/authentication/presentation/bloc/auth_state.dart index ae93e70..31f4914 100644 --- a/lib/features/authentication/presentation/bloc/auth_state.dart +++ b/lib/features/authentication/presentation/bloc/auth_state.dart @@ -21,6 +21,19 @@ class AuthAuthenticated extends AuthState { class AuthUnauthenticated extends AuthState {} +class AuthPhoneCodeSent extends AuthState { + final String verificationId; + final String phoneNumber; + + AuthPhoneCodeSent({ + required this.verificationId, + required this.phoneNumber, + }); + + @override + List get props => [verificationId, phoneNumber]; +} + class AuthError extends AuthState { final String message; diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 8e52292..14e4ae8 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -9,16 +9,33 @@ import '../bloc/auth_bloc.dart'; import '../bloc/auth_event.dart'; import '../bloc/auth_state.dart'; -class LoginPage extends StatelessWidget { +class LoginPage extends StatefulWidget { final bool isDialog; const LoginPage({super.key, this.isDialog = false}); + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final TextEditingController _phoneController = TextEditingController(); + final TextEditingController _codeController = TextEditingController(); + String? _verificationId; + String? _phoneNumber; + + @override + void dispose() { + _phoneController.dispose(); + _codeController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppConstants.backgroundColor, - appBar: isDialog + appBar: widget.isDialog ? AppBar( title: Text(AppLocalizations.of(context)!.login), backgroundColor: AppConstants.backgroundColor, @@ -66,6 +83,18 @@ class LoginPage extends StatelessWidget { duration: const Duration(seconds: 2), ), ); + } else if (state is AuthPhoneCodeSent) { + setState(() { + _verificationId = state.verificationId; + _phoneNumber = state.phoneNumber; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.verificationCodeSent), + backgroundColor: Colors.green, + duration: const Duration(seconds: 3), + ), + ); } }, builder: (context, state) { @@ -99,11 +128,23 @@ class LoginPage extends StatelessWidget { ), const SizedBox(height: 48), - // Google Sign In Button + // Sign In Options if (state is AuthLoading) const CircularProgressIndicator() - else + else if (_verificationId != null) + _buildVerificationCodeInput(context) + else ...[ + _buildPhoneSignInForm(context), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.orSignInWith, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), _buildGoogleSignInButton(context), + ], const SizedBox(height: 24), @@ -183,6 +224,145 @@ class LoginPage extends StatelessWidget { ); } + Widget _buildPhoneSignInForm(BuildContext context) { + return Column( + children: [ + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.phoneNumber, + hintText: AppLocalizations.of(context)!.enterPhoneNumber, + prefixIcon: const Icon(Icons.phone), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppConstants.primaryColor), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + final phoneNumber = _phoneController.text.trim(); + if (phoneNumber.isNotEmpty) { + context + .read() + .add(AuthPhoneSignInRequested(phoneNumber)); + } + }, + icon: const Icon(Icons.send, color: Colors.white), + label: Text( + AppLocalizations.of(context)!.sendCode, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ); + } + + Widget _buildVerificationCodeInput(BuildContext context) { + return Column( + children: [ + Text( + '${AppLocalizations.of(context)!.verificationCodeSent}\n$_phoneNumber', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.verificationCode, + hintText: AppLocalizations.of(context)!.enterVerificationCode, + prefixIcon: const Icon(Icons.lock), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppConstants.primaryColor), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + final code = _codeController.text.trim(); + if (code.isNotEmpty && _verificationId != null) { + context.read().add(AuthVerifyPhoneCode( + verificationId: _verificationId!, + smsCode: code, + )); + } + }, + icon: const Icon(Icons.verified, color: Colors.white), + label: Text( + AppLocalizations.of(context)!.verifyCode, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppConstants.primaryColor, + foregroundColor: Colors.white, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + setState(() { + _verificationId = null; + _phoneNumber = null; + _codeController.clear(); + }); + }, + child: Text( + AppLocalizations.of(context)!.cancel, + style: const TextStyle(color: AppConstants.primaryColor), + ), + ), + ], + ); + } + Widget _buildTermsAndPrivacy(BuildContext context) { return Column( children: [ @@ -198,7 +378,7 @@ class LoginPage extends StatelessWidget { children: [ GestureDetector( onTap: () { - if (isDialog) { + if (widget.isDialog) { // Close dialog first, then navigate Navigator.of(context).pop(); context.go(AppRouter.terms); @@ -222,7 +402,7 @@ class LoginPage extends StatelessWidget { ), GestureDetector( onTap: () { - if (isDialog) { + if (widget.isDialog) { // Close dialog first, then navigate Navigator.of(context).pop(); context.go(AppRouter.privacy); @@ -250,3 +430,4 @@ class LoginPage extends StatelessWidget { ); } } + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0f21ed9..af9974c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -5,6 +5,18 @@ "subtitle": "Bewerte Personen mit Raphcons", "signIn": "Anmelden", "signInWithGoogle": "Mit Google anmelden", + "signInWithPhone": "Mit Telefon anmelden", + "phoneNumber": "Telefonnummer", + "enterPhoneNumber": "Gib deine Telefonnummer ein", + "verificationCode": "Bestätigungscode", + "enterVerificationCode": "Gib den Bestätigungscode ein", + "sendCode": "Code senden", + "verifyCode": "Code bestätigen", + "resendCode": "Code erneut senden", + "invalidPhoneNumber": "Ungültige Telefonnummer", + "verificationCodeSent": "Bestätigungscode gesendet", + "phoneVerificationFailed": "Telefon-Verifizierung fehlgeschlagen", + "orSignInWith": "Oder anmelden mit", "signOut": "Abmelden", "loading": "Lädt...", "error": "Fehler", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bc19bf5..8145c63 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5,6 +5,18 @@ "subtitle": "Rate people with raphcons", "signIn": "Sign in", "signInWithGoogle": "Sign in with Google", + "signInWithPhone": "Sign in with Phone", + "phoneNumber": "Phone Number", + "enterPhoneNumber": "Enter your phone number", + "verificationCode": "Verification Code", + "enterVerificationCode": "Enter the verification code", + "sendCode": "Send Code", + "verifyCode": "Verify Code", + "resendCode": "Resend Code", + "invalidPhoneNumber": "Invalid phone number", + "verificationCodeSent": "Verification code sent", + "phoneVerificationFailed": "Phone verification failed", + "orSignInWith": "Or sign in with", "signOut": "Sign Out", "loading": "Loading...", "error": "Error", From 2af81fa877fdc9a5bd2f48b7fc0eecf002c0653f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:41:27 +0000 Subject: [PATCH 3/9] fix: Improve phone authentication validation and async handling Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../datasources/auth_remote_datasource.dart | 66 +++++++++++-------- .../presentation/bloc/auth_bloc.dart | 27 ++++++-- .../presentation/pages/login_page.dart | 42 +++++++++++- 3 files changed, 101 insertions(+), 34 deletions(-) diff --git a/lib/features/authentication/data/datasources/auth_remote_datasource.dart b/lib/features/authentication/data/datasources/auth_remote_datasource.dart index 73f7ab5..197ebff 100644 --- a/lib/features/authentication/data/datasources/auth_remote_datasource.dart +++ b/lib/features/authentication/data/datasources/auth_remote_datasource.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; @@ -136,46 +137,56 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { @override Future signInWithPhone(String phoneNumber) async { + final completer = Completer(); + try { - String verificationId = ''; - await _firebaseAuth.verifyPhoneNumber( phoneNumber: phoneNumber, timeout: const Duration(seconds: 60), verificationCompleted: (PhoneAuthCredential credential) async { - // Auto-verification on some devices - await _firebaseAuth.signInWithCredential(credential); + // Auto-verification on some devices - sign in automatically + try { + await _firebaseAuth.signInWithCredential(credential); + // Complete with empty string to indicate auto-verification success + if (!completer.isCompleted) { + completer.complete(''); + } + } catch (e) { + if (!completer.isCompleted) { + completer.completeError(AuthException('loginError')); + } + } }, verificationFailed: (FirebaseAuthException e) { - String errorMessage = 'phoneVerificationFailed'; - switch (e.code) { - case 'invalid-phone-number': - errorMessage = 'invalidPhoneNumber'; - break; - case 'too-many-requests': - errorMessage = 'tooManyRequests'; - break; - default: - errorMessage = e.message ?? 'phoneVerificationFailed'; + if (!completer.isCompleted) { + String errorMessage = 'phoneVerificationFailed'; + switch (e.code) { + case 'invalid-phone-number': + errorMessage = 'invalidPhoneNumber'; + break; + case 'too-many-requests': + errorMessage = 'tooManyRequests'; + break; + default: + errorMessage = e.message ?? 'phoneVerificationFailed'; + } + completer.completeError(AuthException(errorMessage)); } - throw AuthException(errorMessage); }, - codeSent: (String verId, int? resendToken) { - verificationId = verId; + codeSent: (String verificationId, int? resendToken) { + if (!completer.isCompleted) { + completer.complete(verificationId); + } }, - codeAutoRetrievalTimeout: (String verId) { - verificationId = verId; + codeAutoRetrievalTimeout: (String verificationId) { + // Fallback: complete with verificationId if not already completed + if (!completer.isCompleted) { + completer.complete(verificationId); + } }, ); - // Wait a bit to ensure verificationId is set - await Future.delayed(const Duration(milliseconds: 500)); - - if (verificationId.isEmpty) { - throw AuthException('phoneVerificationFailed'); - } - - return verificationId; + return await completer.future; } on FirebaseAuthException catch (e) { String errorMessage = 'phoneVerificationFailed'; switch (e.code) { @@ -190,6 +201,7 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { } throw AuthException(errorMessage); } catch (e) { + if (e is AuthException) rethrow; throw AuthException('phoneVerificationFailed: ${e.toString()}'); } } diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index 0d78969..4a1de06 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -83,10 +83,29 @@ class AuthBloc extends Bloc { final result = await _signInWithPhone(event.phoneNumber); result.fold( (failure) => emit(AuthError(failure.message)), - (verificationId) => emit(AuthPhoneCodeSent( - verificationId: verificationId, - phoneNumber: event.phoneNumber, - )), + (verificationId) { + // Empty verificationId means auto-verification succeeded + if (verificationId.isEmpty) { + // User is already signed in, get current user + _getCurrentUser().then((userResult) { + userResult.fold( + (failure) => emit(AuthError(failure.message)), + (user) { + if (user != null) { + emit(AuthAuthenticated(user)); + } else { + emit(AuthError('loginError')); + } + }, + ); + }); + } else { + emit(AuthPhoneCodeSent( + verificationId: verificationId, + phoneNumber: event.phoneNumber, + )); + } + }, ); } diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 14e4ae8..f9b0025 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -31,6 +31,28 @@ class _LoginPageState extends State { super.dispose(); } + bool _validatePhoneNumber(String phoneNumber) { + // Phone number must start with + and contain only digits after that + // Minimum length is 10 characters (e.g., +1234567890) + if (phoneNumber.isEmpty || !phoneNumber.startsWith('+')) { + return false; + } + + final digitsOnly = phoneNumber.substring(1).replaceAll(RegExp(r'\s+'), ''); + if (digitsOnly.length < 9 || !RegExp(r'^\d+$').hasMatch(digitsOnly)) { + return false; + } + + return true; + } + + bool _validateVerificationCode(String code) { + // SMS verification codes are typically 6 digits + return code.isNotEmpty && + code.length == 6 && + RegExp(r'^\d{6}$').hasMatch(code); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -232,7 +254,7 @@ class _LoginPageState extends State { keyboardType: TextInputType.phone, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.phoneNumber, - hintText: AppLocalizations.of(context)!.enterPhoneNumber, + hintText: '+49 123 4567890', prefixIcon: const Icon(Icons.phone), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -254,10 +276,17 @@ class _LoginPageState extends State { child: ElevatedButton.icon( onPressed: () { final phoneNumber = _phoneController.text.trim(); - if (phoneNumber.isNotEmpty) { + if (_validatePhoneNumber(phoneNumber)) { context .read() .add(AuthPhoneSignInRequested(phoneNumber)); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.invalidPhoneNumber), + backgroundColor: Colors.red, + ), + ); } }, icon: const Icon(Icons.send, color: Colors.white), @@ -320,11 +349,18 @@ class _LoginPageState extends State { child: ElevatedButton.icon( onPressed: () { final code = _codeController.text.trim(); - if (code.isNotEmpty && _verificationId != null) { + if (_validateVerificationCode(code) && _verificationId != null) { context.read().add(AuthVerifyPhoneCode( verificationId: _verificationId!, smsCode: code, )); + } else if (!_validateVerificationCode(code)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.invalidCredential), + backgroundColor: Colors.red, + ), + ); } }, icon: const Icon(Icons.verified, color: Colors.white), From 9861ca4c5e6316113f30f0acecf24fc373878cb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:42:41 +0000 Subject: [PATCH 4/9] fix: Add timeout handling and improve error messages Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../data/datasources/auth_remote_datasource.dart | 10 ++++++++-- .../authentication/presentation/bloc/auth_bloc.dart | 2 +- .../authentication/presentation/pages/login_page.dart | 2 +- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/features/authentication/data/datasources/auth_remote_datasource.dart b/lib/features/authentication/data/datasources/auth_remote_datasource.dart index 197ebff..7ffdb71 100644 --- a/lib/features/authentication/data/datasources/auth_remote_datasource.dart +++ b/lib/features/authentication/data/datasources/auth_remote_datasource.dart @@ -137,7 +137,7 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { @override Future signInWithPhone(String phoneNumber) async { - final completer = Completer(); + final Completer completer = Completer(); try { await _firebaseAuth.verifyPhoneNumber( @@ -186,7 +186,13 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { }, ); - return await completer.future; + // Add timeout to prevent hanging indefinitely + return await completer.future.timeout( + const Duration(seconds: 65), + onTimeout: () { + throw AuthException('phoneVerificationFailed'); + }, + ); } on FirebaseAuthException catch (e) { String errorMessage = 'phoneVerificationFailed'; switch (e.code) { diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index 4a1de06..ec170d7 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -94,7 +94,7 @@ class AuthBloc extends Bloc { if (user != null) { emit(AuthAuthenticated(user)); } else { - emit(AuthError('loginError')); + emit(AuthError('phoneVerificationFailed')); } }, ); diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index f9b0025..501c1aa 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -357,7 +357,7 @@ class _LoginPageState extends State { } else if (!_validateVerificationCode(code)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.invalidCredential), + content: Text(AppLocalizations.of(context)!.invalidVerificationCode), backgroundColor: Colors.red, ), ); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index af9974c..cdc817c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -14,6 +14,7 @@ "verifyCode": "Code bestätigen", "resendCode": "Code erneut senden", "invalidPhoneNumber": "Ungültige Telefonnummer", + "invalidVerificationCode": "Ungültiger Bestätigungscode. Code muss 6 Ziffern haben.", "verificationCodeSent": "Bestätigungscode gesendet", "phoneVerificationFailed": "Telefon-Verifizierung fehlgeschlagen", "orSignInWith": "Oder anmelden mit", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8145c63..f8a2710 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14,6 +14,7 @@ "verifyCode": "Verify Code", "resendCode": "Resend Code", "invalidPhoneNumber": "Invalid phone number", + "invalidVerificationCode": "Invalid verification code. Code must be 6 digits.", "verificationCodeSent": "Verification code sent", "phoneVerificationFailed": "Phone verification failed", "orSignInWith": "Or sign in with", From 00a640e23a642d38b151bba1f2e64656dd82dbbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:44:09 +0000 Subject: [PATCH 5/9] refactor: Extract constants and improve async error handling Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../data/datasources/auth_remote_datasource.dart | 5 ++++- .../presentation/bloc/auth_bloc.dart | 13 ++++++++----- .../presentation/pages/login_page.dart | 15 ++++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/lib/features/authentication/data/datasources/auth_remote_datasource.dart b/lib/features/authentication/data/datasources/auth_remote_datasource.dart index 7ffdb71..79c5a11 100644 --- a/lib/features/authentication/data/datasources/auth_remote_datasource.dart +++ b/lib/features/authentication/data/datasources/auth_remote_datasource.dart @@ -25,6 +25,9 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { final FirebaseFirestore _firestore; final RegisteredUsersService _registeredUsersService; + // Phone verification timeout (5 seconds more than Firebase's timeout to ensure completion) + static const Duration _phoneVerificationTimeout = Duration(seconds: 65); + AuthRemoteDataSourceImpl( this._firebaseAuth, this._googleSignIn, @@ -188,7 +191,7 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { // Add timeout to prevent hanging indefinitely return await completer.future.timeout( - const Duration(seconds: 65), + _phoneVerificationTimeout, onTimeout: () { throw AuthException('phoneVerificationFailed'); }, diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index ec170d7..585fbf4 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -81,13 +81,14 @@ class AuthBloc extends Bloc { emit(AuthLoading()); final result = await _signInWithPhone(event.phoneNumber); - result.fold( - (failure) => emit(AuthError(failure.message)), - (verificationId) { + await result.fold( + (failure) async => emit(AuthError(failure.message)), + (verificationId) async { // Empty verificationId means auto-verification succeeded if (verificationId.isEmpty) { // User is already signed in, get current user - _getCurrentUser().then((userResult) { + try { + final userResult = await _getCurrentUser(); userResult.fold( (failure) => emit(AuthError(failure.message)), (user) { @@ -98,7 +99,9 @@ class AuthBloc extends Bloc { } }, ); - }); + } catch (e) { + emit(AuthError('phoneVerificationFailed')); + } } else { emit(AuthPhoneCodeSent( verificationId: verificationId, diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 501c1aa..487aa15 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -31,15 +31,20 @@ class _LoginPageState extends State { super.dispose(); } + // Phone number validation constants + static const int _minPhoneDigits = 9; // Minimum digits for international numbers (e.g., +1234567890) + static const int _verificationCodeLength = 6; // Standard SMS verification code length + bool _validatePhoneNumber(String phoneNumber) { - // Phone number must start with + and contain only digits after that - // Minimum length is 10 characters (e.g., +1234567890) + // Phone number must start with + followed by country code and number + // Format: +[country code][phone number] (e.g., +491234567890) if (phoneNumber.isEmpty || !phoneNumber.startsWith('+')) { return false; } + // Remove spaces and get only digits after + final digitsOnly = phoneNumber.substring(1).replaceAll(RegExp(r'\s+'), ''); - if (digitsOnly.length < 9 || !RegExp(r'^\d+$').hasMatch(digitsOnly)) { + if (digitsOnly.length < _minPhoneDigits || !RegExp(r'^\d+$').hasMatch(digitsOnly)) { return false; } @@ -49,8 +54,8 @@ class _LoginPageState extends State { bool _validateVerificationCode(String code) { // SMS verification codes are typically 6 digits return code.isNotEmpty && - code.length == 6 && - RegExp(r'^\d{6}$').hasMatch(code); + code.length == _verificationCodeLength && + RegExp(r'^\d{$_verificationCodeLength}$').hasMatch(code); } @override From 1f3de03bea3414fd202fbf3f3d98e05c30d87fc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:45:24 +0000 Subject: [PATCH 6/9] fix: Correct regex pattern and error message mapping Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../authentication/data/datasources/auth_remote_datasource.dart | 2 +- lib/features/authentication/presentation/pages/login_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/authentication/data/datasources/auth_remote_datasource.dart b/lib/features/authentication/data/datasources/auth_remote_datasource.dart index 79c5a11..6b94db9 100644 --- a/lib/features/authentication/data/datasources/auth_remote_datasource.dart +++ b/lib/features/authentication/data/datasources/auth_remote_datasource.dart @@ -246,7 +246,7 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { String errorMessage = 'loginError'; switch (e.code) { case 'invalid-verification-code': - errorMessage = 'invalidCredential'; + errorMessage = 'invalidVerificationCode'; break; case 'session-expired': errorMessage = 'phoneVerificationFailed'; diff --git a/lib/features/authentication/presentation/pages/login_page.dart b/lib/features/authentication/presentation/pages/login_page.dart index 487aa15..080aecb 100644 --- a/lib/features/authentication/presentation/pages/login_page.dart +++ b/lib/features/authentication/presentation/pages/login_page.dart @@ -55,7 +55,7 @@ class _LoginPageState extends State { // SMS verification codes are typically 6 digits return code.isNotEmpty && code.length == _verificationCodeLength && - RegExp(r'^\d{$_verificationCodeLength}$').hasMatch(code); + RegExp(r'^\d{6}$').hasMatch(code); } @override From 1e8bfd3726e6c01ca3094f23faf246d13137f3e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:58:10 +0000 Subject: [PATCH 7/9] fix: Add missing phone auth dependencies to AuthBloc instantiation Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- lib/main.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index b9dff9a..5d79ab0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,6 +32,8 @@ import 'features/raphcon_management/presentation/bloc/raphcon_bloc.dart'; import 'features/authentication/data/repositories/auth_repository_impl.dart'; import 'features/authentication/data/datasources/auth_remote_datasource.dart'; import 'features/authentication/domain/usecases/sign_in_with_google.dart'; +import 'features/authentication/domain/usecases/sign_in_with_phone.dart'; +import 'features/authentication/domain/usecases/verify_phone_code.dart'; import 'services/registered_users_service.dart'; import 'features/authentication/domain/usecases/sign_out.dart'; import 'features/authentication/domain/usecases/get_current_user.dart'; @@ -180,11 +182,18 @@ class AngryRaphiApp extends StatelessWidget { ); final signInWithGoogle = SignInWithGoogle(authRepository); + final signInWithPhone = SignInWithPhone(authRepository); + final verifyPhoneCode = VerifyPhoneCode(authRepository); final signOut = SignOut(authRepository); final getCurrentUser = GetCurrentUser(authRepository); return AuthBloc( - signInWithGoogle, signOut, getCurrentUser, authRepository) + signInWithGoogle, + signInWithPhone, + verifyPhoneCode, + signOut, + getCurrentUser, + authRepository) ..add(AuthStarted()); }, ), From d7b122e6ae02199a370f8f5930e985e1e1673003 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:43:06 +0000 Subject: [PATCH 8/9] test: Add tests for phone authentication events, states, and use cases Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .../usecases/sign_in_with_phone_test.dart | 66 ++++++++++++++++ .../usecases/verify_phone_code_test.dart | 75 +++++++++++++++++++ .../presentation/bloc/auth_event_test.dart | 58 ++++++++++++++ .../presentation/bloc/auth_state_test.dart | 38 ++++++++++ 4 files changed, 237 insertions(+) create mode 100644 test/features/authentication/domain/usecases/sign_in_with_phone_test.dart create mode 100644 test/features/authentication/domain/usecases/verify_phone_code_test.dart diff --git a/test/features/authentication/domain/usecases/sign_in_with_phone_test.dart b/test/features/authentication/domain/usecases/sign_in_with_phone_test.dart new file mode 100644 index 0000000..2adb507 --- /dev/null +++ b/test/features/authentication/domain/usecases/sign_in_with_phone_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:dartz/dartz.dart'; +import 'package:angry_raphi/features/authentication/domain/usecases/sign_in_with_phone.dart'; +import 'package:angry_raphi/features/authentication/domain/repositories/auth_repository.dart'; +import 'package:angry_raphi/core/errors/failures.dart'; + +@GenerateMocks([AuthRepository]) +import 'sign_in_with_phone_test.mocks.dart'; + +void main() { + late SignInWithPhone usecase; + late MockAuthRepository mockAuthRepository; + + setUp(() { + mockAuthRepository = MockAuthRepository(); + usecase = SignInWithPhone(mockAuthRepository); + }); + + group('SignInWithPhone', () { + const testPhoneNumber = '+491234567890'; + const testVerificationId = 'verification123'; + + test('should return verificationId from repository', () async { + // arrange + when(mockAuthRepository.signInWithPhone(any)) + .thenAnswer((_) async => const Right(testVerificationId)); + + // act + final result = await usecase(testPhoneNumber); + + // assert + expect(result, const Right(testVerificationId)); + verify(mockAuthRepository.signInWithPhone(testPhoneNumber)); + verifyNoMoreInteractions(mockAuthRepository); + }); + + test('should return failure when repository fails', () async { + // arrange + final failure = AuthFailure('Phone verification failed'); + when(mockAuthRepository.signInWithPhone(any)) + .thenAnswer((_) async => Left(failure)); + + // act + final result = await usecase(testPhoneNumber); + + // assert + expect(result, Left(failure)); + verify(mockAuthRepository.signInWithPhone(testPhoneNumber)); + verifyNoMoreInteractions(mockAuthRepository); + }); + + test('should pass correct phone number to repository', () async { + // arrange + when(mockAuthRepository.signInWithPhone(any)) + .thenAnswer((_) async => const Right(testVerificationId)); + + // act + await usecase(testPhoneNumber); + + // assert + verify(mockAuthRepository.signInWithPhone(testPhoneNumber)); + }); + }); +} diff --git a/test/features/authentication/domain/usecases/verify_phone_code_test.dart b/test/features/authentication/domain/usecases/verify_phone_code_test.dart new file mode 100644 index 0000000..b4f9712 --- /dev/null +++ b/test/features/authentication/domain/usecases/verify_phone_code_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:dartz/dartz.dart'; +import 'package:angry_raphi/features/authentication/domain/usecases/verify_phone_code.dart'; +import 'package:angry_raphi/features/authentication/domain/repositories/auth_repository.dart'; +import 'package:angry_raphi/features/authentication/domain/entities/user_entity.dart'; +import 'package:angry_raphi/core/errors/failures.dart'; + +@GenerateMocks([AuthRepository]) +import 'verify_phone_code_test.mocks.dart'; + +void main() { + late VerifyPhoneCode usecase; + late MockAuthRepository mockAuthRepository; + + setUp(() { + mockAuthRepository = MockAuthRepository(); + usecase = VerifyPhoneCode(mockAuthRepository); + }); + + group('VerifyPhoneCode', () { + const testVerificationId = 'verification123'; + const testSmsCode = '123456'; + final testDate = DateTime(2024, 1, 1); + final testUser = UserEntity( + id: 'user123', + email: 'test@example.com', + displayName: 'Test User', + isAdmin: false, + createdAt: testDate, + ); + + test('should return user from repository on successful verification', () async { + // arrange + when(mockAuthRepository.verifyPhoneCode(any, any)) + .thenAnswer((_) async => Right(testUser)); + + // act + final result = await usecase(testVerificationId, testSmsCode); + + // assert + expect(result, Right(testUser)); + verify(mockAuthRepository.verifyPhoneCode(testVerificationId, testSmsCode)); + verifyNoMoreInteractions(mockAuthRepository); + }); + + test('should return failure when verification fails', () async { + // arrange + final failure = AuthFailure('Invalid verification code'); + when(mockAuthRepository.verifyPhoneCode(any, any)) + .thenAnswer((_) async => Left(failure)); + + // act + final result = await usecase(testVerificationId, testSmsCode); + + // assert + expect(result, Left(failure)); + verify(mockAuthRepository.verifyPhoneCode(testVerificationId, testSmsCode)); + verifyNoMoreInteractions(mockAuthRepository); + }); + + test('should pass correct parameters to repository', () async { + // arrange + when(mockAuthRepository.verifyPhoneCode(any, any)) + .thenAnswer((_) async => Right(testUser)); + + // act + await usecase(testVerificationId, testSmsCode); + + // assert + verify(mockAuthRepository.verifyPhoneCode(testVerificationId, testSmsCode)); + }); + }); +} diff --git a/test/features/authentication/presentation/bloc/auth_event_test.dart b/test/features/authentication/presentation/bloc/auth_event_test.dart index e8aeda1..2ff3f58 100644 --- a/test/features/authentication/presentation/bloc/auth_event_test.dart +++ b/test/features/authentication/presentation/bloc/auth_event_test.dart @@ -23,6 +23,64 @@ void main() { expect(event.props, isEmpty); }); + test('AuthPhoneSignInRequested includes phoneNumber in props', () { + const phoneNumber = '+491234567890'; + final event = AuthPhoneSignInRequested(phoneNumber); + expect(event.phoneNumber, equals(phoneNumber)); + expect(event.props, equals([phoneNumber])); + }); + + test('two AuthPhoneSignInRequested with same phoneNumber are equal', () { + const phoneNumber = '+491234567890'; + final event1 = AuthPhoneSignInRequested(phoneNumber); + final event2 = AuthPhoneSignInRequested(phoneNumber); + expect(event1, equals(event2)); + }); + + test('AuthPhoneSignInRequested with different phoneNumbers are not equal', () { + final event1 = AuthPhoneSignInRequested('+491234567890'); + final event2 = AuthPhoneSignInRequested('+491234567891'); + expect(event1, isNot(equals(event2))); + }); + + test('AuthVerifyPhoneCode includes verificationId and smsCode in props', () { + const verificationId = 'verification123'; + const smsCode = '123456'; + final event = AuthVerifyPhoneCode( + verificationId: verificationId, + smsCode: smsCode, + ); + expect(event.verificationId, equals(verificationId)); + expect(event.smsCode, equals(smsCode)); + expect(event.props, equals([verificationId, smsCode])); + }); + + test('two AuthVerifyPhoneCode with same values are equal', () { + const verificationId = 'verification123'; + const smsCode = '123456'; + final event1 = AuthVerifyPhoneCode( + verificationId: verificationId, + smsCode: smsCode, + ); + final event2 = AuthVerifyPhoneCode( + verificationId: verificationId, + smsCode: smsCode, + ); + expect(event1, equals(event2)); + }); + + test('AuthVerifyPhoneCode with different values are not equal', () { + final event1 = AuthVerifyPhoneCode( + verificationId: 'verification123', + smsCode: '123456', + ); + final event2 = AuthVerifyPhoneCode( + verificationId: 'verification456', + smsCode: '654321', + ); + expect(event1, isNot(equals(event2))); + }); + test('AuthSignOutRequested has empty props', () { final event = AuthSignOutRequested(); expect(event.props, isEmpty); diff --git a/test/features/authentication/presentation/bloc/auth_state_test.dart b/test/features/authentication/presentation/bloc/auth_state_test.dart index 56f958a..c1cccc3 100644 --- a/test/features/authentication/presentation/bloc/auth_state_test.dart +++ b/test/features/authentication/presentation/bloc/auth_state_test.dart @@ -34,6 +34,44 @@ void main() { expect(state.props, isEmpty); }); + test('AuthPhoneCodeSent includes verificationId and phoneNumber in props', () { + const verificationId = 'verification123'; + const phoneNumber = '+491234567890'; + final state = AuthPhoneCodeSent( + verificationId: verificationId, + phoneNumber: phoneNumber, + ); + expect(state.verificationId, equals(verificationId)); + expect(state.phoneNumber, equals(phoneNumber)); + expect(state.props, equals([verificationId, phoneNumber])); + }); + + test('two AuthPhoneCodeSent with same values are equal', () { + const verificationId = 'verification123'; + const phoneNumber = '+491234567890'; + final state1 = AuthPhoneCodeSent( + verificationId: verificationId, + phoneNumber: phoneNumber, + ); + final state2 = AuthPhoneCodeSent( + verificationId: verificationId, + phoneNumber: phoneNumber, + ); + expect(state1, equals(state2)); + }); + + test('AuthPhoneCodeSent with different values are not equal', () { + final state1 = AuthPhoneCodeSent( + verificationId: 'verification123', + phoneNumber: '+491234567890', + ); + final state2 = AuthPhoneCodeSent( + verificationId: 'verification456', + phoneNumber: '+491234567891', + ); + expect(state1, isNot(equals(state2))); + }); + test('AuthError includes message in props', () { const message = 'Authentication failed'; final state = AuthError(message); From 353d31d0e3ab37b94068df7a6323767271374709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:47:18 +0000 Subject: [PATCH 9/9] docs: Add Copilot instructions and code quality agent guidelines Co-authored-by: tujii <8914318+tujii@users.noreply.github.com> --- .github/CONTRIBUTING.md | 274 +++++++++++++++++++++++++++ .github/agents/code-quality-agent.md | 179 +++++++++++++++++ .github/copilot-instructions.md | 147 ++++++++++++++ 3 files changed, 600 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/agents/code-quality-agent.md create mode 100644 .github/copilot-instructions.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..3d41363 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,274 @@ +# Contributing to AngryRaphi Flutter + +Thank you for your interest in contributing to AngryRaphi! This document provides guidelines and standards for contributing to the project. + +## Code Quality Standards + +This project maintains high code quality standards enforced through automated checks. All contributions must meet these standards: + +### Required Quality Metrics +- ✅ **Test Coverage**: ≥ 80% on new code +- ✅ **Code Duplication**: ≤ 3% on new code +- ✅ **All Tests Passing**: 100% pass rate +- ✅ **Code Analysis**: No errors or warnings +- ✅ **Security**: No vulnerabilities + +## Getting Started + +### Prerequisites +- Flutter SDK 3.27.x or higher +- Dart SDK 3.6.x or higher +- Git +- A code editor (VS Code, Android Studio, or IntelliJ IDEA recommended) + +### Setup +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com/YOUR_USERNAME/angry_raphi_flutter.git + cd angry_raphi_flutter + ``` +3. Install dependencies: + ```bash + flutter pub get + ``` +4. Run the app to verify setup: + ```bash + flutter run -d chrome # For web + ``` + +## Development Workflow + +### 1. Create a Feature Branch +```bash +git checkout -b feature/your-feature-name +``` + +### 2. Make Your Changes +- Follow the [Copilot Instructions](.github/copilot-instructions.md) +- Write clean, readable code +- Follow existing code patterns and architecture +- Add documentation for public APIs + +### 3. Write Tests +**This is mandatory!** All new code must have tests. + +```bash +# Create test file mirroring the source file structure +# Example: lib/features/auth/domain/usecases/login.dart +# test/features/auth/domain/usecases/login_test.dart + +# Run tests +flutter test + +# Check coverage +flutter test --coverage +``` + +### 4. Run Quality Checks +```bash +# Format code +dart format . + +# Analyze code +flutter analyze + +# Run all tests +flutter test +``` + +### 5. Commit Your Changes +```bash +git add . +git commit -m "feat: add user profile feature" +``` + +**Commit Message Format:** +- `feat:` New feature +- `fix:` Bug fix +- `docs:` Documentation changes +- `test:` Adding or updating tests +- `refactor:` Code refactoring +- `style:` Code style changes (formatting) +- `chore:` Build process or auxiliary tool changes + +### 6. Push and Create Pull Request +```bash +git push origin feature/your-feature-name +``` + +Then create a Pull Request on GitHub. + +## Pull Request Process + +### Before Creating PR +- [ ] All tests pass locally +- [ ] Code is formatted +- [ ] No analysis errors +- [ ] Test coverage ≥ 80% for new code +- [ ] Code duplication ≤ 3% for new code +- [ ] Documentation updated if needed + +### PR Description Template +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing +- [ ] Unit tests added/updated +- [ ] Test coverage ≥ 80% +- [ ] All tests passing + +## Checklist +- [ ] Code follows project style guidelines +- [ ] Self-review completed +- [ ] Documentation updated +- [ ] No new warnings introduced +``` + +### Review Process +1. Automated checks run on your PR +2. SonarQube analyzes code quality +3. If quality gates fail: + - Review SonarQube report + - Add missing tests + - Refactor duplicated code + - Fix issues and push updates +4. Once checks pass, request review from maintainers +5. Address review feedback +6. PR will be merged when approved + +## Architecture + +This project follows Clean Architecture principles: + +``` +lib/ +├── core/ # Shared utilities and base classes +├── features/ # Feature modules +│ └── feature_name/ +│ ├── data/ # Data sources, models, repositories +│ ├── domain/ # Entities, use cases, repository interfaces +│ └── presentation/ # UI, BLoC, widgets +``` + +### Key Patterns +- **BLoC**: State management pattern +- **Repository Pattern**: Data access abstraction +- **Use Cases**: Single responsibility business logic +- **Dependency Injection**: Injectable/GetIt + +## Testing Guidelines + +### Test Structure +```dart +void main() { + late ClassUnderTest sut; + late MockDependency mockDependency; + + setUp(() { + mockDependency = MockDependency(); + sut = ClassUnderTest(mockDependency); + }); + + group('method_name', () { + test('should succeed when conditions are met', () async { + // arrange + when(mockDependency.call(any)) + .thenAnswer((_) async => expected); + + // act + final result = await sut.method(); + + // assert + expect(result, expected); + verify(mockDependency.call(any)); + }); + + test('should fail when conditions are not met', () async { + // arrange + when(mockDependency.call(any)) + .thenThrow(Exception()); + + // act & assert + expect(() => sut.method(), throwsException); + }); + }); +} +``` + +### What to Test +- ✅ Use cases (business logic) +- ✅ BLoC events and states +- ✅ Data transformations (models) +- ✅ Validation logic +- ✅ Error handling +- ✅ Edge cases and boundary conditions + +### What Not to Test +- ❌ Third-party packages +- ❌ Flutter framework code +- ❌ UI widgets (unless complex logic) +- ❌ Simple getters/setters + +## Code Review Guidelines + +### For Contributors +- Keep PRs focused and small +- Respond to feedback promptly +- Be open to suggestions +- Test your changes thoroughly + +### For Reviewers +- Be constructive and respectful +- Explain the reasoning behind suggestions +- Approve when quality standards are met +- Consider blocking if critical issues exist + +## Quality Gate Failures + +If your PR fails quality gates: + +### Low Test Coverage +1. Check SonarQube report for uncovered lines +2. Add tests for uncovered code paths +3. Ensure both success and failure scenarios are tested +4. Push updates and wait for re-analysis + +### High Code Duplication +1. Identify duplicated code blocks in SonarQube +2. Extract common logic to utility functions +3. Create reusable components +4. Document if duplication is intentional (e.g., use case pattern) + +### Failed Tests +1. Review test failure output +2. Fix the issue or update tests if behavior changed +3. Run tests locally before pushing +4. Ensure tests are deterministic + +## Resources + +- [Copilot Instructions](.github/copilot-instructions.md) +- [Code Quality Agent](.github/agents/code-quality-agent.md) +- [Flutter Documentation](https://docs.flutter.dev/) +- [BLoC Documentation](https://bloclibrary.dev/) +- [SonarQube Dashboard](https://sonarcloud.io/dashboard?id=tujii_angry_raphi_flutter) + +## Questions? + +If you have questions: +1. Check existing documentation +2. Review similar code in the project +3. Ask in PR comments +4. Open a discussion on GitHub + +## License + +By contributing, you agree that your contributions will be licensed under the same license as the project. diff --git a/.github/agents/code-quality-agent.md b/.github/agents/code-quality-agent.md new file mode 100644 index 0000000..5808a97 --- /dev/null +++ b/.github/agents/code-quality-agent.md @@ -0,0 +1,179 @@ +# Code Quality Agent + +## Purpose +This agent ensures that all code changes meet the project's quality standards before being merged. + +## Quality Gates + +### 1. Test Coverage +- **Requirement**: ≥ 80% coverage on new code +- **Tool**: SonarQube Cloud +- **Action**: Block merge if coverage is below threshold + +### 2. Code Duplication +- **Requirement**: ≤ 3% duplication on new code +- **Tool**: SonarQube Cloud +- **Exception**: Clean Architecture use case patterns +- **Action**: Block merge if duplication exceeds threshold + +### 3. Code Analysis +- **Tool**: Flutter analyze +- **Action**: Block merge if analysis fails +- **Severity**: All errors and warnings must be resolved + +### 4. Unit Tests +- **Tool**: Flutter test +- **Action**: Block merge if any test fails +- **Coverage**: All new features must have corresponding tests + +## Automated Checks + +### On Pull Request +1. Run `flutter analyze` to check code quality +2. Run `flutter test` to execute all tests +3. Generate coverage report +4. Upload coverage to SonarQube +5. Check SonarQube quality gate status +6. Report results in PR comments + +### Quality Gate Criteria + +#### ✅ Pass Conditions +- Test coverage ≥ 80% on new code +- Code duplication ≤ 3% on new code +- All tests passing +- No critical code smells +- No security vulnerabilities +- No blocker issues + +#### ❌ Fail Conditions +- Test coverage < 80% on new code +- Code duplication > 3% on new code +- Any failing tests +- Critical security vulnerabilities +- Blocker issues present + +## Developer Workflow + +### Before Creating PR +1. Write unit tests for all new code +2. Run tests locally: `flutter test --coverage` +3. Check coverage report in `coverage/lcov.info` +4. Run analysis: `flutter analyze` +5. Fix any issues found +6. Commit and push changes + +### After PR Creation +1. Wait for CI/CD pipeline to complete +2. Review SonarQube analysis results +3. Address any quality gate failures: + - Add missing tests for uncovered code + - Refactor duplicated code + - Fix code smells and bugs +4. Push fixes and wait for re-analysis +5. Once quality gate passes, request review + +### Addressing Coverage Issues + +If test coverage is below 80%: + +1. **Identify uncovered code**: + - Check SonarQube report for specific files/lines + - Review `coverage/lcov.info` locally + +2. **Add missing tests**: + ```dart + // Example: Testing a new use case + test('should return success when operation completes', () async { + // arrange + when(mockRepo.method(any)).thenAnswer((_) async => Right(result)); + + // act + final result = await useCase(params); + + // assert + expect(result, Right(expectedResult)); + verify(mockRepo.method(params)); + }); + ``` + +3. **Test both scenarios**: + - Success cases + - Failure cases + - Edge cases + - Null safety + +### Addressing Duplication Issues + +If code duplication exceeds 3%: + +1. **Identify duplicated code**: + - Check SonarQube duplication report + - Look for similar code blocks + +2. **Refactor to remove duplication**: + - Extract common logic to utility functions + - Create base classes for shared behavior + - Use composition over inheritance + - Apply DRY (Don't Repeat Yourself) principle + +3. **Acceptable duplication**: + - Use case pattern in Clean Architecture + - Test setup/teardown code + - Similar data models with different purposes + +## SonarQube Configuration + +### Project Key +`tujii_angry_raphi_flutter` + +### Quality Profile +Flutter/Dart standard rules + +### Quality Gate +Custom gate with strict requirements: +- Coverage on New Code ≥ 80% +- Duplicated Lines on New Code ≤ 3% +- Maintainability Rating on New Code ≥ A +- Reliability Rating on New Code ≥ A +- Security Rating on New Code ≥ A + +## Monitoring + +### Metrics to Track +- Test coverage trend +- Code duplication trend +- Technical debt ratio +- Bug count +- Vulnerability count +- Code smell count + +### Reports Available +- Coverage report: `coverage/lcov.info` +- Test results: Console output +- SonarQube dashboard: [Link](https://sonarcloud.io/dashboard?id=tujii_angry_raphi_flutter) + +## Troubleshooting + +### Issue: Tests fail locally but pass in CI +- Ensure local environment matches CI (Flutter version, dependencies) +- Clear build cache: `flutter clean && flutter pub get` +- Check for platform-specific issues + +### Issue: Coverage report not generated +- Ensure tests run with `--coverage` flag +- Check that test files follow naming convention `*_test.dart` +- Verify coverage files are not gitignored + +### Issue: False positive duplication +- Review the duplicated code in SonarQube +- If it's acceptable pattern duplication, document in PR +- Consider if refactoring would improve maintainability + +## Support + +For questions or issues with code quality checks: +1. Review this documentation +2. Check SonarQube analysis details +3. Review existing tests for patterns +4. Ask in PR comments for guidance diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5e4632d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,147 @@ +# GitHub Copilot Instructions for AngryRaphi Flutter + +## Code Quality Standards + +### Test Coverage Requirements +- **Minimum test coverage**: 80% for all new code +- Always write unit tests for: + - New use cases + - New events and states in BLoC + - New business logic methods + - Data transformations + - Validation logic + +### Code Duplication +- **Maximum duplication**: 3% for new code +- Exceptions: + - Clean Architecture use case pattern (simple repository wrappers) + - Standard boilerplate code + - Similar test structure for consistency +- Avoid copy-pasting large code blocks +- Extract common logic into reusable functions/classes + +## Testing Guidelines + +### Unit Test Structure +Follow the existing test patterns in the repository: +- Use `flutter_test` package +- Use `mockito` for mocking dependencies +- Test structure: Arrange-Act-Assert (AAA) +- Group related tests with `group()` +- Test both success and failure scenarios +- Test edge cases and boundary conditions + +### Test File Locations +- Feature tests: `test/features//` +- Mirror the `lib/` directory structure +- Use `_test.dart` suffix for test files + +### Example Test Pattern +```dart +void main() { + late YourClass classUnderTest; + late MockDependency mockDependency; + + setUp(() { + mockDependency = MockDependency(); + classUnderTest = YourClass(mockDependency); + }); + + group('MethodName', () { + test('should return expected result on success', () async { + // arrange + when(mockDependency.method(any)) + .thenAnswer((_) async => expectedResult); + + // act + final result = await classUnderTest.method(input); + + // assert + expect(result, expectedResult); + verify(mockDependency.method(input)); + verifyNoMoreInteractions(mockDependency); + }); + + test('should return failure when dependency fails', () async { + // arrange + when(mockDependency.method(any)) + .thenThrow(Exception('error')); + + // act & assert + expect( + () => classUnderTest.method(input), + throwsException, + ); + }); + }); +} +``` + +## Architecture Guidelines + +### Clean Architecture Layers +1. **Presentation Layer** (UI, BLoC) +2. **Domain Layer** (Entities, Use Cases, Repository Interfaces) +3. **Data Layer** (Repository Implementations, Data Sources, Models) + +### Use Case Pattern +- One use case per operation +- Inject repository via constructor +- Return `Either` from dartz +- Keep use cases simple (single responsibility) + +### BLoC Pattern +- Events: User actions or system events +- States: UI states (Initial, Loading, Success, Error) +- Always test events and states for equality +- Test BLoC logic with `bloc_test` package when available + +## Before Committing + +### Checklist +- [ ] All new code has corresponding unit tests +- [ ] Test coverage is ≥ 80% for new code +- [ ] Code duplication is ≤ 3% for new code +- [ ] All tests pass (`flutter test`) +- [ ] Code analysis passes (`flutter analyze`) +- [ ] Code is formatted (`dart format`) +- [ ] No linting errors + +### Running Quality Checks Locally +```bash +# Run tests with coverage +flutter test --coverage + +# Analyze code +flutter analyze + +# Format code +dart format . + +# Check for unused dependencies +dart pub deps +``` + +## SonarQube Integration + +The repository uses SonarQube Cloud for code quality analysis. After pushing, check: +- Test coverage metrics +- Code duplication metrics +- Code smells and bugs +- Security vulnerabilities + +Address any quality gate failures before merging. + +## Common Mistakes to Avoid + +1. **Forgetting to add tests** - Always add tests with new functionality +2. **Not testing failure scenarios** - Test both happy and unhappy paths +3. **Copy-pasting code** - Extract common logic instead +4. **Not following existing patterns** - Match the style of existing code +5. **Skipping edge cases** - Test boundary conditions and null cases + +## Additional Resources + +- [Flutter Testing Documentation](https://docs.flutter.dev/testing) +- [BLoC Testing Guide](https://bloclibrary.dev/#/testing) +- [Clean Architecture in Flutter](https://resocoder.com/flutter-clean-architecture-tdd/)