CTK 1.2.1
This commit is contained in:
@@ -16,6 +16,7 @@ import 'consultorios/clinic_favorites_prefs.dart';
|
||||
import 'consultorios/consultorios_screen.dart';
|
||||
import 'consultorios/overpass_service.dart';
|
||||
import 'main.dart' show supabase;
|
||||
import 'onboarding_prefs.dart';
|
||||
import 'quiz/quiz1.dart';
|
||||
import 'quiz/quiz_prefs.dart';
|
||||
import 'quiz/quiz_progress_prefs.dart';
|
||||
@@ -29,6 +30,8 @@ import 'colors/app_gradients.dart';
|
||||
import 'strings/address_strings.dart';
|
||||
import 'strings/consultorios_strings.dart';
|
||||
import 'strings/home_strings.dart';
|
||||
import 'strings/onboarding_strings.dart';
|
||||
import 'widgets/coach_mark.dart';
|
||||
import 'widgets/entrance.dart';
|
||||
import 'widgets/liquid_waves_background.dart';
|
||||
import 'widgets/name_input_formatter.dart';
|
||||
@@ -97,6 +100,15 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
String? _clinicsError;
|
||||
Set<String> _favoriteClinicIds = {};
|
||||
|
||||
// Âncoras do tutorial guiado (ver [_startOnboardingTutorial]) — cada uma
|
||||
// marca o widget real que o passo correspondente do tour vai destacar.
|
||||
final GlobalKey _gaugesKey = GlobalKey();
|
||||
final GlobalKey _quizCardKey = GlobalKey();
|
||||
final GlobalKey _videoCardKey = GlobalKey();
|
||||
final GlobalKey _navConsultoriosKey = GlobalKey();
|
||||
final GlobalKey _navPerfilKey = GlobalKey();
|
||||
final GlobalKey _navAjustesKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -104,8 +116,80 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
_loadInitialProfile();
|
||||
refreshStats();
|
||||
_loadFavoriteClinics();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
await _maybeShowOnboardingTutorial();
|
||||
if (!mounted) return;
|
||||
await _maybeStartPendingQuiz();
|
||||
});
|
||||
}
|
||||
|
||||
/// Mostra o tutorial guiado automaticamente na primeira vez que o
|
||||
/// utilizador entra na app — depois disso só reaparece se pedido
|
||||
/// manualmente em Ajustes (ver [replayOnboardingTutorial]).
|
||||
Future<void> _maybeShowOnboardingTutorial() async {
|
||||
final seen = await OnboardingPrefs.hasSeenTutorial();
|
||||
if (seen || !mounted) return;
|
||||
await _startOnboardingTutorial();
|
||||
await OnboardingPrefs.markTutorialSeen();
|
||||
}
|
||||
|
||||
/// Corre o tour de novo a pedido do utilizador (botão "Rever tutorial" em
|
||||
/// Ajustes) — muda para a aba Início primeiro, já que é onde vivem todos
|
||||
/// os alvos do tour.
|
||||
void replayOnboardingTutorial() {
|
||||
setState(() => _index = 0);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _maybeStartPendingQuiz();
|
||||
if (mounted) _startOnboardingTutorial();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startOnboardingTutorial() {
|
||||
// Pequena pausa para deixar a animação de entrada dos cards da Home
|
||||
// (FadeSlideIn) terminar antes de medir as suas posições.
|
||||
return Future<void>.delayed(const Duration(milliseconds: 450), () {
|
||||
if (!mounted) return Future<void>.value();
|
||||
return showCoachMarkTour(context, [
|
||||
CoachMarkStep(
|
||||
targetKey: _gaugesKey,
|
||||
title: OnboardingStrings.gaugesTitle,
|
||||
description: OnboardingStrings.gaugesDescription,
|
||||
borderRadius: 20,
|
||||
),
|
||||
CoachMarkStep(
|
||||
targetKey: _quizCardKey,
|
||||
title: OnboardingStrings.quizTitle,
|
||||
description: OnboardingStrings.quizDescription,
|
||||
borderRadius: 28,
|
||||
),
|
||||
CoachMarkStep(
|
||||
targetKey: _videoCardKey,
|
||||
title: OnboardingStrings.videosTitle,
|
||||
description: OnboardingStrings.videosDescription,
|
||||
borderRadius: 28,
|
||||
),
|
||||
CoachMarkStep(
|
||||
targetKey: _navConsultoriosKey,
|
||||
title: OnboardingStrings.clinicsTitle,
|
||||
description: OnboardingStrings.clinicsDescription,
|
||||
borderRadius: 40,
|
||||
padding: 14,
|
||||
),
|
||||
CoachMarkStep(
|
||||
targetKey: _navPerfilKey,
|
||||
title: OnboardingStrings.profileTitle,
|
||||
description: OnboardingStrings.profileDescription,
|
||||
borderRadius: 40,
|
||||
padding: 14,
|
||||
),
|
||||
CoachMarkStep(
|
||||
targetKey: _navAjustesKey,
|
||||
title: OnboardingStrings.settingsTitle,
|
||||
description: OnboardingStrings.settingsDescription,
|
||||
borderRadius: 40,
|
||||
padding: 14,
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,6 +467,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: AnimatedNavIcon(
|
||||
key: _navConsultoriosKey,
|
||||
icon: Icons.medical_services_rounded,
|
||||
selected: _index == 1,
|
||||
),
|
||||
@@ -390,6 +475,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: AnimatedNavIcon(
|
||||
key: _navPerfilKey,
|
||||
icon: Icons.person_rounded,
|
||||
selected: _index == 2,
|
||||
),
|
||||
@@ -397,6 +483,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: AnimatedNavIcon(
|
||||
key: _navAjustesKey,
|
||||
icon: Icons.settings_rounded,
|
||||
selected: _index == 3,
|
||||
),
|
||||
@@ -484,6 +571,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
top: kToolbarHeight + 96,
|
||||
child: Center(
|
||||
child: Row(
|
||||
key: _gaugesKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RiskArcGauge(
|
||||
@@ -679,7 +767,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
refreshStats();
|
||||
},
|
||||
),
|
||||
_ => const SettingsBody(),
|
||||
_ => SettingsBody(onReplayTutorial: replayOnboardingTutorial),
|
||||
},
|
||||
10,
|
||||
),
|
||||
@@ -963,6 +1051,7 @@ class _InicioTab extends StatelessWidget {
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _HeroQuizCard(
|
||||
key: state?._quizCardKey,
|
||||
onStartQuiz: () => _startQuiz(context),
|
||||
),
|
||||
),
|
||||
@@ -978,6 +1067,7 @@ class _InicioTab extends StatelessWidget {
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _VideoLibraryCard(
|
||||
key: state?._videoCardKey,
|
||||
watchedCount: state?._watchedVideoCount ?? 0,
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
@@ -1270,7 +1360,7 @@ Future<Map<String, dynamic>?> _pickChildSheet(
|
||||
}
|
||||
|
||||
class _HeroQuizCard extends StatelessWidget {
|
||||
const _HeroQuizCard({required this.onStartQuiz});
|
||||
const _HeroQuizCard({super.key, required this.onStartQuiz});
|
||||
|
||||
final VoidCallback onStartQuiz;
|
||||
|
||||
@@ -1598,7 +1688,11 @@ class _ClinicsPreview extends StatelessWidget {
|
||||
/// só ícone e texto. Um único toque (no card ou no botão) leva direto à
|
||||
/// grelha onde se escolhe qual episódio assistir.
|
||||
class _VideoLibraryCard extends StatelessWidget {
|
||||
const _VideoLibraryCard({required this.watchedCount, required this.onTap});
|
||||
const _VideoLibraryCard({
|
||||
super.key,
|
||||
required this.watchedCount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int watchedCount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
20
lib/onboarding_prefs.dart
Normal file
20
lib/onboarding_prefs.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Regista localmente se o utilizador já viu o tutorial guiado da Home
|
||||
/// ([showCoachMarkTour] a partir de [LoggedHomeScreen]), para só o mostrar
|
||||
/// automaticamente uma vez.
|
||||
class OnboardingPrefs {
|
||||
const OnboardingPrefs._();
|
||||
|
||||
static const String _kSeenTutorialKey = 'seen_onboarding_tutorial_v1';
|
||||
|
||||
static Future<bool> hasSeenTutorial() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_kSeenTutorialKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> markTutorialSeen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kSeenTutorialKey, true);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ const Color _accentPink = AppColors.pink;
|
||||
/// Conteúdo da aba de Configurações, para ser embutido na bottom navigation
|
||||
/// do LoggedHomeScreen (sem Scaffold/AppBar próprios).
|
||||
class SettingsBody extends StatefulWidget {
|
||||
const SettingsBody({super.key});
|
||||
const SettingsBody({super.key, required this.onReplayTutorial});
|
||||
|
||||
/// Corre de novo o tutorial guiado da Home (ver [showCoachMarkTour]).
|
||||
final VoidCallback onReplayTutorial;
|
||||
|
||||
@override
|
||||
State<SettingsBody> createState() => _SettingsBodyState();
|
||||
@@ -131,6 +134,12 @@ class _SettingsBodyState extends State<SettingsBody> {
|
||||
_SectionLabel(SettingsStrings.about),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_ActionTile(
|
||||
icon: Icons.school_outlined,
|
||||
title: SettingsStrings.replayTutorial,
|
||||
onTap: widget.onReplayTutorial,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_ActionTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: SettingsStrings.termsOfService,
|
||||
@@ -154,7 +163,7 @@ class _SettingsBodyState extends State<SettingsBody> {
|
||||
const _InfoTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: SettingsStrings.appVersion,
|
||||
subtitle: '1.1.0',
|
||||
subtitle: '1.2.1',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
36
lib/strings/onboarding_strings.dart
Normal file
36
lib/strings/onboarding_strings.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
/// Texto do tutorial guiado mostrado a novos utilizadores no primeiro login
|
||||
/// ([showCoachMarkTour] a partir de [LoggedHomeScreen]).
|
||||
class OnboardingStrings {
|
||||
const OnboardingStrings._();
|
||||
|
||||
static const String skip = 'Saltar';
|
||||
static const String next = 'Seguinte';
|
||||
static const String finish = 'Concluir';
|
||||
|
||||
static const String gaugesTitle = 'O seu resultado';
|
||||
static const String gaugesDescription =
|
||||
'Depois de fazer a avaliação, os sinais de má oclusão e os fatores '
|
||||
'de risco da criança aparecem aqui.';
|
||||
|
||||
static const String quizTitle = 'Avaliação gratuita';
|
||||
static const String quizDescription =
|
||||
'Responda a um questionário rápido para avaliar o risco de má '
|
||||
'oclusão dentária da criança.';
|
||||
|
||||
static const String videosTitle = 'Vídeos educativos';
|
||||
static const String videosDescription =
|
||||
'Episódios sobre saúde oral para toda a família.';
|
||||
|
||||
static const String clinicsTitle = 'Consultórios';
|
||||
static const String clinicsDescription =
|
||||
'Encontre consultórios de odontopediatria perto de si e ligue '
|
||||
'diretamente.';
|
||||
|
||||
static const String profileTitle = 'Perfil';
|
||||
static const String profileDescription =
|
||||
'Adicione as suas crianças e acompanhe o progresso de cada uma.';
|
||||
|
||||
static const String settingsTitle = 'Ajustes';
|
||||
static const String settingsDescription =
|
||||
'Configurações da conta, termos de serviço e mais.';
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SettingsStrings {
|
||||
static const String signOut = 'Sair';
|
||||
|
||||
static const String about = 'Sobre';
|
||||
static const String replayTutorial = 'Rever tutorial';
|
||||
static const String termsOfService = 'Termos de Serviço';
|
||||
static const String creatorsAndContributors = 'Criadores e colaboradores';
|
||||
static const String appVersion = 'Versão do app';
|
||||
|
||||
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