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

1624 lines
43 KiB
Markdown

# 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<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`:
```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`:
```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<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`:
```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`:
```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<bool> _handleTokenRefresh() async {
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
```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<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`:
```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`:
```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
```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<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`:
```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`:
```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
```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<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 token',
build: () {
when(() => mockRepository.getAccessToken()).thenAnswer((_) async => 'valid_token');
return authBloc;
},
act: (bloc) => bloc.add(AuthStarted()),
expect: () => [AuthLoading(), isA<AuthAuthenticated>()],
);
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`:
```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`:
```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`:
```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 token = await _repository.getAccessToken();
if (token != null) {
emit(const AuthAuthenticated(
user: AuthUser(id: '', email: ''),
));
} else {
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
```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<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`:
```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
```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<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`:
```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
```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<void> configureDependencies() async {
final dio = Dio(BaseOptions(baseUrl: Env.apiUrl));
final secureStorage = const FlutterSecureStorage();
sl.registerSingleton<Dio>(dio);
sl.registerSingleton<FlutterSecureStorage>(secureStorage);
sl.registerSingleton<TokenStorage>(SecureTokenStorage(secureStorage));
sl.registerSingleton<ApiClient>(ApiClient(
baseUrl: Env.apiUrl,
tokenStorage: sl<TokenStorage>(),
dio: sl<Dio>(),
));
sl.registerSingleton<AuthApi>(AuthApi(sl<ApiClient>()));
sl.registerSingleton<AuthRepository>(AuthRepositoryImpl(
api: sl<AuthApi>(),
tokenStorage: sl<TokenStorage>(),
));
sl.registerSingleton<AuthBloc>(AuthBloc(sl<AuthRepository>()));
}
```
### 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