import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../colors/app_colors.dart'; import '../colors/app_gradients.dart'; import '../strings/onboarding_strings.dart'; import 'tap_bounce.dart'; /// Um passo do tutorial guiado: aponta para o widget marcado com [targetKey] /// e mostra um balão com [title]/[description] ao lado dele. class CoachMarkStep { const CoachMarkStep({ required this.targetKey, required this.title, required this.description, this.borderRadius = 18, this.padding = 10, }); final GlobalKey targetKey; final String title; final String description; final double borderRadius; final double padding; } /// Mostra um tutorial guiado (spotlight + balão) sobre os widgets marcados /// pelos [CoachMarkStep.targetKey] de [steps], um de cada vez. Se o widget- /// alvo estiver dentro de um [Scrollable], este é rolado automaticamente até /// o alvo ficar visível antes de o destacar. Devolve quando o tour termina /// (concluído ou saltado pelo utilizador). Future showCoachMarkTour(BuildContext context, List steps) { if (steps.isEmpty) return Future.value(); final completer = Completer(); late final OverlayEntry entry; entry = OverlayEntry( builder: (context) => _CoachMarkOverlay( steps: steps, onFinished: () { entry.remove(); if (!completer.isCompleted) completer.complete(); }, ), ); Overlay.of(context, rootOverlay: true).insert(entry); return completer.future; } class _CoachMarkOverlay extends StatefulWidget { const _CoachMarkOverlay({required this.steps, required this.onFinished}); final List steps; final VoidCallback onFinished; @override State<_CoachMarkOverlay> createState() => _CoachMarkOverlayState(); } class _CoachMarkOverlayState extends State<_CoachMarkOverlay> with TickerProviderStateMixin { int _index = 0; Rect? _previousRect; Rect? _targetRect; late final AnimationController _moveController = AnimationController( vsync: this, duration: const Duration(milliseconds: 420), ); late final CurvedAnimation _moveCurve = CurvedAnimation( parent: _moveController, curve: Curves.easeInOutCubic, ); // Respiração suave do contorno do spotlight, para chamar a atenção sem // ser distrativa — o mesmo tipo de animação usada nos cards de destaque // da Home (ver `_Pulse` em logged_home.dart). late final AnimationController _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), )..repeat(reverse: true); late final Animation _pulse = Tween(begin: 0.55, end: 1.0) .animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut)); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); } @override void dispose() { _moveController.dispose(); _pulseController.dispose(); super.dispose(); } Future _measure() async { final step = widget.steps[_index]; final targetContext = step.targetKey.currentContext; if (targetContext == null) { _next(); return; } final scrollable = Scrollable.maybeOf(targetContext); if (scrollable != null) { try { await Scrollable.ensureVisible( targetContext, duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, alignment: 0.5, ); } catch (_) { // Alvo já pode ter sido desmontado durante a animação de scroll. } } if (!mounted) return; final renderObject = step.targetKey.currentContext?.findRenderObject(); if (renderObject is RenderBox && renderObject.attached && renderObject.hasSize) { final topLeft = renderObject.localToGlobal(Offset.zero); final rect = (topLeft & renderObject.size).inflate(step.padding); setState(() { _previousRect = _targetRect; _targetRect = rect; }); _moveController.forward(from: 0); } else { _next(); } } void _goTo(int newIndex) { if (newIndex >= widget.steps.length) { widget.onFinished(); return; } setState(() => _index = newIndex); WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); } void _next() => _goTo(_index + 1); @override Widget build(BuildContext context) { final step = widget.steps[_index]; final size = MediaQuery.sizeOf(context); return Material( type: MaterialType.transparency, child: AnimatedBuilder( animation: Listenable.merge([_moveCurve, _pulse]), builder: (context, _) { // Antes da primeira medição não há alvo — o balão "nasce" a partir // do centro do próprio alvo, em vez de aparecer instantaneamente. final target = _targetRect; final rect = target == null ? null : Rect.lerp( _previousRect ?? Rect.fromCenter(center: target.center, width: 0, height: 0), target, _moveCurve.value, ); return Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _next, child: AnimatedOpacity( duration: const Duration(milliseconds: 250), opacity: rect == null ? 0 : 1, child: rect == null ? const SizedBox.expand() : CustomPaint( painter: _SpotlightPainter( rect: rect, borderRadius: step.borderRadius, borderOpacity: _pulse.value, ), ), ), ), ), if (rect != null) Builder( builder: (context) { // O Positioned tem de ser filho direto deste Stack — por // isso fica aqui fora, e só o conteúdo do balão (que // muda de passo para passo) é que vai dentro do // AnimatedSwitcher, para o cross-fade entre passos. final position = _tooltipPosition( rect: rect, screenSize: size, mediaPadding: MediaQuery.paddingOf(context), ); return Positioned( left: position.left, top: position.top, width: _CoachMarkTooltip._cardWidth, child: AnimatedSwitcher( duration: const Duration(milliseconds: 220), child: _CoachMarkTooltip( key: ValueKey(_index), title: step.title, description: step.description, stepIndex: _index, stepCount: widget.steps.length, onNext: _next, onSkip: widget.onFinished, ), ), ); }, ), ], ); }, ), ); } } /// Escurece o ecrã todo exceto um recorte arredondado à volta de [rect], com /// um contorno rosa a marcar o alvo. class _SpotlightPainter extends CustomPainter { const _SpotlightPainter({ required this.rect, required this.borderRadius, required this.borderOpacity, }); final Rect rect; final double borderRadius; final double borderOpacity; @override void paint(Canvas canvas, Size size) { final overlayPath = Path() ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); final holeRRect = RRect.fromRectAndRadius( rect, Radius.circular(borderRadius), ); final holePath = Path()..addRRect(holeRRect); final combined = Path.combine( PathOperation.difference, overlayPath, holePath, ); canvas.drawPath(combined, Paint()..color = Colors.black.withValues(alpha: 0.72)); canvas.drawRRect( holeRRect, Paint() ..color = AppColors.pink.withValues(alpha: borderOpacity) ..style = PaintingStyle.stroke ..strokeWidth = 3, ); } @override bool shouldRepaint(covariant _SpotlightPainter oldDelegate) { return oldDelegate.rect != rect || oldDelegate.borderRadius != borderRadius || oldDelegate.borderOpacity != borderOpacity; } } /// Posição (canto superior-esquerdo) do balão do tutorial para destacar /// [rect] no ecrã de tamanho [screenSize] — do lado com mais espaço à volta /// do centro do alvo, sempre dentro dos limites do ecrã (nunca sobrepõe o /// alvo nem sai para fora, mesmo perto do topo/fundo). ({double left, double top}) _tooltipPosition({ required Rect rect, required Size screenSize, required EdgeInsets mediaPadding, }) { const cardWidth = _CoachMarkTooltip._cardWidth; // Estimativa da altura do balão (título + descrição + botão) — usada só // para decidir de que lado colocá-lo e para o manter dentro do ecrã, já // que medir a altura real exigiria um segundo passo de layout. const estimatedHeight = 230.0; const gap = 16.0; final placeBelow = rect.center.dy <= screenSize.height / 2; final left = (rect.center.dx - cardWidth / 2).clamp( 16.0, screenSize.width - cardWidth - 16, ); final minTop = mediaPadding.top + 8; final maxTop = screenSize.height - mediaPadding.bottom - estimatedHeight - 8; final desiredTop = placeBelow ? rect.bottom + gap : rect.top - gap - estimatedHeight; final top = desiredTop.clamp(minTop, math.max(minTop, maxTop)); return (left: left.toDouble(), top: top.toDouble()); } /// Balão de texto do passo atual (só o conteúdo — quem o posiciona no ecrã /// é [_tooltipPosition], via o [Positioned] em [_CoachMarkOverlayState]). class _CoachMarkTooltip extends StatelessWidget { const _CoachMarkTooltip({ super.key, required this.title, required this.description, required this.stepIndex, required this.stepCount, required this.onNext, required this.onSkip, }); final String title; final String description; final int stepIndex; final int stepCount; final VoidCallback onNext; final VoidCallback onSkip; static const double _cardWidth = 300; @override Widget build(BuildContext context) { final isLast = stepIndex == stepCount - 1; return Material( color: Colors.transparent, child: Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.28), blurRadius: 24, offset: const Offset(0, 10), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${stepIndex + 1}/$stepCount', style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 12, color: AppColors.teal, ), ), TapBounce( child: InkWell( borderRadius: BorderRadius.circular(999), onTap: onSkip, child: const Padding( padding: EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Text( OnboardingStrings.skip, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 12, color: Colors.black45, ), ), ), ), ), ], ), const SizedBox(height: 8), Text( title, style: const TextStyle( fontWeight: FontWeight.w900, fontSize: 16, color: AppColors.pink, ), ), const SizedBox(height: 6), Text( description, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Colors.black.withValues(alpha: 0.7), height: 1.35, ), ), const SizedBox(height: 14), TapBounce( child: ClipRRect( borderRadius: BorderRadius.circular(999), child: DecoratedBox( decoration: const BoxDecoration(gradient: kGreenButtonGradient), child: FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.transparent, foregroundColor: Colors.white, shape: const StadiumBorder(), textStyle: const TextStyle(fontWeight: FontWeight.w800), ), onPressed: onNext, child: Text( isLast ? OnboardingStrings.finish : OnboardingStrings.next, ), ), ), ), ), ], ), ), ); } }