import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import 'package:http/http.dart' as http; 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 'package:gal/gal.dart'; import 'dart:ui' as ui; import 'package:flutter/rendering.dart'; import 'dart:io' show File, SocketException; import 'dart:io' as io show File, SocketException; // Configuration de l'API corrigée class ApiConfig { static const String _baseUrl = 'localhost:8000'; static const String _protocol = 'http'; // Méthode sécurisée pour obtenir l'URL selon la plateforme static String get baseUrl { if (kIsWeb) { return '$_protocol://localhost:8000'; } else { return _getMobileUrl(); } } // Méthode privée pour obtenir l'URL mobile avec gestion d'erreurs static String _getMobileUrl() { try { switch (defaultTargetPlatform) { case TargetPlatform.android: return '$_protocol://10.0.2.2:8000'; case TargetPlatform.iOS: return '$_protocol://localhost:8000'; case TargetPlatform.windows: case TargetPlatform.macOS: case TargetPlatform.linux: default: return '$_protocol://localhost:8000'; } } catch (e) { return '$_protocol://localhost:8000'; } } // Méthode pour obtenir le nom de la plateforme en sécurité static String get platformName { if (kIsWeb) { return 'web'; } try { switch (defaultTargetPlatform) { case TargetPlatform.android: return 'android'; case TargetPlatform.iOS: return 'ios'; case TargetPlatform.windows: return 'windows'; case TargetPlatform.macOS: return 'macos'; case TargetPlatform.linux: return 'linux'; case TargetPlatform.fuchsia: return 'fuchsia'; default: return 'unknown'; } } catch (e) { return 'unknown'; } } // Méthode pour vérifier si on est sur mobile static bool get isMobile { if (kIsWeb) return false; try { return defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS; } catch (e) { return false; } } // Méthode pour vérifier si on est sur desktop static bool get isDesktop { if (kIsWeb) return false; try { return defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.linux; } catch (e) { return false; } } // Timeout configurations static const Duration connectionTimeout = Duration(seconds: 10); static const Duration readTimeout = Duration(seconds: 60); static const Duration writeTimeout = Duration(seconds: 30); // Limites de fichier static const int maxFileSize = 10 * 1024 * 1024; // 10MB static const List allowedExtensions = [ '.jpg', '.jpeg', '.png', '.bmp' ]; static const List allowedMimeTypes = [ 'image/jpeg', 'image/png', 'image/bmp' ]; } // Modèle de données pour les erreurs API class ApiError { final int statusCode; final String message; final String? detail; final bool isNetworkError; final bool isTimeout; ApiError({ required this.statusCode, required this.message, this.detail, this.isNetworkError = false, this.isTimeout = false, }); @override String toString() { if (isNetworkError) { return 'Erreur de réseau: Impossible de se connecter au serveur local'; } if (isTimeout) { return 'Timeout: Le serveur met trop de temps à répondre'; } return 'Erreur $statusCode: $message'; } } // Nouvelle logique pour déterminer le stade clinique String determinerStade(double tailleMm, int nombreNodules) { if (nombreNodules == 0) { return "Infection latente"; } else if (tailleMm < 3) { if (nombreNodules >= 1 && nombreNodules <= 5) { return "Infection primaire"; } else { return "Infection primaire ou début actif"; } } else { if (nombreNodules >= 1 && nombreNodules <= 5) { return "Infection active probable"; } else { return "Infection active confirmée"; } } } // Modèle de données pour les résultats d'analyse class AnalysisResult { final bool isTuberculosis; final double confidence; final String stage; final String stageDescription; final int noduleCount; final double tailleMm; final String analyzedImageBase64; final List recommendations; final DateTime timestamp; AnalysisResult({ required this.isTuberculosis, required this.confidence, required this.stage, required this.stageDescription, required this.noduleCount, required this.tailleMm, required this.analyzedImageBase64, required this.recommendations, DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); factory AnalysisResult.fromJson(Map json) { final noduleCount = json['nodule_count'] ?? 0; final tailleMm = (json['taille_mm'] ?? 0.0).toDouble(); // Application de la nouvelle logique de stade final stage = determinerStade(tailleMm, noduleCount); return AnalysisResult( isTuberculosis: json['is_tuberculosis'] ?? false, confidence: (json['confidence'] ?? 0.0).toDouble(), stage: stage, stageDescription: _getStageDescription(stage), noduleCount: noduleCount, tailleMm: tailleMm, analyzedImageBase64: json['analyzed_image_base64'] ?? '', recommendations: List.from(json['recommendations'] ?? []), ); } static String _getStageDescription(String stage) { switch (stage) { case "Infection latente": return "Aucun nodule détecté, infection dormante possible"; case "Infection primaire": return "Nodules de petite taille, infection au stade initial"; case "Infection primaire ou début actif": return "Nodules de petite taille mais nombreux, surveillance nécessaire"; case "Infection active probable": return "Nodules de taille significative, infection probablement active"; case "Infection active confirmée": return "Nodules importants et nombreux, infection active avérée"; default: return "Stade indéterminé"; } } Map toJson() { return { 'is_tuberculosis': isTuberculosis, 'confidence': confidence, 'stage': stage, 'stage_description': stageDescription, 'nodule_count': noduleCount, 'taille_mm': tailleMm, 'analyzed_image_base64': analyzedImageBase64, 'recommendations': recommendations, 'timestamp': timestamp.toIso8601String(), }; } } // Classe pour gérer les fichiers de manière cross-platform class CrossPlatformFile { final String path; final String name; final Uint8List bytes; CrossPlatformFile({ required this.path, required this.name, required this.bytes, }); int get length => bytes.length; static Future fromXFile(XFile xFile) async { try { final bytes = await xFile.readAsBytes(); return CrossPlatformFile( path: xFile.path, name: xFile.name, bytes: bytes, ); } catch (e) { return null; } } } // Service API pour la tuberculose class TuberculosisApiService { static Future checkApiHealth() async { try { final response = await http.get( Uri.parse('${ApiConfig.baseUrl}/health'), headers: {'Content-Type': 'application/json'}, ).timeout(ApiConfig.connectionTimeout); return response.statusCode == 200; } on SocketException { throw ApiError( statusCode: 0, message: 'Impossible de se connecter au serveur', isNetworkError: true, ); } on TimeoutException { throw ApiError( statusCode: 0, message: 'Timeout de connexion', isTimeout: true, ); } catch (e) { throw ApiError( statusCode: 0, message: e.toString(), ); } } static Future analyzeImage( CrossPlatformFile imageFile) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/analyze'); final request = http.MultipartRequest('POST', uri); // Déterminer le type MIME final mimeType = lookupMimeType(imageFile.name) ?? 'image/jpeg'; final mediaType = MediaType.parse(mimeType); // Ajouter le fichier request.files.add( http.MultipartFile.fromBytes( 'file', imageFile.bytes, filename: imageFile.name, contentType: mediaType, ), ); // Ajouter les métadonnées request.fields['platform'] = ApiConfig.platformName; request.fields['timestamp'] = DateTime.now().toIso8601String(); // Envoyer la requête final streamedResponse = await request.send().timeout( ApiConfig.readTimeout, ); final response = await http.Response.fromStream(streamedResponse); if (response.statusCode == 200) { final jsonData = json.decode(response.body); return AnalysisResult.fromJson(jsonData); } else { final errorData = json.decode(response.body); throw ApiError( statusCode: response.statusCode, message: errorData['message'] ?? 'Erreur inconnue', detail: errorData['detail'], ); } } on SocketException { throw ApiError( statusCode: 0, message: 'Impossible de se connecter au serveur', isNetworkError: true, ); } on TimeoutException { throw ApiError( statusCode: 0, message: 'Timeout - Le serveur met trop de temps à répondre', isTimeout: true, ); } catch (e) { if (e is ApiError) rethrow; throw ApiError( statusCode: 0, message: e.toString(), ); } } } // Helper pour la validation et sélection d'images class ImageHelper { static Future pickFromGallery() async { try { final picker = ImagePicker(); final image = await picker.pickImage( source: ImageSource.gallery, maxWidth: 2048, maxHeight: 2048, imageQuality: 85, ); if (image == null) return null; final file = await CrossPlatformFile.fromXFile(image); if (file != null) { await _validateImage(file); } return file; } catch (e) { throw Exception('Erreur lors de la sélection: $e'); } } static Future takePhoto() async { try { final picker = ImagePicker(); final image = await picker.pickImage( source: ImageSource.camera, maxWidth: 2048, maxHeight: 2048, imageQuality: 85, ); if (image == null) return null; final file = await CrossPlatformFile.fromXFile(image); if (file != null) { await _validateImage(file); } return file; } catch (e) { throw Exception('Erreur lors de la prise de photo: $e'); } } static Future _validateImage(CrossPlatformFile file) async { // Vérifier la taille if (file.length == 0) { throw Exception('Le fichier image est vide'); } if (file.length > ApiConfig.maxFileSize) { throw Exception('Image trop volumineuse (max 10MB)'); } // Vérifier l'extension final extension = path.extension(file.name).toLowerCase(); if (!ApiConfig.allowedExtensions.contains(extension)) { throw Exception( 'Format non supporté. Utilisez: ${ApiConfig.allowedExtensions.join(', ')}'); } // Vérifier le type MIME final mimeType = lookupMimeType(file.name); if (mimeType == null || !ApiConfig.allowedMimeTypes.contains(mimeType)) { throw Exception('Type MIME non supporté: $mimeType'); } } // Méthode pour sauvegarder l'image dans la galerie static Future saveImageToGallery( Uint8List imageBytes, String fileName) async { try { if (kIsWeb) { // Pour le web, on propose le téléchargement return await _handleWebDownload(imageBytes, fileName); } else { // Pour mobile, on utilise Gal avec vérification des permissions return await _saveImageMobile(imageBytes, fileName); } } catch (e) { print('Erreur lors de la sauvegarde: $e'); return false; } } static Future _saveImageMobile( Uint8List imageBytes, String fileName) async { try { // Vérifier les permissions d'abord if (!await Gal.hasAccess()) { final hasPermission = await Gal.requestAccess(); if (!hasPermission) { throw Exception('Permission d\'accès à la galerie refusée'); } } // Obtenir le répertoire temporaire final tempDir = await getTemporaryDirectory(); final tempPath = path.join(tempDir.path, fileName); // Écrire le fichier temporaire final tempFile = io.File(tempPath); await tempFile.writeAsBytes(imageBytes); // Vérifier que le fichier a été créé if (!await tempFile.exists()) { throw Exception('Impossible de créer le fichier temporaire'); } // Sauvegarder dans la galerie avec Gal await Gal.putImage(tempPath); // Nettoyer le fichier temporaire try { await tempFile.delete(); } catch (e) { print( 'Avertissement: Impossible de supprimer le fichier temporaire: $e'); } return true; } catch (e) { print('Erreur sauvegarde mobile: $e'); rethrow; } } static Future _handleWebDownload( Uint8List imageBytes, String fileName) async { try { if (kIsWeb) { // Essayer d'abord le partage final shared = await _shareImageWeb(imageBytes, fileName); if (shared) return true; // Fallback: téléchargement direct return await _downloadImageWeb(imageBytes, fileName); } return false; } catch (e) { print('Erreur téléchargement web: $e'); return false; } } // Fonction pour le partage web (Share Plus) static Future _shareImageWeb( Uint8List imageBytes, String fileName) async { if (!kIsWeb) return false; try { // Créer un XFile à partir des bytes final xFile = XFile.fromData( imageBytes, name: fileName, mimeType: _getMimeTypeFromFileName(fileName), ); // Partager le fichier await Share.shareXFiles([xFile], text: 'Image analysée - TB Analyzer'); return true; } catch (e) { print('Erreur partage web: $e'); return false; } } // Fonction pour le téléchargement direct web static Future _downloadImageWeb( Uint8List imageBytes, String fileName) async { if (!kIsWeb) return false; try { // Pour le web, on utilise une approche plus simple // On informe l'utilisateur de faire clic droit > sauvegarder print( 'Sur le web, utilisez le partage ou faites clic droit > "Enregistrer l\'image sous"'); return false; } catch (e) { print('Erreur téléchargement direct web: $e'); return false; } } // Fonction utilitaire pour déterminer le type MIME static String _getMimeTypeFromFileName(String fileName) { final extension = path.extension(fileName).toLowerCase(); switch (extension) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.gif': return 'image/gif'; case '.webp': return 'image/webp'; default: return 'image/png'; // Défaut } } // Fonction pour vérifier si la sauvegarde est supportée static bool get isSaveSupported { return !kIsWeb || (kIsWeb && _isWebSaveSupported); } static bool get _isWebSaveSupported { // Vérifier si le navigateur supporte les APIs nécessaires return kIsWeb; // Simplification - en réalité, on pourrait vérifier plus précisément } // Fonction pour obtenir un message d'erreur approprié static String getSaveErrorMessage(dynamic error) { if (error.toString().contains('permission')) { return 'Permission d\'accès à la galerie requise. Veuillez autoriser l\'accès dans les paramètres.'; } else if (error.toString().contains('space')) { return 'Espace de stockage insuffisant sur l\'appareil.'; } else if (error.toString().contains('network')) { return 'Erreur de connexion. Vérifiez votre connexion internet.'; } else { return 'Erreur lors de la sauvegarde: ${error.toString()}'; } } // Nouvelle méthode pour capturer un widget en image static Future captureWidget(GlobalKey key) async { try { RenderRepaintBoundary boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary; ui.Image image = await boundary.toImage(pixelRatio: 3.0); ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); return byteData?.buffer.asUint8List(); } catch (e) { print('Erreur capture widget: $e'); return null; } } } // Classe utilitaire simplifiée (sans imports web complexes) class WebUtils { static bool get isWebSaveSupported => kIsWeb; static String getWebSaveMessage() { return 'Sur le web, utilisez le bouton de partage ou faites clic droit > "Enregistrer l\'image sous" pour sauvegarder l\'image.'; } } // Classe pour gérer les imports conditionnels web class WebImports { static dynamic html; static void initialize() { if (kIsWeb) { try { // Import conditionnel de dart:html html = _getHtmlLibrary(); } catch (e) { print('Impossible d\'importer dart:html: $e'); } } } static dynamic _getHtmlLibrary() { // Cette fonction doit être implémentée avec un import conditionnel // ou utiliser une approche différente selon votre architecture return null; } } // Thème personnalisé pour l'application médicale class MedicalTheme { static const Color primaryColor = Color(0xFF2E5C8A); // Bleu médical professionnel static const Color secondaryColor = Color(0xFF4A90B8); // Bleu plus clair static const Color accentColor = Color(0xFF00A693); // Vert médical (stéthoscope) static const Color warningColor = Color(0xFFFF6B35); // Orange pour les alertes static const Color errorColor = Color(0xFFE53E3E); // Rouge pour les erreurs static const Color successColor = Color(0xFF38A169); // Vert pour les succès static const Color backgroundColor = Color(0xFFF7FAFC); // Fond très clair static const Color surfaceColor = Color(0xFFFFFFFF); // Blanc pour les cartes static const Color cardColor = Color(0xFFFAFAFA); // Gris très clair pour les cartes static ThemeData get lightTheme { return ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: primaryColor, brightness: Brightness.light, primary: primaryColor, secondary: secondaryColor, tertiary: accentColor, surface: surfaceColor, background: backgroundColor, error: errorColor, ), appBarTheme: const AppBarTheme( backgroundColor: primaryColor, foregroundColor: Colors.white, elevation: 0, centerTitle: true, ), cardTheme: CardTheme( elevation: 2, shadowColor: Colors.black.withOpacity(0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), color: surfaceColor, ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, elevation: 2, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), floatingActionButtonTheme: const FloatingActionButtonThemeData( backgroundColor: accentColor, foregroundColor: Colors.white, ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey[300]!), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primaryColor, width: 2), ), ), ); } } // Widget principal de l'application avec nouveau thème class TuberculosisAnalyzerApp extends StatelessWidget { const TuberculosisAnalyzerApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: 'TB Analyzer - Diagnostic Tuberculose', theme: MedicalTheme.lightTheme, home: const MainNavigation(), debugShowCheckedModeBanner: false, ); } } // Page d'accueil avec nouvelle interface class HomePage extends StatefulWidget { const HomePage({Key? key}) : super(key: key); @override _HomePageState createState() => _HomePageState(); } class _HomePageState extends State with TickerProviderStateMixin { CrossPlatformFile? _selectedImage; AnalysisResult? _analysisResult; bool _isAnalyzing = false; ApiError? _error; bool _isApiHealthy = false; String _apiStatus = 'Vérification en cours...'; late AnimationController _pulseController; late Animation _pulseAnimation; // Clé globale pour capturer le widget des images combinées final GlobalKey _combinedImagesKey = GlobalKey(); @override void initState() { super.initState(); _pulseController = AnimationController( duration: const Duration(seconds: 2), vsync: this, ); _pulseAnimation = Tween( begin: 0.8, end: 1.2, ).animate(CurvedAnimation( parent: _pulseController, curve: Curves.easeInOut, )); _pulseController.repeat(reverse: true); _checkApiHealth(); } @override void dispose() { _pulseController.dispose(); super.dispose(); } Future _checkApiHealth() async { setState(() { _apiStatus = 'Vérification en cours...'; _isApiHealthy = false; _error = null; }); try { // Utiliser le service API pour vérifier la santé final isHealthy = await TuberculosisApiService.checkApiHealth(); setState(() { _isApiHealthy = isHealthy; _apiStatus = isHealthy ? 'Serveur IA connecté' : 'Serveur IA non accessible'; _error = null; }); } catch (e) { setState(() { _isApiHealthy = false; _apiStatus = 'Serveur IA non accessible'; _error = ApiError(statusCode: 0, message: e.toString()); }); } } // Méthode pour sélectionner une image depuis la galerie Future _selectImageFromGallery() async { try { final file = await ImageHelper.pickFromGallery(); if (file != null) { setState(() { _selectedImage = file; _analysisResult = null; _error = null; }); } } catch (e) { setState(() { _error = ApiError(statusCode: 0, message: e.toString()); }); } } Future _takePhoto() async { try { final file = await ImageHelper.takePhoto(); if (file != null) { setState(() { _selectedImage = file; _analysisResult = null; _error = null; }); } } catch (e) { setState(() { _error = ApiError(statusCode: 0, message: e.toString()); }); } } Future _analyzeImage() async { if (_selectedImage == null) return; setState(() { _isAnalyzing = true; _error = null; }); try { // Utiliser le service API pour analyser l'image final result = await TuberculosisApiService.analyzeImage(_selectedImage!); setState(() { _analysisResult = result; _isAnalyzing = false; }); } catch (e) { setState(() { _isAnalyzing = false; _error = e is ApiError ? e : ApiError(statusCode: 0, message: e.toString()); }); } } // Nouvelle méthode pour sauvegarder les images combinées Future _saveCombinedImages() async { if (_analysisResult == null || _selectedImage == null) return; try { // Capturer le widget des images combinées final imageBytes = await ImageHelper.captureWidget(_combinedImagesKey); if (imageBytes != null) { final fileName = 'tb_analysis_combined_${DateTime.now().millisecondsSinceEpoch}.png'; final success = await ImageHelper.saveImageToGallery(imageBytes, fileName); if (success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Images combinées sauvegardées avec succès'), backgroundColor: MedicalTheme.successColor, ), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Erreur lors de la sauvegarde'), backgroundColor: MedicalTheme.errorColor, ), ); } } else { throw Exception('Impossible de capturer les images'); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur: $e'), backgroundColor: MedicalTheme.errorColor, ), ); } } // Nouvelle méthode pour rafraîchir/réinitialiser la page void _refreshApp() { setState(() { _selectedImage = null; _analysisResult = null; _error = null; _isAnalyzing = false; }); _checkApiHealth(); } // Méthode pour obtenir la couleur selon le niveau de confiance Color _getConfidenceColor(double confidence) { if (confidence >= 0.8) return MedicalTheme.successColor; if (confidence >= 0.6) return MedicalTheme.warningColor; return MedicalTheme.errorColor; } // Méthode pour obtenir la couleur selon le stade Color _getStageColor(String stage) { switch (stage) { case "Infection latente": return MedicalTheme.successColor; case "Infection primaire": return Colors.orange; case "Infection primaire ou début actif": return MedicalTheme.warningColor; case "Infection active probable": return Colors.deepOrange; case "Infection active confirmée": return MedicalTheme.errorColor; default: return Colors.grey; } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: MedicalTheme.backgroundColor, appBar: AppBar( title: const Text('TB Analyzer'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _refreshApp, tooltip: 'Rafraîchir', ), IconButton( icon: Icon( _isApiHealthy ? Icons.cloud_done : Icons.cloud_off, color: _isApiHealthy ? MedicalTheme.successColor : MedicalTheme.errorColor, ), onPressed: _checkApiHealth, tooltip: _apiStatus, ), ], ), body: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Carte de statut de l'API _buildApiStatusCard(), const SizedBox(height: 16), // Section de sélection d'image _buildImageSelectionCard(), const SizedBox(height: 16), // Affichage de l'image sélectionnée if (_selectedImage != null) _buildSelectedImageCard(), if (_selectedImage != null) const SizedBox(height: 16), // Bouton d'analyse if (_selectedImage != null && _isApiHealthy) _buildAnalyzeButton(), if (_selectedImage != null && _isApiHealthy) const SizedBox(height: 16), // Indicateur de chargement if (_isAnalyzing) _buildLoadingIndicator(), if (_isAnalyzing) const SizedBox(height: 16), // Résultats de l'analyse if (_analysisResult != null) _buildResultsCard(), // Affichage des erreurs if (_error != null) _buildErrorCard(), ], ), ), ); } Widget _buildApiStatusCard() { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { return Transform.scale( scale: _isApiHealthy ? 1.0 : _pulseAnimation.value, child: Icon( _isApiHealthy ? Icons.check_circle : Icons.error, color: _isApiHealthy ? MedicalTheme.successColor : MedicalTheme.errorColor, size: 24, ), ); }, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Statut du serveur IA', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), Text( _apiStatus, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: _isApiHealthy ? MedicalTheme.successColor : MedicalTheme.errorColor, ), ), if (!_isApiHealthy) Text( 'Plateforme: ${ApiConfig.platformName} - URL: ${ApiConfig.baseUrl}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), ], ), ), IconButton( icon: const Icon(Icons.refresh), onPressed: _checkApiHealth, tooltip: 'Vérifier à nouveau', ), ], ), ), ); } Widget _buildImageSelectionCard() { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Sélectionner une radiographie pulmonaire', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), ), const SizedBox(height: 8), Text( 'Choisissez une image de radiographie pour analyser la présence de tuberculose.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: _selectImageFromGallery, icon: const Icon(Icons.photo_library), label: const Text('Galerie'), style: ElevatedButton.styleFrom( backgroundColor: MedicalTheme.secondaryColor, ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( onPressed: ApiConfig.isMobile ? _takePhoto : null, icon: const Icon(Icons.camera_alt), label: const Text('Caméra'), style: ElevatedButton.styleFrom( backgroundColor: MedicalTheme.accentColor, ), ), ), ], ), if (!ApiConfig.isMobile) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( 'Caméra disponible uniquement sur mobile', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[500], fontStyle: FontStyle.italic, ), textAlign: TextAlign.center, ), ), ], ), ), ); } Widget _buildSelectedImageCard() { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Image sélectionnée', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 12), Container( height: 300, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[300]!), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.memory( _selectedImage!.bytes, fit: BoxFit.contain, width: double.infinity, ), ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Fichier: ${_selectedImage!.name}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), Text( 'Taille: ${(_selectedImage!.length / 1024 / 1024).toStringAsFixed(1)} MB', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), ], ), ], ), ), ); } Widget _buildAnalyzeButton() { return SizedBox( height: 56, child: ElevatedButton.icon( onPressed: _isAnalyzing ? null : _analyzeImage, icon: _isAnalyzing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : const Icon(Icons.analytics, size: 24), label: Text( _isAnalyzing ? 'Analyse en cours...' : 'Analyser l\'image', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), style: ElevatedButton.styleFrom( backgroundColor: MedicalTheme.primaryColor, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ); } Widget _buildLoadingIndicator() { return Card( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( children: [ const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(MedicalTheme.primaryColor), strokeWidth: 3, ), const SizedBox(height: 16), Text( 'Analyse de l\'image en cours...', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), Text( 'L\'intelligence artificielle examine votre radiographie.\nCela peut prendre quelques secondes.', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), ), ], ), ), ); } Widget _buildResultsCard() { if (_analysisResult == null) return const SizedBox.shrink(); return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // En-tête des résultats Row( children: [ Icon( _analysisResult!.isTuberculosis ? Icons.warning : Icons.check_circle, color: _analysisResult!.isTuberculosis ? MedicalTheme.errorColor : MedicalTheme.successColor, size: 28, ), const SizedBox(width: 12), Expanded( child: Text( 'Résultats de l\'analyse', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), ), ), ], ), const SizedBox(height: 16), // Diagnostic principal Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _analysisResult!.isTuberculosis ? MedicalTheme.errorColor.withOpacity(0.1) : MedicalTheme.successColor.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all( color: _analysisResult!.isTuberculosis ? MedicalTheme.errorColor : MedicalTheme.successColor, width: 2, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _analysisResult!.isTuberculosis ? 'TUBERCULOSE DÉTECTÉE' : 'TUBERCULOSE NON DÉTECTÉE', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: _analysisResult!.isTuberculosis ? MedicalTheme.errorColor : MedicalTheme.successColor, ), ), const SizedBox(height: 8), Text( 'Niveau de confiance: ${(_analysisResult!.confidence * 100).toStringAsFixed(1)}%', style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), ), const SizedBox(height: 16), const SizedBox(height: 16), // Recommandations if (_analysisResult!.recommendations.isNotEmpty) ...[ Text( 'Recommandations:', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), ..._analysisResult!.recommendations.map((rec) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.arrow_right, size: 16), const SizedBox(width: 8), Expanded( child: Text(rec, style: Theme.of(context).textTheme.bodyMedium), ), ], ), )), const SizedBox(height: 16), ], // Images comparatives avec RepaintBoundary pour la capture RepaintBoundary( key: _combinedImagesKey, child: _buildComparisonImages(), ), const SizedBox(height: 16), // Boutons d'action Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _saveCombinedImages, icon: const Icon(Icons.save), label: const Text('Sauvegarder'), style: OutlinedButton.styleFrom( foregroundColor: MedicalTheme.primaryColor, side: const BorderSide(color: MedicalTheme.primaryColor), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( onPressed: () => _shareResults(context), icon: const Icon(Icons.share), label: const Text('Partager'), ), ), ], ), // Avertissement médical const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: MedicalTheme.warningColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: MedicalTheme.warningColor), ), child: Row( children: [ const Icon(Icons.info, color: MedicalTheme.warningColor), const SizedBox(width: 8), Expanded( child: Text( 'Ce diagnostic automatique ne remplace pas l\'avis d\'un professionnel de santé. Consultez un médecin pour un diagnostic définitif.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: MedicalTheme.warningColor, fontWeight: FontWeight.w500, ), ), ), ], ), ), ], ), ), ); } Widget _buildInfoRow(String label, String value, {Color? color}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: Colors.grey[700], ), ), ), Expanded( child: Text( value, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: color ?? Colors.black87, fontWeight: color != null ? FontWeight.w600 : FontWeight.normal, ), ), ), ], ), ); } Widget _buildComparisonImages() { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[300]!), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: MedicalTheme.primaryColor.withOpacity(0.1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), ), ), child: Text( 'Comparaison des images', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), textAlign: TextAlign.center, ), ), Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // Image originale Expanded( child: Column( children: [ Text( 'Image originale', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Container( height: 200, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( _selectedImage!.bytes, fit: BoxFit.contain, width: double.infinity, ), ), ), ], ), ), const SizedBox(width: 12), // Image analysée Expanded( child: Column( children: [ Text( 'Image analysée', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Container( height: 200, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: _analysisResult!.analyzedImageBase64.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.memory( base64Decode( _analysisResult!.analyzedImageBase64), fit: BoxFit.contain, width: double.infinity, ), ) : Container( decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(8), ), child: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.image_not_supported, size: 48, color: Colors.grey), SizedBox(height: 8), Text('Image analysée non disponible'), ], ), ), ), ), ], ), ), ], ), ), Text( _analysisResult!.isTuberculosis ? 'TUBERCULOSE DÉTECTÉE' : 'TUBERCULOSE NON DÉTECTÉE', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: _analysisResult!.isTuberculosis ? MedicalTheme.errorColor : MedicalTheme.successColor, ), ), const SizedBox(height: 8), Text( 'Niveau de confiance: ${(_analysisResult!.confidence * 100).toStringAsFixed(1)}%', style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, ), ), // Informations détaillées const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[200]!), ), child: Column( children: [ Text( 'Informations détaillées', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), ), const SizedBox(height: 12), _buildInfoRow('Stade clinique:', _analysisResult!.stage, color: _getStageColor(_analysisResult!.stage)), _buildInfoRow( 'Description:', _analysisResult!.stageDescription), ], ), ), ), const SizedBox(height: 12), ], ), ); } Widget _buildErrorCard() { if (_error == null) return const SizedBox.shrink(); return Card( color: MedicalTheme.errorColor.withOpacity(0.1), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Icon(Icons.error, color: MedicalTheme.errorColor, size: 28), const SizedBox(width: 12), Expanded( child: Text( 'Erreur d\'analyse', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.errorColor, ), ), ), ], ), const SizedBox(height: 12), Text( _error!.toString(), style: Theme.of(context).textTheme.bodyMedium, ), if (_error!.detail != null) ...[ const SizedBox(height: 8), Text( 'Détail: ${_error!.detail}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), ], const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _checkApiHealth, icon: const Icon(Icons.refresh), label: const Text('Vérifier la connexion'), style: OutlinedButton.styleFrom( foregroundColor: MedicalTheme.errorColor, side: const BorderSide(color: MedicalTheme.errorColor), ), ), ), if (_selectedImage != null) ...[ const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( onPressed: _analyzeImage, icon: const Icon(Icons.refresh), label: const Text('Réessayer'), style: ElevatedButton.styleFrom( backgroundColor: MedicalTheme.errorColor, ), ), ), ], ], ), ], ), ), ); } Future _shareResults(BuildContext context) async { if (_analysisResult == null) return; try { // Créer un texte de résumé final summary = ''' TB Analyzer - Résultats d'analyse Diagnostic: ${_analysisResult!.isTuberculosis ? 'TUBERCULOSE DÉTECTÉE' : 'TUBERCULOSE NON DÉTECTÉE'} Confiance: ${(_analysisResult!.confidence * 100).toStringAsFixed(1)}% Stade: ${_analysisResult!.stage} Nodules: ${_analysisResult!.noduleCount} Taille moyenne: ${_analysisResult!.tailleMm.toStringAsFixed(1)} mm Date: ${_analysisResult!.timestamp.toLocal().toString().split('.')[0]} ⚠️ Ce diagnostic automatique ne remplace pas l'avis d'un professionnel de santé. '''; await Share.share(summary, subject: 'Résultats TB Analyzer'); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur lors du partage: $e'), backgroundColor: MedicalTheme.errorColor, ), ); } } } // Page d'historique (placeholder pour l'extension future) class HistoryPage extends StatelessWidget { const HistoryPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: MedicalTheme.backgroundColor, appBar: AppBar( title: const Text('Historique'), ), body: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.history, size: 64, color: Colors.grey), SizedBox(height: 16), Text( 'Historique des analyses', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), SizedBox(height: 8), Text( 'Cette fonctionnalité sera disponible prochainement', style: TextStyle(color: Colors.grey), ), ], ), ), ); } } // Page d'informations médicales class InfoPage extends StatelessWidget { const InfoPage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: MedicalTheme.backgroundColor, appBar: AppBar( title: const Text('Informations Tuberculose'), ), body: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildInfoCard( context, 'Qu\'est-ce que la tuberculose ?', 'La tuberculose (TB) est une maladie infectieuse causée par la bactérie Mycobacterium tuberculosis. Elle affecte principalement les poumons, mais peut toucher d\'autres parties du corps.', Icons.info, MedicalTheme.primaryColor, ), const SizedBox(height: 16), _buildInfoCard( context, 'Symptômes principaux', '• Toux persistante (plus de 3 semaines)\n• Crachats parfois sanguinolents\n• Fièvre et sueurs nocturnes\n• Perte de poids inexpliquée\n• Fatigue et faiblesse', Icons.local_hospital, MedicalTheme.warningColor, ), const SizedBox(height: 16), _buildInfoCard( context, 'Stades de la maladie', '• Infection latente: Bactéries présentes mais inactives\n• Infection primaire: Premier contact avec la bactérie\n• Infection active: Maladie déclarée et contagieuse', Icons.timeline, MedicalTheme.accentColor, ), const SizedBox(height: 16), _buildInfoCard( context, 'Prévention', '• Vaccination BCG (selon les recommandations)\n• Éviter les lieux confinés mal ventilés\n• Port de masque en cas de toux\n• Dépistage régulier pour les personnes à risque', Icons.shield, MedicalTheme.successColor, ), const SizedBox(height: 16), _buildInfoCard( context, 'Traitement', 'La tuberculose se soigne avec des antibiotiques spécifiques pris pendant 6 à 9 mois. Le traitement doit être suivi rigoureusement même après amélioration des symptômes.', Icons.medication, MedicalTheme.primaryColor, ), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: MedicalTheme.errorColor.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: MedicalTheme.errorColor), ), child: Column( children: [ const Icon(Icons.warning, color: MedicalTheme.errorColor, size: 32), const SizedBox(height: 12), Text( 'Avertissement Important', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.errorColor, ), ), const SizedBox(height: 8), const Text( 'Cette application d\'analyse automatique ne remplace en aucun cas l\'avis d\'un professionnel de santé qualifié. En cas de symptômes ou de doutes, consultez immédiatement un médecin.', textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.w500), ), ], ), ), ], ), ), ); } // Suite du code précédent... Widget _buildInfoCard(BuildContext context, String title, String content, IconData icon, Color color) { return Card( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: color, size: 28), const SizedBox(width: 12), Expanded( child: Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: color, ), ), ), ], ), const SizedBox(height: 12), Text( content, style: Theme.of(context).textTheme.bodyMedium?.copyWith( height: 1.5, ), ), ], ), ), ); } } // Page des paramètres class SettingsPage extends StatefulWidget { const SettingsPage({Key? key}) : super(key: key); @override _SettingsPageState createState() => _SettingsPageState(); } class _SettingsPageState extends State { bool _notificationsEnabled = true; bool _autoSaveResults = false; String _selectedLanguage = 'Français'; double _imageQuality = 85.0; final List _languages = ['Français', 'English', 'العربية']; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: MedicalTheme.backgroundColor, appBar: AppBar( title: const Text('Paramètres'), ), body: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Paramètres généraux _buildSettingsSection( 'Général', [ _buildSwitchTile( 'Notifications', 'Recevoir des notifications pour les résultats', Icons.notifications, _notificationsEnabled, (value) => setState(() => _notificationsEnabled = value), ), _buildSwitchTile( 'Sauvegarde automatique', 'Sauvegarder automatiquement les résultats', Icons.save, _autoSaveResults, (value) => setState(() => _autoSaveResults = value), ), _buildDropdownTile( 'Langue', 'Langue de l\'interface', Icons.language, _selectedLanguage, _languages, (value) => setState(() => _selectedLanguage = value!), ), ], ), const SizedBox(height: 16), // Paramètres d'image _buildSettingsSection( 'Qualité d\'image', [ _buildSliderTile( 'Qualité de compression', 'Ajuster la qualité des images (${_imageQuality.round()}%)', Icons.image, _imageQuality, 0.0, 100.0, (value) => setState(() => _imageQuality = value), ), ], ), const SizedBox(height: 16), // Informations sur l'application _buildSettingsSection( 'À propos', [ _buildInfoTile( 'Version', '1.0.0', Icons.info, ), _buildInfoTile( 'Plateforme', ApiConfig.platformName, Icons.phone_android, ), _buildInfoTile( 'Serveur API', ApiConfig.baseUrl, Icons.cloud, ), ], ), const SizedBox(height: 16), // Actions _buildSettingsSection( 'Actions', [ _buildActionTile( 'Tester la connexion', 'Vérifier la connexion au serveur IA', Icons.network_check, () => _testConnection(), ), _buildActionTile( 'Vider le cache', 'Supprimer les données temporaires', Icons.clear_all, () => _clearCache(), ), _buildActionTile( 'Signaler un problème', 'Envoyer un rapport de bug', Icons.bug_report, () => _reportIssue(), ), ], ), const SizedBox(height: 32), // Disclaimer Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: MedicalTheme.primaryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: MedicalTheme.primaryColor), ), child: Column( children: [ const Icon( Icons.medical_services, color: MedicalTheme.primaryColor, size: 32, ), const SizedBox(height: 8), Text( 'TB Analyzer', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), ), const SizedBox(height: 4), Text( 'Outil d\'aide au diagnostic par IA', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: MedicalTheme.primaryColor, ), ), ], ), ), ], ), ), ); } Widget _buildSettingsSection(String title, List children) { return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: MedicalTheme.primaryColor.withOpacity(0.1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), ), ), child: Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: MedicalTheme.primaryColor, ), ), ), ...children, ], ), ); } Widget _buildSwitchTile(String title, String subtitle, IconData icon, bool value, ValueChanged onChanged) { return ListTile( leading: Icon(icon, color: MedicalTheme.primaryColor), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text(subtitle), trailing: Switch( value: value, onChanged: onChanged, activeColor: MedicalTheme.primaryColor, ), ); } Widget _buildDropdownTile(String title, String subtitle, IconData icon, String value, List items, ValueChanged onChanged) { return ListTile( leading: Icon(icon, color: MedicalTheme.primaryColor), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text(subtitle), trailing: DropdownButton( value: value, onChanged: onChanged, items: items .map((item) => DropdownMenuItem( value: item, child: Text(item), )) .toList(), ), ); } Widget _buildSliderTile(String title, String subtitle, IconData icon, double value, double min, double max, ValueChanged onChanged) { return ListTile( leading: Icon(icon, color: MedicalTheme.primaryColor), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(subtitle), Slider( value: value, min: min, max: max, divisions: (max - min).round(), activeColor: MedicalTheme.primaryColor, onChanged: onChanged, ), ], ), ); } Widget _buildInfoTile(String title, String value, IconData icon) { return ListTile( leading: Icon(icon, color: MedicalTheme.primaryColor), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), trailing: Text(value, style: const TextStyle(color: Colors.grey)), ); } Widget _buildActionTile( String title, String subtitle, IconData icon, VoidCallback onTap) { return ListTile( leading: Icon(icon, color: MedicalTheme.primaryColor), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text(subtitle), trailing: const Icon(Icons.arrow_forward_ios, size: 16), onTap: onTap, ); } Future _testConnection() async { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: const [ CircularProgressIndicator(), SizedBox(height: 16), Text('Test de connexion en cours...'), ], ), ), ); try { final isHealthy = await TuberculosisApiService.checkApiHealth(); Navigator.pop(context); showDialog( context: context, builder: (context) => AlertDialog( title: Row( children: [ Icon( isHealthy ? Icons.check_circle : Icons.error, color: isHealthy ? MedicalTheme.successColor : MedicalTheme.errorColor, ), const SizedBox(width: 8), Text(isHealthy ? 'Connexion OK' : 'Connexion échouée'), ], ), content: Text( isHealthy ? 'Le serveur IA est accessible et fonctionne correctement.' : 'Impossible de se connecter au serveur IA. Vérifiez que le serveur est démarré.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('OK'), ), ], ), ); } catch (e) { Navigator.pop(context); showDialog( context: context, builder: (context) => AlertDialog( title: const Row( children: [ Icon(Icons.error, color: MedicalTheme.errorColor), SizedBox(width: 8), Text('Erreur de connexion'), ], ), content: Text('Erreur: $e'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('OK'), ), ], ), ); } } void _clearCache() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Vider le cache'), content: const Text( 'Êtes-vous sûr de vouloir supprimer toutes les données temporaires ? ' 'Cette action ne peut pas être annulée.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Annuler'), ), ElevatedButton( onPressed: () { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Cache vidé avec succès'), backgroundColor: MedicalTheme.successColor, ), ); }, style: ElevatedButton.styleFrom( backgroundColor: MedicalTheme.errorColor, ), child: const Text('Vider'), ), ], ), ); } void _reportIssue() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Signaler un problème'), content: const Text( 'Pour signaler un problème ou suggérer une amélioration, ' 'contactez notre équipe de support technique.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Plus tard'), ), ElevatedButton( onPressed: () { Navigator.pop(context); // Ici, vous pourriez ouvrir un email ou un formulaire de contact ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Fonctionnalité à venir'), backgroundColor: MedicalTheme.primaryColor, ), ); }, child: const Text('Contacter'), ), ], ), ); } } // Navigation principale avec BottomNavigationBar class MainNavigation extends StatefulWidget { const MainNavigation({Key? key}) : super(key: key); @override _MainNavigationState createState() => _MainNavigationState(); } class _MainNavigationState extends State { int _currentIndex = 0; final List _pages = [ const HomePage(), const HistoryPage(), const InfoPage(), const SettingsPage(), ]; @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: _currentIndex, children: _pages, ), bottomNavigationBar: BottomNavigationBar( type: BottomNavigationBarType.fixed, currentIndex: _currentIndex, selectedItemColor: MedicalTheme.primaryColor, unselectedItemColor: Colors.grey, backgroundColor: Colors.white, elevation: 8, onTap: (index) => setState(() => _currentIndex = index), items: const [ BottomNavigationBarItem( icon: Icon(Icons.home), label: 'Accueil', ), BottomNavigationBarItem( icon: Icon(Icons.history), label: 'Historique', ), BottomNavigationBarItem( icon: Icon(Icons.info), label: 'Info TB', ), BottomNavigationBarItem( icon: Icon(Icons.settings), label: 'Paramètres', ), ], ), ); } } // Point d'entrée de l'application void main() { // Initialiser les imports web si nécessaire WebImports.initialize(); runApp(const TuberculosisAnalyzerApp()); } // Classe utilitaire pour les constantes de l'application class AppConstants { static const String appName = 'TB Analyzer'; static const String appVersion = '1.0.0'; static const String appDescription = 'Diagnostic automatique de la tuberculose par IA'; // Messages d'erreur standardisés static const String errorNetworkConnection = 'Erreur de connexion réseau. Vérifiez votre connexion internet.'; static const String errorServerUnavailable = 'Serveur temporairement indisponible. Réessayez plus tard.'; static const String errorImageTooLarge = 'Image trop volumineuse. Taille maximale autorisée: 10MB.'; static const String errorUnsupportedFormat = 'Format d\'image non supporté. Utilisez JPG, PNG ou BMP.'; static const String errorAnalysisFailed = 'Échec de l\'analyse. Vérifiez la qualité de l\'image.'; // Messages d'information static const String infoMedicalDisclaimer = 'Ce diagnostic automatique ne remplace pas l\'avis d\'un professionnel de santé.'; static const String infoConsultDoctor = 'Consultez un médecin pour un diagnostic définitif.'; static const String infoEmergency = 'En cas d\'urgence médicale, contactez immédiatement les services d\'urgence.'; // Configuration par défaut static const double defaultImageQuality = 85.0; static const int maxRetryAttempts = 3; static const Duration defaultTimeout = Duration(seconds: 30); } // Gestionnaire d'état global (optionnel pour extension future) class AppState { static final AppState _instance = AppState._internal(); factory AppState() => _instance; AppState._internal(); // Variables d'état globales bool _isApiHealthy = false; String _currentLanguage = 'fr'; bool _notificationsEnabled = true; // Getters bool get isApiHealthy => _isApiHealthy; String get currentLanguage => _currentLanguage; bool get notificationsEnabled => _notificationsEnabled; // Setters avec notification set isApiHealthy(bool value) { _isApiHealthy = value; _notifyListeners(); } set currentLanguage(String value) { _currentLanguage = value; _notifyListeners(); } set notificationsEnabled(bool value) { _notificationsEnabled = value; _notifyListeners(); } // Liste des écouteurs (pour un système de notification simple) final List _listeners = []; void addListener(VoidCallback listener) { _listeners.add(listener); } void removeListener(VoidCallback listener) { _listeners.remove(listener); } void _notifyListeners() { for (final listener in _listeners) { listener(); } } // Méthodes utilitaires void reset() { _isApiHealthy = false; _currentLanguage = 'fr'; _notificationsEnabled = true; _notifyListeners(); } } // Extension utilitaire pour les couleurs extension ColorExtension on Color { /// Retourne une version plus claire de la couleur Color get lighter { return Color.lerp(this, Colors.white, 0.3) ?? this; } /// Retourne une version plus foncée de la couleur Color get darker { return Color.lerp(this, Colors.black, 0.3) ?? this; } } // Extension utilitaire pour les strings extension StringExtension on String { /// Capitalise la première lettre String get capitalize { if (isEmpty) return this; return this[0].toUpperCase() + substring(1); } /// Vérifie si la string est un email valide bool get isValidEmail { return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this); } } // Extension utilitaire pour les dates extension DateTimeExtension on DateTime { /// Formate la date pour l'affichage français String get formatFrench { const months = [ 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre' ]; return '$day ${months[month - 1]} $year à ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; } /// Retourne le temps écoulé de façon lisible String get timeAgo { final now = DateTime.now(); final difference = now.difference(this); if (difference.inDays > 7) { return formatFrench; } else if (difference.inDays > 0) { return 'Il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}'; } else if (difference.inHours > 0) { return 'Il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}'; } else if (difference.inMinutes > 0) { return 'Il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}'; } else { return 'À l\'instant'; } } }