Files
qlionbleusamandClaude Opus 5 32582aba3d feat(tutorial): guide the first launch with spotlighted steps and gesture hands
Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un
trou de lumière autour de l'élément à découvrir, une main animée mime le geste
attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une
bulle explique l'étape avec sa progression.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:46:02 +02:00

380 lines
11 KiB
Dart

/// 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;
}