From 887c62c379111b91e6c3b9532570af3861e668c9 Mon Sep 17 00:00:00 2001 From: Carlos Correia <240402@epvc.ptm> Date: Thu, 30 Jul 2026 01:25:31 +0100 Subject: [PATCH] CTK 1.2.1 --- lib/logged_home.dart | 102 ++++++- lib/onboarding_prefs.dart | 20 ++ lib/screens/settings_screen.dart | 13 +- lib/strings/onboarding_strings.dart | 36 +++ lib/strings/settings_strings.dart | 1 + lib/widgets/coach_mark.dart | 427 ++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 7 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 lib/onboarding_prefs.dart create mode 100644 lib/strings/onboarding_strings.dart create mode 100644 lib/widgets/coach_mark.dart diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 3725451..78e01e2 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -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 String? _clinicsError; Set _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 _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 _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 _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.delayed(const Duration(milliseconds: 450), () { + if (!mounted) return Future.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 ), BottomNavigationBarItem( icon: AnimatedNavIcon( + key: _navConsultoriosKey, icon: Icons.medical_services_rounded, selected: _index == 1, ), @@ -390,6 +475,7 @@ class _LoggedHomeScreenState extends State ), BottomNavigationBarItem( icon: AnimatedNavIcon( + key: _navPerfilKey, icon: Icons.person_rounded, selected: _index == 2, ), @@ -397,6 +483,7 @@ class _LoggedHomeScreenState extends State ), BottomNavigationBarItem( icon: AnimatedNavIcon( + key: _navAjustesKey, icon: Icons.settings_rounded, selected: _index == 3, ), @@ -484,6 +571,7 @@ class _LoggedHomeScreenState extends State top: kToolbarHeight + 96, child: Center( child: Row( + key: _gaugesKey, mainAxisSize: MainAxisSize.min, children: [ _RiskArcGauge( @@ -679,7 +767,7 @@ class _LoggedHomeScreenState extends State 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?> _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; diff --git a/lib/onboarding_prefs.dart b/lib/onboarding_prefs.dart new file mode 100644 index 0000000..3a9ce73 --- /dev/null +++ b/lib/onboarding_prefs.dart @@ -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 hasSeenTutorial() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_kSeenTutorialKey) ?? false; + } + + static Future markTutorialSeen() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kSeenTutorialKey, true); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 374003a..2ffa17c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsBodyState(); @@ -131,6 +134,12 @@ class _SettingsBodyState extends State { _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 { const _InfoTile( icon: Icons.info_outline_rounded, title: SettingsStrings.appVersion, - subtitle: '1.1.0', + subtitle: '1.2.1', ), ], ), diff --git a/lib/strings/onboarding_strings.dart b/lib/strings/onboarding_strings.dart new file mode 100644 index 0000000..1f61af9 --- /dev/null +++ b/lib/strings/onboarding_strings.dart @@ -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.'; +} diff --git a/lib/strings/settings_strings.dart b/lib/strings/settings_strings.dart index 4e07d82..d587ced 100644 --- a/lib/strings/settings_strings.dart +++ b/lib/strings/settings_strings.dart @@ -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'; diff --git a/lib/widgets/coach_mark.dart b/lib/widgets/coach_mark.dart new file mode 100644 index 0000000..ffacca2 --- /dev/null +++ b/lib/widgets/coach_mark.dart @@ -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 showCoachMarkTour(BuildContext context, List steps) { + if (steps.isEmpty) return Future.value(); + final completer = Completer(); + 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 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 _pulse = Tween(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 _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, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 5e30a74..8151cc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.0 +version: 1.2.1 environment: sdk: ^3.10.4