CODEX_ADK/BACKEND/.claude-docs/FLUTTER-INTEGRATION.md
Svrnty 229a0698a3 Initial commit: CODEX_ADK monorepo
Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend.
Implements CQRS architecture, OpenAPI contract-first API design.

BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama
FRONTEND: Cross-platform UI with strict typing and Result-based error handling

Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
2025-10-26 23:12:32 -04:00

962 lines
22 KiB
Markdown

# Flutter Frontend: Complete API Integration Guide
## Objective
Integrate your Flutter app with the Codex CQRS-based ASP.NET Core API using type-safe Dart clients generated from OpenAPI specification.
---
## Prerequisites
- Flutter SDK 3.0+ installed
- Dart 3.0+
- Git access to backend repository
- VS Code with Flutter/Dart extensions or Android Studio
- Basic understanding of REST APIs and Dart
---
## Architecture Overview
The Codex API uses **CQRS pattern** with OpenHarbor.CQRS framework:
- **Queries** (Read): `POST /api/query/{queryName}` or `GET /api/query/{queryName}`
- **Commands** (Write): `POST /api/command/{commandName}`
- **Dynamic Queries** (Paginated): `POST /api/dynamicquery/{itemType}`
All requests use JSON payloads (even for empty requests).
---
## Step 1: Access OpenAPI Specification
### Get the Spec File
```bash
# Option A: Clone backend repository
git clone <backend-repo-url>
cd backend
# The spec is located at: docs/openapi.json
# Option B: Download from running API
curl http://localhost:5246/swagger/v1/swagger.json -o openapi.json
```
### Review Documentation
Before coding, read:
- `backend/docs/ARCHITECTURE.md` - Understand CQRS patterns
- `backend/docs/CHANGELOG.md` - Check for breaking changes
- `backend/docs/openapi.json` - Your API contract
---
## Step 2: Generate Dart API Client
### Install OpenAPI Generator
```bash
# Install via Homebrew (macOS)
brew install openapi-generator
# Or via npm
npm install -g @openapitools/openapi-generator-cli
# Or use Docker
docker pull openapitools/openapi-generator-cli
```
### Generate Dart Client
```bash
# Navigate to your Flutter project
cd /path/to/your/flutter-app
# Create API directory
mkdir -p lib/api
# Generate Dart client
openapi-generator-cli generate \
-i ../backend/docs/openapi.json \
-g dart \
-o lib/api/generated \
--additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client
# If using Docker:
docker run --rm \
-v ${PWD}:/local openapitools/openapi-generator-cli generate \
-i /local/../backend/docs/openapi.json \
-g dart \
-o /local/lib/api/generated \
--additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client
```
This generates:
- `lib/api/generated/lib/api.dart` - API client
- `lib/api/generated/lib/model/` - DTOs and models
- `lib/api/generated/doc/` - API documentation
---
## Step 3: Configure Flutter Project
### Update pubspec.yaml
Add required dependencies:
```yaml
name: your_flutter_app
description: Your Flutter app description
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# HTTP client
http: ^1.1.0
# JSON serialization
json_annotation: ^4.8.1
# State management (choose one)
flutter_riverpod: ^2.4.9 # Recommended
# provider: ^6.1.1
# bloc: ^8.1.3
# Secure storage for tokens
flutter_secure_storage: ^9.0.0
# Environment configuration
flutter_dotenv: ^5.1.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.7
json_serializable: ^6.7.1
flutter_lints: ^3.0.1
mockito: ^5.4.4
flutter:
uses-material-design: true
# Add environment files
assets:
- .env
- .env.development
- .env.production
```
### Run pub get
```bash
flutter pub get
cd lib/api/generated
flutter pub get
cd ../../..
```
---
## Step 4: Setup Environment Configuration
### Create Environment Files
**`.env.development`:**
```env
API_BASE_URL=http://localhost:5246
API_TIMEOUT=30000
```
**`.env.production`:**
```env
API_BASE_URL=https://api.yourapp.com
API_TIMEOUT=30000
```
**`.env` (default/local):**
```env
API_BASE_URL=http://localhost:5246
API_TIMEOUT=30000
```
### Create Config Class
**`lib/config/api_config.dart`:**
```dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
class ApiConfig {
static String get baseUrl => dotenv.env['API_BASE_URL'] ?? 'http://localhost:5246';
static int get timeout => int.parse(dotenv.env['API_TIMEOUT'] ?? '30000');
static const String apiVersion = 'v1';
}
```
### Initialize in main.dart
**`lib/main.dart`:**
```dart
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load environment variables
await dotenv.load(fileName: ".env");
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Codex App',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const HomePage(),
);
}
}
```
---
## Step 5: Create API Client Service
### Create HTTP Client with Interceptors
**`lib/services/api_client.dart`:**
```dart
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config/api_config.dart';
class ApiClient extends http.BaseClient {
final http.Client _client = http.Client();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Add base URL if not already present
if (!request.url.toString().startsWith('http')) {
request = _updateRequestUrl(request);
}
// Add authentication token
final token = await _storage.read(key: 'auth_token');
if (token != null && token.isNotEmpty) {
request.headers['Authorization'] = 'Bearer $token';
}
// Add default headers
request.headers['Content-Type'] = 'application/json';
request.headers['Accept'] = 'application/json';
print('REQUEST ${request.method} ${request.url}');
try {
final response = await _client.send(request);
print('RESPONSE ${response.statusCode} ${request.url}');
return response;
} catch (e) {
print('ERROR Request failed: $e');
rethrow;
}
}
http.BaseRequest _updateRequestUrl(http.BaseRequest request) {
final newUri = Uri.parse('${ApiConfig.baseUrl}${request.url.path}');
if (request is http.Request) {
final newRequest = http.Request(request.method, newUri)
..headers.addAll(request.headers)
..body = request.body;
return newRequest;
}
throw UnsupportedError('Unsupported request type');
}
Future<void> setAuthToken(String token) async {
await _storage.write(key: 'auth_token', value: token);
}
Future<void> clearAuthToken() async {
await _storage.delete(key: 'auth_token');
}
Future<String?> getAuthToken() async {
return await _storage.read(key: 'auth_token');
}
}
```
---
## Step 6: Create Service Layer (Repository Pattern)
### Health Query Service Example
**`lib/services/health_service.dart`:**
```dart
import 'package:codex_api_client/api.dart';
import '../models/api_result.dart';
import 'api_client.dart';
class HealthService {
final ApiClient _apiClient;
late final DefaultApi _api;
HealthService(this._apiClient) {
_api = DefaultApi(_apiClient, ApiConfig.baseUrl);
}
/// Check if API is healthy
Future<ApiResult<bool>> checkHealth() async {
try {
// Call POST /api/query/health
final response = await _api.apiQueryHealthPost(
healthQuery: HealthQuery(), // Empty query object
);
return ApiResult.success(response ?? false);
} on ApiException catch (e) {
return ApiResult.failure(
message: e.message ?? 'Health check failed',
statusCode: e.code,
);
} catch (e) {
return ApiResult.failure(
message: 'Network error: $e',
);
}
}
}
```
### Create ApiResult Model
**`lib/models/api_result.dart`:**
```dart
class ApiResult<T> {
final T? data;
final String? errorMessage;
final int? statusCode;
final Map<String, List<String>>? validationErrors;
bool get isSuccess => errorMessage == null;
bool get isFailure => !isSuccess;
ApiResult.success(this.data)
: errorMessage = null,
statusCode = null,
validationErrors = null;
ApiResult.failure({
required String message,
this.statusCode,
this.validationErrors,
}) : data = null,
errorMessage = message;
/// Handle the result with callbacks
R when<R>({
required R Function(T data) success,
required R Function(String message) failure,
}) {
if (isSuccess && data != null) {
return success(data as T);
} else {
return failure(errorMessage ?? 'Unknown error');
}
}
}
```
---
## Step 7: Integrate with Riverpod (State Management)
### Create Providers
**`lib/providers/api_providers.dart`:**
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/api_client.dart';
import '../services/health_service.dart';
// API Client Provider
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
// Health Service Provider
final healthServiceProvider = Provider<HealthService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return HealthService(apiClient);
});
// Health Check Provider (auto-fetches)
final healthCheckProvider = FutureProvider<bool>((ref) async {
final healthService = ref.watch(healthServiceProvider);
final result = await healthService.checkHealth();
return result.when(
success: (isHealthy) => isHealthy,
failure: (_) => false,
);
});
```
### Use in UI
**`lib/screens/home_screen.dart`:**
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/api_providers.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final healthCheck = ref.watch(healthCheckProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Codex App'),
actions: [
// API Health Indicator
healthCheck.when(
data: (isHealthy) => Icon(
isHealthy ? Icons.check_circle : Icons.error,
color: isHealthy ? Colors.green : Colors.red,
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Icon(Icons.error, color: Colors.red),
),
const SizedBox(width: 16),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
healthCheck.when(
data: (isHealthy) => Text(
isHealthy ? 'API Connected' : 'API Disconnected',
style: Theme.of(context).textTheme.headlineSmall,
),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
),
],
),
),
);
}
}
```
---
## Step 8: Handle CQRS Commands and Queries
### Example: User Command Service
**`lib/services/user_service.dart`:**
```dart
import 'package:codex_api_client/api.dart';
import '../models/api_result.dart';
import 'api_client.dart';
class UserService {
final ApiClient _apiClient;
late final DefaultApi _api;
UserService(this._apiClient) {
_api = DefaultApi(_apiClient, ApiConfig.baseUrl);
}
/// Create a new user (Command)
Future<ApiResult<void>> createUser({
required String username,
required String email,
required String password,
}) async {
try {
await _api.apiCommandCreateuserPost(
createUserCommand: CreateUserCommand(
username: username,
email: email,
password: password,
),
);
return ApiResult.success(null);
} on ApiException catch (e) {
return _handleApiException(e);
} catch (e) {
return ApiResult.failure(message: 'Network error: $e');
}
}
/// Get paginated users (Dynamic Query)
Future<ApiResult<PagedResult<UserDto>>> getUsers({
int page = 1,
int pageSize = 20,
String? searchTerm,
}) async {
try {
final filters = searchTerm != null && searchTerm.isNotEmpty
? [
DynamicQueryFilter(
path: 'username',
type: FilterType.contains,
value: searchTerm,
),
]
: <DynamicQueryFilter>[];
final response = await _api.apiDynamicqueryUserPost(
dynamicQuery: DynamicQuery(
page: page,
pageSize: pageSize,
filters: filters,
sorts: [
DynamicQuerySort(
path: 'createdAt',
descending: true,
),
],
),
);
return ApiResult.success(response);
} on ApiException catch (e) {
return _handleApiException(e);
} catch (e) {
return ApiResult.failure(message: 'Network error: $e');
}
}
ApiResult<T> _handleApiException<T>(ApiException e) {
switch (e.code) {
case 400:
// Validation errors from FluentValidation
return ApiResult.failure(
message: 'Validation failed',
statusCode: 400,
validationErrors: _parseValidationErrors(e.message),
);
case 401:
return ApiResult.failure(
message: 'Unauthorized. Please log in.',
statusCode: 401,
);
case 403:
return ApiResult.failure(
message: 'Permission denied',
statusCode: 403,
);
case 404:
return ApiResult.failure(
message: 'Resource not found',
statusCode: 404,
);
case 500:
return ApiResult.failure(
message: 'Server error. Please try again.',
statusCode: 500,
);
default:
return ApiResult.failure(
message: e.message ?? 'Unknown error',
statusCode: e.code,
);
}
}
Map<String, List<String>>? _parseValidationErrors(String? message) {
// Parse validation errors from response
// Format depends on backend's error response structure
return null;
}
}
```
---
## Step 9: Testing
### Unit Tests
**`test/services/health_service_test.dart`:**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:your_app/services/health_service.dart';
import 'package:your_app/services/api_client.dart';
@GenerateMocks([ApiClient])
import 'health_service_test.mocks.dart';
void main() {
late HealthService healthService;
late MockApiClient mockApiClient;
setUp(() {
mockApiClient = MockApiClient();
healthService = HealthService(mockApiClient);
});
group('HealthService', () {
test('checkHealth returns true when API is healthy', () async {
// Arrange
when(mockApiClient.send(any)).thenAnswer(
(_) async => http.StreamedResponse(
Stream.value(utf8.encode('true')),
200,
),
);
// Act
final result = await healthService.checkHealth();
// Assert
expect(result.isSuccess, true);
expect(result.data, true);
});
test('checkHealth returns failure on error', () async {
// Arrange
when(mockApiClient.send(any)).thenThrow(Exception('Network error'));
// Act
final result = await healthService.checkHealth();
// Assert
expect(result.isFailure, true);
expect(result.errorMessage, contains('Network error'));
});
});
}
```
### Integration Tests
**`integration_test/api_integration_test.dart`:**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/services/api_client.dart';
import 'package:your_app/services/health_service.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('API Integration Tests', () {
late ApiClient apiClient;
late HealthService healthService;
setUp(() {
apiClient = ApiClient();
healthService = HealthService(apiClient);
});
testWidgets('Health check endpoint works', (tester) async {
// Ensure backend is running on localhost:5246
final result = await healthService.checkHealth();
expect(result.isSuccess, true);
expect(result.data, true);
});
});
}
```
### Run Tests
```bash
# Unit tests
flutter test
# Integration tests (requires backend running)
flutter test integration_test/
```
---
## Step 10: Error Handling & User Feedback
### Create Error Display Widget
**`lib/widgets/error_snackbar.dart`:**
```dart
import 'package:flutter/material.dart';
import '../models/api_result.dart';
class ErrorSnackbar {
static void show(BuildContext context, ApiResult result) {
if (result.isFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.errorMessage ?? 'An error occurred'),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Dismiss',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
}
static void showValidationErrors(
BuildContext context,
Map<String, List<String>>? errors,
) {
if (errors == null || errors.isEmpty) return;
final errorMessages = errors.entries
.map((e) => '${e.key}: ${e.value.join(', ')}')
.join('\n');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessages),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
}
```
---
## Step 11: Monitoring API Changes
### Create Update Script
**`scripts/update_api_client.sh`:**
```bash
#!/bin/bash
echo "Updating API client from backend..."
# Pull latest backend changes
cd ../backend
git pull origin main
# Regenerate Dart client
cd ../flutter-app
openapi-generator-cli generate \
-i ../backend/docs/openapi.json \
-g dart \
-o lib/api/generated \
--additional-properties=pubName=codex_api_client,pubLibrary=codex_api_client
echo "API client updated!"
echo ""
echo "Next steps:"
echo "1. Check backend/docs/CHANGELOG.md for breaking changes"
echo "2. Run: flutter pub get"
echo "3. Run: flutter test"
echo "4. Fix any compilation errors"
```
Make executable:
```bash
chmod +x scripts/update_api_client.sh
```
---
## Step 12: CI/CD Integration
### GitHub Actions Workflow
**`.github/workflows/flutter_ci.yml`:**
```yaml
name: Flutter CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Flutter app
uses: actions/checkout@v3
- name: Checkout backend
uses: actions/checkout@v3
with:
repository: your-org/backend
path: backend
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'
channel: 'stable'
- name: Install OpenAPI Generator
run: npm install -g @openapitools/openapi-generator-cli
- name: Check for API changes
run: |
if ! diff -q lib/api/openapi.json backend/docs/openapi.json; then
echo "API changes detected!"
./scripts/update_api_client.sh
fi
- name: Get dependencies
run: flutter pub get
- name: Analyze code
run: flutter analyze
- name: Run tests
run: flutter test
- name: Build app
run: flutter build apk --debug
```
---
## Complete Checklist for Flutter Team
### Initial Setup
- [ ] Clone backend repository or access `docs/openapi.json`
- [ ] Review `docs/CHANGELOG.md` for breaking changes
- [ ] Review `docs/ARCHITECTURE.md` for CQRS patterns
- [ ] Install OpenAPI Generator CLI
- [ ] Generate Dart API client
- [ ] Update `pubspec.yaml` with dependencies
- [ ] Run `flutter pub get`
- [ ] Create environment configuration files
- [ ] Setup API client with interceptors
### Service Layer
- [ ] Create `ApiClient` with authentication
- [ ] Create `ApiResult` model for error handling
- [ ] Create service classes (Health, User, etc.)
- [ ] Setup Riverpod providers
- [ ] Implement error handling
### Testing
- [ ] Write unit tests for services
- [ ] Write widget tests for UI
- [ ] Setup integration tests
- [ ] Test authentication flow
- [ ] Test error scenarios (401, 400, 500)
### UI Integration
- [ ] Create error display widgets
- [ ] Implement loading states
- [ ] Add API health indicator
- [ ] Handle validation errors in forms
### DevOps
- [ ] Create API update script
- [ ] Setup CI/CD pipeline
- [ ] Configure environment variables
- [ ] Document team workflow
---
## Common Issues & Solutions
### Issue: "Failed to load .env file"
**Solution:** Ensure `.env` file exists and is listed in `pubspec.yaml` assets.
### Issue: "401 Unauthorized on all requests"
**Solution:** Check that `ApiClient` is properly adding Bearer token to headers.
### Issue: "Connection refused to localhost"
**Solution:**
- On Android emulator: use `http://10.0.2.2:5246`
- On iOS simulator: use `http://localhost:5246`
- Update `API_BASE_URL` in `.env` accordingly
### Issue: "Type errors after regenerating client"
**Solution:** Breaking changes occurred. Review `backend/docs/CHANGELOG.md` and update service layer.
### Issue: "OpenAPI generator fails"
**Solution:** Ensure `openapi.json` is valid JSON. Validate with: `cat openapi.json | python3 -m json.tool`
---
## Android Network Configuration
**`android/app/src/main/AndroidManifest.xml`:**
```xml
<manifest>
<application
android:usesCleartextTraffic="true">
<!-- Your app config -->
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
```
---
## iOS Network Configuration
**`ios/Runner/Info.plist`:**
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
```
---
## Support & Resources
- **OpenAPI Spec:** `backend/docs/openapi.json`
- **Breaking Changes:** `backend/docs/CHANGELOG.md`
- **Architecture:** `backend/docs/ARCHITECTURE.md`
- **Backend API (Dev):** `http://localhost:5246`
- **Swagger UI (Dev):** `http://localhost:5246/swagger`
---
**Last Updated:** 2025-01-26
**Backend API Version:** v1
**OpenAPI Version:** 3.0.1
**Recommended Dart Client Generator:** openapi-generator-cli with `dart` generator