From b694691fddf04325552829443d2902f1cd4b1156 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 9 Jan 2026 14:18:53 +0700 Subject: [PATCH 01/13] chore(ios): add Google Sign-In Info.plist config --- ios/Runner/Info.plist | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2f85a4c..41ca439 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,21 @@ + GIDClientID + 342615478088-590oga73rl6r8hoodfnfpfh6gj38kn2f.apps.googleusercontent.com + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.342615478088-590oga73rl6r8hoodfnfpfh6gj38kn2f + + + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName From bd7ecbfe504ad0c6205fd7b94128f2534d327f46 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 11 Jan 2026 15:45:54 +0700 Subject: [PATCH 02/13] feat(deps): add google_sign_in package to pubspec.yaml --- ios/Podfile.lock | 39 ++++++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 18 +++++++++++ pubspec.lock | 48 ++++++++++++++++++++++++++++ pubspec.yaml | 1 + 4 files changed, 106 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6d3a421..c3d65d1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1188,6 +1188,16 @@ PODS: - abseil/meta/type_traits - abseil/xcprivacy - abseil/xcprivacy (1.20240722.0) + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) - BoringSSL-GRPC (0.0.37): - BoringSSL-GRPC/Implementation (= 0.0.37) - BoringSSL-GRPC/Interface (= 0.0.37) @@ -1323,6 +1333,11 @@ PODS: - GoogleUtilities/Environment (~> 8.1) - GTMSessionFetcher/Core (< 6.0, >= 3.4) - Flutter (1.0.0) + - google_sign_in_ios (0.0.1): + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 9.0) + - GTMSessionFetcher (>= 3.4.0) - GoogleAdsOnDeviceConversion (3.2.0): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) @@ -1353,6 +1368,11 @@ PODS: - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) + - GoogleSignIn (9.1.0): + - AppAuth (~> 2.0) + - AppCheckCore (~> 11.0) + - GTMAppAuth (~> 5.0) + - GTMSessionFetcher/Core (~> 3.3) - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger @@ -1472,7 +1492,14 @@ PODS: - gRPC-Core/Privacy (= 1.69.0) - gRPC-Core/Interface (1.69.0) - gRPC-Core/Privacy (1.69.0) + - GTMAppAuth (5.0.0): + - AppAuth/Core (~> 2.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core - leveldb-library (1.22.6) - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) @@ -1502,6 +1529,7 @@ DEPENDENCIES: - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_storage (from `.symlinks/plugins/firebase_storage/ios`) - Flutter (from `Flutter`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -1509,6 +1537,8 @@ DEPENDENCIES: SPEC REPOS: trunk: - abseil + - AppAuth + - AppCheckCore - BoringSSL-GRPC - Firebase - FirebaseAnalytics @@ -1529,9 +1559,11 @@ SPEC REPOS: - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport + - GoogleSignIn - GoogleUtilities - "gRPC-C++" - gRPC-Core + - GTMAppAuth - GTMSessionFetcher - leveldb-library - nanopb @@ -1556,6 +1588,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_storage/ios" Flutter: :path: Flutter + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -1565,6 +1599,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: abseil: a05cc83bf02079535e17169a73c5be5ba47f714b + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f BoringSSL-GRPC: dded2a44897e45f28f08ae87a55ee4bcd19bc508 cloud_firestore: 45532e0c1fd1f6a5720af8aa0312470c366b73d2 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd @@ -1590,12 +1626,15 @@ SPEC CHECKSUMS: FirebaseSharedSwift: 79f27fff0addd15c3de19b87fba426f3cc2c964f FirebaseStorage: 550349b1e8f7315834ea08308696e9469d77135d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + google_sign_in_ios: 00dfa94252eb10278b64828c81bcb7158a81a53a GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 "gRPC-C++": cc207623316fb041a7a3e774c252cf68a058b9e8 gRPC-Core: 860978b7db482de8b4f5e10677216309b5ff6330 + GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 leveldb-library: cc8b8f8e013647a295ad3f8cd2ddf49a6f19be19 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fd06209..f93b7a6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3D0E01DF18316EDF773EAB0D /* [CP] Embed Pods Frameworks */, + 5180CEFDE13AEFAE9BBD9627 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -350,6 +351,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 5180CEFDE13AEFAE9BBD9627 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/pubspec.lock b/pubspec.lock index c9372de..3ddb105 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -549,6 +549,54 @@ packages: url: "https://pub.dev" source: hosted version: "17.0.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb" + url: "https://pub.dev" + source: hosted + version: "7.2.7" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: aee8bb29484611a37607849175a2009d423de6bd7c129326c69fd7068f0b6ed7 + url: "https://pub.dev" + source: hosted + version: "6.2.4" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" + url: "https://pub.dev" + source: hosted + version: "1.1.0" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1d83386..656b150 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: freezed_annotation: ^3.1.0 get_it: ^9.2.0 go_router: ^17.0.1 + google_sign_in: ^7.2.0 intl: ^0.20.2 json_annotation: ^4.9.0 logger: ^2.6.2 From ff3f787e1a3b9436e5247c2397bc97fa667fcfa6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:03:26 +0700 Subject: [PATCH 03/13] feat(auth): add signInWithGoogle method to AuthRepository --- lib/features/auth/domain/repositories/auth_repository.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index 19a274c..f1f7492 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -25,6 +25,13 @@ abstract interface class AuthRepository { /// - [Result.failure] with [AuthFailure] if sign out fails. Future> signOut(); + /// Signs in a user using Google authentication. + /// + /// Returns: + /// - [Result.success] with [User] if authentication succeeds. + /// - [Result.failure] with [AuthFailure] if authentication fails. + Future> signInWithGoogle(); + /// Stream of authentication state changes. /// /// Emits: From 48e2262ffc2aaae6d729dc81d942d55a0b781aae Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:17:47 +0700 Subject: [PATCH 04/13] feat(core): implement new AuthFailure classes for google auth integration, enhance l10n mapper and make it nullable --- .../failures/feature/auth/auth_failure.dart | 72 +++++++++++++++++++ .../mappers/auth_failure_l10n_mapper.dart | 8 ++- .../auth/presentation/pages/sign_in_page.dart | 16 +++-- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/lib/core/failures/feature/auth/auth_failure.dart b/lib/core/failures/feature/auth/auth_failure.dart index 2faa451..1a634ef 100644 --- a/lib/core/failures/feature/auth/auth_failure.dart +++ b/lib/core/failures/feature/auth/auth_failure.dart @@ -145,6 +145,78 @@ final class NetworkRequestFailedFailure extends AuthFailure { }) : super(''); } +/// Operation cancelled error. +final class OperationCancelledFailure extends AuthFailure { + @override + String get code => 'canceled'; + + /// Creates an instance of [OperationCancelledFailure]. + const OperationCancelledFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + +/// Operation interrupted error. +final class OperationInterruptedFailure extends AuthFailure { + @override + String get code => 'interrupted'; + + /// Creates an instance of [OperationInterruptedFailure]. + const OperationInterruptedFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + +/// Client configuration error. +final class ClientConfigurationFailure extends AuthFailure { + @override + String get code => 'clientConfigurationError'; + + /// Creates an instance of [ClientConfigurationFailure]. + const ClientConfigurationFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + +/// Provider configuration error. +final class ProviderConfigurationFailure extends AuthFailure { + @override + String get code => 'providerConfigurationError'; + + /// Creates an instance of [ProviderConfigurationFailure]. + const ProviderConfigurationFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + +/// UI unavailable error. +final class UIUnavailableFailure extends AuthFailure { + @override + String get code => 'uiUnavailable'; + + /// Creates an instance of [UIUnavailableFailure]. + const UIUnavailableFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + +/// Operation cancelled error. +final class UserMismatchFailure extends AuthFailure { + @override + String get code => 'userMismatch'; + + /// Creates an instance of [UserMismatchFailure]. + const UserMismatchFailure({ + super.stackTrace, + super.parentException, + }) : super(''); +} + /// Unknown authentication error. final class UnknownAuthFailure extends AuthFailure { @override diff --git a/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart b/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart index f1b8dea..8303edd 100644 --- a/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart +++ b/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart @@ -6,7 +6,7 @@ import '../../../../core/localization/generated/app_localizations.g.dart'; /// Extension to provide localized messages for [AuthFailure]s. extension AuthFailureL10n on AuthFailure { /// Returns a localized message for the given [AuthFailure]. - String toMessage(BuildContext context) { + String? toMessage(BuildContext context) { final l10n = AppLocalizations.of(context); return switch (this) { @@ -22,6 +22,12 @@ extension AuthFailureL10n on AuthFailure { TooManyRequestsFailure() => l10n.auth_error_tooManyRequests, NetworkRequestFailedFailure() => l10n.auth_error_networkRequestFailed, UnknownAuthFailure() => l10n.auth_error_unknown, + OperationCancelledFailure() => null, + OperationInterruptedFailure() => null, + ClientConfigurationFailure() => null, + ProviderConfigurationFailure() => null, + UIUnavailableFailure() => null, + UserMismatchFailure() => null, }; } } diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 879085f..04e84b8 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -42,13 +42,15 @@ class _SignInPageState extends State { operationInProgress: () => const Center( child: CircularProgressIndicator.adaptive(), ), - authError: (failure) => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Center( - child: Text(failure.toMessage(context)), - ), - ), - ), + authError: (failure) => failure.toMessage(context) == null + ? ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Center( + child: Text(failure.toMessage(context)!), + ), + ), + ) + : null, orElse: () => const SizedBox.shrink(), ); }, From 1879c3218d6cf1afd598f284e124fbb75d652ba8 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:18:00 +0700 Subject: [PATCH 05/13] feat(auth): implement mapper for converting exceptions from google auth to AuthFailure --- .../mappers/google_auth_failure_mapper.dart | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 lib/features/auth/data/mappers/google_auth_failure_mapper.dart diff --git a/lib/features/auth/data/mappers/google_auth_failure_mapper.dart b/lib/features/auth/data/mappers/google_auth_failure_mapper.dart new file mode 100644 index 0000000..64cf7b5 --- /dev/null +++ b/lib/features/auth/data/mappers/google_auth_failure_mapper.dart @@ -0,0 +1,45 @@ +import 'package:google_sign_in/google_sign_in.dart'; + +import '../../../../core/failures/feature/auth/auth_failure.dart'; + +/// Extension for converting [GoogleSignInException] to domain [AuthFailure]. +extension GoogleAuthFailureMapper on GoogleSignInException { + /// Converts [GoogleSignInException] to domain [AuthFailure]. + /// + /// Returns a specific [AuthFailure] subclass based on the Google Sign In error code, + /// or [UnknownAuthFailure] for unmapped error codes. + AuthFailure toAuthFailure([StackTrace? stackTrace]) { + return switch (code) { + GoogleSignInExceptionCode.canceled => OperationCancelledFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.interrupted => OperationInterruptedFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.clientConfigurationError => ClientConfigurationFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.providerConfigurationError => ProviderConfigurationFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.uiUnavailable => UIUnavailableFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.userMismatch => UserMismatchFailure( + parentException: this, + stackTrace: stackTrace, + ), + GoogleSignInExceptionCode.unknownError => UnknownAuthFailure( + 'unknownError', + originalMessage: description, + parentException: this, + stackTrace: stackTrace, + ), + }; + } +} From 2143b3a1d336ef11ff5ce81114059ae3a63852f6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:18:36 +0700 Subject: [PATCH 06/13] feat(auth): implement signInWithGoogle method in AuthRepositoryImpl --- .../repositories/auth_repository_impl.dart | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index de7d76d..cc9bb41 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:google_sign_in/google_sign_in.dart'; import '../../../../core/failures/feature/auth/auth_failure.dart'; import '../../../../core/result/result.dart'; @@ -6,6 +7,7 @@ import '../../../../core/utils/logger/app_logger.dart'; import '../../domain/entities/user.dart'; import '../../domain/repositories/auth_repository.dart'; import '../mappers/auth_failure_mapper.dart'; +import '../mappers/google_auth_failure_mapper.dart'; import '../mappers/user_to_entity_mapper.dart'; /// Implementation of [AuthRepository] using Firebase Authentication. @@ -16,8 +18,11 @@ class AuthRepositoryImpl implements AuthRepository { /// Firebase Authentication instance for performing auth operations. final fb.FirebaseAuth _auth; + /// Google Sign-In instance for handling Google authentication. + final GoogleSignIn _googleSignIn; + /// Creates an instance of [AuthRepositoryImpl]. - AuthRepositoryImpl(this._logger, this._auth); + AuthRepositoryImpl(this._logger, this._auth, this._googleSignIn); /// Validates the provided credentials before processing login or /// registration requests. @@ -124,6 +129,44 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future> signInWithGoogle() async { + _logger.d('SignInWithGoogle attempt'); + try { + final GoogleSignInAccount googleSignInAccount = await _googleSignIn.authenticate( + scopeHint: ['email'], + ); + + final authClient = _googleSignIn.authorizationClient; + final authorization = await authClient.authorizationForScopes(['email']); + + final googleAuthCredential = fb.GoogleAuthProvider.credential( + idToken: googleSignInAccount.authentication.idToken, + accessToken: authorization?.accessToken, + ); + + final credential = await _auth.signInWithCredential(googleAuthCredential); + + return _validateCredential(credential, 'sign in with Google'); + } on GoogleSignInException catch (e, s) { + _logger.i('SignInWithGoogle failed with Google error: ${e.code.name}', e, s); + return Result.failure(e.toAuthFailure(s)); + } on fb.FirebaseAuthException catch (e, s) { + _logger.i('SignInWithGoogle failed with Firebase error: ${e.code}', e, s); + return Result.failure(e.toAuthFailure(s)); + } catch (e, s) { + _logger.e('SignInWithGoogle failed with unexpected error', e, s); + return Result.failure( + UnknownAuthFailure( + 'unknown', + originalMessage: e.toString(), + parentException: e, + stackTrace: s, + ), + ); + } + } + @override Stream get authStateChanges { return _auth.authStateChanges().map( From 87adf2c219c86e4ac4c5242285f13732e5f3aafe Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:20:11 +0700 Subject: [PATCH 07/13] test(auth): cover signInWithGoogle method with tests --- .../auth_repository_impl_test.dart | 195 ++++++++++++++++-- 1 file changed, 174 insertions(+), 21 deletions(-) diff --git a/test/features/auth/data/repositories/auth_repository_impl_test.dart b/test/features/auth/data/repositories/auth_repository_impl_test.dart index 4af5c84..af7e099 100644 --- a/test/features/auth/data/repositories/auth_repository_impl_test.dart +++ b/test/features/auth/data/repositories/auth_repository_impl_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_starter_template/features/auth/data/repositories/auth_re import 'package:flutter_starter_template/features/auth/domain/entities/user.dart'; import 'package:flutter_starter_template/features/auth/domain/repositories/auth_repository.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -15,12 +16,22 @@ import 'auth_repository_impl_test.mocks.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), MockSpec(), MockSpec(), ]) void main() { late MockAppLogger mockLogger; late MockFirebaseAuth mockAuth; + late MockGoogleSignIn mockGoogleSignIn; + late MockGoogleSignInAccount mockGoogleSignInAccount; + late MockGoogleSignInAuthorizationClient mockGoogleSignInAuthClient; + late MockGoogleSignInClientAuthorization mockGoogleSignInClientAuth; + late MockGoogleSignInAuthentication mockGoogleSignInAuth; late MockUserCredential mockCredential; late MockUser mockUser; late AuthRepository repository; @@ -28,17 +39,22 @@ void main() { setUp(() { mockLogger = MockAppLogger(); mockAuth = MockFirebaseAuth(); + mockGoogleSignIn = MockGoogleSignIn(); + mockGoogleSignInAccount = MockGoogleSignInAccount(); + mockGoogleSignInAuthClient = MockGoogleSignInAuthorizationClient(); + mockGoogleSignInClientAuth = MockGoogleSignInClientAuthorization(); + mockGoogleSignInAuth = MockGoogleSignInAuthentication(); mockCredential = MockUserCredential(); mockUser = MockUser(); - repository = AuthRepositoryImpl(mockLogger, mockAuth); + repository = AuthRepositoryImpl(mockLogger, mockAuth, mockGoogleSignIn); }); group('AuthRepositoryImpl', () { const String uid = 'test_uid'; const String email = 'test@gmail.com'; const String password = 'test_password'; - const code = 'test_code'; - const message = 'test_message'; + + const firebaseErrorCode = 'invalid-email'; void arrangeSuccessfulCredential() { when(mockUser.uid).thenReturn(uid); @@ -117,10 +133,7 @@ void main() { password: password, ), ).thenThrow( - fb.FirebaseAuthException( - code: code, - message: message, - ), + fb.FirebaseAuthException(code: firebaseErrorCode), ); // Act @@ -134,8 +147,7 @@ void main() { expect(result.isFailure, true); expect(failure, isA()); - expect(failure.code, code); - expect(failure.message, message); + expect(failure.code, firebaseErrorCode); verify( mockAuth.signInWithEmailAndPassword( @@ -240,10 +252,7 @@ void main() { password: password, ), ).thenThrow( - fb.FirebaseAuthException( - code: code, - message: message, - ), + fb.FirebaseAuthException(code: firebaseErrorCode), ); // Act @@ -254,8 +263,7 @@ void main() { expect(result.isFailure, true); expect(failure, isA()); - expect(failure.code, code); - expect(failure.message, message); + expect(failure.code, firebaseErrorCode); verify( mockAuth.createUserWithEmailAndPassword( @@ -311,10 +319,7 @@ void main() { test('should return AuthFailure when FirebaseAuthException thrown', () async { // Arrange when(mockAuth.signOut()).thenThrow( - fb.FirebaseAuthException( - code: code, - message: message, - ), + fb.FirebaseAuthException(code: firebaseErrorCode), ); // Act @@ -325,8 +330,7 @@ void main() { expect(result.isFailure, true); expect(failure, isA()); - expect(failure.code, code); - expect(failure.message, message); + expect(failure.code, firebaseErrorCode); verify(mockAuth.signOut()).called(1); verifyNoMoreInteractions(mockAuth); @@ -349,6 +353,155 @@ void main() { }); }); + group('AuthRepositoryImpl.signInWithGoogle', () { + const idToken = 'test_id_token'; + const accessToken = 'test_access_token'; + const googleErrorCode = 'canceled'; + + void arrangeGoogleHappyPath(String idToken, String accessToken) { + when(mockGoogleSignIn.initialize()).thenAnswer((_) async {}); + when( + mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint')), + ).thenAnswer((_) async => mockGoogleSignInAccount); + when(mockGoogleSignIn.authorizationClient).thenReturn(mockGoogleSignInAuthClient); + + when( + mockGoogleSignInAuthClient.authorizationForScopes(any), + ).thenAnswer((_) async => mockGoogleSignInClientAuth); + + when(mockGoogleSignInAccount.authentication).thenReturn(mockGoogleSignInAuth); + when(mockGoogleSignInAuth.idToken).thenReturn(idToken); + when(mockGoogleSignInClientAuth.accessToken).thenReturn(accessToken); + } + + test('should return Success with User when valid credentials provided', () async { + // Arrange + arrangeSuccessfulCredential(); + arrangeGoogleHappyPath(idToken, accessToken); + + when( + mockAuth.signInWithCredential(any), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signInWithGoogle(); + + // Assert + expect(result.isSuccess, true, reason: result.failure?.toString()); + + final success = result.success!; + expect(success.uid, uid); + expect(success.email, email); + + verify(mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint'))).called(1); + verify(mockGoogleSignInAuthClient.authorizationForScopes(any)).called(1); + + verify(mockAuth.signInWithCredential(any)).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when credential.user is null', () async { + // Arrange + arrangeNullCredential(); + arrangeGoogleHappyPath(idToken, accessToken); + + when( + mockAuth.signInWithCredential(any), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signInWithGoogle(); + + // Assert + expect(result.isFailure, true); + + final failure = result.failure!; + expect(failure, isA()); + + verify(mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint'))).called(1); + verify(mockGoogleSignInAuthClient.authorizationForScopes(any)).called(1); + + verify(mockAuth.signInWithCredential(any)).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test( + 'should return OperationCancelledFailure when GoogleSignInException.canceled thrown', + () async { + // Arrange + when(mockGoogleSignIn.initialize()).thenAnswer((_) async {}); + when( + mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint')), + ).thenThrow( + const GoogleSignInException(code: GoogleSignInExceptionCode.canceled), + ); + + // Act + final result = await repository.signInWithGoogle(); + + // Assert + expect(result.isFailure, true); + + final failure = result.failure!; + expect(failure, isA()); + expect(failure.code, googleErrorCode); + + verify(mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint'))).called(1); + verifyNever(mockAuth.signInWithCredential(any)); + }, + ); + + test( + 'should return InvalidEmailFailure when FirebaseAuthException.invalid-email thrown', + () async { + // Arrange + arrangeGoogleHappyPath(idToken, accessToken); + + when( + mockAuth.signInWithCredential(any), + ).thenThrow( + fb.FirebaseAuthException(code: firebaseErrorCode), + ); + + // Act + final result = await repository.signInWithGoogle(); + + // Assert + expect(result.isFailure, true); + + final failure = result.failure!; + expect(failure, isA()); + expect(failure.code, firebaseErrorCode); + + verify(mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint'))).called(1); + verify(mockAuth.signInWithCredential(any)).called(1); + }, + ); + + test('should return UnknownAuthFailure when unexpected error thrown', () async { + // Arrange + when(mockGoogleSignIn.initialize()).thenAnswer((_) async {}); + when( + mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint')), + ).thenThrow(const Object()); + + when( + mockAuth.signInWithCredential(any), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signInWithGoogle(); + + // Assert + expect(result.isFailure, true); + + final failure = result.failure!; + expect(failure, isA()); + + verify(mockGoogleSignIn.authenticate(scopeHint: anyNamed('scopeHint'))).called(1); + }); + }); + group('AuthRepositoryImpl.authStateChanges', () { void arrangeStream() { when(mockUser.uid).thenReturn(uid); From 7693f502bb7bf1c94563a5628af18dd3bc1cdb76 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:21:42 +0700 Subject: [PATCH 08/13] feat(core): register dependencies for google auth in DI and make setupDI method async --- lib/core/di/di.dart | 19 ++++++++++++++++--- lib/runner.dart | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 5ad782e..229e96f 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -3,6 +3,7 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; +import 'package:google_sign_in/google_sign_in.dart'; import 'package:logger/logger.dart'; import '../../features/auth/data/repositories/auth_repository_impl.dart'; @@ -21,7 +22,7 @@ import '../utils/logger/logger_setup.dart'; final di = GetIt.instance; /// Initializes the application's dependencies using GetIt. -void setupDI() { +Future setupDI() async { // Logger di.registerLazySingleton(() => createLogger()); di.registerLazySingleton(() => AppLoggerImpl(di())); @@ -36,8 +37,18 @@ void setupDI() { // Authentication di.registerLazySingleton(() => FirebaseAuth.instance); - di.registerLazySingleton( - () => AuthRepositoryImpl(di(), di()), + di.registerSingletonAsync(() async { + final googleSignIn = GoogleSignIn.instance; + await googleSignIn.initialize(); + return googleSignIn; + }); + di.registerSingletonWithDependencies( + () => AuthRepositoryImpl( + di(), + di(), + di(), + ), + dependsOn: [GoogleSignIn], ); di.registerLazySingleton(() => AuthBloc(di(), di())); @@ -47,4 +58,6 @@ void setupDI() { () => NetworkServiceImpl(di()), dispose: (param) => param.dispose(), ); + + await di.allReady(); } diff --git a/lib/runner.dart b/lib/runner.dart index be468c6..f278566 100644 --- a/lib/runner.dart +++ b/lib/runner.dart @@ -24,7 +24,7 @@ Future run() async { await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true); // Setup dependency injection - setupDI(); + await setupDI(); final logger = di(); // Catch errors from Flutter framework From 6c72eaec5aa99b3ebe1a0608cd0da757421c37c0 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 13:53:13 +0700 Subject: [PATCH 09/13] fix(auth): fix snack bar error handling on sign-in page --- .../auth/presentation/pages/sign_in_page.dart | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 04e84b8..c2979b5 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -42,15 +42,13 @@ class _SignInPageState extends State { operationInProgress: () => const Center( child: CircularProgressIndicator.adaptive(), ), - authError: (failure) => failure.toMessage(context) == null - ? ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Center( - child: Text(failure.toMessage(context)!), - ), - ), - ) - : null, + authError: (failure) { + final message = failure.toMessage(context); + if (message == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Center(child: Text(message))), + ); + }, orElse: () => const SizedBox.shrink(), ); }, From 7990c5488bb94f5b18098fc605122f53dd4119ce Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 14:24:18 +0700 Subject: [PATCH 10/13] feat(auth): add google sign-in handler method to AuthBloc --- .../auth/presentation/bloc/auth_bloc.dart | 19 +++++++++++++++++++ .../auth/presentation/bloc/auth_event.dart | 3 +++ 2 files changed, 22 insertions(+) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index 010d8f9..7896b7d 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -26,6 +26,7 @@ class AuthBloc extends Bloc with AppLoggerMixin { on<_SignUpRequested>(_onSignUpRequested); on<_SignOutRequested>(_onSignOutRequested); on<_AuthStateChanged>(_onAuthStateChanged); + on<_SignInWithGoogleRequested>(_onSignInWithGoogleRequested); } final AppAnalytics _analytics; @@ -87,6 +88,24 @@ class AuthBloc extends Bloc with AppLoggerMixin { } } + Future _onSignInWithGoogleRequested( + _SignInWithGoogleRequested event, + Emitter emit, + ) async { + await _analytics.logEvent('signInWithGoogleRequested'); + emit(const AuthState.operationInProgress()); + + final result = await _repository.signInWithGoogle(); + + switch (result) { + case Success(): + await _analytics.logEvent('signInWithGoogleCompleted'); + return; + case Failure(:final error): + emit(AuthState.authError(error)); + } + } + @override Future close() async { await _authStateChanges?.cancel(); diff --git a/lib/features/auth/presentation/bloc/auth_event.dart b/lib/features/auth/presentation/bloc/auth_event.dart index ae1a029..4eb2ffc 100644 --- a/lib/features/auth/presentation/bloc/auth_event.dart +++ b/lib/features/auth/presentation/bloc/auth_event.dart @@ -12,6 +12,9 @@ class AuthEvent with _$AuthEvent { /// Event to request sign-up. const factory AuthEvent.signUpRequested(String email, String password) = _SignUpRequested; + /// Event to request google sign-in. + const factory AuthEvent.signInWithGoogleRequested() = _SignInWithGoogleRequested; + /// Event to request sign-out. const factory AuthEvent.signOutRequested() = _SignOutRequested; } From 068af4e827d5d6468210f2a51b90103d513e51e4 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 14:24:51 +0700 Subject: [PATCH 11/13] test(auth): cover sign-in handler method with tests --- .../presentation/bloc/auth_bloc_test.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/features/auth/presentation/bloc/auth_bloc_test.dart b/test/features/auth/presentation/bloc/auth_bloc_test.dart index e10f65b..6b4988a 100644 --- a/test/features/auth/presentation/bloc/auth_bloc_test.dart +++ b/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -210,5 +210,54 @@ void main() { }, ); }); + + group('AuthBloc._onSignInWithGoogleRequested', () { + blocTest( + 'emits authenticated when authStateChanges emits user on sign in with google', + build: () => authBloc, + setUp: () => when(mockRepository.signInWithGoogle()).thenAnswer( + (_) async => const Success(user), + ), + act: (bloc) async { + bloc.add(const AuthEvent.signInWithGoogleRequested()); + await pumpEventQueue(); + + authController.add(user); + await pumpEventQueue(); + }, + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authenticated(user), + ], + verify: (bloc) { + verify(mockRepository.signInWithGoogle()).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signInWithGoogleRequested')).called(1); + verify(mockAnalytics.logEvent('signInWithGoogleCompleted')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + + blocTest( + 'emits authError on sign in with google failure', + build: () => authBloc, + setUp: () => when(mockRepository.signInWithGoogle()).thenAnswer( + (_) async => const Failure(authFailure), + ), + act: (bloc) async => bloc.add(const AuthEvent.signInWithGoogleRequested()), + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authError(authFailure), + ], + verify: (bloc) { + verify(mockRepository.signInWithGoogle()).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signInWithGoogleRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + }); }); } From c00c65348788c322e1f7ab5680dfc6a77c699cea Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 14:25:16 +0700 Subject: [PATCH 12/13] feat(auth): add button for sign-in with google --- .../auth/presentation/pages/sign_in_page.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index c2979b5..48c9700 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -152,6 +152,23 @@ class _SignInPageState extends State { ), ), const SizedBox(height: 16), + Center( + child: ElevatedButton( + onPressed: () { + context.read().add( + const AuthEvent.signInWithGoogleRequested(), + ); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Text('Sign In with Google'), + ), + ), + const SizedBox(height: 16), Center( child: RichText( text: TextSpan( From 86d3d01f0ce6874198e9bf6276ed2a0aa1d238bc Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 12 Jan 2026 14:28:32 +0700 Subject: [PATCH 13/13] docs: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d5323..d4893a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `DebugScreen` sign out action for quick manual verification. - Added auth-aware routing with GoRouter redirect (`/auth/*` is public, other routes require authentication). - Added localized authentication failure messages (ARB + `AuthFailure` → l10n mapper). +- Added Google Sign-In authentication. ### Changed