|
|
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;
|
|
|
|
|
|
|
|
|
class ApiConfig {
|
|
|
static const String _baseUrl = 'localhost:8000';
|
|
|
static const String _protocol = 'http';
|
|
|
|
|
|
|
|
|
static String get baseUrl {
|
|
|
if (kIsWeb) {
|
|
|
return '$_protocol://localhost:8000';
|
|
|
} else {
|
|
|
return _getMobileUrl();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static bool get isMobile {
|
|
|
if (kIsWeb) return false;
|
|
|
try {
|
|
|
return defaultTargetPlatform == TargetPlatform.android ||
|
|
|
defaultTargetPlatform == TargetPlatform.iOS;
|
|
|
} catch (e) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static bool get isDesktop {
|
|
|
if (kIsWeb) return false;
|
|
|
try {
|
|
|
return defaultTargetPlatform == TargetPlatform.windows ||
|
|
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
|
|
defaultTargetPlatform == TargetPlatform.linux;
|
|
|
} catch (e) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static const Duration connectionTimeout = Duration(seconds: 10);
|
|
|
static const Duration readTimeout = Duration(seconds: 60);
|
|
|
static const Duration writeTimeout = Duration(seconds: 30);
|
|
|
|
|
|
|
|
|
static const int maxFileSize = 10 * 1024 * 1024;
|
|
|
static const List<String> allowedExtensions = [
|
|
|
'.jpg',
|
|
|
'.jpeg',
|
|
|
'.png',
|
|
|
'.bmp'
|
|
|
];
|
|
|
static const List<String> allowedMimeTypes = [
|
|
|
'image/jpeg',
|
|
|
'image/png',
|
|
|
'image/bmp'
|
|
|
];
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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";
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
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(),
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
final mimeType = lookupMimeType(imageFile.name) ?? 'image/jpeg';
|
|
|
final mediaType = MediaType.parse(mimeType);
|
|
|
|
|
|
|
|
|
request.files.add(
|
|
|
http.MultipartFile.fromBytes(
|
|
|
'file',
|
|
|
imageFile.bytes,
|
|
|
filename: imageFile.name,
|
|
|
contentType: mediaType,
|
|
|
),
|
|
|
);
|
|
|
|
|
|
|
|
|
request.fields['platform'] = ApiConfig.platformName;
|
|
|
request.fields['timestamp'] = DateTime.now().toIso8601String();
|
|
|
|
|
|
|
|
|
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(),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
if (file.length == 0) {
|
|
|
throw Exception('Le fichier image est vide');
|
|
|
}
|
|
|
|
|
|
if (file.length > ApiConfig.maxFileSize) {
|
|
|
throw Exception('Image trop volumineuse (max 10MB)');
|
|
|
}
|
|
|
|
|
|
|
|
|
final extension = path.extension(file.name).toLowerCase();
|
|
|
if (!ApiConfig.allowedExtensions.contains(extension)) {
|
|
|
throw Exception(
|
|
|
'Format non supporté. Utilisez: ${ApiConfig.allowedExtensions.join(', ')}');
|
|
|
}
|
|
|
|
|
|
|
|
|
final mimeType = lookupMimeType(file.name);
|
|
|
if (mimeType == null || !ApiConfig.allowedMimeTypes.contains(mimeType)) {
|
|
|
throw Exception('Type MIME non supporté: $mimeType');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static Future<bool> saveImageToGallery(
|
|
|
Uint8List imageBytes, String fileName) async {
|
|
|
try {
|
|
|
if (kIsWeb) {
|
|
|
|
|
|
return await _handleWebDownload(imageBytes, fileName);
|
|
|
} else {
|
|
|
|
|
|
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 {
|
|
|
|
|
|
if (!await Gal.hasAccess()) {
|
|
|
final hasPermission = await Gal.requestAccess();
|
|
|
if (!hasPermission) {
|
|
|
throw Exception('Permission d\'accès à la galerie refusée');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
|
final tempPath = path.join(tempDir.path, fileName);
|
|
|
|
|
|
|
|
|
final tempFile = io.File(tempPath);
|
|
|
await tempFile.writeAsBytes(imageBytes);
|
|
|
|
|
|
|
|
|
if (!await tempFile.exists()) {
|
|
|
throw Exception('Impossible de créer le fichier temporaire');
|
|
|
}
|
|
|
|
|
|
|
|
|
await Gal.putImage(tempPath);
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
final shared = await _shareImageWeb(imageBytes, fileName);
|
|
|
if (shared) return true;
|
|
|
|
|
|
|
|
|
return await _downloadImageWeb(imageBytes, fileName);
|
|
|
}
|
|
|
return false;
|
|
|
} catch (e) {
|
|
|
print('Erreur téléchargement web: $e');
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static Future<bool> _shareImageWeb(
|
|
|
Uint8List imageBytes, String fileName) async {
|
|
|
if (!kIsWeb) return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
final xFile = XFile.fromData(
|
|
|
imageBytes,
|
|
|
name: fileName,
|
|
|
mimeType: _getMimeTypeFromFileName(fileName),
|
|
|
);
|
|
|
|
|
|
|
|
|
await Share.shareXFiles([xFile], text: 'Image analysée - TB Analyzer');
|
|
|
|
|
|
return true;
|
|
|
} catch (e) {
|
|
|
print('Erreur partage web: $e');
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static Future<bool> _downloadImageWeb(
|
|
|
Uint8List imageBytes, String fileName) async {
|
|
|
if (!kIsWeb) return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
static bool get isSaveSupported {
|
|
|
return !kIsWeb || (kIsWeb && _isWebSaveSupported);
|
|
|
}
|
|
|
|
|
|
static bool get _isWebSaveSupported {
|
|
|
|
|
|
return kIsWeb;
|
|
|
}
|
|
|
|
|
|
|
|
|
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()}';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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.';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
class WebImports {
|
|
|
static dynamic html;
|
|
|
|
|
|
static void initialize() {
|
|
|
if (kIsWeb) {
|
|
|
try {
|
|
|
|
|
|
html = _getHtmlLibrary();
|
|
|
} catch (e) {
|
|
|
print('Impossible d\'importer dart:html: $e');
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static dynamic _getHtmlLibrary() {
|
|
|
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
class MedicalTheme {
|
|
|
static const Color primaryColor =
|
|
|
Color(0xFF2E5C8A);
|
|
|
static const Color secondaryColor = Color(0xFF4A90B8);
|
|
|
static const Color accentColor =
|
|
|
Color(0xFF00A693);
|
|
|
static const Color warningColor =
|
|
|
Color(0xFFFF6B35);
|
|
|
static const Color errorColor = Color(0xFFE53E3E);
|
|
|
static const Color successColor = Color(0xFF38A169);
|
|
|
static const Color backgroundColor = Color(0xFFF7FAFC);
|
|
|
static const Color surfaceColor = Color(0xFFFFFFFF);
|
|
|
static const Color cardColor =
|
|
|
Color(0xFFFAFAFA);
|
|
|
|
|
|
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),
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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());
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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());
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
Future<void> _saveCombinedImages() async {
|
|
|
if (_analysisResult == null || _selectedImage == null) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
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,
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
void _refreshApp() {
|
|
|
setState(() {
|
|
|
_selectedImage = null;
|
|
|
_analysisResult = null;
|
|
|
_error = null;
|
|
|
_isAnalyzing = false;
|
|
|
});
|
|
|
_checkApiHealth();
|
|
|
}
|
|
|
|
|
|
|
|
|
Color _getConfidenceColor(double confidence) {
|
|
|
if (confidence >= 0.8) return MedicalTheme.successColor;
|
|
|
if (confidence >= 0.6) return MedicalTheme.warningColor;
|
|
|
return MedicalTheme.errorColor;
|
|
|
}
|
|
|
|
|
|
|
|
|
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: [
|
|
|
|
|
|
_buildApiStatusCard(),
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
_buildImageSelectionCard(),
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
if (_selectedImage != null) _buildSelectedImageCard(),
|
|
|
if (_selectedImage != null) const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
if (_selectedImage != null && _isApiHealthy) _buildAnalyzeButton(),
|
|
|
if (_selectedImage != null && _isApiHealthy)
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
if (_isAnalyzing) _buildLoadingIndicator(),
|
|
|
if (_isAnalyzing) const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
if (_analysisResult != null) _buildResultsCard(),
|
|
|
|
|
|
|
|
|
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: [
|
|
|
|
|
|
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),
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
|
|
|
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),
|
|
|
],
|
|
|
|
|
|
|
|
|
RepaintBoundary(
|
|
|
key: _combinedImagesKey,
|
|
|
child: _buildComparisonImages(),
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
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'),
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
|
|
|
|
|
|
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: [
|
|
|
|
|
|
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),
|
|
|
|
|
|
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,
|
|
|
),
|
|
|
),
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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,
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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: [
|
|
|
|
|
|
_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),
|
|
|
|
|
|
|
|
|
_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),
|
|
|
|
|
|
|
|
|
_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),
|
|
|
|
|
|
|
|
|
_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),
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
const SnackBar(
|
|
|
content: Text('Fonctionnalité à venir'),
|
|
|
backgroundColor: MedicalTheme.primaryColor,
|
|
|
),
|
|
|
);
|
|
|
},
|
|
|
child: const Text('Contacter'),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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',
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
WebImports.initialize();
|
|
|
|
|
|
runApp(const TuberculosisAnalyzerApp());
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
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.';
|
|
|
|
|
|
|
|
|
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.';
|
|
|
|
|
|
|
|
|
static const double defaultImageQuality = 85.0;
|
|
|
static const int maxRetryAttempts = 3;
|
|
|
static const Duration defaultTimeout = Duration(seconds: 30);
|
|
|
}
|
|
|
|
|
|
|
|
|
class AppState {
|
|
|
static final AppState _instance = AppState._internal();
|
|
|
factory AppState() => _instance;
|
|
|
AppState._internal();
|
|
|
|
|
|
|
|
|
bool _isApiHealthy = false;
|
|
|
String _currentLanguage = 'fr';
|
|
|
bool _notificationsEnabled = true;
|
|
|
|
|
|
|
|
|
bool get isApiHealthy => _isApiHealthy;
|
|
|
String get currentLanguage => _currentLanguage;
|
|
|
bool get notificationsEnabled => _notificationsEnabled;
|
|
|
|
|
|
|
|
|
set isApiHealthy(bool value) {
|
|
|
_isApiHealthy = value;
|
|
|
_notifyListeners();
|
|
|
}
|
|
|
|
|
|
set currentLanguage(String value) {
|
|
|
_currentLanguage = value;
|
|
|
_notifyListeners();
|
|
|
}
|
|
|
|
|
|
set notificationsEnabled(bool value) {
|
|
|
_notificationsEnabled = value;
|
|
|
_notifyListeners();
|
|
|
}
|
|
|
|
|
|
|
|
|
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();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
void reset() {
|
|
|
_isApiHealthy = false;
|
|
|
_currentLanguage = 'fr';
|
|
|
_notificationsEnabled = true;
|
|
|
_notifyListeners();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
extension ColorExtension on Color {
|
|
|
|
|
|
Color get lighter {
|
|
|
return Color.lerp(this, Colors.white, 0.3) ?? this;
|
|
|
}
|
|
|
|
|
|
|
|
|
Color get darker {
|
|
|
return Color.lerp(this, Colors.black, 0.3) ?? this;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
extension StringExtension on String {
|
|
|
|
|
|
String get capitalize {
|
|
|
if (isEmpty) return this;
|
|
|
return this[0].toUpperCase() + substring(1);
|
|
|
}
|
|
|
|
|
|
|
|
|
bool get isValidEmail {
|
|
|
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
extension DateTimeExtension on DateTime {
|
|
|
|
|
|
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')}';
|
|
|
}
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|