CKT 1.0.2 | otorrinolaringologista
This commit is contained in:
@@ -12,6 +12,7 @@ import 'brushing_prefs.dart';
|
|||||||
import 'main.dart' show supabase;
|
import 'main.dart' show supabase;
|
||||||
import 'quiz/quiz1.dart';
|
import 'quiz/quiz1.dart';
|
||||||
import 'quiz/quiz_prefs.dart';
|
import 'quiz/quiz_prefs.dart';
|
||||||
|
import 'quiz/quiz_progress_prefs.dart';
|
||||||
import 'quiz/quiz_result.dart' show kSignsMax, kFactorsMax;
|
import 'quiz/quiz_result.dart' show kSignsMax, kFactorsMax;
|
||||||
import 'screens/settings_screen.dart';
|
import 'screens/settings_screen.dart';
|
||||||
import 'screens/video_screen.dart';
|
import 'screens/video_screen.dart';
|
||||||
@@ -733,10 +734,91 @@ class _InicioTab extends StatelessWidget {
|
|||||||
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
|
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
|
||||||
state?.selectChild(childName, scopeId);
|
state?.selectChild(childName, scopeId);
|
||||||
|
|
||||||
await Navigator.of(context).push(quizStartRoute(scopeId: scopeId));
|
final progress = await QuizProgressPrefs.getProgress(scopeId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
Route<void> route;
|
||||||
|
if (progress != null) {
|
||||||
|
final resume = await _confirmResumeQuiz(context, childName: childName);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (resume) {
|
||||||
|
route = quizResumeRoute(
|
||||||
|
questionIndex: progress.questionIndex,
|
||||||
|
score: progress.score,
|
||||||
|
scopeId: scopeId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await QuizProgressPrefs.clearProgress();
|
||||||
|
route = quizStartRoute(scopeId: scopeId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
route = quizStartRoute(scopeId: scopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await Navigator.of(context).push(route);
|
||||||
onQuizClosed();
|
onQuizClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pergunta se quer continuar de onde parou ou recomeçar — mostrado só
|
||||||
|
/// quando há progresso guardado para a mesma criança escolhida agora.
|
||||||
|
/// Sem opção de cancelar: uma das duas ações inicia sempre o quiz.
|
||||||
|
Future<bool> _confirmResumeQuiz(
|
||||||
|
BuildContext context, {
|
||||||
|
required String childName,
|
||||||
|
}) async {
|
||||||
|
final resume = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
HomeStrings.resumeQuizTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w900, color: AppColors.pink),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
HomeStrings.resumeQuizMessage(childName),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.black.withValues(alpha: 0.72)),
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
|
actions: [
|
||||||
|
TapBounce(
|
||||||
|
child: TextButton(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.teal),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text(HomeStrings.restartQuiz),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TapBounce(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(gradient: kGreenButtonGradient),
|
||||||
|
child: FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text(HomeStrings.resumeQuiz),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resume ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
|
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
|
||||||
@@ -795,6 +877,9 @@ class _InicioTab extends StatelessWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: VideoScreen.routeName,
|
||||||
|
),
|
||||||
builder: (_) => VideoScreen(scopeId: scopeId),
|
builder: (_) => VideoScreen(scopeId: scopeId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1903,6 +1988,14 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
context,
|
context,
|
||||||
HomeStrings.timeoutAdding,
|
HomeStrings.timeoutAdding,
|
||||||
);
|
);
|
||||||
|
} on PostgrestException catch (e) {
|
||||||
|
if (!mounted || !context.mounted) return;
|
||||||
|
showPillSnackBar(
|
||||||
|
context,
|
||||||
|
e.code == '23505'
|
||||||
|
? HomeStrings.childCodeAlreadyInUse
|
||||||
|
: HomeStrings.errorAdding(e),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted || !context.mounted) return;
|
if (!mounted || !context.mounted) return;
|
||||||
showPillSnackBar(context, HomeStrings.errorAdding(e));
|
showPillSnackBar(context, HomeStrings.errorAdding(e));
|
||||||
@@ -2379,6 +2472,7 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
DateTime? _birthDate;
|
DateTime? _birthDate;
|
||||||
String? _gender;
|
String? _gender;
|
||||||
String? _birthDateError;
|
String? _birthDateError;
|
||||||
|
String? _genderError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -2408,12 +2502,14 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
|
|
||||||
void _submit() {
|
void _submit() {
|
||||||
final formOk = _formKey.currentState?.validate() ?? false;
|
final formOk = _formKey.currentState?.validate() ?? false;
|
||||||
|
final genderMissing = (_gender ?? '').trim().isEmpty;
|
||||||
setState(() {
|
setState(() {
|
||||||
_birthDateError = _birthDate == null
|
_birthDateError = _birthDate == null
|
||||||
? HomeStrings.birthDateRequired
|
? HomeStrings.birthDateRequired
|
||||||
: null;
|
: null;
|
||||||
|
_genderError = genderMissing ? HomeStrings.genderRequired : null;
|
||||||
});
|
});
|
||||||
if (!formOk || _birthDate == null) return;
|
if (!formOk || _birthDate == null || genderMissing) return;
|
||||||
Navigator.of(context).pop({
|
Navigator.of(context).pop({
|
||||||
'name': _nameController.text.trim(),
|
'name': _nameController.text.trim(),
|
||||||
'birth_date': _birthDate!.toIso8601String().split('T').first,
|
'birth_date': _birthDate!.toIso8601String().split('T').first,
|
||||||
@@ -2514,27 +2610,16 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<String>(
|
Padding(
|
||||||
initialValue: _gender,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
items: const [
|
child: _GenderPillSelector(
|
||||||
DropdownMenuItem(
|
value: _gender,
|
||||||
value: HomeStrings.male,
|
errorText: _genderError,
|
||||||
child: Text(HomeStrings.male),
|
onChanged: (v) => setState(() {
|
||||||
),
|
_gender = v;
|
||||||
DropdownMenuItem(
|
_genderError = null;
|
||||||
value: HomeStrings.female,
|
}),
|
||||||
child: Text(HomeStrings.female),
|
),
|
||||||
),
|
|
||||||
DropdownMenuItem(value: HomeStrings.other, child: Text(HomeStrings.other)),
|
|
||||||
],
|
|
||||||
onChanged: (v) => setState(() => _gender = v),
|
|
||||||
decoration: const InputDecoration(labelText: HomeStrings.gender),
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.trim().isEmpty) {
|
|
||||||
return HomeStrings.genderRequired;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2588,3 +2673,110 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const List<String> _kGenderOptions = [
|
||||||
|
HomeStrings.male,
|
||||||
|
HomeStrings.female,
|
||||||
|
HomeStrings.other,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Seletor de género em formato de pílulas selecionáveis, no mesmo estilo
|
||||||
|
/// visual das respostas Sim/Não do quiz — usado em vez de um dropdown
|
||||||
|
/// genérico.
|
||||||
|
class _GenderPillSelector extends StatelessWidget {
|
||||||
|
const _GenderPillSelector({
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? value;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
final String? errorText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasError = errorText != null;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
HomeStrings.gender,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: hasError
|
||||||
|
? AppColors.pink
|
||||||
|
: Colors.black.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _kGenderOptions.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _GenderPill(
|
||||||
|
label: _kGenderOptions[i],
|
||||||
|
selected: value == _kGenderOptions[i],
|
||||||
|
onTap: () => onChanged(_kGenderOptions[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (hasError) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
errorText!,
|
||||||
|
style: const TextStyle(color: AppColors.pink, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenderPill extends StatelessWidget {
|
||||||
|
const _GenderPill({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TapBounce(
|
||||||
|
scale: 0.96,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? AppColors.teal : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected
|
||||||
|
? AppColors.teal
|
||||||
|
: Colors.black.withValues(alpha: 0.22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: selected ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,13 +65,73 @@ Route<void> quizStartRoute({String? scopeId}) {
|
|||||||
QuizStrings.checklistItem2,
|
QuizStrings.checklistItem2,
|
||||||
QuizStrings.checklistItem3,
|
QuizStrings.checklistItem3,
|
||||||
],
|
],
|
||||||
onAdvance: (context) => Navigator.of(context).pushReplacement(
|
// Importante: usa push (não pushReplacement) — _startQuiz(), em
|
||||||
|
// logged_home.dart, faz `await Navigator.push(quizStartRoute(...))`
|
||||||
|
// (esta rota, a checklist) para saber quando o quiz inteiro terminou
|
||||||
|
// e recarregar o resultado na Home. Se esta rota fosse substituída
|
||||||
|
// aqui, o seu future completava logo ao entrar na Quiz1 — muito antes
|
||||||
|
// do fim real do quiz — e a Home deixava de saber quando recarregar,
|
||||||
|
// só voltando a mostrar o resultado novo depois de fechar e reabrir
|
||||||
|
// a app.
|
||||||
|
onAdvance: (context) => Navigator.of(context).push(
|
||||||
quizPageRoute(builder: (_) => Quiz1Screen(scopeId: scopeId)),
|
quizPageRoute(builder: (_) => Quiz1Screen(scopeId: scopeId)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Um construtor por pergunta (índice 0 = Quiz1, ..., 28 = Quiz29), usado
|
||||||
|
/// por [quizResumeRoute] para saltar diretamente para a pergunta onde um
|
||||||
|
/// quiz anterior ficou por terminar.
|
||||||
|
final List<Widget Function(QuizScore score, String? scopeId)>
|
||||||
|
_kQuizScreenBuilders = [
|
||||||
|
(score, scopeId) => Quiz1Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz2Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz3Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz4Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz5Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz6Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz7Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz8Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz9Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz10Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz11Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz12Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz13Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz14Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz15Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz16Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz17Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz18Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz19Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz20Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz21Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz22Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz23Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz24Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz25Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz26Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz27Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz28Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
(score, scopeId) => Quiz29Screen(currentScore: score, scopeId: scopeId),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Salta diretamente para a pergunta [questionIndex] (1-based) do quiz, com
|
||||||
|
/// a pontuação já acumulada — usado para "Continuar de onde parou".
|
||||||
|
Route<void> quizResumeRoute({
|
||||||
|
required int questionIndex,
|
||||||
|
required QuizScore score,
|
||||||
|
String? scopeId,
|
||||||
|
}) {
|
||||||
|
final index = (questionIndex - 1).clamp(
|
||||||
|
0,
|
||||||
|
_kQuizScreenBuilders.length - 1,
|
||||||
|
);
|
||||||
|
return quizPageRoute(
|
||||||
|
builder: (_) => _kQuizScreenBuilders[index](score, scopeId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Quiz 1: Problemas respiratórios (Yes/No)
|
// Quiz 1: Problemas respiratórios (Yes/No)
|
||||||
class Quiz1Screen extends StatelessWidget {
|
class Quiz1Screen extends StatelessWidget {
|
||||||
const Quiz1Screen({
|
const Quiz1Screen({
|
||||||
@@ -111,6 +171,7 @@ class Quiz1Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz2Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz2Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -155,6 +216,7 @@ class Quiz2Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz3Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz3Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -199,6 +261,7 @@ class Quiz3Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz4Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz4Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -243,6 +306,7 @@ class Quiz4Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz5Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz5Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -287,6 +351,7 @@ class Quiz5Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz6Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz6Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -324,6 +389,7 @@ class Quiz6Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz7Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz7Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -368,6 +434,7 @@ class Quiz7Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz8Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz8Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -412,6 +479,7 @@ class Quiz8Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz9Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz9Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -456,6 +524,7 @@ class Quiz9Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz10Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz10Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -500,6 +569,7 @@ class Quiz10Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz11Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz11Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -544,6 +614,7 @@ class Quiz11Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz12Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz12Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -581,6 +652,7 @@ class Quiz12Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz13Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz13Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -618,6 +690,7 @@ class Quiz13Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz14Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz14Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -655,6 +728,7 @@ class Quiz14Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz15Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz15Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -699,6 +773,7 @@ class Quiz15Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz16Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz16Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -743,6 +818,7 @@ class Quiz16Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz17Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz17Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -787,6 +863,7 @@ class Quiz17Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => QuizVideoGuideScreen(
|
builder: (_) => QuizVideoGuideScreen(
|
||||||
youtubeId: 'W2BcK9nSyt0',
|
youtubeId: 'W2BcK9nSyt0',
|
||||||
@@ -837,6 +914,7 @@ class Quiz18Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz19Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz19Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -898,6 +976,7 @@ class Quiz19Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz20Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz20Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -943,6 +1022,7 @@ class Quiz20Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz21Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz21Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -992,6 +1072,7 @@ class Quiz21Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz22Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz22Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1039,6 +1120,7 @@ class Quiz22Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => QuizVideoGuideScreen(
|
builder: (_) => QuizVideoGuideScreen(
|
||||||
youtubeId: 'msKYr7nPxcw',
|
youtubeId: 'msKYr7nPxcw',
|
||||||
@@ -1097,6 +1179,7 @@ class Quiz23Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz24Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz24Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1147,6 +1230,7 @@ class Quiz24Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1188,6 +1272,7 @@ class Quiz25Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1229,6 +1314,7 @@ class Quiz26Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz27Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz27Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1279,6 +1365,7 @@ class Quiz27Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz28Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz28Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1321,6 +1408,7 @@ class Quiz28Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => Quiz29Screen(currentScore: nextScore, scopeId: scopeId),
|
builder: (_) => Quiz29Screen(currentScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
@@ -1364,6 +1452,7 @@ class Quiz29Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
|
scopeId: scopeId,
|
||||||
nextRoute: (context, nextScore) => quizPageRoute(
|
nextRoute: (context, nextScore) => quizPageRoute(
|
||||||
builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId),
|
builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId),
|
||||||
),
|
),
|
||||||
|
|||||||
61
lib/quiz/quiz_progress_prefs.dart
Normal file
61
lib/quiz/quiz_progress_prefs.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'quiz_question_screen.dart' show QuizScore;
|
||||||
|
|
||||||
|
/// Progresso de um quiz por terminar: em que pergunta ficou e a pontuação
|
||||||
|
/// acumulada até aí.
|
||||||
|
class QuizProgressData {
|
||||||
|
const QuizProgressData({required this.questionIndex, required this.score});
|
||||||
|
|
||||||
|
/// Número (1-based) da pergunta onde retomar.
|
||||||
|
final int questionIndex;
|
||||||
|
final QuizScore score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guarda o progresso de um quiz abandonado a meio (ex.: "Voltar para
|
||||||
|
/// homepage"), para oferecer "Continuar de onde parou" da próxima vez que o
|
||||||
|
/// mesmo utilizador iniciar o quiz para a mesma criança. Só existe uma
|
||||||
|
/// ranhura (não uma por criança): ao guardar progresso para uma criança
|
||||||
|
/// diferente, substitui o anterior — como só se mostra a opção de continuar
|
||||||
|
/// quando o [scopeId] guardado corresponde exatamente à criança escolhida,
|
||||||
|
/// isto já cumpre "só se for a mesma criança" sem precisar de armazenamento
|
||||||
|
/// por criança.
|
||||||
|
class QuizProgressPrefs {
|
||||||
|
static const String _kScopeKey = 'quiz_progress_scope';
|
||||||
|
static const String _kIndexKey = 'quiz_progress_index';
|
||||||
|
static const String _kSignsKey = 'quiz_progress_signs';
|
||||||
|
static const String _kFactorsKey = 'quiz_progress_factors';
|
||||||
|
|
||||||
|
static Future<void> saveProgress({
|
||||||
|
required String scopeId,
|
||||||
|
required int questionIndex,
|
||||||
|
required QuizScore score,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_kScopeKey, scopeId);
|
||||||
|
await prefs.setInt(_kIndexKey, questionIndex);
|
||||||
|
await prefs.setInt(_kSignsKey, score.signs);
|
||||||
|
await prefs.setInt(_kFactorsKey, score.factors);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<QuizProgressData?> getProgress(String scopeId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (prefs.getString(_kScopeKey) != scopeId) return null;
|
||||||
|
final index = prefs.getInt(_kIndexKey);
|
||||||
|
final signs = prefs.getInt(_kSignsKey);
|
||||||
|
final factors = prefs.getInt(_kFactorsKey);
|
||||||
|
if (index == null || signs == null || factors == null) return null;
|
||||||
|
return QuizProgressData(
|
||||||
|
questionIndex: index,
|
||||||
|
score: QuizScore(signs: signs, factors: factors),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearProgress() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_kScopeKey);
|
||||||
|
await prefs.remove(_kIndexKey);
|
||||||
|
await prefs.remove(_kSignsKey);
|
||||||
|
await prefs.remove(_kFactorsKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../strings/quiz_ui_strings.dart';
|
|||||||
import '../widgets/entrance.dart';
|
import '../widgets/entrance.dart';
|
||||||
import '../widgets/liquid_waves_background.dart';
|
import '../widgets/liquid_waves_background.dart';
|
||||||
import '../widgets/tap_bounce.dart';
|
import '../widgets/tap_bounce.dart';
|
||||||
|
import 'quiz_progress_prefs.dart';
|
||||||
|
|
||||||
typedef QuizNextBuilder =
|
typedef QuizNextBuilder =
|
||||||
Route<void> Function(BuildContext context, QuizScore nextScore);
|
Route<void> Function(BuildContext context, QuizScore nextScore);
|
||||||
@@ -85,6 +86,7 @@ class QuizQuestionScreen extends StatefulWidget {
|
|||||||
this.fallbackColor,
|
this.fallbackColor,
|
||||||
this.answerImageAspectRatio,
|
this.answerImageAspectRatio,
|
||||||
this.correctBadgeLabel = QuizUiStrings.defaultCorrectBadgeLabel,
|
this.correctBadgeLabel = QuizUiStrings.defaultCorrectBadgeLabel,
|
||||||
|
this.scopeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -93,6 +95,11 @@ class QuizQuestionScreen extends StatefulWidget {
|
|||||||
final QuizNextBuilder nextRoute;
|
final QuizNextBuilder nextRoute;
|
||||||
final QuizScore currentScore;
|
final QuizScore currentScore;
|
||||||
|
|
||||||
|
/// Identifica a criança selecionada — usado só para guardar o progresso
|
||||||
|
/// do quiz localmente (ver [QuizProgressPrefs]), para poder oferecer
|
||||||
|
/// "Continuar de onde parou" caso o utilizador saia a meio.
|
||||||
|
final String? scopeId;
|
||||||
|
|
||||||
/// Quando true, uma resposta "de risco" (weight 2) soma a
|
/// Quando true, uma resposta "de risco" (weight 2) soma a
|
||||||
/// [QuizScore.signs] em vez de [QuizScore.factors] — usado só nas 4
|
/// [QuizScore.signs] em vez de [QuizScore.factors] — usado só nas 4
|
||||||
/// perguntas que representam sinais de má oclusão já instalados
|
/// perguntas que representam sinais de má oclusão já instalados
|
||||||
@@ -612,6 +619,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final scopeId =
|
||||||
|
widget.scopeId;
|
||||||
|
if (scopeId != null &&
|
||||||
|
scopeId
|
||||||
|
.trim()
|
||||||
|
.isNotEmpty) {
|
||||||
|
QuizProgressPrefs.saveProgress(
|
||||||
|
scopeId: scopeId,
|
||||||
|
questionIndex:
|
||||||
|
_questionIndex +
|
||||||
|
1,
|
||||||
|
score: nextScore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await Navigator.of(
|
await Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(
|
).push(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../widgets/entrance.dart';
|
|||||||
import '../widgets/liquid_waves_background.dart';
|
import '../widgets/liquid_waves_background.dart';
|
||||||
import '../widgets/tap_bounce.dart';
|
import '../widgets/tap_bounce.dart';
|
||||||
import 'quiz_prefs.dart';
|
import 'quiz_prefs.dart';
|
||||||
|
import 'quiz_progress_prefs.dart';
|
||||||
import 'quiz_question_screen.dart' show QuizScore;
|
import 'quiz_question_screen.dart' show QuizScore;
|
||||||
import 'quiz_video_guide.dart';
|
import 'quiz_video_guide.dart';
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
|
|
||||||
Future<void> _saveResult() async {
|
Future<void> _saveResult() async {
|
||||||
QuizPrefs.markQuizSeen();
|
QuizPrefs.markQuizSeen();
|
||||||
|
// O quiz terminou — não há progresso por retomar.
|
||||||
|
QuizProgressPrefs.clearProgress();
|
||||||
final result = QuizResultData(
|
final result = QuizResultData(
|
||||||
signs: widget.finalScore.signs,
|
signs: widget.finalScore.signs,
|
||||||
signsMax: kSignsMax,
|
signsMax: kSignsMax,
|
||||||
|
|||||||
@@ -34,18 +34,16 @@ const List<_CreditSection> _kCreditSections = [
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
_CreditSection(CreditsStrings.developmentSection, [
|
_CreditSection(CreditsStrings.developmentSection, [
|
||||||
_CreditPerson('Carlos Correia', CreditsStrings.developerRole),
|
_CreditPerson('Carlos Eduardo Correia', CreditsStrings.developerRole),
|
||||||
_CreditPerson('Fábio Ceia', CreditsStrings.developerRole),
|
_CreditPerson('Fábio Oliveira Ceia', CreditsStrings.developerRole),
|
||||||
_CreditPerson('Ruben Grandra', CreditsStrings.developerRole),
|
_CreditPerson('Dinis Maria Grulha Filipe', CreditsStrings.developerRole),
|
||||||
_CreditPerson('Dinis Maria', CreditsStrings.developerRole),
|
_CreditPerson('Rúben Silva Gandra', CreditsStrings.developerRole),
|
||||||
]),
|
]),
|
||||||
_CreditSection(CreditsStrings.advisorsSection, [
|
_CreditSection(CreditsStrings.advisorsSection, [
|
||||||
_CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.advisorRole),
|
_CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.medRole),
|
||||||
_CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.advisorRole),
|
_CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.medRole),
|
||||||
_CreditPerson(
|
_CreditPerson('João Carlos Rodrigues L. Miranda',CreditsStrings.infRole,),
|
||||||
'João Carlos Rodrigues L. Miranda',
|
_CreditPerson('Tiago Órfão)', CreditsStrings.otoringoRole),
|
||||||
CreditsStrings.advisorRole,
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
_CreditSection(CreditsStrings.institutionSection, [
|
_CreditSection(CreditsStrings.institutionSection, [
|
||||||
_CreditPerson('Universidade Fernando Pessoa', ''),
|
_CreditPerson('Universidade Fernando Pessoa', ''),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../colors/app_colors.dart';
|
import '../colors/app_colors.dart';
|
||||||
|
|
||||||
import '../main.dart' show supabase;
|
import '../main.dart' show supabase;
|
||||||
|
import '../strings/common_strings.dart';
|
||||||
import '../strings/settings_strings.dart';
|
import '../strings/settings_strings.dart';
|
||||||
import '../widgets/app_dialogs.dart';
|
import '../widgets/app_dialogs.dart';
|
||||||
import '../widgets/entrance.dart';
|
import '../widgets/entrance.dart';
|
||||||
@@ -41,9 +42,29 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final password = await _promptPassword(context);
|
||||||
|
if (password == null || password.isEmpty) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final email = (supabase.auth.currentUser?.email ?? '').trim();
|
||||||
|
if (email.isEmpty) return;
|
||||||
|
|
||||||
setState(() => _deletingAccount = true);
|
setState(() => _deletingAccount = true);
|
||||||
try {
|
try {
|
||||||
|
// Reautentica com a palavra-passe introduzida antes de apagar nada —
|
||||||
|
// sem esta verificação, qualquer pessoa com o telemóvel desbloqueado
|
||||||
|
// (sessão já iniciada) conseguia apagar a conta sem confirmar que é
|
||||||
|
// mesmo o dono.
|
||||||
|
try {
|
||||||
|
await supabase.auth.signInWithPassword(email: email, password: password);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showPillSnackBar(context, SettingsStrings.wrongPassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// A remoção de `auth.users` exige a service role key, que o app nunca
|
// 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
|
// deve carregar — por isso corre numa Edge Function (server-side); ver
|
||||||
// supabase/functions/delete-account. Sem isto, apagar só as linhas de
|
// supabase/functions/delete-account. Sem isto, apagar só as linhas de
|
||||||
@@ -146,7 +167,7 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_SectionLabel(SettingsStrings.dangerZone),
|
_SectionLabel(SettingsStrings.userData),
|
||||||
_SettingsCard(
|
_SettingsCard(
|
||||||
children: [
|
children: [
|
||||||
_ActionTile(
|
_ActionTile(
|
||||||
@@ -261,3 +282,91 @@ class _ActionTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pede a palavra-passe atual antes de uma ação irreversível (apagar dados
|
||||||
|
/// da conta) — devolve a palavra-passe introduzida, ou `null` se cancelado.
|
||||||
|
/// Só valida que o campo não está vazio; a palavra-passe em si é validada
|
||||||
|
/// depois via `signInWithPassword`, pelo chamador.
|
||||||
|
Future<String?> _promptPassword(BuildContext context) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
var obscure = true;
|
||||||
|
String? errorText;
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setState) {
|
||||||
|
void submit() {
|
||||||
|
if (controller.text.isEmpty) {
|
||||||
|
setState(() => errorText = SettingsStrings.passwordRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(ctx).pop(controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
SettingsStrings.confirmPasswordTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w900, color: _accentPink),
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
SettingsStrings.confirmPasswordMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.black.withValues(alpha: 0.72)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscure,
|
||||||
|
autofocus: true,
|
||||||
|
onSubmitted: (_) => submit(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: SettingsStrings.password,
|
||||||
|
errorText: errorText,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
obscure
|
||||||
|
? Icons.visibility_off_rounded
|
||||||
|
: Icons.visibility_rounded,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => obscure = !obscure),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
|
actions: [
|
||||||
|
TapBounce(
|
||||||
|
child: TextButton(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: _teal),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(null),
|
||||||
|
child: const Text(CommonStrings.cancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TapBounce(
|
||||||
|
child: FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: _accentPink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
onPressed: submit,
|
||||||
|
child: const Text(SettingsStrings.confirm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ Future<void> showVideoPlayerDialog(
|
|||||||
class VideoScreen extends StatefulWidget {
|
class VideoScreen extends StatefulWidget {
|
||||||
const VideoScreen({super.key, this.scopeId});
|
const VideoScreen({super.key, this.scopeId});
|
||||||
|
|
||||||
|
/// Nome de rota usado para identificar esta tela na pilha de navegação —
|
||||||
|
/// permite que o botão de voltar de um vídeo (mesmo vários "Próximos"
|
||||||
|
/// adentro) volte diretamente para aqui, em vez de vídeo a vídeo.
|
||||||
|
static const String routeName = 'video_library';
|
||||||
|
|
||||||
/// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para
|
/// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para
|
||||||
/// guardar localmente quais episódios ela já assistiu até ao fim.
|
/// guardar localmente quais episódios ela já assistiu até ao fim.
|
||||||
final String? scopeId;
|
final String? scopeId;
|
||||||
@@ -717,15 +722,32 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volta diretamente para a grelha de vídeos ([VideoScreen]), mesmo que
|
||||||
|
/// se tenha chegado aqui através de vários vídeos "Próximos" seguidos —
|
||||||
|
/// em vez de um pop normal, que voltaria vídeo a vídeo.
|
||||||
|
void _returnToLibrary() {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.popUntil(
|
||||||
|
(route) => route.settings.name == VideoScreen.routeName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<YoutubePlayerValue>(
|
return ValueListenableBuilder<YoutubePlayerValue>(
|
||||||
valueListenable: _controller,
|
valueListenable: _controller,
|
||||||
builder: (context, value, _) {
|
builder: (context, value, _) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !value.isFullScreen,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop) _controller.toggleFullScreenMode();
|
if (didPop) return;
|
||||||
|
if (value.isFullScreen) {
|
||||||
|
_controller.toggleFullScreenMode();
|
||||||
|
} else {
|
||||||
|
_returnToLibrary();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: value.isFullScreen
|
backgroundColor: value.isFullScreen
|
||||||
@@ -745,6 +767,10 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
|||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: _returnToLibrary,
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.video.title,
|
widget.video.title,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w900),
|
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||||
@@ -823,6 +849,9 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
|||||||
onTap: () =>
|
onTap: () =>
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: VideoScreen.routeName,
|
||||||
|
),
|
||||||
builder: (_) => VideoScreen(
|
builder: (_) => VideoScreen(
|
||||||
scopeId: widget.scopeId,
|
scopeId: widget.scopeId,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ class CreditsStrings {
|
|||||||
|
|
||||||
static const String originalCreatorSection = 'Criadora original';
|
static const String originalCreatorSection = 'Criadora original';
|
||||||
static const String developmentSection = 'Desenvolvimento informático';
|
static const String developmentSection = 'Desenvolvimento informático';
|
||||||
static const String advisorsSection = 'Orientadores';
|
static const String advisorsSection = 'Orientação';
|
||||||
static const String institutionSection = 'Instituição colaboradora';
|
static const String institutionSection = 'Instituição colaboradora';
|
||||||
|
|
||||||
|
|
||||||
static const String originalCreatorRole = 'Criadora original';
|
static const String originalCreatorRole = 'Criadora original';
|
||||||
static const String developerRole = 'Desenvolvedor';
|
static const String developerRole = 'Desenvolvedor';
|
||||||
static const String advisorRole = 'Orientador(a)';
|
static const String advisorRole = 'Orientador(a)';
|
||||||
|
static const String otoringoRole = 'Otorrinolaringologia';
|
||||||
|
static const String medRole = 'Medicina Dentária';
|
||||||
|
static const String infRole = 'Informática';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ class HomeStrings {
|
|||||||
static const String childCodeRequired = 'Indique o código';
|
static const String childCodeRequired = 'Indique o código';
|
||||||
static const String childCodeDigitsOnly = 'O código só pode conter números';
|
static const String childCodeDigitsOnly = 'O código só pode conter números';
|
||||||
static const String childCodeAlreadyInUse =
|
static const String childCodeAlreadyInUse =
|
||||||
'Este código já está atribuído a outra criança';
|
'Código numérico já está em uso';
|
||||||
static String codeLabel(String code) => 'Cód. $code';
|
static String codeLabel(String code) => 'Cód. $code';
|
||||||
|
|
||||||
|
static const String resumeQuizTitle = 'Questionário por terminar';
|
||||||
|
static String resumeQuizMessage(String childName) =>
|
||||||
|
'Há um questionário por terminar para $childName. Quer continuar de '
|
||||||
|
'onde parou ou recomeçar do início?';
|
||||||
|
static const String resumeQuiz = 'Continuar';
|
||||||
|
static const String restartQuiz = 'Recomeçar';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,14 @@ class SettingsStrings {
|
|||||||
static const String creatorsAndContributors = 'Criadores e colaboradores';
|
static const String creatorsAndContributors = 'Criadores e colaboradores';
|
||||||
static const String appVersion = 'Versão do app';
|
static const String appVersion = 'Versão do app';
|
||||||
|
|
||||||
static const String dangerZone = 'Zona de risco';
|
static const String userData = 'Dados do utilizador';
|
||||||
|
|
||||||
|
static const String confirmPasswordTitle = 'Confirme a sua palavra-passe';
|
||||||
|
static const String confirmPasswordMessage =
|
||||||
|
'Por segurança, introduza a sua palavra-passe para apagar os dados '
|
||||||
|
'da conta.';
|
||||||
|
static const String password = 'Palavra-passe';
|
||||||
|
static const String passwordRequired = 'Indique a palavra-passe';
|
||||||
|
static const String wrongPassword = 'Palavra-passe incorreta';
|
||||||
|
static const String confirm = 'Confirmar';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../colors/app_colors.dart';
|
import '../colors/app_colors.dart';
|
||||||
|
import '../colors/app_gradients.dart';
|
||||||
import '../strings/common_strings.dart';
|
import '../strings/common_strings.dart';
|
||||||
|
|
||||||
import 'tap_bounce.dart';
|
import 'tap_bounce.dart';
|
||||||
@@ -7,8 +8,9 @@ import 'tap_bounce.dart';
|
|||||||
const Color _teal = AppColors.teal;
|
const Color _teal = AppColors.teal;
|
||||||
const Color _accentPink = AppColors.pink;
|
const Color _accentPink = AppColors.pink;
|
||||||
|
|
||||||
/// Diálogo de confirmação com a identidade visual do app (título rosa,
|
/// Diálogo de confirmação com a identidade visual do app (título rosa
|
||||||
/// botões em pílula), usado para todas as confirmações destrutivas/decisórias.
|
/// centrado, botão principal em pílula com gradiente), usado para todas as
|
||||||
|
/// confirmações destrutivas/decisórias.
|
||||||
Future<bool?> showConfirmDialog(
|
Future<bool?> showConfirmDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String title,
|
required String title,
|
||||||
@@ -17,16 +19,39 @@ Future<bool?> showConfirmDialog(
|
|||||||
required String confirmLabel,
|
required String confirmLabel,
|
||||||
Color confirmColor = _teal,
|
Color confirmColor = _teal,
|
||||||
}) {
|
}) {
|
||||||
|
// Só o teal (o valor por omissão, usado nas confirmações não-destrutivas
|
||||||
|
// como "Adicionar outra criança?") ganha o gradiente verde da app; ações
|
||||||
|
// destrutivas continuam com a cor sólida passada (normalmente rosa), para
|
||||||
|
// manter esse alerta visual.
|
||||||
|
final isDefaultColor = confirmColor == _teal;
|
||||||
|
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 8, 24, 12),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w900, color: _accentPink),
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: _accentPink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
content: message == null ? null : Text(message),
|
content: message == null
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black.withValues(alpha: 0.72),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.center,
|
||||||
actions: [
|
actions: [
|
||||||
TapBounce(
|
TapBounce(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
@@ -36,15 +61,24 @@ Future<bool?> showConfirmDialog(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TapBounce(
|
TapBounce(
|
||||||
child: FilledButton(
|
child: ClipRRect(
|
||||||
style: FilledButton.styleFrom(
|
borderRadius: BorderRadius.circular(999),
|
||||||
backgroundColor: confirmColor,
|
child: DecoratedBox(
|
||||||
foregroundColor: Colors.white,
|
decoration: BoxDecoration(
|
||||||
shape: const StadiumBorder(),
|
gradient: isDefaultColor ? kGreenButtonGradient : null,
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
color: isDefaultColor ? null : confirmColor,
|
||||||
|
),
|
||||||
|
child: FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: Text(confirmLabel),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
child: Text(confirmLabel),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user