1624 lines
43 KiB
Markdown
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
|