ailixir-chemical-rag / FLUTTER_INTEGRATION.md
AILIXIR Bot
Auto-sync: f5ce6ac77794a3b45f6053fe4c4a770721c82ec1
8cc99a5
|
Raw
History Blame Contribute Delete
20.3 kB

Flutter Integration Guide

πŸ“± Connecting Flutter App to Chemical RAG System

Complete guide for integrating the Chemical RAG API with a Flutter mobile application.


πŸ”Œ REST Client Setup

Step 1: Add Dependencies

Update your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  http: ^1.1.0
  dio: ^5.3.0           # Alternative HTTP client
  freezed_annotation: ^2.4.0
  json_serializable: ^6.7.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^2.4.0
  json_serializable: ^6.7.0

Run: flutter pub get


🎯 Data Models

models/compound.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'compound.freezed.dart';
part 'compound.g.dart';

@freezed
class SearchRequest with _$SearchRequest {
  const factory SearchRequest({
    required String smiles,
    @Default(3) int topK,
  }) = _SearchRequest;

  factory SearchRequest.fromJson(Map<String, dynamic> json) =>
      _$SearchRequestFromJson(json);
}

@freezed
class CompoundResult with _$CompoundResult {
  const factory CompoundResult({
    required String smiles,
    required double distance,
    String? image,
  }) = _CompoundResult;

  factory CompoundResult.fromJson(Map<String, dynamic> json) =>
      _$CompoundResultFromJson(json);
}

@freezed
class SearchResponse with _$SearchResponse {
  const factory SearchResponse({
    required List<CompoundResult> results,
  }) = _SearchResponse;

  factory SearchResponse.fromJson(Map<String, dynamic> json) =>
      _$SearchResponseFromJson(json);
}

@freezed
class ApiStats with _$ApiStats {
  const factory ApiStats({
    required int compounds,
    @JsonKey(name: 'index_size') required int indexSize,
    @JsonKey(name: 'bit_size') required int bitSize,
  }) = _ApiStats;

  factory ApiStats.fromJson(Map<String, dynamic> json) =>
      _$ApiStatsFromJson(json);
}

@freezed
class HealthStatus with _$HealthStatus {
  const factory HealthStatus({
    required String status,
    required String service,
    required String version,
  }) = _HealthStatus;

  factory HealthStatus.fromJson(Map<String, dynamic> json) =>
      _$HealthStatusFromJson(json);
}

πŸ”Œ API Service

services/chemical_api_service.dart

import 'package:dio/dio.dart';
import '../models/compound.dart';

class ChemicalApiService {
  late final Dio _dio;
  final String baseUrl;

  ChemicalApiService({this.baseUrl = 'http://127.0.0.1:8000'}) {
    _dio = Dio(
      BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 10),
        persistentConnection: true,
      ),
    );

    // Add logging and error handling
    _dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
        logPrint: (obj) => print(obj),
      ),
    );
  }

  /// Health check endpoint
  Future<HealthStatus> getHealth() async {
    try {
      final response = await _dio.get('/health');
      return HealthStatus.fromJson(response.data);
    } catch (e) {
      rethrow;
    }
  }

  /// Get system statistics
  Future<ApiStats> getStats() async {
    try {
      final response = await _dio.get('/stats');
      return ApiStats.fromJson(response.data);
    } catch (e) {
      rethrow;
    }
  }

  /// Search for similar compounds
  Future<SearchResponse> search({
    required String smiles,
    int topK = 3,
  }) async {
    try {
      final payload = SearchRequest(smiles: smiles, topK: topK);
      final response = await _dio.post(
        '/search',
        data: payload.toJson(),
      );
      return SearchResponse.fromJson(response.data);
    } on DioException catch (e) {
      // Handle specific errors
      if (e.response?.statusCode == 400) {
        final error = e.response?.data['detail'] ?? 'Invalid request';
        throw ArgumentError(error);
      }
      rethrow;
    }
  }

  /// Check if server is running
  Future<bool> isServerRunning() async {
    try {
      await getHealth();
      return true;
    } catch (e) {
      return false;
    }
  }
}

πŸ—οΈ State Management (Provider Pattern)

providers/chemical_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/chemical_api_service.dart';
import '../models/compound.dart';

// Service provider
final chemicalApiProvider = Provider((ref) {
  return ChemicalApiService();
});

// Health status provider
final healthStatusProvider = FutureProvider((ref) async {
  final api = ref.watch(chemicalApiProvider);
  return api.getHealth();
});

// System stats provider
final systemStatsProvider = FutureProvider((ref) async {
  final api = ref.watch(chemicalApiProvider);
  return api.getStats();
});

// Search results provider with parameter
final searchResultsProvider = FutureProvider.family<SearchResponse, String>(
  (ref, smiles) async {
    final api = ref.watch(chemicalApiProvider);
    return api.search(smiles: smiles, topK: 5);
  },
);

// Server status provider
final serverStatusProvider = FutureProvider((ref) async {
  final api = ref.watch(chemicalApiProvider);
  return api.isServerRunning();
});

