Compare commits

..
Author SHA1 Message Date
qlionbleusam b789df8dac Merge branch 'hotfix/didacticiel' 2026-09-09 14:32:41 +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
qlionbleusam a9651588bb Merge branch 'feature/didacticiel' 2026-08-29 12:04:41 +02:00
qlionbleusamandClaude Opus 5 32582aba3d feat(tutorial): guide the first launch with spotlighted steps and gesture hands
Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un
trou de lumière autour de l'élément à découvrir, une main animée mime le geste
attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une
bulle explique l'étape avec sa progression.

- visite d'accueil : nouvelle session, télémétrie, barre de navigation, réglages
- visite de l'éditeur d'impacts : ajouter, déplacer, zoomer au pincement, valider
- chaque visite n'est jouée qu'une fois (SharedPreferences)
- Paramètres > Aide & didacticiel > Revoir le didacticiel : réinitialise tout et
  relance la visite au retour sur l'écran concerné

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:46:02 +02:00
qlionbleusam 32143c5bb1 Merge branch 'feature/detection-cible-au-sol' 2026-08-29 11:24:28 +02:00
qlionbleusam 6ee0839db5 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 11:07:46 +02:00
qlionbleusam 8804d8b6a1 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 10:33:59 +02:00
qlionbleusam 869835f234 Merge branch 'feature/ai-contribution-filter' 2026-08-29 10:19:42 +02:00
qlionbleusam 3d9e574309 feat(ai-export): annotate uploads with the expected shot count 2026-08-29 10:19:01 +02:00
qlionbleusam 3f79252bb5 Merge branch 'feature/ai-consent-wording' 2026-08-29 10:07:31 +02:00
qlionbleusam f6f134f2a5 docs(ai-export): state what the training upload sends and drop the anonymity claim 2026-08-29 10:04:09 +02:00
qlionbleusam 56e88c3e06 Merge branch 'fix/ai-export-privacy' 2026-08-29 09:42:25 +02:00
22 changed files with 2046 additions and 46 deletions
+19
View File
@@ -98,6 +98,19 @@ flutter test --coverage
- Visualisation des sessions passées - Visualisation des sessions passées
- Suppression de sessions - Suppression de sessions
### Didacticiel (visite guidée)
- Démarre automatiquement à la **première utilisation** sur l'écran d'accueil :
voile sombre, « trou de lumière » autour de l'élément à découvrir et bulle
explicative (bouton nouvelle session, télémétrie, barre de navigation, réglages)
- Visite dédiée à la **première ouverture de l'éditeur d'impacts** : ajouter,
déplacer, **pincer pour zoomer**, valider
- **Main animée** qui mime le geste attendu (tap, appui long, glisser,
pincement à deux doigts, balayage), dessinée au CustomPainter — aucun asset
- « Passer » interrompt la visite, un tap n'importe où passe à l'étape suivante ;
chaque visite n'est jouée qu'une fois (mémorisée dans les SharedPreferences)
- **Paramètres > Aide & didacticiel > Revoir le didacticiel** : réinitialise
toutes les visites, qui rejouent dès le retour sur l'écran concerné
### Interface utilisateur ### Interface utilisateur
- Thème sombre adapté au tir - Thème sombre adapté au tir
- Support multilingue (Français) - Support multilingue (Français)
@@ -135,3 +148,9 @@ history_chart.dart Graphique d'évolution des 10 dernières sessions
statistics_screen.dart Écran statistiques avec filtrage par période statistics_screen.dart Écran statistiques avec filtrage par période
heat_map_widget.dart Heat map avec gradient bleu→rouge heat_map_widget.dart Heat map avec gradient bleu→rouge
backup_service.dart Export/import JSON des sessions, stats et armurerie backup_service.dart Export/import JSON des sessions, stats et armurerie
tutorial_service.dart Persistance des visites guidées déjà vues (SharedPreferences)
tutorial_provider.dart État du didacticiel : visites à jouer, réactivation
tutorial_coach.dart Lance une visite guidée par-dessus l'écran courant
tutorial_step.dart Modèle d'étape : cible, texte, geste, forme du spot
tutorial_overlay.dart Voile sombre, trou de lumière et bulle explicative
tutorial_hand.dart Main animée qui mime le geste (tap, appui long, pincement…)
+23 -21
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,28 +88,9 @@ class GlassContainer extends StatelessWidget {
), ),
], ],
), ),
child: child, child: content,
); );
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,
@@ -248,6 +248,7 @@ class AnalysisProvider extends ChangeNotifier {
String? sessionId, String? sessionId,
int? distance, int? distance,
String? caliber, String? caliber,
int? expectedShots,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant."; _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
@@ -273,6 +274,7 @@ class AnalysisProvider extends ChangeNotifier {
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25, distanceMeters: distance ?? 25,
caliber: caliber ?? 'unknown', caliber: caliber ?? 'unknown',
expectedShots: expectedShots,
); );
_state = AnalysisState.success; _state = AnalysisState.success;
+6 -1
View File
@@ -784,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// qu'elle ressorte sur le bouton. // qu'elle ressorte sur le bouton.
color: Colors.white, color: Colors.white,
), ),
label: 'TERMINER TOUT ET EXPORTER', label: 'TERMINER ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor, color: AppTheme.warningColor,
onPressed: () => onPressed: () =>
_finishSession(context, provider, export: true), _finishSession(context, provider, export: true),
@@ -943,6 +943,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
sessionId: sessionProvider.activeSessionId, sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance, distance: sessionProvider.distance,
caliber: sessionProvider.currentWeaponCaliber, caliber: sessionProvider.currentWeaponCaliber,
// Hors session, shotsPerTarget vaut sa valeur par defaut : mieux
// vaut ne rien annoncer qu'annoncer un nombre faux.
expectedShots: sessionProvider.isSessionActive
? sessionProvider.shotsPerTarget
: null,
); );
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
} }
@@ -25,6 +25,9 @@ import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/shot.dart'; import '../../data/models/shot.dart';
import '../../services/tutorial_service.dart';
import '../tutorial/tutorial_coach.dart';
import '../tutorial/tutorial_step.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
@@ -43,12 +46,71 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
String? _movingShotId; String? _movingShotId;
// Clés du didacticiel : zone de travail et bouton de validation.
final GlobalKey _tutoCanvasKey = GlobalKey();
final GlobalKey _tutoValidateKey = GlobalKey();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_transformationController.addListener(_onTransformChanged); _transformationController.addListener(_onTransformChanged);
// Première ouverture de l'éditeur : on montre les gestes disponibles.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
TutorialCoach.maybeStart(
context,
tourId: TutorialTours.impactEditor,
stepsBuilder: _buildEditorTutorialSteps,
);
});
} }
List<TutorialStep> _buildEditorTutorialSteps() => [
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Ajouter un impact',
description:
'Touchez la cible à l\'endroit de l\'impact : il est ajouté '
'immédiatement, même collé à un impact déjà placé. Le score est '
'calculé automatiquement selon la zone touchée.',
gesture: TutorialGesture.tap,
icon: Icons.add_location_alt_outlined,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Déplacer un impact',
description:
'Appui long sur un impact, puis glissez le doigt pour l\'ajuster '
'au millimètre. L\'impact reste visible au-dessus du doigt.',
gesture: TutorialGesture.drag,
icon: Icons.open_with,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Zoomer sur la cible',
description:
'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos '
'impacts avec précision ; rapprochez-les pour dézoomer. À un '
'doigt, vous faites glisser l\'image.',
gesture: TutorialGesture.pinch,
icon: Icons.zoom_in,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoValidateKey,
title: 'Valider vos impacts',
description:
'VALIDER renvoie vers la synthèse avec les scores et le '
'groupement. La corbeille, à gauche, efface tous les impacts '
'sans toucher à la calibration.',
gesture: TutorialGesture.tap,
icon: Icons.check_circle_outline,
spotPadding: 8,
),
];
@override @override
void dispose() { void dispose() {
_transformationController.removeListener(_onTransformChanged); _transformationController.removeListener(_onTransformChanged);
@@ -123,6 +185,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
FloatingActionButton.extended( FloatingActionButton.extended(
key: _tutoValidateKey,
heroTag: 'validate_impacts', heroTag: 'validate_impacts',
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
backgroundColor: AppTheme.primaryColor, backgroundColor: AppTheme.primaryColor,
@@ -147,6 +210,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
// Zone image plein écran : InteractiveViewer dans un body nu. // Zone image plein écran : InteractiveViewer dans un body nu.
Expanded( Expanded(
key: _tutoCanvasKey,
child: InteractiveViewer( child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
+25 -9
View File
@@ -169,8 +169,9 @@ class _CaptureScreenState extends State<CaptureScreen>
final prev = _parallelismData; final prev = _parallelismData;
final bool changed = prev == null || final bool changed = prev == null ||
prev.status != data.status || prev.status != data.status ||
prev.pitchDegrees.toStringAsFixed(1) != prev.pose != data.pose ||
data.pitchDegrees.toStringAsFixed(1) || prev.pitchDeviation.toStringAsFixed(1) !=
data.pitchDeviation.toStringAsFixed(1) ||
prev.rollDegrees.toStringAsFixed(1) != prev.rollDegrees.toStringAsFixed(1) !=
data.rollDegrees.toStringAsFixed(1); data.rollDegrees.toStringAsFixed(1);
@@ -218,16 +219,21 @@ class _CaptureScreenState extends State<CaptureScreen>
return 'ALIGNEZ LA CIBLE DANS LE CADRE'; return 'ALIGNEZ LA CIBLE DANS LE CADRE';
} }
final bool onGround = _parallelismData!.pose == TargetPose.ground;
// Aligné → message de validation (avec bonus si la cible est détectée) // Aligné → message de validation (avec bonus si la cible est détectée)
if (_parallelismData!.isAligned) { if (_parallelismData!.isAligned) {
return _targetReady if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT';
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' return onGround
? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
} }
// Mal aligné → message directif selon l'axe le plus dévié // Mal aligné → message directif selon l'axe le plus dévié.
final double pitch = _parallelismData!.pitchDegrees; // On raisonne sur les écarts à la pose détectée, pas sur le tangage brut :
final double roll = _parallelismData!.rollDegrees; // à plat, celui-ci vaut -90° alors que le cadrage peut être parfait.
final double pitch = _parallelismData!.pitchDeviation;
final double roll = _parallelismData!.rollDeviation;
if (pitch.abs() >= roll.abs()) { if (pitch.abs() >= roll.abs()) {
return pitch > 0 return pitch > 0
@@ -776,15 +782,25 @@ class _CaptureScreenState extends State<CaptureScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(
data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR',
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
_buildAngleRow( _buildAngleRow(
label: 'Pitch', label: 'Pitch',
value: data.pitchDegrees, value: data.pitchDeviation,
color: color, color: color,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
_buildAngleRow( _buildAngleRow(
label: 'Roll ', label: 'Roll ',
value: data.rollDegrees, value: data.rollDeviation,
color: color, color: color,
), ),
], ],
+107 -1
View File
@@ -14,6 +14,10 @@ import '../session/session_setup_screen.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import '../settings/settings_screen.dart'; import '../settings/settings_screen.dart';
import '../statistics/statistics_screen.dart'; import '../statistics/statistics_screen.dart';
import '../tutorial/tutorial_coach.dart';
import '../tutorial/tutorial_provider.dart';
import '../tutorial/tutorial_step.dart';
import '../../services/tutorial_service.dart';
import 'widgets/stats_card.dart'; import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@@ -33,10 +37,18 @@ class _HomeScreenState extends State<HomeScreen> {
SessionProvider? _sessionProvider; SessionProvider? _sessionProvider;
bool _wasSessionActive = false; bool _wasSessionActive = false;
// Clés utilisées par le didacticiel pour mettre les éléments en avant.
final GlobalKey _tutoActionKey = GlobalKey();
final GlobalKey _tutoStatsKey = GlobalKey();
final GlobalKey _tutoSettingsKey = GlobalKey();
TutorialProvider? _tutorialProvider;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadStats(); _loadStats();
// Première utilisation : la visite guidée démarre dès le premier rendu.
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeStartTutorial());
} }
@override @override
@@ -57,6 +69,89 @@ class _HomeScreenState extends State<HomeScreen> {
_sessionProvider!.addListener(_onSessionChanged); _sessionProvider!.addListener(_onSessionChanged);
_wasSessionActive = provider.isSessionActive; _wasSessionActive = provider.isSessionActive;
} }
final tutorial = context.read<TutorialProvider>();
if (tutorial != _tutorialProvider) {
_tutorialProvider?.removeListener(_onTutorialChanged);
_tutorialProvider = tutorial;
_tutorialProvider!.addListener(_onTutorialChanged);
}
}
/// Les préférences du didacticiel sont lues de façon asynchrone : on
/// retente le démarrage dès qu'elles sont disponibles.
void _onTutorialChanged() {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeStartTutorial());
}
/// Lance la visite guidée de l'accueil si elle n'a pas encore été vue
/// (première utilisation, ou didacticiel relancé depuis les paramètres).
void _maybeStartTutorial() {
if (!mounted) return;
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
TutorialCoach.maybeStart(
context,
tourId: TutorialTours.home,
stepsBuilder: _buildHomeTutorialSteps,
);
}
List<TutorialStep> _buildHomeTutorialSteps() {
final isSessionActive = context.read<SessionProvider>().isSessionActive;
return [
const TutorialStep(
title: 'Bienvenue dans Bully',
description:
'Ce guide rapide vous montre comment enregistrer une séance, '
'placer vos impacts et suivre vos progrès.\n'
'Touchez l\'écran pour passer à l\'étape suivante.',
icon: Icons.waving_hand_outlined,
),
TutorialStep(
targetKey: _tutoActionKey,
title: isSessionActive ? 'Votre session en cours' : 'Démarrez une session',
description: isSessionActive
? 'Reprenez la session en cours pour photographier une nouvelle '
'cible, ou clôturez-la avec le bouton rouge.'
: 'Tout part d\'ici : créez une session, choisissez votre arme, '
'puis photographiez votre cible.',
gesture: TutorialGesture.tap,
icon: Icons.add_circle_outline,
spotPadding: 10,
),
TutorialStep(
targetKey: _tutoStatsKey,
title: 'Votre télémétrie',
description:
'Sessions, tirs analysés, score moyen et meilleur score se mettent '
'à jour après chaque séance. « Détails » ouvre les statistiques '
'complètes.',
icon: Icons.insights_outlined,
),
TutorialStep(
targetKey: navigationDockKey,
title: 'La barre de navigation',
description:
'Accueil, Historique, Statistiques et Armurerie : vos quatre '
'espaces de travail, accessibles à tout moment.',
gesture: TutorialGesture.tap,
icon: Icons.dashboard_customize_outlined,
spotPadding: 6,
),
TutorialStep(
targetKey: _tutoSettingsKey,
title: 'Paramètres & aide',
description:
'Thème, armurerie, sauvegarde… et le bouton « Revoir le '
'didacticiel » pour rejouer ce guide quand vous le souhaitez.',
gesture: TutorialGesture.tap,
shape: TutorialHighlightShape.circle,
icon: Icons.settings_outlined,
spotPadding: 6,
),
];
} }
void _onSessionChanged() { void _onSessionChanged() {
@@ -70,6 +165,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void dispose() { void dispose() {
_sessionProvider?.removeListener(_onSessionChanged); _sessionProvider?.removeListener(_onSessionChanged);
_tutorialProvider?.removeListener(_onTutorialChanged);
super.dispose(); super.dispose();
} }
@@ -146,6 +242,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
actions: [ actions: [
IconButton( IconButton(
key: _tutoSettingsKey,
icon: const Icon(Icons.settings_outlined), icon: const Icon(Icons.settings_outlined),
onPressed: () => _navigateToSettings(context), onPressed: () => _navigateToSettings(context),
tooltip: 'Paramètres', tooltip: 'Paramètres',
@@ -205,7 +302,10 @@ class _HomeScreenState extends State<HomeScreen> {
children: [ children: [
_buildHeader(isDark, primaryColor), _buildHeader(isDark, primaryColor),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMainActionSection(context, isDark, primaryColor), KeyedSubtree(
key: _tutoActionKey,
child: _buildMainActionSection(context, isDark, primaryColor),
),
const SizedBox(height: 22), const SizedBox(height: 22),
if (_isLoading) if (_isLoading)
const Padding( const Padding(
@@ -596,6 +696,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
key: _tutoStatsKey,
children: [ children: [
Expanded( Expanded(
child: InkWell( child: InkWell(
@@ -985,6 +1086,11 @@ class _HomeScreenState extends State<HomeScreen> {
context, context,
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
); );
if (!mounted) return;
_loadStats(); _loadStats();
// Si le didacticiel a été relancé depuis les paramètres, il redémarre
// une fois la transition de retour terminée.
await Future<void>.delayed(const Duration(milliseconds: 350));
_maybeStartTutorial();
} }
} }
+54 -1
View File
@@ -9,6 +9,7 @@ import '../../core/theme/theme_provider.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';
import '../tutorial/tutorial_provider.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -462,7 +463,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Text( const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.', '📤 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), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -692,6 +709,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
/// Réactive le didacticiel : toutes les visites guidées sont remises à zéro
/// et celle de l'accueil redémarre dès le retour sur l'écran principal.
Future<void> _restartTutorial() async {
final tutorial = context.read<TutorialProvider>();
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
await tutorial.restart();
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(
const SnackBar(
content: Text('Didacticiel réactivé : la visite guidée redémarre.'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 3),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
@@ -981,6 +1018,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
const SizedBox(height: 20),
_buildSectionHeader('AIDE & DIDACTICIEL', primary),
Consumer<TutorialProvider>(
builder: (context, tutorial, child) {
return _buildSettingsTile(
context: context,
icon: Icons.school_outlined,
title: 'Revoir le didacticiel',
subtitle: tutorial.hasSeenIntro
? 'Rejouer la visite guidée de l\'application'
: 'Visite guidée en attente sur l\'écran d\'accueil',
onTap: _restartTutorial,
);
},
),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildSectionHeader('À PROPOS', primary), _buildSectionHeader('À PROPOS', primary),
_buildSettingsTile( _buildSettingsTile(
+68
View File
@@ -0,0 +1,68 @@
/// Point d'entrée du didacticiel : lance une visite guidée par-dessus l'écran
/// courant (route transparente) et mémorise qu'elle a été vue.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'tutorial_provider.dart';
import 'tutorial_step.dart';
import 'widgets/tutorial_overlay.dart';
class TutorialCoach {
const TutorialCoach._();
static bool _isShowing = false;
/// Une visite est-elle déjà affichée ? (évite les doubles déclenchements)
static bool get isShowing => _isShowing;
/// Joue la visite [tourId] uniquement si elle n'a jamais été vue.
///
/// [stepsBuilder] n'est évalué que si la visite doit réellement démarrer :
/// les clés des widgets sont ainsi lues au dernier moment.
static Future<void> maybeStart(
BuildContext context, {
required String tourId,
required List<TutorialStep> Function() stepsBuilder,
}) async {
if (_isShowing) return;
if (!context.read<TutorialProvider>().shouldRun(tourId)) return;
await start(context, tourId: tourId, steps: stepsBuilder());
}
/// Joue la visite [tourId], même si elle a déjà été vue.
static Future<void> start(
BuildContext context, {
required String tourId,
required List<TutorialStep> steps,
}) async {
if (_isShowing || steps.isEmpty) return;
final provider = context.read<TutorialProvider>();
final navigator = Navigator.of(context, rootNavigator: true);
_isShowing = true;
try {
await navigator.push(
PageRouteBuilder<void>(
opaque: false,
barrierDismissible: false,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 240),
reverseTransitionDuration: const Duration(milliseconds: 180),
pageBuilder: (routeContext, animation, _) => FadeTransition(
opacity: animation,
child: TutorialOverlay(
steps: steps,
onFinished: () => Navigator.of(routeContext).pop(),
),
),
),
);
} finally {
_isShowing = false;
}
await provider.complete(tourId);
}
}
@@ -0,0 +1,54 @@
/// État global du didacticiel : quelles visites guidées restent à jouer.
///
/// Le provider est chargé au démarrage ; tant que les préférences ne sont pas
/// lues, aucune visite n'est déclenchée (évite un flash d'overlay au lancement).
library;
import 'package:flutter/foundation.dart';
import '../../services/tutorial_service.dart';
class TutorialProvider with ChangeNotifier {
TutorialProvider({TutorialService? service})
: _service = service ?? TutorialService() {
load();
}
final TutorialService _service;
final Set<String> _completed = {};
bool _loaded = false;
/// `true` une fois les préférences lues.
bool get isLoaded => _loaded;
/// `true` si l'utilisateur a déjà terminé (ou passé) la visite d'accueil.
bool get hasSeenIntro => _completed.contains(TutorialTours.home);
/// Lit les visites déjà vues. Les visites terminées pendant le chargement
/// sont conservées (le résultat est fusionné, jamais écrasé).
Future<void> load() async {
_completed.addAll(await _service.loadCompletedTours());
_loaded = true;
notifyListeners();
}
/// Faut-il jouer la visite [tourId] ?
bool shouldRun(String tourId) => _loaded && !_completed.contains(tourId);
/// Marque une visite comme vue (terminée ou passée) : elle ne rejouera plus.
Future<void> complete(String tourId) async {
if (_completed.add(tourId)) {
notifyListeners();
await _service.markCompleted(tourId);
}
}
/// Relance le didacticiel depuis les paramètres : toutes les visites
/// redeviennent disponibles et rejoueront dès l'affichage de leur écran.
Future<void> restart() async {
await _service.resetAll();
_completed.clear();
_loaded = true;
notifyListeners();
}
}
+76
View File
@@ -0,0 +1,76 @@
/// Modèle d'une étape de didacticiel (visite guidée).
///
/// Une étape met en avant un élément de l'écran (le « spot ») grâce à une
/// [GlobalKey] posée sur le widget concerné, et affiche une bulle explicative
/// accompagnée, si besoin, d'une main animée qui mime le geste attendu
/// (tap, appui long, pincement…) comme dans les applications mobiles.
library;
import 'package:flutter/widgets.dart';
/// Geste mimé par la main animée pendant l'étape.
enum TutorialGesture {
/// Aucune main animée : simple explication.
none,
/// Un appui simple.
tap,
/// Deux appuis rapprochés.
doubleTap,
/// Un appui maintenu.
longPress,
/// Un appui maintenu suivi d'un déplacement du doigt.
drag,
/// Deux doigts qui s'écartent puis se rapprochent (zoom).
pinch,
/// Un doigt qui balaie l'écran vers le haut.
swipeUp,
/// Un doigt qui balaie l'écran horizontalement.
swipeHorizontal,
}
/// Forme du trou de lumière découpé dans le voile sombre.
enum TutorialHighlightShape { rounded, circle }
class TutorialStep {
/// Clé posée sur le widget à mettre en avant.
///
/// `null` (ou clé non montée) => l'étape s'affiche comme une carte centrée,
/// utile pour les messages d'accueil et de fin.
final GlobalKey? targetKey;
final String title;
final String description;
/// Geste mimé par la main animée.
final TutorialGesture gesture;
final TutorialHighlightShape shape;
/// Marge ajoutée autour du widget mis en avant.
final double spotPadding;
/// Icône affichée dans la bulle explicative.
final IconData? icon;
/// Force la position de la bulle (`true` = au-dessus du spot).
/// Par défaut, la bulle se place automatiquement du côté le plus dégagé.
final bool? preferTooltipAbove;
const TutorialStep({
this.targetKey,
required this.title,
required this.description,
this.gesture = TutorialGesture.none,
this.shape = TutorialHighlightShape.rounded,
this.spotPadding = 8,
this.icon,
this.preferTooltipAbove,
});
}
@@ -0,0 +1,379 @@
/// Main animée du didacticiel : mime le geste attendu par l'utilisateur
/// (tap, appui long, glisser, pincement pour zoomer, balayage) comme dans les
/// tutoriels des applications mobiles.
///
/// Tout est dessiné au CustomPainter : pas d'asset, la couleur suit l'accent
/// du thème et le rendu reste lisible sur fond clair comme sur fond sombre
/// grâce au cœur blanc entouré d'un halo coloré.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../tutorial_step.dart';
class TutorialHand extends StatefulWidget {
final TutorialGesture gesture;
final Color color;
final double size;
const TutorialHand({
super.key,
required this.gesture,
required this.color,
this.size = 96,
});
@override
State<TutorialHand> createState() => _TutorialHandState();
}
class _TutorialHandState extends State<TutorialHand>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: _durationFor(widget.gesture),
)..repeat();
static Duration _durationFor(TutorialGesture gesture) {
switch (gesture) {
case TutorialGesture.pinch:
return const Duration(milliseconds: 2400);
case TutorialGesture.longPress:
case TutorialGesture.drag:
return const Duration(milliseconds: 2200);
case TutorialGesture.swipeUp:
case TutorialGesture.swipeHorizontal:
return const Duration(milliseconds: 1800);
default:
return const Duration(milliseconds: 1500);
}
}
@override
void didUpdateWidget(TutorialHand oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.gesture != widget.gesture) {
_controller
..stop()
..duration = _durationFor(widget.gesture)
..repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.gesture == TutorialGesture.none) return const SizedBox.shrink();
return IgnorePointer(
child: SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
painter: _GesturePainter(
t: _controller.value,
gesture: widget.gesture,
color: widget.color,
),
),
),
),
);
}
}
class _GesturePainter extends CustomPainter {
final double t;
final TutorialGesture gesture;
final Color color;
_GesturePainter({
required this.t,
required this.gesture,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
switch (gesture) {
case TutorialGesture.none:
return;
case TutorialGesture.tap:
_paintTap(canvas, size, center, pulses: 1);
case TutorialGesture.doubleTap:
_paintTap(canvas, size, center, pulses: 2);
case TutorialGesture.longPress:
_paintLongPress(canvas, size, center);
case TutorialGesture.drag:
_paintDrag(canvas, size, center);
case TutorialGesture.pinch:
_paintPinch(canvas, size, center);
case TutorialGesture.swipeUp:
_paintSwipe(canvas, size, center, vertical: true);
case TutorialGesture.swipeHorizontal:
_paintSwipe(canvas, size, center, vertical: false);
}
}
// --------------------------------------------------------------- Utilitaires
/// Onde triangulaire adoucie : 0 -> 1 -> 0 sur un cycle.
double _wave(double x) => Curves.easeInOut
.transform((x < 0.5 ? x * 2 : (1 - x) * 2).clamp(0.0, 1.0));
/// Doigt posé sur l'écran : cœur blanc + halo coloré.
void _paintFingertip(
Canvas canvas,
Offset position,
double radius, {
double opacity = 1.0,
}) {
canvas.drawCircle(
position,
radius * 2.1,
Paint()
..color = color.withValues(alpha: 0.22 * opacity)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
canvas.drawCircle(
position,
radius,
Paint()..color = Colors.white.withValues(alpha: 0.95 * opacity),
);
canvas.drawCircle(
position,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..color = color.withValues(alpha: 0.9 * opacity),
);
}
/// Ondes concentriques émises au moment du contact.
void _paintRipple(
Canvas canvas,
Offset position,
double progress,
double maxRadius,
) {
if (progress <= 0 || progress >= 1) return;
canvas.drawCircle(
position,
6 + progress * maxRadius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5 * (1 - progress) + 0.6
..color = color.withValues(alpha: 0.55 * (1 - progress)),
);
}
/// Main « pointeur » (icône Material) dont l'index touche [tip].
void _paintHand(
Canvas canvas,
Offset tip,
double glyphSize, {
double press = 0,
}) {
final painter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: String.fromCharCode(Icons.touch_app_rounded.codePoint),
style: TextStyle(
fontSize: glyphSize,
fontFamily: Icons.touch_app_rounded.fontFamily,
package: Icons.touch_app_rounded.fontPackage,
color: Colors.white.withValues(alpha: 0.96),
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.55),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
),
)..layout();
// Le bout de l'index de l'icône se situe environ à 32 % / 16 % du glyphe.
final origin = tip -
Offset(painter.width * 0.32, painter.height * 0.16) +
Offset(glyphSize * 0.03 * press, glyphSize * 0.06 * press);
painter.paint(canvas, origin);
}
// ------------------------------------------------------------------ Gestes
void _paintTap(
Canvas canvas,
Size size,
Offset center, {
required int pulses,
}) {
final cycle = (t * pulses) % 1.0;
final press = _wave((cycle / 0.4).clamp(0.0, 1.0));
_paintRipple(canvas, center, cycle, size.width * 0.34);
_paintRipple(
canvas, center, (cycle - 0.25).clamp(0.0, 1.0), size.width * 0.34);
_paintFingertip(canvas, center, size.width * 0.055 + 2 * press,
opacity: 0.35 + 0.65 * press);
_paintHand(canvas, center, size.width * 0.58, press: press);
}
void _paintLongPress(Canvas canvas, Size size, Offset center) {
final hold = Curves.easeOut.transform((t / 0.75).clamp(0.0, 1.0));
final radius = size.width * 0.24;
canvas.drawCircle(
center,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.white.withValues(alpha: 0.22),
);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
2 * math.pi * hold,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.4
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.95),
);
_paintFingertip(canvas, center, size.width * 0.06);
_paintHand(canvas, center, size.width * 0.58, press: 1);
}
void _paintDrag(Canvas canvas, Size size, Offset center) {
// 0 -> 0.35 : appui maintenu ; puis déplacement aller-retour du doigt.
final hold = Curves.easeOut.transform((t / 0.35).clamp(0.0, 1.0));
final travel =
t <= 0.35 ? 0.0 : _wave(((t - 0.35) / 0.65).clamp(0.0, 1.0));
final start = center - Offset(size.width * 0.16, 0);
final position = start + Offset(size.width * 0.32 * travel, 0);
canvas.drawLine(
start,
position,
Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.35),
);
if (hold < 1) {
canvas.drawArc(
Rect.fromCircle(center: start, radius: size.width * 0.16),
-math.pi / 2,
2 * math.pi * hold,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.9),
);
}
_paintFingertip(canvas, position, size.width * 0.06);
_paintHand(canvas, position, size.width * 0.58, press: 1);
}
void _paintPinch(Canvas canvas, Size size, Offset center) {
// Les deux doigts s'écartent (zoom avant) puis se rapprochent.
final spread = _wave(t);
final direction = Offset(math.cos(-math.pi / 4), math.sin(-math.pi / 4));
final distance = size.width * (0.12 + 0.24 * spread);
final first = center + direction * distance;
final second = center - direction * distance;
final radius = size.width * 0.075;
// Ligne pointillée reliant les deux doigts.
final dashPaint = Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.45);
const dashes = 9;
for (int i = 0; i < dashes; i++) {
if (i.isOdd) continue;
final a = Offset.lerp(second, first, i / dashes)!;
final b = Offset.lerp(second, first, (i + 1) / dashes)!;
canvas.drawLine(a, b, dashPaint);
}
// Chevrons indiquant le sens de l'écartement.
_paintChevron(canvas, first, direction, size.width * 0.06, spread);
_paintChevron(canvas, second, -direction, size.width * 0.06, spread);
_paintFingertip(canvas, first, radius);
_paintFingertip(canvas, second, radius);
}
void _paintChevron(
Canvas canvas,
Offset tip,
Offset direction,
double length,
double spread,
) {
final base = tip + direction * (length * 1.6);
final angle = math.atan2(direction.dy, direction.dx);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.6
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.25 + 0.6 * spread);
for (final sign in [-1, 1]) {
final branch = angle + sign * 2.5;
canvas.drawLine(
base,
base + Offset(math.cos(branch), math.sin(branch)) * length,
paint,
);
}
}
void _paintSwipe(
Canvas canvas,
Size size,
Offset center, {
required bool vertical,
}) {
final progress = Curves.easeInOut.transform((t / 0.75).clamp(0.0, 1.0));
final fade = t > 0.75 ? 1 - ((t - 0.75) / 0.25) : 1.0;
final axis = vertical ? const Offset(0, -1) : const Offset(1, 0);
final amplitude = size.width * 0.28;
final start = center - axis * amplitude;
final position = start + axis * (2 * amplitude * progress);
canvas.drawLine(
start,
position,
Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.32 * fade),
);
_paintFingertip(canvas, position, size.width * 0.06, opacity: fade);
_paintHand(canvas, position, size.width * 0.58, press: 1);
}
@override
bool shouldRepaint(_GesturePainter old) =>
old.t != t || old.gesture != gesture || old.color != color;
}
@@ -0,0 +1,536 @@
/// Voile du didacticiel : assombrit l'écran, découpe un « trou de lumière »
/// autour de l'élément à découvrir, y anime une main qui mime le geste attendu
/// et affiche une bulle explicative avec la progression.
///
/// L'overlay est purement visuel : il n'exécute pas l'action à la place de
/// l'utilisateur. On avance d'une étape en touchant l'écran ou le bouton
/// « Suivant » ; « Passer » interrompt la visite.
library;
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../tutorial_step.dart';
import 'tutorial_hand.dart';
class TutorialOverlay extends StatefulWidget {
final List<TutorialStep> steps;
/// Appelé à la fin de la visite (terminée ou passée).
final VoidCallback onFinished;
const TutorialOverlay({
super.key,
required this.steps,
required this.onFinished,
});
@override
State<TutorialOverlay> createState() => _TutorialOverlayState();
}
class _TutorialOverlayState extends State<TutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
int _index = 0;
Rect? _spotRect;
late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null;
TutorialStep get _step => widget.steps[_index];
bool get _isLast => _index == widget.steps.length - 1;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
/// Amène l'élément visé à l'écran puis mesure sa position.
Future<void> _prepareStep() async {
final targetContext = _step.targetKey?.currentContext;
if (targetContext == null || !targetContext.mounted) {
if (mounted) setState(() { _spotRect = null; _ready = true; });
return;
}
try {
await Scrollable.ensureVisible(
targetContext,
alignment: 0.35,
duration: const Duration(milliseconds: 320),
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.
await Future<void>.delayed(const Duration(milliseconds: 60));
if (!mounted) return;
setState(() {
_spotRect = _measure(targetContext, _step.spotPadding);
_ready = true;
});
}
Rect? _measure(BuildContext targetContext, double padding) {
if (!targetContext.mounted) return null;
final renderObject = targetContext.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
try {
final origin = renderObject.localToGlobal(Offset.zero);
final screen = MediaQuery.of(context).size;
return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height)
.inflate(padding)
.intersect(Offset.zero & screen);
} catch (_) {
return null;
}
}
void _next() {
if (_isLast) {
_finish();
return;
}
setState(() {
_index++;
_ready = widget.steps[_index].targetKey == null;
_spotRect = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
}
void _finish() => widget.onFinished();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
final spot = _spotRect;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _finish();
},
child: Material(
color: Colors.transparent,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _ready ? _next : null,
child: Stack(
children: [
// Voile sombre percé autour de l'élément mis en avant.
Positioned.fill(
child: AnimatedBuilder(
animation: _pulseController,
builder: (context, _) => CustomPaint(
painter: _SpotlightPainter(
spot: spot,
circle: _step.shape == TutorialHighlightShape.circle,
pulse: _pulseController.value,
glowColor: primary,
),
),
),
),
// Main animée posée sur l'élément mis en avant.
if (spot != null && _step.gesture != TutorialGesture.none)
_buildHand(spot, primary),
// Bulle explicative.
if (_ready) _buildTooltip(context, spot, primary),
],
),
),
),
);
}
Widget _buildHand(Rect spot, Color primary) {
final handSize =
(spot.shortestSide * 1.4).clamp(96.0, 190.0).toDouble();
// Sur une grande zone (image plein écran), la main reste au centre ;
// sur un bouton, elle se cale sur le centre du bouton.
final center = spot.center;
return Positioned(
left: center.dx - handSize / 2,
top: center.dy - handSize / 2,
child: TutorialHand(
gesture: _step.gesture,
color: primary,
size: handSize,
),
);
}
Widget _buildTooltip(BuildContext context, Rect? spot, Color primary) {
final media = MediaQuery.of(context);
final screenHeight = media.size.height;
final card = _TutorialCard(
step: _step,
index: _index,
total: widget.steps.length,
primary: primary,
isLast: _isLast,
onNext: _next,
onSkip: _finish,
);
if (spot == null) {
return Positioned.fill(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: card,
),
),
),
);
}
const gap = 22.0;
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
// pour laisser la main animée visible au centre.
if (spot.height > screenHeight * 0.55) {
final available = (screenHeight * 0.4).clamp(140.0, screenHeight);
return Positioned(
left: 16,
right: 16,
bottom: media.padding.bottom + 24,
child: SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: available),
child: Center(
child: card,
),
),
),
);
}
// Sinon, la bulle se place du côté le plus dégagé du spot.
final above =
_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(
left: 16,
right: 16,
top: above ? null : (spot.bottom + gap),
bottom: above ? (screenHeight - spot.top + gap) : null,
child: SafeArea(
top: !above,
bottom: above,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: available),
child: Center(
child: card,
),
),
),
);
}
}
/// Carte explicative d'une étape (titre, texte, progression, boutons).
class _TutorialCard extends StatelessWidget {
final TutorialStep step;
final int index;
final int total;
final Color primary;
final bool isLast;
final VoidCallback onNext;
final VoidCallback onSkip;
const _TutorialCard({
required this.step,
required this.index,
required this.total,
required this.primary,
required this.isLast,
required this.onNext,
required this.onSkip,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final surface = isDark ? const Color(0xFF121A26) : Colors.white;
final textPrimary =
isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary;
final textSecondary =
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
decoration: BoxDecoration(
color: surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: primary.withValues(alpha: 0.35), width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.6 : 0.25),
blurRadius: 28,
offset: const Offset(0, 10),
),
BoxShadow(
color: primary.withValues(alpha: 0.18),
blurRadius: 24,
spreadRadius: -6,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.22 : 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
step.icon ?? Icons.school_outlined,
color: primary,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
step.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: textPrimary,
letterSpacing: -0.2,
),
),
),
const SizedBox(width: 8),
Text(
'${index + 1}/$total',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: textSecondary,
),
),
],
),
const SizedBox(height: 10),
Text(
step.description,
style: TextStyle(
fontSize: 13.5,
height: 1.4,
color: textSecondary,
),
),
if (step.gesture != TutorialGesture.none) ...[
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.gesture, size: 16, color: primary),
const SizedBox(width: 6),
Expanded(
child: Text(
_gestureHint(step.gesture),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: primary,
),
),
),
],
),
],
const SizedBox(height: 12),
Row(
children: [
...List.generate(total, (i) {
final active = i == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 5),
width: active ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: active
? primary
: primary.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(3),
),
);
}),
const Spacer(),
TextButton(
onPressed: onSkip,
style: TextButton.styleFrom(foregroundColor: textSecondary),
child: const Text('Passer'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: onNext,
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
isLast ? 'C\'est parti' : 'Suivant',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
],
),
],
),
),
);
}
static String _gestureHint(TutorialGesture gesture) {
switch (gesture) {
case TutorialGesture.tap:
return 'Appuyez une fois';
case TutorialGesture.doubleTap:
return 'Appuyez deux fois';
case TutorialGesture.longPress:
return 'Appui long';
case TutorialGesture.drag:
return 'Appui long puis glisser';
case TutorialGesture.pinch:
return 'Pincez avec deux doigts pour zoomer';
case TutorialGesture.swipeUp:
return 'Balayez vers le haut';
case TutorialGesture.swipeHorizontal:
return 'Balayez latéralement';
case TutorialGesture.none:
return '';
}
}
}
/// Voile sombre percé d'un trou de lumière, avec anneau pulsant.
class _SpotlightPainter extends CustomPainter {
final Rect? spot;
final bool circle;
final double pulse;
final Color glowColor;
_SpotlightPainter({
required this.spot,
required this.circle,
required this.pulse,
required this.glowColor,
});
@override
void paint(Canvas canvas, Size size) {
final scrim = Paint()..color = Colors.black.withValues(alpha: 0.76);
final screenPath = Path()..addRect(Offset.zero & size);
final target = spot;
if (target == null || target.isEmpty) {
canvas.drawPath(screenPath, scrim);
return;
}
final holePath = circle
? (Path()
..addOval(Rect.fromCircle(
center: target.center,
radius: target.longestSide / 2,
)))
: (Path()
..addRRect(RRect.fromRectAndRadius(
target,
const Radius.circular(18),
)));
canvas.drawPath(
Path.combine(PathOperation.difference, screenPath, holePath),
scrim,
);
// Halo diffus autour du trou.
canvas.drawPath(
holePath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = glowColor.withValues(alpha: 0.28)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10),
);
// Contour net.
canvas.drawPath(
holePath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..color = glowColor.withValues(alpha: 0.95),
);
// Anneau qui s'écarte en boucle pour attirer l'œil.
final ringPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..color = glowColor.withValues(alpha: 0.5 * (1 - pulse));
final expansion = target.shortestSide * 0.10 * pulse;
if (circle) {
canvas.drawCircle(
target.center,
target.longestSide / 2 + expansion,
ringPaint,
);
} else {
canvas.drawRRect(
RRect.fromRectAndRadius(
target.inflate(expansion),
Radius.circular(18 + expansion),
),
ringPaint,
);
}
}
@override
bool shouldRepaint(_SpotlightPainter old) =>
old.spot != spot ||
old.pulse != pulse ||
old.circle != circle ||
old.glowColor != glowColor;
}
+2
View File
@@ -10,6 +10,7 @@ import 'data/repositories/session_repository.dart';
import 'services/score_calculator_service.dart'; import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart'; import 'services/grouping_analyzer_service.dart';
import 'features/session/session_provider.dart'; import 'features/session/session_provider.dart';
import 'features/tutorial/tutorial_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -39,6 +40,7 @@ void main() async {
Provider<SessionRepository>(create: (_) => SessionRepository()), Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()), ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()), ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
ChangeNotifierProvider<TutorialProvider>(create: (_) => TutorialProvider()),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),
+4
View File
@@ -16,6 +16,9 @@ const int mainTabGarage = 3;
final GlobalKey<State<MainNavigationHolder>> mainNavKey = final GlobalKey<State<MainNavigationHolder>> mainNavKey =
GlobalKey<State<MainNavigationHolder>>(); GlobalKey<State<MainNavigationHolder>>();
/// Clé du dock flottant : permet au didacticiel de le mettre en avant.
final GlobalKey navigationDockKey = GlobalKey();
/// Ouvre l'onglet [index] de la navigation principale. /// Ouvre l'onglet [index] de la navigation principale.
void openMainTab(int index) { void openMainTab(int index) {
final state = mainNavKey.currentState; final state = mainNavKey.currentState;
@@ -80,6 +83,7 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
return SafeArea( return SafeArea(
child: Container( child: Container(
key: navigationDockKey,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
height: 68, height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
+6
View File
@@ -108,6 +108,7 @@ class AiExportService {
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25, int distanceMeters = 25,
String caliber = 'unknown', String caliber = 'unknown',
int? expectedShots,
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
@@ -172,6 +173,11 @@ class AiExportService {
"type": targetType.name, "type": targetType.name,
"distance_meters": distanceMeters, "distance_meters": distanceMeters,
"caliber": caliber, "caliber": caliber,
// Nombre de coups prevus pour cette cible, null hors session.
// Un ecart avec le nombre d'impacts ne signifie pas que le
// marquage est faux (un coup peut etre parti hors papier) : c'est
// au tri du dataset d'en decider, pas au client.
"expected_shots": expectedShots,
}, },
"plotting": { "plotting": {
"target_corners": corners, "target_corners": corners,
+101 -13
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:sensors_plus/sensors_plus.dart'; import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel. /// Statut de parallélisme retourné en temps réel.
@@ -14,31 +15,67 @@ enum ParallelismStatus {
misaligned, misaligned,
} }
/// Pose de prise de vue déduite de l'inclinaison de l'appareil.
enum TargetPose {
/// Cible accrochée verticalement : l'appareil est tenu droit.
wall,
/// Cible posée au sol : l'appareil est à plat, caméra vers le bas.
ground,
}
/// Données de parallélisme calculées à chaque frame capteur. /// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData { class ParallelismData {
final ParallelismStatus status; final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical). /// Pose détectée automatiquement, qui sert de référence aux écarts.
final TargetPose pose;
/// Inclinaison avant/arrière brute en degrés (0° = appareil vertical,
/// +90° = appareil à plat, caméra vers le sol).
final double pitchDegrees; final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit). /// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees; final double rollDegrees;
/// Écart de tangage par rapport à la pose détectée.
///
/// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose
/// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat.
/// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90°
/// alors que le cadrage est parfait.
final double pitchDeviation;
const ParallelismData({ const ParallelismData({
required this.status, required this.status,
required this.pose,
required this.pitchDegrees, required this.pitchDegrees,
required this.rollDegrees, required this.rollDegrees,
required this.pitchDeviation,
}); });
bool get isAligned => status == ParallelismStatus.aligned; bool get isAligned => status == ParallelismStatus.aligned;
/// Écart latéral. Le roulis se mesure de la même façon dans les deux poses.
double get rollDeviation => rollDegrees;
@override @override
String toString() => String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; 'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, '
'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
} }
/// Service de détection du parallélisme par accéléromètre. /// Service de détection du parallélisme par accéléromètre.
/// ///
/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le
/// plus proche est retenue automatiquement :
///
/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°)
/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°)
///
/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le
/// haut, la caméra vise le plafond et le vert n'aurait aucun sens.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// Implémente une hystérésis à deux seuils pour éviter le clignotement :
/// ///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold] /// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
@@ -58,6 +95,13 @@ class ParallelismService {
/// Doit être > alignThreshold pour créer la zone d'hystérésis. /// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold; final double misalignThreshold;
/// Écart minimum en faveur de l'autre pose pour basculer.
///
/// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° —
/// très loin des deux zones vertes. Cette marge évite seulement que
/// l'étiquette de pose clignote pile à la frontière.
static const double poseSwitchMargin = 5.0;
StreamSubscription<AccelerometerEvent>? _subscription; StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller = final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast(); StreamController<ParallelismData>.broadcast();
@@ -65,6 +109,9 @@ class ParallelismService {
/// État interne mémorisé entre deux frames (cœur de l'hystérésis). /// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown; ParallelismStatus _currentStatus = ParallelismStatus.unknown;
/// Pose retenue à la frame précédente, pour l'hystérésis de pose.
TargetPose? _currentPose;
ParallelismService({ ParallelismService({
this.alignThreshold = 25.0, this.alignThreshold = 25.0,
this.misalignThreshold = 32.0, this.misalignThreshold = 32.0,
@@ -86,10 +133,13 @@ class ParallelismService {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) { if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
_controller.add(const ParallelismData( _controller.add(const ParallelismData(
status: ParallelismStatus.unknown, status: ParallelismStatus.unknown,
pose: TargetPose.wall,
pitchDegrees: 0, pitchDegrees: 0,
rollDegrees: 0, rollDegrees: 0,
pitchDeviation: 0,
)); ));
} }
}, },
@@ -100,6 +150,7 @@ class ParallelismService {
_subscription?.cancel(); _subscription?.cancel();
_subscription = null; _subscription = null;
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
} }
void dispose() { void dispose() {
@@ -110,12 +161,18 @@ class ParallelismService {
void _onAccelerometerEvent(AccelerometerEvent event) { void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return; if (_controller.isClosed) return;
final double gx = event.x; final data = evaluate(event.x, event.y, event.z);
final double gy = event.y; if (data != null) _controller.add(data);
final double gz = event.z; }
/// Calcule la pose et le statut depuis une mesure d'accéléromètre.
///
/// Met à jour l'état d'hystérésis, donc l'ordre des appels compte.
/// Retourne null sur une mesure aberrante, qu'il faut alors ignorer.
@visibleForTesting
ParallelismData? evaluate(double gx, double gy, double gz) {
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes if (magnitude < 1.0) return null; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact) // Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude; final double nx = gx / magnitude;
@@ -125,10 +182,39 @@ class ParallelismService {
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran. // ── Choix de la pose ────────────────────────────────────────────────────
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement // Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée.
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert. final double wallPitchDeviation = pitchDeg;
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs());
// Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers
// le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne
// peut alors jamais gagner, ce qui est exactement le comportement voulu.
final double groundPitchDeviation = pitchDeg - 90.0;
final double groundWorst =
math.max(groundPitchDeviation.abs(), rollDeg.abs());
if (_currentPose == null) {
_currentPose =
groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall;
} else if (_currentPose == TargetPose.wall) {
if (groundWorst + poseSwitchMargin < wallWorst) {
_currentPose = TargetPose.ground;
}
} else {
if (wallWorst + poseSwitchMargin < groundWorst) {
_currentPose = TargetPose.wall;
}
}
final bool isGround = _currentPose == TargetPose.ground;
final double pitchDeviation =
isGround ? groundPitchDeviation : wallPitchDeviation;
// Le critère de couleur = le PIRE des deux écarts affichés à l'écran.
// Ainsi ce que voit l'utilisateur correspond exactement à la décision
// vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = isGround ? groundWorst : wallWorst;
// ── Hystérésis ────────────────────────────────────────────────────────── // ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement // Premier appel : on décide selon alignThreshold uniquement
@@ -152,10 +238,12 @@ class ParallelismService {
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData( return ParallelismData(
status: _currentStatus, status: _currentStatus,
pose: _currentPose!,
pitchDegrees: pitchDeg, pitchDegrees: pitchDeg,
rollDegrees: rollDeg, rollDegrees: rollDeg,
)); pitchDeviation: pitchDeviation,
);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
/// Persistance du didacticiel : mémorise les visites guidées déjà vues.
///
/// Chaque visite guidée (« tour ») possède un identifiant stocké dans les
/// SharedPreferences sous la forme `tutorial_done_<id>`. Réinitialiser le
/// didacticiel depuis les paramètres efface simplement ces clés.
library;
import 'package:shared_preferences/shared_preferences.dart';
/// Identifiants des visites guidées de l'application.
class TutorialTours {
const TutorialTours._();
/// Visite d'accueil : présentation générale et navigation.
static const String home = 'home';
/// Visite de l'éditeur d'impacts : tap, appui long et pincement pour zoomer.
static const String impactEditor = 'impact_editor';
/// Toutes les visites connues, dans l'ordre logique de découverte.
static const List<String> all = [home, impactEditor];
}
class TutorialService {
static const String _prefix = 'tutorial_done_';
Future<Set<String>> loadCompletedTours() async {
final prefs = await SharedPreferences.getInstance();
return TutorialTours.all
.where((id) => prefs.getBool('$_prefix$id') ?? false)
.toSet();
}
Future<void> markCompleted(String tourId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('$_prefix$tourId', true);
}
/// Efface la mémoire du didacticiel : toutes les visites seront rejouées.
Future<void> resetAll() async {
final prefs = await SharedPreferences.getInstance();
for (final id in TutorialTours.all) {
await prefs.remove('$_prefix$id');
}
}
}
+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);
});
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/features/tutorial/tutorial_step.dart';
import 'package:bully/features/tutorial/widgets/tutorial_hand.dart';
import 'package:bully/features/tutorial/widgets/tutorial_overlay.dart';
/// Monte l'overlay au-dessus d'un faux écran contenant l'élément visé.
Future<GlobalKey> pumpOverlay(
WidgetTester tester, {
required List<TutorialStep> Function(GlobalKey targetKey) steps,
required VoidCallback onFinished,
}) async {
final targetKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: [
Center(
child: SizedBox(key: targetKey, width: 160, height: 48),
),
TutorialOverlay(steps: steps(targetKey), onFinished: onFinished),
],
),
),
),
);
// Laisse le temps à la mesure du spot (défilement + délai de stabilisation).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
return targetKey;
}
void main() {
testWidgets('la visite avance d\'une étape à l\'autre puis se termine',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Démarrez une session',
description: 'Tout part d\'ici.',
gesture: TutorialGesture.tap,
),
TutorialStep(
targetKey: targetKey,
title: 'Zoomer sur la cible',
description: 'Écartez deux doigts.',
gesture: TutorialGesture.pinch,
),
],
);
expect(find.text('Démarrez une session'), findsOneWidget);
expect(find.text('1/2'), findsOneWidget);
// La main animée mime le geste attendu, posée sur l'élément mis en avant.
expect(find.byType(TutorialHand), findsOneWidget);
expect(find.text('Appuyez une fois'), findsOneWidget);
await tester.tap(find.text('Suivant'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Zoomer sur la cible'), findsOneWidget);
expect(find.text('2/2'), findsOneWidget);
expect(find.text('Pincez avec deux doigts pour zoomer'), findsOneWidget);
expect(finished, isFalse);
await tester.tap(find.text('C\'est parti'));
await tester.pump();
expect(finished, isTrue);
});
testWidgets('« Passer » interrompt la visite immédiatement',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Première étape',
description: 'Description.',
),
const TutorialStep(
title: 'Seconde étape',
description: 'Description.',
),
],
);
await tester.tap(find.text('Passer'));
await tester.pump();
expect(finished, isTrue);
expect(find.text('Seconde étape'), findsNothing);
});
testWidgets('une étape sans cible s\'affiche comme carte centrée',
(WidgetTester tester) async {
await pumpOverlay(
tester,
onFinished: () {},
steps: (_) => [
const TutorialStep(
title: 'Bienvenue dans Bully',
description: 'Ce guide rapide vous montre l\'essentiel.',
),
],
);
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
expect(find.text('C\'est parti'), findsOneWidget);
// Aucune cible : pas de main animée non plus.
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);
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/services/parallelism_service.dart';
/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée.
///
/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0°
/// appareil vertical et +90° appareil à plat caméra vers le sol.
({double x, double y, double z}) gravity({
double pitch = 0,
double roll = 0,
}) {
const double g = 9.81;
final double p = pitch * math.pi / 180.0;
final double r = roll * math.pi / 180.0;
final double nz = math.sin(p);
final double nx = math.sin(r);
// Ce qui reste va sur y, l'axe vertical de l'écran.
final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx));
return (x: nx * g, y: ny * g, z: nz * g);
}
ParallelismData evaluatePose(
ParallelismService service, {
double pitch = 0,
double roll = 0,
}) {
final v = gravity(pitch: pitch, roll: roll);
final data = service.evaluate(v.x, v.y, v.z);
expect(data, isNotNull, reason: 'mesure jugée aberrante à tort');
return data!;
}
void main() {
group('pose cible au mur (comportement existant)', () {
test('appareil vertical → aligné', () {
final data = evaluatePose(ParallelismService());
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart de tangage reste le tangage brut', () {
final data = evaluatePose(ParallelismService(), pitch: 10);
expect(data.pose, TargetPose.wall);
expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001));
});
test('inclinaison au-delà du seuil → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 40);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
test('roulis excessif → désaligné même si le tangage est bon', () {
final data = evaluatePose(ParallelismService(), roll: 35);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('pose cible au sol', () {
test('appareil à plat caméra vers le bas → aligné', () {
final data = evaluatePose(ParallelismService(), pitch: 90);
expect(data.pose, TargetPose.ground);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart se mesure par rapport à +90°, pas à 0°', () {
final data = evaluatePose(ParallelismService(), pitch: 80);
expect(data.pose, TargetPose.ground);
expect(data.pitchDeviation, closeTo(-10, 0.5));
expect(data.status, ParallelismStatus.aligned);
});
test('à plat mais trop incliné → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 50);
expect(data.status, ParallelismStatus.misaligned);
});
test('à plat écran vers le haut → jamais aligné', () {
// La caméra vise le plafond : le vert n'aurait aucun sens.
final data = evaluatePose(ParallelismService(), pitch: -90);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('hystérésis', () {
test('le vert survit à un léger tremblement entre les deux seuils', () {
final service = ParallelismService();
expect(evaluatePose(service).status, ParallelismStatus.aligned);
// 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°).
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned);
expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned);
// Repasser sous 32° ne suffit pas : il faut redescendre sous 25°.
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned);
expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned);
});
test('la pose ne bascule pas pour un écart négligeable', () {
final service = ParallelismService();
expect(evaluatePose(service).pose, TargetPose.wall);
// Pile à la frontière des deux poses : on garde celle déjà retenue.
expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall);
// Franchement du côté du sol : on bascule.
expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground);
});
test('stop() remet la pose et le statut à zéro', () {
final service = ParallelismService();
expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground);
service.stop();
expect(evaluatePose(service).pose, TargetPose.wall);
});
});
test('une mesure aberrante est ignorée', () {
expect(ParallelismService().evaluate(0, 0, 0), isNull);
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:bully/features/tutorial/tutorial_provider.dart';
import 'package:bully/services/tutorial_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
group('TutorialService', () {
test('aucune visite n\'est marquée vue à la première utilisation',
() async {
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('mémorise puis réinitialise les visites vues', () async {
final service = TutorialService();
await service.markCompleted(TutorialTours.home);
expect(await service.loadCompletedTours(), {TutorialTours.home});
await service.markCompleted(TutorialTours.impactEditor);
expect(
await service.loadCompletedTours(),
{TutorialTours.home, TutorialTours.impactEditor},
);
await service.resetAll();
expect(await service.loadCompletedTours(), isEmpty);
});
});
group('TutorialProvider', () {
test('joue chaque visite une seule fois', () async {
final provider = TutorialProvider();
await provider.load();
expect(provider.isLoaded, isTrue);
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.hasSeenIntro, isFalse);
await provider.complete(TutorialTours.home);
expect(provider.shouldRun(TutorialTours.home), isFalse);
expect(provider.hasSeenIntro, isTrue);
// Les autres visites restent disponibles.
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
});
test('la réactivation depuis les paramètres rejoue toutes les visites',
() async {
final provider = TutorialProvider();
await provider.load();
await provider.complete(TutorialTours.home);
await provider.complete(TutorialTours.impactEditor);
await provider.restart();
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
// La remise à zéro est bien persistée.
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('l\'état vu survit à un redémarrage de l\'application', () async {
final first = TutorialProvider();
await first.load();
await first.complete(TutorialTours.home);
final second = TutorialProvider();
await second.load();
expect(second.shouldRun(TutorialTours.home), isFalse);
expect(second.shouldRun(TutorialTours.impactEditor), isTrue);
});
});
}