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'),