Files
social-app/docs/plans/2026-02-25-flutter-auth-integration-plan.md
T

45 KiB

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:

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<ApiException>());
      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:

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<String, dynamic>? 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:

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:

abstract class TokenStorage {
  Future<String?> getAccessToken();
  Future<String?> getRefreshToken();
  Future<void> saveTokens({required String access, required String refresh});
  Future<void> clear();
}

class SecureTokenStorage implements TokenStorage {
  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';

  final dynamic _storage;

  SecureTokenStorage([this._storage]);

  @override
  Future<String?> getAccessToken() async {
    return _storage?.read(key: _accessTokenKey);
  }

  @override
  Future<String?> getRefreshToken() async {
    return _storage?.read(key: _refreshTokenKey);
  }

  @override
  Future<void> saveTokens({required String access, required String refresh}) async {
    await _storage?.write(key: _accessTokenKey, value: access);
    await _storage?.write(key: _refreshTokenKey, value: refresh);
  }

  @override
  Future<void> 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:

import 'package:dio/dio.dart';
import '../storage/token_storage.dart';

class ApiInterceptor extends Interceptor {
  final TokenStorage tokenStorage;
  final Future<bool> 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:

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;
  final Future<AuthResponse> Function(String)? _refreshToken;

  ApiClient({
    required String baseUrl,
    required TokenStorage tokenStorage,
    Dio? dio,
    Future<AuthResponse> Function(String)? refreshToken,
  })  : _tokenStorage = tokenStorage,
        _refreshToken = refreshToken,
        _dio = dio ?? Dio(BaseOptions(baseUrl: baseUrl)) {
    _dio.interceptors.add(ApiInterceptor(
      tokenStorage: _tokenStorage,
      onTokenRefresh: _handleTokenRefresh,
    ));
  }

  Dio get dio => _dio;

  Future<bool> _handleTokenRefresh() async {
    final refreshToken = await _tokenStorage.getRefreshToken();
    if (refreshToken == null || _refreshToken == null) return false;
    try {
      final response = await _refreshToken!(refreshToken);
      await _tokenStorage.saveTokens(
        access: response.accessToken,
        refresh: response.refreshToken,
      );
      return true;
    } catch (_) {
      return false;
    }
  }

  Future<Response<T>> get<T>(String path, {Options? options}) async {
    try {
      return await _dio.get<T>(path, options: options);
    } catch (e) {
      throw ApiException.fromDioError(e);
    }
  }

  Future<Response<T>> post<T>(String path, {dynamic data, Options? options}) async {
    try {
      return await _dio.post<T>(path, data: data, options: options);
    } catch (e) {
      throw ApiException.fromDioError(e);
    }
  }
}

Step 11: Commit core API infrastructure

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:

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:

class SignupStartRequest {
  final String username;
  final String email;
  final String password;

  const SignupStartRequest({
    required this.username,
    required this.email,
    required this.password,
  });

  Map<String, dynamic> toJson() => {
        'username': username,
        'email': email,
        'password': password,
      };
}

class SignupVerifyRequest {
  final String email;
  final String token;

  const SignupVerifyRequest({
    required this.email,
    required this.token,
  });

  Map<String, dynamic> toJson() => {
        'email': email,
        'token': token,
      };
}

class SignupResendRequest {
  final String email;

  const SignupResendRequest({required this.email});

  Map<String, dynamic> toJson() => {'email': email};
}

Step 4: Implement LoginRequest

Create apps/lib/features/auth/data/models/login_request.dart:

class LoginRequest {
  final String email;
  final String password;

  const LoginRequest({
    required this.email,
    required this.password,
  });

  Map<String, dynamic> toJson() => {
        'email': email,
        'password': password,
      };
}

class RefreshRequest {
  final String refreshToken;

  const RefreshRequest({required this.refreshToken});

  Map<String, dynamic> toJson() => {'refresh_token': refreshToken};
}

class LogoutRequest {
  final String refreshToken;

  const LogoutRequest({required this.refreshToken});

  Map<String, dynamic> toJson() => {'refresh_token': refreshToken};
}

Step 5: Implement AuthResponse

Create apps/lib/features/auth/data/models/auth_response.dart:

class AuthUser {
  final String id;
  final String email;

  const AuthUser({required this.id, required this.email});

