feat(ai-training): ajout validation OpenCV, gestion des logs et modération des contributions

- 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.
This commit is contained in:
streaper2
2026-08-25 12:16:29 +02:00
parent 0374a7611e
commit 44ac4462a6
14 changed files with 2869 additions and 141 deletions
+10 -7
View File
@@ -241,15 +241,18 @@ class AnalysisProvider extends ChangeNotifier {
/// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<bool> exportToAiBackend({
Future<AiExportResult> exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
return false;
return AiExportResult.error(
code: 'MISSING_DATA',
message: "Impossible d'exporter : image ou type de cible manquant.",
);
}
final service = AiExportService();
@@ -257,7 +260,7 @@ class AnalysisProvider extends ChangeNotifier {
_state = AnalysisState.loading;
notifyListeners();
final success = await service.exportData(
final result = await service.exportData(
imagePath: _imagePath!,
sessionId: sessionId ?? 'export',
targetType: _targetType!,
@@ -270,11 +273,11 @@ class AnalysisProvider extends ChangeNotifier {
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
if (!result.isSuccess) {
_errorMessage = result.message;
}
notifyListeners();
return success;
return result;
}
/// Save the session
+65 -15
View File
@@ -19,6 +19,7 @@ import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../../services/wallet_identity_service.dart';
import '../../services/ai_export_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
@@ -933,12 +934,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
date: sessionProvider.sessionDate,
);
bool? exportSucceeded;
AiExportResult? exportResult;
if (export) {
messenger.showSnackBar(
const SnackBar(content: Text('Exportation en cours...')),
const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
);
exportSucceeded = await provider.exportToAiBackend(
exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon,
@@ -954,19 +955,68 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
openMainTab(mainTabStats);
}
if (exportSucceeded != null) {
messenger.showSnackBar(
SnackBar(
content: Text(
exportSucceeded
? 'Export réussi vers le backend IA !'
: (provider.errorMessage ?? 'Erreur d\'export'),
if (exportResult != null) {
if (exportResult.isBanned) {
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.block, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text(
'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 6),
),
backgroundColor: exportSucceeded
? AppTheme.successColor
: AppTheme.errorColor,
),
);
);
} else if (exportResult.isSuccess) {
final targetStatus = exportResult.targetValidation?['status'];
final isCertified = targetStatus == 'VALID';
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
isCertified ? Icons.verified : Icons.cloud_done,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isCertified
? 'Export réussi ! Cible certifiée par l\'IA.'
: exportResult.message,
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text('Échec de l\'export : ${exportResult.message}'),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 4),
),
);
}
}
} catch (e) {
messenger.showSnackBar(
+350 -70
View File
@@ -22,7 +22,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
bool _isCheckingStatus = false;
bool _isUploadEnabled = false;
bool _isBanned = false;
String? _banReason;
String _serverUrl = 'http://localhost:3000';
@override
void initState() {
@@ -32,11 +36,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
final serverUrl = await _walletService.getServerBaseUrl();
final isBanned = await _walletService.isBanned();
final banReason = await _walletService.getBanReason();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
_isUploadEnabled = isEnabled;
_serverUrl = serverUrl;
_isBanned = isBanned;
_banReason = banReason;
_isUploadEnabled = isEnabled && !isBanned;
});
_fetchStats(phrase);
}
@@ -47,19 +57,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
final baseUrl = Theme.of(context).platform == TargetPlatform.android
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final baseUrl = await _walletService.getServerBaseUrl();
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
const Duration(seconds: 4),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
_serverUrl = baseUrl;
_isLoadingStats = false;
});
}
@@ -72,6 +81,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// Action manuelle de l'utilisateur pour vérifier et synchroniser son statut auprès du serveur
Future<void> _refreshAccountStatus() async {
setState(() => _isCheckingStatus = true);
try {
final isBanned = await _walletService.syncBanStatus();
final banReason = await _walletService.getBanReason();
final isEnabled = await _walletService.isUploadEnabled();
if (_identityPhrase != null) {
await _fetchStats(_identityPhrase!);
}
if (!mounted) return;
setState(() {
_isBanned = isBanned;
_banReason = banReason;
_isUploadEnabled = isEnabled && !isBanned;
_isCheckingStatus = false;
});
if (isBanned) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.block, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text('Compte toujours suspendu : ${_banReason ?? "Non-respect des règles"}'),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 10),
Expanded(
child: Text('Statut actualisé : votre compte est actif et autorisé !'),
),
],
),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 4),
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isCheckingStatus = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible de joindre le serveur : $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
@@ -182,8 +257,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showOptInDisclaimer(bool value) {
if (_isBanned) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.block, color: AppTheme.errorColor),
SizedBox(width: 10),
Expanded(child: Text('Programme IA Suspendu')),
],
),
content: Text(
'Votre participation au programme d\'entraînement a été suspendue par la modération pour le motif suivant :\n\n'
'« ${_banReason ?? "Envois non conformes ou inappropriés"} »\n\n'
'L\'envoi de photos pour l\'entraînement est définitivement désactivé sur cet appareil.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Compris'),
),
],
),
);
return;
}
if (!value) {
// Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
@@ -196,27 +297,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
style: TextStyle(fontSize: 13),
),
SizedBox(height: 12),
Text(
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
style: TextStyle(fontSize: 13),
),
],
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
),
const SizedBox(height: 16),
const Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles au serveur d\'entraînement IA.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 14),
const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🎁 Récompenses : Vos contributions sont enregistrées pour vous donner accès à des fonctionnalités exclusives.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.4)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 20),
SizedBox(width: 8),
Text(
'Règles strictes & Bannissement',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
SizedBox(height: 6),
Text(
'Vous vous engagez à n\'envoyer que de réelles cibles de tir conformes. '
'Tout envoi de photos non conformes, fausses cibles, images floues ou contenu inapproprié '
'entraînera le bannissement immédiat et définitif de votre compte. '
'L\'application perdra définitivement la possibilité d\'envoyer des photos.',
style: TextStyle(fontSize: 12, height: 1.4),
),
],
),
),
],
),
),
actions: [
TextButton(
@@ -238,7 +380,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
},
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
),
],
),
);
}
void _showEditServerUrlDialog() {
final urlController = TextEditingController(text: _serverUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Adresse du Serveur IA'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
TextField(
controller: urlController,
decoration: const InputDecoration(
hintText: 'Ex: http://192.168.1.50:3000',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
const Text(
'💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
onPressed: () async {
final newUrl = urlController.text.trim();
if (newUrl.isNotEmpty) {
await _walletService.setServerBaseUrl(newUrl);
setState(() {
_serverUrl = newUrl;
});
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
}
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Adresse du serveur IA mise à jour'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
),
],
),
@@ -363,51 +568,126 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 24),
_buildSectionHeader('Configuration Serveur IA'),
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeThumbColor: AppTheme.primaryColor,
_buildSectionHeader('Programme d\'Entraînement IA'),
if (_isBanned) ...[
Card(
elevation: 0,
color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.block, color: AppTheme.errorColor, size: 22),
SizedBox(width: 10),
Expanded(
child: Text(
'Participation au programme IA suspendue',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Motif : ${_banReason ?? "Envois d'images non appropriées ou fausses cibles."}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
const Text(
'Suite à des envois non conformes, la possibilité de participer au programme d\'entraînement et d\'envoyer des photos a été révoquée pour cet identifiant.',
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.3),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: _isCheckingStatus
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.refresh, size: 18),
label: Text(
_isCheckingStatus ? 'Vérification...' : 'ACTUALISER MON STATUT',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
),
),
],
),
),
onChanged: _showOptInDisclaimer,
),
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: 'http://localhost:3000/api/upload',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Changement d\'adresse à venir')),
);
},
] else ...[
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onChanged: _showOptInDisclaimer,
),
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.data_usage,
title: 'Photos Exportées',
subtitle: _isLoadingStats
? 'Chargement...'
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
onTap: () {
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
},
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: _serverUrl,
onTap: _showEditServerUrlDialog,
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.sync,
title: 'Actualiser mon statut',
subtitle: _isCheckingStatus ? 'Vérification en cours...' : 'Vérifier l\'état du compte auprès du serveur',
onTap: _isCheckingStatus ? () {} : _refreshAccountStatus,
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.data_usage,
title: 'Photos Exportées',
subtitle: _isLoadingStats
? 'Chargement...'
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
onTap: () {
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
},
),
],
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
+108 -32
View File
@@ -8,16 +8,62 @@ import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
class AiExportService {
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
static String get _defaultApiUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000/api/upload';
}
return 'http://localhost:3000/api/upload';
/// 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();
@@ -45,7 +91,7 @@ class AiExportService {
}
/// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({
Future<AiExportResult> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
@@ -58,23 +104,23 @@ class AiExportService {
String? apiUrl,
}) async {
try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
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()) {
throw Exception('Le fichier image n\'existe pas');
return AiExportResult.error(
code: 'FILE_NOT_FOUND',
message: 'Le fichier image cible est introuvable.',
);
}
// Read image metadata (approximate dimensions since decoding image can be heavy)
// On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -98,7 +144,6 @@ class AiExportService {
}).toList();
// Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
@@ -113,7 +158,6 @@ class AiExportService {
"type": targetType.name,
"distance_meters": distanceMeters,
"weapon": weaponName,
// The backend could extract exact width/height from the image.
},
"plotting": {
"target_corners": corners,
@@ -121,29 +165,61 @@ class AiExportService {
}
};
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
// Send request
final response = await request.send();
final streamedResponse = await request.send().timeout(
const Duration(seconds: 15),
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
);
if (response.statusCode == 200) {
final responseData = await response.stream.bytesToString();
debugPrint('Export réussi: $responseData');
return true;
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 {
final errorData = await response.stream.bytesToString();
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
return false;
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 false;
return AiExportResult.error(
code: 'UNKNOWN_ERROR',
message: 'Erreur lors de l\'export: $e',
);
}
}
}
+83 -1
View File
@@ -1,6 +1,7 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
static const String _bannedKey = 'wallet_is_banned';
static const String _banReasonKey = 'wallet_ban_reason';
static const String _serverUrlKey = 'ai_server_url';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
@@ -40,18 +44,96 @@ class WalletIdentityService {
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
/// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000)
Future<String> getServerBaseUrl() async {
final prefs = await SharedPreferences.getInstance();
final customUrl = prefs.getString(_serverUrlKey);
if (customUrl != null && customUrl.trim().isNotEmpty) {
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000';
}
return 'http://localhost:3000';
}
/// Définit une URL personnalisée pour le serveur IA
Future<void> setServerBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
await prefs.setString(_serverUrlKey, cleanUrl);
}
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) return false;
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) {
await prefs.setBool(_uploadEnabledKey, false);
return;
}
await prefs.setBool(_uploadEnabledKey, enabled);
}
/// Vérifie si ce wallet/utilisateur est banni en local
Future<bool> isBanned() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_bannedKey) ?? false;
}
/// Récupère le motif de bannissement enregistré
Future<String?> getBanReason() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_banReasonKey);
}
/// Enregistre l'état de bannissement et le motif
Future<void> setBanned(bool banned, {String? reason}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_bannedKey, banned);
if (banned) {
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
await prefs.setBool(_uploadEnabledKey, false);
} else {
await prefs.remove(_banReasonKey);
}
}
/// Synchronise le statut de modération/bannissement avec le serveur backend
Future<bool> syncBanStatus() async {
try {
final phrase = await getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await getServerBaseUrl();
final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
const Duration(seconds: 4),
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final isBannedOnServer = data['is_banned'] == true;
if (isBannedOnServer) {
await setBanned(true, reason: data['ban_reason']);
} else {
await setBanned(false);
}
return isBannedOnServer;
}
} catch (e) {
debugPrint('Erreur synchro ban: $e');
}
return await isBanned();
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();