diff --git a/assets/mockup_images/28.jpeg b/assets/mockup_images/28.jpeg new file mode 100644 index 0000000..fe8b7ab Binary files /dev/null and b/assets/mockup_images/28.jpeg differ diff --git a/assets/mockup_images/29.jpeg b/assets/mockup_images/29.jpeg new file mode 100644 index 0000000..3938015 Binary files /dev/null and b/assets/mockup_images/29.jpeg differ diff --git a/assets/mockup_images/4.jpeg b/assets/mockup_images/4.jpeg index f2c5161..bdaa5af 100644 Binary files a/assets/mockup_images/4.jpeg and b/assets/mockup_images/4.jpeg differ diff --git a/assets/mockup_images/5.png b/assets/mockup_images/5.png index 16863cb..a2dcb19 100644 Binary files a/assets/mockup_images/5.png and b/assets/mockup_images/5.png differ diff --git a/assets/mockup_images/6.jpeg b/assets/mockup_images/6.jpeg index 1dd8d2c..d808cb6 100644 Binary files a/assets/mockup_images/6.jpeg and b/assets/mockup_images/6.jpeg differ diff --git a/assets/mockup_images/7.png b/assets/mockup_images/7.png index 16863cb..5783c5e 100644 Binary files a/assets/mockup_images/7.png and b/assets/mockup_images/7.png differ diff --git a/lib/_debug_harness.dart b/lib/_debug_harness.dart new file mode 100644 index 0000000..c89b604 --- /dev/null +++ b/lib/_debug_harness.dart @@ -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(); + } +} diff --git a/lib/home_screen.dart b/lib/home_screen.dart index c6513e9..9ce0a5e 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -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 { @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) { diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 91fbd05..1c86a71 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -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 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 String? _selectedChildName; String? _selectedChildScopeId; - int? _lastScore; - int? _lastMaxScore; + QuizResultData? _lastResult; int? _brushingWeekCount; int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; @@ -204,8 +203,7 @@ class _LoggedHomeScreenState extends State 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 .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 } } - 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 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 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 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 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 ), ), ), - //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 _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 } 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( duration: const Duration(milliseconds: 700), curve: Curves.easeOutCubic, - tween: Tween(begin: 0, end: clamped / 100), - builder: (context, value, _) { - final shown = (value * 100).round(); + tween: Tween(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 _loadScoreForScope(String scopeId) { + return QuizPrefs.getLastResultForScope(scopeId); } Future _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( 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, diff --git a/lib/main.dart b/lib/main.dart index 5d1a209..71b9865 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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(), ); } } diff --git a/lib/quiz/quiz1.dart b/lib/quiz/quiz1.dart index 9aff5f6..598571f 100644 --- a/lib/quiz/quiz1.dart +++ b/lib/quiz/quiz1.dart @@ -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 quizStartRoute({String? scopeId}) { 'Quando deve procurar um dentista (urgência vs vigilância)', ], onAdvance: (context) => Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => Quiz1Screen(scopeId: scopeId), - ), + MaterialPageRoute(builder: (_) => Quiz1Screen(scopeId: scopeId)), ), ), ); @@ -40,15 +48,19 @@ MaterialPageRoute 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( 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( 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( + 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( - 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( - 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( - builder: (_) => QuizResultScreen( - finalScore: nextScore, - maxScore: 52, - scopeId: scopeId, - ), + builder: (_) => QuizResultScreen(finalScore: nextScore, scopeId: scopeId), ), answerType: QuizAnswerType.image, isFinal: true, diff --git a/lib/quiz/quiz_checklist_screen.dart b/lib/quiz/quiz_checklist_screen.dart index 1b9f7e6..299dd10 100644 --- a/lib/quiz/quiz_checklist_screen.dart +++ b/lib/quiz/quiz_checklist_screen.dart @@ -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), diff --git a/lib/quiz/quiz_prefs.dart b/lib/quiz/quiz_prefs.dart index 5763e5c..d8cd55d 100644 --- a/lib/quiz/quiz_prefs.dart +++ b/lib/quiz/quiz_prefs.dart @@ -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 saveLastResult({required int score, required int maxScore}) async { + static Future saveLastResult(QuizResultData result) async { + await saveLastResultForScope(scopeId: null, result: result); + } + + static Future saveLastResultForUser({ + required String userId, + required QuizResultData result, + }) async { + await saveLastResultForScope(scopeId: userId, result: result); + } + + static Future 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 saveLastResultForUser({required String userId, required int score, required int maxScore}) async { - await saveLastResultForScope(scopeId: userId, score: score, maxScore: maxScore); - } + static Future getLastResult() => + getLastResultForScope(null); - static Future saveLastResultForScope({required String scopeId, required int score, required int maxScore}) async { + static Future getLastResultForUser(String userId) => + getLastResultForScope(userId); + + static Future 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 getLastScore() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kLastScoreKey); - } - - static Future getLastScoreForUser(String userId) async { - return getLastScoreForScope(userId); - } - - static Future getLastScoreForScope(String scopeId) async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_scopeKey(_kLastScoreKey, scopeId)); - } - - static Future getLastMaxScore() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt(_kLastMaxScoreKey); - } - - static Future getLastMaxScoreForUser(String userId) async { - return getLastMaxScoreForScope(userId); - } - - static Future 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, + ); } } diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index 30e085c..5fc60e5 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -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 Function(BuildContext context, int nextScore); + Route 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 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 { 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 { // 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 { () => _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) { diff --git a/lib/quiz/quiz_random.dart b/lib/quiz/quiz_random.dart deleted file mode 100644 index 787636a..0000000 --- a/lib/quiz/quiz_random.dart +++ /dev/null @@ -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 createState() => _QuizRandomScreenState(); -} - -class _QuizRandomScreenState extends State { - final List _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 _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( - 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(builder: (_) => const SizedBox.shrink()); - }, - isFinal: isLastQuestion, - showBackButton: _currentQuestionIndex > 0, - answerType: currentQuestion.answerType, - questionImagePaths: currentQuestion.questionImagePaths, - onFinished: isLastQuestion - ? () { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => QuizResultScreen( - finalScore: _currentScore, - maxScore: 75, - scopeId: widget.scopeId, - ), - ), - ); - } - : null, - ); - } -} - -class QuizQuestion { - final int id; - final String title; - final String question; - final List answers; - final QuizAnswerType answerType; - // Reference images shown ABOVE the question text (visualization only). - final List 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 _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', -}; diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index 1fbc612..3bca4ff 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -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 { Future _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 { 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 { @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( - duration: const Duration(milliseconds: 1100), - curve: Curves.easeOutCubic, - tween: Tween(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( + duration: const Duration(milliseconds: 1100), + curve: Curves.easeOutCubic, + tween: Tween(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, + ), + ), + ], + ); + } +} diff --git a/lib/quiz/quiz_video_guide.dart b/lib/quiz/quiz_video_guide.dart index ba0f445..aca7ff6 100644 --- a/lib/quiz/quiz_video_guide.dart +++ b/lib/quiz/quiz_video_guide.dart @@ -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 { @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), diff --git a/lib/screens/curiosidade_screen.dart b/lib/screens/curiosidade_screen.dart index 62c1a2c..1c6a970 100644 --- a/lib/screens/curiosidade_screen.dart +++ b/lib/screens/curiosidade_screen.dart @@ -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, diff --git a/lib/screens/privacy_gate_screen.dart b/lib/screens/privacy_gate_screen.dart index 2a2839f..af5d8fe 100644 --- a/lib/screens/privacy_gate_screen.dart +++ b/lib/screens/privacy_gate_screen.dart @@ -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 { @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -87,30 +84,8 @@ class _PrivacyGateScreenState extends State { 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: [ diff --git a/lib/screens/terms_gate_screen.dart b/lib/screens/terms_gate_screen.dart index c21fb8e..ff6e8c2 100644 --- a/lib/screens/terms_gate_screen.dart +++ b/lib/screens/terms_gate_screen.dart @@ -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 { @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -64,30 +61,8 @@ class _TermsGateScreenState extends State { 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: [ diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index 96970c6..60cce4f 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -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 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 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 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 showVideoPlayerDialog( } return Navigator.of(context).push( MaterialPageRoute( - builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId), + builder: (context) => + _YoutubePlayerPage(video: video, scopeId: scopeId), ), ); } @@ -234,7 +237,6 @@ class _VideoScreenState extends State { @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 { 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 { 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( + 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 get _nextVideos => + videoList.where((v) => v.id > widget.video.id).toList(); + + List 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( + 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( diff --git a/lib/widgets/liquid_waves_background.dart b/lib/widgets/liquid_waves_background.dart new file mode 100644 index 0000000..e308720 --- /dev/null +++ b/lib/widgets/liquid_waves_background.dart @@ -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 createState() => _LiquidWavesBackgroundState(); +} + +class _LiquidWavesBackgroundState extends State + 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, + ), + ), + ), + ), + ), + ); + } +}