Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba58cf8efc |
@@ -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<bool> showAiConsentDialog(BuildContext context) async {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
final accepted = await showDialog<bool>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -45,33 +45,12 @@ class GlassContainer extends StatelessWidget {
|
|||||||
final resolvedBorderColor = borderColor ?? defaultBorder;
|
final resolvedBorderColor = borderColor ?? defaultBorder;
|
||||||
final resolvedBg = customBackgroundColor ?? defaultBg;
|
final resolvedBg = customBackgroundColor ?? defaultBg;
|
||||||
|
|
||||||
Widget innerContent = Container(
|
Widget content = Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: resolvedBg,
|
color: resolvedBg,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
border: Border.all(color: resolvedBorderColor, width: 1.2),
|
border: Border.all(color: resolvedBorderColor, width: 1.2),
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget content = blur > 0
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
|
||||||
child: innerContent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
child: innerContent,
|
|
||||||
);
|
|
||||||
|
|
||||||
content = Container(
|
|
||||||
margin: margin,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
if (glowColor != null)
|
if (glowColor != null)
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -88,9 +67,28 @@ class GlassContainer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: content,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (blur > 0) {
|
||||||
|
content = ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (margin != EdgeInsets.zero) {
|
||||||
|
content = Padding(padding: margin, child: content);
|
||||||
|
}
|
||||||
|
|
||||||
if (onTap != null || onLongPress != null) {
|
if (onTap != null || onLongPress != null) {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../main_navigation_holder.dart';
|
import '../../main_navigation_holder.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/ai_consent_dialog.dart';
|
||||||
import '../../data/models/target_type.dart';
|
import '../../data/models/target_type.dart';
|
||||||
import '../../data/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
import '../../services/score_calculator_service.dart';
|
import '../../services/score_calculator_service.dart';
|
||||||
@@ -708,11 +709,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
AnalysisProvider provider,
|
AnalysisProvider provider,
|
||||||
) async {
|
) async {
|
||||||
// L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT
|
// Les réglages du programme IA sont lus AVANT d'ouvrir la popup :
|
||||||
// d'ouvrir la popup : elle décide de la présence du bouton d'export.
|
// - option « Participer à l'entraînement IA » activée (Paramètres) :
|
||||||
final canExport =
|
// aucun switch, « TERMINER TOUT » envoie systématiquement la cible ;
|
||||||
await WalletIdentityService().isUploadEnabled() &&
|
// - option désactivée : un switch (OFF par défaut) propose l'envoi pour
|
||||||
provider.state == AnalysisState.success;
|
// 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;
|
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
|
// Les boutons sont dans le contenu (et non dans `actions`) pour être
|
||||||
// tous à la même largeur, alignés les uns sous les autres.
|
// tous à la même largeur, alignés les uns sous les autres.
|
||||||
content: Column(
|
content: StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -765,29 +777,59 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
color: AppTheme.secondaryColor,
|
color: AppTheme.secondaryColor,
|
||||||
onPressed: () => _saveAndAddTarget(context, provider),
|
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),
|
const SizedBox(height: 8),
|
||||||
_buildDialogButton(
|
_buildDialogButton(
|
||||||
icon: const Icon(Icons.save, color: Colors.white),
|
icon: const Icon(Icons.save, color: Colors.white),
|
||||||
label: 'TERMINER TOUT',
|
label: 'TERMINER TOUT',
|
||||||
color: AppTheme.primaryColor,
|
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é.
|
// Rappel discret quand l'envoi est acquis par les Paramètres :
|
||||||
if (canExport) ...[
|
// sans switch, l'utilisateur doit savoir que « TERMINER TOUT »
|
||||||
const SizedBox(height: 8),
|
// contribue aussi à l'IA.
|
||||||
_buildDialogButton(
|
if (canExport && alwaysExport) ...[
|
||||||
icon: Image.asset(
|
const SizedBox(height: 6),
|
||||||
'assets/icons/cloud_save.png',
|
Row(
|
||||||
width: 24,
|
children: [
|
||||||
height: 24,
|
const Icon(Icons.psychology,
|
||||||
// L'icône est un trait noir : on la recolore en blanc pour
|
size: 14, color: AppTheme.warningColor),
|
||||||
// qu'elle ressorte sur le bouton.
|
const SizedBox(width: 6),
|
||||||
color: Colors.white,
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
label: 'TERMINER ET CONTRIBUER À L\'IA',
|
'Cette cible sera envoyée au programme d\'entraînement IA.',
|
||||||
color: AppTheme.warningColor,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
onPressed: () =>
|
),
|
||||||
_finishSession(context, provider, export: true),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -813,6 +855,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
child: const Text('ANNULER'),
|
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<bool> 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.
|
/// Enregistre la cible courante et enchaîne sur une nouvelle capture.
|
||||||
Future<void> _saveAndAddTarget(
|
Future<void> _saveAndAddTarget(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/theme/theme_provider.dart';
|
import '../../core/theme/theme_provider.dart';
|
||||||
|
import '../../core/widgets/ai_consent_dialog.dart';
|
||||||
import '../../core/widgets/glass_container.dart';
|
import '../../core/widgets/glass_container.dart';
|
||||||
import '../../services/wallet_identity_service.dart';
|
import '../../services/wallet_identity_service.dart';
|
||||||
import '../garage/weapon_list_screen.dart';
|
import '../garage/weapon_list_screen.dart';
|
||||||
@@ -392,9 +393,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showOptInDisclaimer(bool value) {
|
Future<void> _showOptInDisclaimer(bool value) async {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
|
||||||
|
|
||||||
if (_isBanned) {
|
if (_isBanned) {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -433,126 +433,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
final accepted = await showAiConsentDialog(context);
|
||||||
context: context,
|
if (!accepted || !mounted) return;
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => AlertDialog(
|
_walletService.setUploadEnabled(true);
|
||||||
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
setState(() {
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
_isUploadEnabled = true;
|
||||||
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
|
});
|
||||||
content: SingleChildScrollView(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
child: Column(
|
const SnackBar(
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Text('Merci pour votre contribution !'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
backgroundColor: AppTheme.successColor,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
|
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
Rect? _spotRect;
|
Rect? _spotRect;
|
||||||
late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null;
|
bool _ready = false;
|
||||||
|
|
||||||
TutorialStep get _step => widget.steps[_index];
|
TutorialStep get _step => widget.steps[_index];
|
||||||
bool get _isLast => _index == widget.steps.length - 1;
|
bool get _isLast => _index == widget.steps.length - 1;
|
||||||
@@ -63,17 +63,12 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await Scrollable.ensureVisible(
|
||||||
await Scrollable.ensureVisible(
|
targetContext,
|
||||||
targetContext,
|
alignment: 0.35,
|
||||||
alignment: 0.35,
|
duration: const Duration(milliseconds: 320),
|
||||||
duration: const Duration(milliseconds: 320),
|
curve: Curves.easeOutCubic,
|
||||||
curve: Curves.easeOutCubic,
|
);
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
// Ignorer si l'élément n'est pas dans un widget scrollable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Laisse le temps au défilement de se stabiliser avant de mesurer.
|
// Laisse le temps au défilement de se stabiliser avant de mesurer.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -85,18 +80,13 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rect? _measure(BuildContext targetContext, double padding) {
|
Rect? _measure(BuildContext targetContext, double padding) {
|
||||||
if (!targetContext.mounted) return null;
|
final box = targetContext.findRenderObject() as RenderBox?;
|
||||||
final renderObject = targetContext.findRenderObject();
|
if (box == null || !box.hasSize) return null;
|
||||||
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
|
final origin = box.localToGlobal(Offset.zero);
|
||||||
try {
|
final screen = MediaQuery.of(context).size;
|
||||||
final origin = renderObject.localToGlobal(Offset.zero);
|
return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height)
|
||||||
final screen = MediaQuery.of(context).size;
|
.inflate(padding)
|
||||||
return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height)
|
.intersect(Offset.zero & screen);
|
||||||
.inflate(padding)
|
|
||||||
.intersect(Offset.zero & screen);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _next() {
|
void _next() {
|
||||||
@@ -106,7 +96,7 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_index++;
|
_index++;
|
||||||
_ready = widget.steps[_index].targetKey == null;
|
_ready = false;
|
||||||
_spotRect = null;
|
_spotRect = null;
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
||||||
@@ -192,14 +182,10 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (spot == null) {
|
if (spot == null) {
|
||||||
return Positioned.fill(
|
return Center(
|
||||||
child: SafeArea(
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
child: card,
|
||||||
child: Center(
|
|
||||||
child: card,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,19 +195,11 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
|
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
|
||||||
// pour laisser la main animée visible au centre.
|
// pour laisser la main animée visible au centre.
|
||||||
if (spot.height > screenHeight * 0.55) {
|
if (spot.height > screenHeight * 0.55) {
|
||||||
final available = (screenHeight * 0.4).clamp(140.0, screenHeight);
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: media.padding.bottom + 24,
|
bottom: media.padding.bottom + 24,
|
||||||
child: SafeArea(
|
child: card,
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(maxHeight: available),
|
|
||||||
child: Center(
|
|
||||||
child: card,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,25 +207,12 @@ class _TutorialOverlayState extends State<TutorialOverlay>
|
|||||||
final above =
|
final above =
|
||||||
_step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom);
|
_step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom);
|
||||||
|
|
||||||
final available = above
|
|
||||||
? (spot.top - gap - media.padding.top).clamp(140.0, screenHeight)
|
|
||||||
: (screenHeight - spot.bottom - gap - media.padding.bottom).clamp(140.0, screenHeight);
|
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
top: above ? null : (spot.bottom + gap),
|
top: above ? null : spot.bottom + gap,
|
||||||
bottom: above ? (screenHeight - spot.top + gap) : null,
|
bottom: above ? (screenHeight - spot.top + gap) : null,
|
||||||
child: SafeArea(
|
child: card,
|
||||||
top: !above,
|
|
||||||
bottom: above,
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(maxHeight: available),
|
|
||||||
child: Center(
|
|
||||||
child: card,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,9 +246,10 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
final textSecondary =
|
final textSecondary =
|
||||||
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
|
|
||||||
return ConstrainedBox(
|
return Center(
|
||||||
constraints: const BoxConstraints(maxWidth: 460),
|
child: ConstrainedBox(
|
||||||
child: Container(
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
child: Container(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: surface,
|
color: surface,
|
||||||
@@ -304,7 +270,7 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -332,7 +298,6 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
Text(
|
||||||
'${index + 1}/$total',
|
'${index + 1}/$total',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -374,21 +339,23 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
...List.generate(total, (i) {
|
Row(
|
||||||
final active = i == index;
|
children: List.generate(total, (i) {
|
||||||
return AnimatedContainer(
|
final active = i == index;
|
||||||
duration: const Duration(milliseconds: 200),
|
return AnimatedContainer(
|
||||||
margin: const EdgeInsets.only(right: 5),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: active ? 18 : 6,
|
margin: const EdgeInsets.only(right: 5),
|
||||||
height: 6,
|
width: active ? 18 : 6,
|
||||||
decoration: BoxDecoration(
|
height: 6,
|
||||||
color: active
|
decoration: BoxDecoration(
|
||||||
? primary
|
color: active
|
||||||
: primary.withValues(alpha: 0.28),
|
? primary
|
||||||
borderRadius: BorderRadius.circular(3),
|
: primary.withValues(alpha: 0.28),
|
||||||
),
|
borderRadius: BorderRadius.circular(3),
|
||||||
);
|
),
|
||||||
}),
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: onSkip,
|
onPressed: onSkip,
|
||||||
@@ -396,9 +363,9 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
child: const Text('Passer'),
|
child: const Text('Passer'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
FilledButton(
|
ElevatedButton(
|
||||||
onPressed: onNext,
|
onPressed: onNext,
|
||||||
style: FilledButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: primary,
|
backgroundColor: primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -417,7 +384,8 @@ class _TutorialCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _gestureHint(TutorialGesture gesture) {
|
static String _gestureHint(TutorialGesture gesture) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
class WalletIdentityService {
|
class WalletIdentityService {
|
||||||
static const String _prefsKey = 'wallet_identity_phrase';
|
static const String _prefsKey = 'wallet_identity_phrase';
|
||||||
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
|
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 _bannedKey = 'wallet_is_banned';
|
||||||
static const String _banReasonKey = 'wallet_ban_reason';
|
static const String _banReasonKey = 'wallet_ban_reason';
|
||||||
static const String _serverUrlKey = 'ai_server_url';
|
static const String _serverUrlKey = 'ai_server_url';
|
||||||
@@ -76,6 +77,9 @@ class WalletIdentityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Active ou désactive l'envoi de données
|
/// 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<void> setUploadEnabled(bool enabled) async {
|
Future<void> setUploadEnabled(bool enabled) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
||||||
@@ -84,6 +88,26 @@ class WalletIdentityService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await prefs.setBool(_uploadEnabledKey, enabled);
|
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<bool> hasAcceptedAiTerms() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_termsAcceptedKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mémorise l'acceptation des règles du programme IA.
|
||||||
|
Future<void> setAiTermsAccepted(bool accepted) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_termsAcceptedKey, accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie si ce wallet/utilisateur est banni en local
|
/// Vérifie si ce wallet/utilisateur est banni en local
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
||||||
|
|
||||||
import 'package:bully/app.dart';
|
|
||||||
import 'package:bully/core/theme/theme_provider.dart';
|
|
||||||
import 'package:bully/data/repositories/session_repository.dart';
|
|
||||||
import 'package:bully/features/session/session_provider.dart';
|
|
||||||
import 'package:bully/features/tutorial/tutorial_provider.dart';
|
|
||||||
import 'package:bully/services/grouping_analyzer_service.dart';
|
|
||||||
import 'package:bully/services/score_calculator_service.dart';
|
|
||||||
import 'package:bully/services/tutorial_service.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
setUpAll(() {
|
|
||||||
sqfliteFfiInit();
|
|
||||||
databaseFactory = databaseFactoryFfi;
|
|
||||||
FlutterError.onError = (details) {
|
|
||||||
// ignore: avoid_print
|
|
||||||
print('CAUGHT_FLUTTER_ERROR: ${details.exceptionAsString()}');
|
|
||||||
// ignore: avoid_print
|
|
||||||
print('CAUGHT_STACK: ${details.stack}');
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('BullyApp démarre et affiche HomeScreen + Tutorial sans erreur',
|
|
||||||
(WidgetTester tester) async {
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
final repository = SessionRepository();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MultiProvider(
|
|
||||||
providers: [
|
|
||||||
Provider<ScoreCalculatorService>(
|
|
||||||
create: (_) => ScoreCalculatorService(),
|
|
||||||
),
|
|
||||||
Provider<GroupingAnalyzerService>(
|
|
||||||
create: (_) => GroupingAnalyzerService(),
|
|
||||||
),
|
|
||||||
Provider<SessionRepository>.value(value: repository),
|
|
||||||
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
|
|
||||||
ChangeNotifierProvider<SessionProvider>(
|
|
||||||
create: (_) => SessionProvider()),
|
|
||||||
ChangeNotifierProvider<TutorialProvider>(
|
|
||||||
create: (_) => TutorialProvider(service: TutorialService())),
|
|
||||||
],
|
|
||||||
child: const BullyApp(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
expect(find.text('BULLY'), findsOneWidget);
|
|
||||||
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
|
|
||||||
|
|
||||||
// Passer à l'étape suivante
|
|
||||||
await tester.tap(find.text('Suivant'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
expect(find.text('Démarrez une session'), findsOneWidget);
|
|
||||||
|
|
||||||
// Passer
|
|
||||||
await tester.tap(find.text('Passer'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
expect(find.text('Démarrez une session'), findsNothing);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -124,62 +124,4 @@ void main() {
|
|||||||
// Aucune cible : pas de main animée non plus.
|
// Aucune cible : pas de main animée non plus.
|
||||||
expect(find.byType(TutorialHand), findsNothing);
|
expect(find.byType(TutorialHand), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('l\'overlay se rend correctement dans un Navigator push modal',
|
|
||||||
(WidgetTester tester) async {
|
|
||||||
final targetKey = GlobalKey();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => Scaffold(
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
SizedBox(key: targetKey, width: 200, height: 60),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
PageRouteBuilder<void>(
|
|
||||||
opaque: false,
|
|
||||||
pageBuilder: (routeCtx, _, __) => TutorialOverlay(
|
|
||||||
steps: [
|
|
||||||
const TutorialStep(
|
|
||||||
title: 'Bienvenue',
|
|
||||||
description: 'Texte introductif',
|
|
||||||
),
|
|
||||||
TutorialStep(
|
|
||||||
targetKey: targetKey,
|
|
||||||
title: 'Bouton',
|
|
||||||
description: 'Texte bouton',
|
|
||||||
gesture: TutorialGesture.tap,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onFinished: () => Navigator.of(routeCtx).pop(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('Ouvrir'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Ouvrir'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
expect(find.text('Bienvenue'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Suivant'));
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
expect(find.text('Bouton'), findsOneWidget);
|
|
||||||
expect(find.byType(TutorialHand), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user