- Backend IA : - Intégration d'OpenCV (WebAssembly) pour la détection et certification des cibles (anneaux concentriques, confiance). - Ajout du système de journalisation complet des uploads (SQLite, IP, appareil, statut, métadonnées). - Implémentation du système de modération (bannissement/débannissement de wallets, rejet 403 avec motifs). - Harmonisation des codes d'erreur et messages JSON (UPLOAD_SUCCESS, WALLET_BANNED, etc.). - Dashboard Next.js : page dédiée aux logs avec filtres, export CSV et actions de modération en un clic. - Application Mobile (Flutter) : - Encapsulation des réponses d'export dans AiExportResult avec gestion fine des erreurs et statuts. - Mise à jour du disclaimer de participation à l'entraînement IA avec avertissements stricts de bannissement. - Révocation de l'envoi de photos et affichage d'un bandeau explicatif en cas de suspension. - Ajout d'un bouton manuel « Actualiser mon statut » dans les paramètres pour synchroniser l'état du compte. - Possibilité de configurer l'URL/IP du serveur IA directement depuis les paramètres.
226 lines
7.4 KiB
Dart
226 lines
7.4 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'dart:io';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
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<String, dynamic>? targetValidation;
|
|
|
|
AiExportResult({
|
|
required this.isSuccess,
|
|
required this.code,
|
|
required this.message,
|
|
this.reason,
|
|
this.isBanned = false,
|
|
this.targetValidation,
|
|
});
|
|
|
|
factory AiExportResult.success({
|
|
String? message,
|
|
Map<String, dynamic>? 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 {
|
|
/// Extrait les informations de l'appareil
|
|
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
|
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
|
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'};
|
|
|
|
try {
|
|
if (Platform.isAndroid) {
|
|
final androidInfo = await deviceInfoPlugin.androidInfo;
|
|
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
|
|
deviceData['os'] = 'Android ${androidInfo.version.release}';
|
|
} else if (Platform.isIOS) {
|
|
final iosInfo = await deviceInfoPlugin.iosInfo;
|
|
deviceData['model'] = iosInfo.name;
|
|
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
|
|
} else if (Platform.isWindows) {
|
|
final windowsInfo = await deviceInfoPlugin.windowsInfo;
|
|
deviceData['model'] = 'Windows PC';
|
|
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur lors de la récupération des infos appareil: $e');
|
|
}
|
|
|
|
return deviceData;
|
|
}
|
|
|
|
/// Exporte l'image et les données de plotting vers le serveur
|
|
Future<AiExportResult> exportData({
|
|
required String imagePath,
|
|
required String sessionId,
|
|
required TargetType targetType,
|
|
required double targetCenterX,
|
|
required double targetCenterY,
|
|
required double targetRadius,
|
|
required List<Shot> shots,
|
|
int distanceMeters = 25,
|
|
String weaponName = 'Unknown',
|
|
String? apiUrl,
|
|
}) async {
|
|
try {
|
|
final walletService = WalletIdentityService();
|
|
final baseUrl = await walletService.getServerBaseUrl();
|
|
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
|
|
final url = Uri.parse(effectiveUrl);
|
|
final request = http.MultipartRequest('POST', url);
|
|
|
|
// 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 deviceData = await _getDeviceInfo();
|
|
|
|
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(),
|
|
"device_info": deviceData,
|
|
"target_metadata": {
|
|
"type": targetType.name,
|
|
"distance_meters": distanceMeters,
|
|
"weapon": weaponName,
|
|
},
|
|
"plotting": {
|
|
"target_corners": corners,
|
|
"impacts": formattedImpacts
|
|
}
|
|
};
|
|
|
|
request.fields['plotting'] = jsonEncode(plottingJson);
|
|
request.files.add(
|
|
await http.MultipartFile.fromPath('photo', imagePath),
|
|
);
|
|
|
|
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<String, dynamic> 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<String, dynamic>?,
|
|
);
|
|
} 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',
|
|
);
|
|
}
|
|
}
|
|
}
|