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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user