// Selected compound state
final selectedCompoundProvider = StateProvider<CompoundResult?>((ref) => null);

// Search history
final searchHistoryProvider = StateProvider<List<String>>((ref) => []);

🎨 UI Widgets

screens/search_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/chemical_provider.dart';
import '../widgets/compound_card.dart';

class SearchScreen extends ConsumerStatefulWidget {
  const SearchScreen({Key? key}) : super(key: key);

  @override
  ConsumerState<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends ConsumerState<SearchScreen> {
  late TextEditingController _smilesController;
  String? _selectedSmiles;

  @override
  void initState() {
    super.initState();
    _smilesController = TextEditingController();
  }

  @override
  void dispose() {
    _smilesController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final searchResults = _selectedSmiles != null
        ? ref.watch(searchResultsProvider(_selectedSmiles!))
        : null;

    final serverStatus = ref.watch(serverStatusProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Chemical Similarity Search'),
        elevation: 0,
        actions: [
          Center(
            child: serverStatus.when(
              data: (isRunning) => Container(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Center(
                  child: Row(
                    children: [
                      Container(
                        width: 8,
                        height: 8,
                        decoration: BoxDecoration(
                          shape: BoxShape.circle,
                          color: isRunning ? Colors.green : Colors.red,
                        ),
                      ),
                      const SizedBox(width: 8),
                      Text(
                        isRunning ? 'Connected' : 'Disconnected',
                        style: const TextStyle(fontSize: 12),
                      ),
                    ],
                  ),
                ),
              ),
              loading: () => const SizedBox(
                width: 24,
                height: 24,
                child: CircularProgressIndicator(strokeWidth: 2),
              ),
              error: (err, stack) => const Text('Error'),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // Stats Card
          Padding(
            padding: const EdgeInsets.all(16),
            child: StatsCard(),
          ),

          // Search Input
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                TextField(
                  controller: _smilesController,
                  decoration: InputDecoration(
                    hintText: 'Enter SMILES string (e.g., CCO, c1ccccc1)',
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(8),
                    ),
                    suffixIcon: IconButton(
                      icon: const Icon(Icons.clear),
                      onPressed: _smilesController.clear,
                    ),
                  ),
                ),
                const SizedBox(height: 12),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton.icon(
                    onPressed: _performSearch,
                    icon: const Icon(Icons.search),
                    label: const Text('Search'),
                  ),
                ),
              ],
            ),
          ),

          const SizedBox(height: 16),

          // Common SMILES Presets
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: [
                  _presetSmiles('Ethanol', 'CCO'),
                  const SizedBox(width: 8),
                  _presetSmiles('Benzene', 'c1ccccc1'),
                  const SizedBox(width: 8),
                  _presetSmiles('Acetic Acid', 'CC(=O)O'),
                  const SizedBox(width: 8),
                  _presetSmiles('Methanol', 'CO'),
                ],
              ),
            ),
          ),

          const SizedBox(height: 16),

          // Results
          Expanded(
            child: searchResults == null
                ? Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Icon(
                          Icons.search,
                          size: 64,
                          color: Colors.grey[300],
                        ),
                        const SizedBox(height: 16),
                        Text(
                          'Enter a SMILES string to search',
                          style: Theme.of(context).textTheme.bodyLarge,
                        ),
                      ],
                    ),
                  )
                : searchResults.when(
                    data: (results) => ListView.builder(
                      padding: const EdgeInsets.symmetric(horizontal: 16),
                      itemCount: results.results.length,
                      itemBuilder: (context, index) {
                        final compound = results.results[index];
                        return CompoundCard(compound: compound);
                      },
                    ),
                    loading: () => const Center(
                      child: CircularProgressIndicator(),
                    ),
                    error: (error, stack) => Center(
                      child: Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(
                            Icons.error_outline,
                            size: 48,
                            color: Colors.red[300],
                          ),
                          const SizedBox(height: 16),
                          Text(
                            error.toString(),
                            textAlign: TextAlign.center,
                            style: TextStyle(color: Colors.red[300]),
                          ),
                        ],
                      ),
                    ),
                  ),
          ),
        ],
      ),
    );
  }

  Widget _presetSmiles(String label, String smiles) {
    return ActionChip(
      label: Text(label),
      onPressed: () {
        _smilesController.text = smiles;
        _performSearch();
      },
    );
  }

  void _performSearch() {
    final smiles = _smilesController.text.trim();
    if (smiles.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Please enter a SMILES string')),
      );
      return;
    }
    setState(() => _selectedSmiles = smiles);
  }
}

// Stats Card Widget
class StatsCard extends ConsumerWidget {
  const StatsCard({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final stats = ref.watch(systemStatsProvider);

    return stats.when(
      data: (stat) => Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _StatItem(label: 'Compounds', value: stat.compounds.toString()),
              _StatItem(label: 'Index Size', value: stat.indexSize.toString()),
              _StatItem(label: 'Fingerprints', value: '${stat.bitSize}-bit'),
            ],
          ),
        ),
      ),
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => const Card(child: Text('Failed to load stats')),
    );
  }
}

