From 439e1d8523119a4b373ebc18e00038f8e7159f9c Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 28 Dec 2025 14:02:12 +0700 Subject: [PATCH 01/20] refactor(core): simplify AppFailure ctor and remove AsyncResult alias --- CHANGELOG.md | 9 +++++++++ lib/core/failures/app_failure.dart | 3 ++- lib/core/failures/unknown_failure.dart | 2 +- lib/core/result/async_result.dart | 5 ----- 4 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 lib/core/result/async_result.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 859871f..7827032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Changed `AppFailure` constructor: `message` is now a positional parameter (API change). +- Removed `AsyncResult` type alias (deleted `async_result.dart`). + +### Fixed + +- Updated `AppFailure` constructor to match new `AppFailure` signature. + ## [0.2.0] - 2025-12-20 ### Added 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/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/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>; From 68df68b4b6a5c0b0c0b8a659ab77be3c5ae3d65c Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 28 Dec 2025 14:09:51 +0700 Subject: [PATCH 02/20] feat(auth): add User entity and AuthRepository interface for authentication operations --- CHANGELOG.md | 5 +++ 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 | 40 +++++++++++++++++++ 5 files changed, 72 insertions(+) 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7827032..95e3828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ 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. + ### Changed - Changed `AppFailure` constructor: `message` is now a positional parameter (API change). 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..a811a5a --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,40 @@ +import '../../../../core/failures/feature/auth/auth_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/user.dart'; + +/// Repository interface for authentication operations. +/// +/// Defines the contract for authentication actions (sign in, sign up, sign out) +/// and authentication state observation. +/// +/// All methods return [Result] type for functional error handling, allowing +/// the presentation layer to handle success and failure cases explicitly. +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; +} From 76c80687509cb7bf928144e2c4ecdf06fdae60b0 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 28 Dec 2025 14:11:18 +0700 Subject: [PATCH 03/20] feat(auth): add typed AuthFailure and Firebase mappers --- CHANGELOG.md | 3 + lib/core/failures/feature/.gitkeep | 0 .../failures/feature/auth/auth_failure.dart | 164 ++++++++++++++++++ lib/features/auth/data/mappers/.gitkeep | 0 .../data/mappers/auth_failure_mapper.dart | 65 +++++++ .../data/mappers/user_to_entity_mapper.dart | 16 ++ 6 files changed, 248 insertions(+) delete mode 100644 lib/core/failures/feature/.gitkeep create mode 100644 lib/core/failures/feature/auth/auth_failure.dart 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e3828..81fed44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. ### Changed 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..aae79f1 --- /dev/null +++ b/lib/core/failures/feature/auth/auth_failure.dart @@ -0,0 +1,164 @@ +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, + }); +} + +/// +final class InvalidCredentialFailure extends AuthFailure { + @override + String get code => 'invalid-credential'; + + /// + const InvalidCredentialFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class WeakPasswordFailure extends AuthFailure { + @override + String get code => 'weak-password'; + + /// + const WeakPasswordFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class WrongPasswordFailure extends AuthFailure { + @override + String get code => 'wrong-password'; + + /// + const WrongPasswordFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class InvalidEmailFailure extends AuthFailure { + @override + String get code => 'invalid-email'; + + /// + const InvalidEmailFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +/// IMPORTANT: WE DON'T NEED TO NOTIFY USER THAT ACCOUNT WITH THIS EMAIL IS IN OUR DATABASE +final class EmailAlreadyInUseFailure extends AuthFailure { + @override + String get code => 'email-already-in-use'; + + /// + const EmailAlreadyInUseFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class UserDisabledFailure extends AuthFailure { + @override + String get code => 'user-disabled'; + + /// + const UserDisabledFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class UserTokenExpiredFailure extends AuthFailure { + @override + String get code => 'user-token-expired'; + + /// + const UserTokenExpiredFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class UserNotFoundFailure extends AuthFailure { + @override + String get code => 'user-not-found'; + + /// + const UserNotFoundFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class OperationNotAllowed extends AuthFailure { + @override + String get code => 'operation-not-allowed'; + + /// + const OperationNotAllowed({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class TooManyRequestsFailure extends AuthFailure { + @override + String get code => 'too-many-requests'; + + /// + const TooManyRequestsFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class NetworkRequestFailedFailure extends AuthFailure { + @override + String get code => 'network-request-failed'; + + /// + const NetworkRequestFailedFailure({ + super.parentException, + super.stackTrace, + }) : super(''); +} + +/// +final class UnknownAuthFailure extends AuthFailure { + @override + final String code; + + /// + final String? originalMessage; + + /// + const UnknownAuthFailure( + this.code, { + this.originalMessage, + super.parentException, + super.stackTrace, + }) : super(originalMessage ?? 'Unknown authentication error'); +} 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..ecd3fa4 --- /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 Firebase authentication exception to application-specific [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, + ); + } +} From 65311dc698709ba36419d2487ba2f43f9b694995 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 28 Dec 2025 14:13:53 +0700 Subject: [PATCH 04/20] feat(auth): implement AuthRepository using Firebase Authentication --- CHANGELOG.md | 1 + lib/features/auth/data/repositories/.gitkeep | 0 .../repositories/auth_repository_impl.dart | 150 ++++++++++++++++++ 3 files changed, 151 insertions(+) delete mode 100644 lib/features/auth/data/repositories/.gitkeep create mode 100644 lib/features/auth/data/repositories/auth_repository_impl.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 81fed44..61ccb9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ### Changed 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..94135c3 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,150 @@ +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. +/// +/// This class handles authentication operations (sign in, sign up, sign out) +/// and converts Firebase-specific errors to domain [AuthFailure] types. +/// +/// All operations are logged using [AppLogger] for debugging and monitoring. +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 and returns a [Result] containing either + /// a [User] on success or an [AuthFailure] on failure. + /// + /// This is a private helper method used internally by the authentication + /// repository to verify user 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; + } + }, + ); + } +} From 0616fac0dd57db6834c83811d98eb8c55d61cf20 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 28 Dec 2025 14:14:32 +0700 Subject: [PATCH 05/20] test(auth): cover AuthRepositoryImpl --- CHANGELOG.md | 1 + .../auth_repository_impl_test.dart | 437 ++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 test/features/auth/data/repositories/auth_repository_impl_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ccb9c..0be59f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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). ### Changed 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..cfd4c70 --- /dev/null +++ b/test/features/auth/data/repositories/auth_repository_impl_test.dart @@ -0,0 +1,437 @@ +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 + await repository.signOut(); + + // Assert + 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); + }); + + 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); + }); + + 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); + }); + }); + }); +} From 3376f1117179cfe071b867c5b67994c76cfd700d Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 5 Jan 2026 13:34:58 +0700 Subject: [PATCH 06/20] test(auth): enhance AuthRepositoryImpl tests with signOut and authStateChanges verifications --- .../repositories/auth_repository_impl_test.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 cfd4c70..4af5c84 100644 --- a/test/features/auth/data/repositories/auth_repository_impl_test.dart +++ b/test/features/auth/data/repositories/auth_repository_impl_test.dart @@ -299,9 +299,11 @@ void main() { when(mockAuth.signOut()).thenAnswer((_) async {}); // Act - await repository.signOut(); + final result = await repository.signOut(); // Assert + expect(result.isSuccess, true); + verify(mockAuth.signOut()).called(1); verifyNoMoreInteractions(mockAuth); }); @@ -376,6 +378,9 @@ void main() { 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 { @@ -397,6 +402,9 @@ void main() { // Assert final user = users.first; expect(user, null); + + verify(mockAuth.authStateChanges()).called(1); + verifyNoMoreInteractions(mockAuth); }); test('should emit sequence of auth state changes', () async { @@ -431,6 +439,9 @@ void main() { expect(user.email, email); expect(users.last, null); + + verify(mockAuth.authStateChanges()).called(1); + verifyNoMoreInteractions(mockAuth); }); }); }); From a09535f8aea3b680a636959741c1313862cfb885 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 5 Jan 2026 14:32:11 +0700 Subject: [PATCH 07/20] chore: add *.freezed.dart to .gitignore for Build Runner generated files --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 6b4efe165be797c455e2bf4d7c8ffdeccad0c727 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 5 Jan 2026 14:32:42 +0700 Subject: [PATCH 08/20] feat(auth): implement AuthBloc with authentication events and states --- .../auth/presentation/bloc/auth_bloc.dart | 95 +++++++++++++++++++ .../auth/presentation/bloc/auth_event.dart | 17 ++++ .../auth/presentation/bloc/auth_state.dart | 20 ++++ 3 files changed, 132 insertions(+) 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 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; +} From a45f26754034448b9b76186a5d4a9a4d9babee32 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Mon, 5 Jan 2026 14:32:56 +0700 Subject: [PATCH 09/20] test(auth): cover AuthBloc --- .../presentation/bloc/auth_bloc_test.dart | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 test/features/auth/presentation/bloc/auth_bloc_test.dart 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 37eda57d8076c94051ee6386d18a3448eee1fc91 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Wed, 7 Jan 2026 15:41:05 +0700 Subject: [PATCH 10/20] feat(auth): implement SignInPage and update routing for authentication --- lib/core/di/di.dart | 11 ++ lib/core/router/router.dart | 8 +- lib/core/router/router_paths.dart | 12 +- lib/features/app/app.dart | 8 +- lib/features/auth/presentation/pages/.gitkeep | 0 .../auth/presentation/pages/sign_in_page.dart | 177 ++++++++++++++++++ 6 files changed, 207 insertions(+), 9 deletions(-) delete mode 100644 lib/features/auth/presentation/pages/.gitkeep create mode 100644 lib/features/auth/presentation/pages/sign_in_page.dart diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 44d6726..e01d197 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()), ); + // Firebase 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/router/router.dart b/lib/core/router/router.dart index 4612b0c..b66e9c5 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -1,5 +1,6 @@ import 'package:go_router/go_router.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'; @@ -8,15 +9,18 @@ import 'router_paths.dart'; /// The application's router using GoRouter. final router = GoRouter( - initialLocation: AppRoutePaths.debugPath, + initialLocation: AppRoutePaths.signInPath, observers: [ AnalyticsRouteObserver(di()), ], routes: [ - // Define your app's routes here GoRoute( path: AppRoutePaths.debugPath, builder: (context, state) => const DebugScreen(), ), + GoRoute( + path: AppRoutePaths.signInPath, + builder: (_, _) => const SignInPage(), + ), ], ); 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/features/app/app.dart b/lib/features/app/app.dart index d721fa9..8b29930 100644 --- a/lib/features/app/app.dart +++ b/lib/features/app/app.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../core/di/di.dart'; import '../../core/router/router.dart'; +import '../auth/presentation/bloc/auth_bloc.dart'; /// The main application widget. class FlutterStarterTemplate extends StatelessWidget { @@ -9,6 +12,9 @@ class FlutterStarterTemplate extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp.router(routerConfig: router); + return BlocProvider( + create: (context) => di(), + child: MaterialApp.router(routerConfig: router), + ); } } 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..c1a1132 --- /dev/null +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -0,0 +1,177 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../bloc/auth_bloc.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.toString()), + ), + ), + ), + 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; + }), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} From 03086aa26061d7f11b18c0ca067ac67839080c5d Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Wed, 7 Jan 2026 15:41:58 +0700 Subject: [PATCH 11/20] feat(debug): add Sign Out button to DebugScreen for testing AuthBloc --- .../debug/presentation/debug_screen.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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'), + ), ), ); } From 314388c8d6a229d7f6e837df67543e0ca31e4e67 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Wed, 7 Jan 2026 15:42:05 +0700 Subject: [PATCH 12/20] feat(auth): implement redirect logic and auth state listener in router --- lib/core/router/router.dart | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index b66e9c5..8716ddb 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -1,5 +1,9 @@ +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'; @@ -7,12 +11,31 @@ 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.signInPath, observers: [ AnalyticsRouteObserver(di()), ], + redirect: (_, state) => _redirect(di().state, state), + refreshListenable: GoRouterAuthRefreshStream(di().stream), routes: [ GoRoute( path: AppRoutePaths.debugPath, @@ -24,3 +47,19 @@ final router = GoRouter( ), ], ); + +/// 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(); + } +} From f1888e963cecca6ffbe25a7d0a3ad8ce6231a7c0 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Thu, 8 Jan 2026 13:04:39 +0700 Subject: [PATCH 13/20] feat(auth): add localized error messages for authentication failures --- lib/features/app/app.dart | 7 ++++- .../mappers/auth_failure_l10n_mapper.dart | 27 +++++++++++++++++++ .../auth/presentation/pages/sign_in_page.dart | 3 ++- lib/l10n/app_en.arb | 14 +++++++++- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 lib/features/auth/presentation/mappers/auth_failure_l10n_mapper.dart diff --git a/lib/features/app/app.dart b/lib/features/app/app.dart index 8b29930..f7be1aa 100644 --- a/lib/features/app/app.dart +++ b/lib/features/app/app.dart @@ -2,6 +2,7 @@ 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'; @@ -14,7 +15,11 @@ class FlutterStarterTemplate extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => di(), - child: MaterialApp.router(routerConfig: router), + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), ); } } 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/sign_in_page.dart b/lib/features/auth/presentation/pages/sign_in_page.dart index c1a1132..879085f 100644 --- a/lib/features/auth/presentation/pages/sign_in_page.dart +++ b/lib/features/auth/presentation/pages/sign_in_page.dart @@ -3,6 +3,7 @@ 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 { @@ -44,7 +45,7 @@ class _SignInPageState extends State { authError: (failure) => ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Center( - child: Text(failure.toString()), + child: Text(failure.toMessage(context)), ), ), ), 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 From 3ad34a799c4e2ae4a74445e41964581bcbc07c44 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Thu, 8 Jan 2026 13:24:51 +0700 Subject: [PATCH 14/20] docs(core): add descriptive comments for authentication failure classes --- .../failures/feature/auth/auth_failure.dart | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/core/failures/feature/auth/auth_failure.dart b/lib/core/failures/feature/auth/auth_failure.dart index aae79f1..31058b5 100644 --- a/lib/core/failures/feature/auth/auth_failure.dart +++ b/lib/core/failures/feature/auth/auth_failure.dart @@ -13,7 +13,7 @@ sealed class AuthFailure extends AppFailure { }); } -/// +/// Invalid credential error. final class InvalidCredentialFailure extends AuthFailure { @override String get code => 'invalid-credential'; @@ -25,7 +25,7 @@ final class InvalidCredentialFailure extends AuthFailure { }) : super(''); } -/// +/// Weak password error. final class WeakPasswordFailure extends AuthFailure { @override String get code => 'weak-password'; @@ -37,7 +37,7 @@ final class WeakPasswordFailure extends AuthFailure { }) : super(''); } -/// +/// Wrong password error. final class WrongPasswordFailure extends AuthFailure { @override String get code => 'wrong-password'; @@ -49,7 +49,7 @@ final class WrongPasswordFailure extends AuthFailure { }) : super(''); } -/// +/// Invalid email error. final class InvalidEmailFailure extends AuthFailure { @override String get code => 'invalid-email'; @@ -61,8 +61,7 @@ final class InvalidEmailFailure extends AuthFailure { }) : super(''); } -/// -/// IMPORTANT: WE DON'T NEED TO NOTIFY USER THAT ACCOUNT WITH THIS EMAIL IS IN OUR DATABASE +/// Email already in use error. final class EmailAlreadyInUseFailure extends AuthFailure { @override String get code => 'email-already-in-use'; @@ -74,7 +73,7 @@ final class EmailAlreadyInUseFailure extends AuthFailure { }) : super(''); } -/// +/// User disabled error. final class UserDisabledFailure extends AuthFailure { @override String get code => 'user-disabled'; @@ -86,7 +85,7 @@ final class UserDisabledFailure extends AuthFailure { }) : super(''); } -/// +/// User token expired error. final class UserTokenExpiredFailure extends AuthFailure { @override String get code => 'user-token-expired'; @@ -98,7 +97,7 @@ final class UserTokenExpiredFailure extends AuthFailure { }) : super(''); } -/// +/// User not found error. final class UserNotFoundFailure extends AuthFailure { @override String get code => 'user-not-found'; @@ -110,7 +109,7 @@ final class UserNotFoundFailure extends AuthFailure { }) : super(''); } -/// +/// Operation not allowed error. final class OperationNotAllowed extends AuthFailure { @override String get code => 'operation-not-allowed'; @@ -122,7 +121,7 @@ final class OperationNotAllowed extends AuthFailure { }) : super(''); } -/// +/// Too many requests error. final class TooManyRequestsFailure extends AuthFailure { @override String get code => 'too-many-requests'; @@ -134,7 +133,7 @@ final class TooManyRequestsFailure extends AuthFailure { }) : super(''); } -/// +/// Network request failed error. final class NetworkRequestFailedFailure extends AuthFailure { @override String get code => 'network-request-failed'; @@ -146,7 +145,7 @@ final class NetworkRequestFailedFailure extends AuthFailure { }) : super(''); } -/// +/// Unknown authentication error. final class UnknownAuthFailure extends AuthFailure { @override final String code; From 8a659c82bd6b383848ba938b7bec9f7993b82a2d Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Thu, 8 Jan 2026 13:32:30 +0700 Subject: [PATCH 15/20] refactor(auth): simplify comments and documentation in authentication files and di --- lib/core/di/di.dart | 2 +- .../auth/data/mappers/auth_failure_mapper.dart | 2 +- .../auth/data/repositories/auth_repository_impl.dart | 11 +---------- .../auth/domain/repositories/auth_repository.dart | 6 ------ 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index e01d197..5ad782e 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -34,7 +34,7 @@ void setupDI() { : DebugAnalyticsImpl(di()), ); - // Firebase Authentication + // Authentication di.registerLazySingleton(() => FirebaseAuth.instance); di.registerLazySingleton( () => AuthRepositoryImpl(di(), di()), diff --git a/lib/features/auth/data/mappers/auth_failure_mapper.dart b/lib/features/auth/data/mappers/auth_failure_mapper.dart index ecd3fa4..da826cd 100644 --- a/lib/features/auth/data/mappers/auth_failure_mapper.dart +++ b/lib/features/auth/data/mappers/auth_failure_mapper.dart @@ -4,7 +4,7 @@ import '../../../../core/failures/feature/auth/auth_failure.dart'; /// Extension for converting [FirebaseAuthException] to domain [AuthFailure]. extension AuthFailureMapper on FirebaseAuthException { - /// Converts Firebase authentication exception to application-specific [AuthFailure]. + /// Converts [FirebaseAuthException] to domain [AuthFailure]. /// /// Returns a specific [AuthFailure] subclass based on the Firebase error code, /// or [UnknownAuthFailure] for unmapped error codes. diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 94135c3..de7d76d 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -9,11 +9,6 @@ import '../mappers/auth_failure_mapper.dart'; import '../mappers/user_to_entity_mapper.dart'; /// Implementation of [AuthRepository] using Firebase Authentication. -/// -/// This class handles authentication operations (sign in, sign up, sign out) -/// and converts Firebase-specific errors to domain [AuthFailure] types. -/// -/// All operations are logged using [AppLogger] for debugging and monitoring. class AuthRepositoryImpl implements AuthRepository { /// Logger for tracking authentication operations and errors. final AppLogger _logger; @@ -24,11 +19,7 @@ class AuthRepositoryImpl implements AuthRepository { /// Creates an instance of [AuthRepositoryImpl]. AuthRepositoryImpl(this._logger, this._auth); - /// Validates the provided credentials and returns a [Result] containing either - /// a [User] on success or an [AuthFailure] on failure. - /// - /// This is a private helper method used internally by the authentication - /// repository to verify user credentials before processing login or + /// Validates the provided credentials before processing login or /// registration requests. /// /// Returns: diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index a811a5a..19a274c 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -3,12 +3,6 @@ import '../../../../core/result/result.dart'; import '../entities/user.dart'; /// Repository interface for authentication operations. -/// -/// Defines the contract for authentication actions (sign in, sign up, sign out) -/// and authentication state observation. -/// -/// All methods return [Result] type for functional error handling, allowing -/// the presentation layer to handle success and failure cases explicitly. abstract interface class AuthRepository { /// Signs in a user with email and password. /// From 2a51a4e375e159b3f7a92e96c53362a90ada6600 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Thu, 8 Jan 2026 13:33:47 +0700 Subject: [PATCH 16/20] chore: remove empty .gitkeep files from unnecessary directories --- lib/core/localization/.gitkeep | 0 lib/core/services/.gitkeep | 0 lib/features/auth/data/models/.gitkeep | 0 lib/features/auth/data/sources/.gitkeep | 0 lib/features/auth/domain/usecases/.gitkeep | 0 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 lib/core/localization/.gitkeep delete mode 100644 lib/core/services/.gitkeep delete mode 100644 lib/features/auth/data/models/.gitkeep delete mode 100644 lib/features/auth/data/sources/.gitkeep delete mode 100644 lib/features/auth/domain/usecases/.gitkeep diff --git a/lib/core/localization/.gitkeep b/lib/core/localization/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/core/services/.gitkeep b/lib/core/services/.gitkeep deleted file mode 100644 index e69de29..0000000 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/sources/.gitkeep b/lib/features/auth/data/sources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/auth/domain/usecases/.gitkeep b/lib/features/auth/domain/usecases/.gitkeep deleted file mode 100644 index e69de29..0000000 From cacc3679a4b3a2d6b09e3339e7295f444c06c240 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Thu, 8 Jan 2026 13:33:58 +0700 Subject: [PATCH 17/20] chore: update Firebase dependencies to latest versions --- ios/Podfile.lock | 204 +++++++++++++++++++++++------------------------ 1 file changed, 102 insertions(+), 102 deletions(-) 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 From 7479be586207b2adef697c4d39c2dae8df3eee35 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 9 Jan 2026 13:24:26 +0700 Subject: [PATCH 18/20] fix(ci): add code generation step to CI/CD pipeline and Dockerfile --- .github/workflows/main.yml | 3 +++ Dockerfile | 4 ++++ 2 files changed, 7 insertions(+) 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/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 From 5d026638303e9b0fb7267ef440aa208b8c138f31 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 9 Jan 2026 13:54:42 +0700 Subject: [PATCH 19/20] docs(core): update documentation for authentication failure classes --- .../failures/feature/auth/auth_failure.dart | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/core/failures/feature/auth/auth_failure.dart b/lib/core/failures/feature/auth/auth_failure.dart index 31058b5..2faa451 100644 --- a/lib/core/failures/feature/auth/auth_failure.dart +++ b/lib/core/failures/feature/auth/auth_failure.dart @@ -18,7 +18,7 @@ final class InvalidCredentialFailure extends AuthFailure { @override String get code => 'invalid-credential'; - /// + /// Creates an instance of [InvalidCredentialFailure]. const InvalidCredentialFailure({ super.parentException, super.stackTrace, @@ -30,7 +30,7 @@ final class WeakPasswordFailure extends AuthFailure { @override String get code => 'weak-password'; - /// + /// Creates an instance of [WeakPasswordFailure]. const WeakPasswordFailure({ super.parentException, super.stackTrace, @@ -42,7 +42,7 @@ final class WrongPasswordFailure extends AuthFailure { @override String get code => 'wrong-password'; - /// + /// Creates an instance of [WrongPasswordFailure]. const WrongPasswordFailure({ super.parentException, super.stackTrace, @@ -54,7 +54,7 @@ final class InvalidEmailFailure extends AuthFailure { @override String get code => 'invalid-email'; - /// + /// Creates an instance of [InvalidEmailFailure]. const InvalidEmailFailure({ super.parentException, super.stackTrace, @@ -66,7 +66,7 @@ final class EmailAlreadyInUseFailure extends AuthFailure { @override String get code => 'email-already-in-use'; - /// + /// Creates an instance of [EmailAlreadyInUseFailure]. const EmailAlreadyInUseFailure({ super.parentException, super.stackTrace, @@ -78,7 +78,7 @@ final class UserDisabledFailure extends AuthFailure { @override String get code => 'user-disabled'; - /// + /// Creates an instance of [UserDisabledFailure]. const UserDisabledFailure({ super.parentException, super.stackTrace, @@ -90,7 +90,7 @@ final class UserTokenExpiredFailure extends AuthFailure { @override String get code => 'user-token-expired'; - /// + /// Creates an instance of [UserTokenExpiredFailure]. const UserTokenExpiredFailure({ super.parentException, super.stackTrace, @@ -102,7 +102,7 @@ final class UserNotFoundFailure extends AuthFailure { @override String get code => 'user-not-found'; - /// + /// Creates an instance of [UserNotFoundFailure]. const UserNotFoundFailure({ super.parentException, super.stackTrace, @@ -114,7 +114,7 @@ final class OperationNotAllowed extends AuthFailure { @override String get code => 'operation-not-allowed'; - /// + /// Creates an instance of [OperationNotAllowed]. const OperationNotAllowed({ super.parentException, super.stackTrace, @@ -126,7 +126,7 @@ final class TooManyRequestsFailure extends AuthFailure { @override String get code => 'too-many-requests'; - /// + /// Creates an instance of [TooManyRequestsFailure]. const TooManyRequestsFailure({ super.parentException, super.stackTrace, @@ -138,7 +138,7 @@ final class NetworkRequestFailedFailure extends AuthFailure { @override String get code => 'network-request-failed'; - /// + /// Creates an instance of [NetworkRequestFailedFailure]. const NetworkRequestFailedFailure({ super.parentException, super.stackTrace, @@ -150,10 +150,10 @@ 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, From ecc6e822ac4c934bc701a7fd659da255460dadea Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 9 Jan 2026 13:54:49 +0700 Subject: [PATCH 20/20] docs: update CHANGELOG.md --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be59f4..f0d5323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,15 +18,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 -- Updated `AppFailure` constructor to match new `AppFailure` signature. +- Fixed CI failing due to missing generated Freezed files by running `build_runner` code generation in CI/Dockerfile. ## [0.2.0] - 2025-12-20