Atualização de gauge

This commit is contained in:
Carlos Correia
2026-07-14 18:23:03 +01:00
parent 4fcf3a475a
commit 5b25d9bef4
22 changed files with 973 additions and 1441 deletions

View File

@@ -7,12 +7,22 @@ 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}) {
Widget _zoomCrop(
String path, {
Alignment alignment = Alignment.center,
double scale = 2.0,
// Alinhamento do próprio Image.asset ao aplicar o BoxFit.cover — separado
// do alignment do Transform.scale acima, porque o cover já decide (antes
// do zoom) que fatia vertical/horizontal da foto original mostrar; se essa
// fatia inicial não incluir a zona pretendida (ex.: queixo cortado fora),
// nenhum scale/alignment do Transform consegue "recuperar" esses pixels.
Alignment imageAlignment = Alignment.center,
}) {
return ClipRect(
child: Transform.scale(
scale: scale,
alignment: alignment,
child: Image.asset(path, fit: BoxFit.cover),
child: Image.asset(path, fit: BoxFit.cover, alignment: imageAlignment),
),
);
}
@@ -30,9 +40,7 @@ MaterialPageRoute<void> quizStartRoute({String? scopeId}) {
'Quando deve procurar um dentista (urgência vs vigilância)',
],
onAdvance: (context) => Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => Quiz1Screen(scopeId: scopeId),
),
MaterialPageRoute<void>(builder: (_) => Quiz1Screen(scopeId: scopeId)),
),
),
);
@@ -40,15 +48,19 @@ MaterialPageRoute<void> quizStartRoute({String? scopeId}) {
// Quiz 1: Problemas respiratórios (Yes/No)
class Quiz1Screen extends StatelessWidget {
const Quiz1Screen({super.key, this.currentScore = 0, this.scopeId});
const Quiz1Screen({
super.key,
this.currentScore = const QuizScore(),
this.scopeId,
});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 1/26',
title: 'Quiz 1/27',
category: 'Saúde respiratória',
categoryIcon: Icons.medical_information_rounded,
fallbackIcon: Icons.medical_information_rounded,
@@ -89,13 +101,13 @@ class Quiz1Screen extends StatelessWidget {
class Quiz2Screen extends StatelessWidget {
const Quiz2Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 2/26',
title: 'Quiz 2/27',
category: 'Respiração',
categoryIcon: Icons.air_rounded,
fallbackIcon: Icons.air_rounded,
@@ -136,13 +148,13 @@ class Quiz2Screen extends StatelessWidget {
class Quiz3Screen extends StatelessWidget {
const Quiz3Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 3/26',
title: 'Quiz 3/27',
category: 'Sono',
categoryIcon: Icons.bedtime_rounded,
fallbackIcon: Icons.bedtime_rounded,
@@ -183,13 +195,13 @@ class Quiz3Screen extends StatelessWidget {
class Quiz4Screen extends StatelessWidget {
const Quiz4Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 4/26',
title: 'Quiz 4/27',
category: 'Respiração',
categoryIcon: Icons.sick_rounded,
fallbackIcon: Icons.sick_rounded,
@@ -230,13 +242,13 @@ class Quiz4Screen extends StatelessWidget {
class Quiz5Screen extends StatelessWidget {
const Quiz5Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 5/26',
title: 'Quiz 5/27',
category: 'Sono',
categoryIcon: Icons.nights_stay_rounded,
fallbackIcon: Icons.nights_stay_rounded,
@@ -279,13 +291,13 @@ class Quiz5Screen extends StatelessWidget {
class Quiz6Screen extends StatelessWidget {
const Quiz6Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 6/26',
title: 'Quiz 6/27',
category: 'Hábitos noturnos',
categoryIcon: Icons.nights_stay_rounded,
fallbackIcon: Icons.nights_stay_rounded,
@@ -319,13 +331,13 @@ class Quiz6Screen extends StatelessWidget {
class Quiz7Screen extends StatelessWidget {
const Quiz7Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 7/26',
title: 'Quiz 7/27',
category: 'Saúde geral',
categoryIcon: Icons.local_florist_rounded,
fallbackIcon: Icons.local_florist_rounded,
@@ -366,13 +378,13 @@ class Quiz7Screen extends StatelessWidget {
class Quiz8Screen extends StatelessWidget {
const Quiz8Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 8/26',
title: 'Quiz 8/27',
category: 'Sono',
categoryIcon: Icons.water_drop_rounded,
fallbackIcon: Icons.water_drop_rounded,
@@ -413,13 +425,13 @@ class Quiz8Screen extends StatelessWidget {
class Quiz9Screen extends StatelessWidget {
const Quiz9Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 9/26',
title: 'Quiz 9/27',
category: 'Saúde geral',
categoryIcon: Icons.hearing_rounded,
fallbackIcon: Icons.hearing_rounded,
@@ -460,13 +472,13 @@ class Quiz9Screen extends StatelessWidget {
class Quiz10Screen extends StatelessWidget {
const Quiz10Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 10/26',
title: 'Quiz 10/27',
category: 'Saúde geral',
categoryIcon: Icons.healing_rounded,
fallbackIcon: Icons.healing_rounded,
@@ -507,13 +519,13 @@ class Quiz10Screen extends StatelessWidget {
class Quiz11Screen extends StatelessWidget {
const Quiz11Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 11/26',
title: 'Quiz 11/27',
category: 'Saúde respiratória',
categoryIcon: Icons.air_rounded,
fallbackIcon: Icons.air_rounded,
@@ -555,13 +567,13 @@ class Quiz11Screen extends StatelessWidget {
class Quiz12Screen extends StatelessWidget {
const Quiz12Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 12/26',
title: 'Quiz 12/27',
category: 'Hábitos alimentares',
categoryIcon: Icons.restaurant_rounded,
fallbackIcon: Icons.restaurant_rounded,
@@ -595,13 +607,13 @@ class Quiz12Screen extends StatelessWidget {
class Quiz13Screen extends StatelessWidget {
const Quiz13Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 13/26',
title: 'Quiz 13/27',
category: 'Hábitos alimentares',
categoryIcon: Icons.schedule_rounded,
fallbackIcon: Icons.schedule_rounded,
@@ -635,13 +647,13 @@ class Quiz13Screen extends StatelessWidget {
class Quiz14Screen extends StatelessWidget {
const Quiz14Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 14/26',
title: 'Quiz 14/27',
category: 'Hábitos alimentares',
categoryIcon: Icons.restaurant_menu_rounded,
fallbackIcon: Icons.restaurant_menu_rounded,
@@ -675,13 +687,13 @@ class Quiz14Screen extends StatelessWidget {
class Quiz15Screen extends StatelessWidget {
const Quiz15Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 15/26',
title: 'Quiz 15/27',
category: 'Hábitos alimentares',
categoryIcon: Icons.local_drink_rounded,
fallbackIcon: Icons.local_drink_rounded,
@@ -722,13 +734,13 @@ class Quiz15Screen extends StatelessWidget {
class Quiz16Screen extends StatelessWidget {
const Quiz16Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 16/26',
title: 'Quiz 16/27',
category: 'Hábitos orais',
categoryIcon: Icons.child_care_rounded,
fallbackIcon: Icons.child_care_rounded,
@@ -769,13 +781,13 @@ class Quiz16Screen extends StatelessWidget {
class Quiz17Screen extends StatelessWidget {
const Quiz17Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 17/26',
title: 'Quiz 17/27',
category: 'Hábitos orais',
categoryIcon: Icons.back_hand_rounded,
fallbackIcon: Icons.back_hand_rounded,
@@ -824,13 +836,13 @@ class Quiz17Screen extends StatelessWidget {
class Quiz18Screen extends StatelessWidget {
const Quiz18Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 18/26',
title: 'Quiz 18/27',
category: 'Avaliação postural',
categoryIcon: Icons.accessibility_new_rounded,
fallbackIcon: Icons.accessibility_new_rounded,
@@ -870,13 +882,13 @@ class Quiz18Screen extends StatelessWidget {
class Quiz19Screen extends StatelessWidget {
const Quiz19Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 19/26',
title: 'Quiz 19/27',
category: 'Avaliação facial',
categoryIcon: Icons.face_rounded,
fallbackIcon: Icons.face_rounded,
@@ -935,20 +947,20 @@ class Quiz19Screen extends StatelessWidget {
class Quiz20Screen extends StatelessWidget {
const Quiz20Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 20/26',
title: 'Quiz 20/27',
category: 'Avaliação facial',
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(
answers: [
const QuizAnswer(
title: 'Boca fechada',
description: 'Boca fechada habitualmente',
weight: 1,
@@ -962,16 +974,17 @@ class Quiz20Screen extends StatelessWidget {
weight: 2,
hideTitle: true,
value: 'boca_entreaberta',
imagePath: 'assets/mockup_images/4.jpeg',
imageBuilder: (context) => _zoomCrop(
'assets/mockup_images/4.jpeg',
alignment: const Alignment(0, 0.45),
scale: 1.8,
),
),
],
currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
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: (_) =>
@@ -990,13 +1003,13 @@ class Quiz20Screen extends StatelessWidget {
class Quiz21Screen extends StatelessWidget {
const Quiz21Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 21/26',
title: 'Quiz 21/27',
category: 'Avaliação facial',
categoryIcon: Icons.visibility_rounded,
fallbackIcon: Icons.visibility_rounded,
@@ -1043,29 +1056,35 @@ class Quiz21Screen extends StatelessWidget {
class Quiz22Screen extends StatelessWidget {
const Quiz22Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 22/26',
title: 'Quiz 22/27',
category: 'Avaliação facial',
categoryIcon: Icons.face_rounded,
fallbackIcon: Icons.face_rounded,
fallbackColor: const Color(0xFF8E7CC3),
isSignQuestion: true,
question:
'Qual das imagens é mais parecida com o queixo do seu filho/a com a boca fechada?',
answers: const [
answers: [
QuizAnswer(
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',
imageBuilder: (context) => _zoomCrop(
'assets/mockup_images/5.png',
imageAlignment: const Alignment(0, 1),
alignment: const Alignment(0, 1),
scale: 1.8,
),
),
QuizAnswer(
const QuizAnswer(
title: 'Queixo tenso',
description: 'Queixo tenso/franzido com a boca fechada',
weight: 2,
@@ -1088,18 +1107,20 @@ class Quiz22Screen extends StatelessWidget {
class Quiz23Screen extends StatelessWidget {
const Quiz23Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 23/26',
title: 'Quiz 23/27',
category: 'Avaliação facial',
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?',
isSignQuestion: true,
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
answers: const [
QuizAnswer(
title: 'Dentição alinhada',
@@ -1107,7 +1128,7 @@ class Quiz23Screen extends StatelessWidget {
weight: 1,
hideTitle: true,
value: 'dentes_alinhados',
imagePath: 'assets/mockup_images/24.png',
imagePath: 'assets/mockup_images/11.png',
),
QuizAnswer(
title: 'Dentição desalinhada',
@@ -1115,7 +1136,7 @@ class Quiz23Screen extends StatelessWidget {
weight: 2,
hideTitle: true,
value: 'dentes_desalinhados',
imagePath: 'assets/mockup_images/23.jpeg',
imagePath: 'assets/mockup_images/13.png',
),
QuizAnswer(
title: 'Dentição sobreposta',
@@ -1123,7 +1144,7 @@ class Quiz23Screen extends StatelessWidget {
weight: 2,
hideTitle: true,
value: 'dentes_sobrepostos',
imagePath: 'assets/mockup_images/14.jpeg',
imagePath: 'assets/mockup_images/10.png',
),
],
currentScore: currentScore,
@@ -1136,17 +1157,71 @@ class Quiz23Screen extends StatelessWidget {
}
}
// Quiz 24: Freio labial (Image-choice)
// Quiz 24: Apinhamento visto de perto (Image-choice)
class Quiz24Screen extends StatelessWidget {
const Quiz24Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 24/26',
title: 'Quiz 24/27',
category: 'Avaliação facial',
categoryIcon: Icons.zoom_in_rounded,
fallbackIcon: Icons.zoom_in_rounded,
fallbackColor: const Color(0xFF2F9E94),
isSignQuestion: true,
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
answers: const [
QuizAnswer(
title: 'Ligeiro desalinhamento',
description: 'Pequeno desalinhamento ou espaço entre alguns dentes',
weight: 1,
hideTitle: true,
value: 'apinhamento_leve',
imagePath: 'assets/mockup_images/16.png',
),
QuizAnswer(
title: 'Apinhamento moderado',
description: 'Dentes rodados/desalinhados de forma mais visível',
weight: 2,
hideTitle: true,
value: 'apinhamento_moderado',
imagePath: 'assets/mockup_images/15.png',
),
QuizAnswer(
title: 'Apinhamento acentuado',
description: 'Dentes muito sobrepostos entre si',
weight: 2,
hideTitle: true,
value: 'apinhamento_acentuado',
imagePath: 'assets/mockup_images/14.jpeg',
),
],
currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
),
answerType: QuizAnswerType.image,
showBackButton: true,
);
}
}
// Quiz 25: Freio labial (Image-choice)
class Quiz25Screen extends StatelessWidget {
const Quiz25Screen({super.key, required this.currentScore, this.scopeId});
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 25/27',
category: 'Avaliação facial',
categoryIcon: Icons.record_voice_over_rounded,
fallbackIcon: Icons.record_voice_over_rounded,
@@ -1173,7 +1248,7 @@ class Quiz24Screen extends StatelessWidget {
],
currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
),
answerType: QuizAnswerType.image,
showBackButton: true,
@@ -1181,17 +1256,17 @@ class Quiz24Screen extends StatelessWidget {
}
}
// Quiz 25: Freio lingual (Image-choice)
class Quiz25Screen extends StatelessWidget {
const Quiz25Screen({super.key, required this.currentScore, this.scopeId});
// Quiz 26: Freio lingual (Image-choice)
class Quiz26Screen extends StatelessWidget {
const Quiz26Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 25/26',
title: 'Quiz 26/27',
category: 'Avaliação facial',
categoryIcon: Icons.record_voice_over_rounded,
fallbackIcon: Icons.record_voice_over_rounded,
@@ -1218,7 +1293,7 @@ class Quiz25Screen extends StatelessWidget {
],
currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
builder: (_) => Quiz27Screen(currentScore: nextScore, scopeId: scopeId),
),
answerType: QuizAnswerType.image,
showBackButton: true,
@@ -1226,21 +1301,22 @@ class Quiz25Screen extends StatelessWidget {
}
}
// Quiz 26: Céu da boca (Image-choice, final)
class Quiz26Screen extends StatelessWidget {
const Quiz26Screen({super.key, required this.currentScore, this.scopeId});
// Quiz 27: Céu da boca (Image-choice, final)
class Quiz27Screen extends StatelessWidget {
const Quiz27Screen({super.key, required this.currentScore, this.scopeId});
final int currentScore;
final QuizScore currentScore;
final String? scopeId;
@override
Widget build(BuildContext context) {
return QuizQuestionScreen(
title: 'Quiz 26/26',
title: 'Quiz 27/27',
category: 'Avaliação facial',
categoryIcon: Icons.architecture_rounded,
fallbackIcon: Icons.architecture_rounded,
fallbackColor: const Color(0xFFFF55A7),
isSignQuestion: true,
question:
'Qual das seguintes imagens se assemelha ao céu da boca do seu filho/a?',
answers: const [
@@ -1263,11 +1339,7 @@ class Quiz26Screen extends StatelessWidget {
],
currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
builder: (_) => QuizResultScreen(
finalScore: nextScore,
maxScore: 52,
scopeId: scopeId,
),
builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId),
),
answerType: QuizAnswerType.image,
isFinal: true,

View File

@@ -1,9 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import '../widgets/entrance.dart';
import '../widgets/liquid_waves_background.dart';
import '../widgets/tap_bounce.dart';
const Color _pink = Color(0xFFFF55A7);
@@ -28,33 +26,12 @@ class QuizChecklistScreen extends StatelessWidget {
@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,
),
),
),
),
),
),
const LiquidWavesBackground(),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),

View File

@@ -1,9 +1,27 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Resultado do quiz, sempre com os dois indicadores juntos — nunca faz
/// sentido guardar/ler um sem o outro.
class QuizResultData {
const QuizResultData({
required this.signs,
required this.signsMax,
required this.factors,
required this.factorsMax,
});
final int signs;
final int signsMax;
final int factors;
final int factorsMax;
}
class QuizPrefs {
static const String _kSeenQuizKey = 'seen_oral_quiz_v1';
static const String _kLastScoreKey = 'last_oral_quiz_score_v1';
static const String _kLastMaxScoreKey = 'last_oral_quiz_max_score_v1';
static const String _kSignsKey = 'last_oral_quiz_signs_v2';
static const String _kSignsMaxKey = 'last_oral_quiz_signs_max_v2';
static const String _kFactorsKey = 'last_oral_quiz_factors_v2';
static const String _kFactorsMaxKey = 'last_oral_quiz_factors_max_v2';
static String _scopeKey(String base, String? scopeId) {
final id = (scopeId ?? '').trim();
@@ -21,47 +39,56 @@ class QuizPrefs {
await prefs.setBool(_kSeenQuizKey, true);
}
static Future<void> saveLastResult({required int score, required int maxScore}) async {
static Future<void> saveLastResult(QuizResultData result) async {
await saveLastResultForScope(scopeId: null, result: result);
}
static Future<void> saveLastResultForUser({
required String userId,
required QuizResultData result,
}) async {
await saveLastResultForScope(scopeId: userId, result: result);
}
static Future<void> saveLastResultForScope({
required String? scopeId,
required QuizResultData result,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kLastScoreKey, score);
await prefs.setInt(_kLastMaxScoreKey, maxScore);
await prefs.setInt(_scopeKey(_kSignsKey, scopeId), result.signs);
await prefs.setInt(_scopeKey(_kSignsMaxKey, scopeId), result.signsMax);
await prefs.setInt(_scopeKey(_kFactorsKey, scopeId), result.factors);
await prefs.setInt(
_scopeKey(_kFactorsMaxKey, scopeId),
result.factorsMax,
);
}
static Future<void> saveLastResultForUser({required String userId, required int score, required int maxScore}) async {
await saveLastResultForScope(scopeId: userId, score: score, maxScore: maxScore);
}
static Future<QuizResultData?> getLastResult() =>
getLastResultForScope(null);
static Future<void> saveLastResultForScope({required String scopeId, required int score, required int maxScore}) async {
static Future<QuizResultData?> getLastResultForUser(String userId) =>
getLastResultForScope(userId);
static Future<QuizResultData?> getLastResultForScope(
String? scopeId,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_scopeKey(_kLastScoreKey, scopeId), score);
await prefs.setInt(_scopeKey(_kLastMaxScoreKey, scopeId), maxScore);
}
static Future<int?> getLastScore() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_kLastScoreKey);
}
static Future<int?> getLastScoreForUser(String userId) async {
return getLastScoreForScope(userId);
}
static Future<int?> getLastScoreForScope(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_scopeKey(_kLastScoreKey, scopeId));
}
static Future<int?> getLastMaxScore() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_kLastMaxScoreKey);
}
static Future<int?> getLastMaxScoreForUser(String userId) async {
return getLastMaxScoreForScope(userId);
}
static Future<int?> getLastMaxScoreForScope(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_scopeKey(_kLastMaxScoreKey, scopeId));
final signs = prefs.getInt(_scopeKey(_kSignsKey, scopeId));
final signsMax = prefs.getInt(_scopeKey(_kSignsMaxKey, scopeId));
final factors = prefs.getInt(_scopeKey(_kFactorsKey, scopeId));
final factorsMax = prefs.getInt(_scopeKey(_kFactorsMaxKey, scopeId));
if (signs == null ||
signsMax == null ||
factors == null ||
factorsMax == null) {
return null;
}
return QuizResultData(
signs: signs,
signsMax: signsMax,
factors: factors,
factorsMax: factorsMax,
);
}
}

View File

@@ -1,15 +1,29 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lottie/lottie.dart';
import '../screens/video_screen.dart';
import '../widgets/entrance.dart';
import '../widgets/liquid_waves_background.dart';
import '../widgets/tap_bounce.dart';
typedef QuizNextBuilder =
Route<void> Function(BuildContext context, int nextScore);
Route<void> Function(BuildContext context, QuizScore nextScore);
/// Pontuação do quiz, separada em dois contadores independentes em vez de
/// uma única soma — cada pergunta soma 1 a exatamente um dos dois, nunca aos
/// dois nem a nenhum. Ver [QuizQuestionScreen.isSignQuestion].
class QuizScore {
const QuizScore({this.signs = 0, this.factors = 0});
final int signs;
final int factors;
QuizScore addSign(int amount) =>
QuizScore(signs: signs + amount, factors: factors);
QuizScore addFactor(int amount) =>
QuizScore(signs: signs, factors: factors + amount);
}
enum QuizAnswerType { text, image, number, yesNo }
@@ -56,7 +70,8 @@ class QuizQuestionScreen extends StatefulWidget {
required this.question,
required this.answers,
required this.nextRoute,
this.currentScore = 0,
this.currentScore = const QuizScore(),
this.isSignQuestion = false,
this.onFinished,
this.isFinal = false,
this.showBackButton = false,
@@ -76,7 +91,14 @@ class QuizQuestionScreen extends StatefulWidget {
final String question;
final List<QuizAnswer> answers;
final QuizNextBuilder nextRoute;
final int currentScore;
final QuizScore currentScore;
/// Quando true, uma resposta "de risco" (weight 2) soma a
/// [QuizScore.signs] em vez de [QuizScore.factors] — usado só nas 4
/// perguntas que representam sinais de má oclusão já instalados
/// (queixo, boca/dentição, apinhamento, céu da boca); todas as outras
/// contam como fatores de risco associados.
final bool isSignQuestion;
final VoidCallback? onFinished;
final bool isFinal;
final bool showBackButton;
@@ -169,27 +191,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
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,
),
),
),
),
),
),
const LiquidWavesBackground(),
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -477,10 +479,9 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
// ser que a pergunta imponha um
// aspect ratio específico.
imageAspectRatio:
widget.answerImageAspectRatio ??
(widget
.answers
.length >=
widget
.answerImageAspectRatio ??
(widget.answers.length >=
3
? 2.3
: 1.5),
@@ -581,25 +582,30 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
() => _navigating =
true,
);
int nextScore =
QuizScore nextScore =
widget.currentScore;
if (widget.answerType ==
if (widget.answerType !=
QuizAnswerType
.number) {
nextScore =
widget
.currentScore +
(_numberDontKnow
? 0
: (_numberValue ??
0));
} else {
final picked = widget
.answers[_selected!];
final increment =
picked.weight ==
2
? 1
: 0;
nextScore =
widget
.currentScore +
picked.weight;
widget.isSignQuestion
? widget
.currentScore
.addSign(
increment,
)
: widget
.currentScore
.addFactor(
increment,
);
}
if (widget.isFinal) {

View File

@@ -1,713 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'quiz_question_screen.dart';
import 'quiz_result.dart';
class QuizRandomScreen extends StatefulWidget {
const QuizRandomScreen({super.key, this.currentScore = 0, this.scopeId});
final int currentScore;
final String? scopeId;
@override
State<QuizRandomScreen> createState() => _QuizRandomScreenState();
}
class _QuizRandomScreenState extends State<QuizRandomScreen> {
final List<QuizQuestion> _allQuestions = [
QuizQuestion(
id: 1,
title: 'Quiz 1/26',
question:
'Qual das seguintes imagens se assemelha à face do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//1.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/1.jpeg',
),
QuizAnswer(
//2.jpeg
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/2.jpeg',
),
QuizAnswer(
//3.jpeg
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/3.jpeg',
),
],
),
QuizQuestion(
id: 2,
title: 'Quiz 2/26',
question:
'Qual das seguintes imagens se assemelha à posição boca do seu filho/a habitualmente?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//4.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/4.jpeg',
),
QuizAnswer(
//5.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/5.png',
),
],
),
QuizQuestion(
id: 3,
title: 'Quiz 3/26',
question:
'Qual das seguintes imagens se assemelha às olheiras do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//8.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/8.jpeg',
),
QuizAnswer(
//9.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/9.png',
),
],
),
QuizQuestion(
id: 4,
title: 'Quiz 4/26',
question:
'Qual das seguintes imagens se assemelha ao queixo do seu filho/a com a boca fechada?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//6.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/6.jpeg',
),
QuizAnswer(
//7.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/7.png',
),
],
),
QuizQuestion(
id: 6,
title: 'Quiz 6/26',
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//14.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/14.jpeg',
),
QuizAnswer(
//15.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/15.png',
),
QuizAnswer(
//16.png
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/16.png',
),
],
),
QuizQuestion(
id: 7,
title: 'Quiz 7/26',
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//10.png
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/10.png',
),
QuizAnswer(
//11.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/11.png',
),
QuizAnswer(
//13.png
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/13.png',
),
],
),
QuizQuestion(
id: 8,
title: 'Quiz 8/26',
question:
'Qual das seguintes imagens se assemelha ao freio do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//17.png
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/17.png',
),
QuizAnswer(
//18.jpeg
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/18.jpeg',
),
],
),
QuizQuestion(
id: 8,
title: 'Quiz 8/26',
question:
'Qual das seguintes imagens se assemelha ao freio do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//19.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/19.jpeg',
),
QuizAnswer(
//20.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/20.png',
),
],
),
QuizQuestion(
id: 9,
title: 'Quiz 9/26',
question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Problemas respiratórios diagnosticados',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Sem problemas respiratórios diagnosticados',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 10,
title: 'Quiz 10/26',
question: 'O seu filho/a respira habitualmente pela boca?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Respira habitualmente pela boca',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não respira habitualmente pela boca',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 11,
title: 'Quiz 11/26',
question: 'O seu filho/a ressona habitualmente durante a noite?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Ressonar habitualmente durante a noite',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não ressona habitualmente',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 12,
title: 'Quiz 12/26',
question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Sente habitualmente o nariz tapado',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não sente habitualmente o nariz tapado',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 13,
title: 'Quiz 13/26',
question:
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description:
'Tem habitualmente interrupções da respiração durante o sono',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não tem interrupções da respiração durante o sono',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 14,
title: 'Quiz 14/26',
question: 'O seu filho/a range os dentes com frequência?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Range os dentes com frequência',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não range os dentes com frequência',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 15,
title: 'Quiz 15/26',
question: 'O seu filho/a habitualmente tem alergias sazonais?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Habitualmente tem alergias sazonais',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não tem alergias sazonais',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 16,
title: 'Quiz 16/26',
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Acorda com saliva seca na cara ou na almofada',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não acorda com saliva seca',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 17,
title: 'Quiz 17/26',
question: 'O seu filho/a teve ou costuma ter com frequência otites?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Teve ou costuma ter com frequência otites',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não teve ou não costuma ter otites',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 18,
title: 'Quiz 18/26',
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Teve ou costuma ter com frequência amigdalites',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não teve ou não costuma ter amigdalites',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 19,
title: 'Quiz 19/26',
question:
'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Teve ou costuma ter com frequência bronquiolites',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não teve ou não costuma ter bronquiolites',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 20,
title: 'Quiz 20/26',
question: 'O seu filho/a apresenta dificuldades a mastigar?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Apresenta dificuldades a mastigar',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não apresenta dificuldades a mastigar',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 21,
title: 'Quiz 21/26',
question: 'O seu filho/a habitualmente é lento a comer?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Habitualmente é lento a comer',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não é lento a comer',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 22,
title: 'Quiz 22/26',
question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Habitualmente prefere comer alimentos moles',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não prefere alimentos moles',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 23,
title: 'Quiz 23/26',
question: 'Em bebé apenas foi alimentado por biberão?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Em bebé apenas foi alimentado por biberão',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não foi apenas alimentado por biberão',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 24,
title: 'Quiz 24/26',
question: 'O seu filho/a usa ou usou chupeta com frequência?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Usa ou usou chupeta com frequência',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não usa ou não usou chupeta com frequência',
weight: 1,
value: 'nao',
),
],
),
QuizQuestion(
id: 25,
title: 'Quiz 25/26',
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
answerType: QuizAnswerType.yesNo,
answers: const [
QuizAnswer(
title: 'Sim',
description: 'Chucha ou já chuchou o dedo com frequência',
weight: 2,
value: 'sim',
),
QuizAnswer(
title: 'Não',
description: 'Não chucha ou não chuchou o dedo com frequência',
weight: 1,
value: 'nao',
),
],
),
];
late List<QuizQuestion> _shuffledQuestions;
int _currentQuestionIndex = 0;
int _currentScore = 0;
final Random _random = Random();
@override
void initState() {
super.initState();
_currentScore = widget.currentScore;
_shuffledQuestions = List.from(_allQuestions)..shuffle(_random);
}
void _nextQuestion(int scoreToAdd) {
setState(() {
_currentScore += scoreToAdd;
_currentQuestionIndex++;
});
if (_currentQuestionIndex >= _shuffledQuestions.length) {
// Quiz finished
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => QuizResultScreen(
finalScore: _currentScore,
maxScore: 75, // 15 questions * 5 max points
scopeId: widget.scopeId,
),
),
);
}
}
@override
Widget build(BuildContext context) {
if (_currentQuestionIndex >= _shuffledQuestions.length) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final currentQuestion = _shuffledQuestions[_currentQuestionIndex];
final isLastQuestion =
_currentQuestionIndex == _shuffledQuestions.length - 1;
return QuizQuestionScreen(
title: currentQuestion.title,
question: currentQuestion.question,
answers: currentQuestion.answers,
currentScore: _currentScore,
nextRoute: (context, nextScore) {
_nextQuestion(nextScore - _currentScore);
return MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink());
},
isFinal: isLastQuestion,
showBackButton: _currentQuestionIndex > 0,
answerType: currentQuestion.answerType,
questionImagePaths: currentQuestion.questionImagePaths,
onFinished: isLastQuestion
? () {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => QuizResultScreen(
finalScore: _currentScore,
maxScore: 75,
scopeId: widget.scopeId,
),
),
);
}
: null,
);
}
}
class QuizQuestion {
final int id;
final String title;
final String question;
final List<QuizAnswer> answers;
final QuizAnswerType answerType;
// Reference images shown ABOVE the question text (visualization only).
final List<String> questionImagePaths;
QuizQuestion({
required this.id,
required this.title,
required this.question,
required this.answers,
this.answerType = QuizAnswerType.text,
this.questionImagePaths = const [],
});
}
// Helper: returns the asset path for a numbered mockup image (0..27).
// Use in QuizAnswer.imagePath or QuizQuestion.questionImagePaths.
String mockup(int n) => 'assets/mockup_images/$n${_mockupExt[n] ?? '.png'}';
const Map<int, String> _mockupExt = {
0: '.png',
1: '.jpeg',
2: '.jpeg',
3: '.jpeg',
4: '.jpeg',
5: '.png',
6: '.jpeg',
7: '.png',
8: '.jpeg',
9: '.png',
10: '.png',
11: '.png',
12: '.png',
13: '.png',
14: '.jpeg',
15: '.png',
16: '.png',
17: '.png',
18: '.jpeg',
19: '.jpeg',
20: '.png',
21: '.jpeg',
22: '.png',
23: '.jpeg',
24: '.png',
25: '.jpg',
26: '.png',
27: '.png',
};

View File

@@ -4,22 +4,32 @@ import 'dart:async';
import '../main.dart' show supabase;
import '../widgets/app_gradients.dart';
import '../widgets/entrance.dart';
import '../widgets/liquid_waves_background.dart';
import '../widgets/tap_bounce.dart';
import 'quiz_prefs.dart';
import 'quiz_question_screen.dart' show QuizScore;
import 'quiz_video_guide.dart';
const String _resultGuideYoutubeId = '3q7C7txH1dE';
class QuizResultScreen extends StatefulWidget {
const QuizResultScreen({
super.key,
required this.finalScore,
required this.maxScore,
this.scopeId,
});
// Fase 3 tem 27 perguntas no total: 4 são sinais de má oclusão já
// instalados (queixo, boca/dentição, apinhamento, céu da boca) — as
// restantes 23 são fatores de risco associados. Ver o diagrama de
// planeamento partilhado com o utilizador para o racional completo.
const int kSignsMax = 4;
const int kFactorsMax = 23;
final int finalScore;
final int maxScore;
/// A partir de quantos sinais/fatores presentes se recomenda avaliação.
/// Proposta: qualquer sinal já instalado, ou ao menos ~30% dos fatores de
/// risco — ajustável depois de validar com dados reais.
bool _recommendsEvaluation(QuizScore score) {
return score.signs >= 1 || score.factors >= (kFactorsMax * 0.3).ceil();
}
class QuizResultScreen extends StatefulWidget {
const QuizResultScreen({super.key, required this.finalScore, this.scopeId});
final QuizScore finalScore;
final String? scopeId;
@override
@@ -37,26 +47,21 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
Future<void> _saveResult() async {
QuizPrefs.markQuizSeen();
final result = QuizResultData(
signs: widget.finalScore.signs,
signsMax: kSignsMax,
factors: widget.finalScore.factors,
factorsMax: kFactorsMax,
);
final scope = (widget.scopeId ?? '').trim();
if (scope.isNotEmpty) {
await QuizPrefs.saveLastResultForScope(
scopeId: scope,
score: widget.finalScore,
maxScore: widget.maxScore,
);
await QuizPrefs.saveLastResultForScope(scopeId: scope, result: result);
} else {
final uid = supabase.auth.currentUser?.id;
if (uid != null && uid.trim().isNotEmpty) {
await QuizPrefs.saveLastResultForUser(
userId: uid,
score: widget.finalScore,
maxScore: widget.maxScore,
);
await QuizPrefs.saveLastResultForUser(userId: uid, result: result);
} else {
await QuizPrefs.saveLastResult(
score: widget.finalScore,
maxScore: widget.maxScore,
);
await QuizPrefs.saveLastResult(result);
}
}
@@ -67,17 +72,25 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
scope.startsWith('${userId}_')) {
final childId = scope.substring(userId.length + 1).trim();
if (childId.isNotEmpty) {
// Fire-and-forget: avoid blocking UI on erros de rede.
// Fire-and-forget: avoid blocking UI on erros de rede. Requer as
// colunas last_signs/last_signs_max/last_factors/last_factors_max
// na tabela children — se ainda não existirem na base de dados,
// esta chamada falha silenciosamente e o resultado fica só local
// (SharedPreferences, acima), tal como já acontecia antes.
unawaited(
supabase
.from('children')
.update({
'last_score': widget.finalScore,
'last_max_score': widget.maxScore,
'last_signs': widget.finalScore.signs,
'last_signs_max': kSignsMax,
'last_factors': widget.finalScore.factors,
'last_factors_max': kFactorsMax,
})
.eq('id', childId)
.catchError((e) {
debugPrint('[QuizResult] Falha ao gravar score na base de dados: $e');
debugPrint(
'[QuizResult] Falha ao gravar score na base de dados: $e',
);
}),
);
}
@@ -86,195 +99,227 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
@override
Widget build(BuildContext context) {
final clamped = widget.finalScore.clamp(0, widget.maxScore);
final percent = ((clamped / widget.maxScore) * 100).round();
final progress = percent / 100.0;
final recommend = _recommendsEvaluation(widget.finalScore);
return Scaffold(
body: Container(
color: const Color(0xFFFAFAF7),
child: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 12, 22, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
Navigator.of(context).popUntil((r) => r.isFirst),
child: const Text(''),
),
),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
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,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
height: 1.2,
),
),
),
const SizedBox(height: 18),
Center(
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 1100),
curve: Curves.easeOutCubic,
tween: Tween<double>(begin: 0, end: progress),
builder: (context, animatedProgress, _) {
final animatedPercent =
(animatedProgress * 100).round();
return SizedBox(
width: 220,
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 200,
height: 200,
child: CircularProgressIndicator(
value: animatedProgress,
strokeWidth: 12,
backgroundColor: Colors.black
.withValues(alpha: 0.10),
valueColor:
const AlwaysStoppedAnimation(
Color(0xFF2F9E94),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$animatedPercent%',
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w900,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
'${clamped.toInt()}/${widget.maxScore}',
style: TextStyle(
color: Colors.black.withValues(
alpha: 0.60,
),
fontWeight: FontWeight.w800,
),
),
],
),
],
),
);
},
),
),
const SizedBox(height: 18),
FadeSlideIn(
delay: const Duration(milliseconds: 120),
child: Text(
'Conclusões:',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.75),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(height: 10),
FadeSlideIn(
delay: const Duration(milliseconds: 160),
child: Text(
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.70),
fontWeight: FontWeight.w600,
height: 1.25,
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
'Descarregar relatório (em breve)',
style: TextStyle(
color: const Color(
0xFFFF55A7,
).withValues(alpha: 0.95),
fontWeight: FontWeight.w800,
),
),
),
],
body: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
const LiquidWavesBackground(),
SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 12, 22, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
Navigator.of(context).popUntil((r) => r.isFirst),
child: const Text(''),
),
),
),
Center(
child: TapBounce(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
width: 260,
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 6),
FadeSlideIn(
child: const QuizGuideVideoCard(
youtubeId: _resultGuideYoutubeId,
),
),
onPressed: () async {
await _saveResultFuture;
if (!context.mounted) return;
Navigator.of(
context,
).popUntil((r) => r.isFirst);
},
child: const Text('Avançar'),
const SizedBox(height: 22),
FadeSlideIn(
delay: const Duration(milliseconds: 60),
child: const Text(
'Resultado da avaliação',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
height: 1.2,
),
),
),
const SizedBox(height: 18),
FadeSlideIn(
delay: const Duration(milliseconds: 100),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
_ResultRing(
value: widget.finalScore.signs,
max: kSignsMax,
color: const Color(0xFF2F9E94),
label:
'Sinais de má\noclusão instalados',
),
_ResultRing(
value: widget.finalScore.factors,
max: kFactorsMax,
color: const Color(0xFFFF55A7),
label: 'Fatores de\nrisco associados',
),
],
),
),
const SizedBox(height: 20),
FadeSlideIn(
delay: const Duration(milliseconds: 120),
child: Text(
'Conclusões:',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(
alpha: 0.75,
),
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(height: 10),
FadeSlideIn(
delay: const Duration(milliseconds: 160),
child: Text(
recommend
? 'Recomenda-se o agendamento de uma consulta com um dentista/ortodontista.\nEsta avaliação é apenas educativa.'
: 'Não foram encontrados sinais ou fatores de risco relevantes.\nEsta avaliação é apenas educativa.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(
alpha: 0.70,
),
fontWeight: FontWeight.w600,
height: 1.25,
),
),
),
],
),
),
),
),
Center(
child: TapBounce(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
width: 260,
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: () async {
await _saveResultFuture;
if (!context.mounted) return;
Navigator.of(
context,
).popUntil((r) => r.isFirst);
},
child: const Text('Avançar'),
),
),
),
),
),
),
),
],
],
),
),
),
),
),
),
],
),
);
}
}
class _ResultRing extends StatelessWidget {
const _ResultRing({
required this.value,
required this.max,
required this.color,
required this.label,
});
final int value;
final int max;
final Color color;
final String label;
@override
Widget build(BuildContext context) {
final progress = max > 0 ? (value / max).clamp(0.0, 1.0) : 0.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 1100),
curve: Curves.easeOutCubic,
tween: Tween<double>(begin: 0, end: progress),
builder: (context, animatedProgress, _) {
return SizedBox(
width: 150,
height: 150,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 132,
height: 132,
child: CircularProgressIndicator(
value: animatedProgress,
strokeWidth: 12,
backgroundColor: Colors.black.withValues(alpha: 0.10),
valueColor: AlwaysStoppedAnimation(color),
),
),
Text(
'$value/$max',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: Colors.black,
),
),
],
),
);
},
),
const SizedBox(height: 10),
Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
color: Colors.black.withValues(alpha: 0.65),
height: 1.25,
),
),
],
);
}
}

View File

@@ -1,10 +1,8 @@
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/liquid_waves_background.dart';
import '../widgets/tap_bounce.dart';
const Color _pink = Color(0xFFFF55A7);
@@ -118,35 +116,12 @@ class _QuizVideoGuideScreenState extends State<QuizVideoGuideScreen> {
@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,
),
),
),
),
),
),
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
const LiquidWavesBackground(),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),