Atualização de gauge
BIN
assets/mockup_images/28.jpeg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
assets/mockup_images/29.jpeg
Normal file
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 545 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 143 KiB |
12
lib/_debug_harness.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'logged_home.dart';
|
||||
|
||||
class DebugHarness extends StatelessWidget {
|
||||
const DebugHarness({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const LoggedHomeScreen();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'auth_gate.dart' show pendingPrivacyUserId, pendingTermsUserId;
|
||||
@@ -12,6 +10,7 @@ import 'privacy_gate_prefs.dart';
|
||||
import 'terms_gate_prefs.dart';
|
||||
import 'widgets/app_gradients.dart';
|
||||
import 'widgets/entrance.dart';
|
||||
import 'widgets/liquid_waves_background.dart';
|
||||
import 'widgets/name_input_formatter.dart';
|
||||
import 'widgets/pill_snackbar.dart';
|
||||
import 'widgets/tap_bounce.dart';
|
||||
@@ -175,36 +174,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Size 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.38,
|
||||
bottom: -size.width * 0.38,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
@@ -19,6 +18,7 @@ import 'widgets/animated_nav_icon.dart';
|
||||
import 'widgets/app_dialogs.dart';
|
||||
import 'widgets/app_gradients.dart';
|
||||
import 'widgets/entrance.dart';
|
||||
import 'widgets/liquid_waves_background.dart';
|
||||
import 'widgets/name_input_formatter.dart';
|
||||
import 'widgets/pill_snackbar.dart';
|
||||
import 'widgets/tap_bounce.dart';
|
||||
@@ -61,7 +61,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1';
|
||||
|
||||
static const double _collapsedAppBarHeight = kToolbarHeight;
|
||||
static const double _expandedAppBarHeight = 190;
|
||||
static const double _expandedAppBarHeight = 216;
|
||||
static const double _nameOnlyAppBarHeight = 160;
|
||||
|
||||
int _index = 0;
|
||||
@@ -70,8 +70,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
String? _selectedChildName;
|
||||
String? _selectedChildScopeId;
|
||||
|
||||
int? _lastScore;
|
||||
int? _lastMaxScore;
|
||||
QuizResultData? _lastResult;
|
||||
|
||||
int? _brushingWeekCount;
|
||||
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
||||
@@ -204,8 +203,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
final uid = supabase.auth.currentUser?.id;
|
||||
final String? userId = (uid ?? '').trim().isEmpty ? null : uid;
|
||||
|
||||
int? score;
|
||||
int? max;
|
||||
QuizResultData? result;
|
||||
|
||||
if (scope.isNotEmpty && userId != null) {
|
||||
final String childId = scope.startsWith('${userId}_')
|
||||
@@ -218,11 +216,20 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
.select()
|
||||
.eq('id', childId)
|
||||
.maybeSingle();
|
||||
final s = childDoc?['last_score'];
|
||||
final m = childDoc?['last_max_score'];
|
||||
if (s is int && m is int) {
|
||||
score = s;
|
||||
max = m;
|
||||
final signs = childDoc?['last_signs'];
|
||||
final signsMax = childDoc?['last_signs_max'];
|
||||
final factors = childDoc?['last_factors'];
|
||||
final factorsMax = childDoc?['last_factors_max'];
|
||||
if (signs is int &&
|
||||
signsMax is int &&
|
||||
factors is int &&
|
||||
factorsMax is int) {
|
||||
result = QuizResultData(
|
||||
signs: signs,
|
||||
signsMax: signsMax,
|
||||
factors: factors,
|
||||
factorsMax: factorsMax,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// no-op
|
||||
@@ -230,29 +237,20 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
if (score != null && max != null) {
|
||||
if (result != null) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_lastScore = score;
|
||||
_lastMaxScore = max;
|
||||
});
|
||||
setState(() => _lastResult = result);
|
||||
return;
|
||||
}
|
||||
if (scope.isNotEmpty) {
|
||||
score = await QuizPrefs.getLastScoreForScope(scope);
|
||||
max = await QuizPrefs.getLastMaxScoreForScope(scope);
|
||||
result = await QuizPrefs.getLastResultForScope(scope);
|
||||
} else if (userId != null) {
|
||||
score = await QuizPrefs.getLastScoreForUser(userId);
|
||||
max = await QuizPrefs.getLastMaxScoreForUser(userId);
|
||||
result = await QuizPrefs.getLastResultForUser(userId);
|
||||
} else {
|
||||
score = await QuizPrefs.getLastScore();
|
||||
max = await QuizPrefs.getLastMaxScore();
|
||||
result = await QuizPrefs.getLastResult();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_lastScore = score;
|
||||
_lastMaxScore = max;
|
||||
});
|
||||
setState(() => _lastResult = result);
|
||||
}
|
||||
|
||||
void selectChild(String? name, String? scopeId) {
|
||||
@@ -282,27 +280,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Align(
|
||||
@@ -331,7 +309,10 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
type: BottomNavigationBarType.fixed,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: AnimatedNavIcon(icon: Icons.home_rounded, selected: _index == 0),
|
||||
icon: AnimatedNavIcon(
|
||||
icon: Icons.home_rounded,
|
||||
selected: _index == 0,
|
||||
),
|
||||
label: 'Início',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
@@ -356,10 +337,8 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
final int? score = _lastScore;
|
||||
final int? maxScore = _lastMaxScore;
|
||||
final bool hasScore = score != null && maxScore != null && maxScore > 0;
|
||||
final int percent = hasScore ? ((score / maxScore) * 100).round() : 0;
|
||||
final QuizResultData? result = _lastResult;
|
||||
final bool hasScore = result != null;
|
||||
final shownName = _cachedUserName;
|
||||
|
||||
if (_index == 0) {
|
||||
@@ -386,7 +365,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
foregroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(40)),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
bottom: Radius.circular(40),
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: EdgeInsets.zero,
|
||||
@@ -434,14 +415,29 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
//posição da app bar relativamente ao nome
|
||||
//posição da app bar relativamente ao nome
|
||||
if (hasScore)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: kToolbarHeight + 68,
|
||||
top: kToolbarHeight + 60,
|
||||
child: Center(
|
||||
child: _RiskArcGauge(percent: percent),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RiskArcGauge(
|
||||
value: result.signs,
|
||||
max: result.signsMax,
|
||||
label: 'Sinais',
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
_RiskArcGauge(
|
||||
value: result.factors,
|
||||
max: result.factorsMax,
|
||||
label: 'Fatores de risco',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -491,9 +487,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
_greeting(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
@@ -588,47 +582,72 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
}
|
||||
|
||||
class _RiskArcGauge extends StatelessWidget {
|
||||
const _RiskArcGauge({required this.percent});
|
||||
const _RiskArcGauge({
|
||||
required this.value,
|
||||
required this.max,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final int percent;
|
||||
final int value;
|
||||
final int max;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clamped = percent.clamp(0, 100);
|
||||
final progress = max > 0 ? (value / max).clamp(0, 1).toDouble() : 0.0;
|
||||
|
||||
return TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0, end: clamped / 100),
|
||||
builder: (context, value, _) {
|
||||
final shown = (value * 100).round();
|
||||
tween: Tween<double>(begin: 0, end: progress),
|
||||
builder: (context, animatedProgress, _) {
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
height: 60,
|
||||
width: 108,
|
||||
height: 84,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 60,
|
||||
child: CustomPaint(
|
||||
painter: _RiskArcGaugePainter(progress: value),
|
||||
painter: _RiskArcGaugePainter(progress: animatedProgress),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 38,
|
||||
left: 6,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$shown%',
|
||||
'$value/$max',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 58,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -749,7 +768,9 @@ class _InicioTab extends StatelessWidget {
|
||||
FadeSlideIn(
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _HeroQuizCard(onStartQuiz: () => _startQuiz(context)),
|
||||
child: _HeroQuizCard(
|
||||
onStartQuiz: () => _startQuiz(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -757,7 +778,8 @@ class _InicioTab extends StatelessWidget {
|
||||
delay: const Duration(milliseconds: 70),
|
||||
child: _StatsRow(
|
||||
brushingCount: state?._brushingWeekCount,
|
||||
weeklyGoal: state?._weeklyGoal ?? BrushingPrefs.defaultWeeklyGoal,
|
||||
weeklyGoal:
|
||||
state?._weeklyGoal ?? BrushingPrefs.defaultWeeklyGoal,
|
||||
brushedToday: state?._brushingDailyLimitReached ?? false,
|
||||
watchedCount: state?._watchedVideoCount,
|
||||
onTapBrushing: () => _logBrushing(context, state, scopeId),
|
||||
@@ -1008,7 +1030,10 @@ class _StatCard extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 20),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -1306,7 +1331,7 @@ class _HeroQuizCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'26 perguntas rápidas · menos de 3 minutos',
|
||||
'27 perguntas rápidas · menos de 3 minutos',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -1552,10 +1577,8 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<(int?, int?)> _loadScoreForScope(String scopeId) async {
|
||||
final score = await QuizPrefs.getLastScoreForScope(scopeId);
|
||||
final max = await QuizPrefs.getLastMaxScoreForScope(scopeId);
|
||||
return (score, max);
|
||||
Future<QuizResultData?> _loadScoreForScope(String scopeId) {
|
||||
return QuizPrefs.getLastResultForScope(scopeId);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadProfilePhoto(
|
||||
@@ -1854,7 +1877,10 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (!mounted || !context.mounted) return;
|
||||
showPillSnackBar(context, 'Tempo esgotado ao adicionar. Tente novamente.');
|
||||
showPillSnackBar(
|
||||
context,
|
||||
'Tempo esgotado ao adicionar. Tente novamente.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted || !context.mounted) return;
|
||||
showPillSnackBar(context, 'Erro ao adicionar: $e');
|
||||
@@ -2191,16 +2217,13 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FutureBuilder<(int?, int?)>(
|
||||
FutureBuilder<QuizResultData?>(
|
||||
future: _loadScoreForScope(scopeId),
|
||||
builder: (context, snap) {
|
||||
final tuple = snap.data;
|
||||
final s = tuple?.$1;
|
||||
final m = tuple?.$2;
|
||||
final text =
|
||||
(s == null || m == null || m <= 0)
|
||||
final result = snap.data;
|
||||
final text = result == null
|
||||
? '--'
|
||||
: '${(((s / m) * 100).round()).clamp(0, 100)}%';
|
||||
: '${result.signs}/${result.signsMax} · ${result.factors}/${result.factorsMax}';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'gates/debug_launch_gate.dart';
|
||||
import '_debug_harness.dart';
|
||||
|
||||
const String supabaseUrl = 'https://mannjismlhlwaqqqnvog.supabase.co';
|
||||
const String supabaseAnonKey =
|
||||
@@ -17,16 +18,25 @@ Future<void> main() async {
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
Zone.current.handleUncaughtError(details.exception, details.stack ?? StackTrace.current);
|
||||
Zone.current.handleUncaughtError(
|
||||
details.exception,
|
||||
details.stack ?? StackTrace.current,
|
||||
);
|
||||
};
|
||||
|
||||
runZonedGuarded(() async {
|
||||
await Supabase.initialize(url: supabaseUrl, publishableKey: supabaseAnonKey);
|
||||
runApp(const MyApp());
|
||||
}, (error, stack) {
|
||||
debugPrint('UNCAUGHT: $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
});
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
await Supabase.initialize(
|
||||
url: supabaseUrl,
|
||||
publishableKey: supabaseAnonKey,
|
||||
);
|
||||
runApp(const MyApp());
|
||||
},
|
||||
(error, stack) {
|
||||
debugPrint('UNCAUGHT: $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@@ -58,7 +68,7 @@ class MyApp extends StatelessWidget {
|
||||
],
|
||||
supportedLocales: const [Locale('pt', 'PT'), Locale('pt', 'BR')],
|
||||
locale: const Locale('pt', 'PT'),
|
||||
home: const DebugLaunchGate(),
|
||||
home: const DebugHarness(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,22 @@ import 'quiz_video_guide.dart';
|
||||
|
||||
/// Recorta/zoom numa zona específica de uma foto de rosto (ex.: só a zona
|
||||
/// dos olhos), também sem precisar de um novo ficheiro de imagem.
|
||||
Widget _zoomCrop(String path, {Alignment alignment = Alignment.center, double scale = 2.0}) {
|
||||
Widget _zoomCrop(
|
||||
String path, {
|
||||
Alignment alignment = Alignment.center,
|
||||
double scale = 2.0,
|
||||
// Alinhamento do próprio Image.asset ao aplicar o BoxFit.cover — separado
|
||||
// do alignment do Transform.scale acima, porque o cover já decide (antes
|
||||
// do zoom) que fatia vertical/horizontal da foto original mostrar; se essa
|
||||
// fatia inicial não incluir a zona pretendida (ex.: queixo cortado fora),
|
||||
// nenhum scale/alignment do Transform consegue "recuperar" esses pixels.
|
||||
Alignment imageAlignment = Alignment.center,
|
||||
}) {
|
||||
return ClipRect(
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
alignment: alignment,
|
||||
child: Image.asset(path, fit: BoxFit.cover),
|
||||
child: Image.asset(path, fit: BoxFit.cover, alignment: imageAlignment),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -30,9 +40,7 @@ MaterialPageRoute<void> quizStartRoute({String? scopeId}) {
|
||||
'Quando deve procurar um dentista (urgência vs vigilância)',
|
||||
],
|
||||
onAdvance: (context) => Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Quiz1Screen(scopeId: scopeId),
|
||||
),
|
||||
MaterialPageRoute<void>(builder: (_) => Quiz1Screen(scopeId: scopeId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -40,15 +48,19 @@ MaterialPageRoute<void> quizStartRoute({String? scopeId}) {
|
||||
|
||||
// Quiz 1: Problemas respiratórios (Yes/No)
|
||||
class Quiz1Screen extends StatelessWidget {
|
||||
const Quiz1Screen({super.key, this.currentScore = 0, this.scopeId});
|
||||
const Quiz1Screen({
|
||||
super.key,
|
||||
this.currentScore = const QuizScore(),
|
||||
this.scopeId,
|
||||
});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 1/26',
|
||||
title: 'Quiz 1/27',
|
||||
category: 'Saúde respiratória',
|
||||
categoryIcon: Icons.medical_information_rounded,
|
||||
fallbackIcon: Icons.medical_information_rounded,
|
||||
@@ -89,13 +101,13 @@ class Quiz1Screen extends StatelessWidget {
|
||||
class Quiz2Screen extends StatelessWidget {
|
||||
const Quiz2Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 2/26',
|
||||
title: 'Quiz 2/27',
|
||||
category: 'Respiração',
|
||||
categoryIcon: Icons.air_rounded,
|
||||
fallbackIcon: Icons.air_rounded,
|
||||
@@ -136,13 +148,13 @@ class Quiz2Screen extends StatelessWidget {
|
||||
class Quiz3Screen extends StatelessWidget {
|
||||
const Quiz3Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 3/26',
|
||||
title: 'Quiz 3/27',
|
||||
category: 'Sono',
|
||||
categoryIcon: Icons.bedtime_rounded,
|
||||
fallbackIcon: Icons.bedtime_rounded,
|
||||
@@ -183,13 +195,13 @@ class Quiz3Screen extends StatelessWidget {
|
||||
class Quiz4Screen extends StatelessWidget {
|
||||
const Quiz4Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 4/26',
|
||||
title: 'Quiz 4/27',
|
||||
category: 'Respiração',
|
||||
categoryIcon: Icons.sick_rounded,
|
||||
fallbackIcon: Icons.sick_rounded,
|
||||
@@ -230,13 +242,13 @@ class Quiz4Screen extends StatelessWidget {
|
||||
class Quiz5Screen extends StatelessWidget {
|
||||
const Quiz5Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 5/26',
|
||||
title: 'Quiz 5/27',
|
||||
category: 'Sono',
|
||||
categoryIcon: Icons.nights_stay_rounded,
|
||||
fallbackIcon: Icons.nights_stay_rounded,
|
||||
@@ -279,13 +291,13 @@ class Quiz5Screen extends StatelessWidget {
|
||||
class Quiz6Screen extends StatelessWidget {
|
||||
const Quiz6Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 6/26',
|
||||
title: 'Quiz 6/27',
|
||||
category: 'Hábitos noturnos',
|
||||
categoryIcon: Icons.nights_stay_rounded,
|
||||
fallbackIcon: Icons.nights_stay_rounded,
|
||||
@@ -319,13 +331,13 @@ class Quiz6Screen extends StatelessWidget {
|
||||
class Quiz7Screen extends StatelessWidget {
|
||||
const Quiz7Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 7/26',
|
||||
title: 'Quiz 7/27',
|
||||
category: 'Saúde geral',
|
||||
categoryIcon: Icons.local_florist_rounded,
|
||||
fallbackIcon: Icons.local_florist_rounded,
|
||||
@@ -366,13 +378,13 @@ class Quiz7Screen extends StatelessWidget {
|
||||
class Quiz8Screen extends StatelessWidget {
|
||||
const Quiz8Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 8/26',
|
||||
title: 'Quiz 8/27',
|
||||
category: 'Sono',
|
||||
categoryIcon: Icons.water_drop_rounded,
|
||||
fallbackIcon: Icons.water_drop_rounded,
|
||||
@@ -413,13 +425,13 @@ class Quiz8Screen extends StatelessWidget {
|
||||
class Quiz9Screen extends StatelessWidget {
|
||||
const Quiz9Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 9/26',
|
||||
title: 'Quiz 9/27',
|
||||
category: 'Saúde geral',
|
||||
categoryIcon: Icons.hearing_rounded,
|
||||
fallbackIcon: Icons.hearing_rounded,
|
||||
@@ -460,13 +472,13 @@ class Quiz9Screen extends StatelessWidget {
|
||||
class Quiz10Screen extends StatelessWidget {
|
||||
const Quiz10Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 10/26',
|
||||
title: 'Quiz 10/27',
|
||||
category: 'Saúde geral',
|
||||
categoryIcon: Icons.healing_rounded,
|
||||
fallbackIcon: Icons.healing_rounded,
|
||||
@@ -507,13 +519,13 @@ class Quiz10Screen extends StatelessWidget {
|
||||
class Quiz11Screen extends StatelessWidget {
|
||||
const Quiz11Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 11/26',
|
||||
title: 'Quiz 11/27',
|
||||
category: 'Saúde respiratória',
|
||||
categoryIcon: Icons.air_rounded,
|
||||
fallbackIcon: Icons.air_rounded,
|
||||
@@ -555,13 +567,13 @@ class Quiz11Screen extends StatelessWidget {
|
||||
class Quiz12Screen extends StatelessWidget {
|
||||
const Quiz12Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 12/26',
|
||||
title: 'Quiz 12/27',
|
||||
category: 'Hábitos alimentares',
|
||||
categoryIcon: Icons.restaurant_rounded,
|
||||
fallbackIcon: Icons.restaurant_rounded,
|
||||
@@ -595,13 +607,13 @@ class Quiz12Screen extends StatelessWidget {
|
||||
class Quiz13Screen extends StatelessWidget {
|
||||
const Quiz13Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 13/26',
|
||||
title: 'Quiz 13/27',
|
||||
category: 'Hábitos alimentares',
|
||||
categoryIcon: Icons.schedule_rounded,
|
||||
fallbackIcon: Icons.schedule_rounded,
|
||||
@@ -635,13 +647,13 @@ class Quiz13Screen extends StatelessWidget {
|
||||
class Quiz14Screen extends StatelessWidget {
|
||||
const Quiz14Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 14/26',
|
||||
title: 'Quiz 14/27',
|
||||
category: 'Hábitos alimentares',
|
||||
categoryIcon: Icons.restaurant_menu_rounded,
|
||||
fallbackIcon: Icons.restaurant_menu_rounded,
|
||||
@@ -675,13 +687,13 @@ class Quiz14Screen extends StatelessWidget {
|
||||
class Quiz15Screen extends StatelessWidget {
|
||||
const Quiz15Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 15/26',
|
||||
title: 'Quiz 15/27',
|
||||
category: 'Hábitos alimentares',
|
||||
categoryIcon: Icons.local_drink_rounded,
|
||||
fallbackIcon: Icons.local_drink_rounded,
|
||||
@@ -722,13 +734,13 @@ class Quiz15Screen extends StatelessWidget {
|
||||
class Quiz16Screen extends StatelessWidget {
|
||||
const Quiz16Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 16/26',
|
||||
title: 'Quiz 16/27',
|
||||
category: 'Hábitos orais',
|
||||
categoryIcon: Icons.child_care_rounded,
|
||||
fallbackIcon: Icons.child_care_rounded,
|
||||
@@ -769,13 +781,13 @@ class Quiz16Screen extends StatelessWidget {
|
||||
class Quiz17Screen extends StatelessWidget {
|
||||
const Quiz17Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 17/26',
|
||||
title: 'Quiz 17/27',
|
||||
category: 'Hábitos orais',
|
||||
categoryIcon: Icons.back_hand_rounded,
|
||||
fallbackIcon: Icons.back_hand_rounded,
|
||||
@@ -824,13 +836,13 @@ class Quiz17Screen extends StatelessWidget {
|
||||
class Quiz18Screen extends StatelessWidget {
|
||||
const Quiz18Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 18/26',
|
||||
title: 'Quiz 18/27',
|
||||
category: 'Avaliação postural',
|
||||
categoryIcon: Icons.accessibility_new_rounded,
|
||||
fallbackIcon: Icons.accessibility_new_rounded,
|
||||
@@ -870,13 +882,13 @@ class Quiz18Screen extends StatelessWidget {
|
||||
class Quiz19Screen extends StatelessWidget {
|
||||
const Quiz19Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 19/26',
|
||||
title: 'Quiz 19/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.face_rounded,
|
||||
fallbackIcon: Icons.face_rounded,
|
||||
@@ -935,20 +947,20 @@ class Quiz19Screen extends StatelessWidget {
|
||||
class Quiz20Screen extends StatelessWidget {
|
||||
const Quiz20Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 20/26',
|
||||
title: 'Quiz 20/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.sentiment_neutral_rounded,
|
||||
fallbackIcon: Icons.sentiment_neutral_rounded,
|
||||
fallbackColor: const Color(0xFF2F9E94),
|
||||
question: 'Qual é a posição da boca do seu filho/a habitualmente?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
answers: [
|
||||
const QuizAnswer(
|
||||
title: 'Boca fechada',
|
||||
description: 'Boca fechada habitualmente',
|
||||
weight: 1,
|
||||
@@ -962,16 +974,17 @@ class Quiz20Screen extends StatelessWidget {
|
||||
weight: 2,
|
||||
hideTitle: true,
|
||||
value: 'boca_entreaberta',
|
||||
imagePath: 'assets/mockup_images/4.jpeg',
|
||||
imageBuilder: (context) => _zoomCrop(
|
||||
'assets/mockup_images/4.jpeg',
|
||||
alignment: const Alignment(0, 0.45),
|
||||
scale: 1.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => QuizVideoGuideScreen(
|
||||
youtubeId: 'msKYr7nPxcw',
|
||||
extraHeading: 'Observe atentamente a face do seu filho...',
|
||||
extraSubtitle:
|
||||
'Vamos precisar da sua ajuda para registrar o que vê no seu filho/a!',
|
||||
onAdvance: (ctx) => Navigator.of(ctx).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
@@ -990,13 +1003,13 @@ class Quiz20Screen extends StatelessWidget {
|
||||
class Quiz21Screen extends StatelessWidget {
|
||||
const Quiz21Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 21/26',
|
||||
title: 'Quiz 21/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.visibility_rounded,
|
||||
fallbackIcon: Icons.visibility_rounded,
|
||||
@@ -1043,29 +1056,35 @@ class Quiz21Screen extends StatelessWidget {
|
||||
class Quiz22Screen extends StatelessWidget {
|
||||
const Quiz22Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 22/26',
|
||||
title: 'Quiz 22/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.face_rounded,
|
||||
fallbackIcon: Icons.face_rounded,
|
||||
fallbackColor: const Color(0xFF8E7CC3),
|
||||
isSignQuestion: true,
|
||||
question:
|
||||
'Qual das imagens é mais parecida com o queixo do seu filho/a com a boca fechada?',
|
||||
answers: const [
|
||||
answers: [
|
||||
QuizAnswer(
|
||||
title: 'Queixo relaxado',
|
||||
description: 'Queixo liso e relaxado com a boca fechada',
|
||||
weight: 1,
|
||||
hideTitle: true,
|
||||
value: 'queixo_correto',
|
||||
imagePath: 'assets/mockup_images/5.png',
|
||||
imageBuilder: (context) => _zoomCrop(
|
||||
'assets/mockup_images/5.png',
|
||||
imageAlignment: const Alignment(0, 1),
|
||||
alignment: const Alignment(0, 1),
|
||||
scale: 1.8,
|
||||
),
|
||||
),
|
||||
QuizAnswer(
|
||||
const QuizAnswer(
|
||||
title: 'Queixo tenso',
|
||||
description: 'Queixo tenso/franzido com a boca fechada',
|
||||
weight: 2,
|
||||
@@ -1088,18 +1107,20 @@ class Quiz22Screen extends StatelessWidget {
|
||||
class Quiz23Screen extends StatelessWidget {
|
||||
const Quiz23Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 23/26',
|
||||
title: 'Quiz 23/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.sentiment_neutral_rounded,
|
||||
fallbackIcon: Icons.sentiment_neutral_rounded,
|
||||
fallbackColor: const Color(0xFF2F9E94),
|
||||
question: 'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
|
||||
isSignQuestion: true,
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Dentição alinhada',
|
||||
@@ -1107,7 +1128,7 @@ class Quiz23Screen extends StatelessWidget {
|
||||
weight: 1,
|
||||
hideTitle: true,
|
||||
value: 'dentes_alinhados',
|
||||
imagePath: 'assets/mockup_images/24.png',
|
||||
imagePath: 'assets/mockup_images/11.png',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Dentição desalinhada',
|
||||
@@ -1115,7 +1136,7 @@ class Quiz23Screen extends StatelessWidget {
|
||||
weight: 2,
|
||||
hideTitle: true,
|
||||
value: 'dentes_desalinhados',
|
||||
imagePath: 'assets/mockup_images/23.jpeg',
|
||||
imagePath: 'assets/mockup_images/13.png',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Dentição sobreposta',
|
||||
@@ -1123,7 +1144,7 @@ class Quiz23Screen extends StatelessWidget {
|
||||
weight: 2,
|
||||
hideTitle: true,
|
||||
value: 'dentes_sobrepostos',
|
||||
imagePath: 'assets/mockup_images/14.jpeg',
|
||||
imagePath: 'assets/mockup_images/10.png',
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
@@ -1136,17 +1157,71 @@ class Quiz23Screen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz 24: Freio labial (Image-choice)
|
||||
// Quiz 24: Apinhamento visto de perto (Image-choice)
|
||||
class Quiz24Screen extends StatelessWidget {
|
||||
const Quiz24Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 24/26',
|
||||
title: 'Quiz 24/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.zoom_in_rounded,
|
||||
fallbackIcon: Icons.zoom_in_rounded,
|
||||
fallbackColor: const Color(0xFF2F9E94),
|
||||
isSignQuestion: true,
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Ligeiro desalinhamento',
|
||||
description: 'Pequeno desalinhamento ou espaço entre alguns dentes',
|
||||
weight: 1,
|
||||
hideTitle: true,
|
||||
value: 'apinhamento_leve',
|
||||
imagePath: 'assets/mockup_images/16.png',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Apinhamento moderado',
|
||||
description: 'Dentes rodados/desalinhados de forma mais visível',
|
||||
weight: 2,
|
||||
hideTitle: true,
|
||||
value: 'apinhamento_moderado',
|
||||
imagePath: 'assets/mockup_images/15.png',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Apinhamento acentuado',
|
||||
description: 'Dentes muito sobrepostos entre si',
|
||||
weight: 2,
|
||||
hideTitle: true,
|
||||
value: 'apinhamento_acentuado',
|
||||
imagePath: 'assets/mockup_images/14.jpeg',
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
),
|
||||
answerType: QuizAnswerType.image,
|
||||
showBackButton: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz 25: Freio labial (Image-choice)
|
||||
class Quiz25Screen extends StatelessWidget {
|
||||
const Quiz25Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 25/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.record_voice_over_rounded,
|
||||
fallbackIcon: Icons.record_voice_over_rounded,
|
||||
@@ -1173,7 +1248,7 @@ class Quiz24Screen extends StatelessWidget {
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => Quiz25Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
),
|
||||
answerType: QuizAnswerType.image,
|
||||
showBackButton: true,
|
||||
@@ -1181,17 +1256,17 @@ class Quiz24Screen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz 25: Freio lingual (Image-choice)
|
||||
class Quiz25Screen extends StatelessWidget {
|
||||
const Quiz25Screen({super.key, required this.currentScore, this.scopeId});
|
||||
// Quiz 26: Freio lingual (Image-choice)
|
||||
class Quiz26Screen extends StatelessWidget {
|
||||
const Quiz26Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 25/26',
|
||||
title: 'Quiz 26/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.record_voice_over_rounded,
|
||||
fallbackIcon: Icons.record_voice_over_rounded,
|
||||
@@ -1218,7 +1293,7 @@ class Quiz25Screen extends StatelessWidget {
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
builder: (_) => Quiz27Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
),
|
||||
answerType: QuizAnswerType.image,
|
||||
showBackButton: true,
|
||||
@@ -1226,21 +1301,22 @@ class Quiz25Screen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz 26: Céu da boca (Image-choice, final)
|
||||
class Quiz26Screen extends StatelessWidget {
|
||||
const Quiz26Screen({super.key, required this.currentScore, this.scopeId});
|
||||
// Quiz 27: Céu da boca (Image-choice, final)
|
||||
class Quiz27Screen extends StatelessWidget {
|
||||
const Quiz27Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 26/26',
|
||||
title: 'Quiz 27/27',
|
||||
category: 'Avaliação facial',
|
||||
categoryIcon: Icons.architecture_rounded,
|
||||
fallbackIcon: Icons.architecture_rounded,
|
||||
fallbackColor: const Color(0xFFFF55A7),
|
||||
isSignQuestion: true,
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha ao céu da boca do seu filho/a?',
|
||||
answers: const [
|
||||
@@ -1263,11 +1339,7 @@ class Quiz26Screen extends StatelessWidget {
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => QuizResultScreen(
|
||||
finalScore: nextScore,
|
||||
maxScore: 52,
|
||||
scopeId: scopeId,
|
||||
),
|
||||
builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId),
|
||||
),
|
||||
answerType: QuizAnswerType.image,
|
||||
isFinal: true,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
const Color _pink = Color(0xFFFF55A7);
|
||||
@@ -28,33 +26,12 @@ class QuizChecklistScreen extends StatelessWidget {
|
||||
|
||||
@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(
|
||||
right: -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Resultado do quiz, sempre com os dois indicadores juntos — nunca faz
|
||||
/// sentido guardar/ler um sem o outro.
|
||||
class QuizResultData {
|
||||
const QuizResultData({
|
||||
required this.signs,
|
||||
required this.signsMax,
|
||||
required this.factors,
|
||||
required this.factorsMax,
|
||||
});
|
||||
|
||||
final int signs;
|
||||
final int signsMax;
|
||||
final int factors;
|
||||
final int factorsMax;
|
||||
}
|
||||
|
||||
class QuizPrefs {
|
||||
static const String _kSeenQuizKey = 'seen_oral_quiz_v1';
|
||||
static const String _kLastScoreKey = 'last_oral_quiz_score_v1';
|
||||
static const String _kLastMaxScoreKey = 'last_oral_quiz_max_score_v1';
|
||||
static const String _kSignsKey = 'last_oral_quiz_signs_v2';
|
||||
static const String _kSignsMaxKey = 'last_oral_quiz_signs_max_v2';
|
||||
static const String _kFactorsKey = 'last_oral_quiz_factors_v2';
|
||||
static const String _kFactorsMaxKey = 'last_oral_quiz_factors_max_v2';
|
||||
|
||||
static String _scopeKey(String base, String? scopeId) {
|
||||
final id = (scopeId ?? '').trim();
|
||||
@@ -21,47 +39,56 @@ class QuizPrefs {
|
||||
await prefs.setBool(_kSeenQuizKey, true);
|
||||
}
|
||||
|
||||
static Future<void> saveLastResult({required int score, required int maxScore}) async {
|
||||
static Future<void> saveLastResult(QuizResultData result) async {
|
||||
await saveLastResultForScope(scopeId: null, result: result);
|
||||
}
|
||||
|
||||
static Future<void> saveLastResultForUser({
|
||||
required String userId,
|
||||
required QuizResultData result,
|
||||
}) async {
|
||||
await saveLastResultForScope(scopeId: userId, result: result);
|
||||
}
|
||||
|
||||
static Future<void> saveLastResultForScope({
|
||||
required String? scopeId,
|
||||
required QuizResultData result,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_kLastScoreKey, score);
|
||||
await prefs.setInt(_kLastMaxScoreKey, maxScore);
|
||||
await prefs.setInt(_scopeKey(_kSignsKey, scopeId), result.signs);
|
||||
await prefs.setInt(_scopeKey(_kSignsMaxKey, scopeId), result.signsMax);
|
||||
await prefs.setInt(_scopeKey(_kFactorsKey, scopeId), result.factors);
|
||||
await prefs.setInt(
|
||||
_scopeKey(_kFactorsMaxKey, scopeId),
|
||||
result.factorsMax,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> saveLastResultForUser({required String userId, required int score, required int maxScore}) async {
|
||||
await saveLastResultForScope(scopeId: userId, score: score, maxScore: maxScore);
|
||||
}
|
||||
static Future<QuizResultData?> getLastResult() =>
|
||||
getLastResultForScope(null);
|
||||
|
||||
static Future<void> saveLastResultForScope({required String scopeId, required int score, required int maxScore}) async {
|
||||
static Future<QuizResultData?> getLastResultForUser(String userId) =>
|
||||
getLastResultForScope(userId);
|
||||
|
||||
static Future<QuizResultData?> getLastResultForScope(
|
||||
String? scopeId,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_scopeKey(_kLastScoreKey, scopeId), score);
|
||||
await prefs.setInt(_scopeKey(_kLastMaxScoreKey, scopeId), maxScore);
|
||||
}
|
||||
|
||||
static Future<int?> getLastScore() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_kLastScoreKey);
|
||||
}
|
||||
|
||||
static Future<int?> getLastScoreForUser(String userId) async {
|
||||
return getLastScoreForScope(userId);
|
||||
}
|
||||
|
||||
static Future<int?> getLastScoreForScope(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_scopeKey(_kLastScoreKey, scopeId));
|
||||
}
|
||||
|
||||
static Future<int?> getLastMaxScore() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_kLastMaxScoreKey);
|
||||
}
|
||||
|
||||
static Future<int?> getLastMaxScoreForUser(String userId) async {
|
||||
return getLastMaxScoreForScope(userId);
|
||||
}
|
||||
|
||||
static Future<int?> getLastMaxScoreForScope(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_scopeKey(_kLastMaxScoreKey, scopeId));
|
||||
final signs = prefs.getInt(_scopeKey(_kSignsKey, scopeId));
|
||||
final signsMax = prefs.getInt(_scopeKey(_kSignsMaxKey, scopeId));
|
||||
final factors = prefs.getInt(_scopeKey(_kFactorsKey, scopeId));
|
||||
final factorsMax = prefs.getInt(_scopeKey(_kFactorsMaxKey, scopeId));
|
||||
if (signs == null ||
|
||||
signsMax == null ||
|
||||
factors == null ||
|
||||
factorsMax == null) {
|
||||
return null;
|
||||
}
|
||||
return QuizResultData(
|
||||
signs: signs,
|
||||
signsMax: signsMax,
|
||||
factors: factors,
|
||||
factorsMax: factorsMax,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../screens/video_screen.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
typedef QuizNextBuilder =
|
||||
Route<void> Function(BuildContext context, int nextScore);
|
||||
Route<void> Function(BuildContext context, QuizScore nextScore);
|
||||
|
||||
/// Pontuação do quiz, separada em dois contadores independentes em vez de
|
||||
/// uma única soma — cada pergunta soma 1 a exatamente um dos dois, nunca aos
|
||||
/// dois nem a nenhum. Ver [QuizQuestionScreen.isSignQuestion].
|
||||
class QuizScore {
|
||||
const QuizScore({this.signs = 0, this.factors = 0});
|
||||
|
||||
final int signs;
|
||||
final int factors;
|
||||
|
||||
QuizScore addSign(int amount) =>
|
||||
QuizScore(signs: signs + amount, factors: factors);
|
||||
|
||||
QuizScore addFactor(int amount) =>
|
||||
QuizScore(signs: signs, factors: factors + amount);
|
||||
}
|
||||
|
||||
enum QuizAnswerType { text, image, number, yesNo }
|
||||
|
||||
@@ -56,7 +70,8 @@ class QuizQuestionScreen extends StatefulWidget {
|
||||
required this.question,
|
||||
required this.answers,
|
||||
required this.nextRoute,
|
||||
this.currentScore = 0,
|
||||
this.currentScore = const QuizScore(),
|
||||
this.isSignQuestion = false,
|
||||
this.onFinished,
|
||||
this.isFinal = false,
|
||||
this.showBackButton = false,
|
||||
@@ -76,7 +91,14 @@ class QuizQuestionScreen extends StatefulWidget {
|
||||
final String question;
|
||||
final List<QuizAnswer> answers;
|
||||
final QuizNextBuilder nextRoute;
|
||||
final int currentScore;
|
||||
final QuizScore currentScore;
|
||||
|
||||
/// Quando true, uma resposta "de risco" (weight 2) soma a
|
||||
/// [QuizScore.signs] em vez de [QuizScore.factors] — usado só nas 4
|
||||
/// perguntas que representam sinais de má oclusão já instalados
|
||||
/// (queixo, boca/dentição, apinhamento, céu da boca); todas as outras
|
||||
/// contam como fatores de risco associados.
|
||||
final bool isSignQuestion;
|
||||
final VoidCallback? onFinished;
|
||||
final bool isFinal;
|
||||
final bool showBackButton;
|
||||
@@ -169,27 +191,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -477,10 +479,9 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
// ser que a pergunta imponha um
|
||||
// aspect ratio específico.
|
||||
imageAspectRatio:
|
||||
widget.answerImageAspectRatio ??
|
||||
(widget
|
||||
.answers
|
||||
.length >=
|
||||
widget
|
||||
.answerImageAspectRatio ??
|
||||
(widget.answers.length >=
|
||||
3
|
||||
? 2.3
|
||||
: 1.5),
|
||||
@@ -581,25 +582,30 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
() => _navigating =
|
||||
true,
|
||||
);
|
||||
int nextScore =
|
||||
QuizScore nextScore =
|
||||
widget.currentScore;
|
||||
if (widget.answerType ==
|
||||
if (widget.answerType !=
|
||||
QuizAnswerType
|
||||
.number) {
|
||||
nextScore =
|
||||
widget
|
||||
.currentScore +
|
||||
(_numberDontKnow
|
||||
? 0
|
||||
: (_numberValue ??
|
||||
0));
|
||||
} else {
|
||||
final picked = widget
|
||||
.answers[_selected!];
|
||||
final increment =
|
||||
picked.weight ==
|
||||
2
|
||||
? 1
|
||||
: 0;
|
||||
nextScore =
|
||||
widget
|
||||
.currentScore +
|
||||
picked.weight;
|
||||
widget.isSignQuestion
|
||||
? widget
|
||||
.currentScore
|
||||
.addSign(
|
||||
increment,
|
||||
)
|
||||
: widget
|
||||
.currentScore
|
||||
.addFactor(
|
||||
increment,
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isFinal) {
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'quiz_question_screen.dart';
|
||||
import 'quiz_result.dart';
|
||||
|
||||
class QuizRandomScreen extends StatefulWidget {
|
||||
const QuizRandomScreen({super.key, this.currentScore = 0, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
State<QuizRandomScreen> createState() => _QuizRandomScreenState();
|
||||
}
|
||||
|
||||
class _QuizRandomScreenState extends State<QuizRandomScreen> {
|
||||
final List<QuizQuestion> _allQuestions = [
|
||||
QuizQuestion(
|
||||
id: 1,
|
||||
title: 'Quiz 1/26',
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha à face do seu filho/a?',
|
||||
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/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/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/mockup_images/3.jpeg',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 2,
|
||||
title: 'Quiz 2/26',
|
||||
question:
|
||||
'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/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/mockup_images/5.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 3,
|
||||
title: 'Quiz 3/26',
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha às olheiras do seu filho/a?',
|
||||
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/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/mockup_images/9.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 4,
|
||||
title: 'Quiz 4/26',
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha ao queixo do seu filho/a com a boca fechada?',
|
||||
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/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/mockup_images/7.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 6,
|
||||
title: 'Quiz 6/26',
|
||||
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,
|
||||
title: 'Quiz 7/26',
|
||||
question:
|
||||
'Qual das seguintes imagens se assemelha à boca do seu filho/a?',
|
||||
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/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/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/mockup_images/13.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
//17.png
|
||||
title: 'Opção A',
|
||||
description:
|
||||
'Selecione se a imagem se assemelha ao freio do seu filho/a',
|
||||
weight: 2,
|
||||
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/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(
|
||||
//19.jpeg
|
||||
title: 'Opção A',
|
||||
description:
|
||||
'Selecione se a imagem se assemelha ao freio do seu filho/a',
|
||||
weight: 2,
|
||||
imagePath: 'assets/mockup_images/19.jpeg',
|
||||
),
|
||||
QuizAnswer(
|
||||
//20.png
|
||||
title: 'Opção B',
|
||||
description:
|
||||
'Selecione se a imagem se assemelha ao freio do seu filho/a',
|
||||
weight: 2,
|
||||
imagePath: 'assets/mockup_images/20.png',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 9,
|
||||
title: 'Quiz 9/26',
|
||||
question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Problemas respiratórios diagnosticados',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Sem problemas respiratórios diagnosticados',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 10,
|
||||
title: 'Quiz 10/26',
|
||||
question: 'O seu filho/a respira habitualmente pela boca?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Respira habitualmente pela boca',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não respira habitualmente pela boca',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 11,
|
||||
title: 'Quiz 11/26',
|
||||
question: 'O seu filho/a ressona habitualmente durante a noite?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Ressonar habitualmente durante a noite',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não ressona habitualmente',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 12,
|
||||
title: 'Quiz 12/26',
|
||||
question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Sente habitualmente o nariz tapado',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não sente habitualmente o nariz tapado',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 13,
|
||||
title: 'Quiz 13/26',
|
||||
question:
|
||||
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description:
|
||||
'Tem habitualmente interrupções da respiração durante o sono',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não tem interrupções da respiração durante o sono',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 14,
|
||||
title: 'Quiz 14/26',
|
||||
question: 'O seu filho/a range os dentes com frequência?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Range os dentes com frequência',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não range os dentes com frequência',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 15,
|
||||
title: 'Quiz 15/26',
|
||||
question: 'O seu filho/a habitualmente tem alergias sazonais?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Habitualmente tem alergias sazonais',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não tem alergias sazonais',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 16,
|
||||
title: 'Quiz 16/26',
|
||||
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Acorda com saliva seca na cara ou na almofada',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não acorda com saliva seca',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 17,
|
||||
title: 'Quiz 17/26',
|
||||
question: 'O seu filho/a teve ou costuma ter com frequência otites?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Teve ou costuma ter com frequência otites',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não teve ou não costuma ter otites',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 18,
|
||||
title: 'Quiz 18/26',
|
||||
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Teve ou costuma ter com frequência amigdalites',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não teve ou não costuma ter amigdalites',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 19,
|
||||
title: 'Quiz 19/26',
|
||||
question:
|
||||
'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Teve ou costuma ter com frequência bronquiolites',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não teve ou não costuma ter bronquiolites',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 20,
|
||||
title: 'Quiz 20/26',
|
||||
question: 'O seu filho/a apresenta dificuldades a mastigar?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Apresenta dificuldades a mastigar',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não apresenta dificuldades a mastigar',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 21,
|
||||
title: 'Quiz 21/26',
|
||||
question: 'O seu filho/a habitualmente é lento a comer?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Habitualmente é lento a comer',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não é lento a comer',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 22,
|
||||
title: 'Quiz 22/26',
|
||||
question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Habitualmente prefere comer alimentos moles',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não prefere alimentos moles',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 23,
|
||||
title: 'Quiz 23/26',
|
||||
question: 'Em bebé apenas foi alimentado por biberão?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Em bebé apenas foi alimentado por biberão',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não foi apenas alimentado por biberão',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 24,
|
||||
title: 'Quiz 24/26',
|
||||
question: 'O seu filho/a usa ou usou chupeta com frequência?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Usa ou usou chupeta com frequência',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não usa ou não usou chupeta com frequência',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
QuizQuestion(
|
||||
id: 25,
|
||||
title: 'Quiz 25/26',
|
||||
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Sim',
|
||||
description: 'Chucha ou já chuchou o dedo com frequência',
|
||||
weight: 2,
|
||||
value: 'sim',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: 'Não',
|
||||
description: 'Não chucha ou não chuchou o dedo com frequência',
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
late List<QuizQuestion> _shuffledQuestions;
|
||||
int _currentQuestionIndex = 0;
|
||||
int _currentScore = 0;
|
||||
final Random _random = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentScore = widget.currentScore;
|
||||
_shuffledQuestions = List.from(_allQuestions)..shuffle(_random);
|
||||
}
|
||||
|
||||
void _nextQuestion(int scoreToAdd) {
|
||||
setState(() {
|
||||
_currentScore += scoreToAdd;
|
||||
_currentQuestionIndex++;
|
||||
});
|
||||
|
||||
if (_currentQuestionIndex >= _shuffledQuestions.length) {
|
||||
// Quiz finished
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => QuizResultScreen(
|
||||
finalScore: _currentScore,
|
||||
maxScore: 75, // 15 questions * 5 max points
|
||||
scopeId: widget.scopeId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_currentQuestionIndex >= _shuffledQuestions.length) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final currentQuestion = _shuffledQuestions[_currentQuestionIndex];
|
||||
final isLastQuestion =
|
||||
_currentQuestionIndex == _shuffledQuestions.length - 1;
|
||||
|
||||
return QuizQuestionScreen(
|
||||
title: currentQuestion.title,
|
||||
question: currentQuestion.question,
|
||||
answers: currentQuestion.answers,
|
||||
currentScore: _currentScore,
|
||||
nextRoute: (context, nextScore) {
|
||||
_nextQuestion(nextScore - _currentScore);
|
||||
return MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink());
|
||||
},
|
||||
isFinal: isLastQuestion,
|
||||
showBackButton: _currentQuestionIndex > 0,
|
||||
answerType: currentQuestion.answerType,
|
||||
questionImagePaths: currentQuestion.questionImagePaths,
|
||||
onFinished: isLastQuestion
|
||||
? () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => QuizResultScreen(
|
||||
finalScore: _currentScore,
|
||||
maxScore: 75,
|
||||
scopeId: widget.scopeId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QuizQuestion {
|
||||
final int id;
|
||||
final String title;
|
||||
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,
|
||||
required this.title,
|
||||
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',
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
const Color _pink = Color(0xFFFF55A7);
|
||||
@@ -118,35 +116,12 @@ class _QuizVideoGuideScreenState extends State<QuizVideoGuideScreen> {
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../widgets/app_gradients.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
class CuriosidadeScreen extends StatelessWidget {
|
||||
@@ -12,8 +10,6 @@ class CuriosidadeScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||
@@ -35,30 +31,8 @@ class CuriosidadeScreen extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../privacy_gate_prefs.dart';
|
||||
import '../terms_gate_prefs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import '../widgets/privacy_content.dart';
|
||||
|
||||
@@ -76,8 +75,6 @@ class _PrivacyGateScreenState extends State<PrivacyGateScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -87,30 +84,8 @@ class _PrivacyGateScreenState extends State<PrivacyGateScreen> {
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color(0xFFFAFAF7)),
|
||||
),
|
||||
Positioned(
|
||||
left: -size.width * 0.38,
|
||||
bottom: -size.width * 0.38,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../terms_gate_prefs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import '../widgets/terms_content.dart';
|
||||
|
||||
@@ -53,8 +52,6 @@ class _TermsGateScreenState extends State<TermsGateScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -64,30 +61,8 @@ class _TermsGateScreenState extends State<TermsGateScreen> {
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color(0xFFFAFAF7)),
|
||||
),
|
||||
Positioned(
|
||||
left: -size.width * 0.38,
|
||||
bottom: -size.width * 0.38,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
|
||||
|
||||
import '../watched_videos_prefs.dart';
|
||||
import '../widgets/app_gradients.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/liquid_waves_background.dart';
|
||||
import '../widgets/pill_snackbar.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
@@ -62,7 +61,8 @@ final List<VideoData> videoList = [
|
||||
VideoData(
|
||||
id: 5,
|
||||
title: 'Episódio 5',
|
||||
description: 'Qual a Influência das Bronquiolites recorrentes na má oclusão',
|
||||
description:
|
||||
'Qual a Influência das Bronquiolites recorrentes na má oclusão',
|
||||
youtubeId: 'DnhUa-T8_Ps',
|
||||
),
|
||||
VideoData(
|
||||
@@ -74,7 +74,8 @@ final List<VideoData> videoList = [
|
||||
VideoData(
|
||||
id: 7,
|
||||
title: 'Episódio 7',
|
||||
description: 'Qual a Influência das interrupções respiratórias na má oclusão',
|
||||
description:
|
||||
'Qual a Influência das interrupções respiratórias na má oclusão',
|
||||
youtubeId: 'NpmQ2brap5A',
|
||||
),
|
||||
VideoData(
|
||||
@@ -86,7 +87,8 @@ final List<VideoData> videoList = [
|
||||
VideoData(
|
||||
id: 9,
|
||||
title: 'Episódio 9',
|
||||
description: 'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral',
|
||||
description:
|
||||
'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral',
|
||||
youtubeId: 'bOm9t61cT_U',
|
||||
),
|
||||
VideoData(
|
||||
@@ -179,7 +181,8 @@ Future<void> showVideoPlayerDialog(
|
||||
}
|
||||
return Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
|
||||
builder: (context) =>
|
||||
_YoutubePlayerPage(video: video, scopeId: scopeId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -234,7 +237,6 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||
@@ -260,30 +262,8 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
||||
const LiquidWavesBackground(),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -341,9 +321,7 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: 40 * (index % 8),
|
||||
),
|
||||
delay: Duration(milliseconds: 40 * (index % 8)),
|
||||
child: _VideoButton(
|
||||
video: _filteredVideos[index],
|
||||
scopeId: widget.scopeId,
|
||||
@@ -561,70 +539,73 @@ class _VideoButton extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: const Color(0xFFFFE6F1),
|
||||
child: VideoThumbnail(video: video, borderRadius: 0),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.32),
|
||||
],
|
||||
stops: const [0.55, 1.0],
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: const Color(0xFFFFE6F1),
|
||||
child: VideoThumbnail(
|
||||
video: video,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: VideoScreen._accentPink,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'EP. ${video.id}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 9.5,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.32),
|
||||
],
|
||||
stops: const [0.55, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Center(
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: VideoScreen._accentPink,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'EP. ${video.id}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 9.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -718,7 +699,9 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
||||
PlatformDispatcher.instance.views.first.physicalSize.width >
|
||||
PlatformDispatcher.instance.views.first.physicalSize.height;
|
||||
if (isLandscape == _controller.value.isFullScreen) return;
|
||||
_controller.updateValue(_controller.value.copyWith(isFullScreen: isLandscape));
|
||||
_controller.updateValue(
|
||||
_controller.value.copyWith(isFullScreen: isLandscape),
|
||||
);
|
||||
if (isLandscape) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
} else {
|
||||
@@ -782,12 +765,21 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: YoutubePlayer(controller: _controller),
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: kAppBarGradient,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: YoutubePlayer(controller: _controller),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||||
child: _VideoProgressRow(controller: _controller),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -809,6 +801,65 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
if (_nextVideos.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Próximos',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: VideoScreen._accentPink,
|
||||
),
|
||||
),
|
||||
if (_hasMoreVideos)
|
||||
TapBounce(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
999,
|
||||
),
|
||||
onTap: () =>
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VideoScreen(
|
||||
scopeId: widget.scopeId,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
'Ver mais',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
color: VideoScreen._teal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (
|
||||
var i = 0;
|
||||
i < _visibleNextVideos.length;
|
||||
i++
|
||||
) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
_VideoButton(
|
||||
video: _visibleNextVideos[i],
|
||||
scopeId: widget.scopeId,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -820,6 +871,85 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const int _maxNextVideos = 4;
|
||||
|
||||
List<VideoData> get _nextVideos =>
|
||||
videoList.where((v) => v.id > widget.video.id).toList();
|
||||
|
||||
List<VideoData> get _visibleNextVideos =>
|
||||
_nextVideos.take(_maxNextVideos).toList();
|
||||
|
||||
bool get _hasMoreVideos => _nextVideos.length > _maxNextVideos;
|
||||
}
|
||||
|
||||
/// Barra de progresso do vídeo (posição atual / duração total), separada do
|
||||
/// player — a mesma que aparece por baixo do vídeo no design de referência.
|
||||
class _VideoProgressRow extends StatelessWidget {
|
||||
const _VideoProgressRow({required this.controller});
|
||||
|
||||
final YoutubePlayerController controller;
|
||||
|
||||
String _format(Duration d) {
|
||||
final minutes = d.inMinutes.remainder(60).toString();
|
||||
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<YoutubePlayerValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
final duration = value.metaData.duration;
|
||||
final position = value.position;
|
||||
final progress = duration.inMilliseconds > 0
|
||||
? (position.inMilliseconds / duration.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
: 0.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 5,
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.10),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
VideoScreen._accentPink,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_format(position),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_format(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preenche todo o espaço disponível recortando o vídeo (mantém a proporção
|
||||
@@ -1059,8 +1189,7 @@ class _VideoControlsState extends State<_VideoControls> {
|
||||
color: Colors.white,
|
||||
),
|
||||
tooltip: 'Retroceder 10s',
|
||||
onPressed: () =>
|
||||
_seekBy(const Duration(seconds: -10)),
|
||||
onPressed: () => _seekBy(const Duration(seconds: -10)),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
|
||||
70
lib/widgets/liquid_waves_background.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
/// Fundo decorativo com a animação "Liquid waves", reutilizado em todas as
|
||||
/// telas informativas da app (quiz, vídeos, termos, home, etc.) para manter
|
||||
/// a mesma posição/rotação em todo o lado.
|
||||
///
|
||||
/// A animação arranca sincronizada com o relógio do sistema (em vez de
|
||||
/// sempre do frame 0) para que, ao navegar de uma tela para a seguinte, a
|
||||
/// nova instância do Lottie continue visualmente de onde a anterior ficou
|
||||
/// em vez de reiniciar de forma abrupta.
|
||||
class LiquidWavesBackground extends StatefulWidget {
|
||||
const LiquidWavesBackground({super.key});
|
||||
|
||||
@override
|
||||
State<LiquidWavesBackground> createState() => _LiquidWavesBackgroundState();
|
||||
}
|
||||
|
||||
class _LiquidWavesBackgroundState extends State<LiquidWavesBackground>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Duração real da composição (300x200, 60fps, frames 0-540 — ver
|
||||
// lottie/Liquid waves.json), usada para sincronizar o valor inicial com o
|
||||
// relógio do sistema sem depender do callback assíncrono onLoaded.
|
||||
static const Duration _loopDuration = Duration(seconds: 9);
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final elapsedMs =
|
||||
DateTime.now().millisecondsSinceEpoch % _loopDuration.inMilliseconds;
|
||||
_controller = AnimationController(vsync: this, duration: _loopDuration)
|
||||
..value = elapsedMs / _loopDuration.inMilliseconds
|
||||
..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
return 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',
|
||||
controller: _controller,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||