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

22 KiB

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

# 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

# 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

# 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:

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

flutter pub get
cd lib/api/generated
flutter pub get
cd ../../..

Step 4: Setup Environment Configuration

Create Environment Files

.env.development:

API_BASE_URL=http://localhost:5246
API_TIMEOUT=30000

.env.production:

API_BASE_URL=https://api.yourapp.com
API_TIMEOUT=30000

.env (default/local):

API_BASE_URL=http://localhost:5246
API_TIMEOUT=30000

Create Config Class

lib/config/api_config.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:

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:

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:

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:

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:

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:

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:

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:

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:

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

# 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:

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:

#!/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:

chmod +x scripts/update_api_client.sh

Step 12: CI/CD Integration

GitHub Actions Workflow

.github/workflows/flutter_ci.yml:

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:

<manifest>
    <application
        android:usesCleartextTraffic="true">
        <!-- Your app config -->
    </application>

    <uses-permission android:name="android.permission.INTERNET"/>
</manifest>

iOS Network Configuration

ios/Runner/Info.plist:

<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