  factory AuthUser.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>),
    );
  }
}

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<String, dynamic> 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

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:

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:

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<SignupStartResponse> signupStart(SignupStartRequest request) async {
    final response = await _client.post('$_prefix/signup/start', data: request.toJson());
    return SignupStartResponse.fromJson(response.data);
  }

  Future<AuthResponse> signupVerify(SignupVerifyRequest request) async {
    final response = await _client.post('$_prefix/signup/verify', data: request.toJson());
    return AuthResponse.fromJson(response.data);
  }

  Future<SignupStartResponse> signupResend(SignupResendRequest request) async {
    final response = await _client.post('$_prefix/signup/resend', data: request.toJson());
    return SignupStartResponse.fromJson(response.data);
  }

  Future<AuthResponse> login(LoginRequest request) async {
    final response = await _client.post('$_prefix/login', data: request.toJson());
    return AuthResponse.fromJson(response.data);
  }

  Future<AuthResponse> refresh(RefreshRequest request) async {
    final response = await _client.post('$_prefix/refresh', data: request.toJson());
    return AuthResponse.fromJson(response.data);
  }

  Future<void> 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:

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<SignupStartResponse> signupStart(SignupStartRequest request);
  Future<AuthResponse> signupVerify(SignupVerifyRequest request);
  Future<SignupStartResponse> signupResend(SignupResendRequest request);
  Future<AuthResponse> login(LoginRequest request);
  Future<AuthResponse> refresh(String refreshToken);
  Future<void> logout();
  Future<String?> getAccessToken();
  Future<String?> getRefreshToken();
  Future<bool> isAuthenticated();
}

Step 5: Implement AuthRepositoryImpl

Create apps/lib/features/auth/data/auth_repository_impl.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<SignupStartResponse> signupStart(SignupStartRequest request) {
    return _api.signupStart(request);
  }

  @override
  Future<AuthResponse> signupVerify(SignupVerifyRequest request) async {
    final response = await _api.signupVerify(request);
    await _tokenStorage.saveTokens(
      access: response.accessToken,
      refresh: response.refreshToken,
    );
    return response;
  }

  @override
  Future<SignupStartResponse> signupResend(SignupResendRequest request) {
    return _api.signupResend(request);
  }

  @override
  Future<AuthResponse> login(LoginRequest request) async {
    final response = await _api.login(request);
    await _tokenStorage.saveTokens(
      access: response.accessToken,
      refresh: response.refreshToken,
    );
    return response;
  }

  @override
  Future<AuthResponse> 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<void> logout() async {
    final refreshToken = await _tokenStorage.getRefreshToken();
    if (refreshToken != null) {
      await _api.logout(LogoutRequest(refreshToken: refreshToken));
    }
    await _tokenStorage.clear();
  }

  @override
  Future<String?> getAccessToken() => _tokenStorage.getAccessToken();

  @override
  Future<String?> getRefreshToken() => _tokenStorage.getRefreshToken();

  @override
  Future<bool> 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

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:

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/data/models/auth_response.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<AuthBloc, AuthState>(
      '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<AuthBloc, AuthState>(
      'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid refresh token',
      build: () {
        when(() => mockRepository.getRefreshToken()).thenAnswer((_) async => 'valid_refresh');
        when(() => mockRepository.refresh('valid_refresh')).thenAnswer((_) async => AuthResponse(
          accessToken: 'new_access',
          refreshToken: 'new_refresh',
          expiresIn: 3600,
          tokenType: 'bearer',
          user: const AuthUser(id: '123', email: 'test@example.com'),
        ));
        return authBloc;
      },
      act: (bloc) => bloc.add(AuthStarted()),
      expect: () => [AuthLoading(), isA<AuthAuthenticated>()],
    );

    blocTest<AuthBloc, AuthState>(
      'emits [AuthLoading, AuthUnauthenticated] when refresh token expired',
      build: () {
        when(() => mockRepository.getRefreshToken()).thenAnswer((_) async => 'expired_refresh');
        when(() => mockRepository.refresh('expired_refresh')).thenThrow(Exception('Invalid refresh token'));
        when(() => mockRepository.logout()).thenAnswer((_) async {});
        return authBloc;
      },
      act: (bloc) => bloc.add(AuthStarted()),
      expect: () => [AuthLoading(), AuthUnauthenticated()],
    );

    blocTest<AuthBloc, AuthState>(
      '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:

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<Object?> get props => [id, email];
}

abstract class AuthState extends Equatable {
  const AuthState();

  @override
  List<Object?> get props => [];
}

class AuthInitial extends AuthState {}

class AuthLoading extends AuthState {}

class AuthAuthenticated extends AuthState {
  final AuthUser user;

  const AuthAuthenticated({required this.user});

  @override
  List<Object?> get props => [user];
}

class AuthUnauthenticated extends AuthState {}

