import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:image/image.dart' as img; import 'package:crypto/crypto.dart'; import '../data/models/shot.dart'; import '../data/models/target_type.dart'; import 'wallet_identity_service.dart'; /// Résultat détaillé de l'exportation vers le serveur IA class AiExportResult { final bool isSuccess; final String code; final String message; final String? reason; final bool isBanned; final Map? targetValidation; AiExportResult({ required this.isSuccess, required this.code, required this.message, this.reason, this.isBanned = false, this.targetValidation, }); factory AiExportResult.success({ String? message, Map? targetValidation, }) { return AiExportResult( isSuccess: true, code: 'UPLOAD_SUCCESS', message: message ?? 'Export réussi vers le serveur IA !', targetValidation: targetValidation, ); } factory AiExportResult.banned({ String? reason, String? message, }) { return AiExportResult( isSuccess: false, code: 'WALLET_BANNED', isBanned: true, reason: reason, message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.', ); } factory AiExportResult.error({ String? code, required String message, }) { return AiExportResult( isSuccess: false, code: code ?? 'UPLOAD_ERROR', message: message, ); } } class AiExportService { /// Retire toutes les metadonnees de la photo avant l'envoi. /// /// Une photo de cible prise au telephone embarque un bloc EXIF qui contient /// typiquement la position GPS du stand de tir, la date exacte et le modele /// d'appareil. Rien de tout cela n'est utile au modele de detection. /// /// L'orientation est d'abord appliquee physiquement aux pixels : les /// coordonnees d'impact sont normalisees sur l'image telle qu'elle est /// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer /// le tag sans redresser l'image ferait pivoter la photo par rapport a ses /// propres annotations. /// /// Retourne null si l'image est illisible. @visibleForTesting Uint8List? stripMetadata(Uint8List originalBytes) { try { final decoded = img.decodeImage(originalBytes); if (decoded == null) return null; final baked = img.bakeOrientation(decoded); // bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset, // le GPS survivrait au reencodage. baked.exif = img.ExifData(); return img.encodeJpg(baked, quality: 90); } catch (e) { // Sur un fichier tronque, decodeImage leve au lieu de retourner null. debugPrint('Photo illisible, export annule: $e'); return null; } } /// Exporte l'image et les données de plotting vers le serveur Future exportData({ required String imagePath, required String sessionId, required TargetType targetType, required double targetCenterX, required double targetCenterY, required double targetRadius, required List shots, int distanceMeters = 25, String caliber = 'unknown', int? expectedShots, String? apiUrl, }) async { try { final walletService = WalletIdentityService(); final rawBaseUrl = await walletService.getServerBaseUrl(); final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl; final effectiveUrl = apiUrl ?? '$baseUrl/api/upload'; final url = Uri.parse(effectiveUrl); final request = http.MultipartRequest('POST', url); request.headers['X-API-KEY'] = WalletIdentityService.apiKey; // 1. Prepare image final file = File(imagePath); if (!await file.exists()) { return AiExportResult.error( code: 'FILE_NOT_FOUND', message: 'Le fichier image cible est introuvable.', ); } final sanitizedPhoto = stripMetadata(await file.readAsBytes()); if (sanitizedPhoto == null) { return AiExportResult.error( code: 'INVALID_IMAGE', message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.', ); } final corners = [ {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius}, {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius}, ]; // Format the impacts final formattedImpacts = shots.asMap().entries.map((entry) { final index = entry.key; final shot = entry.value; return { "id": index + 1, "label": "bullet_hole", "score": shot.score, "coords": { "norm_x": shot.x, "norm_y": shot.y } }; }).toList(); // Get and hash the wallet identity final phrase = await walletService.getIdentityPhrase(); final phraseBytes = utf8.encode(phrase); final walletHash = sha256.convert(phraseBytes).toString(); // Build JSON payload final plottingJson = { "session_id": sessionId, "wallet_hash": walletHash, "timestamp": DateTime.now().toIso8601String(), "target_metadata": { "type": targetType.name, "distance_meters": distanceMeters, "caliber": caliber, // Nombre de coups prevus pour cette cible, null hors session. // Un ecart avec le nombre d'impacts ne signifie pas que le // marquage est faux (un coup peut etre parti hors papier) : c'est // au tri du dataset d'en decider, pas au client. "expected_shots": expectedShots, }, "plotting": { "target_corners": corners, "impacts": formattedImpacts } }; request.fields['plotting'] = jsonEncode(plottingJson); request.files.add( http.MultipartFile.fromBytes( 'photo', sanitizedPhoto, filename: 'target.jpg', ), ); final streamedResponse = await request.send().timeout( const Duration(seconds: 15), onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'), ); final responseBody = await streamedResponse.stream.bytesToString(); Map responseJson = {}; try { responseJson = jsonDecode(responseBody); } catch (_) {} final statusCode = streamedResponse.statusCode; if (statusCode == 200) { debugPrint('Export réussi: $responseBody'); return AiExportResult.success( message: responseJson['message'] ?? 'Photo et données exportées avec succès.', targetValidation: responseJson['target_validation'] as Map?, ); } else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') { final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution'; debugPrint('Export rejeté (banni): $reason'); // Persister le bannissement localement et couper l'envoi de photos await walletService.setBanned(true, reason: reason); return AiExportResult.banned( reason: reason, message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.', ); } else if (statusCode == 400) { return AiExportResult.error( code: responseJson['code'] ?? 'BAD_REQUEST', message: responseJson['error'] ?? 'Requête d\'export invalide.', ); } else { return AiExportResult.error( code: responseJson['code'] ?? 'SERVER_ERROR', message: responseJson['error'] ?? 'Erreur serveur ($statusCode).', ); } } on SocketException { return AiExportResult.error( code: 'NETWORK_ERROR', message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.', ); } catch (e) { debugPrint('Exception lors de l\'export: $e'); return AiExportResult.error( code: 'UNKNOWN_ERROR', message: 'Erreur lors de l\'export: $e', ); } } }