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