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