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