Atualização do quiz e videos(incompleto)

This commit is contained in:
Carlos Correia
2026-05-26 16:21:11 +01:00
parent ea009af0d3
commit e292256a98
59 changed files with 351 additions and 405 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
assets/mockup_images/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/mockup_images/1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

BIN
assets/mockup_images/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

BIN
assets/mockup_images/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

BIN
assets/mockup_images/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

BIN
assets/mockup_images/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
assets/mockup_images/15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/mockup_images/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/mockup_images/17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
assets/mockup_images/2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

BIN
assets/mockup_images/20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
assets/mockup_images/22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
assets/mockup_images/24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/mockup_images/25.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
assets/mockup_images/26.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/mockup_images/27.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/mockup_images/3.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
assets/mockup_images/4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

BIN
assets/mockup_images/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/mockup_images/6.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
assets/mockup_images/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/mockup_images/8.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
assets/mockup_images/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lottie/lottie.dart';
import 'dart:async';
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import 'dart:math' as math;
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
@@ -413,8 +412,8 @@ class _RiskArcGauge extends StatelessWidget {
builder: (context, value, _) {
final shown = (value * 100).round();
return SizedBox(
width: 178,
height: 94,
width: 120,
height: 60,
child: Stack(
alignment: Alignment.center,
children: [
@@ -424,7 +423,7 @@ class _RiskArcGauge extends StatelessWidget {
),
),
Positioned(
bottom: 8,
bottom: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -432,17 +431,17 @@ class _RiskArcGauge extends StatelessWidget {
'$shown%',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontSize: 20,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
'Risco de Má Oclusão',
'',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontSize: 9,
fontSize: 8,
fontWeight: FontWeight.w900,
),
),
@@ -464,10 +463,10 @@ class _RiskArcGaugePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(16, 12, size.width - 32, size.height * 1.55);
final rect = Rect.fromLTWH(10, 8, size.width - 20, size.height * 1.7);
const startAngle = math.pi;
const sweepAngle = math.pi;
final strokeWidth = size.width * 0.14;
final strokeWidth = size.width * 0.12;
final backgroundPaint = Paint()
..color = Colors.white.withValues(alpha: 0.72)
@@ -652,7 +651,7 @@ class _VideoLibraryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final item = VideoScreen.library.isEmpty ? null : VideoScreen.library.first;
const Object? item = null;
return Material(
color: Colors.white,
@@ -685,7 +684,9 @@ class _VideoLibraryCard extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_VideoThumbnail(url: item.url),
Container(
color: const Color(0xFF2F9E94).withValues(alpha: 0.14),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -767,27 +768,6 @@ class _VideoLibraryCard extends StatelessWidget {
}
}
class _VideoThumbnail extends StatelessWidget {
const _VideoThumbnail({required this.url});
final String url;
@override
Widget build(BuildContext context) {
final id = YoutubePlayer.convertUrlToId(url);
final thumb = id == null ? null : 'https://img.youtube.com/vi/$id/0.jpg';
if (thumb == null) {
return Container(color: Colors.white.withValues(alpha: 0.12));
}
return Image.network(
thumb,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(color: Colors.white.withValues(alpha: 0.12)),
);
}
}
class _PerfilTab extends StatefulWidget {
const _PerfilTab({
required this.selectedChildIndex,

View File

@@ -36,6 +36,7 @@ class QuizQuestionScreen extends StatefulWidget {
this.isFinal = false,
this.showBackButton = false,
this.answerType = QuizAnswerType.text,
this.questionImagePaths = const [],
});
final String title;
@@ -47,6 +48,7 @@ class QuizQuestionScreen extends StatefulWidget {
final bool isFinal;
final bool showBackButton;
final QuizAnswerType answerType;
final List<String> questionImagePaths;
@override
State<QuizQuestionScreen> createState() => _QuizQuestionScreenState();
@@ -136,6 +138,13 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
),
),
const SizedBox(height: 6),
if (widget.questionImagePaths.isNotEmpty) ...[
const SizedBox(height: 6),
_QuestionReferenceImages(
paths: widget.questionImagePaths,
),
const SizedBox(height: 10),
],
Text(
widget.question,
textAlign: TextAlign.center,
@@ -312,6 +321,28 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
),
),
],
const SizedBox(height: 10),
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'),
),
),
],
),
),
@@ -425,6 +456,28 @@ class _QuizAnswerTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (answer.imagePath != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 4 / 3,
child: Image.asset(
answer.imagePath!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.black.withValues(alpha: 0.06),
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: Colors.black38,
),
),
),
),
),
),
const SizedBox(height: 10),
],
Row(
children: [
Expanded(
@@ -437,38 +490,8 @@ class _QuizAnswerTile extends StatelessWidget {
),
),
),
AnimatedRotation(
turns: selected ? 0.5 : 0.0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: Icon(
Icons.expand_more_rounded,
color: Colors.black.withValues(alpha: 0.55),
),
),
],
),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
answer.description,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.72),
fontWeight: FontWeight.w600,
height: 1.25,
),
),
),
crossFadeState: selected
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 220),
firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut,
sizeCurve: Curves.easeOutCubic,
),
],
),
),
@@ -477,3 +500,56 @@ class _QuizAnswerTile extends StatelessWidget {
);
}
}
class _QuestionReferenceImages extends StatelessWidget {
const _QuestionReferenceImages({required this.paths});
final List<String> paths;
@override
Widget build(BuildContext context) {
if (paths.length == 1) {
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
paths.first,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _placeholder(),
),
),
);
}
return SizedBox(
height: 120,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: paths.length,
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, i) {
return AspectRatio(
aspectRatio: 4 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
paths[i],
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _placeholder(),
),
),
);
},
),
);
}
Widget _placeholder() {
return Container(
color: Colors.black.withValues(alpha: 0.06),
child: const Center(
child: Icon(Icons.image_not_supported_outlined, color: Colors.black38),
),
);
}
}

