| | 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';
|
| |
|
| |
|
| | class AnalysisResult {
|
| | final bool isTuberculosis;
|
| | final double confidence;
|
| | final String stage;
|
| | final String stageDescription;
|
| | final int noduleCount;
|
| | final String analyzedImageBase64;
|
| | final List<String> 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<String, dynamic> 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<String>.from(json['recommendations'] ?? []),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | class TuberculosisApiService {
|
| |
|
| | static const String baseUrl = 'https://tuberculose-k708.onrender.com';
|
| |
|
| |
|
| |
|
| |
|
| | static const List<String> allowedFormats = ['jpg', 'jpeg', 'png', 'bmp'];
|
| |
|
| |
|
| | static const Duration apiTimeout = Duration(seconds: 120);
|
| | static const Duration healthTimeout = Duration(seconds: 30);
|
| | static const Duration wakeupTimeout = Duration(seconds: 60);
|
| |
|
| |
|
| | 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<AnalysisResult> analyzeImage(File imageFile) async {
|
| | try {
|
| | debugPrint('=== DÉBUT ANALYSE IMAGE ===');
|
| | debugApiUrl();
|
| |
|
| |
|
| | if (!await imageFile.exists()) {
|
| | throw Exception('Le fichier image n\'existe pas');
|
| | }
|
| |
|
| |
|
| | final fileSize = await imageFile.length();
|
| | if (fileSize > 10 * 1024 * 1024) {
|
| | throw Exception('Le fichier est trop volumineux (max 10MB)');
|
| | }
|
| |
|
| |
|
| | 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');
|
| |
|
| |
|
| | await wakeUpService();
|
| |
|
| |
|
| | final bytes = await imageFile.readAsBytes();
|
| | if (bytes.isEmpty) {
|
| | throw Exception('Le fichier image est vide');
|
| | }
|
| |
|
| | debugPrint('Bytes lus: ${bytes.length}');
|
| |
|
| |
|
| | final mimeType = lookupMimeType(imageFile.path) ??
|
| | _getMimeTypeFromExtension(extension);
|
| | debugPrint('Type MIME détecté: $mimeType');
|
| |
|
| |
|
| | final analyzeUrl = '$baseUrl/analyze';
|
| | debugPrint('URL complète pour l\'analyse: $analyzeUrl');
|
| |
|
| |
|
| | var request = http.MultipartRequest(
|
| | 'POST',
|
| | Uri.parse(analyzeUrl),
|
| | );
|
| |
|
| |
|
| | request.headers.addAll({
|
| | 'Accept': 'application/json',
|
| | 'User-Agent': 'TuberculosisAnalyzer/1.0',
|
| | 'Connection': 'keep-alive',
|
| | });
|
| |
|
| |
|
| | var multipartFile = http.MultipartFile.fromBytes(
|
| | 'file',
|
| | 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}');
|
| |
|
| |
|
| | 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 {
|
| |
|
| | String errorMessage = 'Erreur ${response.statusCode}';
|
| |
|
| | try {
|
| | var errorData = json.decode(response.body);
|
| | if (errorData is Map<String, dynamic>) {
|
| | 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');
|
| | }
|
| | }
|
| |
|
| |
|
| | 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';
|
| | }
|
| | }
|
| |
|
| | static Future<Map<String, dynamic>> getHealthStatus() async {
|
| | try {
|
| | debugPrint('=== VÉRIFICATION SANTÉ API ===');
|
| | debugApiUrl();
|
| |
|
| | 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');
|
| |
|
| | 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<Map<String, dynamic>> getModelInfo() async {
|
| | try {
|
| | debugPrint('=== RÉCUPÉRATION INFOS MODÈLE ===');
|
| | debugApiUrl();
|
| |
|
| | 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');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<void> 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');
|
| |
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<bool> testConnectivity() async {
|
| | try {
|
| | debugPrint('=== TEST DE CONNECTIVITÉ ===');
|
| | debugApiUrl();
|
| |
|
| | 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;
|
| | }
|
| | }
|
| |
|
| |
|
| | 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;
|
| | }
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | 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(),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | class HomePage extends StatefulWidget {
|
| | @override
|
| | _HomePageState createState() => _HomePageState();
|
| | }
|
| |
|
| | class _HomePageState extends State<HomePage> {
|
| | 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<void> _checkApiHealth() async {
|
| | setState(() {
|
| | _isCheckingHealth = true;
|
| | _healthStatus = 'Vérification en cours...';
|
| | });
|
| |
|
| | try {
|
| | debugPrint('=== DIAGNOSTIC DE CONNEXION ===');
|
| |
|
| |
|
| | setState(() {
|
| | _healthStatus = 'Validation de l\'URL...';
|
| | });
|
| |
|
| | if (!TuberculosisApiService.validateApiUrl()) {
|
| | throw Exception(
|
| | 'URL de l\'API invalide: ${TuberculosisApiService.baseUrl}');
|
| | }
|
| |
|
| |
|
| | setState(() {
|
| | _healthStatus = 'Test de connectivité...';
|
| | });
|
| |
|
| | final isConnected = await TuberculosisApiService.testConnectivity();
|
| | if (!isConnected) {
|
| | throw Exception(
|
| | 'Impossible de contacter le serveur à l\'adresse: ${TuberculosisApiService.baseUrl}');
|
| | }
|
| |
|
| |
|
| | setState(() {
|
| | _healthStatus = 'Réveil du service cloud...';
|
| | });
|
| |
|
| | await TuberculosisApiService.wakeUpService();
|
| |
|
| |
|
| | setState(() {
|
| | _healthStatus = 'Vérification des services...';
|
| | });
|
| |
|
| | final healthData = await TuberculosisApiService.getHealthStatus();
|
| |
|
| | setState(() {
|
| | _isApiHealthy = true;
|
| | _errorMessage = null;
|
| | _healthStatus = 'Service connecté et fonctionnel ✓';
|
| | });
|
| |
|
| |
|
| | if (healthData.containsKey('status')) {
|
| | debugPrint('Statut du service: ${healthData['status']}');
|
| | }
|
| |
|
| |
|
| | 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();
|
| |
|
| |
|
| | 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';
|
| | }
|
| | });
|
| |
|
| |
|
| | 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'),
|
| | ),
|
| | ],
|
| | ),
|
| | ],
|
| | ],
|
| | ),
|
| | ),
|
| | );
|
| | }
|
| |
|
| |
|
| | 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<void> _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<void> _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<void> _analyzeImage() async {
|
| | if (_selectedImage == null) return;
|
| |
|
| | setState(() {
|
| | _isAnalyzing = true;
|
| | _errorMessage = null;
|
| | _analysisResult = null;
|
| | });
|
| |
|
| | try {
|
| |
|
| | 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<void> _shareResults() async {
|
| | if (_analysisResult == null) return;
|
| |
|
| | try {
|
| |
|
| | 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';
|
| | }
|
| | }
|
| |
|
| |
|
| | 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);
|
| |
|
| |
|
| | await Share.shareXFiles(
|
| | [XFile(imageFile.path)],
|
| | text: report,
|
| | subject: 'Rapport d\'analyse - Tuberculose',
|
| | );
|
| | } catch (e) {
|
| |
|
| | 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: [
|
| |
|
| | _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),
|
| |
|
| |
|
| | 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),
|
| |
|
| |
|
| | 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),
|
| | ],
|
| |
|
| |
|
| | 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),
|
| | ],
|
| |
|
| |
|
| | 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'),
|
| | ),
|
| | ],
|
| | ),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | class ImageSelectionHelper {
|
| | static final ImagePicker _picker = ImagePicker();
|
| |
|
| |
|
| | static Future<File?> pickImageFromGallery() async {
|
| | try {
|
| |
|
| | final hasPermission = await PermissionHelper.requestStoragePermission();
|
| | if (!hasPermission) {
|
| | throw Exception('Permission d\'accès au stockage refusée');
|
| | }
|
| |
|
| |
|
| | final XFile? pickedFile = await _picker.pickImage(
|
| | source: ImageSource.gallery,
|
| | maxWidth: 1920,
|
| | maxHeight: 1920,
|
| | imageQuality: 85,
|
| | );
|
| |
|
| | if (pickedFile == null) return null;
|
| |
|
| |
|
| | final file = File(pickedFile.path);
|
| | await _validateImageFile(file);
|
| |
|
| | return file;
|
| | } catch (e) {
|
| | throw Exception('Erreur lors de la sélection d\'image: $e');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<File?> takePhoto() async {
|
| | try {
|
| |
|
| | final hasPermission = await PermissionHelper.requestCameraPermission();
|
| | if (!hasPermission) {
|
| | throw Exception('Permission d\'accès à la caméra refusée');
|
| | }
|
| |
|
| |
|
| | final XFile? pickedFile = await _picker.pickImage(
|
| | source: ImageSource.camera,
|
| | maxWidth: 1920,
|
| | maxHeight: 1920,
|
| | imageQuality: 85,
|
| | preferredCameraDevice: CameraDevice.rear,
|
| | );
|
| |
|
| | if (pickedFile == null) return null;
|
| |
|
| |
|
| | final file = File(pickedFile.path);
|
| | await _validateImageFile(file);
|
| |
|
| | return file;
|
| | } catch (e) {
|
| | throw Exception('Erreur lors de la prise de photo: $e');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<File?> 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');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<void> _validateImageFile(File file) async {
|
| |
|
| | if (!await file.exists()) {
|
| | throw Exception('Le fichier sélectionné n\'existe pas');
|
| | }
|
| |
|
| |
|
| | final fileSize = await file.length();
|
| | if (!ImageValidator.isValidSize(fileSize)) {
|
| | throw Exception(
|
| | 'Fichier trop volumineux: ${ImageValidator.getFileSizeString(fileSize)}. '
|
| | 'Taille maximale: ${ImageValidator.getFileSizeString(ImageValidator.maxFileSize)}');
|
| | }
|
| |
|
| |
|
| | if (!ImageValidator.isValidExtension(file.path)) {
|
| | throw Exception('Format de fichier non supporté. '
|
| | 'Formats acceptés: ${ImageValidator.allowedExtensions.join(', ')}');
|
| | }
|
| |
|
| |
|
| | if (fileSize == 0) {
|
| | throw Exception('Le fichier image est vide');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<File> resizeImageIfNeeded(
|
| | File imageFile, {
|
| | int maxWidth = 1920,
|
| | int maxHeight = 1920,
|
| | int quality = 85,
|
| | }) async {
|
| | try {
|
| |
|
| |
|
| | return imageFile;
|
| | } catch (e) {
|
| | throw Exception('Erreur lors du redimensionnement: $e');
|
| | }
|
| | }
|
| |
|
| |
|
| | static Future<Map<String, dynamic>> 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');
|
| | }
|
| | }
|
| | }
|
| |
|
| |
|
| | 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),
|
| |
|
| |
|
| | _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),
|
| |
|
| |
|
| | 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),
|
| | ],
|
| |
|
| |
|
| | 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),
|
| | ],
|
| |
|
| |
|
| | Center(
|
| | child: ElevatedButton.icon(
|
| | onPressed: onShare,
|
| | icon: Icon(Icons.share),
|
| | label: Text('Partager les résultats'),
|
| | ),
|
| | ),
|
| |
|
| |
|
| | 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),
|
| | ),
|
| | ],
|
| | ),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | class ModelInfoWidget extends StatefulWidget {
|
| | @override
|
| | _ModelInfoWidgetState createState() => _ModelInfoWidgetState();
|
| | }
|
| |
|
| | class _ModelInfoWidgetState extends State<ModelInfoWidget> {
|
| | Map<String, dynamic>? _modelInfo;
|
| | bool _isLoading = false;
|
| | String? _errorMessage;
|
| |
|
| | @override
|
| | void initState() {
|
| | super.initState();
|
| | _loadModelInfo();
|
| | }
|
| |
|
| | Future<void> _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),
|
| | ),
|
| | ],
|
| | ),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | class SettingsPage extends StatefulWidget {
|
| | @override
|
| | _SettingsPageState createState() => _SettingsPageState();
|
| | }
|
| |
|
| | class _SettingsPageState extends State<SettingsPage> {
|
| | 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(),
|
| | ],
|
| | ),
|
| | );
|
| | }
|
| | }
|
| |
|
| |
|
| | void main() {
|
| | runApp(TuberculosisAnalyzerApp());
|
| | }
|
| |
|
| |
|
| | 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)}%';
|
| | }
|
| | }
|
| |
|
| |
|
| | class PermissionHelper {
|
| | static Future<bool> requestCameraPermission() async {
|
| |
|
| |
|
| | return true;
|
| | }
|
| |
|
| | static Future<bool> requestStoragePermission() async {
|
| |
|
| |
|
| | return true;
|
| | }
|
| | }
|
| |
|
| |
|
| | class ImageValidator {
|
| | static const int maxFileSize = 10 * 1024 * 1024;
|
| | static const List<String> 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';
|
| | }
|
| | }
|
| |
|