Atualização de gauge
This commit is contained in:
@@ -4,22 +4,32 @@ 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';
|
||||
|
||||
class QuizResultScreen extends StatefulWidget {
|
||||
const QuizResultScreen({
|
||||
super.key,
|
||||
required this.finalScore,
|
||||
required this.maxScore,
|
||||
this.scopeId,
|
||||
});
|
||||
// Fase 3 tem 27 perguntas no total: 4 são sinais de má oclusão já
|
||||
// instalados (queixo, boca/dentição, apinhamento, 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 = 4;
|
||||
const int kFactorsMax = 23;
|
||||
|
||||
final int finalScore;
|
||||
final int maxScore;
|
||||
/// 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
|
||||
@@ -37,26 +47,21 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
|
||||
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,
|
||||
score: widget.finalScore,
|
||||
maxScore: widget.maxScore,
|
||||
);
|
||||
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,
|
||||
score: widget.finalScore,
|
||||
maxScore: widget.maxScore,
|
||||
);
|
||||
await QuizPrefs.saveLastResultForUser(userId: uid, result: result);
|
||||
} else {
|
||||
await QuizPrefs.saveLastResult(
|
||||
score: widget.finalScore,
|
||||
maxScore: widget.maxScore,
|
||||
);
|
||||
await QuizPrefs.saveLastResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,17 +72,25 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
scope.startsWith('${userId}_')) {
|
||||
final childId = scope.substring(userId.length + 1).trim();
|
||||
if (childId.isNotEmpty) {
|
||||
// Fire-and-forget: avoid blocking UI on erros de rede.
|
||||
// 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_score': widget.finalScore,
|
||||
'last_max_score': widget.maxScore,
|
||||
'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');
|
||||
debugPrint(
|
||||
'[QuizResult] Falha ao gravar score na base de dados: $e',
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -86,195 +99,227 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clamped = widget.finalScore.clamp(0, widget.maxScore);
|
||||
final percent = ((clamped / widget.maxScore) * 100).round();
|
||||
final progress = percent / 100.0;
|
||||
final recommend = _recommendsEvaluation(widget.finalScore);
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: const Color(0xFFFAFAF7),
|
||||
child: 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: 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(
|
||||
'A percentagem de risco\navaliada é de:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Center(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0, end: progress),
|
||||
builder: (context, animatedProgress, _) {
|
||||
final animatedPercent =
|
||||
(animatedProgress * 100).round();
|
||||
return SizedBox(
|
||||
width: 220,
|
||||
height: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CircularProgressIndicator(
|
||||
value: animatedProgress,
|
||||
strokeWidth: 12,
|
||||
backgroundColor: Colors.black
|
||||
.withValues(alpha: 0.10),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(
|
||||
Color(0xFF2F9E94),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$animatedPercent%',
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${clamped.toInt()}/${widget.maxScore}',
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.60,
|
||||
),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
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(
|
||||
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.70),
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'Descarregar relatório (em breve)',
|
||||
style: TextStyle(
|
||||
color: const Color(
|
||||
0xFFFF55A7,
|
||||
).withValues(alpha: 0.95),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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(''),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
FadeSlideIn(
|
||||
child: const QuizGuideVideoCard(
|
||||
youtubeId: _resultGuideYoutubeId,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await _saveResultFuture;
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(
|
||||
context,
|
||||
).popUntil((r) => r.isFirst);
|
||||
},
|
||||
child: const Text('Avançar'),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user