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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user