Munça da UI de inicio | escovagem semanal adicionada | quantidade de videos assistidos | todos os videos na nuvem | possibilidade de continuar o video de onde parou

This commit is contained in:
Carlos Correia
2026-07-09 15:31:59 +01:00
parent 67b580778a
commit 9fb2840529
14 changed files with 1584 additions and 381 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

84
lib/brushing_prefs.dart Normal file
View File

@@ -0,0 +1,84 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Registo local (por criança) de escovagens e da meta semanal.
/// Segue o mesmo padrão de [QuizPrefs]: chaves com sufixo `_scopeId`,
/// guardadas via [SharedPreferences], sem qualquer chamada ao Supabase.
///
/// Cada escovagem é guardada com a hora exata (não só a data), para permitir
/// até [maxPerDay] registos por dia — a criança pode escovar os dentes de
/// manhã, à tarde e à noite, a qualquer hora.
class BrushingPrefs {
static const String _kGoalKey = 'brushing_weekly_goal';
static const String _kDatesKey = 'brushing_dates';
static const int maxPerDay = 3;
static const int defaultWeeklyGoal = maxPerDay * 7;
static String _key(String base, String scopeId) => '${base}_$scopeId';
static Future<List<DateTime>> _getEntries(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const [];
return raw.map(DateTime.tryParse).whereType<DateTime>().toList();
}
static Future<int> getWeeklyGoal(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_key(_kGoalKey, scopeId)) ?? defaultWeeklyGoal;
}
static Future<void> setWeeklyGoal(String scopeId, int goal) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_key(_kGoalKey, scopeId), goal);
}
static Future<int> getTodayCount(String scopeId) async {
final entries = await _getEntries(scopeId);
final now = DateTime.now();
return entries
.where(
(d) =>
d.year == now.year && d.month == now.month && d.day == now.day,
)
.length;
}
static Future<bool> canLogMore(String scopeId) async {
return (await getTodayCount(scopeId)) < maxPerDay;
}
/// Regista uma escovagem agora — não faz nada se o limite diário já foi
/// atingido (chamador deve verificar [canLogMore] antes, se quiser avisar
/// o utilizador).
static Future<void> logToday(String scopeId) async {
if (!(await canLogMore(scopeId))) return;
final prefs = await SharedPreferences.getInstance();
final key = _key(_kDatesKey, scopeId);
final list = prefs.getStringList(key) ?? <String>[];
list.add(DateTime.now().toIso8601String());
await prefs.setStringList(key, list);
}
/// Já atingiu o limite diário de [maxPerDay] escovagens hoje?
static Future<bool> hasReachedDailyLimit(String scopeId) async {
return (await getTodayCount(scopeId)) >= maxPerDay;
}
/// Conta quantas escovagens foram registadas na semana atual
/// (segunda-feira a domingo).
static Future<int> getWeekCount(String scopeId) async {
final entries = await _getEntries(scopeId);
final now = DateTime.now();
final monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
return entries
.where(
(d) => !DateTime(d.year, d.month, d.day).isBefore(monday),
)
.length;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@ class Quiz1Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 1/25', title: 'Quiz 1/25',
category: 'Avaliação facial',
categoryIcon: Icons.face_rounded,
question: 'O rosto do seu filho/a se parece com o desta imagem?', question: 'O rosto do seu filho/a se parece com o desta imagem?',
questionImagePaths: const ['assets/mockup_images/2.jpeg'], questionImagePaths: const ['assets/mockup_images/2.jpeg'],
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1 suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1
@@ -53,6 +55,8 @@ class Quiz2Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 2/25', title: 'Quiz 2/25',
category: 'Avaliação facial',
categoryIcon: Icons.sentiment_neutral_rounded,
question: question:
'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?', 'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?',
questionImagePaths: const ['assets/mockup_images/4.jpeg'], questionImagePaths: const ['assets/mockup_images/4.jpeg'],
@@ -93,6 +97,8 @@ class Quiz3Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 3/25', title: 'Quiz 3/25',
category: 'Avaliação facial',
categoryIcon: Icons.visibility_rounded,
question: 'O seu filho/a tem olheiras semelhantes às desta imagem?', question: 'O seu filho/a tem olheiras semelhantes às desta imagem?',
questionImagePaths: const ['assets/mockup_images/8.jpeg'], questionImagePaths: const ['assets/mockup_images/8.jpeg'],
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3 suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3
@@ -132,6 +138,8 @@ class Quiz4Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 4/25', title: 'Quiz 4/25',
category: 'Avaliação facial',
categoryIcon: Icons.face_rounded,
question: question:
'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?', 'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?',
questionImagePaths: const ['assets/mockup_images/6.jpeg'], questionImagePaths: const ['assets/mockup_images/6.jpeg'],
@@ -172,6 +180,10 @@ class Quiz5Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 5/25', title: 'Quiz 5/25',
category: 'Contagem de dentes',
categoryIcon: Icons.numbers_rounded,
fallbackIcon: Icons.numbers_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'Quantos dentes tem o seu filho/a em cima na boca?', question: 'Quantos dentes tem o seu filho/a em cima na boca?',
answers: const [], answers: const [],
currentScore: currentScore, currentScore: currentScore,
@@ -195,6 +207,10 @@ class Quiz6Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 6/25', title: 'Quiz 6/25',
category: 'Contagem de dentes',
categoryIcon: Icons.numbers_rounded,
fallbackIcon: Icons.numbers_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'Quantos dentes tem o seu filho/a em baixo na boca?', question: 'Quantos dentes tem o seu filho/a em baixo na boca?',
answers: const [], answers: const [],
currentScore: currentScore, currentScore: currentScore,
@@ -218,6 +234,8 @@ class Quiz7Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 7/25', title: 'Quiz 7/25',
category: 'Avaliação facial',
categoryIcon: Icons.sentiment_neutral_rounded,
question: 'A boca do seu filho/a se parece com a desta imagem?', question: 'A boca do seu filho/a se parece com a desta imagem?',
questionImagePaths: const ['assets/mockup_images/14.jpeg'], questionImagePaths: const ['assets/mockup_images/14.jpeg'],
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5 suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5
@@ -257,6 +275,8 @@ class Quiz8Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 8/25', title: 'Quiz 8/25',
category: 'Avaliação facial',
categoryIcon: Icons.record_voice_over_rounded,
question: question:
'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?', 'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?',
questionImagePaths: const ['assets/mockup_images/17.png'], questionImagePaths: const ['assets/mockup_images/17.png'],
@@ -297,6 +317,10 @@ class Quiz9Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 9/25', title: 'Quiz 9/25',
category: 'Saúde respiratória',
categoryIcon: Icons.medical_information_rounded,
fallbackIcon: Icons.medical_information_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'O seu filho/a tem problemas respiratórios diagnosticados?', question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -311,6 +335,12 @@ class Quiz9Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -333,6 +363,10 @@ class Quiz10Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 10/25', title: 'Quiz 10/25',
category: 'Respiração',
categoryIcon: Icons.air_rounded,
fallbackIcon: Icons.air_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'O seu filho/a respira habitualmente pela boca?', question: 'O seu filho/a respira habitualmente pela boca?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -347,6 +381,12 @@ class Quiz10Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -369,6 +409,10 @@ class Quiz11Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 11/25', title: 'Quiz 11/25',
category: 'Sono',
categoryIcon: Icons.bedtime_rounded,
fallbackIcon: Icons.bedtime_rounded,
fallbackColor: const Color(0xFF8E7CC3),
question: 'O seu filho/a ressona habitualmente durante a noite?', question: 'O seu filho/a ressona habitualmente durante a noite?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -383,6 +427,12 @@ class Quiz11Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -405,6 +455,10 @@ class Quiz12Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 12/25', title: 'Quiz 12/25',
category: 'Respiração',
categoryIcon: Icons.sick_rounded,
fallbackIcon: Icons.sick_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'O seu filho/a sente habitualmente o nariz "tapado"?', question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -419,6 +473,12 @@ class Quiz12Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -441,6 +501,10 @@ class Quiz13Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 13/25', title: 'Quiz 13/25',
category: 'Sono',
categoryIcon: Icons.nights_stay_rounded,
fallbackIcon: Icons.nights_stay_rounded,
fallbackColor: const Color(0xFF8E7CC3),
question: question:
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?', 'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
answers: const [ answers: const [
@@ -457,6 +521,12 @@ class Quiz13Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -479,6 +549,10 @@ class Quiz14Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 14/25', title: 'Quiz 14/25',
category: 'Hábitos noturnos',
categoryIcon: Icons.nights_stay_rounded,
fallbackIcon: Icons.nights_stay_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'O seu filho/a range os dentes com frequência?', question: 'O seu filho/a range os dentes com frequência?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -493,6 +567,12 @@ class Quiz14Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -515,6 +595,10 @@ class Quiz15Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 15/25', title: 'Quiz 15/25',
category: 'Saúde geral',
categoryIcon: Icons.local_florist_rounded,
fallbackIcon: Icons.local_florist_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'O seu filho/a habitualmente tem alergias sazonais?', question: 'O seu filho/a habitualmente tem alergias sazonais?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -529,6 +613,12 @@ class Quiz15Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -551,6 +641,10 @@ class Quiz16Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 16/25', title: 'Quiz 16/25',
category: 'Sono',
categoryIcon: Icons.water_drop_rounded,
fallbackIcon: Icons.water_drop_rounded,
fallbackColor: const Color(0xFF8E7CC3),
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?', question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -565,6 +659,12 @@ class Quiz16Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -587,6 +687,10 @@ class Quiz17Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 17/25', title: 'Quiz 17/25',
category: 'Saúde geral',
categoryIcon: Icons.hearing_rounded,
fallbackIcon: Icons.hearing_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'O seu filho/a teve ou costuma ter com frequência otites?', question: 'O seu filho/a teve ou costuma ter com frequência otites?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -601,6 +705,12 @@ class Quiz17Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -623,6 +733,10 @@ class Quiz18Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 18/25', title: 'Quiz 18/25',
category: 'Saúde geral',
categoryIcon: Icons.healing_rounded,
fallbackIcon: Icons.healing_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?', question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -637,6 +751,12 @@ class Quiz18Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -659,6 +779,10 @@ class Quiz19Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 19/25', title: 'Quiz 19/25',
category: 'Saúde respiratória',
categoryIcon: Icons.air_rounded,
fallbackIcon: Icons.air_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: question:
'O seu filho/a teve ou costuma ter com frequência bronquiolites?', 'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
answers: const [ answers: const [
@@ -674,6 +798,12 @@ class Quiz19Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -696,6 +826,10 @@ class Quiz20Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 20/25', title: 'Quiz 20/25',
category: 'Hábitos alimentares',
categoryIcon: Icons.restaurant_rounded,
fallbackIcon: Icons.restaurant_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'O seu filho/a apresenta dificuldades a mastigar?', question: 'O seu filho/a apresenta dificuldades a mastigar?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -710,6 +844,12 @@ class Quiz20Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -732,6 +872,10 @@ class Quiz21Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 21/25', title: 'Quiz 21/25',
category: 'Hábitos alimentares',
categoryIcon: Icons.schedule_rounded,
fallbackIcon: Icons.schedule_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'O seu filho/a habitualmente é lento a comer?', question: 'O seu filho/a habitualmente é lento a comer?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -746,6 +890,12 @@ class Quiz21Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -768,6 +918,10 @@ class Quiz22Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 22/25', title: 'Quiz 22/25',
category: 'Hábitos alimentares',
categoryIcon: Icons.restaurant_menu_rounded,
fallbackIcon: Icons.restaurant_menu_rounded,
fallbackColor: const Color(0xFF8E7CC3),
question: 'O seu filho/a habitualmente prefere comer alimentos moles?', question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -782,6 +936,12 @@ class Quiz22Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -804,6 +964,10 @@ class Quiz23Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 23/25', title: 'Quiz 23/25',
category: 'Hábitos alimentares',
categoryIcon: Icons.local_drink_rounded,
fallbackIcon: Icons.local_drink_rounded,
fallbackColor: const Color(0xFFFF55A7),
question: 'Em bebé apenas foi alimentado por biberão?', question: 'Em bebé apenas foi alimentado por biberão?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -818,6 +982,12 @@ class Quiz23Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -840,6 +1010,10 @@ class Quiz24Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 24/25', title: 'Quiz 24/25',
category: 'Hábitos orais',
categoryIcon: Icons.child_care_rounded,
fallbackIcon: Icons.child_care_rounded,
fallbackColor: const Color(0xFF2F9E94),
question: 'O seu filho/a usa ou usou chupeta com frequência?', question: 'O seu filho/a usa ou usou chupeta com frequência?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -854,6 +1028,12 @@ class Quiz24Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(
@@ -876,6 +1056,10 @@ class Quiz25Screen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return QuizQuestionScreen( return QuizQuestionScreen(
title: 'Quiz 25/25', title: 'Quiz 25/25',
category: 'Hábitos orais',
categoryIcon: Icons.back_hand_rounded,
fallbackIcon: Icons.back_hand_rounded,
fallbackColor: const Color(0xFF8E7CC3),
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?', question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
answers: const [ answers: const [
QuizAnswer( QuizAnswer(
@@ -890,6 +1074,12 @@ class Quiz25Screen extends StatelessWidget {
weight: 1, weight: 1,
value: 'nao', value: 'nao',
), ),
QuizAnswer(
title: 'Não sei',
description: 'Não tenho a certeza',
weight: 1,
value: 'nao_sei',
),
], ],
currentScore: currentScore, currentScore: currentScore,
nextRoute: (context, nextScore) => MaterialPageRoute<void>( nextRoute: (context, nextScore) => MaterialPageRoute<void>(

View File

@@ -45,6 +45,10 @@ class QuizQuestionScreen extends StatefulWidget {
this.suggestedVideoPath, this.suggestedVideoPath,
this.suggestedYoutubeId, this.suggestedYoutubeId,
this.suggestedVideoTitle, this.suggestedVideoTitle,
this.category,
this.categoryIcon,
this.fallbackIcon,
this.fallbackColor,
}); });
final String title; final String title;
@@ -61,6 +65,16 @@ class QuizQuestionScreen extends StatefulWidget {
final String? suggestedYoutubeId; final String? suggestedYoutubeId;
final String? suggestedVideoTitle; final String? suggestedVideoTitle;
/// Rótulo pequeno do tema da pergunta (ex.: "Avaliação facial"), mostrado
/// num badge acima da imagem/pergunta.
final String? category;
final IconData? categoryIcon;
/// Usados só quando [questionImagePaths] está vazio: em vez de o bloco de
/// imagem colapsar, mostra-se um bloco colorido com este ícone.
final IconData? fallbackIcon;
final Color? fallbackColor;
@override @override
State<QuizQuestionScreen> createState() => _QuizQuestionScreenState(); State<QuizQuestionScreen> createState() => _QuizQuestionScreenState();
} }
@@ -74,8 +88,24 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
int? _selected; int? _selected;
TextEditingController? _numberController; TextEditingController? _numberController;
int? _numberValue; int? _numberValue;
bool _numberDontKnow = false;
bool _navigating = false; bool _navigating = false;
/// O título ainda chega como texto livre ("Quiz 3/25") em vez de números
/// separados — extraímos daqui em vez de acrescentar mais dois parâmetros
/// obrigatórios a cada uma das 25 telas de pergunta.
static final RegExp _progressPattern = RegExp(r'(\d+)\s*/\s*(\d+)');
int get _questionIndex {
final match = _progressPattern.firstMatch(widget.title);
return int.tryParse(match?.group(1) ?? '') ?? 1;
}
int get _totalQuestions {
final match = _progressPattern.firstMatch(widget.title);
return int.tryParse(match?.group(2) ?? '') ?? 1;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -96,10 +126,11 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
bool canProceed = _selected != null && !_navigating; bool canProceed = _selected != null && !_navigating;
if (widget.answerType == QuizAnswerType.number) { if (widget.answerType == QuizAnswerType.number) {
canProceed = canProceed =
_numberValue != null && !_navigating &&
_numberValue! >= 0 && (_numberDontKnow ||
_numberValue! <= _maxTeethCount && (_numberValue != null &&
!_navigating; _numberValue! >= 0 &&
_numberValue! <= _maxTeethCount));
} }
final bool hasSuggestedVideo = final bool hasSuggestedVideo =
@@ -146,49 +177,103 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox( Padding(
height: 44, padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Stack( child: Row(
alignment: Alignment.center,
children: [ children: [
Text(
widget.title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w800,
),
),
if (widget.showBackButton) if (widget.showBackButton)
Positioned( TapBounce(
left: 4, scale: 0.9,
child: TapBounce( child: Material(
scale: 0.9, color: Colors.white.withValues(alpha: 0.85),
child: Material( shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.85), elevation: 4,
shape: const CircleBorder(), shadowColor: Colors.black.withValues(alpha: 0.15),
elevation: 4, child: InkWell(
shadowColor: Colors.black.withValues( customBorder: const CircleBorder(),
alpha: 0.15, onTap: () => Navigator.of(context).maybePop(),
), child: const Padding(
child: InkWell( padding: EdgeInsets.all(9),
customBorder: const CircleBorder(), child: Icon(
onTap: () => Navigator.of(context).maybePop(), Icons.arrow_back_rounded,
child: const Padding( color: Color(0xFF2F9E94),
padding: EdgeInsets.all(10), size: 20,
child: Icon(
Icons.arrow_back_rounded,
color: Color(0xFF2F9E94),
size: 22,
),
), ),
), ),
), ),
), ),
)
else
const SizedBox(width: 38),
const SizedBox(width: 12),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (_questionIndex / _totalQuestions).clamp(
0.0,
1.0,
),
minHeight: 8,
backgroundColor: const Color(
0xFFFF55A7,
).withValues(alpha: 0.15),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFFFF55A7),
),
),
), ),
),
const SizedBox(width: 10),
Text(
'$_questionIndex/$_totalQuestions',
style: TextStyle(
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
], ],
), ),
), ),
if ((widget.category ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.categoryIcon != null) ...[
Icon(
widget.categoryIcon,
size: 15,
color: const Color(0xFF2F9E94),
),
const SizedBox(width: 6),
],
Text(
widget.category!,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 12,
color: Color(0xFF2F9E94),
),
),
],
),
),
),
),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@@ -224,16 +309,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.stretch, CrossAxisAlignment.stretch,
children: [ children: [
if (widget const SizedBox(height: 6),
.questionImagePaths widget.questionImagePaths.isNotEmpty
.isNotEmpty) ...[ ? _QuestionReferenceImages(
const SizedBox(height: 6), paths: widget
_QuestionReferenceImages( .questionImagePaths,
paths: )
widget.questionImagePaths, : _FallbackIconBlock(
), icon:
const SizedBox(height: 10), widget.fallbackIcon ??
], Icons.info_outline_rounded,
color:
widget.fallbackColor ??
const Color(0xFF2F9E94),
),
const SizedBox(height: 10),
if (hasSuggestedVideo) ...[ if (hasSuggestedVideo) ...[
TextButton.icon( TextButton.icon(
onPressed: () => onPressed: () =>
@@ -324,15 +414,38 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
delay: Duration( delay: Duration(
milliseconds: 60 * i, milliseconds: 60 * i,
), ),
child: _QuizAnswerTile( child:
answer: widget.answerType ==
widget.answers[i], QuizAnswerType
selected: .yesNo &&
_selected == i, widget
onTap: () => setState( .answers[i]
() => _selected = i, .imagePath ==
), null
), ? _QuizAnswerPill(
answer: widget
.answers[i],
selected:
_selected == i,
onTap: () =>
setState(
() =>
_selected =
i,
),
)
: _QuizAnswerTile(
answer: widget
.answers[i],
selected:
_selected == i,
onTap: () =>
setState(
() =>
_selected =
i,
),
),
), ),
], ],
], ],
@@ -357,7 +470,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
FilledButton.styleFrom( FilledButton.styleFrom(
backgroundColor: backgroundColor:
const Color( const Color(
0xFF2F9E94, 0xFFFF55A7,
), ),
foregroundColor: foregroundColor:
Colors.white, Colors.white,
@@ -426,8 +539,10 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
nextScore = nextScore =
widget widget
.currentScore + .currentScore +
(_numberValue ?? (_numberDontKnow
0); ? 0
: (_numberValue ??
0));
} else { } else {
final picked = widget final picked = widget
.answers[_selected!]; .answers[_selected!];
@@ -474,44 +589,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
), ),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 6),
TapBounce( TextButton(
child: SizedBox( style: TextButton.styleFrom(
width: size.width * 0.62, foregroundColor: const Color(
height: 42, 0xFF2F9E94,
child: OutlinedButton(
style:
OutlinedButton.styleFrom(
foregroundColor:
const Color(
0xFF2F9E94,
),
side: const BorderSide(
color: Color(
0xFF2F9E94,
),
width: 1.3,
),
shape:
const StadiumBorder(),
textStyle:
const TextStyle(
fontWeight:
FontWeight
.w900,
),
),
onPressed: () =>
Navigator.of(
context,
).popUntil(
(route) =>
route.isFirst,
),
child: const Text(
'Voltar para homepage',
),
), ),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text(
'Voltar para homepage',
), ),
), ),
], ],
@@ -540,54 +632,62 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Opacity(
width: 150, opacity: _numberDontKnow ? 0.4 : 1,
decoration: BoxDecoration( child: IgnorePointer(
color: Colors.white.withValues(alpha: 0.70), ignoring: _numberDontKnow,
borderRadius: BorderRadius.circular(16), child: Container(
border: Border.all( width: 150,
color: Colors.black.withValues(alpha: 0.12), decoration: BoxDecoration(
width: 1.0, color: Colors.white.withValues(alpha: 0.70),
), borderRadius: BorderRadius.circular(16),
boxShadow: [ border: Border.all(
BoxShadow( color: Colors.black.withValues(alpha: 0.12),
color: Colors.black.withValues(alpha: 0.06), width: 1.0,
blurRadius: 18, ),
offset: const Offset(0, 10), boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
), ),
], child: TextField(
), controller: _numberController,
child: TextField( keyboardType: TextInputType.number,
controller: _numberController, textAlign: TextAlign.center,
keyboardType: TextInputType.number, inputFormatters: [
textAlign: TextAlign.center, FilteringTextInputFormatter.digitsOnly,
inputFormatters: [ LengthLimitingTextInputFormatter(2),
FilteringTextInputFormatter.digitsOnly, ],
LengthLimitingTextInputFormatter(2), style: const TextStyle(
], fontSize: 24,
style: const TextStyle( fontWeight: FontWeight.w900,
fontSize: 24, color: Color(0xFF2F9E94),
fontWeight: FontWeight.w900, ),
color: Color(0xFF2F9E94), decoration: const InputDecoration(
), border: InputBorder.none,
decoration: const InputDecoration( hintText: '0',
border: InputBorder.none, hintStyle: TextStyle(
hintText: '0', fontSize: 24,
hintStyle: TextStyle( fontWeight: FontWeight.w900,
fontSize: 24, color: Colors.grey,
fontWeight: FontWeight.w900, ),
color: Colors.grey, contentPadding: EdgeInsets.symmetric(vertical: 20),
),
onChanged: (value) {
setState(() {
_numberValue = int.tryParse(value);
});
},
), ),
contentPadding: EdgeInsets.symmetric(vertical: 20),
), ),
onChanged: (value) {
setState(() {
_numberValue = int.tryParse(value);
});
},
), ),
), ),
if (_numberValue != null && _numberValue! > _maxTeethCount) ...[ if (_numberValue != null &&
_numberValue! > _maxTeethCount &&
!_numberDontKnow) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.', 'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.',
@@ -599,6 +699,62 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
), ),
), ),
], ],
const SizedBox(height: 14),
TapBounce(
scale: 0.97,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () {
setState(() {
_numberDontKnow = !_numberDontKnow;
if (_numberDontKnow) {
_numberController?.clear();
_numberValue = null;
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: _numberDontKnow
? const Color(0xFF2F9E94)
: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: _numberDontKnow
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.help_outline_rounded,
size: 16,
color: _numberDontKnow
? Colors.white
: Colors.black.withValues(alpha: 0.55),
),
const SizedBox(width: 6),
Text(
'Não sei',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 13,
color: _numberDontKnow
? Colors.white
: Colors.black.withValues(alpha: 0.55),
),
),
],
),
),
),
),
], ],
), ),
); );
@@ -745,11 +901,38 @@ class _QuestionReferenceImages extends StatelessWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: Image.asset( child: Stack(
paths.first, fit: StackFit.expand,
fit: BoxFit.cover, children: [
cacheWidth: 800, Image.asset(
errorBuilder: (context, error, stackTrace) => _placeholder(), paths.first,
fit: BoxFit.cover,
cacheWidth: 800,
errorBuilder: (context, error, stackTrace) => _placeholder(),
),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'Imagem de referência',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 10.5,
),
),
),
),
],
), ),
), ),
); );
@@ -787,3 +970,150 @@ class _QuestionReferenceImages extends StatelessWidget {
); );
} }
} }
/// Mostrado quando a pergunta não tem imagem de referência — em vez de o
/// bloco colapsar (como acontecia antes), mostra um pequeno ícone colorido
/// relevante ao tema, sem ocupar a largura toda (esse destaque é reservado
/// para as perguntas que realmente têm imagem).
class _FallbackIconBlock extends StatelessWidget {
const _FallbackIconBlock({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(18),
),
child: Icon(icon, size: 32, color: Colors.white),
),
);
}
}
/// Resposta Sim/Não em formato de pílula horizontal: círculo colorido +
/// texto + indicador circular à direita que preenche quando selecionado.
class _QuizAnswerPill extends StatelessWidget {
const _QuizAnswerPill({
required this.answer,
required this.selected,
required this.onTap,
});
final QuizAnswer answer;
final bool selected;
final VoidCallback onTap;
bool get _isYes => (answer.value ?? '').trim().toLowerCase() == 'sim';
bool get _isNo => (answer.value ?? '').trim().toLowerCase() == 'nao';
@override
Widget build(BuildContext context) {
final accent = _isYes
? const Color(0xFF2F9E94)
: _isNo
? const Color(0xFFFF55A7)
: Colors.black.withValues(alpha: 0.35);
final borderColor = selected
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.10);
return TapBounce(
scale: 0.97,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.92)
: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
splashFactory: InkSparkle.splashFactory,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
child: Icon(
_isYes
? Icons.check_rounded
: _isNo
? Icons.close_rounded
: Icons.help_outline_rounded,
color: Colors.white,
size: 17,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
answer.title,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
color: Color(0xFF2F9E94),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? const Color(0xFF2F9E94)
: Colors.transparent,
border: Border.all(
color: selected
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.25),
width: 1.6,
),
),
child: selected
? const Icon(
Icons.check_rounded,
size: 14,
color: Colors.white,
)
: null,
),
],
),
),
),
),
),
);
}
}

