|
|
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';
|
|
|
}
|
|
|
}
|
|
|
|