Atulização do quiz | Porcentagem na base de dados | Telas de Contrato com o usuario

This commit is contained in:
Carlos Correia
2026-07-13 22:28:01 +01:00
parent 824fdb8089
commit 4fcf3a475a
23 changed files with 2061 additions and 369 deletions

View File

@@ -0,0 +1,282 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import '../widgets/entrance.dart';
import '../widgets/tap_bounce.dart';
const Color _pink = Color(0xFFFF55A7);
const Color _teal = Color(0xFF2F9E94);
/// Player do YouTube incorporado (sem AppBar/tela cheia própria) — usado
/// tanto no ecrã intersticial [QuizVideoGuideScreen] como embutido
/// diretamente no ecrã de resultado do quiz.
class QuizGuideVideoCard extends StatefulWidget {
const QuizGuideVideoCard({super.key, required this.youtubeId, this.onEnded});
final String youtubeId;
/// Chamado uma única vez quando o vídeo chega ao fim.
final VoidCallback? onEnded;
@override
State<QuizGuideVideoCard> createState() => _QuizGuideVideoCardState();
}
class _QuizGuideVideoCardState extends State<QuizGuideVideoCard> {
late final YoutubePlayerController _controller = YoutubePlayerController(
initialVideoId: widget.youtubeId,
flags: const YoutubePlayerFlags(autoPlay: true, mute: false),
);
bool _endedNotified = false;
@override
void initState() {
super.initState();
_controller.addListener(_handleValueChange);
}
void _handleValueChange() {
if (_endedNotified) return;
if (_controller.value.playerState == PlayerState.ended) {
_endedNotified = true;
widget.onEnded?.call();
}
}
@override
void dispose() {
_controller.removeListener(_handleValueChange);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: AspectRatio(
aspectRatio: 16 / 9,
child: YoutubePlayer(
controller: _controller,
showVideoProgressIndicator: true,
progressColors: const ProgressBarColors(
playedColor: _pink,
handleColor: _pink,
),
),
),
);
}
}
/// Ecrã intersticial mostrado a meio do quiz, convidando a ver um vídeo
/// educativo antes de continuar. Tanto "Avançar" como "Pular" seguem para
/// [onAdvance] — o vídeo é um convite, não um bloqueio.
class QuizVideoGuideScreen extends StatefulWidget {
const QuizVideoGuideScreen({
super.key,
required this.youtubeId,
required this.onAdvance,
this.caption =
'Assista para compreender melhor as próximas perguntas do questionário.',
this.extraHeading,
this.extraSubtitle,
});
final String youtubeId;
final void Function(BuildContext context) onAdvance;
final String caption;
/// Quando definidos, mostram um bloco de destaque extra por baixo da
/// legenda do vídeo (ex.: um aviso a preparar o utilizador para as
/// próximas perguntas de observação).
final String? extraHeading;
final String? extraSubtitle;
@override
State<QuizVideoGuideScreen> createState() => _QuizVideoGuideScreenState();
}
class _QuizVideoGuideScreenState extends State<QuizVideoGuideScreen> {
// Evita que um duplo-toque (Pular + Avançar, ou o mesmo botão duas vezes
// seguidas antes da navegação trocar de ecrã) dispare onAdvance duas
// vezes sobre uma rota que já foi substituída.
bool _advanced = false;
// "Avançar" só fica disponível depois de o vídeo chegar ao fim; "Pular"
// continua livre a qualquer momento.
bool _videoWatched = false;
void _advance() {
if (_advanced) return;
_advanced = true;
widget.onAdvance(context);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return Scaffold(
body: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(color: const Color(0xFFFAFAF7)),
),
Positioned(
left: -size.width * 0.40,
bottom: -size.width * 0.45,
child: IgnorePointer(
child: SizedBox(
width: size.width * 1.05,
height: size.width * 1.05,
child: Transform.rotate(
angle: 35 * math.pi / 180,
child: Opacity(
opacity: 0.95,
child: Lottie.asset(
'lottie/Liquid waves.json',
fit: BoxFit.cover,
repeat: true,
),
),
),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.topRight,
child: TextButton(
onPressed: _advance,
child: const Text(
'Pular',
style: TextStyle(
color: Colors.black45,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(height: 4),
const FadeSlideIn(
child: Text(
'Antes de continuar, assista ao vídeo educativo...',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w900,
color: Colors.black87,
height: 1.25,
),
),
),
const SizedBox(height: 24),
Expanded(
child: Center(
child: FadeSlideIn(
delay: const Duration(milliseconds: 80),
child: QuizGuideVideoCard(
youtubeId: widget.youtubeId,
onEnded: () => setState(() => _videoWatched = true),
),
),
),
),
const SizedBox(height: 16),
FadeSlideIn(
delay: const Duration(milliseconds: 120),
child: Text(
widget.caption,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: Colors.black.withValues(alpha: 0.6),
height: 1.4,
),
),
),
if (widget.extraHeading != null) ...[
const SizedBox(height: 16),
FadeSlideIn(
delay: const Duration(milliseconds: 140),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _pink.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
widget.extraHeading!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: _pink,
height: 1.25,
),
),
if (widget.extraSubtitle != null) ...[
const SizedBox(height: 6),
Text(
widget.extraSubtitle!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: Colors.black.withValues(alpha: 0.6),
height: 1.4,
),
),
],
],
),
),
),
],
const SizedBox(height: 20),
FadeSlideIn(
delay: const Duration(milliseconds: 160),
child: TapBounce(
child: SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _teal,
disabledBackgroundColor: _teal.withValues(
alpha: 0.35,
),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
),
),
onPressed: _videoWatched ? _advance : null,
child: const Text('Avançar'),
),
),
),
),
],
),
),
),
],
),
);
}
}