Step 4: Implement AuthEvent

Create apps/lib/features/auth/presentation/bloc/auth_event.dart:

import 'package:equatable/equatable.dart';
import 'auth_state.dart';

abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object?> get props => [];
}

class AuthStarted extends AuthEvent {}

class AuthLoggedIn extends AuthEvent {
  final AuthUser user;

  const AuthLoggedIn({required this.user});

  @override
  List<Object?> get props => [user];
}

class AuthLoggedOut extends AuthEvent {}

Step 5: Implement AuthBloc

Create apps/lib/features/auth/presentation/bloc/auth_bloc.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<AuthEvent, AuthState> {
  final AuthRepository _repository;

  AuthBloc(this._repository) : super(AuthInitial()) {
    on<AuthStarted>(_onStarted);
    on<AuthLoggedIn>(_onLoggedIn);
    on<AuthLoggedOut>(_onLoggedOut);
  }

  Future<void> _onStarted(AuthStarted event, Emitter<AuthState> emit) async {
    emit(AuthLoading());
    final refreshToken = await _repository.getRefreshToken();
    if (refreshToken != null) {
      try {
        final response = await _repository.refresh(refreshToken);
        emit(AuthAuthenticated(
          user: AuthUser(id: response.user.id, email: response.user.email),
        ));
        return;
      } catch (_) {
        await _repository.logout();
      }
    }
    emit(AuthUnauthenticated());
  }

  void _onLoggedIn(AuthLoggedIn event, Emitter<AuthState> emit) {
    emit(AuthAuthenticated(user: event.user));
  }

  Future<void> _onLoggedOut(AuthLoggedOut event, Emitter<AuthState> 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

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:

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<RegisterCubit, RegisterState>(
      'usernameChanged updates username',
      build: () => cubit,
      act: (c) => c.usernameChanged('testuser'),
      expect: () => [isA<RegisterState>()],
    );

    blocTest<RegisterCubit, RegisterState>(
      'emailChanged updates email',
      build: () => cubit,
      act: (c) => c.emailChanged('test@example.com'),
      expect: () => [isA<RegisterState>()],
    );

    blocTest<RegisterCubit, RegisterState>(
      'passwordChanged updates password',
      build: () => cubit,
      act: (c) => c.passwordChanged('password123'),
      expect: () => [isA<RegisterState>()],
    );
  });
}

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:

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<String, String> {
  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<String, String> {
  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<String, String> {
  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<String, String> {
  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<Object?> get props => [
        username,
        email,
        password,
        verificationCode,
        status,
        errorMessage,
        pendingEmail,
        codeSent,
      ];
}

class RegisterCubit extends Cubit<RegisterState> {
  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<bool> 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<AuthResponse?> 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<void> 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

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:

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<LoginCubit, LoginState>(
      'emailChanged updates email',
      build: () => cubit,
      act: (c) => c.emailChanged('test@example.com'),
      expect: () => [isA<LoginState>()],
    });
  });
}

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:

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<Object?> get props => [email, password, status, errorMessage];
}

class LoginCubit extends Cubit<LoginState> {
  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<AuthResponse?> 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

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:

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:

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<void> configureDependencies() async {
  final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
  final secureStorage = const FlutterSecureStorage();
  final tokenStorage = SecureTokenStorage(secureStorage);

  sl.registerSingleton<Dio>(dio);
  sl.registerSingleton<FlutterSecureStorage>(secureStorage);
  sl.registerSingleton<TokenStorage>(tokenStorage);
  
  // Register AuthApi and AuthRepository first (needed for ApiClient refresh)
  final authApi = AuthApi(ApiClient(
    baseUrl: Env.apiUrl,
    tokenStorage: tokenStorage,
    dio: dio,
  ));
  sl.registerSingleton<AuthApi>(authApi);
  
  final authRepository = AuthRepositoryImpl(
    api: authApi,
    tokenStorage: tokenStorage,
  );
  sl.registerSingleton<AuthRepository>(authRepository);
  
  // Re-register ApiClient with refresh capability
  sl.registerSingleton<ApiClient>(ApiClient(
    baseUrl: Env.apiUrl,
    tokenStorage: tokenStorage,
    dio: dio,
    refreshToken: (token) => authRepository.refresh(token),
  ));
  
  sl.registerSingleton<AuthBloc>(AuthBloc(authRepository));
}

Step 3: Commit DI configuration

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

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

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

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

git add apps/integration_test/
git commit -m "test(apps): add auth flow integration tests"

Final: Run Flutter analyze

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