Files
CheckTheethKids/lib/widgets/coach_mark.dart
Carlos Correia 887c62c379 CTK 1.2.1
2026-07-30 01:25:31 +01:00

428 lines
14 KiB
Dart

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<void> showCoachMarkTour(BuildContext context, List<CoachMarkStep> steps) {
if (steps.isEmpty) return Future<void>.value();
final completer = Completer<void>();
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<CoachMarkStep> 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<double> _pulse = Tween<double>(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<void> _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,
),
),
),
),
),
],
),
),
);
}
}