428 lines
14 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|