CTK 1.2.1
This commit is contained in:
427
lib/widgets/coach_mark.dart
Normal file
427
lib/widgets/coach_mark.dart
Normal file
@@ -0,0 +1,427 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user