diff --git a/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/1º Episódio GUIA OFICIAL.mp4 b/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/1º Episódio GUIA OFICIAL.mp4 deleted file mode 100644 index 1dea8bd..0000000 Binary files a/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/1º Episódio GUIA OFICIAL.mp4 and /dev/null differ diff --git a/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/2º Episódio GUIA OFICIAL.mp4 b/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/2º Episódio GUIA OFICIAL.mp4 deleted file mode 100644 index d8c1ddc..0000000 Binary files a/assets/Vídeos_APP_Check-Teeth-Kids/Videos/Vídeos de orientação - dentro do questionário/2º Episódio GUIA OFICIAL.mp4 and /dev/null differ diff --git a/assets/mockup_images/0.1.png b/assets/mockup_images/0.1.png new file mode 100644 index 0000000..ca771d5 Binary files /dev/null and b/assets/mockup_images/0.1.png differ diff --git a/assets/mockup_images/0.png b/assets/mockup_images/0.png index 3e4ad99..3e8739d 100644 Binary files a/assets/mockup_images/0.png and b/assets/mockup_images/0.png differ diff --git a/assets/mockup_images/1.jpeg b/assets/mockup_images/1.jpeg index 78344a8..f709f38 100644 Binary files a/assets/mockup_images/1.jpeg and b/assets/mockup_images/1.jpeg differ diff --git a/assets/mockup_images/2.jpeg b/assets/mockup_images/2.jpeg index 9911cfa..b72e05b 100644 Binary files a/assets/mockup_images/2.jpeg and b/assets/mockup_images/2.jpeg differ diff --git a/assets/mockup_images/3.jpeg b/assets/mockup_images/3.jpeg index ace0adc..8548f3b 100644 Binary files a/assets/mockup_images/3.jpeg and b/assets/mockup_images/3.jpeg differ diff --git a/lib/auth_gate.dart b/lib/auth_gate.dart index 68d14a5..8b7de35 100644 --- a/lib/auth_gate.dart +++ b/lib/auth_gate.dart @@ -6,9 +6,25 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'main.dart' show supabase; import 'home_screen.dart'; import 'logged_home.dart'; +import 'privacy_gate_prefs.dart'; +import 'screens/privacy_gate_screen.dart'; +import 'screens/terms_gate_screen.dart'; +import 'terms_gate_prefs.dart'; final ValueNotifier forceHomeScreen = ValueNotifier(false); +/// Quando um cadastro (signUp) acabou de acontecer, guarda o uid da conta +/// nova aqui — o AuthGate mostra o [TermsGateScreen] em vez do LoggedHome +/// enquanto este valor corresponder à sessão ativa. Aceitar limpa o valor +/// (avança para a Home); recusar apaga a conta e termina a sessão. +final ValueNotifier pendingTermsUserId = ValueNotifier(null); + +/// Igual a [pendingTermsUserId], mas para o consentimento de privacidade +/// que o AuthGate mostra primeiro, antes do TermsGateScreen. +final ValueNotifier pendingPrivacyUserId = ValueNotifier( + null, +); + class AuthGate extends StatefulWidget { const AuthGate({super.key}); @@ -20,6 +36,32 @@ class _AuthGateState extends State { String? _validatedUserId; String? _validatingUserId; + @override + void initState() { + super.initState(); + _loadPendingTermsUid(); + _loadPendingPrivacyUid(); + } + + /// Recupera do disco o uid pendente de aceitação dos Termos (se existir), + /// para o caso de o processo ter sido morto entre o cadastro e a + /// aceitação — sem isto, reabrir a app perderia o estado "pendente" (que + /// por defeito vive só em memória) e o gate seria ignorado. + Future _loadPendingTermsUid() async { + final uid = await TermsGatePrefs.getPendingUid(); + if (uid != null && mounted) { + pendingTermsUserId.value = uid; + } + } + + /// Igual a [_loadPendingTermsUid], para o consentimento de privacidade. + Future _loadPendingPrivacyUid() async { + final uid = await PrivacyGatePrefs.getPendingUid(); + if (uid != null && mounted) { + pendingPrivacyUserId.value = uid; + } + } + /// Confirma que a sessão ativa ainda corresponde a um perfil existente na /// base de dados. Sessões do Supabase Auth sobrevivem mesmo que os dados /// da conta tenham sido apagados (ex.: "Apagar dados da conta" ou remoção @@ -61,43 +103,71 @@ class _AuthGateState extends State { return ValueListenableBuilder( valueListenable: forceHomeScreen, builder: (context, forcedHome, _) { - return StreamBuilder( - stream: supabase.auth.onAuthStateChange, - initialData: AuthState(AuthChangeEvent.initialSession, supabase.auth.currentSession), - builder: (context, snapshot) { - final user = snapshot.data?.session?.user; + return ValueListenableBuilder( + valueListenable: pendingPrivacyUserId, + builder: (context, pendingPrivacyUid, _) { + return ValueListenableBuilder( + valueListenable: pendingTermsUserId, + builder: (context, pendingUid, _) { + return StreamBuilder( + stream: supabase.auth.onAuthStateChange, + initialData: AuthState(AuthChangeEvent.initialSession, supabase.auth.currentSession), + builder: (context, snapshot) { + final user = snapshot.data?.session?.user; - if (user != null && user.id != _validatedUserId) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _validateSession(user.id); - }); - } + if (user != null && user.id != _validatedUserId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _validateSession(user.id); + }); + } - final Widget child; - if (snapshot.connectionState == ConnectionState.waiting) { - child = const SizedBox.shrink(); - } else if (forcedHome || user == null || user.id != _validatedUserId) { - child = const HomeScreen(key: ValueKey('home_screen')); - } else { - child = const LoggedHomeScreen(key: ValueKey('logged_home_screen')); - } + final Widget child; + if (snapshot.connectionState == ConnectionState.waiting) { + child = const SizedBox.shrink(); + } else if (forcedHome || user == null || user.id != _validatedUserId) { + child = const HomeScreen(key: ValueKey('home_screen')); + } else if (pendingPrivacyUid == user.id) { + child = PrivacyGateScreen( + key: const ValueKey('privacy_gate_screen'), + userId: user.id, + onAccepted: () { + pendingPrivacyUserId.value = null; + PrivacyGatePrefs.clearPendingUid(); + }, + ); + } else if (pendingUid == user.id) { + child = TermsGateScreen( + key: const ValueKey('terms_gate_screen'), + userId: user.id, + onAccepted: () { + pendingTermsUserId.value = null; + TermsGatePrefs.clearPendingUid(); + }, + ); + } else { + child = const LoggedHomeScreen(key: ValueKey('logged_home_screen')); + } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 280), - reverseDuration: const Duration(milliseconds: 240), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut); - return FadeTransition( - opacity: fade, - child: ScaleTransition( - scale: Tween(begin: 0.985, end: 1.0).animate(fade), - child: child, - ), + return AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + reverseDuration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + final fade = CurvedAnimation(parent: animation, curve: Curves.easeOut); + return FadeTransition( + opacity: fade, + child: ScaleTransition( + scale: Tween(begin: 0.985, end: 1.0).animate(fade), + child: child, + ), + ); + }, + child: child, + ); + }, ); }, - child: child, ); }, ); diff --git a/lib/home_screen.dart b/lib/home_screen.dart index ab4a88b..c6513e9 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -6,7 +6,10 @@ import 'package:flutter/services.dart'; import 'package:lottie/lottie.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'auth_gate.dart' show pendingPrivacyUserId, pendingTermsUserId; import 'main.dart' show supabase; +import 'privacy_gate_prefs.dart'; +import 'terms_gate_prefs.dart'; import 'widgets/app_gradients.dart'; import 'widgets/entrance.dart'; import 'widgets/name_input_formatter.dart'; @@ -114,6 +117,19 @@ class _HomeScreenState extends State { throw StateError('Usuário não encontrado após criar conta.'); } + // Antes de persistir o perfil (o que já faz o AuthGate considerar a + // sessão válida), marca esta conta como pendente de consentimento + // de privacidade e de aceitação dos Termos — o AuthGate mostra + // primeiro o PrivacyGateScreen e, só depois de aceite, o + // TermsGateScreen, em vez do LoggedHome, enquanto estes uids + // estiverem marcados. Grava também em disco (não só em memória) + // para os gates sobreviverem caso o processo seja morto antes de o + // utilizador responder. + pendingPrivacyUserId.value = user.id; + unawaited(PrivacyGatePrefs.setPendingUid(user.id)); + pendingTermsUserId.value = user.id; + unawaited(TermsGatePrefs.setPendingUid(user.id)); + // Precisa de terminar antes de navegar: o AuthGate só mostra a app // depois de confirmar que existe um perfil na base de dados. await _persistRegistrationData(uid: user.id, name: name, email: email); diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 9daa8dc..91fbd05 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -143,9 +143,7 @@ class _LoggedHomeScreenState extends State await prefs.remove(_kPendingQuizScopeKey); if (!mounted) return; - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => Quiz1Screen(scopeId: scopeId)), - ); + await Navigator.of(context).push(quizStartRoute(scopeId: scopeId)); if (!mounted) return; await _loadQuizResult(); } catch (_) { @@ -721,9 +719,7 @@ class _InicioTab extends StatelessWidget { final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); state?.selectChild(childName, scopeId); - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => Quiz1Screen(scopeId: scopeId)), - ); + await Navigator.of(context).push(quizStartRoute(scopeId: scopeId)); onQuizClosed(); } @@ -1310,7 +1306,7 @@ class _HeroQuizCard extends StatelessWidget { ), const SizedBox(height: 5), Text( - '23 perguntas rápidas · menos de 3 minutos', + '26 perguntas rápidas · menos de 3 minutos', style: TextStyle( color: Colors.white.withValues(alpha: 0.92), fontWeight: FontWeight.w600, diff --git a/lib/privacy_gate_prefs.dart b/lib/privacy_gate_prefs.dart new file mode 100644 index 0000000..f2ebd5c --- /dev/null +++ b/lib/privacy_gate_prefs.dart @@ -0,0 +1,26 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Guarda em disco o uid de uma conta que acabou de se registar e ainda não +/// consentiu a partilha de dados/privacidade. Um simples valor em memória +/// não chega: se o sistema matar o processo entre o cadastro e o +/// consentimento (comum em Android quando a app fica em segundo plano), +/// reabrir a app perderia o estado "pendente" e o utilizador entraria +/// direto na Home sem nunca ter consentido nada. +class PrivacyGatePrefs { + static const String _kPendingUidKey = 'pending_privacy_uid'; + + static Future getPendingUid() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kPendingUidKey); + } + + static Future setPendingUid(String uid) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kPendingUidKey, uid); + } + + static Future clearPendingUid() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kPendingUidKey); + } +} diff --git a/lib/quiz/quiz1.dart b/lib/quiz/quiz1.dart index c452370..9aff5f6 100644 --- a/lib/quiz/quiz1.dart +++ b/lib/quiz/quiz1.dart @@ -1,7 +1,42 @@ import 'package:flutter/material.dart'; +import 'quiz_checklist_screen.dart'; import 'quiz_question_screen.dart'; import 'quiz_result.dart'; +import 'quiz_video_guide.dart'; + +/// Recorta/zoom numa zona específica de uma foto de rosto (ex.: só a zona +/// dos olhos), também sem precisar de um novo ficheiro de imagem. +Widget _zoomCrop(String path, {Alignment alignment = Alignment.center, double scale = 2.0}) { + return ClipRect( + child: Transform.scale( + scale: scale, + alignment: alignment, + child: Image.asset(path, fit: BoxFit.cover), + ), + ); +} + +/// Rota inicial do quiz: mostra primeiro o ecrã de checklist explicativo, +/// só depois a pergunta 1 — usada em todos os pontos de entrada do quiz +/// em vez de navegar diretamente para [Quiz1Screen]. +MaterialPageRoute quizStartRoute({String? scopeId}) { + return MaterialPageRoute( + builder: (_) => QuizChecklistScreen( + heading: 'Vamos ajudá-lo/a a compreender:', + items: const [ + 'Sinais de alerta podem passar despercebidos', + 'Prevenir é melhor do que tratar', + 'Quando deve procurar um dentista (urgência vs vigilância)', + ], + onAdvance: (context) => Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => Quiz1Screen(scopeId: scopeId), + ), + ), + ), + ); +} // Quiz 1: Problemas respiratórios (Yes/No) class Quiz1Screen extends StatelessWidget { @@ -13,7 +48,7 @@ class Quiz1Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 1/23', + title: 'Quiz 1/26', category: 'Saúde respiratória', categoryIcon: Icons.medical_information_rounded, fallbackIcon: Icons.medical_information_rounded, @@ -60,7 +95,7 @@ class Quiz2Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 2/23', + title: 'Quiz 2/26', category: 'Respiração', categoryIcon: Icons.air_rounded, fallbackIcon: Icons.air_rounded, @@ -107,7 +142,7 @@ class Quiz3Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 3/23', + title: 'Quiz 3/26', category: 'Sono', categoryIcon: Icons.bedtime_rounded, fallbackIcon: Icons.bedtime_rounded, @@ -154,7 +189,7 @@ class Quiz4Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 4/23', + title: 'Quiz 4/26', category: 'Respiração', categoryIcon: Icons.sick_rounded, fallbackIcon: Icons.sick_rounded, @@ -201,7 +236,7 @@ class Quiz5Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 5/23', + title: 'Quiz 5/26', category: 'Sono', categoryIcon: Icons.nights_stay_rounded, fallbackIcon: Icons.nights_stay_rounded, @@ -250,7 +285,7 @@ class Quiz6Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 6/23', + title: 'Quiz 6/26', category: 'Hábitos noturnos', categoryIcon: Icons.nights_stay_rounded, fallbackIcon: Icons.nights_stay_rounded, @@ -290,7 +325,7 @@ class Quiz7Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 7/23', + title: 'Quiz 7/26', category: 'Saúde geral', categoryIcon: Icons.local_florist_rounded, fallbackIcon: Icons.local_florist_rounded, @@ -337,7 +372,7 @@ class Quiz8Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 8/23', + title: 'Quiz 8/26', category: 'Sono', categoryIcon: Icons.water_drop_rounded, fallbackIcon: Icons.water_drop_rounded, @@ -384,7 +419,7 @@ class Quiz9Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 9/23', + title: 'Quiz 9/26', category: 'Saúde geral', categoryIcon: Icons.hearing_rounded, fallbackIcon: Icons.hearing_rounded, @@ -431,7 +466,7 @@ class Quiz10Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 10/23', + title: 'Quiz 10/26', category: 'Saúde geral', categoryIcon: Icons.healing_rounded, fallbackIcon: Icons.healing_rounded, @@ -478,7 +513,7 @@ class Quiz11Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 11/23', + title: 'Quiz 11/26', category: 'Saúde respiratória', categoryIcon: Icons.air_rounded, fallbackIcon: Icons.air_rounded, @@ -526,7 +561,7 @@ class Quiz12Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 12/23', + title: 'Quiz 12/26', category: 'Hábitos alimentares', categoryIcon: Icons.restaurant_rounded, fallbackIcon: Icons.restaurant_rounded, @@ -566,7 +601,7 @@ class Quiz13Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 13/23', + title: 'Quiz 13/26', category: 'Hábitos alimentares', categoryIcon: Icons.schedule_rounded, fallbackIcon: Icons.schedule_rounded, @@ -606,7 +641,7 @@ class Quiz14Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 14/23', + title: 'Quiz 14/26', category: 'Hábitos alimentares', categoryIcon: Icons.restaurant_menu_rounded, fallbackIcon: Icons.restaurant_menu_rounded, @@ -646,7 +681,7 @@ class Quiz15Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 15/23', + title: 'Quiz 15/26', category: 'Hábitos alimentares', categoryIcon: Icons.local_drink_rounded, fallbackIcon: Icons.local_drink_rounded, @@ -693,7 +728,7 @@ class Quiz16Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 16/23', + title: 'Quiz 16/26', category: 'Hábitos orais', categoryIcon: Icons.child_care_rounded, fallbackIcon: Icons.child_care_rounded, @@ -740,7 +775,7 @@ class Quiz17Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 17/23', + title: 'Quiz 17/26', category: 'Hábitos orais', categoryIcon: Icons.back_hand_rounded, fallbackIcon: Icons.back_hand_rounded, @@ -769,7 +804,15 @@ class Quiz17Screen extends StatelessWidget { ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( - builder: (_) => Quiz18Screen(currentScore: nextScore, scopeId: scopeId), + builder: (_) => QuizVideoGuideScreen( + youtubeId: 'W2BcK9nSyt0', + onAdvance: (ctx) => Navigator.of(ctx).pushReplacement( + MaterialPageRoute( + builder: (_) => + Quiz18Screen(currentScore: nextScore, scopeId: scopeId), + ), + ), + ), ), answerType: QuizAnswerType.yesNo, showBackButton: true, @@ -777,7 +820,7 @@ class Quiz17Screen extends StatelessWidget { } } -// Quiz 18: Face (Image-based) +// Quiz 18: Postura (Image-choice) class Quiz18Screen extends StatelessWidget { const Quiz18Screen({super.key, required this.currentScore, this.scopeId}); @@ -787,38 +830,43 @@ class Quiz18Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 18/23', - category: 'Avaliação facial', - categoryIcon: Icons.face_rounded, - question: 'O rosto do seu filho/a se parece com o desta imagem?', - questionImagePaths: const ['assets/mockup_images/2.jpeg'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1 - suggestedVideoTitle: 'Ver vídeo: Episódio 1', + title: 'Quiz 18/26', + category: 'Avaliação postural', + categoryIcon: Icons.accessibility_new_rounded, + fallbackIcon: Icons.accessibility_new_rounded, + fallbackColor: const Color(0xFF8E7CC3), + answerImageAspectRatio: 1.5, + question: + 'Qual das seguintes imagens é mais parecida com a postura do seu filho/a?', answers: const [ QuizAnswer( - title: 'Sim', - description: 'O rosto se assemelha à imagem', + title: 'Postura inadequada', + description: 'Postura curvada, ombros e pescoço projetados', weight: 2, - value: 'sim', + hideTitle: true, + value: 'postura_inadequada', + imagePath: 'assets/mockup_images/0.1.png', ), QuizAnswer( - title: 'Não', - description: 'O rosto não se assemelha à imagem', + title: 'Postura correta', + description: 'Postura ereta, coluna alinhada', weight: 1, - value: 'nao', + hideTitle: true, + value: 'postura_correta', + imagePath: 'assets/mockup_images/0.png', ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( builder: (_) => Quiz19Screen(currentScore: nextScore, scopeId: scopeId), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, showBackButton: true, ); } } -// Quiz 19: Boca (Image-based) +// Quiz 19: Perfil (Image-choice) class Quiz19Screen extends StatelessWidget { const Quiz19Screen({super.key, required this.currentScore, this.scopeId}); @@ -828,39 +876,62 @@ class Quiz19Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 19/23', + title: 'Quiz 19/26', category: 'Avaliação facial', - categoryIcon: Icons.sentiment_neutral_rounded, + categoryIcon: Icons.face_rounded, + fallbackIcon: Icons.face_rounded, + fallbackColor: const Color(0xFFFF55A7), question: - 'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?', - questionImagePaths: const ['assets/mockup_images/4.jpeg'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 2 - suggestedVideoTitle: 'Ver vídeo: Episódio 2', - answers: const [ + 'Qual das seguintes imagens é mais parecida com o perfil do seu filho/a?', + answers: [ QuizAnswer( - title: 'Sim', - description: 'A boca fica habitualmente entreaberta', - weight: 2, - value: 'sim', + title: 'Perfil reto', + description: 'Perfil facial reto', + weight: 1, + hideTitle: true, + value: 'perfil_reto', + imageBuilder: (context) => _zoomCrop( + 'assets/mockup_images/1.jpeg', + alignment: const Alignment(0.1, 0.7), + scale: 1.05, + ), ), QuizAnswer( - title: 'Não', - description: 'A boca fica habitualmente fechada', - weight: 1, - value: 'nao', + title: 'Perfil convexo', + description: 'Perfil facial convexo', + weight: 2, + hideTitle: true, + value: 'perfil_convexo', + imageBuilder: (context) => _zoomCrop( + 'assets/mockup_images/2.jpeg', + alignment: const Alignment(0.1, 0.7), + scale: 1.05, + ), + ), + QuizAnswer( + title: 'Perfil côncavo', + description: 'Perfil facial côncavo', + weight: 2, + hideTitle: true, + value: 'perfil_concavo', + imageBuilder: (context) => _zoomCrop( + 'assets/mockup_images/3.jpeg', + alignment: const Alignment(0.1, 0.7), + scale: 1.05, + ), ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( builder: (_) => Quiz20Screen(currentScore: nextScore, scopeId: scopeId), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, showBackButton: true, ); } } -// Quiz 20: Olheiras (Image-based) +// Quiz 20: Boca habitual (Image-choice) class Quiz20Screen extends StatelessWidget { const Quiz20Screen({super.key, required this.currentScore, this.scopeId}); @@ -870,38 +941,52 @@ class Quiz20Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 20/23', + title: 'Quiz 20/26', category: 'Avaliação facial', - categoryIcon: Icons.visibility_rounded, - question: 'O seu filho/a tem olheiras semelhantes às desta imagem?', - questionImagePaths: const ['assets/mockup_images/8.jpeg'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3 - suggestedVideoTitle: 'Ver vídeo: Episódio 3', + categoryIcon: Icons.sentiment_neutral_rounded, + fallbackIcon: Icons.sentiment_neutral_rounded, + fallbackColor: const Color(0xFF2F9E94), + question: 'Qual é a posição da boca do seu filho/a habitualmente?', answers: const [ QuizAnswer( - title: 'Sim', - description: 'Tem olheiras semelhantes à imagem', - weight: 2, - value: 'sim', + title: 'Boca fechada', + description: 'Boca fechada habitualmente', + weight: 1, + hideTitle: true, + value: 'boca_fechada', + imagePath: 'assets/mockup_images/7.png', ), QuizAnswer( - title: 'Não', - description: 'Não tem olheiras semelhantes à imagem', - weight: 1, - value: 'nao', + title: 'Boca entreaberta', + description: 'Boca entreaberta habitualmente', + weight: 2, + hideTitle: true, + value: 'boca_entreaberta', + imagePath: 'assets/mockup_images/4.jpeg', ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( - builder: (_) => Quiz21Screen(currentScore: nextScore, scopeId: scopeId), + builder: (_) => QuizVideoGuideScreen( + youtubeId: 'msKYr7nPxcw', + extraHeading: 'Observe atentamente a face do seu filho...', + extraSubtitle: + 'Vamos precisar da sua ajuda para registrar o que vê no seu filho/a!', + onAdvance: (ctx) => Navigator.of(ctx).pushReplacement( + MaterialPageRoute( + builder: (_) => + Quiz21Screen(currentScore: nextScore, scopeId: scopeId), + ), + ), + ), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, showBackButton: true, ); } } -// Quiz 21: Queixo (Image-based) +// Quiz 21: Zona abaixo dos olhos (Image-choice) class Quiz21Screen extends StatelessWidget { const Quiz21Screen({super.key, required this.currentScore, this.scopeId}); @@ -911,39 +996,50 @@ class Quiz21Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 21/23', + title: 'Quiz 21/26', category: 'Avaliação facial', - categoryIcon: Icons.face_rounded, + categoryIcon: Icons.visibility_rounded, + fallbackIcon: Icons.visibility_rounded, + fallbackColor: const Color(0xFFFF55A7), question: - 'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?', - questionImagePaths: const ['assets/mockup_images/6.jpeg'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 4 - suggestedVideoTitle: 'Ver vídeo: Episódio 4', - answers: const [ + 'Qual das imagens, na zona abaixo dos olhos, se assemelha mais ao seu filho/a?', + answers: [ QuizAnswer( - title: 'Sim', - description: 'O queixo se assemelha à imagem', - weight: 2, - value: 'sim', + title: 'Sem sinais', + description: 'Sem olheiras visíveis abaixo dos olhos', + weight: 1, + hideTitle: true, + value: 'sem_olheiras', + imageBuilder: (context) => _zoomCrop( + 'assets/mockup_images/9.png', + alignment: const Alignment(0, -0.55), + scale: 2.3, + ), ), QuizAnswer( - title: 'Não', - description: 'O queixo não se assemelha à imagem', - weight: 1, - value: 'nao', + title: 'Sinal de risco', + description: 'Olheiras visíveis abaixo dos olhos', + weight: 2, + hideTitle: true, + value: 'com_olheiras', + imageBuilder: (context) => _zoomCrop( + 'assets/mockup_images/8.jpeg', + alignment: const Alignment(0, -0.55), + scale: 2.3, + ), ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( builder: (_) => Quiz22Screen(currentScore: nextScore, scopeId: scopeId), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, showBackButton: true, ); } } -// Quiz 22: Boca (Image-based) +// Quiz 22: Queixo com a boca fechada (Image-choice) class Quiz22Screen extends StatelessWidget { const Quiz22Screen({super.key, required this.currentScore, this.scopeId}); @@ -953,38 +1049,42 @@ class Quiz22Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 22/23', + title: 'Quiz 22/26', category: 'Avaliação facial', - categoryIcon: Icons.sentiment_neutral_rounded, - question: 'A boca do seu filho/a se parece com a desta imagem?', - questionImagePaths: const ['assets/mockup_images/14.jpeg'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5 - suggestedVideoTitle: 'Ver vídeo: Episódio 5', + categoryIcon: Icons.face_rounded, + fallbackIcon: Icons.face_rounded, + fallbackColor: const Color(0xFF8E7CC3), + question: + 'Qual das imagens é mais parecida com o queixo do seu filho/a com a boca fechada?', answers: const [ QuizAnswer( - title: 'Sim', - description: 'A boca se assemelha à imagem', - weight: 2, - value: 'sim', + title: 'Queixo relaxado', + description: 'Queixo liso e relaxado com a boca fechada', + weight: 1, + hideTitle: true, + value: 'queixo_correto', + imagePath: 'assets/mockup_images/5.png', ), QuizAnswer( - title: 'Não', - description: 'A boca não se assemelha à imagem', - weight: 1, - value: 'nao', + title: 'Queixo tenso', + description: 'Queixo tenso/franzido com a boca fechada', + weight: 2, + hideTitle: true, + value: 'queixo_tenso', + imagePath: 'assets/mockup_images/6.jpeg', ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( builder: (_) => Quiz23Screen(currentScore: nextScore, scopeId: scopeId), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, showBackButton: true, ); } } -// Quiz 23: Freio (Image-based) +// Quiz 23: Boca / dentição (Image-choice) class Quiz23Screen extends StatelessWidget { const Quiz23Screen({super.key, required this.currentScore, this.scopeId}); @@ -994,37 +1094,182 @@ class Quiz23Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 23/23', + title: 'Quiz 23/26', category: 'Avaliação facial', - categoryIcon: Icons.record_voice_over_rounded, - question: - 'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?', - questionImagePaths: const ['assets/mockup_images/17.png'], - suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 6 - suggestedVideoTitle: 'Ver vídeo: Episódio 6', + categoryIcon: Icons.sentiment_neutral_rounded, + fallbackIcon: Icons.sentiment_neutral_rounded, + fallbackColor: const Color(0xFF2F9E94), + question: 'Qual das seguintes imagens se assemelha à boca do seu filho/a?', answers: const [ QuizAnswer( - title: 'Sim', - description: 'O frénulo se assemelha à imagem', - weight: 2, - value: 'sim', + title: 'Dentição alinhada', + description: 'Dentição bem alinhada, sem apinhamento', + weight: 1, + hideTitle: true, + value: 'dentes_alinhados', + imagePath: 'assets/mockup_images/24.png', ), QuizAnswer( - title: 'Não', - description: 'O frénulo não se assemelha à imagem', + title: 'Dentição desalinhada', + description: 'Dentição desalinhada/apinhada', + weight: 2, + hideTitle: true, + value: 'dentes_desalinhados', + imagePath: 'assets/mockup_images/23.jpeg', + ), + QuizAnswer( + title: 'Dentição sobreposta', + description: 'Dentes sobrepostos/tortos', + weight: 2, + hideTitle: true, + value: 'dentes_sobrepostos', + imagePath: 'assets/mockup_images/14.jpeg', + ), + ], + currentScore: currentScore, + nextRoute: (context, nextScore) => MaterialPageRoute( + builder: (_) => Quiz24Screen(currentScore: nextScore, scopeId: scopeId), + ), + answerType: QuizAnswerType.image, + showBackButton: true, + ); + } +} + +// Quiz 24: Freio labial (Image-choice) +class Quiz24Screen extends StatelessWidget { + const Quiz24Screen({super.key, required this.currentScore, this.scopeId}); + + final int currentScore; + final String? scopeId; + + @override + Widget build(BuildContext context) { + return QuizQuestionScreen( + title: 'Quiz 24/26', + category: 'Avaliação facial', + categoryIcon: Icons.record_voice_over_rounded, + fallbackIcon: Icons.record_voice_over_rounded, + fallbackColor: const Color(0xFFFF55A7), + question: + 'Qual das seguintes imagens se assemelha ao freio labial do seu filho/a?', + answers: const [ + QuizAnswer( + title: 'Freio labial correto', + description: 'Inserção do freio labial mais alta', weight: 1, - value: 'nao', + hideTitle: true, + value: 'freio_labial_correto', + imagePath: 'assets/mockup_images/20.png', + ), + QuizAnswer( + title: 'Freio labial inadequado', + description: 'Inserção do freio labial baixa, entre os dentes', + weight: 2, + hideTitle: true, + value: 'freio_labial_inadequado', + imagePath: 'assets/mockup_images/19.jpeg', + ), + ], + currentScore: currentScore, + nextRoute: (context, nextScore) => MaterialPageRoute( + builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId), + ), + answerType: QuizAnswerType.image, + showBackButton: true, + ); + } +} + +// Quiz 25: Freio lingual (Image-choice) +class Quiz25Screen extends StatelessWidget { + const Quiz25Screen({super.key, required this.currentScore, this.scopeId}); + + final int currentScore; + final String? scopeId; + + @override + Widget build(BuildContext context) { + return QuizQuestionScreen( + title: 'Quiz 25/26', + category: 'Avaliação facial', + categoryIcon: Icons.record_voice_over_rounded, + fallbackIcon: Icons.record_voice_over_rounded, + fallbackColor: const Color(0xFF8E7CC3), + question: + 'Qual das seguintes imagens se assemelha ao freio lingual do seu filho/a?', + answers: const [ + QuizAnswer( + title: 'Freio lingual correto', + description: 'Língua move-se livremente, sem restrição visível', + weight: 1, + hideTitle: true, + value: 'freio_lingual_correto', + imagePath: 'assets/mockup_images/17.png', + ), + QuizAnswer( + title: 'Freio lingual inadequado', + description: 'Freio lingual curto/apertado (língua em coração)', + weight: 2, + hideTitle: true, + value: 'freio_lingual_inadequado', + imagePath: 'assets/mockup_images/18.jpeg', + ), + ], + currentScore: currentScore, + nextRoute: (context, nextScore) => MaterialPageRoute( + builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId), + ), + answerType: QuizAnswerType.image, + showBackButton: true, + ); + } +} + +// Quiz 26: Céu da boca (Image-choice, final) +class Quiz26Screen extends StatelessWidget { + const Quiz26Screen({super.key, required this.currentScore, this.scopeId}); + + final int currentScore; + final String? scopeId; + + @override + Widget build(BuildContext context) { + return QuizQuestionScreen( + title: 'Quiz 26/26', + category: 'Avaliação facial', + categoryIcon: Icons.architecture_rounded, + fallbackIcon: Icons.architecture_rounded, + fallbackColor: const Color(0xFFFF55A7), + question: + 'Qual das seguintes imagens se assemelha ao céu da boca do seu filho/a?', + answers: const [ + QuizAnswer( + title: 'Posição em U saudável', + description: 'Palato largo, em forma de U', + weight: 1, + hideTitle: true, + value: 'palato_u', + imagePath: 'assets/mockup_images/26.png', + ), + QuizAnswer( + title: 'Posição profunda incorreta', + description: 'Palato estreito/profundo, em forma de V', + weight: 2, + hideTitle: true, + value: 'palato_v', + imagePath: 'assets/mockup_images/27.png', ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( builder: (_) => QuizResultScreen( finalScore: nextScore, - maxScore: 46, + maxScore: 52, scopeId: scopeId, ), ), - answerType: QuizAnswerType.yesNo, + answerType: QuizAnswerType.image, isFinal: true, showBackButton: true, ); diff --git a/lib/quiz/quiz_checklist_screen.dart b/lib/quiz/quiz_checklist_screen.dart new file mode 100644 index 0000000..1b9f7e6 --- /dev/null +++ b/lib/quiz/quiz_checklist_screen.dart @@ -0,0 +1,168 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; + +const Color _pink = Color(0xFFFF55A7); +const Color _teal = Color(0xFF2F9E94); + +/// Ecrã intersticial informativo, mostrado a meio do quiz para preparar o +/// utilizador antes de continuar (ex.: logo a seguir a um vídeo-guia) — +/// um ícone, um título e uma lista de pontos com visto. +class QuizChecklistScreen extends StatelessWidget { + const QuizChecklistScreen({ + super.key, + required this.heading, + required this.items, + required this.onAdvance, + this.icon = Icons.health_and_safety_rounded, + }); + + final String heading; + final List items; + final void Function(BuildContext context) onAdvance; + final IconData icon; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Scaffold( + body: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))), + Positioned( + right: -size.width * 0.40, + bottom: -size.width * 0.45, + child: IgnorePointer( + child: SizedBox( + width: size.width * 1.05, + height: size.width * 1.05, + child: Transform.rotate( + angle: -35 * math.pi / 180, + child: Opacity( + opacity: 0.95, + child: Lottie.asset( + 'lottie/Liquid waves.json', + fit: BoxFit.cover, + repeat: true, + ), + ), + ), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FadeSlideIn( + child: Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: _teal.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(icon, color: _teal, size: 36), + ), + ), + const SizedBox(height: 22), + FadeSlideIn( + delay: const Duration(milliseconds: 60), + child: Text( + heading, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.black87, + height: 1.25, + ), + ), + ), + const SizedBox(height: 22), + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + FadeSlideIn( + delay: Duration(milliseconds: 100 + 40 * i), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + margin: const EdgeInsets.only(top: 1), + decoration: const BoxDecoration( + color: _teal, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + size: 15, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + items[i], + style: TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: Colors.black.withValues( + alpha: 0.75, + ), + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + FadeSlideIn( + delay: const Duration(milliseconds: 260), + child: TapBounce( + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: _pink, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + onPressed: () => onAdvance(context), + child: const Text('Avançar'), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index d81382a..30e085c 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -19,6 +19,8 @@ class QuizAnswer { required this.description, required this.weight, this.imagePath, + this.imageBuilder, + this.hideTitle = false, this.value, this.helpVideoId, }); @@ -27,6 +29,18 @@ class QuizAnswer { final String description; final int weight; final String? imagePath; + + /// Quando definido, substitui [imagePath] na renderização — usado para + /// mostrar um recorte/zoom de uma imagem partilhada (ex.: metade + /// esquerda/direita de uma foto de comparação) sem precisar de gerar + /// novos ficheiros de imagem. + final WidgetBuilder? imageBuilder; + + /// Quando true, esconde o texto [title] por baixo da imagem — usado nas + /// perguntas "escolha a imagem" do quiz, onde a própria imagem já é a + /// resposta e uma legenda seria redundante. + final bool hideTitle; + final String? value; /// Quando definido (normalmente só na resposta "Não sei"), identifica um @@ -55,6 +69,7 @@ class QuizQuestionScreen extends StatefulWidget { this.categoryIcon, this.fallbackIcon, this.fallbackColor, + this.answerImageAspectRatio, }); final String title; @@ -71,6 +86,12 @@ class QuizQuestionScreen extends StatefulWidget { final String? suggestedYoutubeId; final String? suggestedVideoTitle; + /// Substitui o cálculo automático (baseado no número de opções) do + /// aspect ratio dos blocos de imagem em [QuizAnswerType.image] — usado + /// quando as fotos já vêm pré-recortadas num formato específico (ex.: + /// retrato, para a postura). + final double? answerImageAspectRatio; + /// Rótulo pequeno do tema da pergunta (ex.: "Avaliação facial"), mostrado /// num badge acima da imagem/pergunta. final String? category; @@ -147,9 +168,7 @@ class _QuizQuestionScreenState extends State { body: Stack( clipBehavior: Clip.none, children: [ - Positioned.fill( - child: Container(color: const Color(0xFFFAFAF7)), - ), + Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))), Positioned( left: -size.width * 0.40, bottom: -size.width * 0.45, @@ -282,9 +301,7 @@ class _QuizQuestionScreenState extends State { ), child: Center( child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 520, - ), + constraints: const BoxConstraints(maxWidth: 520), child: Padding( padding: const EdgeInsets.symmetric( vertical: 16, @@ -316,7 +333,8 @@ class _QuizQuestionScreenState extends State { : _FallbackIconBlock( icon: widget.fallbackIcon ?? - Icons.info_outline_rounded, + Icons + .info_outline_rounded, color: widget.fallbackColor ?? const Color(0xFF2F9E94), @@ -350,8 +368,7 @@ class _QuizQuestionScreenState extends State { 'Ver vídeo (opcional)', style: const TextStyle( color: Color(0xFF2F9E94), - fontWeight: - FontWeight.w800, + fontWeight: FontWeight.w800, ), ), ), @@ -378,8 +395,9 @@ class _QuizQuestionScreenState extends State { : 'Escolha apenas uma opção', textAlign: TextAlign.center, style: TextStyle( - color: Colors.black - .withValues(alpha: 0.55), + color: Colors.black.withValues( + alpha: 0.55, + ), fontWeight: FontWeight.w700, ), ), @@ -387,7 +405,13 @@ class _QuizQuestionScreenState extends State { ), ), ), - const SizedBox(height: 18), + SizedBox( + height: + widget.answerType == + QuizAnswerType.image + ? 6 + : 18, + ), Padding( padding: const EdgeInsets.symmetric( horizontal: 20, @@ -407,7 +431,14 @@ class _QuizQuestionScreenState extends State { i++ ) ...[ if (i > 0) - const SizedBox(height: 12), + SizedBox( + height: + widget.answerType == + QuizAnswerType + .image + ? 5 + : 12, + ), FadeSlideIn( delay: Duration( milliseconds: 60 * i, @@ -437,6 +468,22 @@ class _QuizQuestionScreenState extends State { .answers[i], selected: _selected == i, + // Perguntas com mais opções de + // imagem precisam de blocos mais + // compactos para caber sem rolar + // — com 2 opções sobra espaço + // para um enquadramento mais + // vertical (ex.: postura), a não + // ser que a pergunta imponha um + // aspect ratio específico. + imageAspectRatio: + widget.answerImageAspectRatio ?? + (widget + .answers + .length >= + 3 + ? 2.3 + : 1.5), onTap: () => setState( () => @@ -449,7 +496,13 @@ class _QuizQuestionScreenState extends State { ], ), ), - const SizedBox(height: 24), + SizedBox( + height: + widget.answerType == + QuizAnswerType.image + ? 8 + : 24, + ), Padding( padding: const EdgeInsets.fromLTRB( 20, @@ -462,7 +515,11 @@ class _QuizQuestionScreenState extends State { TapBounce( child: SizedBox( width: size.width * 0.62, - height: 46, + height: + widget.answerType == + QuizAnswerType.image + ? 38 + : 46, child: FilledButton( style: FilledButton.styleFrom( @@ -477,8 +534,7 @@ class _QuizQuestionScreenState extends State { textStyle: const TextStyle( fontWeight: - FontWeight - .w900, + FontWeight.w900, ), ).copyWith( animationDuration: @@ -491,16 +547,14 @@ class _QuizQuestionScreenState extends State { WidgetStateProperty.resolveWith< Color? >((states) { - if (states - .contains( - WidgetState - .pressed, - )) { + if (states.contains( + WidgetState + .pressed, + )) { return Colors .white .withValues( - alpha: - 0.14, + alpha: 0.14, ); } if (states.contains( @@ -514,8 +568,7 @@ class _QuizQuestionScreenState extends State { return Colors .white .withValues( - alpha: - 0.08, + alpha: 0.08, ); } return null; @@ -529,8 +582,7 @@ class _QuizQuestionScreenState extends State { true, ); int nextScore = - widget - .currentScore; + widget.currentScore; if (widget.answerType == QuizAnswerType .number) { @@ -587,7 +639,13 @@ class _QuizQuestionScreenState extends State { ), ), ), - const SizedBox(height: 6), + SizedBox( + height: + widget.answerType == + QuizAnswerType.image + ? 0 + : 6, + ), TextButton( style: TextButton.styleFrom( foregroundColor: const Color( @@ -596,10 +654,19 @@ class _QuizQuestionScreenState extends State { textStyle: const TextStyle( fontWeight: FontWeight.w800, ), + padding: + widget.answerType == + QuizAnswerType.image + ? const EdgeInsets.symmetric( + vertical: 4, + horizontal: 12, + ) + : null, ), - onPressed: () => Navigator.of( - context, - ).popUntil((route) => route.isFirst), + onPressed: () => + Navigator.of(context).popUntil( + (route) => route.isFirst, + ), child: const Text( 'Voltar para homepage', ), @@ -764,11 +831,13 @@ class _QuizAnswerTile extends StatelessWidget { required this.answer, required this.selected, required this.onTap, + this.imageAspectRatio = 4 / 3, }); final QuizAnswer answer; final bool selected; final VoidCallback onTap; + final double imageAspectRatio; @override Widget build(BuildContext context) { @@ -810,47 +879,56 @@ class _QuizAnswerTile extends StatelessWidget { onTap: onTap, splashFactory: InkSparkle.splashFactory, child: Padding( - padding: const EdgeInsets.symmetric( + padding: EdgeInsets.symmetric( horizontal: 16, - vertical: 14, + vertical: answer.hideTitle ? 8 : 14, ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (answer.imagePath != null) ...[ + if (answer.imagePath != null || + answer.imageBuilder != null) ...[ ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( - aspectRatio: 4 / 3, - child: Image.asset( - answer.imagePath!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - Container( - color: Colors.black.withValues( - alpha: 0.06, - ), - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: Colors.black38, - ), - ), + aspectRatio: imageAspectRatio, + child: answer.imageBuilder != null + ? answer.imageBuilder!(context) + : Image.asset( + answer.imagePath!, + fit: BoxFit.cover, + errorBuilder: + ( + context, + error, + stackTrace, + ) => Container( + color: Colors.black.withValues( + alpha: 0.06, + ), + child: const Center( + child: Icon( + Icons + .image_not_supported_outlined, + color: Colors.black38, + ), + ), + ), ), - ), ), ), - const SizedBox(height: 10), + if (!answer.hideTitle) const SizedBox(height: 10), ], - Text( - answer.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15, - color: Color(0xFF2F9E94), + if (!answer.hideTitle) + Text( + answer.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15, + color: Color(0xFF2F9E94), + ), ), - ), ], ), ), @@ -983,13 +1061,13 @@ class _FallbackIconBlock extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Container( - width: 64, - height: 64, + width: 52, + height: 52, decoration: BoxDecoration( color: color, - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(16), ), - child: Icon(icon, size: 32, color: Colors.white), + child: Icon(icon, size: 26, color: Colors.white), ), ); } diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index 5bb793c..1fbc612 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -6,6 +6,9 @@ import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; import 'quiz_prefs.dart'; +import 'quiz_video_guide.dart'; + +const String _resultGuideYoutubeId = '3q7C7txH1dE'; class QuizResultScreen extends StatefulWidget { const QuizResultScreen({ @@ -71,10 +74,11 @@ class _QuizResultScreenState extends State { .update({ 'last_score': widget.finalScore, 'last_max_score': widget.maxScore, - 'last_quiz_at': DateTime.now().toIso8601String(), }) .eq('id', childId) - .catchError((_) {}), + .catchError((e) { + debugPrint('[QuizResult] Falha ao gravar score na base de dados: $e'); + }), ); } } @@ -113,6 +117,13 @@ class _QuizResultScreenState extends State { children: [ const SizedBox(height: 6), FadeSlideIn( + child: const QuizGuideVideoCard( + youtubeId: _resultGuideYoutubeId, + ), + ), + const SizedBox(height: 22), + FadeSlideIn( + delay: const Duration(milliseconds: 60), child: const Text( 'A percentagem de risco\navaliada é de:', textAlign: TextAlign.center, diff --git a/lib/quiz/quiz_video_guide.dart b/lib/quiz/quiz_video_guide.dart new file mode 100644 index 0000000..ba0f445 --- /dev/null +++ b/lib/quiz/quiz_video_guide.dart @@ -0,0 +1,282 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; + +const Color _pink = Color(0xFFFF55A7); +const Color _teal = Color(0xFF2F9E94); + +/// Player do YouTube incorporado (sem AppBar/tela cheia própria) — usado +/// tanto no ecrã intersticial [QuizVideoGuideScreen] como embutido +/// diretamente no ecrã de resultado do quiz. +class QuizGuideVideoCard extends StatefulWidget { + const QuizGuideVideoCard({super.key, required this.youtubeId, this.onEnded}); + + final String youtubeId; + + /// Chamado uma única vez quando o vídeo chega ao fim. + final VoidCallback? onEnded; + + @override + State createState() => _QuizGuideVideoCardState(); +} + +class _QuizGuideVideoCardState extends State { + late final YoutubePlayerController _controller = YoutubePlayerController( + initialVideoId: widget.youtubeId, + flags: const YoutubePlayerFlags(autoPlay: true, mute: false), + ); + bool _endedNotified = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleValueChange); + } + + void _handleValueChange() { + if (_endedNotified) return; + if (_controller.value.playerState == PlayerState.ended) { + _endedNotified = true; + widget.onEnded?.call(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleValueChange); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: AspectRatio( + aspectRatio: 16 / 9, + child: YoutubePlayer( + controller: _controller, + showVideoProgressIndicator: true, + progressColors: const ProgressBarColors( + playedColor: _pink, + handleColor: _pink, + ), + ), + ), + ); + } +} + +/// Ecrã intersticial mostrado a meio do quiz, convidando a ver um vídeo +/// educativo antes de continuar. Tanto "Avançar" como "Pular" seguem para +/// [onAdvance] — o vídeo é um convite, não um bloqueio. +class QuizVideoGuideScreen extends StatefulWidget { + const QuizVideoGuideScreen({ + super.key, + required this.youtubeId, + required this.onAdvance, + this.caption = + 'Assista para compreender melhor as próximas perguntas do questionário.', + this.extraHeading, + this.extraSubtitle, + }); + + final String youtubeId; + final void Function(BuildContext context) onAdvance; + final String caption; + + /// Quando definidos, mostram um bloco de destaque extra por baixo da + /// legenda do vídeo (ex.: um aviso a preparar o utilizador para as + /// próximas perguntas de observação). + final String? extraHeading; + final String? extraSubtitle; + + @override + State createState() => _QuizVideoGuideScreenState(); +} + +class _QuizVideoGuideScreenState extends State { + // Evita que um duplo-toque (Pular + Avançar, ou o mesmo botão duas vezes + // seguidas antes da navegação trocar de ecrã) dispare onAdvance duas + // vezes sobre uma rota que já foi substituída. + bool _advanced = false; + + // "Avançar" só fica disponível depois de o vídeo chegar ao fim; "Pular" + // continua livre a qualquer momento. + bool _videoWatched = false; + + void _advance() { + if (_advanced) return; + _advanced = true; + widget.onAdvance(context); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + return Scaffold( + body: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container(color: const Color(0xFFFAFAF7)), + ), + Positioned( + left: -size.width * 0.40, + bottom: -size.width * 0.45, + child: IgnorePointer( + child: SizedBox( + width: size.width * 1.05, + height: size.width * 1.05, + child: Transform.rotate( + angle: 35 * math.pi / 180, + child: Opacity( + opacity: 0.95, + child: Lottie.asset( + 'lottie/Liquid waves.json', + fit: BoxFit.cover, + repeat: true, + ), + ), + ), + ), + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.topRight, + child: TextButton( + onPressed: _advance, + child: const Text( + 'Pular', + style: TextStyle( + color: Colors.black45, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 4), + const FadeSlideIn( + child: Text( + 'Antes de continuar, assista ao vídeo educativo...', + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.w900, + color: Colors.black87, + height: 1.25, + ), + ), + ), + const SizedBox(height: 24), + Expanded( + child: Center( + child: FadeSlideIn( + delay: const Duration(milliseconds: 80), + child: QuizGuideVideoCard( + youtubeId: widget.youtubeId, + onEnded: () => setState(() => _videoWatched = true), + ), + ), + ), + ), + const SizedBox(height: 16), + FadeSlideIn( + delay: const Duration(milliseconds: 120), + child: Text( + widget.caption, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), + height: 1.4, + ), + ), + ), + if (widget.extraHeading != null) ...[ + const SizedBox(height: 16), + FadeSlideIn( + delay: const Duration(milliseconds: 140), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _pink.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + widget.extraHeading!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: _pink, + height: 1.25, + ), + ), + if (widget.extraSubtitle != null) ...[ + const SizedBox(height: 6), + Text( + widget.extraSubtitle!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + ], + const SizedBox(height: 20), + FadeSlideIn( + delay: const Duration(milliseconds: 160), + child: TapBounce( + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: _teal, + disabledBackgroundColor: _teal.withValues( + alpha: 0.35, + ), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + onPressed: _videoWatched ? _advance : null, + child: const Text('Avançar'), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/privacy_gate_screen.dart b/lib/screens/privacy_gate_screen.dart new file mode 100644 index 0000000..2a2839f --- /dev/null +++ b/lib/screens/privacy_gate_screen.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../main.dart' show supabase; +import '../privacy_gate_prefs.dart'; +import '../terms_gate_prefs.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; +import '../widgets/privacy_content.dart'; + +/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes do +/// [TermsGateScreen] e antes de entrar na app. Só avança para o próximo +/// passo se o utilizador consentir os três itens de privacidade; se +/// recusar (botão ou seta de voltar), a conta acabada de criar é removida +/// (perfil apagado + sessão terminada) e volta-se ao login. +class PrivacyGateScreen extends StatefulWidget { + const PrivacyGateScreen({ + super.key, + required this.userId, + required this.onAccepted, + }); + + final String userId; + final VoidCallback onAccepted; + + @override + State createState() => _PrivacyGateScreenState(); +} + +class _PrivacyGateScreenState extends State { + final Set _accepted = {}; + bool _declining = false; + + bool get _allAccepted => _accepted.length == kPrivacyConsentItems.length; + + void _toggle(String id, bool value) { + setState(() { + if (value) { + _accepted.add(id); + } else { + _accepted.remove(id); + } + }); + } + + void _acceptAll() { + setState(() { + _accepted.addAll(kPrivacyConsentItems.map((e) => e.id)); + }); + } + + Future _decline() async { + if (_declining) return; + setState(() => _declining = true); + try { + await supabase + .from('profiles') + .delete() + .eq('id', widget.userId) + .timeout(const Duration(seconds: 20)); + } catch (_) { + // Mesmo que a limpeza do perfil falhe, termina a sessão de qualquer + // forma — o AuthGate trata contas sem perfil como inexistentes. + } + await PrivacyGatePrefs.clearPendingUid(); + // A conta vai ser apagada — não faz sentido deixar o utilizador cair + // no ecrã de Termos logo a seguir com uma sessão já sem perfil. + await TermsGatePrefs.clearPendingUid(); + await supabase.auth.signOut(); + // Não faz setState depois disto: o widget é removido da árvore assim + // que o AuthGate reage à sessão terminada. + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _decline(); + }, + child: Scaffold( + body: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container(color: const Color(0xFFFAFAF7)), + ), + Positioned( + left: -size.width * 0.38, + bottom: -size.width * 0.38, + child: IgnorePointer( + child: SizedBox( + width: size.width * 1.05, + height: size.width * 1.05, + child: Transform.rotate( + angle: 35 * math.pi / 180, + child: Opacity( + opacity: 0.95, + child: Lottie.asset( + 'lottie/Liquid waves.json', + fit: BoxFit.cover, + repeat: true, + ), + ), + ), + ), + ), + ), + SafeArea( + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: IconButton( + onPressed: _declining ? null : _decline, + icon: const Icon(Icons.close_rounded), + color: Colors.black54, + ), + ), + // Só o cabeçalho e a lista de itens rolam — os botões + // ficam fixos, fora do SingleChildScrollView, tal como + // no TermsGateScreen. + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 4), + child: Column( + children: [ + const FadeSlideIn(child: PrivacyHeader()), + const SizedBox(height: 22), + FadeSlideIn( + delay: const Duration(milliseconds: 80), + child: _PrivacyConsentList( + accepted: _accepted, + onChanged: _declining ? null : _toggle, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + child: Column( + children: [ + TapBounce( + child: TextButton( + onPressed: _declining ? null : _acceptAll, + child: const Text( + 'Aceitar tudo', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + color: kPrivacyTeal, + ), + ), + ), + ), + const SizedBox(height: 8), + _AdvanceButton( + enabled: _allAccepted && !_declining, + onPressed: widget.onAccepted, + ), + ], + ), + ), + ], + ), + ), + if (_declining) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.12), + child: const Center( + child: CircularProgressIndicator(color: kPrivacyTeal), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PrivacyConsentList extends StatelessWidget { + const _PrivacyConsentList({required this.accepted, required this.onChanged}); + + final Set accepted; + final void Function(String id, bool value)? onChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (var i = 0; i < kPrivacyConsentItems.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + _PrivacyConsentRow( + item: kPrivacyConsentItems[i], + checked: accepted.contains(kPrivacyConsentItems[i].id), + onChanged: onChanged == null + ? null + : (v) => onChanged!(kPrivacyConsentItems[i].id, v), + ), + ], + ], + ); + } +} + +class _PrivacyConsentRow extends StatelessWidget { + const _PrivacyConsentRow({ + required this.item, + required this.checked, + required this.onChanged, + }); + + final PrivacyConsentItem item; + final bool checked; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + return TapBounce( + scale: 0.98, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onChanged == null ? null : () => onChanged!(!checked), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + height: 24, + // IgnorePointer: o Checkbox tem um tap target mínimo de + // 48x48 que competia na arena de gestos com o InkWell da + // linha toda — o mesmo problema já resolvido no + // TermsGateScreen. Aqui serve só para o visual; quem trata + // o toque é sempre o InkWell à volta de toda a linha. + child: IgnorePointer( + child: Checkbox( + value: checked, + onChanged: onChanged == null ? null : (_) {}, + activeColor: kPrivacyTeal, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + item.text, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + height: 1.45, + color: Colors.black.withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AdvanceButton extends StatelessWidget { + const _AdvanceButton({required this.enabled, required this.onPressed}); + + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TapBounce( + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: kPrivacyTeal, + disabledBackgroundColor: kPrivacyTeal.withValues(alpha: 0.35), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + onPressed: enabled ? onPressed : null, + child: const Text('Avançar'), + ), + ), + ); + } +} diff --git a/lib/screens/terms_gate_screen.dart b/lib/screens/terms_gate_screen.dart new file mode 100644 index 0000000..c21fb8e --- /dev/null +++ b/lib/screens/terms_gate_screen.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +import '../main.dart' show supabase; +import '../terms_gate_prefs.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; +import '../widgets/terms_content.dart'; + +/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes de +/// entrar na app. Só avança para a Home se o utilizador aceitar os Termos; +/// se recusar (botão ou seta de voltar), a conta acabada de criar é +/// removida (perfil apagado + sessão terminada) e volta-se ao login. +class TermsGateScreen extends StatefulWidget { + const TermsGateScreen({ + super.key, + required this.userId, + required this.onAccepted, + }); + + final String userId; + final VoidCallback onAccepted; + + @override + State createState() => _TermsGateScreenState(); +} + +class _TermsGateScreenState extends State { + bool _accepted = false; + bool _declining = false; + + Future _decline() async { + if (_declining) return; + setState(() => _declining = true); + try { + await supabase + .from('profiles') + .delete() + .eq('id', widget.userId) + .timeout(const Duration(seconds: 20)); + } catch (_) { + // Mesmo que a limpeza do perfil falhe, termina a sessão de qualquer + // forma — o AuthGate trata contas sem perfil como inexistentes. + } + await TermsGatePrefs.clearPendingUid(); + await supabase.auth.signOut(); + // Não faz setState depois disto: o widget é removido da árvore assim + // que o AuthGate reage à sessão terminada. + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _decline(); + }, + child: Scaffold( + body: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container(color: const Color(0xFFFAFAF7)), + ), + Positioned( + left: -size.width * 0.38, + bottom: -size.width * 0.38, + child: IgnorePointer( + child: SizedBox( + width: size.width * 1.05, + height: size.width * 1.05, + child: Transform.rotate( + angle: 35 * math.pi / 180, + child: Opacity( + opacity: 0.95, + child: Lottie.asset( + 'lottie/Liquid waves.json', + fit: BoxFit.cover, + repeat: true, + ), + ), + ), + ), + ), + ), + SafeArea( + child: Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: IconButton( + onPressed: _declining ? null : _decline, + icon: const Icon(Icons.close_rounded), + color: Colors.black54, + ), + ), + // O checkbox e o botão ficam fixos, fora do + // SingleChildScrollView — só o texto dos termos rola. Além + // de ser o padrão comum em ecrãs de Termos (ação sempre + // visível, sem precisar de chegar ao fundo do texto), evita + // que a ação de aceitar dependa de o scroll estar + // completamente parado. + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 4), + child: Column( + children: [ + const FadeSlideIn(child: TermsHeader()), + const SizedBox(height: 22), + FadeSlideIn( + delay: const Duration(milliseconds: 80), + child: const TermsBody(), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + child: Column( + children: [ + _AcceptCheckboxRow( + accepted: _accepted, + onChanged: _declining + ? null + : (v) => setState(() => _accepted = v), + ), + const SizedBox(height: 18), + _AdvanceButton( + enabled: _accepted && !_declining, + onPressed: widget.onAccepted, + ), + ], + ), + ), + ], + ), + ), + if (_declining) + Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.12), + child: const Center( + child: CircularProgressIndicator(color: kTermsPink), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _AcceptCheckboxRow extends StatelessWidget { + const _AcceptCheckboxRow({required this.accepted, required this.onChanged}); + + final bool accepted; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + return TapBounce( + scale: 0.98, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onChanged == null ? null : () => onChanged!(!accepted), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + // IgnorePointer: o Checkbox tem um tap target mínimo de + // 48x48 que, espremido neste SizedBox de 24x24 dentro do + // InkWell da linha toda, competia na arena de gestos e + // acabava por engolir o toque sem disparar nada. Aqui serve + // só para o visual (marcado/desmarcado); quem trata o toque + // é sempre o InkWell à volta de toda a linha. + child: IgnorePointer( + child: Checkbox( + value: accepted, + onChanged: onChanged == null ? null : (_) {}, + activeColor: kTermsPink, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ), + const SizedBox(width: 10), + const Text( + 'Aceitar tudo', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + color: kTermsPink, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AdvanceButton extends StatelessWidget { + const _AdvanceButton({required this.enabled, required this.onPressed}); + + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TapBounce( + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: kTermsPink, + disabledBackgroundColor: kTermsPink.withValues(alpha: 0.35), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + onPressed: enabled ? onPressed : null, + child: const Text('Avançar'), + ), + ), + ); + } +} diff --git a/lib/screens/terms_screen.dart b/lib/screens/terms_screen.dart index dc5efef..3b3309f 100644 --- a/lib/screens/terms_screen.dart +++ b/lib/screens/terms_screen.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import '../widgets/app_gradients.dart'; +import '../widgets/terms_content.dart'; class TermsScreen extends StatelessWidget { const TermsScreen({super.key}); - static const Color _accentPink = Color(0xFFFF55A7); - @override Widget build(BuildContext context) { return Scaffold( @@ -30,41 +29,13 @@ class TermsScreen extends StatelessWidget { body: Container( color: const Color(0xFFFAFAF7), child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'Termos de Serviço', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: _accentPink, - ), - ), - const SizedBox(height: 16), - Expanded( - child: Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(20), - ), - child: const SingleChildScrollView( - child: Text( - 'Conteúdo dos Termos de Serviço em breve.\n\n' - 'Este espaço será preenchido com os termos de uso e ' - 'condições do Check-Teeth Kids.', - style: TextStyle( - fontSize: 14, - height: 1.5, - color: Colors.black87, - ), - ), - ), - ), - ), + const TermsHeader(), + const SizedBox(height: 22), + const TermsBody(), ], ), ), diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index eea6420..96970c6 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -319,7 +319,10 @@ class _VideoScreenState extends State { ), ), const SizedBox(height: 16), - // Video grid + // Lista vertical (um vídeo abaixo do outro, rolando para + // baixo), mas cada card em si é horizontal — miniatura à + // esquerda, título/descrição à direita — em vez da antiga + // grelha de 2 colunas com cards verticais. Expanded( child: _filteredVideos.isEmpty ? Center( @@ -332,15 +335,10 @@ class _VideoScreenState extends State { ), ), ) - : GridView.builder( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 0.85, - ), + : ListView.separated( itemCount: _filteredVideos.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), itemBuilder: (context, index) { return FadeSlideIn( delay: Duration( @@ -546,112 +544,127 @@ class _VideoButton extends StatelessWidget { @override Widget build(BuildContext context) { return TapBounce( - scale: 0.95, + scale: 0.97, child: Material( - elevation: 10, - shadowColor: Colors.black.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(18), - color: Colors.white, - child: InkWell( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(18), - onTap: () => _showVideoPlayer(context, video), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: const Color(0xFFFFE6F1), - child: VideoThumbnail(video: video, borderRadius: 0), - ), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.32), - ], - stops: const [0.55, 1.0], - ), + color: Colors.white, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => _showVideoPlayer(context, video), + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: const Color(0xFFFFE6F1), + child: VideoThumbnail(video: video, borderRadius: 0), ), - ), - Center( - child: Container( - width: 34, - height: 34, + DecoratedBox( decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.92), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: VideoScreen._accentPink, - size: 20, - ), - ), - ), - Positioned( - left: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'EP. ${video.id}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w800, - fontSize: 9.5, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.32), + ], + stops: const [0.55, 1.0], ), ), ), + Center( + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: VideoScreen._accentPink, + size: 20, + ), + ), + ), + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'EP. ${video.id}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 9.5, + ), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15, + color: VideoScreen._teal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + video.description, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), ), - ), - const SizedBox(height: 8), - Text( - video.title, - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 14, - color: VideoScreen._teal, + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + color: Colors.black.withValues(alpha: 0.3), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - video.description, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.black.withValues(alpha: 0.6), - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), ), ), - ), ); } } @@ -733,24 +746,73 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> if (!didPop) _controller.toggleFullScreenMode(); }, child: Scaffold( - backgroundColor: Colors.black, + backgroundColor: value.isFullScreen + ? Colors.black + : const Color(0xFFFAFAF7), appBar: value.isFullScreen ? null - : AppBar( - backgroundColor: VideoScreen._teal, - foregroundColor: Colors.white, - elevation: 0, - title: Text( - widget.video.title, - style: const TextStyle(fontWeight: FontWeight.w900), + : PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Container( + decoration: const BoxDecoration( + gradient: kAppBarGradient, + ), + child: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + widget.video.title, + style: const TextStyle(fontWeight: FontWeight.w900), + ), + ), ), ), + // Em vez de centrar o vídeo num quadro 16:9 no meio de um ecrã + // preto (o que sobrava como barras pretas em cima/baixo no + // modo retrato), o vídeo fica encostado ao topo, a preencher a + // largura toda, com o título e a descrição do episódio logo + // abaixo — sem nenhum espaço preto sobrando. O modo tela cheia + // (paisagem) continua a recortar o vídeo para preencher tudo. body: value.isFullScreen ? _CoverYoutubePlayer(controller: _controller) - : Center( - child: AspectRatio( - aspectRatio: 16 / 9, - child: YoutubePlayer(controller: _controller), + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: YoutubePlayer(controller: _controller), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.video.title, + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 19, + color: VideoScreen._teal, + ), + ), + const SizedBox(height: 8), + Text( + widget.video.description, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.65), + height: 1.4, + ), + ), + ], + ), + ), + ], ), ), ), diff --git a/lib/terms_gate_prefs.dart b/lib/terms_gate_prefs.dart new file mode 100644 index 0000000..a4a7a4d --- /dev/null +++ b/lib/terms_gate_prefs.dart @@ -0,0 +1,26 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Guarda em disco o uid de uma conta que acabou de se registar e ainda não +/// aceitou os Termos e Condições. Um simples valor em memória não chega: +/// se o sistema matar o processo entre o cadastro e a aceitação (comum em +/// Android quando a app fica em segundo plano), reabrir a app perderia o +/// estado "pendente" e o utilizador entraria direto na Home sem nunca ter +/// aceitado nada. +class TermsGatePrefs { + static const String _kPendingUidKey = 'pending_terms_uid'; + + static Future getPendingUid() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kPendingUidKey); + } + + static Future setPendingUid(String uid) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kPendingUidKey, uid); + } + + static Future clearPendingUid() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kPendingUidKey); + } +} diff --git a/lib/widgets/privacy_content.dart b/lib/widgets/privacy_content.dart new file mode 100644 index 0000000..c60512b --- /dev/null +++ b/lib/widgets/privacy_content.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +const Color kPrivacyTeal = Color(0xFF2F9E94); + +/// Um dos três itens de consentimento mostrados no ecrã de privacidade. +class PrivacyConsentItem { + const PrivacyConsentItem({required this.id, required this.text}); + + final String id; + final String text; +} + +/// Texto fixo dos consentimentos de privacidade, mostrado no cadastro. +const List kPrivacyConsentItems = [ + PrivacyConsentItem( + id: 'health_data', + text: + 'Concordo com o processamento dos meus dados pessoais de saúde para ' + 'ter acesso aos recursos do aplicativo Check-Teeth Kids. Veja mais ' + 'na Política de Privacidade.', + ), + PrivacyConsentItem( + id: 'privacy_policy', + text: 'Concordo com a Política de Privacidade e com os Termos de Uso.', + ), + PrivacyConsentItem( + id: 'tracking', + text: + 'Autorizo o CK-T-KD a rastrear a minha atividade na app. Podem ' + 'receber dados como minha faixa etária, status da assinatura, ' + 'momento de abertura da app e identificadores técnicos, conforme ' + 'descrito na Política de Privacidade. Isso ajuda a CK-T-KD a ' + 'aprimorar as suas campanhas publicitárias. *', + ), +]; + +/// Emblema circular + título "Privacidade em primeiro lugar", reutilizado +/// onde este conteúdo for mostrado. +class PrivacyHeader extends StatelessWidget { + const PrivacyHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: kPrivacyTeal.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.privacy_tip_rounded, + color: kPrivacyTeal, + size: 36, + ), + ), + const SizedBox(height: 16), + const Text( + 'Privacidade em primeiro lugar', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.black, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/terms_content.dart b/lib/widgets/terms_content.dart new file mode 100644 index 0000000..23d6442 --- /dev/null +++ b/lib/widgets/terms_content.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +const Color kTermsPink = Color(0xFFFF55A7); + +/// Parágrafos dos Termos e Condições — texto fixo, igual em todo o app +/// (ecrã de bloqueio no cadastro e ecrã informativo em Ajustes). +const List kTermsParagraphs = [ + '© 2026, Francisca Salgado Ferreira Pacheco Silva. A aplicação, o método ' + 'de avaliação, arquitetura e conteúdos são propriedade intelectual ' + 'exclusiva da autora.', + 'A Check Teeth Kids é uma aplicação desenvolvida com o propósito de ' + 'promover a literacia em saúde oral infantil, através de conteúdos ' + 'educativos e questionários interativos, com especial enfoque na ' + 'identificação precoce de sinais associados à má oclusão dentária.', + 'Pertence-se uma avaliação orientadora do risco de má oclusão nas ' + 'crianças, com base nas respostas fornecidas. É gerada uma estimativa ' + 'percentual de risco, que pretende auxiliar os utilizadores na tomada ' + 'de decisão quanto à necessidade de procurar uma avaliação clínica ' + 'especializada.', + 'Não tem como objetivo realizar diagnósticos clínicos nem substituir a ' + 'avaliação por um profissional de saúde oral. Esta ferramenta digital ' + 'destina-se exclusivamente a uma triagem inicial e informativa, ' + 'promovendo o encaminhamento atempado para um médico dentista ' + 'especialista (ortodontista ou odontopediatra).', + 'A utilização desta aplicação deve ser encarada como um complemento ' + 'educativo e de sensibilização, reforçando a importância do ' + 'acompanhamento regular por profissionais qualificados e contribuindo ' + 'para a deteção precoce de possíveis alterações no desenvolvimento ' + 'oral infantil.', +]; + +/// Emblema circular rosa + título "Termos e Condições", reutilizado nos +/// dois ecrãs que mostram este conteúdo. +class TermsHeader extends StatelessWidget { + const TermsHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: kTermsPink.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.gavel_rounded, + color: kTermsPink, + size: 36, + ), + ), + const SizedBox(height: 16), + const Text( + 'Termos e Condições', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.black, + ), + ), + ], + ); + } +} + +/// Corpo de texto dos Termos, dentro de um cartão branco. +class TermsBody extends StatelessWidget { + const TermsBody({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < kTermsParagraphs.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + Text( + kTermsParagraphs[i], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.5, + color: Colors.black.withValues(alpha: 0.78), + ), + ), + ], + ], + ), + ); + } +}