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>
380 lines
11 KiB
Dart
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;
|
|
}
|