Atulização do quiz | Porcentagem na base de dados | Telas de Contrato com o usuario

This commit is contained in:
Carlos Correia
2026-07-13 22:28:01 +01:00
parent 824fdb8089
commit 4fcf3a475a
23 changed files with 2061 additions and 369 deletions

View File

@@ -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<void> quizStartRoute({String? scopeId}) {
return MaterialPageRoute<void>(
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<void>(
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<void>(
builder: (_) => Quiz18Screen(currentScore: nextScore, scopeId: scopeId),
builder: (_) => QuizVideoGuideScreen(
youtubeId: 'W2BcK9nSyt0',
onAdvance: (ctx) => Navigator.of(ctx).pushReplacement(
MaterialPageRoute<void>(
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<void>(
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: '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<void>(
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<void>(
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<void>(
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: '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<void>(
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: '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<void>(
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<void>(
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<void>(
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<void>(
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<void>(
builder: (_) => QuizResultScreen(
finalScore: nextScore,
maxScore: 46,
maxScore: 52,
scopeId: scopeId,
),
),
answerType: QuizAnswerType.yesNo,
answerType: QuizAnswerType.image,
isFinal: true,
showBackButton: true,
);

View File

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

View File

@@ -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<QuizQuestionScreen> {
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<QuizQuestionScreen> {
),
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<QuizQuestionScreen> {
: _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<QuizQuestionScreen> {
'Ver vídeo (opcional)',
style: const TextStyle(
color: Color(0xFF2F9E94),
fontWeight:
FontWeight.w800,
fontWeight: FontWeight.w800,
),
),
),
@@ -378,8 +395,9 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
: '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<QuizQuestionScreen> {
),
),
),
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<QuizQuestionScreen> {
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<QuizQuestionScreen> {
.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<QuizQuestionScreen> {
],
),
),
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<QuizQuestionScreen> {
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<QuizQuestionScreen> {
textStyle:
const TextStyle(
fontWeight:
FontWeight
.w900,
FontWeight.w900,
),
).copyWith(
animationDuration:
@@ -491,16 +547,14 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
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<QuizQuestionScreen> {
return Colors
.white
.withValues(
alpha:
0.08,
alpha: 0.08,
);
}
return null;
@@ -529,8 +582,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
true,
);
int nextScore =
widget
.currentScore;
widget.currentScore;
if (widget.answerType ==
QuizAnswerType
.number) {
@@ -587,7 +639,13 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
),
),
),
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<QuizQuestionScreen> {
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),
),
);
}

View File

@@ -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<QuizResultScreen> {
.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<QuizResultScreen> {
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,

View File

@@ -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<QuizGuideVideoCard> createState() => _QuizGuideVideoCardState();
}
class _QuizGuideVideoCardState extends State<QuizGuideVideoCard> {
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<QuizVideoGuideScreen> createState() => _QuizVideoGuideScreenState();
}
class _QuizVideoGuideScreenState extends State<QuizVideoGuideScreen> {
// 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'),
),
),
),
),
],
),
),
),
],
),
);
}
}