class _StatItem extends StatelessWidget {
  final String label;
  final String value;

  const _StatItem({required this.label, required this.value});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          value,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 4),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    );
  }
}

widgets/compound_card.dart

import 'package:flutter/material.dart';
import '../models/compound.dart';

class CompoundCard extends StatelessWidget {
  final CompoundResult compound;

  const CompoundCard({required this.compound, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: ListTile(
        leading: compound.image != null
            ? Image.network(
                compound.image!,
                width: 56,
                height: 56,
                fit: BoxFit.cover,
                errorBuilder: (_, __, ___) => Container(
                  width: 56,
                  height: 56,
                  color: Colors.grey[300],
                  child: const Icon(Icons.image_not_supported),
                ),
              )
            : Container(
                width: 56,
                height: 56,
                color: Colors.grey[300],
                child: const Icon(Icons.molecular_weight),
              ),
        title: Text(
          compound.smiles,
          style: const TextStyle(fontFamily: 'monospace'),
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
        subtitle: Text(
          'Distance: ${compound.distance.toStringAsFixed(2)}',
          style: TextStyle(
            color: Colors.blue[600],
            fontWeight: FontWeight.w500,
          ),
        ),
        trailing: const Icon(Icons.arrow_forward_ios, size: 16),
        onTap: () {
          // Handle compound selection
          showDialog(
            context: context,
            builder: (context) => AlertDialog(
              title: const Text('Compound Details'),
              content: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  SelectableText('SMILES: ${compound.smiles}'),
                  const SizedBox(height: 8),
                  SelectableText(
                      'Distance: ${compound.distance.toStringAsFixed(4)}'),
                ],
              ),
              actions: [
                TextButton(
                  onPressed: () => Navigator.pop(context),
                  child: const Text('Close'),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

πŸ”§ Configuration

constants/api_config.dart

class ApiConfig {
  // Development
  static const String devBaseUrl = 'http://127.0.0.1:8000';
  
  // Production
  static const String prodBaseUrl = 'https://chemical-rag.example.com';
  
  // Timeouts
  static const Duration connectTimeout = Duration(seconds: 10);
  static const Duration receiveTimeout = Duration(seconds: 30);
  
  // API endpoints
  static const String healthEndpoint = '/health';
  static const String searchEndpoint = '/search';
  static const String statsEndpoint = '/stats';
  
  // Default search parameters
  static const int defaultTopK = 5;
}

πŸš€ Main App Setup

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'screens/search_screen.dart';
import 'constants/api_config.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Chemical Similarity Search',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      home: const SearchScreen(),
    );
  }
}

πŸ“² Testing the Integration

Test Cases

  1. Server Connection

    test('Server connection', () async {
      final api = ChemicalApiService();
      final health = await api.getHealth();
      expect(health.status, 'healthy');
    });
    
  2. Search Functionality

    test('Search compounds', () async {
      final api = ChemicalApiService();
      final results = await api.search(smiles: 'CCO');
      expect(results.results, isNotEmpty);
    });
    
  3. Error Handling

    test('Invalid SMILES error', () async {
      final api = ChemicalApiService();
      expect(
        () => api.search(smiles: 'INVALID'),
        throwsA(isA<ArgumentError>()),
      );
    });
    

🌐 Network Configuration

android/app/src/main/AndroidManifest.xml

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

ios/Runner/Info.plist

<key>NSLocalNetworkUsageDescription</key>
<string>Allow connection to chemical RAG server</string>
<key>NSBonjourServiceTypes</key>
<array>
  <string>_http._tcp</string>
</array>

πŸ“¦ Release Checklist

  • Update baseUrl to production server
  • Enable SSL/TLS for HTTPS
  • Add request/response logging
  • Implement error recovery
  • Test on real device
  • Cache API responses locally
  • Add offline support (optional)

🎯 Advanced Features

Client-Side Caching

final cachedSearchProvider = 
    StateNotifierProvider<SearchCache, Map<String, SearchResponse>>(
      (ref) => SearchCache(),
    );

class SearchCache extends StateNotifier<Map<String, SearchResponse>> {
  SearchCache() : super({});
  
  Future<SearchResponse> getOrFetch(
    String smiles,
    ChemicalApiService api,
  ) async {
    if (state.containsKey(smiles)) {
      return state[smiles]!;
    }
    
    final result = await api.search(smiles: smiles);
    state = {...state, smiles: result};
    return result;
  }
}

Batch Search

Future<List<SearchResponse>> batchSearch(
  List<String> smilesList,
  ChemicalApiService api,
) async {
  return Future.wait(
    smilesList.map((smiles) => api.search(smiles: smiles)),
  );
}

πŸ› Troubleshooting

Issue Solution
Connection refused Ensure API server is running on the correct host/port
CORS errors Server should be on the same network or allow CORS
Timeout errors Increase timeout in BaseOptions
Image loading fails Check server is serving /static/images
Model generation fails Run flutter pub run build_runner build

Ready to launch your Flutter app with Chemical RAG! πŸš€