diff --git a/lib/core/widgets/ai_consent_dialog.dart b/lib/core/widgets/ai_consent_dialog.dart new file mode 100644 index 00000000..6b284763 --- /dev/null +++ b/lib/core/widgets/ai_consent_dialog.dart @@ -0,0 +1,131 @@ +/// Avertissement du programme d'entraînement IA - Consentement partagé. +/// +/// Même texte pour les deux points d'entrée : le switch « Participer à +/// l'entraînement IA » des Paramètres et le switch « Contribuer à l'IA » de la +/// popup de fin de session. La fonction ne persiste rien : elle renvoie +/// simplement `true` si l'utilisateur accepte les règles, à charge de l'appelant +/// d'enregistrer ce choix. +library; + +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Affiche l'avertissement IA et renvoie `true` si les règles sont acceptées. +Future showAiConsentDialog(BuildContext context) async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final primary = Theme.of(context).colorScheme.primary; + + final accepted = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.psychology, size: 40, color: primary), + ), + ), + 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( + '📤 Ce qui est envoyé : la photo de la cible, la position des ' + 'impacts que vous avez placés, la distance de tir, le calibre et ' + 'le nombre de coups prévus.', + 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), + ), + 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( + onPressed: () => Navigator.pop(context, false), + child: const Text('Refuser', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('J\'accepte les règles'), + ), + ], + ), + ); + + return accepted ?? false; +} diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 249defb5..208add17 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -14,6 +14,7 @@ import 'package:provider/provider.dart'; import '../../main_navigation_holder.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/ai_consent_dialog.dart'; import '../../data/models/target_type.dart'; import '../../data/repositories/session_repository.dart'; import '../../services/score_calculator_service.dart'; @@ -708,11 +709,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { BuildContext context, AnalysisProvider provider, ) async { - // L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT - // d'ouvrir la popup : elle décide de la présence du bouton d'export. - final canExport = - await WalletIdentityService().isUploadEnabled() && - provider.state == AnalysisState.success; + // Les réglages du programme IA sont lus AVANT d'ouvrir la popup : + // - option « Participer à l'entraînement IA » activée (Paramètres) : + // aucun switch, « TERMINER TOUT » envoie systématiquement la cible ; + // - option désactivée : un switch (OFF par défaut) propose l'envoi pour + // cette session seulement ; + // - compte banni ou analyse en échec : ni switch ni envoi. + final wallet = WalletIdentityService(); + final isBanned = await wallet.isBanned(); + final alwaysExport = await wallet.isUploadEnabled(); + final canExport = !isBanned && provider.state == AnalysisState.success; + final showExportSwitch = canExport && !alwaysExport; + + // Choix ponctuel, jamais mémorisé d'une session à l'autre : le switch + // repart toujours de OFF pour qu'aucune photo ne parte sans geste explicite. + bool exportThisSession = false; if (!context.mounted) return; @@ -751,7 +762,8 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ), // Les boutons sont dans le contenu (et non dans `actions`) pour être // tous à la même largeur, alignés les uns sous les autres. - content: Column( + content: StatefulBuilder( + builder: (context, setDialogState) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -765,29 +777,59 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { color: AppTheme.secondaryColor, onPressed: () => _saveAndAddTarget(context, provider), ), + // Switch d'envoi ponctuel : il remplace l'ancien bouton + // « TERMINER ET CONTRIBUER À L'IA » et n'apparaît que si l'option + // globale des Paramètres est désactivée. + if (showExportSwitch) ...[ + const SizedBox(height: 8), + _buildExportSwitch( + context: context, + value: exportThisSession, + onChanged: (value) async { + if (!value) { + setDialogState(() => exportThisSession = false); + return; + } + // Les règles du programme (contenu envoyé, pseudonymat, + // bannissement) sont présentées une seule fois : refuser + // laisse le switch sur OFF. + if (!await wallet.hasAcceptedAiTerms()) { + if (!context.mounted) return; + if (!await showAiConsentDialog(context)) return; + await wallet.setAiTermsAccepted(true); + } + setDialogState(() => exportThisSession = true); + }, + ), + ], const SizedBox(height: 8), _buildDialogButton( icon: const Icon(Icons.save, color: Colors.white), label: 'TERMINER TOUT', color: AppTheme.primaryColor, - onPressed: () => _finishSession(context, provider), + onPressed: () => _finishSession( + context, + provider, + export: canExport && (alwaysExport || exportThisSession), + ), ), - // Bouton d'export : uniquement si l'entraînement IA est autorisé. - if (canExport) ...[ - const SizedBox(height: 8), - _buildDialogButton( - icon: Image.asset( - 'assets/icons/cloud_save.png', - width: 24, - height: 24, - // L'icône est un trait noir : on la recolore en blanc pour - // qu'elle ressorte sur le bouton. - color: Colors.white, - ), - label: 'TERMINER ET CONTRIBUER À L\'IA', - color: AppTheme.warningColor, - onPressed: () => - _finishSession(context, provider, export: true), + // Rappel discret quand l'envoi est acquis par les Paramètres : + // sans switch, l'utilisateur doit savoir que « TERMINER TOUT » + // contribue aussi à l'IA. + if (canExport && alwaysExport) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.psychology, + size: 14, color: AppTheme.warningColor), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Cette cible sera envoyée au programme d\'entraînement IA.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], ), ], const SizedBox(height: 4), @@ -813,6 +855,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { child: const Text('ANNULER'), ), ], + ), ), ), ); @@ -873,6 +916,52 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { ); } + /// Switch « Contribuer à l'IA » de la popup de fin de session. + /// + /// Sur ON, « TERMINER TOUT » enverra en plus la cible au backend + /// d'entraînement ; sur OFF, la session est simplement enregistrée en local. + Widget _buildExportSwitch({ + required BuildContext context, + required bool value, + required ValueChanged onChanged, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.warningColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppTheme.warningColor.withValues(alpha: value ? 0.8 : 0.35), + ), + ), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + secondary: Image.asset( + 'assets/icons/cloud_save.png', + width: 24, + height: 24, + // L'icône est un trait noir : on la recolore pour la rendre lisible + // sur les deux thèmes. + color: AppTheme.warningColor, + ), + title: const Text( + 'CONTRIBUER À L\'IA', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + subtitle: Text( + value + ? 'La cible sera envoyée au serveur d\'entraînement.' + : 'La cible reste sur votre appareil.', + style: Theme.of(context).textTheme.bodySmall, + ), + value: value, + activeThumbColor: AppTheme.warningColor, + onChanged: onChanged, + ), + ); + } + /// Enregistre la cible courante et enchaîne sur une nouvelle capture. Future _saveAndAddTarget( BuildContext context, diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index d962651f..4b8ad6f8 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import '../../core/theme/app_theme.dart'; import '../../core/theme/theme_provider.dart'; +import '../../core/widgets/ai_consent_dialog.dart'; import '../../core/widgets/glass_container.dart'; import '../../services/wallet_identity_service.dart'; import '../garage/weapon_list_screen.dart'; @@ -392,9 +393,8 @@ class _SettingsScreenState extends State { ); } - void _showOptInDisclaimer(bool value) { + Future _showOptInDisclaimer(bool value) async { final isDark = Theme.of(context).brightness == Brightness.dark; - final primary = Theme.of(context).colorScheme.primary; if (_isBanned) { showDialog( @@ -433,126 +433,17 @@ class _SettingsScreenState extends State { return; } - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: primary.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: Icon(Icons.psychology, size: 40, color: primary), - ), - ), - 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( - '📤 Ce qui est envoyé : la photo de la cible, la position des ' - 'impacts que vous avez placés, la distance de tir, le calibre et ' - 'le nombre de coups prévus.', - 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), - ), - 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( - onPressed: () => Navigator.pop(context), - child: const Text('Refuser', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - onPressed: () { - _walletService.setUploadEnabled(true); - setState(() { - _isUploadEnabled = true; - }); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Merci pour votre contribution !'), - backgroundColor: AppTheme.successColor, - ), - ); - }, - child: const Text('J\'accepte les règles'), - ), - ], + final accepted = await showAiConsentDialog(context); + if (!accepted || !mounted) return; + + _walletService.setUploadEnabled(true); + setState(() { + _isUploadEnabled = true; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Merci pour votre contribution !'), + backgroundColor: AppTheme.successColor, ), ); } diff --git a/lib/services/wallet_identity_service.dart b/lib/services/wallet_identity_service.dart index bbe84950..4fea1a04 100644 --- a/lib/services/wallet_identity_service.dart +++ b/lib/services/wallet_identity_service.dart @@ -10,6 +10,7 @@ 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 _termsAcceptedKey = 'is_ai_terms_accepted'; static const String _bannedKey = 'wallet_is_banned'; static const String _banReasonKey = 'wallet_ban_reason'; static const String _serverUrlKey = 'ai_server_url'; @@ -76,6 +77,9 @@ class WalletIdentityService { } /// Active ou désactive l'envoi de données + /// + /// L'activation n'est proposée qu'après lecture des règles du programme : + /// on mémorise donc au passage que l'avertissement a été accepté. Future setUploadEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); final isBanned = prefs.getBool(_bannedKey) ?? false; @@ -84,6 +88,26 @@ class WalletIdentityService { return; } await prefs.setBool(_uploadEnabledKey, enabled); + if (enabled) { + await prefs.setBool(_termsAcceptedKey, true); + } + } + + /// Indique si les règles du programme IA ont déjà été lues et acceptées. + /// + /// Utilisé par le switch « Contribuer à l'IA » de la fin de session : + /// l'avertissement (contenu envoyé, pseudonymat, bannissement) n'est + /// présenté qu'une seule fois, même si l'option globale des Paramètres + /// reste désactivée. + Future hasAcceptedAiTerms() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_termsAcceptedKey) ?? false; + } + + /// Mémorise l'acceptation des règles du programme IA. + Future setAiTermsAccepted(bool accepted) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_termsAcceptedKey, accepted); } /// Vérifie si ce wallet/utilisateur est banni en local diff --git a/test/services/wallet_identity_service_test.dart b/test/services/wallet_identity_service_test.dart new file mode 100644 index 00000000..d2319ca6 --- /dev/null +++ b/test/services/wallet_identity_service_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:bully/services/wallet_identity_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('WalletIdentityService - consentement IA', () { + test('rien n\'est accepté ni activé à la première utilisation', () async { + final service = WalletIdentityService(); + + expect(await service.isUploadEnabled(), isFalse); + expect(await service.hasAcceptedAiTerms(), isFalse); + }); + + test('activer l\'option des Paramètres vaut acceptation des règles', + () async { + final service = WalletIdentityService(); + + await service.setUploadEnabled(true); + + expect(await service.isUploadEnabled(), isTrue); + expect(await service.hasAcceptedAiTerms(), isTrue); + }); + + test('désactiver l\'option ne fait pas oublier les règles acceptées', + () async { + final service = WalletIdentityService(); + + await service.setUploadEnabled(true); + await service.setUploadEnabled(false); + + expect(await service.isUploadEnabled(), isFalse); + // Le switch de fin de session ne redemandera donc pas l'avertissement. + expect(await service.hasAcceptedAiTerms(), isTrue); + }); + + test('accepter depuis le switch de fin de session n\'active pas l\'option ' + 'globale', () async { + final service = WalletIdentityService(); + + await service.setAiTermsAccepted(true); + + expect(await service.hasAcceptedAiTerms(), isTrue); + expect(await service.isUploadEnabled(), isFalse); + }); + + test('un compte banni ne peut plus activer l\'envoi', () async { + final service = WalletIdentityService(); + + await service.setBanned(true, reason: 'Cibles non conformes'); + await service.setUploadEnabled(true); + + expect(await service.isBanned(), isTrue); + expect(await service.isUploadEnabled(), isFalse); + expect(await service.hasAcceptedAiTerms(), isFalse); + }); + }); +}