diff --git a/docs/plans/2026-02-25-flutter-auth-integration-plan.md b/docs/plans/2026-02-25-flutter-auth-integration-plan.md new file mode 100644 index 0000000..51efb98 --- /dev/null +++ b/docs/plans/2026-02-25-flutter-auth-integration-plan.md @@ -0,0 +1,1623 @@ +# Flutter Auth Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Integrate Flutter mobile app with backend auth APIs for signup and login flows. + +**Architecture:** Complete Bloc architecture with ApiClient, Repository, AuthBloc for global state, and Cubits for form state. Uses flutter_secure_storage for tokens, dio for HTTP, and go_router for navigation with auth protection. + +**Tech Stack:** Flutter, flutter_bloc, dio, flutter_secure_storage, formz, get_it, go_router + +--- + +## Task 1: Core API Infrastructure + +**Files:** +- Create: `apps/lib/core/api/api_exception.dart` +- Create: `apps/lib/core/storage/token_storage.dart` +- Create: `apps/lib/core/api/api_client.dart` +- Create: `apps/lib/core/api/api_interceptor.dart` +- Test: `apps/test/core/api/api_exception_test.dart` +- Test: `apps/test/core/storage/token_storage_test.dart` + +### Step 1: Write failing test for ApiException + +Create `apps/test/core/api/api_exception_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/api/api_exception.dart'; + +void main() { + group('ApiException', () { + test('creates from DioException with 400 status', () { + final dioException = Exception('Bad request'); + final apiException = ApiException.fromDioError(dioException); + + expect(apiException, isA()); + expect(apiException.message, contains('Request failed')); + }); + + test('NetworkException has correct message', () { + const exception = NetworkException('No internet'); + expect(exception.message, 'No internet'); + }); + + test('UnauthorizedException has default message', () { + const exception = UnauthorizedException(); + expect(exception.message, 'Authentication required'); + }); + }); +} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/core/api/api_exception_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement ApiException + +Create `apps/lib/core/api/api_exception.dart`: + +```dart +abstract class ApiException implements Exception { + final String message; + final int? statusCode; + + const ApiException(this.message, {this.statusCode}); + + factory ApiException.fromDioError(Object error) { + if (error is ApiException) return error; + return ServerException(error.toString()); + } +} + +class NetworkException extends ApiException { + const NetworkException(super.message); +} + +class ServerException extends ApiException { + const ServerException(super.message, {super.statusCode}); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException([super.message = 'Authentication required']) : super(statusCode: 401); +} + +class ValidationException extends ApiException { + final Map? errors; + const ValidationException(super.message, {this.errors, super.statusCode}); +} +``` + +### Step 4: Run test to verify it passes + +Run: `cd apps && flutter test test/core/api/api_exception_test.dart` +Expected: PASS + +### Step 5: Write failing test for TokenStorage + +Create `apps/test/core/storage/token_storage_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +void main() { + late TokenStorage storage; + + setUp(() { + storage = MockTokenStorage(); + }); + + group('TokenStorage', () { + test('saves and retrieves access token', () async { + when(() => storage.getAccessToken()).thenAnswer((_) async => 'test_access'); + when(() => storage.saveTokens(access: 'test_access', refresh: 'test_refresh')) + .thenAnswer((_) async {}); + + await storage.saveTokens(access: 'test_access', refresh: 'test_refresh'); + final token = await storage.getAccessToken(); + + expect(token, 'test_access'); + verify(() => storage.saveTokens(access: 'test_access', refresh: 'test_refresh')).called(1); + }); + + test('clear removes all tokens', () async { + when(() => storage.clear()).thenAnswer((_) async {}); + when(() => storage.getAccessToken()).thenAnswer((_) async => null); + + await storage.clear(); + final token = await storage.getAccessToken(); + + expect(token, isNull); + }); + }); +} +``` + +### Step 6: Run test to verify it fails + +Run: `cd apps && flutter test test/core/storage/token_storage_test.dart` +Expected: FAIL - file not found + +### Step 7: Implement TokenStorage + +Create `apps/lib/core/storage/token_storage.dart`: + +```dart +abstract class TokenStorage { + Future getAccessToken(); + Future getRefreshToken(); + Future saveTokens({required String access, required String refresh}); + Future clear(); +} + +class SecureTokenStorage implements TokenStorage { + static const _accessTokenKey = 'access_token'; + static const _refreshTokenKey = 'refresh_token'; + + final dynamic _storage; + + SecureTokenStorage([this._storage]); + + @override + Future getAccessToken() async { + return _storage?.read(key: _accessTokenKey); + } + + @override + Future getRefreshToken() async { + return _storage?.read(key: _refreshTokenKey); + } + + @override + Future saveTokens({required String access, required String refresh}) async { + await _storage?.write(key: _accessTokenKey, value: access); + await _storage?.write(key: _refreshTokenKey, value: refresh); + } + + @override + Future clear() async { + await _storage?.delete(key: _accessTokenKey); + await _storage?.delete(key: _refreshTokenKey); + } +} +``` + +### Step 8: Run test to verify it passes + +Run: `cd apps && flutter test test/core/storage/token_storage_test.dart` +Expected: PASS + +### Step 9: Implement ApiInterceptor + +Create `apps/lib/core/api/api_interceptor.dart`: + +```dart +import 'package:dio/dio.dart'; +import '../storage/token_storage.dart'; + +class ApiInterceptor extends Interceptor { + final TokenStorage tokenStorage; + final Future Function()? onTokenRefresh; + + ApiInterceptor({ + required this.tokenStorage, + this.onTokenRefresh, + }); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401 && onTokenRefresh != null) { + final refreshed = await onTokenRefresh!(); + if (refreshed) { + final token = await tokenStorage.getAccessToken(); + if (token != null) { + err.requestOptions.headers['Authorization'] = 'Bearer $token'; + try { + final response = await Dio().fetch(err.requestOptions); + handler.resolve(response); + return; + } catch (_) {} + } + } + } + handler.next(err); + } +} +``` + +### Step 10: Implement ApiClient + +Create `apps/lib/core/api/api_client.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'api_exception.dart'; +import 'api_interceptor.dart'; +import '../storage/token_storage.dart'; + +class ApiClient { + final Dio _dio; + final TokenStorage _tokenStorage; + + ApiClient({ + required String baseUrl, + required TokenStorage tokenStorage, + Dio? dio, + }) : _tokenStorage = tokenStorage, + _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) { + _dio.interceptors.add(ApiInterceptor( + tokenStorage: _tokenStorage, + onTokenRefresh: _handleTokenRefresh, + )); + } + + Dio get dio => _dio; + + Future _handleTokenRefresh() async { + return false; + } + + Future> get(String path, {Options? options}) async { + try { + return await _dio.get(path, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } + + Future> post(String path, {dynamic data, Options? options}) async { + try { + return await _dio.post(path, data: data, options: options); + } catch (e) { + throw ApiException.fromDioError(e); + } + } +} +``` + +### Step 11: Commit core API infrastructure + +```bash +git add apps/lib/core/api/ apps/lib/core/storage/ apps/test/core/ +git commit -m "feat(apps): add core API infrastructure" +``` + +--- + +## Task 2: Auth Data Models + +**Files:** +- Create: `apps/lib/features/auth/data/models/signup_request.dart` +- Create: `apps/lib/features/auth/data/models/login_request.dart` +- Create: `apps/lib/features/auth/data/models/auth_response.dart` +- Test: `apps/test/features/auth/data/models/auth_models_test.dart` + +### Step 1: Write failing test for auth models + +Create `apps/test/features/auth/data/models/auth_models_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; + +void main() { + group('SignupStartRequest', () { + test('serializes to JSON', () { + final request = SignupStartRequest( + username: 'testuser', + email: 'test@example.com', + password: 'password123', + ); + + final json = request.toJson(); + + expect(json['username'], 'testuser'); + expect(json['email'], 'test@example.com'); + expect(json['password'], 'password123'); + }); + }); + + group('LoginRequest', () { + test('serializes to JSON', () { + final request = LoginRequest( + email: 'test@example.com', + password: 'password123', + ); + + final json = request.toJson(); + + expect(json['email'], 'test@example.com'); + expect(json['password'], 'password123'); + }); + }); + + group('AuthResponse', () { + test('parses from JSON', () { + final json = { + 'access_token': 'test_access', + 'refresh_token': 'test_refresh', + 'expires_in': 3600, + 'token_type': 'bearer', + 'user': {'id': '123', 'email': 'test@example.com'}, + }; + + final response = AuthResponse.fromJson(json); + + expect(response.accessToken, 'test_access'); + expect(response.refreshToken, 'test_refresh'); + expect(response.expiresIn, 3600); + expect(response.user.id, '123'); + expect(response.user.email, 'test@example.com'); + }); + }); +} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/features/auth/data/models/auth_models_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement SignupRequest + +Create `apps/lib/features/auth/data/models/signup_request.dart`: + +```dart +class SignupStartRequest { + final String username; + final String email; + final String password; + + const SignupStartRequest({ + required this.username, + required this.email, + required this.password, + }); + + Map toJson() => { + 'username': username, + 'email': email, + 'password': password, + }; +} + +class SignupVerifyRequest { + final String email; + final String token; + + const SignupVerifyRequest({ + required this.email, + required this.token, + }); + + Map toJson() => { + 'email': email, + 'token': token, + }; +} + +class SignupResendRequest { + final String email; + + const SignupResendRequest({required this.email}); + + Map toJson() => {'email': email}; +} +``` + +### Step 4: Implement LoginRequest + +Create `apps/lib/features/auth/data/models/login_request.dart`: + +```dart +class LoginRequest { + final String email; + final String password; + + const LoginRequest({ + required this.email, + required this.password, + }); + + Map toJson() => { + 'email': email, + 'password': password, + }; +} + +class RefreshRequest { + final String refreshToken; + + const RefreshRequest({required this.refreshToken}); + + Map toJson() => {'refresh_token': refreshToken}; +} + +class LogoutRequest { + final String refreshToken; + + const LogoutRequest({required this.refreshToken}); + + Map toJson() => {'refresh_token': refreshToken}; +} +``` + +### Step 5: Implement AuthResponse + +Create `apps/lib/features/auth/data/models/auth_response.dart`: + +```dart +class AuthUser { + final String id; + final String email; + + const AuthUser({required this.id, required this.email}); + + factory AuthUser.fromJson(Map json) { + return AuthUser( + id: json['id'] as String, + email: json['email'] as String, + ); + } +} + +class AuthResponse { + final String accessToken; + final String refreshToken; + final int expiresIn; + final String tokenType; + final AuthUser user; + + const AuthResponse({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + required this.tokenType, + required this.user, + }); + + factory AuthResponse.fromJson(Map json) { + return AuthResponse( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + expiresIn: json['expires_in'] as int, + tokenType: json['token_type'] as String, + user: AuthUser.fromJson(json['user'] as Map), + ); + } +} + +class SignupStartResponse { + final String status; + final String email; + final String message; + + const SignupStartResponse({ + required this.status, + required this.email, + required this.message, + }); + + factory SignupStartResponse.fromJson(Map json) { + return SignupStartResponse( + status: json['status'] as String, + email: json['email'] as String, + message: json['message'] as String, + ); + } +} +``` + +### Step 6: Run test to verify it passes + +Run: `cd apps && flutter test test/features/auth/data/models/auth_models_test.dart` +Expected: PASS + +### Step 7: Commit auth data models + +```bash +git add apps/lib/features/auth/data/models/ apps/test/features/auth/data/ +git commit -m "feat(apps): add auth data models" +``` + +--- + +## Task 3: Auth Repository + +**Files:** +- Create: `apps/lib/features/auth/data/auth_api.dart` +- Create: `apps/lib/features/auth/data/auth_repository.dart` +- Create: `apps/lib/features/auth/data/auth_repository_impl.dart` +- Test: `apps/test/features/auth/data/auth_repository_test.dart` + +### Step 1: Write failing test for AuthRepository + +Create `apps/test/features/auth/data/auth_repository_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/auth_repository_impl.dart'; +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +void main() { + late AuthRepository repository; + late MockTokenStorage mockStorage; + + setUp(() { + mockStorage = MockTokenStorage(); + repository = AuthRepositoryImpl( + tokenStorage: mockStorage, + ); + }); + + group('AuthRepository', () { + test('signupStart returns SignupStartResponse', () async { + final repo = _MockAuthRepository(); + when(() => repo.signupStart(any())) + .thenAnswer((_) async => const SignupStartResponse( + status: 'pending_verification', + email: 'test@example.com', + message: 'Verification code sent', + )); + + final result = await repo.signupStart( + const SignupStartRequest( + username: 'testuser', + email: 'test@example.com', + password: 'password123', + ), + ); + + expect(result.status, 'pending_verification'); + expect(result.email, 'test@example.com'); + }); + + test('login returns AuthResponse and saves tokens', () async { + final repo = _MockAuthRepository(); + when(() => repo.login(any())) + .thenAnswer((_) async => AuthResponse( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + )); + + final result = await repo.login( + const LoginRequest(email: 'test@example.com', password: 'password123'), + ); + + expect(result.accessToken, 'access_token'); + expect(result.user.email, 'test@example.com'); + }); + }); +} + +class _MockAuthRepository extends Mock implements AuthRepository {} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/features/auth/data/auth_repository_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement AuthApi + +Create `apps/lib/features/auth/data/auth_api.dart`: + +```dart +import 'package:social_app/core/api/api_client.dart'; +import 'models/signup_request.dart'; +import 'models/login_request.dart'; +import 'models/auth_response.dart'; + +class AuthApi { + final ApiClient _client; + static const _prefix = '/v1/auth'; + + AuthApi(this._client); + + Future signupStart(SignupStartRequest request) async { + final response = await _client.post('$_prefix/signup/start', data: request.toJson()); + return SignupStartResponse.fromJson(response.data); + } + + Future signupVerify(SignupVerifyRequest request) async { + final response = await _client.post('$_prefix/signup/verify', data: request.toJson()); + return AuthResponse.fromJson(response.data); + } + + Future signupResend(SignupResendRequest request) async { + final response = await _client.post('$_prefix/signup/resend', data: request.toJson()); + return SignupStartResponse.fromJson(response.data); + } + + Future login(LoginRequest request) async { + final response = await _client.post('$_prefix/login', data: request.toJson()); + return AuthResponse.fromJson(response.data); + } + + Future refresh(RefreshRequest request) async { + final response = await _client.post('$_prefix/refresh', data: request.toJson()); + return AuthResponse.fromJson(response.data); + } + + Future logout(LogoutRequest request) async { + await _client.post('$_prefix/logout', data: request.toJson()); + } +} +``` + +### Step 4: Implement AuthRepository + +Create `apps/lib/features/auth/data/auth_repository.dart`: + +```dart +import 'package:social_app/features/auth/data/models/signup_request.dart'; +import 'package:social_app/features/auth/data/models/login_request.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; + +abstract class AuthRepository { + Future signupStart(SignupStartRequest request); + Future signupVerify(SignupVerifyRequest request); + Future signupResend(SignupResendRequest request); + Future login(LoginRequest request); + Future refresh(String refreshToken); + Future logout(); + Future getAccessToken(); + Future getRefreshToken(); + Future isAuthenticated(); +} +``` + +### Step 5: Implement AuthRepositoryImpl + +Create `apps/lib/features/auth/data/auth_repository_impl.dart`: + +```dart +import 'package:social_app/core/storage/token_storage.dart'; +import 'auth_api.dart'; +import 'auth_repository.dart'; +import 'models/signup_request.dart'; +import 'models/login_request.dart'; +import 'models/auth_response.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final AuthApi _api; + final TokenStorage _tokenStorage; + + AuthRepositoryImpl({ + required AuthApi api, + required TokenStorage tokenStorage, + }) : _api = api, + _tokenStorage = tokenStorage; + + @override + Future signupStart(SignupStartRequest request) { + return _api.signupStart(request); + } + + @override + Future signupVerify(SignupVerifyRequest request) async { + final response = await _api.signupVerify(request); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future signupResend(SignupResendRequest request) { + return _api.signupResend(request); + } + + @override + Future login(LoginRequest request) async { + final response = await _api.login(request); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future refresh(String refreshToken) async { + final response = await _api.refresh(RefreshRequest(refreshToken: refreshToken)); + await _tokenStorage.saveTokens( + access: response.accessToken, + refresh: response.refreshToken, + ); + return response; + } + + @override + Future logout() async { + final refreshToken = await _tokenStorage.getRefreshToken(); + if (refreshToken != null) { + await _api.logout(LogoutRequest(refreshToken: refreshToken)); + } + await _tokenStorage.clear(); + } + + @override + Future getAccessToken() => _tokenStorage.getAccessToken(); + + @override + Future getRefreshToken() => _tokenStorage.getRefreshToken(); + + @override + Future isAuthenticated() async { + final token = await _tokenStorage.getAccessToken(); + return token != null; + } +} +``` + +### Step 6: Run test to verify it passes + +Run: `cd apps && flutter test test/features/auth/data/auth_repository_test.dart` +Expected: PASS + +### Step 7: Commit auth repository + +```bash +git add apps/lib/features/auth/data/ apps/test/features/auth/data/ +git commit -m "feat(apps): add auth repository" +``` + +--- + +## Task 4: AuthBloc + +**Files:** +- Create: `apps/lib/features/auth/presentation/bloc/auth_state.dart` +- Create: `apps/lib/features/auth/presentation/bloc/auth_event.dart` +- Create: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart` +- Test: `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart` + +### Step 1: Write failing test for AuthBloc + +Create `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart`: + +```dart +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late AuthBloc authBloc; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + authBloc = AuthBloc(mockRepository); + }); + + tearDown(() { + authBloc.close(); + }); + + group('AuthBloc', () { + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when AuthStarted and no token', + build: () { + when(() => mockRepository.getAccessToken()).thenAnswer((_) async => null); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + + blocTest( + 'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid token', + build: () { + when(() => mockRepository.getAccessToken()).thenAnswer((_) async => 'valid_token'); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), isA()], + ); + + blocTest( + 'emits [AuthUnauthenticated] when AuthLoggedOut', + build: () { + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + seed: () => AuthAuthenticated(user: const AuthUser(id: '1', email: 'test@example.com')), + act: (bloc) => bloc.add(AuthLoggedOut()), + expect: () => [AuthUnauthenticated()], + ); + }); +} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement AuthState + +Create `apps/lib/features/auth/presentation/bloc/auth_state.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +class AuthUser extends Equatable { + final String id; + final String email; + + const AuthUser({required this.id, required this.email}); + + @override + List get props => [id, email]; +} + +abstract class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +class AuthInitial extends AuthState {} + +class AuthLoading extends AuthState {} + +class AuthAuthenticated extends AuthState { + final AuthUser user; + + const AuthAuthenticated({required this.user}); + + @override + List get props => [user]; +} + +class AuthUnauthenticated extends AuthState {} +``` + +### Step 4: Implement AuthEvent + +Create `apps/lib/features/auth/presentation/bloc/auth_event.dart`: + +```dart +import 'package:equatable/equatable.dart'; +import 'auth_state.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +class AuthStarted extends AuthEvent {} + +class AuthLoggedIn extends AuthEvent { + final AuthUser user; + + const AuthLoggedIn({required this.user}); + + @override + List get props => [user]; +} + +class AuthLoggedOut extends AuthEvent {} +``` + +### Step 5: Implement AuthBloc + +Create `apps/lib/features/auth/presentation/bloc/auth_bloc.dart`: + +```dart +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/auth_repository.dart'; +import 'auth_event.dart'; +import 'auth_state.dart'; + +class AuthBloc extends Bloc { + final AuthRepository _repository; + + AuthBloc(this._repository) : super(AuthInitial()) { + on(_onStarted); + on(_onLoggedIn); + on(_onLoggedOut); + } + + Future _onStarted(AuthStarted event, Emitter emit) async { + emit(AuthLoading()); + final token = await _repository.getAccessToken(); + if (token != null) { + emit(const AuthAuthenticated( + user: AuthUser(id: '', email: ''), + )); + } else { + emit(AuthUnauthenticated()); + } + } + + void _onLoggedIn(AuthLoggedIn event, Emitter emit) { + emit(AuthAuthenticated(user: event.user)); + } + + Future _onLoggedOut(AuthLoggedOut event, Emitter emit) async { + await _repository.logout(); + emit(AuthUnauthenticated()); + } +} +``` + +### Step 6: Run test to verify it passes + +Run: `cd apps && flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart` +Expected: PASS + +### Step 7: Commit AuthBloc + +```bash +git add apps/lib/features/auth/presentation/bloc/ apps/test/features/auth/presentation/bloc/ +git commit -m "feat(apps): add AuthBloc for global auth state" +``` + +--- + +## Task 5: Register Cubit + +**Files:** +- Create: `apps/lib/features/auth/presentation/cubits/register_cubit.dart` +- Test: `apps/test/features/auth/presentation/cubits/register_cubit_test.dart` + +### Step 1: Write failing test for RegisterCubit + +Create `apps/test/features/auth/presentation/cubits/register_cubit_test.dart`: + +```dart +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late RegisterCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = RegisterCubit(mockRepository); + }); + + tearDown(() { + cubit.close(); + }); + + group('RegisterCubit', () { + test('initial state has pure status', () { + expect(cubit.state.status, FormzSubmissionStatus.initial); + }); + + blocTest( + 'usernameChanged updates username', + build: () => cubit, + act: (c) => c.usernameChanged('testuser'), + expect: () => [isA()], + ); + + blocTest( + 'emailChanged updates email', + build: () => cubit, + act: (c) => c.emailChanged('test@example.com'), + expect: () => [isA()], + ); + + blocTest( + 'passwordChanged updates password', + build: () => cubit, + act: (c) => c.passwordChanged('password123'), + expect: () => [isA()], + ); + }); +} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/features/auth/presentation/cubits/register_cubit_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement RegisterCubit + +Create `apps/lib/features/auth/presentation/cubits/register_cubit.dart`: + +```dart +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import '../../data/auth_repository.dart'; +import '../../data/models/signup_request.dart'; +import '../../data/models/auth_response.dart'; + +class Username extends FormzInput { + const Username.pure() : super.pure(''); + const Username.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Username is required'; + if (value.length < 3) return 'Username must be at least 3 characters'; + if (value.length > 30) return 'Username must be at most 30 characters'; + return null; + } +} + +class Email extends FormzInput { + const Email.pure() : super.pure(''); + const Email.dirty([super.value = '']) : super.dirty(); + + static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Email is required'; + if (!_regex.hasMatch(value)) return 'Invalid email format'; + return null; + } +} + +class Password extends FormzInput { + const Password.pure() : super.pure(''); + const Password.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Password is required'; + if (value.length < 6) return 'Password must be at least 6 characters'; + return null; + } +} + +class VerificationCode extends FormzInput { + const VerificationCode.pure() : super.pure(''); + const VerificationCode.dirty([super.value = '']) : super.dirty(); + + @override + String? validator(String value) { + if (value.isEmpty) return 'Code is required'; + if (!RegExp(r'^\d{6}$').hasMatch(value)) return 'Code must be 6 digits'; + return null; + } +} + +class RegisterState extends Equatable { + final Username username; + final Email email; + final Password password; + final VerificationCode verificationCode; + final FormzSubmissionStatus status; + final String? errorMessage; + final String? pendingEmail; + final bool codeSent; + + const RegisterState({ + this.username = const Username.pure(), + this.email = const Email.pure(), + this.password = const Password.pure(), + this.verificationCode = const VerificationCode.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + this.pendingEmail, + this.codeSent = false, + }); + + bool get isStep1Valid => username.isValid && email.isValid && password.isValid; + bool get isStep2Valid => verificationCode.isValid; + + RegisterState copyWith({ + Username? username, + Email? email, + Password? password, + VerificationCode? verificationCode, + FormzSubmissionStatus? status, + String? errorMessage, + String? pendingEmail, + bool? codeSent, + }) { + return RegisterState( + username: username ?? this.username, + email: email ?? this.email, + password: password ?? this.password, + verificationCode: verificationCode ?? this.verificationCode, + status: status ?? this.status, + errorMessage: errorMessage, + pendingEmail: pendingEmail ?? this.pendingEmail, + codeSent: codeSent ?? this.codeSent, + ); + } + + @override + List get props => [ + username, + email, + password, + verificationCode, + status, + errorMessage, + pendingEmail, + codeSent, + ]; +} + +class RegisterCubit extends Cubit { + final AuthRepository _repository; + + RegisterCubit(this._repository) : super(const RegisterState()); + + void usernameChanged(String value) { + emit(state.copyWith(username: Username.dirty(value))); + } + + void emailChanged(String value) { + emit(state.copyWith(email: Email.dirty(value))); + } + + void passwordChanged(String value) { + emit(state.copyWith(password: Password.dirty(value))); + } + + void verificationCodeChanged(String value) { + emit(state.copyWith(verificationCode: VerificationCode.dirty(value))); + } + + Future submitStep1() async { + if (!state.isStep1Valid) return false; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupStart( + SignupStartRequest( + username: state.username.value, + email: state.email.value, + password: state.password.value, + ), + ); + emit(state.copyWith( + status: FormzSubmissionStatus.success, + pendingEmail: response.email, + codeSent: true, + )); + return true; + } catch (e) { + emit(state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + )); + return false; + } + } + + Future submitStep2() async { + if (!state.isStep2Valid || state.pendingEmail == null) return null; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.signupVerify( + SignupVerifyRequest( + email: state.pendingEmail!, + token: state.verificationCode.value, + ), + ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + return response; + } catch (e) { + emit(state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + )); + return null; + } + } + + Future resendCode() async { + if (state.pendingEmail == null) return; + + try { + await _repository.signupResend( + SignupResendRequest(email: state.pendingEmail!), + ); + emit(state.copyWith(codeSent: true)); + } catch (e) { + emit(state.copyWith(errorMessage: e.toString())); + } + } +} +``` + +### Step 4: Run test to verify it passes + +Run: `cd apps && flutter test test/features/auth/presentation/cubits/register_cubit_test.dart` +Expected: PASS + +### Step 5: Commit RegisterCubit + +```bash +git add apps/lib/features/auth/presentation/cubits/ apps/test/features/auth/presentation/cubits/ +git commit -m "feat(apps): add RegisterCubit for signup form" +``` + +--- + +## Task 6: Login Cubit + +**Files:** +- Create: `apps/lib/features/auth/presentation/cubits/login_cubit.dart` +- Test: `apps/test/features/auth/presentation/cubits/login_cubit_test.dart` + +### Step 1: Write failing test for LoginCubit + +Create `apps/test/features/auth/presentation/cubits/login_cubit_test.dart`: + +```dart +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formz/formz.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/cubits/login_cubit.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late LoginCubit cubit; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + cubit = LoginCubit(mockRepository); + }); + + tearDown(() { + cubit.close(); + }); + + group('LoginCubit', () { + test('initial state has pure status', () { + expect(cubit.state.status, FormzSubmissionStatus.initial); + }); + + blocTest( + 'emailChanged updates email', + build: () => cubit, + act: (c) => c.emailChanged('test@example.com'), + expect: () => [isA()], + }); + }); +} +``` + +### Step 2: Run test to verify it fails + +Run: `cd apps && flutter test test/features/auth/presentation/cubits/login_cubit_test.dart` +Expected: FAIL - file not found + +### Step 3: Implement LoginCubit + +Create `apps/lib/features/auth/presentation/cubits/login_cubit.dart`: + +```dart +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import '../../data/auth_repository.dart'; +import '../../data/models/login_request.dart'; +import '../../data/models/auth_response.dart'; +import 'register_cubit.dart' show Email, Password; + +class LoginState extends Equatable { + final Email email; + final Password password; + final FormzSubmissionStatus status; + final String? errorMessage; + + const LoginState({ + this.email = const Email.pure(), + this.password = const Password.pure(), + this.status = FormzSubmissionStatus.initial, + this.errorMessage, + }); + + bool get isValid => email.isValid && password.isValid; + + LoginState copyWith({ + Email? email, + Password? password, + FormzSubmissionStatus? status, + String? errorMessage, + }) { + return LoginState( + email: email ?? this.email, + password: password ?? this.password, + status: status ?? this.status, + errorMessage: errorMessage, + ); + } + + @override + List get props => [email, password, status, errorMessage]; +} + +class LoginCubit extends Cubit { + final AuthRepository _repository; + + LoginCubit(this._repository) : super(const LoginState()); + + void emailChanged(String value) { + emit(state.copyWith(email: Email.dirty(value))); + } + + void passwordChanged(String value) { + emit(state.copyWith(password: Password.dirty(value))); + } + + Future submit() async { + if (!state.isValid) return null; + + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + + try { + final response = await _repository.login( + LoginRequest( + email: state.email.value, + password: state.password.value, + ), + ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + return response; + } catch (e) { + emit(state.copyWith( + status: FormzSubmissionStatus.failure, + errorMessage: e.toString(), + )); + return null; + } + } +} +``` + +### Step 4: Run test to verify it passes + +Run: `cd apps && flutter test test/features/auth/presentation/cubits/login_cubit_test.dart` +Expected: PASS + +### Step 5: Commit LoginCubit + +```bash +git add apps/lib/features/auth/presentation/cubits/login_cubit.dart apps/test/features/auth/presentation/cubits/login_cubit_test.dart +git commit -m "feat(apps): add LoginCubit for login form" +``` + +--- + +## Task 7: Dependency Injection + +**Files:** +- Create: `apps/lib/core/di/injection.dart` +- Create: `apps/lib/core/config/env.dart` + +### Step 1: Implement Env config + +Create `apps/lib/core/config/env.dart`: + +```dart +class Env { + static String get apiUrl { + const url = String.fromEnvironment('API_URL'); + return url.isNotEmpty ? url : 'http://localhost:8000'; + } +} +``` + +### Step 2: Implement DI configuration + +Create `apps/lib/core/di/injection.dart`: + +```dart +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get_it/get_it.dart'; +import '../api/api_client.dart'; +import '../storage/token_storage.dart'; +import '../config/env.dart'; +import '../../features/auth/data/auth_api.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../features/auth/data/auth_repository_impl.dart'; +import '../../features/auth/presentation/bloc/auth_bloc.dart'; + +final sl = GetIt.instance; + +Future configureDependencies() async { + final dio = Dio(BaseOptions(baseUrl: Env.apiUrl)); + final secureStorage = const FlutterSecureStorage(); + + sl.registerSingleton(dio); + sl.registerSingleton(secureStorage); + sl.registerSingleton(SecureTokenStorage(secureStorage)); + sl.registerSingleton(ApiClient( + baseUrl: Env.apiUrl, + tokenStorage: sl(), + dio: sl(), + )); + sl.registerSingleton(AuthApi(sl())); + sl.registerSingleton(AuthRepositoryImpl( + api: sl(), + tokenStorage: sl(), + )); + sl.registerSingleton(AuthBloc(sl())); +} +``` + +### Step 3: Commit DI configuration + +```bash +git add apps/lib/core/di/ apps/lib/core/config/ +git commit -m "feat(apps): add dependency injection configuration" +``` + +--- + +## Task 8: Update Register Screen + +**Files:** +- Modify: `apps/lib/features/auth/ui/screens/register_screen.dart` +- Modify: `apps/lib/features/auth/ui/screens/register_step2_screen.dart` + +### Step 1: Update register_screen.dart + +Modify `apps/lib/features/auth/ui/screens/register_screen.dart` to: +1. Add password input field +2. Wrap with BlocProvider for RegisterCubit +3. Call submitStep1 on button press +4. Pass email to step2 via route params + +### Step 2: Update register_step2_screen.dart + +Modify `apps/lib/features/auth/ui/screens/register_step2_screen.dart` to: +1. Remove password field (moved to step1) +2. Integrate with RegisterCubit +3. Call submitStep2 and navigate to /home on success +4. Keep invite code field but don't send + +### Step 3: Commit register screen updates + +```bash +git add apps/lib/features/auth/ui/screens/register_screen.dart apps/lib/features/auth/ui/screens/register_step2_screen.dart +git commit -m "feat(apps): update register screens with backend integration" +``` + +--- + +## Task 9: Update Login Screen + +**Files:** +- Modify: `apps/lib/features/auth/ui/screens/login_email_screen.dart` +- Modify: `apps/lib/features/auth/ui/screens/login_password_screen.dart` + +### Step 1: Update login flow + +Modify login screens to: +1. Pass email from step1 to step2 via route params +2. Wrap with BlocProvider for LoginCubit +3. Call submit and navigate to /home on success +4. Remove "Login with OTP" button + +### Step 2: Commit login screen updates + +```bash +git add apps/lib/features/auth/ui/screens/login_email_screen.dart apps/lib/features/auth/ui/screens/login_password_screen.dart +git commit -m "feat(apps): update login screens with backend integration" +``` + +--- + +## Task 10: Router Auth Protection + +**Files:** +- Modify: `apps/lib/core/router/app_router.dart` +- Modify: `apps/lib/main.dart` + +### Step 1: Update app_router.dart + +Add auth protection using GoRouter redirect: +1. Inject AuthBloc +2. Add redirect logic to check authentication +3. Protect routes requiring authentication + +### Step 2: Update main.dart + +1. Call configureDependencies() +2. Dispatch AuthStarted event +3. Provide AuthBloc at app level + +### Step 3: Commit router and main updates + +```bash +git add apps/lib/core/router/app_router.dart apps/lib/main.dart +git commit -m "feat(apps): add auth protection to router" +``` + +--- + +## Task 11: Integration Testing + +**Files:** +- Create: `apps/integration_test/auth_flow_test.dart` + +### Step 1: Write integration test + +Create integration test for complete signup and login flows. + +### Step 2: Run integration test + +Run: `cd apps && flutter test integration_test/auth_flow_test.dart` + +### Step 3: Commit integration test + +```bash +git add apps/integration_test/ +git commit -m "test(apps): add auth flow integration tests" +``` + +--- + +## Final: Run Flutter analyze + +```bash +cd apps && flutter analyze +``` + +Expected: No issues found + +--- + +## Summary + +- 11 tasks total +- Each task follows TDD: test first, implement, verify +- Frequent commits after each task +- Core infrastructure first, then UI integration