import 'dart:async'; import 'package:flutter/material.dart'; import '../colors/app_colors.dart'; import '../main.dart' show supabase; import '../colors/app_gradients.dart'; import '../strings/quiz_result_strings.dart'; import '../strings/quiz_ui_strings.dart'; import '../widgets/entrance.dart'; import '../widgets/liquid_waves_background.dart'; import '../widgets/tap_bounce.dart'; import 'quiz_prefs.dart'; import 'quiz_question_screen.dart' show QuizScore; import 'quiz_video_guide.dart'; const String _resultGuideYoutubeId = '3q7C7txH1dE'; // Fase 3 tem 29 perguntas no total: 6 são sinais de má oclusão já // instalados (queixo, boca/dentição, apinhamento, boca/dentição 2, // boca/dentição 3, céu da boca) — as restantes 23 são fatores de risco // associados. Ver o diagrama de planeamento partilhado com o utilizador // para o racional completo. const int kSignsMax = 6; const int kFactorsMax = 23; /// A partir de quantos sinais/fatores presentes se recomenda avaliação. /// Proposta: qualquer sinal já instalado, ou ao menos ~30% dos fatores de /// risco — ajustável depois de validar com dados reais. bool _recommendsEvaluation(QuizScore score) { return score.signs >= 1 || score.factors >= (kFactorsMax * 0.3).ceil(); } class QuizResultScreen extends StatefulWidget { const QuizResultScreen({super.key, required this.finalScore, this.scopeId}); final QuizScore finalScore; final String? scopeId; @override State createState() => _QuizResultScreenState(); } class _QuizResultScreenState extends State { late final Future _saveResultFuture; @override void initState() { super.initState(); _saveResultFuture = _saveResult(); } Future _saveResult() async { QuizPrefs.markQuizSeen(); final result = QuizResultData( signs: widget.finalScore.signs, signsMax: kSignsMax, factors: widget.finalScore.factors, factorsMax: kFactorsMax, ); final scope = (widget.scopeId ?? '').trim(); if (scope.isNotEmpty) { await QuizPrefs.saveLastResultForScope(scopeId: scope, result: result); } else { final uid = supabase.auth.currentUser?.id; if (uid != null && uid.trim().isNotEmpty) { await QuizPrefs.saveLastResultForUser(userId: uid, result: result); } else { await QuizPrefs.saveLastResult(result); } } final uid = supabase.auth.currentUser?.id; final userId = (uid ?? '').trim(); if (userId.isNotEmpty && scope.isNotEmpty && scope.startsWith('${userId}_')) { final childId = scope.substring(userId.length + 1).trim(); if (childId.isNotEmpty) { // Aguarda a gravação (em vez de fire-and-forget) para que, ao voltar // à Home logo a seguir, _loadQuizResult já encontre o valor novo na // base de dados — caso contrário havia uma corrida em que a Home // consultava a tabela antes desta escrita terminar, mostrando o // resultado antigo até trocar de aba ou reabrir a app. Requer as // colunas last_signs/last_signs_max/last_factors/last_factors_max // na tabela children — se ainda não existirem, falha silenciosamente // e o resultado fica só local (SharedPreferences, acima). try { await supabase .from('children') .update({ 'last_signs': widget.finalScore.signs, 'last_signs_max': kSignsMax, 'last_factors': widget.finalScore.factors, 'last_factors_max': kFactorsMax, }) .eq('id', childId); } catch (e) { debugPrint( '[QuizResult] Falha ao gravar score na base de dados: $e', ); } } } } void _goToVideo() { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => QuizVideoGuideScreen( youtubeId: _resultGuideYoutubeId, heading: QuizResultStrings.watchTheFollowingVideo, caption: QuizResultStrings.resultVideoCaption, onAdvance: (context) => Navigator.of(context).popUntil((r) => r.isFirst), ), ), ); } @override Widget build(BuildContext context) { final recommend = _recommendsEvaluation(widget.finalScore); final signs = widget.finalScore.signs; final factors = widget.finalScore.factors; return Scaffold( body: Stack( clipBehavior: Clip.none, children: [ Positioned.fill(child: Container(color: AppColors.background)), const LiquidWavesBackground(), SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Padding( padding: const EdgeInsets.fromLTRB(22, 12, 22, 18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Center( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 10), FadeSlideIn( child: const Text( QuizResultStrings.resultHeading, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w900, color: AppColors.pink, height: 1.2, ), ), ), const SizedBox(height: 18), FadeSlideIn( delay: const Duration(milliseconds: 60), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _ResultRing( value: signs, max: kSignsMax, color: AppColors.teal, label: QuizResultStrings.signsRingLabel, ), _ResultRing( value: factors, max: kFactorsMax, color: AppColors.pink, label: QuizResultStrings.factorsRingLabel, ), ], ), ), const SizedBox(height: 22), FadeSlideIn( delay: const Duration(milliseconds: 100), child: _ResultSection( heading: QuizResultStrings.conclusionsHeading, body: QuizResultStrings.conclusionsBody( signs: signs, signsMax: kSignsMax, factors: factors, factorsMax: kFactorsMax, ), bodyColor: AppColors.pink, bodyWeight: FontWeight.w800, ), ), const SizedBox(height: 16), FadeSlideIn( delay: const Duration(milliseconds: 140), child: _ResultSection( heading: QuizResultStrings.whatToDoNowHeading, body: recommend ? QuizResultStrings .whatToDoNowRecommend : QuizResultStrings .whatToDoNowNoConcerns, ), ), const SizedBox(height: 16), FadeSlideIn( delay: const Duration(milliseconds: 180), child: _ResultSection( heading: QuizResultStrings .importantToKnowHeading, body: QuizResultStrings .importantToKnowBody, ), ), ], ), ), ), ), Center( child: TapBounce( child: ClipRRect( borderRadius: BorderRadius.circular(999), child: DecoratedBox( decoration: const BoxDecoration( gradient: kGreenButtonGradient, ), child: SizedBox( width: 260, height: 46, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.transparent, foregroundColor: Colors.white, shape: const StadiumBorder(), textStyle: const TextStyle( fontWeight: FontWeight.w900, ), ), onPressed: () async { await _saveResultFuture; if (!context.mounted) return; _goToVideo(); }, child: const Text(QuizUiStrings.advance), ), ), ), ), ), ), ], ), ), ), ), ), ], ), ); } } /// Bloco de texto "título + parágrafo" reutilizado nas secções de /// conclusão do resultado ("Conclusões:", "O que deve fazer agora", /// "Importante saber"). class _ResultSection extends StatelessWidget { const _ResultSection({ required this.heading, required this.body, this.bodyColor, this.bodyWeight = FontWeight.w600, }); final String heading; final String body; final Color? bodyColor; final FontWeight bodyWeight; @override Widget build(BuildContext context) { return Column( children: [ Text( heading, textAlign: TextAlign.center, style: TextStyle( color: Colors.black.withValues(alpha: 0.75), fontWeight: FontWeight.w900, ), ), const SizedBox(height: 8), Text( body, textAlign: TextAlign.center, style: TextStyle( color: bodyColor ?? Colors.black.withValues(alpha: 0.70), fontWeight: bodyWeight, height: 1.35, ), ), ], ); } } /// Anel de progresso — usado para os dois indicadores do resultado /// (sinais / fatores de risco), com o valor no centro e a legenda por /// baixo. class _ResultRing extends StatelessWidget { const _ResultRing({ required this.value, required this.max, required this.color, required this.label, }); final int value; final int max; final Color color; final String label; @override Widget build(BuildContext context) { final progress = max > 0 ? (value / max).clamp(0.0, 1.0) : 0.0; return Column( mainAxisSize: MainAxisSize.min, children: [ TweenAnimationBuilder( duration: const Duration(milliseconds: 1100), curve: Curves.easeOutCubic, tween: Tween(begin: 0, end: progress), builder: (context, animatedProgress, _) { return SizedBox( width: 130, height: 130, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 130, height: 130, child: CircularProgressIndicator( value: animatedProgress, strokeWidth: 12, backgroundColor: Colors.black.withValues(alpha: 0.10), valueColor: AlwaysStoppedAnimation(color), ), ), Text( '$value/$max', style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w900, color: Colors.black, ), ), ], ), ); }, ), const SizedBox(height: 10), Text( label, textAlign: TextAlign.center, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w800, color: Colors.black.withValues(alpha: 0.65), height: 1.25, ), ), ], ); } }