View File

@@ -25,32 +25,28 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//1.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/images/face_a.png',
imagePath: 'assets/mockup_images/1.jpeg',
),
QuizAnswer(
//2.jpeg
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/images/face_b.png',
imagePath: 'assets/mockup_images/2.jpeg',
),
QuizAnswer(
//3.jpeg
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/images/face_c.png',
),
QuizAnswer(
title: 'Opção D',
description:
'Selecione se a imagem se assemelha à face do seu filho/a',
weight: 2,
imagePath: 'assets/images/face_d.png',
imagePath: 'assets/mockup_images/3.jpeg',
),
],
),
@@ -58,36 +54,24 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
id: 2,
title: 'Quiz 2/26',
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
'Qual das seguintes imagens se assemelha à posição boca do seu filho/a habitualmente?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//4.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth_a.png',
imagePath: 'assets/mockup_images/4.jpeg',
),
QuizAnswer(
//5.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth_b.png',
),
QuizAnswer(
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth_c.png',
),
QuizAnswer(
title: 'Opção D',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth_d.png',
imagePath: 'assets/mockup_images/5.png',
),
],
),
@@ -99,32 +83,20 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//8.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/images/dark_circles_a.png',
imagePath: 'assets/mockup_images/8.jpeg',
),
QuizAnswer(
//9.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/images/dark_circles_b.png',
),
QuizAnswer(
title: 'Opção C',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/images/dark_circles_c.png',
),
QuizAnswer(
title: 'Opção D',
description:
'Selecione se a imagem se assemelha às olheiras do seu filho/a',
weight: 2,
imagePath: 'assets/images/dark_circles_d.png',
imagePath: 'assets/mockup_images/9.png',
),
],
),
@@ -136,48 +108,55 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//6.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/images/chin_a.png',
imagePath: 'assets/mockup_images/6.jpeg',
),
QuizAnswer(
//7.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/images/chin_b.png',
),
QuizAnswer(
title: 'Opção C',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/images/chin_c.png',
),
QuizAnswer(
title: 'Opção D',
description:
'Selecione se a imagem se assemelha ao queixo do seu filho/a',
weight: 2,
imagePath: 'assets/images/chin_d.png',
imagePath: 'assets/mockup_images/7.png',
),
],
),
QuizQuestion(
id: 5,
title: 'Quiz 5/26',
question: 'Quantos dentes tem o seu filho/a em cima na boca?',
answerType: QuizAnswerType.number,
answers: const [],
),
QuizQuestion(
id: 6,
title: 'Quiz 6/26',
question: 'Quantos dentes tem o seu filho/a em baixo na boca?',
answerType: QuizAnswerType.number,
answers: const [],
question:
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//14.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/14.jpeg',
),
QuizAnswer(
//15.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/15.png',
),
QuizAnswer(
//16.png
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/mockup_images/16.png',
),
],
),
QuizQuestion(
id: 7,
@@ -187,32 +166,28 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//10.png
title: 'Opção A',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth2_a.png',
imagePath: 'assets/mockup_images/10.png',
),
QuizAnswer(
//11.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth2_b.png',
imagePath: 'assets/mockup_images/11.png',
),
QuizAnswer(
//13.png
title: 'Opção C',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth2_c.png',
),
QuizAnswer(
title: 'Opção D',
description:
'Selecione se a imagem se assemelha à boca do seu filho/a',
weight: 2,
imagePath: 'assets/images/mouth2_d.png',
imagePath: 'assets/mockup_images/13.png',
),
],
),
@@ -224,32 +199,45 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
//17.png
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/images/frenulum_a.png',
imagePath: 'assets/mockup_images/17.png',
),
QuizAnswer(
//18.jpeg
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/images/frenulum_b.png',
imagePath: 'assets/mockup_images/18.jpeg',
),
],
),
QuizQuestion(
id: 8,
title: 'Quiz 8/26',
question:
'Qual das seguintes imagens se assemelha ao freio do seu filho/a?',
answerType: QuizAnswerType.image,
answers: const [
QuizAnswer(
title: 'Opção C',
//19.jpeg
title: 'Opção A',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/images/frenulum_c.png',
imagePath: 'assets/mockup_images/19.jpeg',
),
QuizAnswer(
title: 'Opção D',
//20.png
title: 'Opção B',
description:
'Selecione se a imagem se assemelha ao freio do seu filho/a',
weight: 2,
imagePath: 'assets/images/frenulum_d.png',
imagePath: 'assets/mockup_images/20.png',
),
],
),
@@ -652,6 +640,7 @@ class _QuizRandomScreenState extends State<QuizRandomScreen> {
isFinal: isLastQuestion,
showBackButton: _currentQuestionIndex > 0,
answerType: currentQuestion.answerType,
questionImagePaths: currentQuestion.questionImagePaths,
onFinished: isLastQuestion
? () {
Navigator.of(context).pushReplacement(
@@ -675,6 +664,8 @@ class QuizQuestion {
final String question;
final List<QuizAnswer> answers;
final QuizAnswerType answerType;
// Reference images shown ABOVE the question text (visualization only).
final List<String> questionImagePaths;
QuizQuestion({
required this.id,
@@ -682,5 +673,41 @@ class QuizQuestion {
required this.question,
required this.answers,
this.answerType = QuizAnswerType.text,
this.questionImagePaths = const [],
});
}
// Helper: returns the asset path for a numbered mockup image (0..27).
// Use in QuizAnswer.imagePath or QuizQuestion.questionImagePaths.
String mockup(int n) => 'assets/mockup_images/$n${_mockupExt[n] ?? '.png'}';
const Map<int, String> _mockupExt = {
0: '.png',
1: '.jpeg',
2: '.jpeg',
3: '.jpeg',
4: '.jpeg',
5: '.png',
6: '.jpeg',
7: '.png',
8: '.jpeg',
9: '.png',
10: '.png',
11: '.png',
12: '.png',
13: '.png',
14: '.jpeg',
15: '.png',
16: '.png',
17: '.png',
18: '.jpeg',
19: '.jpeg',
20: '.png',
21: '.jpeg',
22: '.png',
23: '.jpeg',
24: '.png',
25: '.jpg',
26: '.png',
27: '.png',
};

View File

@@ -62,7 +62,9 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
scope.startsWith('${userId}_')) {
final childId = scope.substring(userId.length + 1).trim();
if (childId.isNotEmpty) {
await FirebaseFirestore.instance
// Fire-and-forget: avoid blocking UI on Firestore (may hang offline).
unawaited(
FirebaseFirestore.instance
.collection('users')
.doc(userId)
.collection('children')
@@ -72,7 +74,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
'lastMaxScore': widget.maxScore,
'lastQuizAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true))
.catchError((_) {});
.catchError((_) {}),
);
}
}
}
@@ -222,8 +225,11 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
fontWeight: FontWeight.w900,
),
),
onPressed: () =>
Navigator.of(context).popUntil((r) => r.isFirst),
onPressed: () async {
await _saveResultFuture;
if (!context.mounted) return;
Navigator.of(context).popUntil((r) => r.isFirst);
},
child: const Text('Avançar'),
),
),

View File

@@ -2,14 +2,6 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
class VideoItem {
const VideoItem({required this.title, required this.url});
final String title;
final String url;
}
class VideoScreen extends StatelessWidget {
const VideoScreen({super.key});
@@ -17,10 +9,6 @@ class VideoScreen extends StatelessWidget {
static const Color _teal = Color(0xFF2F9E94);
static const Color _accentPink = Color(0xFFFF55A7);
static const List<VideoItem> library = [
VideoItem(title: 'Como escovar da maneira certa', url: 'https://www.youtube.com/watch?v=uH8dBWkD__0'),
];
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
@@ -29,9 +17,8 @@ class VideoScreen extends StatelessWidget {
backgroundColor: _teal,
foregroundColor: Colors.white,
elevation: 0,
title: const Text(
'Vídeos Educativos',
'Videos Educativos',
style: TextStyle(fontWeight: FontWeight.w900),
),
),
@@ -44,10 +31,7 @@ class VideoScreen extends StatelessWidget {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFE6F1),
Color(0xFFFFC9DF),
],
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
),
@@ -74,242 +58,55 @@ class VideoScreen extends StatelessWidget {
),
),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
padding: const EdgeInsets.all(24),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.black.withValues(alpha: 0.08),
),
child: const Row(
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.play_circle_fill_rounded, color: _accentPink),
SizedBox(width: 10),
Expanded(
child: Text(
'Vídeos Educativos',
style: TextStyle(
fontWeight: FontWeight.w900,
Icon(
Icons.play_circle_fill_rounded,
size: 64,
color: _accentPink,
),
),
),
],
),
),
const SizedBox(height: 14),
Expanded(
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.92,
children: library.map((item) {
final videoId = YoutubePlayer.convertUrlToId(item.url);
final thumb = videoId == null ? null : 'https://img.youtube.com/vi/$videoId/0.jpg';
return _VideoCard(
title: item.title,
thumbnailUrl: thumb,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoPlayerScreen(url: item.url, title: item.title),
),
);
},
);
}).toList(),
),
),
],
),
),
),
),
),
],
),
);
}
}
class _VideoCard extends StatelessWidget {
const _VideoCard({required this.title, required this.thumbnailUrl, required this.onTap});
final String title;
final String? thumbnailUrl;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white.withValues(alpha: 0.80),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: Stack(
fit: StackFit.expand,
children: [
if (thumbnailUrl != null)
Image.network(
thumbnailUrl!,
fit: BoxFit.cover,
)
else
Container(color: Colors.black.withValues(alpha: 0.06)),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.10),
Colors.black.withValues(alpha: 0.42),
],
),
),
),
const Align(
alignment: Alignment.center,
child: Icon(Icons.play_circle_fill_rounded, size: 54, color: Colors.white),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Color(0xFF2F9E94),
),
),
),
],
),
),
);
}
}
class VideoPlayerScreen extends StatefulWidget {
const VideoPlayerScreen({super.key, required this.url, required this.title});
final String url;
final String title;
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
static const Color _teal = Color(0xFF2F9E94);
static const Color _bg = Color(0xFFFFE6F1);
late final YoutubePlayerController _controller;
@override
void initState() {
super.initState();
final id = YoutubePlayer.convertUrlToId(widget.url);
_controller = YoutubePlayerController(
initialVideoId: id ?? '',
flags: const YoutubePlayerFlags(
autoPlay: true,
mute: false,
enableCaption: true,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final hasVideo = _controller.initialVideoId.isNotEmpty;
return Scaffold(
appBar: AppBar(
backgroundColor: _teal,
foregroundColor: Colors.white,
elevation: 0,
title: Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.w900),
),
),
body: Container(
color: _bg,
child: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: hasVideo
? YoutubePlayer(
controller: _controller,
showVideoProgressIndicator: true,
progressIndicatorColor: Colors.white,
)
: Container(
color: Colors.black.withValues(alpha: 0.10),
child: const Center(
child: Text(
'Link de vídeo inválido',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
),
),
),
const SizedBox(height: 14),
SizedBox(height: 12),
Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
),
),
const SizedBox(height: 6),
Text(
'Assista ao vídeo e aprenda mais sobre saúde bucal.',
'Em breve',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: _teal,
),
),
SizedBox(height: 8),
Text(
'Os videos educativos serao disponibilizados em breve.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.70),
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -12,6 +12,7 @@ import firebase_core
import firebase_storage
import flutter_inappwebview_macos
import shared_preferences_foundation
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
@@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
}

View File

@@ -121,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -360,6 +368,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@@ -717,6 +733,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
url: "https://pub.dev"
source: hosted
version: "2.9.7"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
@@ -767,4 +823,4 @@ packages:
version: "9.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
flutter: ">=3.35.0"
flutter: ">=3.38.0"

View File

@@ -42,6 +42,7 @@ dependencies:
lottie: ^3.3.1
shared_preferences: ^2.3.2
youtube_player_flutter: ^9.0.0
video_player: ^2.9.2
dev_dependencies:
flutter_test:
@@ -72,6 +73,7 @@ flutter:
# - images/a_dot_ham.jpeg
- lottie/
- assets/
- assets/mockup_images/
flutter_launcher_icons:
android: true