diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 3f16ceb..acab4d6 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -12,6 +12,7 @@ import 'brushing_prefs.dart'; import 'main.dart' show supabase; import 'quiz/quiz1.dart'; import 'quiz/quiz_prefs.dart'; +import 'quiz/quiz_progress_prefs.dart'; import 'quiz/quiz_result.dart' show kSignsMax, kFactorsMax; import 'screens/settings_screen.dart'; import 'screens/video_screen.dart'; @@ -733,10 +734,91 @@ class _InicioTab extends StatelessWidget { final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); state?.selectChild(childName, scopeId); - await Navigator.of(context).push(quizStartRoute(scopeId: scopeId)); + final progress = await QuizProgressPrefs.getProgress(scopeId); + if (!context.mounted) return; + + Route 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(); } + /// 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 _confirmResumeQuiz( + BuildContext context, { + required String childName, + }) async { + final resume = await showDialog( + 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 Widget build(BuildContext context) { final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); @@ -795,6 +877,9 @@ class _InicioTab extends StatelessWidget { onTap: () async { await Navigator.of(context).push( MaterialPageRoute( + settings: const RouteSettings( + name: VideoScreen.routeName, + ), builder: (_) => VideoScreen(scopeId: scopeId), ), ); @@ -1903,6 +1988,14 @@ class _PerfilTabState extends State<_PerfilTab> { context, HomeStrings.timeoutAdding, ); + } on PostgrestException catch (e) { + if (!mounted || !context.mounted) return; + showPillSnackBar( + context, + e.code == '23505' + ? HomeStrings.childCodeAlreadyInUse + : HomeStrings.errorAdding(e), + ); } catch (e) { if (!mounted || !context.mounted) return; showPillSnackBar(context, HomeStrings.errorAdding(e)); @@ -2379,6 +2472,7 @@ class _AddChildSheetState extends State<_AddChildSheet> { DateTime? _birthDate; String? _gender; String? _birthDateError; + String? _genderError; @override void dispose() { @@ -2408,12 +2502,14 @@ class _AddChildSheetState extends State<_AddChildSheet> { void _submit() { final formOk = _formKey.currentState?.validate() ?? false; + final genderMissing = (_gender ?? '').trim().isEmpty; setState(() { _birthDateError = _birthDate == null ? HomeStrings.birthDateRequired : null; + _genderError = genderMissing ? HomeStrings.genderRequired : null; }); - if (!formOk || _birthDate == null) return; + if (!formOk || _birthDate == null || genderMissing) return; Navigator.of(context).pop({ 'name': _nameController.text.trim(), 'birth_date': _birthDate!.toIso8601String().split('T').first, @@ -2514,27 +2610,16 @@ class _AddChildSheetState extends State<_AddChildSheet> { ), ), ), - DropdownButtonFormField( - initialValue: _gender, - items: const [ - DropdownMenuItem( - value: HomeStrings.male, - child: Text(HomeStrings.male), - ), - DropdownMenuItem( - 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; - }, + Padding( + padding: const EdgeInsets.only(top: 8), + child: _GenderPillSelector( + value: _gender, + errorText: _genderError, + onChanged: (v) => setState(() { + _gender = v; + _genderError = null; + }), + ), ), ], ), @@ -2588,3 +2673,110 @@ class _AddChildSheetState extends State<_AddChildSheet> { ); } } + +const List _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 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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/quiz/quiz1.dart b/lib/quiz/quiz1.dart index 7bc0aa4..412b64b 100644 --- a/lib/quiz/quiz1.dart +++ b/lib/quiz/quiz1.dart @@ -65,13 +65,73 @@ Route quizStartRoute({String? scopeId}) { QuizStrings.checklistItem2, 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)), ), ), ); } +/// 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 +_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 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) class Quiz1Screen extends StatelessWidget { const Quiz1Screen({ @@ -111,6 +171,7 @@ class Quiz1Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz2Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -155,6 +216,7 @@ class Quiz2Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz3Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -199,6 +261,7 @@ class Quiz3Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz4Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -243,6 +306,7 @@ class Quiz4Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz5Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -287,6 +351,7 @@ class Quiz5Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz6Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -324,6 +389,7 @@ class Quiz6Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz7Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -368,6 +434,7 @@ class Quiz7Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz8Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -412,6 +479,7 @@ class Quiz8Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz9Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -456,6 +524,7 @@ class Quiz9Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz10Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -500,6 +569,7 @@ class Quiz10Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz11Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -544,6 +614,7 @@ class Quiz11Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz12Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -581,6 +652,7 @@ class Quiz12Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz13Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -618,6 +690,7 @@ class Quiz13Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz14Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -655,6 +728,7 @@ class Quiz14Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz15Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -699,6 +773,7 @@ class Quiz15Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz16Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -743,6 +818,7 @@ class Quiz16Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz17Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -787,6 +863,7 @@ class Quiz17Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => QuizVideoGuideScreen( youtubeId: 'W2BcK9nSyt0', @@ -837,6 +914,7 @@ class Quiz18Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz19Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -898,6 +976,7 @@ class Quiz19Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz20Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -943,6 +1022,7 @@ class Quiz20Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz21Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -992,6 +1072,7 @@ class Quiz21Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz22Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1039,6 +1120,7 @@ class Quiz22Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => QuizVideoGuideScreen( youtubeId: 'msKYr7nPxcw', @@ -1097,6 +1179,7 @@ class Quiz23Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz24Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1147,6 +1230,7 @@ class Quiz24Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1188,6 +1272,7 @@ class Quiz25Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1229,6 +1314,7 @@ class Quiz26Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz27Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1279,6 +1365,7 @@ class Quiz27Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz28Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1321,6 +1408,7 @@ class Quiz28Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => Quiz29Screen(currentScore: nextScore, scopeId: scopeId), ), @@ -1364,6 +1452,7 @@ class Quiz29Screen extends StatelessWidget { ), ], currentScore: currentScore, + scopeId: scopeId, nextRoute: (context, nextScore) => quizPageRoute( builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId), ), diff --git a/lib/quiz/quiz_progress_prefs.dart b/lib/quiz/quiz_progress_prefs.dart new file mode 100644 index 0000000..34d1365 --- /dev/null +++ b/lib/quiz/quiz_progress_prefs.dart @@ -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 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 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 clearProgress() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kScopeKey); + await prefs.remove(_kIndexKey); + await prefs.remove(_kSignsKey); + await prefs.remove(_kFactorsKey); + } +} diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index ae17563..ca604b3 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -7,6 +7,7 @@ import '../strings/quiz_ui_strings.dart'; import '../widgets/entrance.dart'; import '../widgets/liquid_waves_background.dart'; import '../widgets/tap_bounce.dart'; +import 'quiz_progress_prefs.dart'; typedef QuizNextBuilder = Route Function(BuildContext context, QuizScore nextScore); @@ -85,6 +86,7 @@ class QuizQuestionScreen extends StatefulWidget { this.fallbackColor, this.answerImageAspectRatio, this.correctBadgeLabel = QuizUiStrings.defaultCorrectBadgeLabel, + this.scopeId, }); final String title; @@ -93,6 +95,11 @@ class QuizQuestionScreen extends StatefulWidget { final QuizNextBuilder nextRoute; 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 /// [QuizScore.signs] em vez de [QuizScore.factors] — usado só nas 4 /// perguntas que representam sinais de má oclusão já instalados @@ -612,6 +619,21 @@ class _QuizQuestionScreenState extends State { return; } + final scopeId = + widget.scopeId; + if (scopeId != null && + scopeId + .trim() + .isNotEmpty) { + QuizProgressPrefs.saveProgress( + scopeId: scopeId, + questionIndex: + _questionIndex + + 1, + score: nextScore, + ); + } + await Navigator.of( context, ).push( diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index c357717..883e8be 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -11,6 +11,7 @@ import '../widgets/entrance.dart'; import '../widgets/liquid_waves_background.dart'; import '../widgets/tap_bounce.dart'; import 'quiz_prefs.dart'; +import 'quiz_progress_prefs.dart'; import 'quiz_question_screen.dart' show QuizScore; import 'quiz_video_guide.dart'; @@ -52,6 +53,8 @@ class _QuizResultScreenState extends State { Future _saveResult() async { QuizPrefs.markQuizSeen(); + // O quiz terminou — não há progresso por retomar. + QuizProgressPrefs.clearProgress(); final result = QuizResultData( signs: widget.finalScore.signs, signsMax: kSignsMax, diff --git a/lib/screens/credits_screen.dart b/lib/screens/credits_screen.dart index 8322f75..eccef18 100644 --- a/lib/screens/credits_screen.dart +++ b/lib/screens/credits_screen.dart @@ -34,18 +34,16 @@ const List<_CreditSection> _kCreditSections = [ ), ]), _CreditSection(CreditsStrings.developmentSection, [ - _CreditPerson('Carlos Correia', CreditsStrings.developerRole), - _CreditPerson('Fábio Ceia', CreditsStrings.developerRole), - _CreditPerson('Ruben Grandra', CreditsStrings.developerRole), - _CreditPerson('Dinis Maria', CreditsStrings.developerRole), + _CreditPerson('Carlos Eduardo Correia', CreditsStrings.developerRole), + _CreditPerson('Fábio Oliveira Ceia', CreditsStrings.developerRole), + _CreditPerson('Dinis Maria Grulha Filipe', CreditsStrings.developerRole), + _CreditPerson('Rúben Silva Gandra', CreditsStrings.developerRole), ]), _CreditSection(CreditsStrings.advisorsSection, [ - _CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.advisorRole), - _CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.advisorRole), - _CreditPerson( - 'João Carlos Rodrigues L. Miranda', - CreditsStrings.advisorRole, - ), + _CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.medRole), + _CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.medRole), + _CreditPerson('João Carlos Rodrigues L. Miranda',CreditsStrings.infRole,), + _CreditPerson('Tiago Órfão)', CreditsStrings.otoringoRole), ]), _CreditSection(CreditsStrings.institutionSection, [ _CreditPerson('Universidade Fernando Pessoa', ''), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index abd97d7..a5d1655 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../colors/app_colors.dart'; import '../main.dart' show supabase; +import '../strings/common_strings.dart'; import '../strings/settings_strings.dart'; import '../widgets/app_dialogs.dart'; import '../widgets/entrance.dart'; @@ -41,9 +42,29 @@ class _SettingsBodyState extends State { ); 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); 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 // deve carregar — por isso corre numa Edge Function (server-side); ver // supabase/functions/delete-account. Sem isto, apagar só as linhas de @@ -146,7 +167,7 @@ class _SettingsBodyState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _SectionLabel(SettingsStrings.dangerZone), + _SectionLabel(SettingsStrings.userData), _SettingsCard( children: [ _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 _promptPassword(BuildContext context) { + final controller = TextEditingController(); + return showDialog( + 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), + ), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index 7d81608..a19f2bf 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -194,6 +194,11 @@ Future showVideoPlayerDialog( class VideoScreen extends StatefulWidget { 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 /// guardar localmente quais episódios ela já assistiu até ao fim. final String? scopeId; @@ -717,15 +722,32 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> 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 Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: _controller, builder: (context, value, _) { return PopScope( - canPop: !value.isFullScreen, + canPop: false, onPopInvokedWithResult: (didPop, _) { - if (!didPop) _controller.toggleFullScreenMode(); + if (didPop) return; + if (value.isFullScreen) { + _controller.toggleFullScreenMode(); + } else { + _returnToLibrary(); + } }, child: Scaffold( backgroundColor: value.isFullScreen @@ -745,6 +767,10 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> surfaceTintColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: _returnToLibrary, + ), title: Text( widget.video.title, style: const TextStyle(fontWeight: FontWeight.w900), @@ -823,6 +849,9 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> onTap: () => Navigator.of(context).push( MaterialPageRoute( + settings: const RouteSettings( + name: VideoScreen.routeName, + ), builder: (_) => VideoScreen( scopeId: widget.scopeId, ), diff --git a/lib/strings/credits_strings.dart b/lib/strings/credits_strings.dart index abef974..b459611 100644 --- a/lib/strings/credits_strings.dart +++ b/lib/strings/credits_strings.dart @@ -9,10 +9,15 @@ class CreditsStrings { static const String originalCreatorSection = 'Criadora original'; 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 originalCreatorRole = 'Criadora original'; static const String developerRole = 'Desenvolvedor'; static const String advisorRole = 'Orientador(a)'; + static const String otoringoRole = 'Otorrinolaringologia'; + static const String medRole = '⁠Medicina Dentária'; + static const String infRole = 'Informática'; + } diff --git a/lib/strings/home_strings.dart b/lib/strings/home_strings.dart index bc6dad8..2bb2143 100644 --- a/lib/strings/home_strings.dart +++ b/lib/strings/home_strings.dart @@ -104,6 +104,13 @@ class HomeStrings { static const String childCodeRequired = 'Indique o código'; static const String childCodeDigitsOnly = 'O código só pode conter números'; 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 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'; } diff --git a/lib/strings/settings_strings.dart b/lib/strings/settings_strings.dart index c4c1901..4e07d82 100644 --- a/lib/strings/settings_strings.dart +++ b/lib/strings/settings_strings.dart @@ -22,5 +22,14 @@ class SettingsStrings { static const String creatorsAndContributors = 'Criadores e colaboradores'; 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'; } diff --git a/lib/widgets/app_dialogs.dart b/lib/widgets/app_dialogs.dart index 6d09efd..fcb3b86 100644 --- a/lib/widgets/app_dialogs.dart +++ b/lib/widgets/app_dialogs.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../colors/app_colors.dart'; +import '../colors/app_gradients.dart'; import '../strings/common_strings.dart'; import 'tap_bounce.dart'; @@ -7,8 +8,9 @@ import 'tap_bounce.dart'; const Color _teal = AppColors.teal; const Color _accentPink = AppColors.pink; -/// 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. +/// Diálogo de confirmação com a identidade visual do app (título rosa +/// centrado, botão principal em pílula com gradiente), usado para todas as +/// confirmações destrutivas/decisórias. Future showConfirmDialog( BuildContext context, { required String title, @@ -17,16 +19,39 @@ Future showConfirmDialog( required String confirmLabel, 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( context: context, builder: (ctx) { 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, - 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: [ TapBounce( child: TextButton( @@ -36,15 +61,24 @@ Future showConfirmDialog( ), ), TapBounce( - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: confirmColor, - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w800), + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: isDefaultColor ? kGreenButtonGradient : null, + 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), ), ), ],