import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:http/http.dart' as http; import 'dart:io'; import 'dart:typed_data'; import 'dart:convert'; import 'dart:async'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as path; import 'package:http_parser/http_parser.dart'; import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; // Modèles de données pour l'API class AnalysisResult { final bool isTuberculosis; final double confidence; final String stage; final String stageDescription; final int noduleCount; final String analyzedImageBase64; final List recommendations; AnalysisResult({ required this.isTuberculosis, required this.confidence, required this.stage, required this.stageDescription, required this.noduleCount, required this.analyzedImageBase64, required this.recommendations, }); factory AnalysisResult.fromJson(Map json) { return AnalysisResult( isTuberculosis: json['is_tuberculosis'] ?? false, confidence: (json['confidence'] ?? 0.0).toDouble(), stage: json['stage'] ?? 'unknown', stageDescription: json['stage_description'] ?? '', noduleCount: json['nodule_count'] ?? 0, analyzedImageBase64: json['analyzed_image_base64'] ?? '', recommendations: List.from(json['recommendations'] ?? []), ); } } // Service pour interagir avec l'API - VERSION CORRIGÉE class TuberculosisApiService { // URL de votre API déployée sur Render - VÉRIFIEZ CETTE URL static const String baseUrl = 'https://tuberculose-k708.onrender.com'; // IMPORTANT: Assurez-vous que cette URL correspond exactement à votre déploiement Render // Formats d'image acceptés static const List allowedFormats = ['jpg', 'jpeg', 'png', 'bmp']; // Configuration des timeouts pour les services cloud static const Duration apiTimeout = Duration(seconds: 120); static const Duration healthTimeout = Duration(seconds: 30); static const Duration wakeupTimeout = Duration(seconds: 60); // Méthode pour vérifier et afficher l'URL actuelle static void debugApiUrl() { debugPrint('=== CONFIGURATION API ==='); debugPrint('URL de base: $baseUrl'); debugPrint('URL health: $baseUrl/health'); debugPrint('URL analyze: $baseUrl/analyze'); debugPrint('URL model-info: $baseUrl/model-info'); debugPrint('========================'); } static Future analyzeImage(File imageFile) async { try { debugPrint('=== DÉBUT ANALYSE IMAGE ==='); debugApiUrl(); // Afficher l'URL pour debugging // Vérification de l'existence du fichier if (!await imageFile.exists()) { throw Exception('Le fichier image n\'existe pas'); } // Vérification de la taille du fichier (max 10MB) final fileSize = await imageFile.length(); if (fileSize > 10 * 1024 * 1024) { throw Exception('Le fichier est trop volumineux (max 10MB)'); } // Vérification du format de fichier par extension final extension = path.extension(imageFile.path).toLowerCase().replaceFirst('.', ''); if (!allowedFormats.contains(extension)) { throw Exception( 'Format de fichier non supporté. Utilisez: ${allowedFormats.join(', ')}'); } debugPrint('Chemin du fichier: ${imageFile.path}'); debugPrint('Taille du fichier: $fileSize bytes'); debugPrint('Extension: $extension'); // S'assurer que le service est réveillé avant l'analyse await wakeUpService(); // Lire les bytes du fichier final bytes = await imageFile.readAsBytes(); if (bytes.isEmpty) { throw Exception('Le fichier image est vide'); } debugPrint('Bytes lus: ${bytes.length}'); // Détection du type MIME final mimeType = lookupMimeType(imageFile.path) ?? _getMimeTypeFromExtension(extension); debugPrint('Type MIME détecté: $mimeType'); // Création de l'URL complète final analyzeUrl = '$baseUrl/analyze'; debugPrint('URL complète pour l\'analyse: $analyzeUrl'); // Création de la requête multipart var request = http.MultipartRequest( 'POST', Uri.parse(analyzeUrl), ); // Ajout des en-têtes request.headers.addAll({ 'Accept': 'application/json', 'User-Agent': 'TuberculosisAnalyzer/1.0', 'Connection': 'keep-alive', }); // Création du fichier multipart var multipartFile = http.MultipartFile.fromBytes( 'file', // Nom du champ attendu par l'API bytes, filename: path.basename(imageFile.path), contentType: MediaType.parse(mimeType), ); request.files.add(multipartFile); debugPrint('Envoi de la requête vers: ${request.url}'); debugPrint('Nom du fichier: ${multipartFile.filename}'); debugPrint('Content-Type: ${multipartFile.contentType}'); // Envoi avec timeout adapté pour les services cloud var streamedResponse = await request.send().timeout( apiTimeout, onTimeout: () { throw TimeoutException('Timeout: L\'analyse prend trop de temps'); }, ); var response = await http.Response.fromStream(streamedResponse); debugPrint('Code de réponse: ${response.statusCode}'); debugPrint('En-têtes de réponse: ${response.headers}'); debugPrint( 'Début du corps de la réponse: ${response.body.substring(0, math.min(200, response.body.length))}...'); if (response.statusCode == 200) { try { var jsonData = json.decode(response.body); debugPrint('Parsing JSON réussi'); return AnalysisResult.fromJson(jsonData); } catch (e) { debugPrint('Erreur de parsing JSON: $e'); throw Exception('Erreur de parsing JSON: $e'); } } else { // Gestion des erreurs avec plus de détails String errorMessage = 'Erreur ${response.statusCode}'; try { var errorData = json.decode(response.body); if (errorData is Map) { errorMessage = errorData['detail'] ?? errorData['message'] ?? errorMessage; } } catch (_) { errorMessage = response.body.isNotEmpty ? response.body : errorMessage; } debugPrint('Erreur API: $errorMessage'); throw Exception('Erreur API: $errorMessage'); } } on SocketException catch (e) { debugPrint('Erreur de socket: $e'); throw Exception( 'Erreur de connexion réseau: Impossible de contacter le serveur à $baseUrl. Vérifiez votre connexion internet.'); } on TimeoutException catch (e) { debugPrint('Timeout: $e'); throw Exception( 'Timeout: Le serveur met trop de temps à répondre. Les services cloud peuvent être lents au démarrage.'); } on HandshakeException catch (e) { debugPrint('Erreur SSL/TLS: $e'); throw Exception( 'Erreur de sécurité SSL: Problème avec le certificat du serveur'); } on FormatException catch (e) { debugPrint('Erreur de format: $e'); throw Exception('Erreur de format de données: $e'); } catch (e) { debugPrint('Erreur générale: $e'); if (e.toString().contains('Exception:')) { rethrow; } throw Exception('Erreur inattendue: $e'); } } // Méthode utilitaire pour obtenir le type MIME depuis l'extension static String _getMimeTypeFromExtension(String extension) { switch (extension.toLowerCase()) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'gif': return 'image/gif'; case 'bmp': return 'image/bmp'; case 'webp': return 'image/webp'; case 'tiff': case 'tif': return 'image/tiff'; default: return 'image/jpeg'; // Par défaut } } static Future> getHealthStatus() async { try { debugPrint('=== VÉRIFICATION SANTÉ API ==='); debugApiUrl(); // Afficher l'URL pour debugging final healthUrl = '$baseUrl/health'; debugPrint('URL health check: $healthUrl'); var response = await http.get( Uri.parse(healthUrl), headers: { 'Accept': 'application/json', 'User-Agent': 'TuberculosisAnalyzer/1.0', 'Connection': 'keep-alive', }, ).timeout(healthTimeout); debugPrint('Health check - Code: ${response.statusCode}'); debugPrint('Health check - Body: ${response.body}'); if (response.statusCode == 200) { try { return json.decode(response.body); } catch (e) { debugPrint('Erreur parsing JSON health: $e'); // Si le JSON n'est pas valide mais que le serveur répond 200 return {'status': 'ok', 'message': 'Service disponible'}; } } else { throw Exception('API non disponible (Code: ${response.statusCode})'); } } on SocketException catch (e) { debugPrint('Health check - Erreur socket: $e'); throw Exception( 'Serveur non accessible à $baseUrl: Vérifiez l\'URL et votre connexion internet'); } on TimeoutException catch (e) { debugPrint('Health check - Timeout: $e'); throw Exception( 'Timeout: Le service cloud peut être en cours de démarrage (cela peut prendre 1-2 minutes)'); } on HandshakeException catch (e) { debugPrint('Health check - Erreur SSL: $e'); throw Exception('Erreur de sécurité SSL'); } catch (e) { debugPrint('Health check - Erreur: $e'); if (e.toString().contains('Exception:')) { rethrow; } throw Exception('Erreur de connexion: $e'); } } static Future> getModelInfo() async { try { debugPrint('=== RÉCUPÉRATION INFOS MODÈLE ==='); debugApiUrl(); // Afficher l'URL pour debugging final modelInfoUrl = '$baseUrl/model-info'; debugPrint('URL model info: $modelInfoUrl'); var response = await http.get( Uri.parse(modelInfoUrl), headers: { 'Accept': 'application/json', 'User-Agent': 'TuberculosisAnalyzer/1.0', 'Connection': 'keep-alive', }, ).timeout(healthTimeout); debugPrint('Model info - Code: ${response.statusCode}'); debugPrint('Model info - Body: ${response.body}'); if (response.statusCode == 200) { return json.decode(response.body); } else { throw Exception( 'Impossible d\'obtenir les infos du modèle (Code: ${response.statusCode})'); } } on SocketException catch (e) { debugPrint('Model info - Erreur socket: $e'); throw Exception( 'Serveur non accessible à $baseUrl: Vérifiez votre connexion internet'); } on TimeoutException catch (e) { debugPrint('Model info - Timeout: $e'); throw Exception('Timeout lors de la récupération des infos du modèle'); } on HandshakeException catch (e) { debugPrint('Model info - Erreur SSL: $e'); throw Exception('Erreur de sécurité SSL'); } catch (e) { debugPrint('Model info - Erreur: $e'); if (e.toString().contains('Exception:')) { rethrow; } throw Exception('Erreur: $e'); } } // Méthode pour réveiller le service (utile pour les services cloud qui se mettent en veille) static Future wakeUpService() async { try { debugPrint('=== RÉVEIL DU SERVICE ==='); debugPrint('Tentative de réveil du service...'); final wakeupUrl = '$baseUrl/health'; debugPrint('URL de réveil: $wakeupUrl'); await http.get( Uri.parse(wakeupUrl), headers: { 'Accept': 'application/json', 'User-Agent': 'TuberculosisAnalyzer/1.0', 'Connection': 'keep-alive', }, ).timeout(wakeupTimeout); debugPrint('Service réveillé avec succès'); } catch (e) { debugPrint('Erreur lors du réveil du service (non critique): $e'); // Ne pas lever d'exception car c'est juste pour réveiller le service } } // Méthode pour tester la connectivité static Future testConnectivity() async { try { debugPrint('=== TEST DE CONNECTIVITÉ ==='); debugApiUrl(); // Afficher l'URL pour debugging final testUrl = '$baseUrl/health'; debugPrint('URL de test: $testUrl'); final response = await http.get( Uri.parse(testUrl), headers: { 'Accept': 'application/json', 'User-Agent': 'TuberculosisAnalyzer/1.0', }, ).timeout(Duration(seconds: 10)); debugPrint('Test connectivité - Code: ${response.statusCode}'); return response.statusCode == 200; } catch (e) { debugPrint('Test connectivité - Erreur: $e'); return false; } } // Méthode pour valider l'URL de l'API static bool validateApiUrl() { try { final uri = Uri.parse(baseUrl); final isValid = uri.scheme == 'https' && uri.host.isNotEmpty && !uri.host.contains('localhost') && !uri.host.contains('127.0.0.1'); debugPrint('=== VALIDATION URL ==='); debugPrint('URL: $baseUrl'); debugPrint('Scheme: ${uri.scheme}'); debugPrint('Host: ${uri.host}'); debugPrint('Valid: $isValid'); debugPrint('=================='); return isValid; } catch (e) { debugPrint('Erreur de validation URL: $e'); return false; } } } // Ajout des imports nécessaires en haut du fichier // Ajout des imports nécessaires en haut du fichier // Widget principal de l'application class TuberculosisAnalyzerApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Analyseur de Tuberculose', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: HomePage(), ); } } // Page d'accueil class HomePage extends StatefulWidget { @override _HomePageState createState() => _HomePageState(); } class _HomePageState extends State { File? _selectedImage; AnalysisResult? _analysisResult; bool _isAnalyzing = false; String? _errorMessage; bool _isApiHealthy = false; bool _isCheckingHealth = false; String _healthStatus = 'Vérification...'; @override void initState() { super.initState(); _checkApiHealth(); } Future _checkApiHealth() async { setState(() { _isCheckingHealth = true; _healthStatus = 'Vérification en cours...'; }); try { debugPrint('=== DIAGNOSTIC DE CONNEXION ==='); // Étape 0: Validation de l'URL setState(() { _healthStatus = 'Validation de l\'URL...'; }); if (!TuberculosisApiService.validateApiUrl()) { throw Exception( 'URL de l\'API invalide: ${TuberculosisApiService.baseUrl}'); } // Étape 1: Test de connectivité basique setState(() { _healthStatus = 'Test de connectivité...'; }); final isConnected = await TuberculosisApiService.testConnectivity(); if (!isConnected) { throw Exception( 'Impossible de contacter le serveur à l\'adresse: ${TuberculosisApiService.baseUrl}'); } // Étape 2: Réveil du service setState(() { _healthStatus = 'Réveil du service cloud...'; }); await TuberculosisApiService.wakeUpService(); // Étape 3: Vérification complète setState(() { _healthStatus = 'Vérification des services...'; }); final healthData = await TuberculosisApiService.getHealthStatus(); setState(() { _isApiHealthy = true; _errorMessage = null; _healthStatus = 'Service connecté et fonctionnel ✓'; }); // Afficher les informations du service si disponibles if (healthData.containsKey('status')) { debugPrint('Statut du service: ${healthData['status']}'); } // Afficher un message de succès ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Connexion à l\'API établie avec succès !'), backgroundColor: Colors.green, duration: Duration(seconds: 2), ), ); } catch (e) { debugPrint('Erreur lors de la vérification: $e'); setState(() { _isApiHealthy = false; _errorMessage = e.toString(); // Messages d'erreur plus informatifs if (e.toString().contains('localhost') || e.toString().contains('127.0.0.1')) { _healthStatus = 'ERREUR: Configuration localhost détectée !'; } else if (e.toString().contains('URL de l\'API invalide')) { _healthStatus = 'Configuration d\'URL incorrecte'; } else if (e.toString().contains('Failed to fetch')) { _healthStatus = 'Service non accessible - Vérifiez le déploiement'; } else if (e.toString().contains('Timeout')) { _healthStatus = 'Service en cours de démarrage (services cloud)'; } else if (e.toString().contains('SocketException')) { _healthStatus = 'Problème de connexion réseau'; } else { _healthStatus = 'Service temporairement indisponible'; } }); // Afficher une alerte avec plus de détails if (e.toString().contains('localhost')) { _showLocalhostWarning(); } } finally { setState(() { _isCheckingHealth = false; }); } } void _showLocalhostWarning() { showDialog( context: context, builder: (context) => AlertDialog( title: Row( children: [ Icon(Icons.warning, color: Colors.red), SizedBox(width: 8), Text('Configuration Localhost'), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'PROBLÈME DÉTECTÉ:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), ), SizedBox(height: 8), Text( 'Votre application essaie de se connecter à localhost, mais vous devez utiliser l\'URL de votre API déployée sur Render.'), SizedBox(height: 16), Text( 'URL ACTUELLE:', style: TextStyle(fontWeight: FontWeight.bold), ), Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.red[50], borderRadius: BorderRadius.circular(4), ), child: Text( TuberculosisApiService.baseUrl, style: TextStyle(fontFamily: 'monospace', color: Colors.red), ), ), SizedBox(height: 16), Text( 'SOLUTION:', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green), ), SizedBox(height: 8), Text('1. Vérifiez que votre API est déployée sur Render'), Text('2. Copiez l\'URL de votre déploiement Render'), Text('3. Remplacez l\'URL dans le code source'), Text('4. Recompilez l\'application'), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('Compris'), ), ], ), ); } Widget _buildApiStatusCard() { return Card( color: _isApiHealthy ? Colors.green[50] : _isCheckingHealth ? Colors.blue[50] : Colors.red[50], child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (_isCheckingHealth) SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) else Icon( _isApiHealthy ? Icons.check_circle : Icons.error, color: _isApiHealthy ? Colors.green : Colors.red, ), SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _healthStatus, style: TextStyle( color: _isApiHealthy ? Colors.green[800] : _isCheckingHealth ? Colors.blue[800] : Colors.red[800], fontWeight: FontWeight.bold, ), ), SizedBox(height: 4), Text( 'API: ${TuberculosisApiService.baseUrl}', style: TextStyle( color: Colors.grey[600], fontSize: 11, fontFamily: 'monospace', ), ), ], ), ), ], ), if (!_isApiHealthy && !_isCheckingHealth) ...[ SizedBox(height: 12), Text( 'Problèmes possibles:', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.red[700], ), ), SizedBox(height: 4), Text( '• Vérifiez votre connexion internet\n' '• Le service cloud peut prendre 1-2 minutes à démarrer\n' '• L\'URL de l\'API est-elle correcte?\n' '• Le service est-il déployé et actif?', style: TextStyle( color: Colors.red[600], fontSize: 12, ), ), SizedBox(height: 8), Row( children: [ ElevatedButton.icon( onPressed: _checkApiHealth, icon: Icon(Icons.refresh), label: Text('Réessayer'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[100], foregroundColor: Colors.red[800], ), ), SizedBox(width: 8), TextButton( onPressed: () => _showConnectionDiagnostic(), child: Text('Diagnostic'), ), ], ), ], ], ), ), ); } // Méthode pour afficher le diagnostic de connexion void _showConnectionDiagnostic() { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Diagnostic de connexion'), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('Configuration actuelle:', style: TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 8), Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(4), ), child: Text( 'URL API: ${TuberculosisApiService.baseUrl}', style: TextStyle(fontFamily: 'monospace', fontSize: 12), ), ), SizedBox(height: 16), Text('Vérifications:', style: TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 8), Text('✓ L\'URL ne contient pas "localhost"'), Text('✓ L\'URL utilise HTTPS'), Text('✓ L\'URL se termine par ".onrender.com"'), SizedBox(height: 16), Text('Si le problème persiste:', style: TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 8), Text( '1. Vérifiez que votre API est déployée sur Render\n' '2. Vérifiez que le service est actif\n' '3. Testez l\'URL dans un navigateur\n' '4. Les services cloud gratuits peuvent se mettre en veille', style: TextStyle(fontSize: 12), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('Fermer'), ), ElevatedButton( onPressed: () { Navigator.of(context).pop(); _checkApiHealth(); }, child: Text('Retester'), ), ], ), ); } Future _selectImageFromGallery() async { try { final file = await ImageSelectionHelper.pickImageFromGallery(); if (file != null) { setState(() { _selectedImage = file; _analysisResult = null; _errorMessage = null; }); } } catch (e) { setState(() { _errorMessage = e.toString(); }); } } Future _takePhoto() async { try { final file = await ImageSelectionHelper.takePhoto(); if (file != null) { setState(() { _selectedImage = file; _analysisResult = null; _errorMessage = null; }); } } catch (e) { setState(() { _errorMessage = e.toString(); }); } } Future _analyzeImage() async { if (_selectedImage == null) return; setState(() { _isAnalyzing = true; _errorMessage = null; _analysisResult = null; }); try { // Afficher un message d'information pour les services cloud ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Analyse en cours... Cela peut prendre jusqu\'à 2 minutes.'), duration: Duration(seconds: 5), ), ); final result = await TuberculosisApiService.analyzeImage(_selectedImage!); setState(() { _analysisResult = result; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Analyse terminée avec succès!'), backgroundColor: Colors.green, ), ); } catch (e) { setState(() { _errorMessage = e.toString(); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur lors de l\'analyse'), backgroundColor: Colors.red, ), ); } finally { setState(() { _isAnalyzing = false; }); } } Future _shareResults() async { if (_analysisResult == null) return; try { // Créer un rapport textuel String report = 'Rapport d\'analyse - Tuberculose\n\n'; report += 'Résultat: ${_analysisResult!.isTuberculosis ? "Tuberculose détectée" : "Pas de tuberculose détectée"}\n'; report += 'Confiance: ${(_analysisResult!.confidence * 100).toStringAsFixed(1)}%\n'; report += 'Stade: ${_analysisResult!.stage}\n'; report += 'Description: ${_analysisResult!.stageDescription}\n'; report += 'Nombre de nodules: ${_analysisResult!.noduleCount}\n\n'; if (_analysisResult!.recommendations.isNotEmpty) { report += 'Recommandations:\n'; for (int i = 0; i < _analysisResult!.recommendations.length; i++) { report += '${i + 1}. ${_analysisResult!.recommendations[i]}\n'; } } // Sauvegarder l'image analysée si disponible if (_analysisResult!.analyzedImageBase64.isNotEmpty) { try { final bytes = base64Decode(_analysisResult!.analyzedImageBase64); final tempDir = await getTemporaryDirectory(); final imageFile = File('${tempDir.path}/analyzed_image.png'); await imageFile.writeAsBytes(bytes); // Utiliser shareXFiles pour partager des fichiers avec du texte await Share.shareXFiles( [XFile(imageFile.path)], text: report, subject: 'Rapport d\'analyse - Tuberculose', ); } catch (e) { // Si l'image ne peut pas être partagée, partager seulement le texte await Share.share( report, subject: 'Rapport d\'analyse - Tuberculose', ); } } else { await Share.share( report, subject: 'Rapport d\'analyse - Tuberculose', ); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur lors du partage: $e')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Analyseur de Tuberculose'), actions: [ IconButton( icon: Icon(Icons.refresh), onPressed: _checkApiHealth, ), IconButton( icon: Icon(Icons.info), onPressed: () => _showInfoDialog(context), ), ], ), body: SingleChildScrollView( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Statut de l'API _buildApiStatusCard(), Card( color: _isApiHealthy ? Colors.green[50] : Colors.red[50], child: Padding( padding: EdgeInsets.all(16.0), child: Row( children: [ if (_isCheckingHealth) SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) else Icon( _isApiHealthy ? Icons.check_circle : Icons.error, color: _isApiHealthy ? Colors.green : Colors.red, ), SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _healthStatus, style: TextStyle( color: _isApiHealthy ? Colors.green[800] : Colors.red[800], fontWeight: FontWeight.bold, ), ), if (!_isApiHealthy && !_isCheckingHealth) Text( 'Les services cloud peuvent prendre du temps à démarrer', style: TextStyle( color: Colors.grey[600], fontSize: 12, ), ), ], ), ), ], ), ), ), SizedBox(height: 16), // Boutons de sélection d'image Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: _selectImageFromGallery, icon: Icon(Icons.photo_library), label: Text('Galerie'), ), ), SizedBox(width: 16), Expanded( child: ElevatedButton.icon( onPressed: _takePhoto, icon: Icon(Icons.camera_alt), label: Text('Appareil photo'), ), ), ], ), SizedBox(height: 16), // Affichage de l'image sélectionnée if (_selectedImage != null) ...[ Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( children: [ Text( 'Image sélectionnée', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), Container( height: 200, width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file( _selectedImage!, fit: BoxFit.contain, ), ), ), SizedBox(height: 16), ElevatedButton.icon( onPressed: _isAnalyzing || !_isApiHealthy ? null : _analyzeImage, icon: _isAnalyzing ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(Icons.analytics), label: Text( _isAnalyzing ? 'Analyse en cours...' : 'Analyser'), ), if (_isAnalyzing) Padding( padding: EdgeInsets.only(top: 8), child: Text( 'Patience, l\'analyse peut prendre jusqu\'à 2 minutes...', style: TextStyle( color: Colors.grey[600], fontSize: 12, ), ), ), ], ), ), ), SizedBox(height: 16), ], // Affichage des erreurs if (_errorMessage != null) ...[ Card( color: Colors.red[50], child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.error, color: Colors.red), SizedBox(width: 8), Text( 'Erreur', style: TextStyle( color: Colors.red[800], fontWeight: FontWeight.bold, ), ), ], ), SizedBox(height: 8), Text( _errorMessage!, style: TextStyle(color: Colors.red[700]), ), SizedBox(height: 8), ElevatedButton( onPressed: _checkApiHealth, child: Text('Réessayer'), ), ], ), ), ), SizedBox(height: 16), ], // Affichage des résultats if (_analysisResult != null) ...[ ResultsWidget( result: _analysisResult!, onShare: _shareResults, ), ], ], ), ), ); } void _showInfoDialog(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('À propos'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Analyseur de Tuberculose'), SizedBox(height: 8), Text( 'Cette application utilise l\'intelligence artificielle pour analyser les radiographies pulmonaires et détecter la présence de tuberculose.'), SizedBox(height: 8), Text( 'Le service est hébergé sur Render Cloud et peut prendre du temps à démarrer s\'il n\'a pas été utilisé récemment.'), SizedBox(height: 8), Text( '⚠️ Attention: Cette application est à des fins éducatives uniquement et ne remplace pas un diagnostic médical professionnel.'), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('Fermer'), ), ], ), ); } } // Helper pour la sélection d'images class ImageSelectionHelper { static final ImagePicker _picker = ImagePicker(); /// Sélectionner une image depuis la galerie static Future pickImageFromGallery() async { try { // Vérifier les permissions de stockage final hasPermission = await PermissionHelper.requestStoragePermission(); if (!hasPermission) { throw Exception('Permission d\'accès au stockage refusée'); } // Sélectionner l'image final XFile? pickedFile = await _picker.pickImage( source: ImageSource.gallery, maxWidth: 1920, maxHeight: 1920, imageQuality: 85, ); if (pickedFile == null) return null; // Validation du fichier final file = File(pickedFile.path); await _validateImageFile(file); return file; } catch (e) { throw Exception('Erreur lors de la sélection d\'image: $e'); } } /// Prendre une photo avec l'appareil photo static Future takePhoto() async { try { // Vérifier les permissions de caméra final hasPermission = await PermissionHelper.requestCameraPermission(); if (!hasPermission) { throw Exception('Permission d\'accès à la caméra refusée'); } // Prendre la photo final XFile? pickedFile = await _picker.pickImage( source: ImageSource.camera, maxWidth: 1920, maxHeight: 1920, imageQuality: 85, preferredCameraDevice: CameraDevice.rear, ); if (pickedFile == null) return null; // Validation du fichier final file = File(pickedFile.path); await _validateImageFile(file); return file; } catch (e) { throw Exception('Erreur lors de la prise de photo: $e'); } } /// Sélectionner une image avec FilePicker (alternative) static Future pickImageWithFilePicker() async { try { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['jpg', 'jpeg', 'png', 'bmp'], allowMultiple: false, ); if (result == null || result.files.isEmpty) return null; final file = File(result.files.single.path!); await _validateImageFile(file); return file; } catch (e) { throw Exception('Erreur lors de la sélection de fichier: $e'); } } /// Validation du fichier image static Future _validateImageFile(File file) async { // Vérifier l'existence du fichier if (!await file.exists()) { throw Exception('Le fichier sélectionné n\'existe pas'); } // Vérifier la taille du fichier final fileSize = await file.length(); if (!ImageValidator.isValidSize(fileSize)) { throw Exception( 'Fichier trop volumineux: ${ImageValidator.getFileSizeString(fileSize)}. ' 'Taille maximale: ${ImageValidator.getFileSizeString(ImageValidator.maxFileSize)}'); } // Vérifier l'extension if (!ImageValidator.isValidExtension(file.path)) { throw Exception('Format de fichier non supporté. ' 'Formats acceptés: ${ImageValidator.allowedExtensions.join(', ')}'); } // Vérifier que le fichier n'est pas vide if (fileSize == 0) { throw Exception('Le fichier image est vide'); } } /// Redimensionner une image si nécessaire static Future resizeImageIfNeeded( File imageFile, { int maxWidth = 1920, int maxHeight = 1920, int quality = 85, }) async { try { // Cette méthode peut être étendue avec une bibliothèque comme image // Pour l'instant, on retourne le fichier original return imageFile; } catch (e) { throw Exception('Erreur lors du redimensionnement: $e'); } } /// Obtenir les informations sur l'image static Future> getImageInfo(File imageFile) async { try { final fileSize = await imageFile.length(); final fileName = path.basename(imageFile.path); final extension = path.extension(imageFile.path); final mimeType = lookupMimeType(imageFile.path) ?? 'unknown'; return { 'fileName': fileName, 'filePath': imageFile.path, 'fileSize': fileSize, 'fileSizeString': ImageValidator.getFileSizeString(fileSize), 'extension': extension, 'mimeType': mimeType, 'isValid': ImageValidator.isValidSize(fileSize) && ImageValidator.isValidExtension(imageFile.path), }; } catch (e) { throw Exception('Erreur lors de l\'obtention des informations: $e'); } } } // Widget pour afficher les résultats class ResultsWidget extends StatelessWidget { final AnalysisResult result; final VoidCallback onShare; const ResultsWidget({ Key? key, required this.result, required this.onShare, }) : super(key: key); @override Widget build(BuildContext context) { return Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( result.isTuberculosis ? Icons.warning : Icons.check_circle, color: result.isTuberculosis ? Colors.orange : Colors.green, size: 32, ), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Résultat de l\'analyse', style: Theme.of(context).textTheme.titleLarge, ), Text( result.isTuberculosis ? 'Tuberculose détectée' : 'Pas de tuberculose détectée', style: TextStyle( color: result.isTuberculosis ? Colors.orange[700] : Colors.green[700], fontWeight: FontWeight.bold, fontSize: 16, ), ), ], ), ), ], ), SizedBox(height: 16), // Informations détaillées _buildInfoRow('Confiance', '${(result.confidence * 100).toStringAsFixed(1)}%'), _buildInfoRow('Stade', result.stage), if (result.stageDescription.isNotEmpty) _buildInfoRow('Description', result.stageDescription), _buildInfoRow('Nombre de nodules', result.noduleCount.toString()), SizedBox(height: 16), // Image analysée if (result.analyzedImageBase64.isNotEmpty) ...[ Text( 'Image analysée', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), Container( height: 200, width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( base64Decode(result.analyzedImageBase64), fit: BoxFit.contain, ), ), ), SizedBox(height: 16), ], // Recommandations if (result.recommendations.isNotEmpty) ...[ Text( 'Recommandations', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), ...result.recommendations .map((rec) => Padding( padding: EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('• ', style: TextStyle(fontWeight: FontWeight.bold)), Expanded(child: Text(rec)), ], ), )) .toList(), SizedBox(height: 16), ], // Bouton de partage Center( child: ElevatedButton.icon( onPressed: onShare, icon: Icon(Icons.share), label: Text('Partager les résultats'), ), ), // Disclaimer médical SizedBox(height: 16), Container( padding: EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.amber[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.amber[200]!), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning, color: Colors.amber[700], size: 20), SizedBox(width: 8), Expanded( child: Text( 'Attention: Ces résultats sont à des fins éducatives uniquement. ' 'Consultez toujours un professionnel de santé pour un diagnostic médical.', style: TextStyle( color: Colors.amber[800], fontSize: 12, ), ), ), ], ), ), ], ), ), ); } Widget _buildInfoRow(String label, String value) { return Padding( padding: EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( '$label:', style: TextStyle(fontWeight: FontWeight.bold), ), ), Expanded( child: Text(value), ), ], ), ); } } // Widget pour afficher les informations du modèle class ModelInfoWidget extends StatefulWidget { @override _ModelInfoWidgetState createState() => _ModelInfoWidgetState(); } class _ModelInfoWidgetState extends State { Map? _modelInfo; bool _isLoading = false; String? _errorMessage; @override void initState() { super.initState(); _loadModelInfo(); } Future _loadModelInfo() async { setState(() { _isLoading = true; _errorMessage = null; }); try { final info = await TuberculosisApiService.getModelInfo(); setState(() { _modelInfo = info; }); } catch (e) { setState(() { _errorMessage = e.toString(); }); } finally { setState(() { _isLoading = false; }); } } @override Widget build(BuildContext context) { if (_isLoading) { return Center( child: CircularProgressIndicator(), ); } if (_errorMessage != null) { return Card( color: Colors.red[50], child: Padding( padding: EdgeInsets.all(16.0), child: Column( children: [ Icon(Icons.error, color: Colors.red), SizedBox(height: 8), Text( 'Erreur lors du chargement des informations du modèle', style: TextStyle(color: Colors.red[700]), ), SizedBox(height: 8), Text( _errorMessage!, style: TextStyle(color: Colors.red[600], fontSize: 12), ), SizedBox(height: 8), ElevatedButton( onPressed: _loadModelInfo, child: Text('Réessayer'), ), ], ), ), ); } if (_modelInfo == null) { return SizedBox.shrink(); } return Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Informations du modèle', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), if (_modelInfo!['model_name'] != null) _buildInfoRow('Nom du modèle', _modelInfo!['model_name']), if (_modelInfo!['model_version'] != null) _buildInfoRow('Version', _modelInfo!['model_version']), if (_modelInfo!['accuracy'] != null) _buildInfoRow('Précision', '${_modelInfo!['accuracy']}%'), if (_modelInfo!['last_updated'] != null) _buildInfoRow( 'Dernière mise à jour', _modelInfo!['last_updated']), if (_modelInfo!['input_size'] != null) _buildInfoRow('Taille d\'entrée', _modelInfo!['input_size']), ], ), ), ); } Widget _buildInfoRow(String label, String value) { return Padding( padding: EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: Text( '$label:', style: TextStyle(fontWeight: FontWeight.bold), ), ), Expanded( child: Text(value), ), ], ), ); } } // Page des paramètres class SettingsPage extends StatefulWidget { @override _SettingsPageState createState() => _SettingsPageState(); } class _SettingsPageState extends State { bool _showDetailedResults = true; bool _saveAnalysisHistory = false; double _imageQuality = 85.0; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Paramètres'), ), body: ListView( padding: EdgeInsets.all(16.0), children: [ Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Affichage des résultats', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), SwitchListTile( title: Text('Afficher les résultats détaillés'), subtitle: Text('Inclure les informations techniques'), value: _showDetailedResults, onChanged: (value) { setState(() { _showDetailedResults = value; }); }, ), ], ), ), ), SizedBox(height: 16), Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Qualité d\'image', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), Text('Qualité: ${_imageQuality.round()}%'), Slider( value: _imageQuality, min: 50.0, max: 100.0, divisions: 10, label: '${_imageQuality.round()}%', onChanged: (value) { setState(() { _imageQuality = value; }); }, ), Text( 'Une qualité plus élevée améliore la précision mais augmente la taille du fichier', style: TextStyle( color: Colors.grey[600], fontSize: 12, ), ), ], ), ), ), SizedBox(height: 16), Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Historique', style: Theme.of(context).textTheme.titleMedium, ), SizedBox(height: 8), SwitchListTile( title: Text('Sauvegarder l\'historique'), subtitle: Text('Conserver les analyses précédentes'), value: _saveAnalysisHistory, onChanged: (value) { setState(() { _saveAnalysisHistory = value; }); }, ), ], ), ), ), SizedBox(height: 16), ModelInfoWidget(), ], ), ); } } // Point d'entrée de l'application void main() { runApp(TuberculosisAnalyzerApp()); } // Extension pour ajouter des méthodes utilitaires extension AnalysisResultExtension on AnalysisResult { String get severityText { if (confidence >= 0.8) return 'Haute'; if (confidence >= 0.6) return 'Moyenne'; return 'Faible'; } Color get severityColor { if (confidence >= 0.8) return Colors.red; if (confidence >= 0.6) return Colors.orange; return Colors.green; } String get formattedConfidence { return '${(confidence * 100).toStringAsFixed(1)}%'; } } // Helper pour la gestion des permissions class PermissionHelper { static Future requestCameraPermission() async { // Cette méthode peut être étendue avec permission_handler // Pour l'instant, on suppose que les permissions sont gérées par le système return true; } static Future requestStoragePermission() async { // Cette méthode peut être étendue avec permission_handler // Pour l'instant, on suppose que les permissions sont gérées par le système return true; } } // Utilitaires pour la validation des images class ImageValidator { static const int maxFileSize = 10 * 1024 * 1024; // 10MB static const List allowedExtensions = [ '.jpg', '.jpeg', '.png', '.bmp' ]; static bool isValidSize(int size) { return size > 0 && size <= maxFileSize; } static bool isValidExtension(String filePath) { final extension = path.extension(filePath).toLowerCase(); return allowedExtensions.contains(extension); } static String getFileSizeString(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } }