/// 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 createState() => _TutorialHandState(); } class _TutorialHandState extends State 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; }