Compare commits

..
4 Commits
Author SHA1 Message Date
qlionbleusam a4252ea269 Merge branch 'feature/switch-ia' 2026-09-09 14:35:00 +02:00
qlionbleusam b789df8dac Merge branch 'hotfix/didacticiel' 2026-09-09 14:32:41 +02:00
qlionbleusamandClaude Opus 5 ba58cf8efc feat(session): swap the AI export button for a per-session switch
The end-of-session dialog no longer carries a second "finish" button.
"TERMINER TOUT" is now the only way out, and what it does with the target
depends on the AI training setting:

- setting on: the target is always sent, with a discreet reminder saying so;
- setting off: a "CONTRIBUER À L'IA" switch (off by default, never
  remembered) decides whether the target is sent or kept on the device;
- banned account or failed analysis: neither switch nor upload.

Turning the switch on for the first time shows the full program disclaimer
(what is sent, pseudonymity, banning rules); refusing leaves it off. That
text moved to a shared showAiConsentDialog() so the settings switch and the
session switch cannot drift apart, and a new is_ai_terms_accepted flag
remembers the acceptance without silently enabling the global setting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:15:03 +02:00
streaper2 25caf6ddf8 correction overlay didacticiel
Origine du problème (BoxConstraints forces an infinite width / RenderPhysicalShape) :
Hauteur non bornée dans le Positioned :
Lorsque la bulle d'aide était positionnée via Positioned(top: ..., bottom: null), Flutter transmettait une contrainte de hauteur infinie (maxHeight: double.infinity).
Le bouton ElevatedButton calcule son ombre et sa forme physique via RenderPhysicalShape en appelant constraints.biggest ; avec une dimension infinie, Flutter générait une contrainte invalide (BoxConstraints(w=Infinity, 50.0<=h<=Infinity)) et crashait le rendu (ce qui provoquait l'écran gris/noir).
Nids de Row imbriqués dans le pied de la carte d'étape.
Solutions apportées :

tutorial_overlay.dart
 :
Hauteur maximale bornée : _buildTooltip calcule et transmet désormais la hauteur disponible réelle de l'écran (availableHeight) via ConstrainedBox(maxHeight: available).
Bouton moderne Material 3 : Remplacement par FilledButton pour le bouton d'action principale ("Suivant" / "C'est parti").
Structure simplifiée : Aplatissement de la barre inférieure avec un Row unique utilisant Spacer().

full_app_render_test.dart
 :
Ajout d'un test d'intégration complet validant le démarrage de l'application et la navigation étape par étape du didacticiel.
2026-08-30 11:01:51 +02:00
9 changed files with 577 additions and 214 deletions
+131
View File
@@ -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;
}
+22 -20
View File
@@ -45,12 +45,33 @@ class GlassContainer extends StatelessWidget {
final resolvedBorderColor = borderColor ?? defaultBorder; final resolvedBorderColor = borderColor ?? defaultBorder;
final resolvedBg = customBackgroundColor ?? defaultBg; final resolvedBg = customBackgroundColor ?? defaultBg;
Widget content = Container( Widget innerContent = 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(
@@ -67,27 +88,8 @@ class GlassContainer extends StatelessWidget {
), ),
], ],
), ),
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, 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(
+111 -22
View File
@@ -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é.
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', // Rappel discret quand l'envoi est acquis par les Paramètres :
color: AppTheme.warningColor, // sans switch, l'utilisateur doit savoir que « TERMINER TOUT »
onPressed: () => // contribue aussi à l'IA.
_finishSession(context, provider, export: true), 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), const SizedBox(height: 4),
@@ -815,6 +857,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
], ],
), ),
), ),
),
); );
} }
@@ -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,
+5 -114
View File
@@ -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,128 +433,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
return; return;
} }
showDialog( final accepted = await showAiConsentDialog(context);
context: context, if (!accepted || !mounted) return;
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); _walletService.setUploadEnabled(true);
setState(() { setState(() {
_isUploadEnabled = true; _isUploadEnabled = true;
}); });
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Merci pour votre contribution !'), content: Text('Merci pour votre contribution !'),
backgroundColor: AppTheme.successColor, backgroundColor: AppTheme.successColor,
), ),
); );
},
child: const Text('J\'accepte les règles'),
),
],
),
);
} }
void _showEditServerUrlDialog() { void _showEditServerUrlDialog() {
@@ -37,7 +37,7 @@ class _TutorialOverlayState extends State<TutorialOverlay>
int _index = 0; int _index = 0;
Rect? _spotRect; Rect? _spotRect;
bool _ready = false; late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null;
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,12 +63,17 @@ 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;
@@ -80,13 +85,18 @@ class _TutorialOverlayState extends State<TutorialOverlay>
} }
Rect? _measure(BuildContext targetContext, double padding) { Rect? _measure(BuildContext targetContext, double padding) {
final box = targetContext.findRenderObject() as RenderBox?; if (!targetContext.mounted) return null;
if (box == null || !box.hasSize) return null; final renderObject = targetContext.findRenderObject();
final origin = box.localToGlobal(Offset.zero); if (renderObject is! RenderBox || !renderObject.hasSize) return null;
try {
final origin = renderObject.localToGlobal(Offset.zero);
final screen = MediaQuery.of(context).size; final screen = MediaQuery.of(context).size;
return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height) return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height)
.inflate(padding) .inflate(padding)
.intersect(Offset.zero & screen); .intersect(Offset.zero & screen);
} catch (_) {
return null;
}
} }
void _next() { void _next() {
@@ -96,7 +106,7 @@ class _TutorialOverlayState extends State<TutorialOverlay>
} }
setState(() { setState(() {
_index++; _index++;
_ready = false; _ready = widget.steps[_index].targetKey == null;
_spotRect = null; _spotRect = null;
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep()); WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
@@ -182,11 +192,15 @@ class _TutorialOverlayState extends State<TutorialOverlay>
); );
if (spot == null) { if (spot == null) {
return Center( return Positioned.fill(
child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: card, child: card,
), ),
),
),
); );
} }
@@ -195,11 +209,19 @@ 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: ConstrainedBox(
constraints: BoxConstraints(maxHeight: available),
child: Center(
child: card, child: card,
),
),
),
); );
} }
@@ -207,12 +229,25 @@ 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(
top: !above,
bottom: above,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: available),
child: Center(
child: card, child: card,
),
),
),
); );
} }
} }
@@ -246,8 +281,7 @@ class _TutorialCard extends StatelessWidget {
final textSecondary = final textSecondary =
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary; isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Center( return ConstrainedBox(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460), constraints: const BoxConstraints(maxWidth: 460),
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12), padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
@@ -270,7 +304,7 @@ class _TutorialCard extends StatelessWidget {
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
children: [ children: [
@@ -298,6 +332,7 @@ class _TutorialCard extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(width: 8),
Text( Text(
'${index + 1}/$total', '${index + 1}/$total',
style: TextStyle( style: TextStyle(
@@ -339,8 +374,7 @@ class _TutorialCard extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Row( ...List.generate(total, (i) {
children: List.generate(total, (i) {
final active = i == index; final active = i == index;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -355,7 +389,6 @@ class _TutorialCard extends StatelessWidget {
), ),
); );
}), }),
),
const Spacer(), const Spacer(),
TextButton( TextButton(
onPressed: onSkip, onPressed: onSkip,
@@ -363,9 +396,9 @@ class _TutorialCard extends StatelessWidget {
child: const Text('Passer'), child: const Text('Passer'),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
ElevatedButton( FilledButton(
onPressed: onNext, onPressed: onNext,
style: ElevatedButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: primary, backgroundColor: primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -384,7 +417,6 @@ class _TutorialCard extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
+24
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
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);
});
}
+58
View File
@@ -124,4 +124,62 @@ 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);
});
});
}