From 245b6582937901063f9ab3493cea791f5137c52d Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:02:36 +0700 Subject: [PATCH 01/18] feat(auth): add email/password authentication flow with router redirect and localized errors (#5) * refactor(core): simplify AppFailure ctor and remove AsyncResult alias * feat(auth): add User entity and AuthRepository interface for authentication operations * feat(auth): add typed AuthFailure and Firebase mappers * feat(auth): implement AuthRepository using Firebase Authentication * test(auth): cover AuthRepositoryImpl * test(auth): enhance AuthRepositoryImpl tests with signOut and authStateChanges verifications * chore: add *.freezed.dart to .gitignore for Build Runner generated files * feat(auth): implement AuthBloc with authentication events and states * test(auth): cover AuthBloc * feat(auth): implement SignInPage and update routing for authentication * feat(debug): add Sign Out button to DebugScreen for testing AuthBloc * feat(auth): implement redirect logic and auth state listener in router * feat(auth): add localized error messages for authentication failures * docs(core): add descriptive comments for authentication failure classes * refactor(auth): simplify comments and documentation in authentication files and di * chore: remove empty .gitkeep files from unnecessary directories * chore: update Firebase dependencies to latest versions * fix(ci): add code generation step to CI/CD pipeline and Dockerfile * docs(core): update documentation for authentication failure classes * docs: update CHANGELOG.md --- .github/workflows/main.yml | 3 + .gitignore | 1 + CHANGELOG.md | 32 ++ Dockerfile | 4 + ios/Podfile.lock | 204 ++++---- lib/core/di/di.dart | 11 + lib/core/failures/app_failure.dart | 3 +- lib/core/failures/feature/.gitkeep | 0 .../failures/feature/auth/auth_failure.dart | 163 +++++++ lib/core/failures/unknown_failure.dart | 2 +- lib/core/localization/.gitkeep | 0 lib/core/result/async_result.dart | 5 - lib/core/router/router.dart | 47 +- lib/core/router/router_paths.dart | 12 +- lib/core/services/.gitkeep | 0 lib/features/app/app.dart | 13 +- lib/features/auth/data/mappers/.gitkeep | 0 .../data/mappers/auth_failure_mapper.dart | 65 +++ .../data/mappers/user_to_entity_mapper.dart | 16 + lib/features/auth/data/models/.gitkeep | 0 lib/features/auth/data/repositories/.gitkeep | 0 .../repositories/auth_repository_impl.dart | 141 ++++++ lib/features/auth/data/sources/.gitkeep | 0 lib/features/auth/domain/entities/.gitkeep | 0 lib/features/auth/domain/entities/user.dart | 27 ++ .../auth/domain/repositories/.gitkeep | 0 .../domain/repositories/auth_repository.dart | 34 ++ lib/features/auth/domain/usecases/.gitkeep | 0 .../auth/presentation/bloc/auth_bloc.dart | 95 ++++ .../auth/presentation/bloc/auth_event.dart | 17 + .../auth/presentation/bloc/auth_state.dart | 20 + .../mappers/auth_failure_l10n_mapper.dart | 27 ++ lib/features/auth/presentation/pages/.gitkeep | 0 .../auth/presentation/pages/sign_in_page.dart | 178 +++++++ .../debug/presentation/debug_screen.dart | 20 +- lib/l10n/app_en.arb | 14 +- .../auth_repository_impl_test.dart | 448 ++++++++++++++++++ .../presentation/bloc/auth_bloc_test.dart | 214 +++++++++ 38 files changed, 1695 insertions(+), 121 deletions(-) delete mode 100644 lib/core/failures/feature/.gitkeep create mode 100644 lib/core/failures/feature/auth/auth_failure.dart delete mode 100644 lib/core/localization/.gitkeep delete mode 100644 lib/core/result/async_result.dart delete mode 100644 lib/core/services/.gitkeep delete mode 100644 lib/features/auth/data/mappers/.gitkeep create mode 100644 lib/features/auth/data/mappers/auth_failure_mapper.dart create mode 100644 lib/features/auth/data/mappers/user_to_entity_mapper.dart delete mode 100644 lib/features/auth/data/models/.gitkeep delete mode 100644 lib/features/auth/data/repositories/.gitkeep create mode 100644 lib/features/auth/data/repositories/auth_repository_impl.dart delete mode 100644 lib/features/auth/data/sources/.gitkeep delete mode 100644 lib/features/auth/domain/entities/.gitkeep create mode 100644 lib/features/auth/domain/entities/user.dart delete mode 100644 lib/features/auth/domain/repositories/.gitkeep create mode 100644 lib/features/auth/domain/repositories/auth_repository.dart delete mode 100644 lib/features/auth/domain/usecases/.gitkeep create mode 100644 lib/features/auth/presentation/bloc/auth_bloc.dart create mode 100644 lib/features/auth/presentation/bloc/auth_event.dart create mode 100644 lib/features/auth/presentation/bloc/auth_state.dart create mode 100644 lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart delete mode 100644 lib/features/auth/presentation/pages/.gitkeep create mode 100644 lib/features/auth/presentation/pages/sign_in_page.dart create mode 100644 test/features/auth/data/repositories/auth_repository_impl_test.dart create mode 100644 test/features/auth/presentation/bloc/auth_bloc_test.dart diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9de6c81..f2c2344 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -187,6 +187,9 @@ jobs: - name: Get dependencies run: flutter pub get + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + - name: Install Pods run: | cd ios diff --git a/.gitignore b/.gitignore index c539165..26ebb32 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ unlinked_spec.ds # Build Runner generated *.g.dart +*.freezed.dart *.mocks.dart # Firebase related diff --git a/CHANGELOG.md b/CHANGELOG.md index 859871f..f0d5323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `User` entity for representing authenticated user data. +- Added `AuthRepository` interface defining contract for authentication operations. +- Added typed authentication failures (`AuthFailure` hierarchy, including `UnknownAuthFailure`). +- Added `AuthFailureMapper` to convert `FirebaseAuthException` to `AuthFailure`. +- Added `UserMapper` to convert Firebase `User` to domain `User`. +- Added `AuthRepositoryImpl` implementation using Firebase Authentication. +- Added unit tests for `AuthRepositoryImpl` (success, Firebase exceptions, unexpected errors, auth state stream). +- Added `AuthBloc` for authentication events/state handling. +- Added unit tests for `AuthBloc`. +- Added email/password auth UI (`SignInPage`) with sign in / sign up flow. +- 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). + +### Changed + +- Changed `AppFailure` constructor: `message` is now a positional parameter (API change). +- Updated `AppFailure` constructor to match new `AppFailure` signature. +- Removed `AsyncResult` type alias (deleted `async_result.dart`). +- Enhanced `AuthRepositoryImpl` tests: added sign out and `authStateChanges` verifications. +- Updated `.gitignore` to exclude generated `*.freezed.dart` files (Build Runner output). +- Updated auth route paths to use `/auth/*` prefix. +- Improved authentication failure documentation/comments. +- Removed unnecessary empty `.gitkeep` files. +- Updated Firebase dependencies to latest versions. + +### Fixed + +- Fixed CI failing due to missing generated Freezed files by running `build_runner` code generation in CI/Dockerfile. + ## [0.2.0] - 2025-12-20 ### Added diff --git a/Dockerfile b/Dockerfile index 4b784a6..5df9963 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,6 +132,10 @@ RUN flutter pub get COPY . . +# Run code generation +RUN dart run build_runner build --delete-conflicting-outputs + + # Build Android APK in release mode RUN flutter build apk --release --split-per-abi --no-tree-shake-icons diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 293e77e..6d3a421 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1194,97 +1194,97 @@ PODS: - BoringSSL-GRPC/Implementation (0.0.37): - BoringSSL-GRPC/Interface (= 0.0.37) - BoringSSL-GRPC/Interface (0.0.37) - - cloud_firestore (6.1.0): - - Firebase/Firestore (= 12.4.0) + - cloud_firestore (6.1.1): + - Firebase/Firestore (= 12.6.0) - firebase_core - Flutter - connectivity_plus (0.0.1): - Flutter - - Firebase/Auth (12.4.0): + - Firebase/Auth (12.6.0): - Firebase/CoreOnly - - FirebaseAuth (~> 12.4.0) - - Firebase/CoreOnly (12.4.0): - - FirebaseCore (~> 12.4.0) - - Firebase/Crashlytics (12.4.0): + - FirebaseAuth (~> 12.6.0) + - Firebase/CoreOnly (12.6.0): + - FirebaseCore (~> 12.6.0) + - Firebase/Crashlytics (12.6.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 12.4.0) - - Firebase/Firestore (12.4.0): + - FirebaseCrashlytics (~> 12.6.0) + - Firebase/Firestore (12.6.0): - Firebase/CoreOnly - - FirebaseFirestore (~> 12.4.0) - - Firebase/Storage (12.4.0): + - FirebaseFirestore (~> 12.6.0) + - Firebase/Storage (12.6.0): - Firebase/CoreOnly - - FirebaseStorage (~> 12.4.0) - - firebase_analytics (12.0.4): + - FirebaseStorage (~> 12.6.0) + - firebase_analytics (12.1.0): - firebase_core - - FirebaseAnalytics (= 12.4.0) + - FirebaseAnalytics (= 12.6.0) - Flutter - - firebase_auth (6.1.2): - - Firebase/Auth (= 12.4.0) + - firebase_auth (6.1.3): + - Firebase/Auth (= 12.6.0) - firebase_core - Flutter - - firebase_core (4.2.1): - - Firebase/CoreOnly (= 12.4.0) + - firebase_core (4.3.0): + - Firebase/CoreOnly (= 12.6.0) - Flutter - - firebase_crashlytics (5.0.5): - - Firebase/Crashlytics (= 12.4.0) + - firebase_crashlytics (5.0.6): + - Firebase/Crashlytics (= 12.6.0) - firebase_core - Flutter - - firebase_storage (13.0.4): - - Firebase/Storage (= 12.4.0) + - firebase_storage (13.0.5): + - Firebase/Storage (= 12.6.0) - firebase_core - Flutter - - FirebaseAnalytics (12.4.0): - - FirebaseAnalytics/Default (= 12.4.0) - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) + - FirebaseAnalytics (12.6.0): + - FirebaseAnalytics/Default (= 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) - - GoogleAppMeasurement/Default (= 12.4.0) + - FirebaseAnalytics/Default (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleAppMeasurement/Default (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAppCheckInterop (12.4.0) - - FirebaseAuth (12.4.0): - - FirebaseAppCheckInterop (~> 12.4.0) - - FirebaseAuthInterop (~> 12.4.0) - - FirebaseCore (~> 12.4.0) - - FirebaseCoreExtension (~> 12.4.0) + - FirebaseAppCheckInterop (12.6.0) + - FirebaseAuth (12.6.0): + - FirebaseAppCheckInterop (~> 12.6.0) + - FirebaseAuthInterop (~> 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) - GTMSessionFetcher/Core (< 6.0, >= 3.4) - RecaptchaInterop (~> 101.0) - - FirebaseAuthInterop (12.4.0) - - FirebaseCore (12.4.0): - - FirebaseCoreInternal (~> 12.4.0) + - FirebaseAuthInterop (12.6.0) + - FirebaseCore (12.6.0): + - FirebaseCoreInternal (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreExtension (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseCoreInternal (12.4.0): + - FirebaseCoreExtension (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreInternal (12.6.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseCrashlytics (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) - - FirebaseRemoteConfigInterop (~> 12.4.0) - - FirebaseSessions (~> 12.4.0) + - FirebaseCrashlytics (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - FirebaseRemoteConfigInterop (~> 12.6.0) + - FirebaseSessions (~> 12.6.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseFirestore (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseCoreExtension (~> 12.4.0) - - FirebaseFirestoreInternal (~> 12.4.0) - - FirebaseSharedSwift (~> 12.4.0) - - FirebaseFirestoreInternal (12.4.0): + - FirebaseFirestore (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) + - FirebaseFirestoreInternal (~> 12.6.0) + - FirebaseSharedSwift (~> 12.6.0) + - FirebaseFirestoreInternal (12.6.0): - abseil/algorithm (~> 1.20240722.0) - abseil/base (~> 1.20240722.0) - abseil/container/flat_hash_map (~> 1.20240722.0) @@ -1293,58 +1293,58 @@ PODS: - abseil/strings/strings (~> 1.20240722.0) - abseil/time (~> 1.20240722.0) - abseil/types (~> 1.20240722.0) - - FirebaseAppCheckInterop (~> 12.4.0) - - FirebaseCore (~> 12.4.0) + - FirebaseAppCheckInterop (~> 12.6.0) + - FirebaseCore (~> 12.6.0) - "gRPC-C++ (~> 1.69.0)" - gRPC-Core (~> 1.69.0) - leveldb-library (~> 1.22) - nanopb (~> 3.30910.0) - - FirebaseInstallations (12.4.0): - - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (12.6.0): + - FirebaseCore (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseRemoteConfigInterop (12.4.0) - - FirebaseSessions (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseCoreExtension (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) + - FirebaseRemoteConfigInterop (12.6.0) + - FirebaseSessions (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (12.4.0) - - FirebaseStorage (12.4.0): - - FirebaseAppCheckInterop (~> 12.4.0) - - FirebaseAuthInterop (~> 12.4.0) - - FirebaseCore (~> 12.4.0) - - FirebaseCoreExtension (~> 12.4.0) + - FirebaseSharedSwift (12.6.0) + - FirebaseStorage (12.6.0): + - FirebaseAppCheckInterop (~> 12.6.0) + - FirebaseAuthInterop (~> 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseCoreExtension (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GTMSessionFetcher/Core (< 6.0, >= 3.4) - Flutter (1.0.0) - - GoogleAdsOnDeviceConversion (3.1.0): + - GoogleAdsOnDeviceConversion (3.2.0): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.4.0): + - GoogleAppMeasurement/Core (12.6.0): - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.4.0): - - GoogleAdsOnDeviceConversion (~> 3.1.0) - - GoogleAppMeasurement/Core (= 12.4.0) - - GoogleAppMeasurement/IdentitySupport (= 12.4.0) + - GoogleAppMeasurement/Default (12.6.0): + - GoogleAdsOnDeviceConversion (~> 3.2.0) + - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.4.0): - - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleAppMeasurement/IdentitySupport (12.6.0): + - GoogleAppMeasurement/Core (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -1472,7 +1472,7 @@ PODS: - gRPC-Core/Privacy (= 1.69.0) - gRPC-Core/Interface (1.69.0) - gRPC-Core/Privacy (1.69.0) - - GTMSessionFetcher/Core (5.0.0) + - GTMSessionFetcher/Core (3.5.0) - leveldb-library (1.22.6) - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) @@ -1566,37 +1566,37 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: abseil: a05cc83bf02079535e17169a73c5be5ba47f714b BoringSSL-GRPC: dded2a44897e45f28f08ae87a55ee4bcd19bc508 - cloud_firestore: 7a6d8a533ec7418a7fe46b3a5dabf55661a5b298 + cloud_firestore: 45532e0c1fd1f6a5720af8aa0312470c366b73d2 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd - Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e - firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf - firebase_auth: 9225db04db5d8e3b46dc8940e04bc6aec6833e27 - firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 - firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081 - firebase_storage: d558bbfa99449fff39352db83c466c8515fa1afa - FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f - FirebaseAppCheckInterop: f734c802f21fe1da0837708f0f9a27218c8a4ed0 - FirebaseAuth: 4a2aed737c84114a9d9b33d11ae1b147d6b94889 - FirebaseAuthInterop: 858e6b754966e70740a4370dd1503dfffe6dbb49 - FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 - FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018 - FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 - FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395 - FirebaseFirestore: 2a6183381cf7679b1bb000eb76a8e3178e25dee2 - FirebaseFirestoreInternal: 6577a27cd5dc3722b900042527f86d4ea1626134 - FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 - FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766 - FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d - FirebaseSharedSwift: 93426a1de92f19e1199fac5295a4f8df16458daa - FirebaseStorage: 20d6b56fb8a40ebaa03d6a2889fe33dac64adb73 + Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 + firebase_analytics: 4f9cca09e65f6c2944a862c6dc86f6bed9fb769c + firebase_auth: f547a06ed4933a1da2b9ac8d8c64ae09d113c8e1 + firebase_core: ba00a168e719694f38960502ceb560285603d073 + firebase_crashlytics: 13f4b77e9ce2a84b1f8ea07f293db5b6213ce1cf + firebase_storage: 90cdab308fdc9e9aedcbe90d62b949cb66d91ce1 + FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 + FirebaseAppCheckInterop: e2178171b4145013c7c1a3cc464d1d446d3a1896 + FirebaseAuth: 613c463cb43545a7fd2cd99ade09b78ac472c544 + FirebaseAuthInterop: db06756ef028006d034b6004dc0c37c24f7828d4 + FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 + FirebaseCoreExtension: 032fd6f8509e591fda8cb76f6651f20d926b121f + FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e + FirebaseCrashlytics: 3d6248c50726ee7832aef0e53cb84c9e64d9fa7e + FirebaseFirestore: 51ce079b9ddcaa481644164eda649d362c2a6396 + FirebaseFirestoreInternal: 8b1d2b0a1b859b2ddbd63f448c416c5be7367405 + FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad + FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 + FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d + FirebaseSharedSwift: 79f27fff0addd15c3de19b87fba426f3cc2c964f + FirebaseStorage: 550349b1e8f7315834ea08308696e9469d77135d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 - GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d + GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f + GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 "gRPC-C++": cc207623316fb041a7a3e774c252cf68a058b9e8 gRPC-Core: 860978b7db482de8b4f5e10677216309b5ff6330 - GTMSessionFetcher: 02d6e866e90bc236f48a703a041dfe43e6221a29 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 leveldb-library: cc8b8f8e013647a295ad3f8cd2ddf49a6f19be19 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 44d6726..5ad782e 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -1,9 +1,13 @@ import 'package:connectivity_plus/connectivity_plus.dart'; 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:logger/logger.dart'; +import '../../features/auth/data/repositories/auth_repository_impl.dart'; +import '../../features/auth/domain/repositories/auth_repository.dart'; +import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../services/network/network_service.dart'; import '../services/network/network_service_impl.dart'; import '../utils/analytics/app_analytics.dart'; @@ -30,6 +34,13 @@ void setupDI() { : DebugAnalyticsImpl(di()), ); + // Authentication + di.registerLazySingleton(() => FirebaseAuth.instance); + di.registerLazySingleton( + () => AuthRepositoryImpl(di(), di()), + ); + di.registerLazySingleton(() => AuthBloc(di(), di())); + /// Connectivity di.registerLazySingleton(() => Connectivity()); di.registerLazySingleton( diff --git a/lib/core/failures/app_failure.dart b/lib/core/failures/app_failure.dart index 9e25e02..1c280b9 100644 --- a/lib/core/failures/app_failure.dart +++ b/lib/core/failures/app_failure.dart @@ -4,6 +4,7 @@ /// and provide meaningful context for application logic. abstract class AppFailure implements Exception { /// A message describing the failure. + /// It can be an empty string if localization occurs in the presentation layer. final String message; /// The original exception that caused this failure, if any. @@ -13,5 +14,5 @@ abstract class AppFailure implements Exception { final StackTrace? stackTrace; /// Creates an instance of [AppFailure]. - const AppFailure(this.parentException, this.message, this.stackTrace); + const AppFailure(this.message, {this.parentException, this.stackTrace}); } diff --git a/lib/core/failures/feature/.gitkeep b/lib/core/failures/feature/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/core/failures/feature/auth/auth_failure.dart b/lib/core/failures/feature/auth/auth_failure.dart new file mode 100644 index 0000000..2faa451 --- /dev/null +++ b/lib/core/failures/feature/auth/auth_failure.dart @@ -0,0 +1,163 @@ +import '../../app_failure.dart'; + +/// Auth application error. +sealed class AuthFailure extends AppFailure { + /// Error code. + abstract final String code; + + /// Creates an instance of [AuthFailure]. + const AuthFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Invalid credential error. +final class InvalidCredentialFailure extends AuthFailure { + @override + String get code => 'invalid-credential'; + + /// Creates an instance of [InvalidCredentialFailure]. + const InvalidCredentialFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Weak password error. +final class WeakPasswordFailure extends AuthFailure { + @override + String get code => 'weak-password'; + + /// Creates an instance of [WeakPasswordFailure]. + const WeakPasswordFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Wrong password error. +final class WrongPasswordFailure extends AuthFailure { + @override + String get code => 'wrong-password'; + + /// Creates an instance of [WrongPasswordFailure]. + const WrongPasswordFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Invalid email error. +final class InvalidEmailFailure extends AuthFailure { + @override + String get code => 'invalid-email'; + + /// Creates an instance of [InvalidEmailFailure]. + const InvalidEmailFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Email already in use error. +final class EmailAlreadyInUseFailure extends AuthFailure { + @override + String get code => 'email-already-in-use'; + + /// Creates an instance of [EmailAlreadyInUseFailure]. + const EmailAlreadyInUseFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// User disabled error. +final class UserDisabledFailure extends AuthFailure { + @override + String get code => 'user-disabled'; + + /// Creates an instance of [UserDisabledFailure]. + const UserDisabledFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// User token expired error. +final class UserTokenExpiredFailure extends AuthFailure { + @override + String get code => 'user-token-expired'; + + /// Creates an instance of [UserTokenExpiredFailure]. + const UserTokenExpiredFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// User not found error. +final class UserNotFoundFailure extends AuthFailure { + @override + String get code => 'user-not-found'; + + /// Creates an instance of [UserNotFoundFailure]. + const UserNotFoundFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Operation not allowed error. +final class OperationNotAllowed extends AuthFailure { + @override + String get code => 'operation-not-allowed'; + + /// Creates an instance of [OperationNotAllowed]. + const OperationNotAllowed({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Too many requests error. +final class TooManyRequestsFailure extends AuthFailure { + @override + String get code => 'too-many-requests'; + + /// Creates an instance of [TooManyRequestsFailure]. + const TooManyRequestsFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Network request failed error. +final class NetworkRequestFailedFailure extends AuthFailure { + @override + String get code => 'network-request-failed'; + + /// Creates an instance of [NetworkRequestFailedFailure]. + const NetworkRequestFailedFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// Unknown authentication error. +final class UnknownAuthFailure extends AuthFailure { + @override + final String code; + + /// Original error message. + final String? originalMessage; + + /// Creates an instance of [UnknownAuthFailure]. + const UnknownAuthFailure( + this.code, { + this.originalMessage, + super.parentException, + super.stackTrace, + }) : super(originalMessage ?? 'Unknown authentication error'); +} diff --git a/lib/core/failures/unknown_failure.dart b/lib/core/failures/unknown_failure.dart index c47ec52..2d122c2 100644 --- a/lib/core/failures/unknown_failure.dart +++ b/lib/core/failures/unknown_failure.dart @@ -3,5 +3,5 @@ import 'app_failure.dart'; /// Unknown application error. final class UnknownFailure extends AppFailure { /// Creates an instance of [UnknownFailure]. - const UnknownFailure(super.parentException, super.message, super.stackTrace); + const UnknownFailure({super.parentException, super.stackTrace}) : super(''); } diff --git a/lib/core/localization/.gitkeep b/lib/core/localization/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/core/result/async_result.dart b/lib/core/result/async_result.dart deleted file mode 100644 index b5a096b..0000000 --- a/lib/core/result/async_result.dart +++ /dev/null @@ -1,5 +0,0 @@ -import '../failures/app_failure.dart'; -import 'result.dart'; - -/// A type alias for an asynchronous [Result]. -typedef AsyncResult = Future>; diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index 4612b0c..8716ddb 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -1,22 +1,65 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import '../../features/auth/presentation/bloc/auth_bloc.dart'; +import '../../features/auth/presentation/pages/sign_in_page.dart'; import '../../features/debug/presentation/debug_screen.dart'; import '../di/di.dart'; import '../utils/analytics/app_analytics.dart'; import 'analytics_route_observer.dart'; import 'router_paths.dart'; +/// Determines the redirect path based on the current [authState] and [state]. +String? _redirect(AuthState authState, GoRouterState state) { + final isAuth = state.matchedLocation.startsWith(AppRoutePaths.authPrefix); + + return authState.maybeWhen( + authenticated: (user) { + if (isAuth) return AppRoutePaths.debugPath; + return null; + }, + unauthenticated: () { + if (isAuth) return null; + return AppRoutePaths.signInPath; + }, + orElse: () => null, + ); +} + /// The application's router using GoRouter. final router = GoRouter( - initialLocation: AppRoutePaths.debugPath, + initialLocation: AppRoutePaths.signInPath, observers: [ AnalyticsRouteObserver(di()), ], + redirect: (_, state) => _redirect(di().state, state), + refreshListenable: GoRouterAuthRefreshStream(di().stream), routes: [ - // Define your app's routes here GoRoute( path: AppRoutePaths.debugPath, builder: (context, state) => const DebugScreen(), ), + GoRoute( + path: AppRoutePaths.signInPath, + builder: (_, _) => const SignInPage(), + ), ], ); + +/// A [ChangeNotifier] that listens to a [Stream] and notifies listeners on new events. +class GoRouterAuthRefreshStream extends ChangeNotifier { + /// Creates a [GoRouterAuthRefreshStream] that listens to the provided [stream]. + GoRouterAuthRefreshStream(Stream stream) { + _subscription = stream.listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/lib/core/router/router_paths.dart b/lib/core/router/router_paths.dart index 8cec8e7..c0a4213 100644 --- a/lib/core/router/router_paths.dart +++ b/lib/core/router/router_paths.dart @@ -1,11 +1,11 @@ /// Contains all the route paths used for app navigation. -/// -/// Each path should be defined as a static constant string, for example: -/// ```dart -/// /// Description of the route. -/// static const String flowPath = 'flowPath'; -/// ``` abstract class AppRoutePaths { /// Route path for the debug screen. static const debugPath = '/debug'; + + /// Prefix for all authentication-related routes. + static const authPrefix = '/auth'; + + /// Route path for the sign-in page. + static const signInPath = '$authPrefix/sign-in'; } diff --git a/lib/core/services/.gitkeep b/lib/core/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/app/app.dart b/lib/features/app/app.dart index d721fa9..f7be1aa 100644 --- a/lib/features/app/app.dart +++ b/lib/features/app/app.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../core/di/di.dart'; +import '../../core/localization/generated/app_localizations.g.dart'; import '../../core/router/router.dart'; +import '../auth/presentation/bloc/auth_bloc.dart'; /// The main application widget. class FlutterStarterTemplate extends StatelessWidget { @@ -9,6 +13,13 @@ class FlutterStarterTemplate extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp.router(routerConfig: router); + return BlocProvider( + create: (context) => di(), + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); } } diff --git a/lib/features/auth/data/mappers/.gitkeep b/lib/features/auth/data/mappers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/data/mappers/auth_failure_mapper.dart b/lib/features/auth/data/mappers/auth_failure_mapper.dart new file mode 100644 index 0000000..da826cd --- /dev/null +++ b/lib/features/auth/data/mappers/auth_failure_mapper.dart @@ -0,0 +1,65 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +import '../../../../core/failures/feature/auth/auth_failure.dart'; + +/// Extension for converting [FirebaseAuthException] to domain [AuthFailure]. +extension AuthFailureMapper on FirebaseAuthException { + /// Converts [FirebaseAuthException] to domain [AuthFailure]. + /// + /// Returns a specific [AuthFailure] subclass based on the Firebase error code, + /// or [UnknownAuthFailure] for unmapped error codes. + AuthFailure toAuthFailure([StackTrace? stackTrace]) { + return switch (code) { + 'invalid-credential' => InvalidCredentialFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'weak-password' => WeakPasswordFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'wrong-password' => WrongPasswordFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'invalid-email' => InvalidEmailFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'email-already-in-use' => EmailAlreadyInUseFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'user-disabled' => UserDisabledFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'user-token-expired' => UserTokenExpiredFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'user-not-found' => UserNotFoundFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'operation-not-allowed' => OperationNotAllowed( + parentException: this, + stackTrace: stackTrace, + ), + 'too-many-requests' => TooManyRequestsFailure( + parentException: this, + stackTrace: stackTrace, + ), + 'network-request-failed' => NetworkRequestFailedFailure( + parentException: this, + stackTrace: stackTrace, + ), + _ => UnknownAuthFailure( + code, + originalMessage: message, + parentException: this, + stackTrace: stackTrace, + ), + }; + } +} diff --git a/lib/features/auth/data/mappers/user_to_entity_mapper.dart b/lib/features/auth/data/mappers/user_to_entity_mapper.dart new file mode 100644 index 0000000..1367cab --- /dev/null +++ b/lib/features/auth/data/mappers/user_to_entity_mapper.dart @@ -0,0 +1,16 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb; + +import '../../domain/entities/user.dart'; + +/// Extension for converting Firebase [fb.User] to domain [User] entity. +extension UserMapper on fb.User { + /// Converts Firebase User to application domain [User] entity. + User toEntity() { + return User( + uid: uid, + email: email!, + displayName: displayName, + photoUrl: photoURL, + ); + } +} diff --git a/lib/features/auth/data/models/.gitkeep b/lib/features/auth/data/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/data/repositories/.gitkeep b/lib/features/auth/data/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..de7d76d --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,141 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb; + +import '../../../../core/failures/feature/auth/auth_failure.dart'; +import '../../../../core/result/result.dart'; +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/user_to_entity_mapper.dart'; + +/// Implementation of [AuthRepository] using Firebase Authentication. +class AuthRepositoryImpl implements AuthRepository { + /// Logger for tracking authentication operations and errors. + final AppLogger _logger; + + /// Firebase Authentication instance for performing auth operations. + final fb.FirebaseAuth _auth; + + /// Creates an instance of [AuthRepositoryImpl]. + AuthRepositoryImpl(this._logger, this._auth); + + /// Validates the provided credentials before processing login or + /// registration requests. + /// + /// Returns: + /// - [Result.success] with a [User] object if credentials are valid + /// - [Result.failure] with an [AuthFailure] if validation fails + Result _validateCredential( + fb.UserCredential credential, + String operation, + ) { + if (credential.user == null) { + _logger.w('$operation succeeded but user is null', null, StackTrace.current); + return Result.failure( + UnknownAuthFailure( + 'unknown', + originalMessage: 'User is null after $operation', + stackTrace: StackTrace.current, + ), + ); + } + _logger.i('$operation successful for user: ${credential.user!.uid}'); + return Result.success(credential.user!.toEntity()); + } + + @override + Future> signInWithEmail( + String email, + String password, + ) async { + _logger.d('SignIn attempt for email: $email'); + try { + final credential = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + + return _validateCredential(credential, 'sign in'); + } on fb.FirebaseAuthException catch (e, s) { + _logger.i('SignIn failed with Firebase error: ${e.code}', e, s); + return Result.failure(e.toAuthFailure(s)); + } catch (e, s) { + _logger.e('SignIn failed with unexpected error', e, s); + return Result.failure( + UnknownAuthFailure( + 'unknown', + originalMessage: e.toString(), + parentException: e, + stackTrace: s, + ), + ); + } + } + + @override + Future> signUpWithEmail( + String email, + String password, + ) async { + _logger.d('SignUp attempt for email: $email'); + try { + final credential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + + return _validateCredential(credential, 'sign up'); + } on fb.FirebaseAuthException catch (e, s) { + _logger.i('SignUp failed with Firebase error: ${e.code}', e, s); + return Result.failure(e.toAuthFailure(s)); + } catch (e, s) { + _logger.e('SignUp failed with unexpected error', e, s); + return Result.failure( + UnknownAuthFailure( + 'unknown', + originalMessage: e.toString(), + parentException: e, + stackTrace: s, + ), + ); + } + } + + @override + Future> signOut() async { + _logger.d('SignOut attempt'); + try { + await _auth.signOut(); + _logger.i('SignOut successful'); + return const Result.success(null); + } on fb.FirebaseAuthException catch (e, s) { + _logger.i('SignOut failed with Firebase error: ${e.code}', e, s); + return Result.failure(e.toAuthFailure(s)); + } catch (e, s) { + _logger.e('SignOut 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( + (fb.User? firebaseUser) { + if (firebaseUser != null) { + _logger.d('Auth state changed: user logged in (${firebaseUser.uid})'); + return firebaseUser.toEntity(); + } else { + _logger.d('Auth state changed: user logged out'); + return null; + } + }, + ); + } +} diff --git a/lib/features/auth/data/sources/.gitkeep b/lib/features/auth/data/sources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/domain/entities/.gitkeep b/lib/features/auth/domain/entities/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/domain/entities/user.dart b/lib/features/auth/domain/entities/user.dart new file mode 100644 index 0000000..a532102 --- /dev/null +++ b/lib/features/auth/domain/entities/user.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Domain entity representing an authenticated user. +class User extends Equatable { + /// Unique identifier of the user. + final String uid; + + /// Email address. + final String email; + + /// Display name (optional). + final String? displayName; + + /// URL of the user's profile photo (optional). + final String? photoUrl; + + /// Creates an instance of [User]. + const User({ + required this.uid, + required this.email, + this.displayName, + this.photoUrl, + }); + + @override + List get props => [uid, email, displayName, photoUrl]; +} diff --git a/lib/features/auth/domain/repositories/.gitkeep b/lib/features/auth/domain/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..19a274c --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,34 @@ +import '../../../../core/failures/feature/auth/auth_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/user.dart'; + +/// Repository interface for authentication operations. +abstract interface class AuthRepository { + /// Signs in a user with email and password. + /// + /// Returns: + /// - [Result.success] with [User] if authentication succeeds. + /// - [Result.failure] with [AuthFailure] if authentication fails. + Future> signInWithEmail(String email, String password); + + /// Creates a new user account with email and password. + /// + /// Returns: + /// - [Result.success] with [User] if account creation succeeds. + /// - [Result.failure] with [AuthFailure] if creation fails. + Future> signUpWithEmail(String email, String password); + + /// Signs out the currently authenticated user. + /// + /// Returns: + /// - [Result.success] with `null` if sign out succeeds. + /// - [Result.failure] with [AuthFailure] if sign out fails. + Future> signOut(); + + /// Stream of authentication state changes. + /// + /// Emits: + /// - [User] when a user is signed in + /// - `null` when no user is signed in + Stream get authStateChanges; +} diff --git a/lib/features/auth/domain/usecases/.gitkeep b/lib/features/auth/domain/usecases/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..010d8f9 --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/auth/auth_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/analytics/app_analytics.dart'; +import '../../../../core/utils/logger/logger_mixin.dart'; +import '../../domain/entities/user.dart'; +import '../../domain/repositories/auth_repository.dart'; + +part 'auth_bloc.freezed.dart'; +part 'auth_event.dart'; +part 'auth_state.dart'; + +/// Bloc for managing authentication state. +class AuthBloc extends Bloc with AppLoggerMixin { + /// Creates an instance of [AuthBloc]. + AuthBloc(this._analytics, this._repository) : super(const AuthState.initial()) { + _authStateChanges = _repository.authStateChanges.listen( + (user) => add(AuthEvent.authStateChanged(user)), + ); + + on<_SignInRequested>(_onSignInRequested); + on<_SignUpRequested>(_onSignUpRequested); + on<_SignOutRequested>(_onSignOutRequested); + on<_AuthStateChanged>(_onAuthStateChanged); + } + + final AppAnalytics _analytics; + final AuthRepository _repository; + + /// Subscription to authentication state changes. + StreamSubscription? _authStateChanges; + + /// Handles authentication state changes. + void _onAuthStateChanged(_AuthStateChanged event, Emitter emit) { + if (event.user != null) { + emit(AuthState.authenticated(event.user!)); + } else { + emit(const AuthState.unauthenticated()); + } + } + + /// Handles sign-in requests. + Future _onSignInRequested(_SignInRequested event, Emitter emit) async { + await _analytics.logEvent('signInRequested'); + emit(const AuthState.operationInProgress()); + + final result = await _repository.signInWithEmail(event.email, event.password); + + switch (result) { + case Success(): + return; + case Failure(:final error): + emit(AuthState.authError(error)); + } + } + + /// Handles sign-up requests. + Future _onSignUpRequested(_SignUpRequested event, Emitter emit) async { + await _analytics.logEvent('signUpRequested'); + emit(const AuthState.operationInProgress()); + + final result = await _repository.signUpWithEmail(event.email, event.password); + + switch (result) { + case Success(): + return; + case Failure(:final error): + emit(AuthState.authError(error)); + } + } + + /// Handles sign-out requests. + Future _onSignOutRequested(_SignOutRequested event, Emitter emit) async { + await _analytics.logEvent('signOutRequested'); + + final result = await _repository.signOut(); + + switch (result) { + case Success(): + return; + case Failure(:final error): + emit(AuthState.authError(error)); + } + } + + @override + Future close() async { + await _authStateChanges?.cancel(); + return super.close(); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_event.dart b/lib/features/auth/presentation/bloc/auth_event.dart new file mode 100644 index 0000000..ae1a029 --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_event.dart @@ -0,0 +1,17 @@ +part of 'auth_bloc.dart'; + +/// Event class for authentication actions. +@freezed +class AuthEvent with _$AuthEvent { + /// Event to indicate a change in authentication state. + const factory AuthEvent.authStateChanged(User? user) = _AuthStateChanged; + + /// Event to request sign-in. + const factory AuthEvent.signInRequested(String email, String password) = _SignInRequested; + + /// Event to request sign-up. + const factory AuthEvent.signUpRequested(String email, String password) = _SignUpRequested; + + /// Event to request sign-out. + const factory AuthEvent.signOutRequested() = _SignOutRequested; +} diff --git a/lib/features/auth/presentation/bloc/auth_state.dart b/lib/features/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..e1be431 --- /dev/null +++ b/lib/features/auth/presentation/bloc/auth_state.dart @@ -0,0 +1,20 @@ +part of 'auth_bloc.dart'; + +/// State class for authentication status. +@freezed +class AuthState with _$AuthState { + /// Initial state of authentication. + const factory AuthState.initial() = _Initial; + + /// State indicating that authentication is in progress. + const factory AuthState.operationInProgress() = _AuthInProgress; + + /// State indicating that the user is authenticated. + const factory AuthState.authenticated(User user) = _Authenticated; + + /// State indicating that the user is unauthenticated. + const factory AuthState.unauthenticated() = _Unauthenticated; + + /// State indicating an authentication error. + const factory AuthState.authError(AuthFailure failure) = _AuthError; +} diff --git a/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart b/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart new file mode 100644 index 0000000..f1b8dea --- /dev/null +++ b/lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../core/failures/feature/auth/auth_failure.dart'; +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) { + final l10n = AppLocalizations.of(context); + + return switch (this) { + InvalidCredentialFailure() => l10n.auth_error_invalidCredential, + WeakPasswordFailure() => l10n.auth_error_weakPassword, + WrongPasswordFailure() => l10n.auth_error_wrongPassword, + InvalidEmailFailure() => l10n.auth_error_invalidEmail, + EmailAlreadyInUseFailure() => l10n.auth_error_emailAlreadyInUse, + UserDisabledFailure() => l10n.auth_error_userDisabled, + UserTokenExpiredFailure() => l10n.auth_error_userTokenExpired, + UserNotFoundFailure() => l10n.auth_error_userNotFound, + OperationNotAllowed() => l10n.auth_error_operationNotAllowed, + TooManyRequestsFailure() => l10n.auth_error_tooManyRequests, + NetworkRequestFailedFailure() => l10n.auth_error_networkRequestFailed, + UnknownAuthFailure() => l10n.auth_error_unknown, + }; + } +} diff --git a/lib/features/auth/presentation/pages/.gitkeep b/lib/features/auth/presentation/pages/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart new file mode 100644 index 0000000..879085f --- /dev/null +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -0,0 +1,178 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/auth_bloc.dart'; +import '../mappers/auth_failure_l10n_mapper.dart'; + +/// A page that allows users to sign in or sign up. +class SignInPage extends StatefulWidget { + /// Creates an instance of [SignInPage]. + const SignInPage({super.key}); + + @override + State createState() => _SignInPageState(); +} + +class _SignInPageState extends State { + final _formKey = GlobalKey(); + + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _isPasswordVisible = false; + bool _isSignIn = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: BlocConsumer( + listener: (context, state) { + state.maybeWhen( + operationInProgress: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + authError: (failure) => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Center( + child: Text(failure.toMessage(context)), + ), + ), + ), + orElse: () => const SizedBox.shrink(), + ); + }, + builder: (_, state) { + return Column( + mainAxisAlignment: .center, + children: [ + Form( + key: _formKey, + child: Column( + crossAxisAlignment: .start, + children: [ + const Text('Email'), + const SizedBox(height: 8), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + autofocus: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Write your email'; + } + // if (!value.contains(RegExp(r''))) { + // return 'Incorrect email format'; + // } + return null; + }, + decoration: InputDecoration( + filled: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(16), + ), + hintText: 'aaa@gmail.com', + ), + ), + const SizedBox(height: 16), + const Text('Password'), + const SizedBox(height: 8), + TextFormField( + controller: _passwordController, + keyboardType: TextInputType.visiblePassword, + obscureText: !_isPasswordVisible, + autocorrect: false, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Write your password'; + } + // if (!value.contains(RegExp(r''))) { + // return 'Incorrect password format'; + // } + return null; + }, + decoration: InputDecoration( + filled: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(16), + ), + hintText: '•' * 8, + suffixIcon: IconButton( + onPressed: () { + setState(() { + debugPrint(_isPasswordVisible.toString()); + _isPasswordVisible = !_isPasswordVisible; + debugPrint(_isPasswordVisible.toString()); + }); + }, + icon: _isPasswordVisible + ? const Icon(Icons.visibility_rounded) + : const Icon(Icons.visibility_off_rounded), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + context.read().add( + _isSignIn + ? AuthEvent.signInRequested( + _emailController.text.trim(), + _passwordController.text.trim(), + ) + : AuthEvent.signUpRequested( + _emailController.text.trim(), + _passwordController.text.trim(), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isSignIn ? const Text('Sign In') : const Text('Sign Up'), + ), + ), + const SizedBox(height: 16), + Center( + child: RichText( + text: TextSpan( + text: _isSignIn ? 'Sign Up' : 'Sign In', + style: const TextStyle(color: Colors.black), + recognizer: TapGestureRecognizer() + ..onTap = () => setState(() { + _isSignIn = !_isSignIn; + }), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/features/debug/presentation/debug_screen.dart b/lib/features/debug/presentation/debug_screen.dart index f3276e4..38e0e6a 100644 --- a/lib/features/debug/presentation/debug_screen.dart +++ b/lib/features/debug/presentation/debug_screen.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../auth/presentation/bloc/auth_bloc.dart'; /// A screen for debugging purposes. class DebugScreen extends StatelessWidget { @@ -7,9 +10,22 @@ class DebugScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( body: Center( - child: Text('Flutter Starter Template'), + child: ElevatedButton( + onPressed: () { + context.read().add( + const AuthEvent.signOutRequested(), + ); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Text('Sign Out'), + ), ), ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9dae4a2..0bd8dd6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,3 +1,15 @@ { - "@@locale": "en" + "@@locale": "en", + "auth_error_invalidCredential": "Incorrect email or password", + "auth_error_weakPassword": "Password is too weak", + "auth_error_wrongPassword": "Incorrect email or password", + "auth_error_invalidEmail": "Enter a valid email address", + "auth_error_emailAlreadyInUse": "Unable to create an account with this email", + "auth_error_userDisabled": "Unable to sign in. Check your details and try again", + "auth_error_userTokenExpired": "Your session has expired. Please sign in again", + "auth_error_userNotFound": "Incorrect email or password", + "auth_error_operationNotAllowed": "This sign-in method is currently unavailable", + "auth_error_tooManyRequests": "Too many attempts. Try again later", + "auth_error_networkRequestFailed": "Network error. Check your connection and try again", + "auth_error_unknown": "Something went wrong. Try again" } \ No newline at end of file diff --git a/test/features/auth/data/repositories/auth_repository_impl_test.dart b/test/features/auth/data/repositories/auth_repository_impl_test.dart new file mode 100644 index 0000000..4af5c84 --- /dev/null +++ b/test/features/auth/data/repositories/auth_repository_impl_test.dart @@ -0,0 +1,448 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:flutter_starter_template/core/failures/feature/auth/auth_failure.dart'; +import 'package:flutter_starter_template/core/utils/logger/app_logger.dart'; +import 'package:flutter_starter_template/features/auth/data/repositories/auth_repository_impl.dart'; +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:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'auth_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger mockLogger; + late MockFirebaseAuth mockAuth; + late MockUserCredential mockCredential; + late MockUser mockUser; + late AuthRepository repository; + + setUp(() { + mockLogger = MockAppLogger(); + mockAuth = MockFirebaseAuth(); + mockCredential = MockUserCredential(); + mockUser = MockUser(); + repository = AuthRepositoryImpl(mockLogger, mockAuth); + }); + + 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'; + + void arrangeSuccessfulCredential() { + when(mockUser.uid).thenReturn(uid); + when(mockUser.email).thenReturn(email); + when(mockCredential.user).thenReturn(mockUser); + } + + void arrangeNullCredential() { + when(mockCredential.user).thenReturn(null); + } + + group('AuthRepositoryImpl.signInWithEmail', () { + test('should return Success with User when valid credentials provided', () async { + // Arrange + arrangeSuccessfulCredential(); + + when( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signInWithEmail(email, password); + final user = result.success!; + + // Assert + expect(result.isSuccess, true); + + expect(user.uid, uid); + expect(user.email, email); + + verify( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when credential.user is null', () async { + // Arrange + arrangeNullCredential(); + + when( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signInWithEmail(email, password); + final failure = result.failure; + + // Assert + expect(result.isFailure, true); + expect(failure, isA()); + + verify( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return AuthFailure when FirebaseAuthException thrown', () async { + // Arrange + when( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).thenThrow( + fb.FirebaseAuthException( + code: code, + message: message, + ), + ); + + // Act + final result = await repository.signInWithEmail( + email, + password, + ); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + + expect(failure, isA()); + expect(failure.code, code); + expect(failure.message, message); + + verify( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when unexpected error thrown', () async { + // Arrange + when( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).thenThrow(Object()); + + // Act + final result = await repository.signInWithEmail(email, password); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + expect(failure, isA()); + + verify( + mockAuth.signInWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + }); + + group('AuthRepositoryImpl.signUpWithEmail', () { + test('should return Success with User when valid credentials provided', () async { + // Arrange + arrangeSuccessfulCredential(); + + when( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signUpWithEmail(email, password); + final user = result.success!; + + // Assert + expect(result.isSuccess, true); + + expect(user.uid, uid); + expect(user.email, email); + + verify( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when credential.user is null', () async { + // Arrange + arrangeNullCredential(); + + when( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).thenAnswer((_) async => mockCredential); + + // Act + final result = await repository.signUpWithEmail(email, password); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + expect(failure, isA()); + + verify( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return AuthFailure when FirebaseAuthException thrown', () async { + // Arrange + when( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).thenThrow( + fb.FirebaseAuthException( + code: code, + message: message, + ), + ); + + // Act + final result = await repository.signUpWithEmail(email, password); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + + expect(failure, isA()); + expect(failure.code, code); + expect(failure.message, message); + + verify( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when unexpected error thrown', () async { + // Arrange + when( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).thenThrow(Object()); + + // Act + final result = await repository.signUpWithEmail(email, password); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + expect(failure, isA()); + + verify( + mockAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ), + ).called(1); + verifyNoMoreInteractions(mockAuth); + }); + }); + + group('AuthRepositoryImpl.signOut', () { + test('should return Success when sign out succeeds', () async { + // Arrange + when(mockAuth.signOut()).thenAnswer((_) async {}); + + // Act + final result = await repository.signOut(); + + // Assert + expect(result.isSuccess, true); + + verify(mockAuth.signOut()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return AuthFailure when FirebaseAuthException thrown', () async { + // Arrange + when(mockAuth.signOut()).thenThrow( + fb.FirebaseAuthException( + code: code, + message: message, + ), + ); + + // Act + final result = await repository.signOut(); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + + expect(failure, isA()); + expect(failure.code, code); + expect(failure.message, message); + + verify(mockAuth.signOut()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should return UnknownAuthFailure when unexpected error thrown', () async { + // Arrange + when(mockAuth.signOut()).thenThrow(Object()); + + // Act + final result = await repository.signOut(); + final failure = result.failure!; + + // Assert + expect(result.isFailure, true); + expect(failure, isA()); + + verify(mockAuth.signOut()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + }); + + group('AuthRepositoryImpl.authStateChanges', () { + void arrangeStream() { + when(mockUser.uid).thenReturn(uid); + when(mockUser.email).thenReturn(email); + when(mockCredential.user).thenReturn(mockUser); + } + + test('should emit User when Firebase user is not null', () async { + // Arrange + arrangeStream(); + + final controller = StreamController.broadcast(); + addTearDown(() async => await controller.close()); + + when(mockAuth.authStateChanges()).thenAnswer((_) => controller.stream); + + final users = []; + + final sub = repository.authStateChanges.listen(users.add); + addTearDown(() async => await sub.cancel()); + + // Act + controller.add(mockUser); + await pumpEventQueue(); + + // Assert + final user = users.first!; + expect(user.uid, uid); + expect(user.email, email); + + verify(mockAuth.authStateChanges()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should emit null when Firebase user is null', () async { + // Arrange + final controller = StreamController.broadcast(); + addTearDown(() async => controller.close()); + + when(mockAuth.authStateChanges()).thenAnswer((_) => controller.stream); + + final users = []; + + final sub = repository.authStateChanges.listen(users.add); + addTearDown(() async => sub.cancel()); + + // Act + controller.add(null); + await pumpEventQueue(); + + // Assert + final user = users.first; + expect(user, null); + + verify(mockAuth.authStateChanges()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + + test('should emit sequence of auth state changes', () async { + // Arrange + arrangeStream(); + + final controller = StreamController.broadcast(); + addTearDown(() async => controller.close()); + + when(mockAuth.authStateChanges()).thenAnswer((_) => controller.stream); + + final users = []; + + final sub = repository.authStateChanges.listen(users.add); + addTearDown(() async => sub.cancel()); + + // Act + controller.add(null); + await pumpEventQueue(); + + controller.add(mockUser); + await pumpEventQueue(); + + controller.add(null); + await pumpEventQueue(); + + // Assert + expect(users.first, null); + + final user = users.singleWhere((user) => user != null)!; + expect(user.uid, uid); + expect(user.email, email); + + expect(users.last, null); + + verify(mockAuth.authStateChanges()).called(1); + verifyNoMoreInteractions(mockAuth); + }); + }); + }); +} diff --git a/test/features/auth/presentation/bloc/auth_bloc_test.dart b/test/features/auth/presentation/bloc/auth_bloc_test.dart new file mode 100644 index 0000000..e10f65b --- /dev/null +++ b/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_starter_template/core/failures/feature/auth/auth_failure.dart'; +import 'package:flutter_starter_template/core/result/result.dart'; +import 'package:flutter_starter_template/core/utils/analytics/app_analytics.dart'; +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_starter_template/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'auth_bloc_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec(), MockSpec()]) +void main() { + late AppAnalytics mockAnalytics; + late AuthRepository mockRepository; + late AuthBloc authBloc; + late StreamController authController; + + const email = 'test@gmail.com'; + const password = 'test_password'; + const user = User(uid: 'test_uid', email: 'test@gmail.com'); + const authFailure = UnknownAuthFailure('test_code'); + + setUp(() { + mockAnalytics = MockAppAnalytics(); + mockRepository = MockAuthRepository(); + + authController = StreamController.broadcast(); + when(mockRepository.authStateChanges).thenAnswer((_) => authController.stream); + + authBloc = AuthBloc(mockAnalytics, mockRepository); + + provideDummy>(const Success(user)); + provideDummy>(const Success(null)); + }); + + tearDown(() async { + await authBloc.close(); + await authController.close(); + }); + + void verifyRepository() { + verify(mockRepository.authStateChanges).called(1); + verifyNoMoreInteractions(mockRepository); + } + + group('AuthBloc', () { + group('AuthBloc._onAuthStateChanged', () { + blocTest( + 'emits authenticated when user is not null', + build: () => authBloc, + act: (bloc) async { + authController.add(user); + await pumpEventQueue(); + }, + expect: () => [const AuthState.authenticated(user)], + verify: (_) => verifyRepository(), + ); + + blocTest( + 'emits unauthenticated when user is null', + build: () => authBloc, + act: (bloc) async { + authController.add(null); + await pumpEventQueue(); + }, + expect: () => [const AuthState.unauthenticated()], + verify: (_) => verifyRepository(), + ); + }); + + group('AuthBloc._onSignInRequested', () { + blocTest( + 'emits authenticated when authStateChanges emits user on sign in', + build: () => authBloc, + setUp: () => when( + mockRepository.signInWithEmail(email, password), + ).thenAnswer((_) async => const Success(user)), + act: (bloc) async { + bloc.add(const AuthEvent.signInRequested(email, password)); + await pumpEventQueue(); + + authController.add(user); + await pumpEventQueue(); + }, + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authenticated(user), + ], + verify: (bloc) { + verify(mockRepository.signInWithEmail(email, password)).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signInRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + + blocTest( + 'emits authError on sign in failure', + build: () => authBloc, + setUp: () => when( + mockRepository.signInWithEmail(email, password), + ).thenAnswer((_) async => const Failure(authFailure)), + act: (bloc) => bloc.add(const AuthEvent.signInRequested(email, password)), + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authError(authFailure), + ], + verify: (bloc) { + verify(mockRepository.signInWithEmail(email, password)).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signInRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + }); + + group('AuthBloc._onSignUpRequested', () { + blocTest( + 'emits authenticated when authStateChanges emits user on sign up', + build: () => authBloc, + setUp: () => when( + mockRepository.signUpWithEmail(email, password), + ).thenAnswer((_) async => const Success(user)), + act: (bloc) async { + bloc.add(const AuthEvent.signUpRequested(email, password)); + await pumpEventQueue(); + + authController.add(user); + await pumpEventQueue(); + }, + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authenticated(user), + ], + verify: (bloc) { + verify(mockRepository.signUpWithEmail(email, password)).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signUpRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + + blocTest( + 'emits authError on sign up failure', + build: () => authBloc, + setUp: () => when( + mockRepository.signUpWithEmail(email, password), + ).thenAnswer((_) async => const Failure(authFailure)), + act: (bloc) => bloc.add(const AuthEvent.signUpRequested(email, password)), + expect: () => [ + const AuthState.operationInProgress(), + const AuthState.authError(authFailure), + ], + verify: (bloc) { + verify(mockRepository.signUpWithEmail(email, password)).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signUpRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + }); + + group('AuthBloc._onSignOutRequested', () { + blocTest( + 'emits unauthenticated when authStateChanges emits null on sign out', + build: () => authBloc, + setUp: () => when( + mockRepository.signOut(), + ).thenAnswer((_) async => const Success(null)), + act: (bloc) async { + bloc.add(const AuthEvent.signOutRequested()); + await pumpEventQueue(); + + authController.add(null); + await pumpEventQueue(); + }, + expect: () => [const AuthState.unauthenticated()], + verify: (bloc) { + verify(mockRepository.signOut()).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signOutRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + + blocTest( + 'emits authError on sign out failure', + build: () => authBloc, + setUp: () => when( + mockRepository.signOut(), + ).thenAnswer((_) async => const Failure(authFailure)), + act: (bloc) => bloc.add(const AuthEvent.signOutRequested()), + expect: () => [const AuthState.authError(authFailure)], + verify: (bloc) { + verify(mockRepository.signOut()).called(1); + verifyRepository(); + + verify(mockAnalytics.logEvent('signOutRequested')).called(1); + verifyNoMoreInteractions(mockAnalytics); + }, + ); + }); + }); +} From 12df61f96f9822a47de1c0d1054de7d84b51b940 Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:08:51 +0700 Subject: [PATCH 02/18] feat(auth): added Google Sign-In authentication (#6) * chore(ios): add Google Sign-In Info.plist config * feat(deps): add google_sign_in package to pubspec.yaml * feat(auth): add signInWithGoogle method to AuthRepository * feat(core): implement new AuthFailure classes for google auth integration, enhance l10n mapper and make it nullable * feat(auth): implement mapper for converting exceptions from google auth to AuthFailure * feat(auth): implement signInWithGoogle method in AuthRepositoryImpl * test(auth): cover signInWithGoogle method with tests * feat(core): register dependencies for google auth in DI and make setupDI method async * fix(auth): fix snack bar error handling on sign-in page * feat(auth): add google sign-in handler method to AuthBloc * test(auth): cover sign-in handler method with tests * feat(auth): add button for sign-in with google * docs: update CHANGELOG.md --- CHANGELOG.md | 1 + ios/Podfile.lock | 39 ++++ ios/Runner.xcodeproj/project.pbxproj | 18 ++ ios/Runner/Info.plist | 15 ++ lib/core/di/di.dart | 19 +- .../failures/feature/auth/auth_failure.dart | 72 +++++++ .../mappers/google_auth_failure_mapper.dart | 45 ++++ .../repositories/auth_repository_impl.dart | 45 +++- .../domain/repositories/auth_repository.dart | 7 + .../auth/presentation/bloc/auth_bloc.dart | 19 ++ .../auth/presentation/bloc/auth_event.dart | 3 + .../mappers/auth_failure_l10n_mapper.dart | 8 +- .../auth/presentation/pages/sign_in_page.dart | 31 ++- lib/runner.dart | 2 +- pubspec.lock | 48 +++++ pubspec.yaml | 1 + .../auth_repository_impl_test.dart | 195 ++++++++++++++++-- .../presentation/bloc/auth_bloc_test.dart | 49 +++++ 18 files changed, 583 insertions(+), 34 deletions(-) create mode 100644 lib/features/auth/data/mappers/google_auth_failure_mapper.dart 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 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/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 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/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/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, + ), + }; + } +} 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( 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: 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; } 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..48c9700 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -42,13 +42,13 @@ 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) { + final message = failure.toMessage(context); + if (message == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Center(child: Text(message))), + ); + }, orElse: () => const SizedBox.shrink(), ); }, @@ -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( 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 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 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); 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 79afda3d4f69bcaea517a5002c26ca52c9b613af Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 25 Jan 2026 14:00:18 +0700 Subject: [PATCH 03/18] feat: add devtools_options.yaml --- devtools_options.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 devtools_options.yaml diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: From a880650156f74021399052204e9b76139b4ca521 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 13:10:43 +0700 Subject: [PATCH 04/18] refactor(auth): improve code readability by formatting method signatures --- .../auth/presentation/bloc/auth_bloc.dart | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index 7896b7d..33ad83d 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -36,7 +36,10 @@ class AuthBloc extends Bloc with AppLoggerMixin { StreamSubscription? _authStateChanges; /// Handles authentication state changes. - void _onAuthStateChanged(_AuthStateChanged event, Emitter emit) { + void _onAuthStateChanged( + _AuthStateChanged event, + Emitter emit, + ) { if (event.user != null) { emit(AuthState.authenticated(event.user!)); } else { @@ -45,11 +48,17 @@ class AuthBloc extends Bloc with AppLoggerMixin { } /// Handles sign-in requests. - Future _onSignInRequested(_SignInRequested event, Emitter emit) async { + Future _onSignInRequested( + _SignInRequested event, + Emitter emit, + ) async { await _analytics.logEvent('signInRequested'); emit(const AuthState.operationInProgress()); - final result = await _repository.signInWithEmail(event.email, event.password); + final result = await _repository.signInWithEmail( + event.email, + event.password, + ); switch (result) { case Success(): @@ -60,11 +69,17 @@ class AuthBloc extends Bloc with AppLoggerMixin { } /// Handles sign-up requests. - Future _onSignUpRequested(_SignUpRequested event, Emitter emit) async { + Future _onSignUpRequested( + _SignUpRequested event, + Emitter emit, + ) async { await _analytics.logEvent('signUpRequested'); emit(const AuthState.operationInProgress()); - final result = await _repository.signUpWithEmail(event.email, event.password); + final result = await _repository.signUpWithEmail( + event.email, + event.password, + ); switch (result) { case Success(): @@ -75,7 +90,10 @@ class AuthBloc extends Bloc with AppLoggerMixin { } /// Handles sign-out requests. - Future _onSignOutRequested(_SignOutRequested event, Emitter emit) async { + Future _onSignOutRequested( + _SignOutRequested event, + Emitter emit, + ) async { await _analytics.logEvent('signOutRequested'); final result = await _repository.signOut(); From bdfaeab68013de04d6e4bdaa4ef0c488595f3995 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 13:34:50 +0700 Subject: [PATCH 05/18] feat(auth): prevent multiple authentication requests during ongoing operations --- lib/features/auth/presentation/bloc/auth_bloc.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index 33ad83d..550e4a8 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -52,6 +52,8 @@ class AuthBloc extends Bloc with AppLoggerMixin { _SignInRequested event, Emitter emit, ) async { + if (state is _AuthInProgress) return; + await _analytics.logEvent('signInRequested'); emit(const AuthState.operationInProgress()); @@ -73,6 +75,8 @@ class AuthBloc extends Bloc with AppLoggerMixin { _SignUpRequested event, Emitter emit, ) async { + if (state is _AuthInProgress) return; + await _analytics.logEvent('signUpRequested'); emit(const AuthState.operationInProgress()); @@ -90,6 +94,9 @@ class AuthBloc extends Bloc with AppLoggerMixin { } /// Handles sign-out requests. + /// + /// No need to emit loading state for sign-out, + /// as it is quick and handled by auth state changes. Future _onSignOutRequested( _SignOutRequested event, Emitter emit, @@ -110,6 +117,8 @@ class AuthBloc extends Bloc with AppLoggerMixin { _SignInWithGoogleRequested event, Emitter emit, ) async { + if (state is _AuthInProgress) return; + await _analytics.logEvent('signInWithGoogleRequested'); emit(const AuthState.operationInProgress()); From 220a07dfc62bec13f4e28bf2a0c6abd2aabd7299 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 13:35:38 +0700 Subject: [PATCH 06/18] feat(auth): log analytics events for sign-in, sign-up, sign-out, and Google sign-in actions --- .../auth/presentation/bloc/auth_bloc.dart | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index 550e4a8..f42e790 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -64,8 +64,12 @@ class AuthBloc extends Bloc with AppLoggerMixin { switch (result) { case Success(): - return; + await _analytics.logEvent('signInCompleted'); case Failure(:final error): + await _analytics.logEvent( + 'authError', + parameters: {'code': error.code}, + ); emit(AuthState.authError(error)); } } @@ -87,8 +91,12 @@ class AuthBloc extends Bloc with AppLoggerMixin { switch (result) { case Success(): - return; + await _analytics.logEvent('signUpCompleted'); case Failure(:final error): + await _analytics.logEvent( + 'authError', + parameters: {'code': error.code}, + ); emit(AuthState.authError(error)); } } @@ -107,8 +115,12 @@ class AuthBloc extends Bloc with AppLoggerMixin { switch (result) { case Success(): - return; + await _analytics.logEvent('signOutCompleted'); case Failure(:final error): + await _analytics.logEvent( + 'authError', + parameters: {'code': error.code}, + ); emit(AuthState.authError(error)); } } @@ -127,8 +139,11 @@ class AuthBloc extends Bloc with AppLoggerMixin { switch (result) { case Success(): await _analytics.logEvent('signInWithGoogleCompleted'); - return; case Failure(:final error): + await _analytics.logEvent( + 'authError', + parameters: {'code': error.code}, + ); emit(AuthState.authError(error)); } } From c549850d32f9d836bf79b4a6884d76252440229c Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 13:35:57 +0700 Subject: [PATCH 07/18] chore: add comment for Google sign-in handling --- lib/features/auth/presentation/bloc/auth_bloc.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index f42e790..b724ca6 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -110,7 +110,6 @@ class AuthBloc extends Bloc with AppLoggerMixin { Emitter emit, ) async { await _analytics.logEvent('signOutRequested'); - final result = await _repository.signOut(); switch (result) { @@ -125,6 +124,7 @@ class AuthBloc extends Bloc with AppLoggerMixin { } } + /// Handles Google sign-in requests. Future _onSignInWithGoogleRequested( _SignInWithGoogleRequested event, Emitter emit, From 18d4d4b5a599de3141e4d25c2efa34f3d65f6a7d Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 14:29:19 +0700 Subject: [PATCH 08/18] refactor(auth): replace BlocConsumer with BlocListener and remove loading state tracking for improved state handling --- .../auth/presentation/pages/sign_in_page.dart | 240 +++++++++--------- 1 file changed, 117 insertions(+), 123 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 48c9700..27de013 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -36,12 +36,9 @@ class _SignInPageState extends State { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: BlocConsumer( + child: BlocListener( listener: (context, state) { state.maybeWhen( - operationInProgress: () => const Center( - child: CircularProgressIndicator.adaptive(), - ), authError: (failure) { final message = failure.toMessage(context); if (message == null) return; @@ -52,141 +49,138 @@ class _SignInPageState extends State { orElse: () => const SizedBox.shrink(), ); }, - builder: (_, state) { - return Column( - mainAxisAlignment: .center, - children: [ - Form( - key: _formKey, - child: Column( - crossAxisAlignment: .start, - children: [ - const Text('Email'), - const SizedBox(height: 8), - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - autofocus: true, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Write your email'; - } - // if (!value.contains(RegExp(r''))) { - // return 'Incorrect email format'; - // } - return null; - }, - decoration: InputDecoration( - filled: true, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(16), - ), - hintText: 'aaa@gmail.com', + child: Column( + mainAxisAlignment: .center, + children: [ + Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUnfocus, + child: Column( + crossAxisAlignment: .start, + children: [ + const Text('Email'), + const SizedBox(height: 8), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + autofocus: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Write your email'; + } + // if (!value.contains(RegExp(r''))) { + // return 'Incorrect email format'; + // } + return null; + }, + decoration: InputDecoration( + filled: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(16), ), + hintText: 'aaa@gmail.com', ), - const SizedBox(height: 16), - const Text('Password'), - const SizedBox(height: 8), - TextFormField( - controller: _passwordController, - keyboardType: TextInputType.visiblePassword, - obscureText: !_isPasswordVisible, - autocorrect: false, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Write your password'; - } - // if (!value.contains(RegExp(r''))) { - // return 'Incorrect password format'; - // } - return null; - }, - decoration: InputDecoration( - filled: true, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(16), - ), - hintText: '•' * 8, - suffixIcon: IconButton( - onPressed: () { - setState(() { - debugPrint(_isPasswordVisible.toString()); - _isPasswordVisible = !_isPasswordVisible; - debugPrint(_isPasswordVisible.toString()); - }); - }, - icon: _isPasswordVisible - ? const Icon(Icons.visibility_rounded) - : const Icon(Icons.visibility_off_rounded), - ), + ), + const SizedBox(height: 16), + const Text('Password'), + const SizedBox(height: 8), + TextFormField( + controller: _passwordController, + keyboardType: TextInputType.visiblePassword, + obscureText: !_isPasswordVisible, + autocorrect: false, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Write your password'; + } + // if (!value.contains(RegExp(r''))) { + // return 'Incorrect password format'; + // } + return null; + }, + decoration: InputDecoration( + filled: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(16), ), - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton( + hintText: '•' * 8, + suffixIcon: IconButton( onPressed: () { - if (_formKey.currentState!.validate()) { - context.read().add( - _isSignIn - ? AuthEvent.signInRequested( - _emailController.text.trim(), - _passwordController.text.trim(), - ) - : AuthEvent.signUpRequested( - _emailController.text.trim(), - _passwordController.text.trim(), - ), - ); - } + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: _isSignIn ? const Text('Sign In') : const Text('Sign Up'), + icon: _isPasswordVisible + ? const Icon(Icons.visibility_rounded) + : const Icon(Icons.visibility_off_rounded), ), ), - const SizedBox(height: 16), - Center( - child: ElevatedButton( - onPressed: () { + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { context.read().add( - const AuthEvent.signInWithGoogleRequested(), + _isSignIn + ? AuthEvent.signInRequested( + _emailController.text.trim(), + _passwordController.text.trim(), + ) + : AuthEvent.signUpRequested( + _emailController.text.trim(), + _passwordController.text.trim(), + ), ); - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + } + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - child: const Text('Sign In with Google'), ), + child: _isSignIn ? const Text('Sign In') : const Text('Sign Up'), ), - const SizedBox(height: 16), - Center( - child: RichText( - text: TextSpan( - text: _isSignIn ? 'Sign Up' : 'Sign In', - style: const TextStyle(color: Colors.black), - recognizer: TapGestureRecognizer() - ..onTap = () => setState(() { - _isSignIn = !_isSignIn; - }), + ), + 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( + text: _isSignIn ? 'Sign Up' : 'Sign In', + style: const TextStyle(color: Colors.black), + recognizer: TapGestureRecognizer() + ..onTap = () => setState(() { + _isSignIn = !_isSignIn; + }), + ), ), - ], - ), + ), + ], ), - ], - ); - }, + ), + ], + ), ), ), ), From 416739d220540cb1f59953e096bb167b62c677bf Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 14:44:48 +0700 Subject: [PATCH 09/18] refactor(auth): replace TextEditingController with form field onSaved for email and password --- .../auth/presentation/pages/sign_in_page.dart | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 27de013..7d239c0 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -16,20 +16,11 @@ class SignInPage extends StatefulWidget { class _SignInPageState extends State { final _formKey = GlobalKey(); - - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); + String? _email, _password; bool _isPasswordVisible = false; bool _isSignIn = true; - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -61,7 +52,6 @@ class _SignInPageState extends State { const Text('Email'), const SizedBox(height: 8), TextFormField( - controller: _emailController, keyboardType: TextInputType.emailAddress, autocorrect: false, autofocus: true, @@ -74,6 +64,7 @@ class _SignInPageState extends State { // } return null; }, + onSaved: (newValue) => _email = newValue, decoration: InputDecoration( filled: true, border: OutlineInputBorder( @@ -87,7 +78,6 @@ class _SignInPageState extends State { const Text('Password'), const SizedBox(height: 8), TextFormField( - controller: _passwordController, keyboardType: TextInputType.visiblePassword, obscureText: !_isPasswordVisible, autocorrect: false, @@ -100,6 +90,7 @@ class _SignInPageState extends State { // } return null; }, + onSaved: (newValue) => _password = newValue, decoration: InputDecoration( filled: true, border: OutlineInputBorder( @@ -123,16 +114,17 @@ class _SignInPageState extends State { Center( child: ElevatedButton( onPressed: () { - if (_formKey.currentState!.validate()) { + if (_formKey.currentState?.validate() == true) { + _formKey.currentState!.save(); context.read().add( _isSignIn ? AuthEvent.signInRequested( - _emailController.text.trim(), - _passwordController.text.trim(), + _email!.trim(), + _password!.trim(), ) : AuthEvent.signUpRequested( - _emailController.text.trim(), - _passwordController.text.trim(), + _email!.trim(), + _password!.trim(), ), ); } From 818cda57a6a24f1c5a856f998c2805fb753223c9 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 14:44:53 +0700 Subject: [PATCH 10/18] feat(auth): enhance validator rules for email and password fields --- .../auth/presentation/pages/sign_in_page.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 7d239c0..c3df297 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -59,9 +59,9 @@ class _SignInPageState extends State { if (value == null || value.isEmpty) { return 'Write your email'; } - // if (!value.contains(RegExp(r''))) { - // return 'Incorrect email format'; - // } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + return 'Incorrect email format'; + } return null; }, onSaved: (newValue) => _email = newValue, @@ -85,9 +85,9 @@ class _SignInPageState extends State { if (value == null || value.isEmpty) { return 'Write your password'; } - // if (!value.contains(RegExp(r''))) { - // return 'Incorrect password format'; - // } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } return null; }, onSaved: (newValue) => _password = newValue, From ed22840d83f02f835269a6c4b5c56b8cd511e5c6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 14:53:52 +0700 Subject: [PATCH 11/18] feat(auth): update sign-in/sign-up toggle text for clarity and improved UX --- .../auth/presentation/pages/sign_in_page.dart | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index c3df297..5abbfc4 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -159,12 +159,18 @@ class _SignInPageState extends State { Center( child: RichText( text: TextSpan( - text: _isSignIn ? 'Sign Up' : 'Sign In', - style: const TextStyle(color: Colors.black), - recognizer: TapGestureRecognizer() - ..onTap = () => setState(() { - _isSignIn = !_isSignIn; - }), + text: _isSignIn + ? 'Don\'t have an account? ' + : 'Already have an account? ', + style: TextStyle(color: Colors.grey[600]), + children: [ + TextSpan( + text: _isSignIn ? 'Sign up' : 'Sign in', + style: const TextStyle(fontWeight: FontWeight.w600), + recognizer: TapGestureRecognizer() + ..onTap = () => setState(() => _isSignIn = !_isSignIn), + ), + ], ), ), ), From c90c0e55353dfe9c5ee57aac14217e449a9ae7f6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 15:01:31 +0700 Subject: [PATCH 12/18] refactor(auth): streamline functions syntax and reduce code length --- .../auth/presentation/pages/sign_in_page.dart | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 5abbfc4..6151038 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -28,18 +28,17 @@ class _SignInPageState extends State { child: Padding( padding: const EdgeInsets.all(16), child: BlocListener( - listener: (context, state) { - state.maybeWhen( - 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(), - ); - }, + listener: (context, state) => state.maybeWhen( + authError: (failure) { + final message = failure.toMessage(context); + if (message == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Center(child: Text(message))), + ); + return null; + }, + orElse: () => const SizedBox.shrink(), + ), child: Column( mainAxisAlignment: .center, children: [ @@ -59,7 +58,9 @@ class _SignInPageState extends State { if (value == null || value.isEmpty) { return 'Write your email'; } - if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + if (!RegExp( + r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$', + ).hasMatch(value)) { return 'Incorrect email format'; } return null; @@ -99,11 +100,9 @@ class _SignInPageState extends State { ), hintText: '•' * 8, suffixIcon: IconButton( - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, + onPressed: () => setState( + () => _isPasswordVisible = !_isPasswordVisible, + ), icon: _isPasswordVisible ? const Icon(Icons.visibility_rounded) : const Icon(Icons.visibility_off_rounded), @@ -141,11 +140,9 @@ class _SignInPageState extends State { const SizedBox(height: 16), Center( child: ElevatedButton( - onPressed: () { - context.read().add( - const AuthEvent.signInWithGoogleRequested(), - ); - }, + onPressed: () => context.read().add( + const AuthEvent.signInWithGoogleRequested(), + ), style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 50), shape: RoundedRectangleBorder( @@ -166,9 +163,13 @@ class _SignInPageState extends State { children: [ TextSpan( text: _isSignIn ? 'Sign up' : 'Sign in', - style: const TextStyle(fontWeight: FontWeight.w600), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), recognizer: TapGestureRecognizer() - ..onTap = () => setState(() => _isSignIn = !_isSignIn), + ..onTap = () => setState( + () => _isSignIn = !_isSignIn, + ), ), ], ), From dc021a70975f50727f7df36c9344d3e9c71fd2f6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 15:33:24 +0700 Subject: [PATCH 13/18] refactor(router): rename variable for clarity in redirect logic --- lib/core/router/router.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index 8716ddb..fd4ed5c 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -13,15 +13,15 @@ import 'router_paths.dart'; /// Determines the redirect path based on the current [authState] and [state]. String? _redirect(AuthState authState, GoRouterState state) { - final isAuth = state.matchedLocation.startsWith(AppRoutePaths.authPrefix); + final isAuthScreen = state.matchedLocation.startsWith(AppRoutePaths.authPrefix); return authState.maybeWhen( authenticated: (user) { - if (isAuth) return AppRoutePaths.debugPath; + if (isAuthScreen) return AppRoutePaths.debugPath; return null; }, unauthenticated: () { - if (isAuth) return null; + if (isAuthScreen) return null; return AppRoutePaths.signInPath; }, orElse: () => null, From 7a41eb3ba8286138805ab02618a9db25570b6185 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 30 Jan 2026 15:39:58 +0700 Subject: [PATCH 14/18] refactor(auth): simplify documentation for authentication methods --- .../repositories/auth_repository_impl.dart | 7 +------ .../domain/repositories/auth_repository.dart | 21 +------------------ 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index cc9bb41..5e1254e 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -24,12 +24,7 @@ class AuthRepositoryImpl implements AuthRepository { /// Creates an instance of [AuthRepositoryImpl]. AuthRepositoryImpl(this._logger, this._auth, this._googleSignIn); - /// Validates the provided credentials before processing login or - /// registration requests. - /// - /// Returns: - /// - [Result.success] with a [User] object if credentials are valid - /// - [Result.failure] with an [AuthFailure] if validation fails + /// Validates credential after Firebase operation. Result _validateCredential( fb.UserCredential credential, String operation, diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index f1f7492..6b211de 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -5,37 +5,18 @@ import '../entities/user.dart'; /// Repository interface for authentication operations. abstract interface class AuthRepository { /// Signs in a user with email and password. - /// - /// Returns: - /// - [Result.success] with [User] if authentication succeeds. - /// - [Result.failure] with [AuthFailure] if authentication fails. Future> signInWithEmail(String email, String password); /// Creates a new user account with email and password. - /// - /// Returns: - /// - [Result.success] with [User] if account creation succeeds. - /// - [Result.failure] with [AuthFailure] if creation fails. Future> signUpWithEmail(String email, String password); /// Signs out the currently authenticated user. - /// - /// Returns: - /// - [Result.success] with `null` if sign out succeeds. - /// - [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: - /// - [User] when a user is signed in - /// - `null` when no user is signed in + /// Emits [User] when signed in, `null` when signed out. Stream get authStateChanges; } From 6f09fb03e03309f763c995b40075944f7d407ff8 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Tue, 3 Feb 2026 10:46:09 +0700 Subject: [PATCH 15/18] feat(auth): add sign-in/sign-up headline text --- lib/features/auth/presentation/pages/sign_in_page.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/features/auth/presentation/pages/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index 6151038..0dc3473 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -48,6 +48,13 @@ class _SignInPageState extends State { child: Column( crossAxisAlignment: .start, children: [ + Center( + child: Text( + _isSignIn ? 'Sign In' : 'Sign Up', + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + const SizedBox(height: 32), const Text('Email'), const SizedBox(height: 8), TextFormField( From 7cb70cb0540ab18d141f4972ae452511381e10a6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Tue, 3 Feb 2026 11:26:35 +0700 Subject: [PATCH 16/18] docs: update CHANGELOG.md --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4893a6..0a85def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,38 +9,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-02-03 + ### Added -- Added `User` entity for representing authenticated user data. -- Added `AuthRepository` interface defining contract for authentication operations. -- Added typed authentication failures (`AuthFailure` hierarchy, including `UnknownAuthFailure`). -- Added `AuthFailureMapper` to convert `FirebaseAuthException` to `AuthFailure`. -- Added `UserMapper` to convert Firebase `User` to domain `User`. -- Added `AuthRepositoryImpl` implementation using Firebase Authentication. -- Added unit tests for `AuthRepositoryImpl` (success, Firebase exceptions, unexpected errors, auth state stream). -- Added `AuthBloc` for authentication events/state handling. -- Added unit tests for `AuthBloc`. -- Added email/password auth UI (`SignInPage`) with sign in / sign up flow. -- 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. +- Complete authentication system with email/password and Google Sign-In support. +- `User` entity, `AuthRepository` (interface + Firebase impl), typed `AuthFailure` hierarchy. +- `AuthBloc` with comprehensive unit tests for all auth flows. +- `SignInPage` UI with sign-in/sign-up toggle, form validation, and error localization. +- Auth-aware GoRouter routing (`/auth/*` public, others require auth). +- Analytics logging for auth events (sign-in/up/out). +- `DebugScreen` with sign-out for testing. ### Changed -- Changed `AppFailure` constructor: `message` is now a positional parameter (API change). -- Updated `AppFailure` constructor to match new `AppFailure` signature. -- Removed `AsyncResult` type alias (deleted `async_result.dart`). -- Enhanced `AuthRepositoryImpl` tests: added sign out and `authStateChanges` verifications. -- Updated `.gitignore` to exclude generated `*.freezed.dart` files (Build Runner output). -- Updated auth route paths to use `/auth/*` prefix. -- Improved authentication failure documentation/comments. -- Removed unnecessary empty `.gitkeep` files. -- Updated Firebase dependencies to latest versions. +- Breaking: `AppFailure` constructor: `message` now positional parameter. +- Form handling: `TextEditingController` → `FormKey` + `onSaved()` (no `dispose()` needed). +- Auth UI: improved toggle text clarity, added headlines/labels, RichText links. +- Bloc: `BlocConsumer` → `BlocListener` (removed redundant loading states). +- Auth routes now use `/auth/*` prefix consistently. ### Fixed -- Fixed CI failing due to missing generated Freezed files by running `build_runner` code generation in CI/Dockerfile. +- CI/CD pipeline: added `build_runner` for Freezed codegen in Docker. +- Prevented multiple simultaneous auth requests (button debounce). +- Enhanced email/password validators with real regex checks. +- Router redirect logic: clearer variable naming. + +### Refactored + +- Simplified auth repository documentation (removed verbose comments). +- Updated `.gitignore` to exclude generated `*.freezed.dart`. + +### Removed + +- Removed unused `.gitkeep` files. +- Removed `AsyncResult` type alias (not used in auth flow). ## [0.2.0] - 2025-12-20 From 34e2ce046c519b41d85b1171b29160a020165cd5 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Tue, 3 Feb 2026 11:26:57 +0700 Subject: [PATCH 17/18] chore: update version to 0.3.0 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 656b150..5773292 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_starter_template description: A scalable Flutter Starter Template for building production-ready business applications. publish_to: 'none' -version: 0.2.0 +version: 0.3.0 environment: sdk: '>=3.10.0 <4.0.0' From e789ac51bb17685c5ce7509f0d1ef131d72aaf20 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Tue, 3 Feb 2026 15:06:12 +0700 Subject: [PATCH 18/18] fix(auth): add analytics verify() assertions to Bloc tests (CI fix) --- .../presentation/bloc/auth_bloc_test.dart | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/features/auth/presentation/bloc/auth_bloc_test.dart b/test/features/auth/presentation/bloc/auth_bloc_test.dart index 6b4988a..40d6fb0 100644 --- a/test/features/auth/presentation/bloc/auth_bloc_test.dart +++ b/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -96,6 +96,7 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signInRequested')).called(1); + verify(mockAnalytics.logEvent('signInCompleted')).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -116,6 +117,12 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signInRequested')).called(1); + verify( + mockAnalytics.logEvent( + 'authError', + parameters: {'code': authFailure.code}, + ), + ).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -144,6 +151,7 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signUpRequested')).called(1); + verify(mockAnalytics.logEvent('signUpCompleted')).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -164,6 +172,12 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signUpRequested')).called(1); + verify( + mockAnalytics.logEvent( + 'authError', + parameters: {'code': authFailure.code}, + ), + ).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -189,6 +203,7 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signOutRequested')).called(1); + verify(mockAnalytics.logEvent('signOutCompleted')).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -206,6 +221,12 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signOutRequested')).called(1); + verify( + mockAnalytics.logEvent( + 'authError', + parameters: {'code': authFailure.code}, + ), + ).called(1); verifyNoMoreInteractions(mockAnalytics); }, ); @@ -255,6 +276,12 @@ void main() { verifyRepository(); verify(mockAnalytics.logEvent('signInWithGoogleRequested')).called(1); + verify( + mockAnalytics.logEvent( + 'authError', + parameters: {'code': authFailure.code}, + ), + ).called(1); verifyNoMoreInteractions(mockAnalytics); }, );