CTK 1.2.1

This commit is contained in:
Carlos Correia
2026-07-30 01:25:31 +01:00
parent fc875d559c
commit 887c62c379
7 changed files with 594 additions and 7 deletions

View File

@@ -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
View 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);
}
}

View File

@@ -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',
),
],
),

View 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.';
}

View File

@@ -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
View 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,
),
),
),
),
),
],
),
),
);
}
}