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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32143c5bb1
commit
32582aba3d
@@ -0,0 +1,504 @@
|
||||
/// 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;
|
||||
bool _ready = false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await Scrollable.ensureVisible(
|
||||
targetContext,
|
||||
alignment: 0.35,
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
// 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) {
|
||||
final box = targetContext.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
final origin = box.localToGlobal(Offset.zero);
|
||||
final screen = MediaQuery.of(context).size;
|
||||
return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height)
|
||||
.inflate(padding)
|
||||
.intersect(Offset.zero & screen);
|
||||
}
|
||||
|
||||
void _next() {
|
||||
if (_isLast) {
|
||||
_finish();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_index++;
|
||||
_ready = false;
|
||||
_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 Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
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) {
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: media.padding.bottom + 24,
|
||||
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);
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: above ? null : spot.bottom + gap,
|
||||
bottom: above ? (screenHeight - spot.top + gap) : null,
|
||||
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 Center(
|
||||
child: 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.start,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: [
|
||||
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),
|
||||
ElevatedButton(
|
||||
onPressed: onNext,
|
||||
style: ElevatedButton.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;
|
||||
}
|
||||
Reference in New Issue
Block a user