326 lines
13 KiB
Dart
326 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
|
|
import '../main.dart' show supabase;
|
|
import '../widgets/app_gradients.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 28 perguntas no total: 5 são sinais de má oclusão já
|
|
// instalados (queixo, boca/dentição, apinhamento, boca/dentição 2, 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 = 5;
|
|
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<QuizResultScreen> createState() => _QuizResultScreenState();
|
|
}
|
|
|
|
class _QuizResultScreenState extends State<QuizResultScreen> {
|
|
late final Future<void> _saveResultFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_saveResultFuture = _saveResult();
|
|
}
|
|
|
|
Future<void> _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) {
|
|
// Fire-and-forget: avoid blocking UI on erros de rede. Requer as
|
|
// colunas last_signs/last_signs_max/last_factors/last_factors_max
|
|
// na tabela children — se ainda não existirem na base de dados,
|
|
// esta chamada falha silenciosamente e o resultado fica só local
|
|
// (SharedPreferences, acima), tal como já acontecia antes.
|
|
unawaited(
|
|
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)
|
|
.catchError((e) {
|
|
debugPrint(
|
|
'[QuizResult] Falha ao gravar score na base de dados: $e',
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final recommend = _recommendsEvaluation(widget.finalScore);
|
|
|
|
return Scaffold(
|
|
body: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
|
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: [
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: () =>
|
|
Navigator.of(context).popUntil((r) => r.isFirst),
|
|
child: const Text(''),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: 6),
|
|
FadeSlideIn(
|
|
child: const QuizGuideVideoCard(
|
|
youtubeId: _resultGuideYoutubeId,
|
|
),
|
|
),
|
|
const SizedBox(height: 22),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 60),
|
|
child: const Text(
|
|
'Resultado da avaliação',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFFFF55A7),
|
|
height: 1.2,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 100),
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_ResultRing(
|
|
value: widget.finalScore.signs,
|
|
max: kSignsMax,
|
|
color: const Color(0xFF2F9E94),
|
|
label:
|
|
'Sinais de má\noclusão instalados',
|
|
),
|
|
_ResultRing(
|
|
value: widget.finalScore.factors,
|
|
max: kFactorsMax,
|
|
color: const Color(0xFFFF55A7),
|
|
label: 'Fatores de\nrisco associados',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 120),
|
|
child: Text(
|
|
'Conclusões:',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.black.withValues(
|
|
alpha: 0.75,
|
|
),
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 160),
|
|
child: Text(
|
|
recommend
|
|
? 'Recomenda-se o agendamento de uma consulta com um dentista/ortodontista.\nEsta avaliação é apenas educativa.'
|
|
: 'Não foram encontrados sinais ou fatores de risco relevantes.\nEsta avaliação é apenas educativa.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.black.withValues(
|
|
alpha: 0.70,
|
|
),
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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;
|
|
Navigator.of(
|
|
context,
|
|
).popUntil((r) => r.isFirst);
|
|
},
|
|
child: const Text('Avançar'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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<double>(
|
|
duration: const Duration(milliseconds: 1100),
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween<double>(begin: 0, end: progress),
|
|
builder: (context, animatedProgress, _) {
|
|
return SizedBox(
|
|
width: 150,
|
|
height: 150,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 132,
|
|
height: 132,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|