View File

@@ -33,34 +33,29 @@ class _SettingsBodyState extends State<SettingsBody> {
context, context,
title: 'Apagar dados da conta', title: 'Apagar dados da conta',
message: message:
'Isso remove permanentemente seu perfil, crianças cadastradas e ' 'Isso remove permanentemente a sua conta, perfil, crianças '
'fotos. Essa ação não pode ser desfeita. Deseja continuar?', 'cadastradas e fotos — incluindo o login, permitindo criar uma '
'nova conta com o mesmo e-mail depois. Essa ação não pode ser '
'desfeita. Deseja continuar?',
confirmLabel: 'Apagar', confirmLabel: 'Apagar',
confirmColor: _accentPink, confirmColor: _accentPink,
); );
if (confirmed != true) return; if (confirmed != true) return;
final uid = supabase.auth.currentUser?.id;
if (uid == null) return;
setState(() => _deletingAccount = true); setState(() => _deletingAccount = true);
try { try {
await supabase.from('children').delete().eq('owner_id', uid); // A remoção de `auth.users` exige a service role key, que o app nunca
// deve carregar — por isso corre numa Edge Function (server-side); ver
// O Supabase não avisa quando uma política de RLS bloqueia silenciosamente // supabase/functions/delete-account. Sem isto, apagar só as linhas de
// uma operação: sem `.select()` para devolver as linhas apagadas não há // `profiles`/`children` deixava o e-mail "ocupado" no Supabase Auth,
// como distinguir "0 linhas existiam" de "sem permissão para apagar". // impedindo criar uma nova conta com o mesmo e-mail.
final deletedProfile = await supabase final response = await supabase.functions.invoke('delete-account');
.from('profiles') final data = response.data;
.delete() final errorMessage = (data is Map) ? data['error']?.toString() : null;
.eq('id', uid) if (response.status != 200 || errorMessage != null) {
.select('id');
if (deletedProfile.isEmpty) {
throw StateError( throw StateError(
'A base de dados recusou apagar o perfil (sem política de RLS ' errorMessage ?? 'Erro ao apagar conta (status ${response.status})',
'para DELETE). Os dados não foram removidos.',
); );
} }

View File

@@ -7,6 +7,7 @@ import 'package:lottie/lottie.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import '../watched_videos_prefs.dart';
import '../widgets/app_gradients.dart'; import '../widgets/app_gradients.dart';
import '../widgets/entrance.dart'; import '../widgets/entrance.dart';
import '../widgets/tap_bounce.dart'; import '../widgets/tap_bounce.dart';
@@ -97,19 +98,19 @@ final List<VideoData> videoList = [
id: 11, id: 11,
title: 'Episódio 11', title: 'Episódio 11',
description: 'Aprenda sobre saúde bucal neste episódio', description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_11.mp4', youtubeId: '6sYoBUjks_I',
), ),
VideoData( VideoData(
id: 12, id: 12,
title: 'Episódio 12', title: 'Episódio 12',
description: 'Aprenda sobre saúde bucal neste episódio', description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_12.mp4', youtubeId: 'eznKrErQbHo',
), ),
VideoData( VideoData(
id: 13, id: 13,
title: 'Episódio 13', title: 'Episódio 13',
description: 'Aprenda sobre saúde bucal neste episódio', description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_13.mp4', youtubeId: 'VO9CNqHRdeM',
), ),
]; ];
@@ -156,7 +157,20 @@ void _evictAllVideoControllers() {
} }
} }
Future<void> showVideoPlayerDialog(BuildContext context, VideoData video) { /// Regista o episódio como assistido (localmente, por criança). Chamado
/// quando um player deteta que o vídeo chegou ao fim — sem [scopeId] (nenhuma
/// criança selecionada) não há onde guardar, por isso não faz nada.
void markVideoWatched(String? scopeId, int videoId) {
final scope = (scopeId ?? '').trim();
if (scope.isEmpty) return;
WatchedVideosPrefs.markWatched(scope, videoId);
}
Future<void> showVideoPlayerDialog(
BuildContext context,
VideoData video, {
String? scopeId,
}) {
if (video.youtubeId != null) { if (video.youtubeId != null) {
if (video.youtubeId!.isEmpty) { if (video.youtubeId!.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -165,17 +179,23 @@ Future<void> showVideoPlayerDialog(BuildContext context, VideoData video) {
return Future.value(); return Future.value();
} }
return Navigator.of(context).push<void>( return Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)), MaterialPageRoute(
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
),
); );
} }
return showDialog<void>( return showDialog<void>(
context: context, context: context,
builder: (context) => _VideoPlayerDialog(video: video), builder: (context) => _VideoPlayerDialog(video: video, scopeId: scopeId),
); );
} }
class VideoScreen extends StatefulWidget { class VideoScreen extends StatefulWidget {
const VideoScreen({super.key}); const VideoScreen({super.key, this.scopeId});
/// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para
/// guardar localmente quais episódios ela já assistiu até ao fim.
final String? scopeId;
static const Color _teal = Color(0xFF2F9E94); static const Color _teal = Color(0xFF2F9E94);
static const Color _accentPink = Color(0xFFFF55A7); static const Color _accentPink = Color(0xFFFF55A7);
@@ -329,6 +349,7 @@ class _VideoScreenState extends State<VideoScreen> {
), ),
child: _VideoButton( child: _VideoButton(
video: _filteredVideos[index], video: _filteredVideos[index],
scopeId: widget.scopeId,
), ),
); );
}, },
@@ -509,9 +530,10 @@ class _VideoThumbnailState extends State<VideoThumbnail> {
} }
class _VideoButton extends StatelessWidget { class _VideoButton extends StatelessWidget {
const _VideoButton({required this.video}); const _VideoButton({required this.video, this.scopeId});
final VideoData video; final VideoData video;
final String? scopeId;
void _showVideoPlayer(BuildContext context, VideoData video) { void _showVideoPlayer(BuildContext context, VideoData video) {
if (video.youtubeId == null) { if (video.youtubeId == null) {
@@ -519,7 +541,7 @@ class _VideoButton extends StatelessWidget {
// em dialog, que precisa dos seus próprios decoders de vídeo/áudio. // em dialog, que precisa dos seus próprios decoders de vídeo/áudio.
_evictAllVideoControllers(); _evictAllVideoControllers();
} }
showVideoPlayerDialog(context, video); showVideoPlayerDialog(context, video, scopeId: scopeId);
} }
@override @override
@@ -588,9 +610,10 @@ class _VideoButton extends StatelessWidget {
/// a proporção 16:9 do YouTube e sobrar espaço vazio quando o ecrã tem uma /// a proporção 16:9 do YouTube e sobrar espaço vazio quando o ecrã tem uma
/// proporção mais larga que 16:9 (ex.: a maioria dos telemóveis atuais). /// proporção mais larga que 16:9 (ex.: a maioria dos telemóveis atuais).
class _YoutubePlayerPage extends StatefulWidget { class _YoutubePlayerPage extends StatefulWidget {
const _YoutubePlayerPage({required this.video}); const _YoutubePlayerPage({required this.video, this.scopeId});
final VideoData video; final VideoData video;
final String? scopeId;
@override @override
State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState(); State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState();
@@ -599,6 +622,7 @@ class _YoutubePlayerPage extends StatefulWidget {
class _YoutubePlayerPageState extends State<_YoutubePlayerPage> class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
with WidgetsBindingObserver { with WidgetsBindingObserver {
late final YoutubePlayerController _controller; late final YoutubePlayerController _controller;
bool _markedWatched = false;
@override @override
void initState() { void initState() {
@@ -607,9 +631,18 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
initialVideoId: widget.video.youtubeId!, initialVideoId: widget.video.youtubeId!,
flags: const YoutubePlayerFlags(autoPlay: true, mute: false), flags: const YoutubePlayerFlags(autoPlay: true, mute: false),
); );
_controller.addListener(_onControllerValueChanged);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
void _onControllerValueChanged() {
if (_markedWatched) return;
if (_controller.value.playerState == PlayerState.ended) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.video.id);
}
}
@override @override
void didChangeMetrics() { void didChangeMetrics() {
final isLandscape = final isLandscape =
@@ -628,6 +661,7 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
SystemChrome.restoreSystemUIOverlays(); SystemChrome.restoreSystemUIOverlays();
_controller.removeListener(_onControllerValueChanged);
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -716,9 +750,10 @@ class _CoverYoutubePlayer extends StatelessWidget {
} }
class _VideoPlayerDialog extends StatefulWidget { class _VideoPlayerDialog extends StatefulWidget {
const _VideoPlayerDialog({required this.video}); const _VideoPlayerDialog({required this.video, this.scopeId});
final VideoData video; final VideoData video;
final String? scopeId;
@override @override
State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState(); State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState();
@@ -727,6 +762,7 @@ class _VideoPlayerDialog extends StatefulWidget {
class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
late VideoPlayerController _controller; late VideoPlayerController _controller;
bool _isInitialized = false; bool _isInitialized = false;
bool _markedWatched = false;
@override @override
void initState() { void initState() {
@@ -738,6 +774,7 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
_controller = VideoPlayerController.asset(widget.video.videoPath!); _controller = VideoPlayerController.asset(widget.video.videoPath!);
try { try {
await _controller.initialize(); await _controller.initialize();
_controller.addListener(_onControllerValueChanged);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isInitialized = true; _isInitialized = true;
@@ -755,8 +792,19 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
} }
} }
void _onControllerValueChanged() {
if (_markedWatched) return;
final value = _controller.value;
if (!value.isInitialized || value.duration == Duration.zero) return;
if (value.position >= value.duration - const Duration(milliseconds: 300)) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.video.id);
}
}
@override @override
void dispose() { void dispose() {
_controller.removeListener(_onControllerValueChanged);
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -802,6 +850,8 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
_VideoControls( _VideoControls(
controller: _controller, controller: _controller,
videoPath: widget.video.videoPath!, videoPath: widget.video.videoPath!,
videoId: widget.video.id,
scopeId: widget.scopeId,
onClose: () => Navigator.of(context).pop(), onClose: () => Navigator.of(context).pop(),
), ),
], ],
@@ -817,11 +867,15 @@ class _VideoControls extends StatefulWidget {
const _VideoControls({ const _VideoControls({
required this.controller, required this.controller,
required this.videoPath, required this.videoPath,
required this.videoId,
required this.onClose, required this.onClose,
this.scopeId,
}); });
final VideoPlayerController controller; final VideoPlayerController controller;
final String videoPath; final String videoPath;
final int videoId;
final String? scopeId;
final VoidCallback onClose; final VoidCallback onClose;
@override @override
@@ -926,6 +980,8 @@ class _VideoControlsState extends State<_VideoControls> {
MaterialPageRoute( MaterialPageRoute(
builder: (context) => _FullscreenVideoPlayer( builder: (context) => _FullscreenVideoPlayer(
videoPath: widget.videoPath, videoPath: widget.videoPath,
videoId: widget.videoId,
scopeId: widget.scopeId,
), ),
fullscreenDialog: true, fullscreenDialog: true,
), ),
@@ -949,9 +1005,15 @@ class _VideoControlsState extends State<_VideoControls> {
} }
class _FullscreenVideoPlayer extends StatefulWidget { class _FullscreenVideoPlayer extends StatefulWidget {
const _FullscreenVideoPlayer({required this.videoPath}); const _FullscreenVideoPlayer({
required this.videoPath,
required this.videoId,
this.scopeId,
});
final String videoPath; final String videoPath;
final int videoId;
final String? scopeId;
@override @override
State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState(); State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
@@ -960,6 +1022,7 @@ class _FullscreenVideoPlayer extends StatefulWidget {
class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> {
late VideoPlayerController _controller; late VideoPlayerController _controller;
bool _isInitialized = false; bool _isInitialized = false;
bool _markedWatched = false;
@override @override
void initState() { void initState() {
@@ -994,6 +1057,16 @@ class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> {
} }
void _onControllerUpdate() { void _onControllerUpdate() {
if (!_markedWatched) {
final value = _controller.value;
if (value.isInitialized &&
value.duration != Duration.zero &&
value.position >=
value.duration - const Duration(milliseconds: 300)) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.videoId);
}
}
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }

View File

@@ -0,0 +1,36 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Registo local (por criança) de episódios assistidos até ao fim.
/// Segue o mesmo padrão de [QuizPrefs]: chaves com sufixo `_scopeId`,
/// guardadas via [SharedPreferences], sem qualquer chamada ao Supabase.
class WatchedVideosPrefs {
static const String _kWatchedKey = 'watched_videos';
static String _key(String scopeId) => '${_kWatchedKey}_$scopeId';
static Future<void> markWatched(String scopeId, int videoId) async {
final prefs = await SharedPreferences.getInstance();
final key = _key(scopeId);
final ids = (prefs.getStringList(key) ?? <String>[]).toSet();
ids.add(videoId.toString());
await prefs.setStringList(key, ids.toList());
}
static Future<bool> isWatched(String scopeId, int videoId) async {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_key(scopeId)) ?? const [];
return ids.contains(videoId.toString());
}
static Future<int> getWatchedCount(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_key(scopeId)) ?? const [];
return ids.length;
}
static Future<Set<int>> getWatchedIds(String scopeId) async {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_key(scopeId)) ?? const [];
return ids.map((e) => int.tryParse(e) ?? -1).where((e) => e >= 0).toSet();
}
}

View File

@@ -14,3 +14,10 @@ const LinearGradient kGreenButtonGradient = LinearGradient(
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [Color(0xFF2F9E94), Color(0xFF6BB79F)], colors: [Color(0xFF2F9E94), Color(0xFF6BB79F)],
); );
/// Gradiente rosa vivo do card do quiz na Home.
const LinearGradient kPinkHeroGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF55A7), Color(0xFFE83E93)],
);

View File

@@ -71,9 +71,6 @@ flutter:
- lottie/ - lottie/
- assets/Check-theeth.png - assets/Check-theeth.png
- assets/mockup_images/ - assets/mockup_images/
- assets/videos/episodio_11.mp4
- assets/videos/episodio_12.mp4
- assets/videos/episodio_13.mp4
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true

View File

@@ -0,0 +1 @@
{"ref":"mannjismlhlwaqqqnvog","name":"Check_Teeth_Kids","organization_id":"huurggiiridujjbkbayo","organization_slug":"huurggiiridujjbkbayo"}

View File

@@ -0,0 +1,65 @@
// Edge Function: apaga TODA a conta do utilizador autenticado — linhas em
// `children`/`profiles` e o próprio registo em `auth.users`. Isto é o que
// falta para permitir criar uma nova conta com o mesmo e-mail depois de
// "Apagar dados da conta": o app (client) nunca teve a service role key para
// poder chamar `auth.admin.deleteUser`, por isso essa etapa faltava e o
// e-mail continuava "ocupado" no Supabase Auth.
//
// Deploy (uma vez, com a Supabase CLI já autenticada):
// supabase functions deploy delete-account --project-ref mannjismlhlwaqqqnvog
//
// SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY já ficam disponíveis
// automaticamente dentro de toda Edge Function — não é preciso configurar
// nenhum secret manualmente.
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
Deno.serve(async (req) => {
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
}
const authHeader = req.headers.get('Authorization') ?? '';
const jwt = authHeader.replace('Bearer ', '').trim();
if (!jwt) {
return new Response(JSON.stringify({ error: 'Sessão em falta' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
// Valida o JWT do chamador e identifica o uid — nunca confiar num uid vindo
// do corpo do pedido, sob pena de qualquer pessoa poder apagar outra conta.
const { data: userData, error: userError } = await admin.auth.getUser(jwt);
if (userError || !userData?.user) {
return new Response(JSON.stringify({ error: 'Sessão inválida' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const uid = userData.user.id;
await admin.from('children').delete().eq('owner_id', uid);
await admin.from('profiles').delete().eq('id', uid);
const { error: deleteUserError } = await admin.auth.admin.deleteUser(uid);
if (deleteUserError) {
return new Response(JSON.stringify({ error: deleteUserError.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});