Compare commits

..
7 changed files with 240 additions and 59 deletions
+6 -3
View File
@@ -239,12 +239,15 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Exporte l'image et le json vers le backend IA. /// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de /// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées. /// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
///
/// Le nom de l'arme n'est volontairement pas transmis : il est souvent
/// personnalisé par l'utilisateur et n'apporte rien au modèle de détection.
Future<AiExportResult> exportToAiBackend({ Future<AiExportResult> exportToAiBackend({
String? sessionId, String? sessionId,
int? distance, int? distance,
String? weapon, String? caliber,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant."; _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
@@ -269,7 +272,7 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25, distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown', caliber: caliber ?? 'unknown',
); );
_state = AnalysisState.success; _state = AnalysisState.success;
+2 -2
View File
@@ -784,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// qu'elle ressorte sur le bouton. // qu'elle ressorte sur le bouton.
color: Colors.white, color: Colors.white,
), ),
label: 'TERMINER TOUT ET EXPORTER', label: 'TERMINER ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor, color: AppTheme.warningColor,
onPressed: () => onPressed: () =>
_finishSession(context, provider, export: true), _finishSession(context, provider, export: true),
@@ -942,7 +942,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
exportResult = await provider.exportToAiBackend( exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId, sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance, distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon, caliber: sessionProvider.currentWeaponCaliber,
); );
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
} }
+9 -1
View File
@@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier {
DateTime? get sessionDate => _sessionDate; DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName; String? _currentWeaponName;
String? _currentWeaponId; String? _currentWeaponId;
String? _currentWeaponCaliber;
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
int _distance = 25; int _distance = 25;
String? _activeSessionId; String? _activeSessionId;
@@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier {
String? get currentWeapon => _currentWeaponName; String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId; String? get currentWeaponId => _currentWeaponId;
/// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne
/// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au
/// backend d'entrainement (le diametre des trous en depend).
String? get currentWeaponCaliber => _currentWeaponCaliber;
int get shotsPerTarget => _shotsPerTarget; int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance; int get distance => _distance;
bool get isSessionActive => _isSessionActive; bool get isSessionActive => _isSessionActive;
@@ -23,9 +29,10 @@ class SessionProvider extends ChangeNotifier {
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length; int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) { void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName; _currentWeaponName = weaponName;
_currentWeaponId = weaponId; _currentWeaponId = weaponId;
_currentWeaponCaliber = caliber;
_shotsPerTarget = shots; _shotsPerTarget = shots;
_distance = distance; _distance = distance;
_sessionDate = date ?? DateTime.now(); _sessionDate = date ?? DateTime.now();
@@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier {
_activeSessionId = null; _activeSessionId = null;
_currentWeaponId = null; _currentWeaponId = null;
_currentWeaponName = null; _currentWeaponName = null;
_currentWeaponCaliber = null;
_currentAnalyses.clear(); _currentAnalyses.clear();
_distance = 25; _distance = 25;
notifyListeners(); notifyListeners();
+102 -24
View File
@@ -27,8 +27,14 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
/// Valeur sentinelle de l'item "Ajouter une nouvelle arme" du menu deroulant.
static const String _addWeaponValue = '__add_weapon__';
final GlobalKey<FormFieldState<String>> _weaponFieldKey =
GlobalKey<FormFieldState<String>>();
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200]; static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30]; static const List<int> _presetShots = [3, 5, 10, 15, 20, 30, 50];
@override @override
void initState() { void initState() {
@@ -44,8 +50,21 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_availableWeapons = weapons; _availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first; // Les armes rechargees sont de nouvelles instances (Weapon n'a pas
// d'operator ==) et l'arme selectionnee a pu etre supprimee : on
// re-resout la selection par identifiant.
final previousId = _selectedWeapon?.id;
_selectedWeapon = null;
for (final weapon in weapons) {
if (weapon.id == previousId) {
_selectedWeapon = weapon;
break;
}
}
if (_selectedWeapon == null && weapons.isNotEmpty) {
_selectedWeapon = weapons.first;
_updateSettingsForWeapon(_selectedWeapon!); _updateSettingsForWeapon(_selectedWeapon!);
} }
_isLoadingWeapons = false; _isLoadingWeapons = false;
@@ -53,6 +72,35 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
} }
Future<void> _openArmory() async {
final knownIds = _availableWeapons.map((w) => w.id).toSet();
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
if (!mounted) return;
await _loadWeapons();
if (!mounted) return;
// Selectionne automatiquement l'arme qui vient d'etre ajoutee.
Weapon? added;
for (final weapon in _availableWeapons) {
if (!knownIds.contains(weapon.id)) {
added = weapon;
break;
}
}
if (added != null) {
final newWeapon = added;
setState(() {
_selectedWeapon = newWeapon;
_updateSettingsForWeapon(newWeapon);
});
}
}
void _updateSettingsForWeapon(Weapon weapon) { void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5; _shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50; _distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
@@ -92,6 +140,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
_shotsPerTarget, _shotsPerTarget,
sessionId, sessionId,
weaponId: _selectedWeapon!.id, weaponId: _selectedWeapon!.id,
caliber: _selectedWeapon!.caliber,
distance: _distance, distance: _distance,
date: _selectedDate, date: _selectedDate,
); );
@@ -177,12 +226,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: _openArmory,
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) => _loadWeapons());
},
icon: const Icon(Icons.shield), icon: const Icon(Icons.shield),
label: const Text('Aller à l\'armurerie'), label: const Text('Aller à l\'armurerie'),
), ),
@@ -213,26 +257,60 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<Weapon>( DropdownButtonFormField<String>(
initialValue: _selectedWeapon, key: _weaponFieldKey,
initialValue: _selectedWeapon?.id,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Arme utilisée', labelText: 'Arme utilisée',
prefixIcon: Icon(Icons.shield_outlined), prefixIcon: Icon(Icons.shield_outlined),
), ),
items: _availableWeapons items: [
.map( ..._availableWeapons.map(
(w) => DropdownMenuItem( (w) => DropdownMenuItem(
value: w, value: w.id,
child: Text('${w.displayName} (${w.caliber})'), child: Text('${w.displayName} (${w.caliber})'),
), ),
) ),
.toList(), DropdownMenuItem(
value: _addWeaponValue,
child: Row(
children: [
Icon(
Icons.add_circle_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Ajouter une nouvelle arme',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value == null) return;
setState(() {
_selectedWeapon = value; if (value == _addWeaponValue) {
_updateSettingsForWeapon(value); // L'item d'action n'est pas une arme : on restaure aussitot
}); // la selection precedente avant d'ouvrir l'armurerie.
_weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
_openArmory();
return;
}
for (final weapon in _availableWeapons) {
if (weapon.id == value) {
setState(() {
_selectedWeapon = weapon;
_updateSettingsForWeapon(weapon);
});
break;
}
} }
}, },
), ),
+16 -1
View File
@@ -462,7 +462,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Text( const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.', '📤 Ce qui est envoyé : la photo de la cible, la position des '
'impacts que vous avez placés, la distance de tir et le calibre.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🚫 Ce qui ne l\'est pas : la géolocalisation de la photo (retirée '
'avant l\'envoi), le nom de votre arme, et le modèle de votre appareil.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🔒 Pseudonymat : votre identité est remplacée par un hash '
'cryptographique. Il reste le même d\'un envoi à l\'autre, afin de '
'rattacher vos contributions à votre compte (récompenses, '
'modération, suppression sur demande).',
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
+44 -28
View File
@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:image/image.dart' as img;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import '../data/models/shot.dart'; import '../data/models/shot.dart';
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
@@ -64,30 +64,37 @@ class AiExportResult {
} }
class AiExportService { class AiExportService {
/// Extrait les informations de l'appareil /// Retire toutes les metadonnees de la photo avant l'envoi.
Future<Map<String, dynamic>> _getDeviceInfo() async { ///
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); /// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'}; /// 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 { try {
if (Platform.isAndroid) { final decoded = img.decodeImage(originalBytes);
final androidInfo = await deviceInfoPlugin.androidInfo; if (decoded == null) return null;
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; 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 /// Exporte l'image et les données de plotting vers le serveur
@@ -100,7 +107,7 @@ class AiExportService {
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25, int distanceMeters = 25,
String weaponName = 'Unknown', String caliber = 'unknown',
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
@@ -121,7 +128,13 @@ class AiExportService {
); );
} }
final deviceData = await _getDeviceInfo(); 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 = [ final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -155,11 +168,10 @@ class AiExportService {
"session_id": sessionId, "session_id": sessionId,
"wallet_hash": walletHash, "wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(), "timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": distanceMeters, "distance_meters": distanceMeters,
"weapon": weaponName, "caliber": caliber,
}, },
"plotting": { "plotting": {
"target_corners": corners, "target_corners": corners,
@@ -169,7 +181,11 @@ class AiExportService {
request.fields['plotting'] = jsonEncode(plottingJson); request.fields['plotting'] = jsonEncode(plottingJson);
request.files.add( request.files.add(
await http.MultipartFile.fromPath('photo', imagePath), http.MultipartFile.fromBytes(
'photo',
sanitizedPhoto,
filename: 'target.jpg',
),
); );
final streamedResponse = await request.send().timeout( final streamedResponse = await request.send().timeout(
+61
View File
@@ -0,0 +1,61 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:bully/services/ai_export_service.dart';
/// Construit un JPEG porteur de métadonnées EXIF comme le ferait un téléphone :
/// position GPS, modèle d'appareil et orientation.
Uint8List _photoWithExif({int orientation = 1}) {
final image = img.Image(width: 40, height: 20);
img.fill(image, color: img.ColorRgb8(120, 120, 120));
image.exif.imageIfd['Model'] = 'Pixel 8';
image.exif.imageIfd.orientation = orientation;
image.exif.gpsIfd['GPSLatitude'] = 48.8584;
image.exif.gpsIfd['GPSLongitude'] = 2.2945;
return img.encodeJpg(image);
}
void main() {
final service = AiExportService();
group('stripMetadata', () {
test('supprime le GPS et les autres métadonnées EXIF', () {
final original = _photoWithExif();
// Garde-fou : sans EXIF au départ, le test ne prouverait rien.
expect(img.decodeImage(original)!.exif.isEmpty, isFalse);
final stripped = service.stripMetadata(original);
final result = img.decodeImage(stripped!)!;
expect(result.exif.isEmpty, isTrue);
expect(result.exif.gpsIfd.isEmpty, isTrue);
});
test('applique l\'orientation aux pixels avant de retirer le tag', () {
// Orientation 6 = rotation de 90°, donc les dimensions s'inversent.
final original = _photoWithExif(orientation: 6);
final result = img.decodeImage(service.stripMetadata(original)!)!;
expect(result.width, 20);
expect(result.height, 40);
expect(result.exif.isEmpty, isTrue);
});
test('conserve les dimensions quand il n\'y a pas d\'orientation', () {
final result = img.decodeImage(service.stripMetadata(_photoWithExif())!)!;
expect(result.width, 40);
expect(result.height, 20);
});
test('retourne null sur une image illisible', () {
expect(service.stripMetadata(Uint8List.fromList([1, 2, 3, 4])), isNull);
});
});
}