Munça da UI de inicio | escovagem semanal adicionada | quantidade de videos assistidos | todos os videos na nuvem | possibilidade de continuar o video de onde parou

This commit is contained in:
Carlos Correia
2026-07-09 15:31:59 +01:00
parent 67b580778a
commit 9fb2840529
14 changed files with 1584 additions and 381 deletions

View File

@@ -45,6 +45,10 @@ class QuizQuestionScreen extends StatefulWidget {
this.suggestedVideoPath,
this.suggestedYoutubeId,
this.suggestedVideoTitle,
this.category,
this.categoryIcon,
this.fallbackIcon,
this.fallbackColor,
});
final String title;
@@ -61,6 +65,16 @@ class QuizQuestionScreen extends StatefulWidget {
final String? suggestedYoutubeId;
final String? suggestedVideoTitle;
/// Rótulo pequeno do tema da pergunta (ex.: "Avaliação facial"), mostrado
/// num badge acima da imagem/pergunta.
final String? category;
final IconData? categoryIcon;
/// Usados só quando [questionImagePaths] está vazio: em vez de o bloco de
/// imagem colapsar, mostra-se um bloco colorido com este ícone.
final IconData? fallbackIcon;
final Color? fallbackColor;
@override
State<QuizQuestionScreen> createState() => _QuizQuestionScreenState();
}
@@ -74,8 +88,24 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
int? _selected;
TextEditingController? _numberController;
int? _numberValue;
bool _numberDontKnow = false;
bool _navigating = false;
/// O título ainda chega como texto livre ("Quiz 3/25") em vez de números
/// separados — extraímos daqui em vez de acrescentar mais dois parâmetros
/// obrigatórios a cada uma das 25 telas de pergunta.
static final RegExp _progressPattern = RegExp(r'(\d+)\s*/\s*(\d+)');
int get _questionIndex {
final match = _progressPattern.firstMatch(widget.title);
return int.tryParse(match?.group(1) ?? '') ?? 1;
}
int get _totalQuestions {
final match = _progressPattern.firstMatch(widget.title);
return int.tryParse(match?.group(2) ?? '') ?? 1;
}
@override
void initState() {
super.initState();
@@ -96,10 +126,11 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
bool canProceed = _selected != null && !_navigating;
if (widget.answerType == QuizAnswerType.number) {
canProceed =
_numberValue != null &&
_numberValue! >= 0 &&
_numberValue! <= _maxTeethCount &&
!_navigating;
!_navigating &&
(_numberDontKnow ||
(_numberValue != null &&
_numberValue! >= 0 &&
_numberValue! <= _maxTeethCount));
}
final bool hasSuggestedVideo =
@@ -146,49 +177,103 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 44,
child: Stack(
alignment: Alignment.center,
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
Text(
widget.title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w800,
),
),
if (widget.showBackButton)
Positioned(
left: 4,
child: TapBounce(
scale: 0.9,
child: Material(
color: Colors.white.withValues(alpha: 0.85),
shape: const CircleBorder(),
elevation: 4,
shadowColor: Colors.black.withValues(
alpha: 0.15,
),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => Navigator.of(context).maybePop(),
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.arrow_back_rounded,
color: Color(0xFF2F9E94),
size: 22,
),
TapBounce(
scale: 0.9,
child: Material(
color: Colors.white.withValues(alpha: 0.85),
shape: const CircleBorder(),
elevation: 4,
shadowColor: Colors.black.withValues(alpha: 0.15),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => Navigator.of(context).maybePop(),
child: const Padding(
padding: EdgeInsets.all(9),
child: Icon(
Icons.arrow_back_rounded,
color: Color(0xFF2F9E94),
size: 20,
),
),
),
),
)
else
const SizedBox(width: 38),
const SizedBox(width: 12),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (_questionIndex / _totalQuestions).clamp(
0.0,
1.0,
),
minHeight: 8,
backgroundColor: const Color(
0xFFFF55A7,
).withValues(alpha: 0.15),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFFFF55A7),
),
),
),
),
const SizedBox(width: 10),
Text(
'$_questionIndex/$_totalQuestions',
style: TextStyle(
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
],
),
),
if ((widget.category ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.categoryIcon != null) ...[
Icon(
widget.categoryIcon,
size: 15,
color: const Color(0xFF2F9E94),
),
const SizedBox(width: 6),
],
Text(
widget.category!,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 12,
color: Color(0xFF2F9E94),
),
),
],
),
),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
@@ -224,16 +309,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
if (widget
.questionImagePaths
.isNotEmpty) ...[
const SizedBox(height: 6),
_QuestionReferenceImages(
paths:
widget.questionImagePaths,
),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
widget.questionImagePaths.isNotEmpty
? _QuestionReferenceImages(
paths: widget
.questionImagePaths,
)
: _FallbackIconBlock(
icon:
widget.fallbackIcon ??
Icons.info_outline_rounded,
color:
widget.fallbackColor ??
const Color(0xFF2F9E94),
),
const SizedBox(height: 10),
if (hasSuggestedVideo) ...[
TextButton.icon(
onPressed: () =>
@@ -324,15 +414,38 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
delay: Duration(
milliseconds: 60 * i,
),
child: _QuizAnswerTile(
answer:
widget.answers[i],
selected:
_selected == i,
onTap: () => setState(
() => _selected = i,
),
),
child:
widget.answerType ==
QuizAnswerType
.yesNo &&
widget
.answers[i]
.imagePath ==
null
? _QuizAnswerPill(
answer: widget
.answers[i],
selected:
_selected == i,
onTap: () =>
setState(
() =>
_selected =
i,
),
)
: _QuizAnswerTile(
answer: widget
.answers[i],
selected:
_selected == i,
onTap: () =>
setState(
() =>
_selected =
i,
),
),
),
],
],
@@ -357,7 +470,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
FilledButton.styleFrom(
backgroundColor:
const Color(
0xFF2F9E94,
0xFFFF55A7,
),
foregroundColor:
Colors.white,
@@ -426,8 +539,10 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
nextScore =
widget
.currentScore +
(_numberValue ??
0);
(_numberDontKnow
? 0
: (_numberValue ??
0));
} else {
final picked = widget
.answers[_selected!];
@@ -474,44 +589,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
),
),
),
const SizedBox(height: 10),
TapBounce(
child: SizedBox(
width: size.width * 0.62,
height: 42,
child: OutlinedButton(
style:
OutlinedButton.styleFrom(
foregroundColor:
const Color(
0xFF2F9E94,
),
side: const BorderSide(
color: Color(
0xFF2F9E94,
),
width: 1.3,
),
shape:
const StadiumBorder(),
textStyle:
const TextStyle(
fontWeight:
FontWeight
.w900,
),
),
onPressed: () =>
Navigator.of(
context,
).popUntil(
(route) =>
route.isFirst,
),
child: const Text(
'Voltar para homepage',
),
const SizedBox(height: 6),
TextButton(
style: TextButton.styleFrom(
foregroundColor: const Color(
0xFF2F9E94,
),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text(
'Voltar para homepage',
),
),
],
@@ -540,54 +632,62 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 150,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.black.withValues(alpha: 0.12),
width: 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 18,
offset: const Offset(0, 10),
Opacity(
opacity: _numberDontKnow ? 0.4 : 1,
child: IgnorePointer(
ignoring: _numberDontKnow,
child: Container(
width: 150,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.black.withValues(alpha: 0.12),
width: 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
],
),
child: TextField(
controller: _numberController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: Color(0xFF2F9E94),
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: '0',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: Colors.grey,
child: TextField(
controller: _numberController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: Color(0xFF2F9E94),
),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: '0',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: Colors.grey,
),
contentPadding: EdgeInsets.symmetric(vertical: 20),
),
onChanged: (value) {
setState(() {
_numberValue = int.tryParse(value);
});
},
),
contentPadding: EdgeInsets.symmetric(vertical: 20),
),
onChanged: (value) {
setState(() {
_numberValue = int.tryParse(value);
});
},
),
),
if (_numberValue != null && _numberValue! > _maxTeethCount) ...[
if (_numberValue != null &&
_numberValue! > _maxTeethCount &&
!_numberDontKnow) ...[
const SizedBox(height: 10),
Text(
'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.',
@@ -599,6 +699,62 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
),
),
],
const SizedBox(height: 14),
TapBounce(
scale: 0.97,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () {
setState(() {
_numberDontKnow = !_numberDontKnow;
if (_numberDontKnow) {
_numberController?.clear();
_numberValue = null;
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: _numberDontKnow
? const Color(0xFF2F9E94)
: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: _numberDontKnow
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.help_outline_rounded,
size: 16,
color: _numberDontKnow
? Colors.white
: Colors.black.withValues(alpha: 0.55),
),
const SizedBox(width: 6),
Text(
'Não sei',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 13,
color: _numberDontKnow
? Colors.white
: Colors.black.withValues(alpha: 0.55),
),
),
],
),
),
),
),
],
),
);
@@ -745,11 +901,38 @@ class _QuestionReferenceImages extends StatelessWidget {
borderRadius: BorderRadius.circular(14),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
paths.first,
fit: BoxFit.cover,
cacheWidth: 800,
errorBuilder: (context, error, stackTrace) => _placeholder(),
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
paths.first,
fit: BoxFit.cover,
cacheWidth: 800,
errorBuilder: (context, error, stackTrace) => _placeholder(),
),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'Imagem de referência',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 10.5,
),
),
),
),
],
),
),
);
@@ -787,3 +970,150 @@ class _QuestionReferenceImages extends StatelessWidget {
);
}
}
/// Mostrado quando a pergunta não tem imagem de referência — em vez de o
/// bloco colapsar (como acontecia antes), mostra um pequeno ícone colorido
/// relevante ao tema, sem ocupar a largura toda (esse destaque é reservado
/// para as perguntas que realmente têm imagem).
class _FallbackIconBlock extends StatelessWidget {
const _FallbackIconBlock({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(18),
),
child: Icon(icon, size: 32, color: Colors.white),
),
);
}
}
/// Resposta Sim/Não em formato de pílula horizontal: círculo colorido +
/// texto + indicador circular à direita que preenche quando selecionado.
class _QuizAnswerPill extends StatelessWidget {
const _QuizAnswerPill({
required this.answer,
required this.selected,
required this.onTap,
});
final QuizAnswer answer;
final bool selected;
final VoidCallback onTap;
bool get _isYes => (answer.value ?? '').trim().toLowerCase() == 'sim';
bool get _isNo => (answer.value ?? '').trim().toLowerCase() == 'nao';
@override
Widget build(BuildContext context) {
final accent = _isYes
? const Color(0xFF2F9E94)
: _isNo
? const Color(0xFFFF55A7)
: Colors.black.withValues(alpha: 0.35);
final borderColor = selected
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.10);
return TapBounce(
scale: 0.97,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.92)
: Colors.white.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
splashFactory: InkSparkle.splashFactory,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
child: Icon(
_isYes
? Icons.check_rounded
: _isNo
? Icons.close_rounded
: Icons.help_outline_rounded,
color: Colors.white,
size: 17,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
answer.title,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
color: Color(0xFF2F9E94),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? const Color(0xFF2F9E94)
: Colors.transparent,
border: Border.all(
color: selected
? const Color(0xFF2F9E94)
: Colors.black.withValues(alpha: 0.25),
width: 1.6,
),
),
child: selected
? const Icon(
Icons.check_rounded,
size: 14,
color: Colors.white,
)
: null,
),
],
),
),
),
),
),
);
}
}