Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b789df8dac | ||
|
|
25caf6ddf8 | ||
|
|
a9651588bb | ||
|
|
32582aba3d | ||
|
|
32143c5bb1 |
@@ -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…)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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});
|
||||||
@@ -708,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;
|
||||||
@@ -997,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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user