diff --git a/assets/videos/episodio_11.mp4 b/assets/videos/episodio_11.mp4 deleted file mode 100644 index 6cfe5b4..0000000 Binary files a/assets/videos/episodio_11.mp4 and /dev/null differ diff --git a/assets/videos/episodio_12.mp4 b/assets/videos/episodio_12.mp4 deleted file mode 100644 index 2f83074..0000000 Binary files a/assets/videos/episodio_12.mp4 and /dev/null differ diff --git a/assets/videos/episodio_13.mp4 b/assets/videos/episodio_13.mp4 deleted file mode 100644 index 64b52f1..0000000 Binary files a/assets/videos/episodio_13.mp4 and /dev/null differ diff --git a/lib/brushing_prefs.dart b/lib/brushing_prefs.dart new file mode 100644 index 0000000..1aa286a --- /dev/null +++ b/lib/brushing_prefs.dart @@ -0,0 +1,84 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Registo local (por criança) de escovagens e da meta semanal. +/// Segue o mesmo padrão de [QuizPrefs]: chaves com sufixo `_scopeId`, +/// guardadas via [SharedPreferences], sem qualquer chamada ao Supabase. +/// +/// Cada escovagem é guardada com a hora exata (não só a data), para permitir +/// até [maxPerDay] registos por dia — a criança pode escovar os dentes de +/// manhã, à tarde e à noite, a qualquer hora. +class BrushingPrefs { + static const String _kGoalKey = 'brushing_weekly_goal'; + static const String _kDatesKey = 'brushing_dates'; + static const int maxPerDay = 3; + static const int defaultWeeklyGoal = maxPerDay * 7; + + static String _key(String base, String scopeId) => '${base}_$scopeId'; + + static Future> _getEntries(String scopeId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const []; + return raw.map(DateTime.tryParse).whereType().toList(); + } + + static Future getWeeklyGoal(String scopeId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_key(_kGoalKey, scopeId)) ?? defaultWeeklyGoal; + } + + static Future setWeeklyGoal(String scopeId, int goal) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_key(_kGoalKey, scopeId), goal); + } + + static Future getTodayCount(String scopeId) async { + final entries = await _getEntries(scopeId); + final now = DateTime.now(); + return entries + .where( + (d) => + d.year == now.year && d.month == now.month && d.day == now.day, + ) + .length; + } + + static Future canLogMore(String scopeId) async { + return (await getTodayCount(scopeId)) < maxPerDay; + } + + /// Regista uma escovagem agora — não faz nada se o limite diário já foi + /// atingido (chamador deve verificar [canLogMore] antes, se quiser avisar + /// o utilizador). + static Future logToday(String scopeId) async { + if (!(await canLogMore(scopeId))) return; + final prefs = await SharedPreferences.getInstance(); + final key = _key(_kDatesKey, scopeId); + final list = prefs.getStringList(key) ?? []; + list.add(DateTime.now().toIso8601String()); + await prefs.setStringList(key, list); + } + + /// Já atingiu o limite diário de [maxPerDay] escovagens hoje? + static Future hasReachedDailyLimit(String scopeId) async { + return (await getTodayCount(scopeId)) >= maxPerDay; + } + + /// Conta quantas escovagens foram registadas na semana atual + /// (segunda-feira a domingo). + static Future getWeekCount(String scopeId) async { + final entries = await _getEntries(scopeId); + + final now = DateTime.now(); + final monday = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: now.weekday - 1)); + + return entries + .where( + (d) => !DateTime(d.year, d.month, d.day).isBefore(monday), + ) + .length; + } +} diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 549f51c..2bc6b72 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -8,11 +8,13 @@ import 'dart:math' as math; import 'dart:io'; import 'package:shared_preferences/shared_preferences.dart'; +import 'brushing_prefs.dart'; import 'main.dart' show supabase; import 'quiz/quiz1.dart'; import 'quiz/quiz_prefs.dart'; import 'screens/settings_screen.dart'; import 'screens/video_screen.dart'; +import 'watched_videos_prefs.dart'; import 'widgets/animated_nav_icon.dart'; import 'widgets/app_dialogs.dart'; import 'widgets/app_gradients.dart'; @@ -22,6 +24,28 @@ import 'widgets/tap_bounce.dart'; /// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números. final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$"); +/// Calcula a idade a partir de `birth_date` (novo campo). Para crianças +/// cadastradas antes desta mudança, que só têm a coluna legada `age`, usa +/// esse valor como fallback. +int? _childAge(Map child) { + final birthDateRaw = (child['birth_date'] ?? '').toString().trim(); + if (birthDateRaw.isNotEmpty) { + final birthDate = DateTime.tryParse(birthDateRaw); + if (birthDate != null) { + final now = DateTime.now(); + int age = now.year - birthDate.year; + if (now.month < birthDate.month || + (now.month == birthDate.month && now.day < birthDate.day)) { + age--; + } + return age; + } + } + final legacyAge = child['age']; + if (legacyAge is int) return legacyAge; + return int.tryParse((legacyAge ?? '').toString()); +} + class LoggedHomeScreen extends StatefulWidget { const LoggedHomeScreen({super.key}); @@ -47,6 +71,12 @@ class _LoggedHomeScreenState extends State int? _lastScore; int? _lastMaxScore; + int? _brushingWeekCount; + int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; + bool _brushingDailyLimitReached = false; + int? _watchedVideoCount; + VideoData? _continueVideo; + String _cachedUserName = 'Sem nome'; String? _cachedPhotoUrl; @@ -55,11 +85,49 @@ class _LoggedHomeScreenState extends State super.initState(); _loadQuizResult(); _loadInitialProfile(); + refreshStats(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _maybeStartPendingQuiz(); }); } + /// Recarrega os dados locais de escovagem e vídeos assistidos da criança + /// atualmente selecionada. Chamado ao trocar de criança, ao editar a meta + /// semanal e depois de registar uma escovagem ou um vídeo completo. + Future refreshStats() async { + final scope = (_selectedChildScopeId ?? '').trim(); + if (scope.isEmpty) { + if (!mounted) return; + setState(() { + _brushingWeekCount = null; + _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; + _brushingDailyLimitReached = false; + _watchedVideoCount = null; + _continueVideo = videoList.first; + }); + return; + } + + final weekCount = await BrushingPrefs.getWeekCount(scope); + final goal = await BrushingPrefs.getWeeklyGoal(scope); + final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope); + final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope); + final watchedIds = await WatchedVideosPrefs.getWatchedIds(scope); + final continueVideo = videoList.firstWhere( + (v) => !watchedIds.contains(v.id), + orElse: () => videoList.last, + ); + + if (!mounted) return; + setState(() { + _brushingWeekCount = weekCount; + _weeklyGoal = goal; + _brushingDailyLimitReached = dailyLimitReached; + _watchedVideoCount = watchedCount; + _continueVideo = continueVideo; + }); + } + Future _maybeStartPendingQuiz() async { try { final prefs = await SharedPreferences.getInstance(); @@ -133,6 +201,7 @@ class _LoggedHomeScreenState extends State } }); await _loadQuizResult(); + await refreshStats(); } catch (_) { // no-op } @@ -200,6 +269,7 @@ class _LoggedHomeScreenState extends State _selectedChildScopeId = scopeId; }); _loadQuizResult(); + refreshStats(); } void updateCachedPhoto(String url) { @@ -272,7 +342,7 @@ class _LoggedHomeScreenState extends State Positioned( left: 0, right: 0, - top: toolbarHeight + 18, + top: toolbarHeight + 34, child: Center( child: Text( (_selectedChildName ?? '').trim(), @@ -313,7 +383,7 @@ class _LoggedHomeScreenState extends State Positioned( left: 0, right: 0, - bottom: 6, + bottom: 4, child: Center( child: _RiskArcGauge(percent: percent), ), @@ -323,7 +393,11 @@ class _LoggedHomeScreenState extends State ), ), title: Align( - alignment: _index == 0 ? Alignment.topLeft : Alignment.center, + alignment: _index != 0 + ? Alignment.center + : (_selectedChildName ?? '').trim().isEmpty + ? Alignment.centerLeft + : Alignment.topLeft, child: _index == 0 ? Padding( padding: const EdgeInsets.only(left: 16, right: 10), @@ -454,6 +528,7 @@ class _LoggedHomeScreenState extends State _selectedChildScopeId = scopeId; }); _loadQuizResult(); + refreshStats(); }, ) : const SettingsBody(), @@ -643,6 +718,9 @@ class _InicioTab extends StatelessWidget { Widget build(BuildContext context) { final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); final selectedChildName = (state?._selectedChildName ?? '').trim(); + final scopeId = state?._selectedChildScopeId; + final featured = state?._continueVideo ?? videoList.first; + final hasWatchedAny = (state?._watchedVideoCount ?? 0) > 0; return Align( alignment: Alignment.topCenter, @@ -655,29 +733,56 @@ class _InicioTab extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (selectedChildName.isNotEmpty) ...[ + FadeSlideIn( + child: _HomeSectionLabel('Para $selectedChildName'), + ), + const SizedBox(height: 8), + ], FadeSlideIn( child: TapBounce( scale: 0.97, - child: _HeroQuizCard( - childName: selectedChildName, - onStartQuiz: () => _startQuiz(context), - ), + child: _HeroQuizCard(onStartQuiz: () => _startQuiz(context)), ), ), const SizedBox(height: 16), FadeSlideIn( - delay: const Duration(milliseconds: 90), - child: TapBounce( - scale: 0.97, - child: _VideoLibraryCard( - onOpenLibrary: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const VideoScreen(), - ), - ); - }, - ), + delay: const Duration(milliseconds: 70), + child: _StatsRow( + brushingCount: state?._brushingWeekCount, + weeklyGoal: state?._weeklyGoal ?? BrushingPrefs.defaultWeeklyGoal, + brushedToday: state?._brushingDailyLimitReached ?? false, + watchedCount: state?._watchedVideoCount, + onTapBrushing: () => _logBrushing(context, state, scopeId), + ), + ), + const SizedBox(height: 20), + FadeSlideIn( + delay: const Duration(milliseconds: 110), + child: const _HomeSectionLabel('Continuar onde parou'), + ), + const SizedBox(height: 8), + FadeSlideIn( + delay: const Duration(milliseconds: 130), + child: _ContinueWatchingRow( + video: featured, + hasWatchedAny: hasWatchedAny, + onTap: () async { + await showVideoPlayerDialog( + context, + featured, + scopeId: scopeId, + ); + await state?.refreshStats(); + }, + onViewAll: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoScreen(scopeId: scopeId), + ), + ); + await state?.refreshStats(); + }, ), ), const SizedBox(height: 16), @@ -688,6 +793,193 @@ class _InicioTab extends StatelessWidget { ), ); } + + Future _logBrushing( + BuildContext context, + _LoggedHomeScreenState? state, + String? scopeId, + ) async { + final scope = (scopeId ?? '').trim(); + if (scope.isEmpty) { + final uid = (supabase.auth.currentUser?.id ?? '').trim(); + if (uid.isEmpty) return; + await _requireFirstChild(context, uid); + return; + } + final messenger = ScaffoldMessenger.of(context); + if (!(await BrushingPrefs.canLogMore(scope))) { + messenger.showSnackBar( + const SnackBar( + content: Text('Já registou as ${BrushingPrefs.maxPerDay} escovagens de hoje!'), + ), + ); + return; + } + await BrushingPrefs.logToday(scope); + await state?.refreshStats(); + messenger.showSnackBar(const SnackBar(content: Text('Escovagem registada!'))); + } +} + +/// Pequeno rótulo maiúsculo discreto usado para separar as secções da Home +/// ("Para {nome}", "Novo episódio", "Continuar a aprender"). +class _HomeSectionLabel extends StatelessWidget { + const _HomeSectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + text.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: Colors.black.withValues(alpha: 0.45), + ), + ), + ); + } +} + +class _StatsRow extends StatelessWidget { + const _StatsRow({ + required this.brushingCount, + required this.weeklyGoal, + required this.brushedToday, + required this.watchedCount, + required this.onTapBrushing, + }); + + final int? brushingCount; + final int weeklyGoal; + final bool brushedToday; + final int? watchedCount; + final VoidCallback onTapBrushing; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: TapBounce( + scale: 0.97, + child: _StatCard( + icon: Icons.brush_rounded, + iconColor: const Color(0xFFFF55A7), + value: brushingCount == null + ? '--' + : '$brushingCount/$weeklyGoal', + label: 'Escovagens esta semana', + done: brushedToday, + onTap: onTapBrushing, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _StatCard( + icon: Icons.movie_filter_rounded, + iconColor: const Color(0xFF8E7CC3), + value: watchedCount == null ? '--' : '$watchedCount', + label: 'Episódios completos', + ), + ), + ], + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.icon, + required this.iconColor, + required this.value, + required this.label, + this.onTap, + this.done = false, + }); + + final IconData icon; + final Color iconColor; + final String value; + final String label; + final VoidCallback? onTap; + final bool done; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.10), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + if (done) + Positioned( + top: -4, + right: -4, + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Color(0xFF2F9E94), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + size: 11, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 20), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: Colors.black.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ), + ); + } } Future?> _createChildViaSheet( @@ -780,7 +1072,7 @@ Future?> _pickChildSheet( crossAxisAlignment: CrossAxisAlignment.stretch, children: children.map((c) { final name = (c['name'] ?? '').toString(); - final age = c['age']; + final age = _childAge(c); final label = age != null ? '$name • $age anos' : name; @@ -839,86 +1131,107 @@ Future?> _pickChildSheet( } class _HeroQuizCard extends StatelessWidget { - const _HeroQuizCard({required this.childName, required this.onStartQuiz}); + const _HeroQuizCard({required this.onStartQuiz}); - final String childName; final VoidCallback onStartQuiz; @override Widget build(BuildContext context) { - const Color teal = Color(0xFF2F9E94); - const Color pink = Color(0xFFFF55A7); - return Material( elevation: 12, - shadowColor: Colors.black.withValues(alpha: 0.18), + shadowColor: Colors.black.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(24), - color: Colors.white, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + child: Ink( + decoration: const BoxDecoration(gradient: kPinkHeroGradient), + child: Stack( children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: teal.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.medical_services_rounded, - color: teal, + Positioned( + right: -30, + bottom: -30, + child: IgnorePointer( + child: Opacity( + opacity: 0.14, + child: Container( + width: 140, + height: 140, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Avaliação de saúde oral', - style: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 16, - color: pink, - ), - ), - const SizedBox(height: 2), - Text( - childName.isNotEmpty - ? 'Para $childName' - : 'Responda o quiz e descubra como cuidar melhor do sorriso.', - style: TextStyle( - color: Colors.black.withValues(alpha: 0.62), - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ], + ), ), - const SizedBox(height: 16), - SizedBox( - height: 48, - width: double.infinity, - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: pink, - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 15, + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt_rounded, color: Colors.white, size: 14), + SizedBox(width: 4), + Text( + 'Avaliação', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ], + ), ), - ), - onPressed: onStartQuiz, - icon: const Icon(Icons.play_arrow_rounded), - label: const Text('Iniciar Quiz'), + const SizedBox(height: 12), + const Text( + 'Avaliação de saúde oral', + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 19, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Leva menos de 3 minutos a completar', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 48, + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFFF55A7), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + onPressed: onStartQuiz, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Iniciar Quiz'), + ), + ), + ], ), ), ], @@ -928,117 +1241,118 @@ class _HeroQuizCard extends StatelessWidget { } } -class _VideoLibraryCard extends StatelessWidget { - const _VideoLibraryCard({required this.onOpenLibrary}); +/// Linha compacta que mostra o próximo episódio por assistir (ou o último, +/// se já viu todos) para retomar o progresso de onde a criança parou. Um +/// segundo toque, no rodapé, abre a grelha completa de vídeos. Propositadamente +/// sem preview de vídeo real (sem [VideoThumbnail]/controllers) — evita a +/// contenção de decodificadores que já causou travamentos nesta app. +class _ContinueWatchingRow extends StatelessWidget { + const _ContinueWatchingRow({ + required this.video, + required this.hasWatchedAny, + required this.onTap, + required this.onViewAll, + }); - final VoidCallback onOpenLibrary; + final VideoData video; + final bool hasWatchedAny; + final VoidCallback onTap; + final VoidCallback onViewAll; @override Widget build(BuildContext context) { - final featured = videoList.firstWhere( - (v) => v.videoPath != null, - orElse: () => videoList.first, - ); - return Material( color: Colors.white, - borderRadius: BorderRadius.circular(24), - elevation: 12, - shadowColor: Colors.black.withValues(alpha: 0.18), - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onOpenLibrary, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClipRRect( + borderRadius: BorderRadius.circular(18), + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TapBounce( + scale: 0.98, + child: InkWell( borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), + top: Radius.circular(18), ), - child: SizedBox( - height: 160, - child: Stack( - fit: StackFit.expand, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( children: [ Container( - color: const Color(0xFF2F9E94).withValues(alpha: 0.14), - ), - VideoThumbnail( - video: featured, - borderRadius: 0, - iconSize: 54, - ), - IgnorePointer( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.35), - ], - stops: const [0.6, 1.0], - ), - ), + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF2F9E94), + borderRadius: BorderRadius.circular(13), ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Continuar onde parou', + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15, + color: Color(0xFFFF55A7), + ), + ), + const SizedBox(height: 2), + Text( + '${video.title} · ${hasWatchedAny ? "continue de onde parou" : "comece a assistir"}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black54, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF2F9E94), + size: 24, ), ], ), ), ), - Padding( - padding: const EdgeInsets.fromLTRB(18, 14, 14, 14), - child: Row( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: const Color(0xFF2F9E94).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.video_library_rounded, - color: Color(0xFF2F9E94), - size: 20, - ), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Vídeos Educativos', - style: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15, - color: Color(0xFFFF55A7), - ), - ), - SizedBox(height: 2), - Text( - 'Aprenda mais sobre saúde oral', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black54, - ), - ), - ], - ), - ), - const Icon( - Icons.chevron_right_rounded, + ), + const Divider(height: 1), + TapBounce( + scale: 0.98, + child: InkWell( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(18), + ), + onTap: onViewAll, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Text( + 'Ver todos os episódios', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w800, color: Color(0xFF2F9E94), - size: 24, ), - ], + ), ), ), - ], - ), + ), + ], ), ); } @@ -1281,6 +1595,75 @@ class _PerfilTabState extends State<_PerfilTab> { } } + Future _editWeeklyGoal( + BuildContext context, { + required String scopeId, + required String childName, + }) async { + final current = await BrushingPrefs.getWeeklyGoal(scopeId); + if (!context.mounted) return; + final controller = TextEditingController(text: current.toString()); + + final saved = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + backgroundColor: const Color(0xFFFFE6F1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + 'Meta semanal de $childName', + style: const TextStyle( + fontWeight: FontWeight.w900, + color: Color(0xFFFF55A7), + ), + ), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Escovagens por semana', + helperText: 'Entre 1 e 21 (até 3 por dia)', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancelar'), + ), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration(gradient: kGreenButtonGradient), + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + ), + onPressed: () { + final value = int.tryParse(controller.text.trim()); + if (value == null || value < 1 || value > 21) return; + Navigator.of(ctx).pop(value); + }, + child: const Text('Guardar'), + ), + ), + ), + ], + ); + }, + ); + + if (saved == null) return; + await BrushingPrefs.setWeeklyGoal(scopeId, saved); + if (!context.mounted) return; + context.findAncestorStateOfType<_LoggedHomeScreenState>()?.refreshStats(); + setState(() {}); + } + Future _addAnotherChild(BuildContext context, String uid) async { if (_addingChild) return; final messenger = ScaffoldMessenger.of(context); @@ -1609,7 +1992,7 @@ class _PerfilTabState extends State<_PerfilTab> { final c = entry.value; final childId = (c['id'] ?? '').toString().trim(); final childName = (c['name'] ?? '').toString().trim(); - final childAge = c['age']; + final childAge = _childAge(c); final childGender = (c['gender'] ?? '').toString().trim(); final scopeId = '${uid}_$childId'; @@ -1713,7 +2096,19 @@ class _PerfilTabState extends State<_PerfilTab> { ); }, ), - const SizedBox(width: 6), + IconButton( + onPressed: () => _editWeeklyGoal( + context, + scopeId: scopeId, + childName: title, + ), + icon: const Icon( + Icons.edit_outlined, + color: Color(0xFF2F9E94), + ), + tooltip: 'Meta semanal de escovagens', + visualDensity: VisualDensity.compact, + ), IconButton( onPressed: () => _confirmDeleteChild( context, @@ -1805,21 +2200,45 @@ class _AddChildSheet extends StatefulWidget { class _AddChildSheetState extends State<_AddChildSheet> { final _formKey = GlobalKey(); final _nameController = TextEditingController(); - final _ageController = TextEditingController(); + DateTime? _birthDate; String? _gender; + String? _birthDateError; @override void dispose() { _nameController.dispose(); - _ageController.dispose(); super.dispose(); } + Future _pickBirthDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _birthDate ?? DateTime(now.year - 5, now.month, now.day), + firstDate: DateTime(now.year - 17, now.month, now.day), + lastDate: DateTime(now.year - 1, now.month, now.day), + helpText: 'Data de nascimento', + cancelText: 'Cancelar', + confirmText: 'Confirmar', + ); + if (picked == null) return; + setState(() { + _birthDate = picked; + _birthDateError = null; + }); + } + void _submit() { - if (!(_formKey.currentState?.validate() ?? false)) return; + final formOk = _formKey.currentState?.validate() ?? false; + setState(() { + _birthDateError = _birthDate == null + ? 'Informe a data de nascimento' + : null; + }); + if (!formOk || _birthDate == null) return; Navigator.of(context).pop({ 'name': _nameController.text.trim(), - 'age': int.parse(_ageController.text.trim()), + 'birth_date': _birthDate!.toIso8601String().split('T').first, 'gender': (_gender ?? '').trim(), }); } @@ -1872,25 +2291,31 @@ class _AddChildSheetState extends State<_AddChildSheet> { return null; }, ), - TextFormField( - controller: _ageController, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.next, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(2), - ], - decoration: const InputDecoration(labelText: 'Idade'), - validator: (v) { - final raw = (v ?? '').trim(); - if (raw.isEmpty) return 'Informe a idade'; - final age = int.tryParse(raw); - if (age == null) return 'Idade inválida'; - if (age < 1 || age > 17) { - return 'Idade deve ser entre 1 e 17 anos'; - } - return null; - }, + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: _pickBirthDate, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Data de nascimento', + errorText: _birthDateError, + suffixIcon: const Icon( + Icons.calendar_today_rounded, + size: 18, + ), + ), + child: Text( + _birthDate == null + ? 'Selecione a data' + : '${_birthDate!.day.toString().padLeft(2, '0')}/' + '${_birthDate!.month.toString().padLeft(2, '0')}/' + '${_birthDate!.year}', + style: _birthDate == null + ? TextStyle( + color: Colors.black.withValues(alpha: 0.4), + ) + : null, + ), + ), ), DropdownButtonFormField( initialValue: _gender, diff --git a/lib/quiz/quiz1.dart b/lib/quiz/quiz1.dart index a9eaaa1..d6b068b 100644 --- a/lib/quiz/quiz1.dart +++ b/lib/quiz/quiz1.dart @@ -14,6 +14,8 @@ class Quiz1Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 1/25', + category: 'Avaliação facial', + categoryIcon: Icons.face_rounded, question: 'O rosto do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/2.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1 @@ -53,6 +55,8 @@ class Quiz2Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 2/25', + category: 'Avaliação facial', + categoryIcon: Icons.sentiment_neutral_rounded, question: 'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?', questionImagePaths: const ['assets/mockup_images/4.jpeg'], @@ -93,6 +97,8 @@ class Quiz3Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 3/25', + category: 'Avaliação facial', + categoryIcon: Icons.visibility_rounded, question: 'O seu filho/a tem olheiras semelhantes às desta imagem?', questionImagePaths: const ['assets/mockup_images/8.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3 @@ -132,6 +138,8 @@ class Quiz4Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 4/25', + category: 'Avaliação facial', + categoryIcon: Icons.face_rounded, question: 'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/6.jpeg'], @@ -172,6 +180,10 @@ class Quiz5Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 5/25', + category: 'Contagem de dentes', + categoryIcon: Icons.numbers_rounded, + fallbackIcon: Icons.numbers_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'Quantos dentes tem o seu filho/a em cima na boca?', answers: const [], currentScore: currentScore, @@ -195,6 +207,10 @@ class Quiz6Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 6/25', + category: 'Contagem de dentes', + categoryIcon: Icons.numbers_rounded, + fallbackIcon: Icons.numbers_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'Quantos dentes tem o seu filho/a em baixo na boca?', answers: const [], currentScore: currentScore, @@ -218,6 +234,8 @@ class Quiz7Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 7/25', + category: 'Avaliação facial', + categoryIcon: Icons.sentiment_neutral_rounded, question: 'A boca do seu filho/a se parece com a desta imagem?', questionImagePaths: const ['assets/mockup_images/14.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5 @@ -257,6 +275,8 @@ class Quiz8Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 8/25', + category: 'Avaliação facial', + categoryIcon: Icons.record_voice_over_rounded, question: 'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/17.png'], @@ -297,6 +317,10 @@ class Quiz9Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 9/25', + category: 'Saúde respiratória', + categoryIcon: Icons.medical_information_rounded, + fallbackIcon: Icons.medical_information_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'O seu filho/a tem problemas respiratórios diagnosticados?', answers: const [ QuizAnswer( @@ -311,6 +335,12 @@ class Quiz9Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -333,6 +363,10 @@ class Quiz10Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 10/25', + category: 'Respiração', + categoryIcon: Icons.air_rounded, + fallbackIcon: Icons.air_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a respira habitualmente pela boca?', answers: const [ QuizAnswer( @@ -347,6 +381,12 @@ class Quiz10Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -369,6 +409,10 @@ class Quiz11Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 11/25', + category: 'Sono', + categoryIcon: Icons.bedtime_rounded, + fallbackIcon: Icons.bedtime_rounded, + fallbackColor: const Color(0xFF8E7CC3), question: 'O seu filho/a ressona habitualmente durante a noite?', answers: const [ QuizAnswer( @@ -383,6 +427,12 @@ class Quiz11Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -405,6 +455,10 @@ class Quiz12Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 12/25', + category: 'Respiração', + categoryIcon: Icons.sick_rounded, + fallbackIcon: Icons.sick_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'O seu filho/a sente habitualmente o nariz "tapado"?', answers: const [ QuizAnswer( @@ -419,6 +473,12 @@ class Quiz12Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -441,6 +501,10 @@ class Quiz13Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 13/25', + category: 'Sono', + categoryIcon: Icons.nights_stay_rounded, + fallbackIcon: Icons.nights_stay_rounded, + fallbackColor: const Color(0xFF8E7CC3), question: 'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?', answers: const [ @@ -457,6 +521,12 @@ class Quiz13Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -479,6 +549,10 @@ class Quiz14Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 14/25', + category: 'Hábitos noturnos', + categoryIcon: Icons.nights_stay_rounded, + fallbackIcon: Icons.nights_stay_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a range os dentes com frequência?', answers: const [ QuizAnswer( @@ -493,6 +567,12 @@ class Quiz14Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -515,6 +595,10 @@ class Quiz15Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 15/25', + category: 'Saúde geral', + categoryIcon: Icons.local_florist_rounded, + fallbackIcon: Icons.local_florist_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'O seu filho/a habitualmente tem alergias sazonais?', answers: const [ QuizAnswer( @@ -529,6 +613,12 @@ class Quiz15Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -551,6 +641,10 @@ class Quiz16Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 16/25', + category: 'Sono', + categoryIcon: Icons.water_drop_rounded, + fallbackIcon: Icons.water_drop_rounded, + fallbackColor: const Color(0xFF8E7CC3), question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?', answers: const [ QuizAnswer( @@ -565,6 +659,12 @@ class Quiz16Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -587,6 +687,10 @@ class Quiz17Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 17/25', + category: 'Saúde geral', + categoryIcon: Icons.hearing_rounded, + fallbackIcon: Icons.hearing_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a teve ou costuma ter com frequência otites?', answers: const [ QuizAnswer( @@ -601,6 +705,12 @@ class Quiz17Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -623,6 +733,10 @@ class Quiz18Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 18/25', + category: 'Saúde geral', + categoryIcon: Icons.healing_rounded, + fallbackIcon: Icons.healing_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?', answers: const [ QuizAnswer( @@ -637,6 +751,12 @@ class Quiz18Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -659,6 +779,10 @@ class Quiz19Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 19/25', + category: 'Saúde respiratória', + categoryIcon: Icons.air_rounded, + fallbackIcon: Icons.air_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a teve ou costuma ter com frequência bronquiolites?', answers: const [ @@ -674,6 +798,12 @@ class Quiz19Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -696,6 +826,10 @@ class Quiz20Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 20/25', + category: 'Hábitos alimentares', + categoryIcon: Icons.restaurant_rounded, + fallbackIcon: Icons.restaurant_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'O seu filho/a apresenta dificuldades a mastigar?', answers: const [ QuizAnswer( @@ -710,6 +844,12 @@ class Quiz20Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -732,6 +872,10 @@ class Quiz21Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 21/25', + category: 'Hábitos alimentares', + categoryIcon: Icons.schedule_rounded, + fallbackIcon: Icons.schedule_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a habitualmente é lento a comer?', answers: const [ QuizAnswer( @@ -746,6 +890,12 @@ class Quiz21Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -768,6 +918,10 @@ class Quiz22Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 22/25', + category: 'Hábitos alimentares', + categoryIcon: Icons.restaurant_menu_rounded, + fallbackIcon: Icons.restaurant_menu_rounded, + fallbackColor: const Color(0xFF8E7CC3), question: 'O seu filho/a habitualmente prefere comer alimentos moles?', answers: const [ QuizAnswer( @@ -782,6 +936,12 @@ class Quiz22Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -804,6 +964,10 @@ class Quiz23Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 23/25', + category: 'Hábitos alimentares', + categoryIcon: Icons.local_drink_rounded, + fallbackIcon: Icons.local_drink_rounded, + fallbackColor: const Color(0xFFFF55A7), question: 'Em bebé apenas foi alimentado por biberão?', answers: const [ QuizAnswer( @@ -818,6 +982,12 @@ class Quiz23Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -840,6 +1010,10 @@ class Quiz24Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 24/25', + category: 'Hábitos orais', + categoryIcon: Icons.child_care_rounded, + fallbackIcon: Icons.child_care_rounded, + fallbackColor: const Color(0xFF2F9E94), question: 'O seu filho/a usa ou usou chupeta com frequência?', answers: const [ QuizAnswer( @@ -854,6 +1028,12 @@ class Quiz24Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( @@ -876,6 +1056,10 @@ class Quiz25Screen extends StatelessWidget { Widget build(BuildContext context) { return QuizQuestionScreen( title: 'Quiz 25/25', + category: 'Hábitos orais', + categoryIcon: Icons.back_hand_rounded, + fallbackIcon: Icons.back_hand_rounded, + fallbackColor: const Color(0xFF8E7CC3), question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?', answers: const [ QuizAnswer( @@ -890,6 +1074,12 @@ class Quiz25Screen extends StatelessWidget { weight: 1, value: 'nao', ), + QuizAnswer( + title: 'Não sei', + description: 'Não tenho a certeza', + weight: 1, + value: 'nao_sei', + ), ], currentScore: currentScore, nextRoute: (context, nextScore) => MaterialPageRoute( diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index 85a5fb0..c218058 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -45,6 +45,10 @@ class QuizQuestionScreen extends StatefulWidget { this.suggestedVideoPath, this.suggestedYoutubeId, this.suggestedVideoTitle, + this.category, + this.categoryIcon, + this.fallbackIcon, + this.fallbackColor, }); final String title; @@ -61,6 +65,16 @@ class QuizQuestionScreen extends StatefulWidget { final String? suggestedYoutubeId; final String? suggestedVideoTitle; + /// Rótulo pequeno do tema da pergunta (ex.: "Avaliação facial"), mostrado + /// num badge acima da imagem/pergunta. + final String? category; + final IconData? categoryIcon; + + /// Usados só quando [questionImagePaths] está vazio: em vez de o bloco de + /// imagem colapsar, mostra-se um bloco colorido com este ícone. + final IconData? fallbackIcon; + final Color? fallbackColor; + @override State createState() => _QuizQuestionScreenState(); } @@ -74,8 +88,24 @@ class _QuizQuestionScreenState extends State { int? _selected; TextEditingController? _numberController; int? _numberValue; + bool _numberDontKnow = false; bool _navigating = false; + /// O título ainda chega como texto livre ("Quiz 3/25") em vez de números + /// separados — extraímos daqui em vez de acrescentar mais dois parâmetros + /// obrigatórios a cada uma das 25 telas de pergunta. + static final RegExp _progressPattern = RegExp(r'(\d+)\s*/\s*(\d+)'); + + int get _questionIndex { + final match = _progressPattern.firstMatch(widget.title); + return int.tryParse(match?.group(1) ?? '') ?? 1; + } + + int get _totalQuestions { + final match = _progressPattern.firstMatch(widget.title); + return int.tryParse(match?.group(2) ?? '') ?? 1; + } + @override void initState() { super.initState(); @@ -96,10 +126,11 @@ class _QuizQuestionScreenState extends State { bool canProceed = _selected != null && !_navigating; if (widget.answerType == QuizAnswerType.number) { canProceed = - _numberValue != null && - _numberValue! >= 0 && - _numberValue! <= _maxTeethCount && - !_navigating; + !_navigating && + (_numberDontKnow || + (_numberValue != null && + _numberValue! >= 0 && + _numberValue! <= _maxTeethCount)); } final bool hasSuggestedVideo = @@ -146,49 +177,103 @@ class _QuizQuestionScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - height: 44, - child: Stack( - alignment: Alignment.center, + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( children: [ - Text( - widget.title, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.55), - fontWeight: FontWeight.w800, - ), - ), if (widget.showBackButton) - Positioned( - left: 4, - child: TapBounce( - scale: 0.9, - child: Material( - color: Colors.white.withValues(alpha: 0.85), - shape: const CircleBorder(), - elevation: 4, - shadowColor: Colors.black.withValues( - alpha: 0.15, - ), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => Navigator.of(context).maybePop(), - child: const Padding( - padding: EdgeInsets.all(10), - child: Icon( - Icons.arrow_back_rounded, - color: Color(0xFF2F9E94), - size: 22, - ), + TapBounce( + scale: 0.9, + child: Material( + color: Colors.white.withValues(alpha: 0.85), + shape: const CircleBorder(), + elevation: 4, + shadowColor: Colors.black.withValues(alpha: 0.15), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => Navigator.of(context).maybePop(), + child: const Padding( + padding: EdgeInsets.all(9), + child: Icon( + Icons.arrow_back_rounded, + color: Color(0xFF2F9E94), + size: 20, ), ), ), ), + ) + else + const SizedBox(width: 38), + const SizedBox(width: 12), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: (_questionIndex / _totalQuestions).clamp( + 0.0, + 1.0, + ), + minHeight: 8, + backgroundColor: const Color( + 0xFFFF55A7, + ).withValues(alpha: 0.15), + valueColor: const AlwaysStoppedAnimation( + Color(0xFFFF55A7), + ), + ), ), + ), + const SizedBox(width: 10), + Text( + '$_questionIndex/$_totalQuestions', + style: TextStyle( + color: Colors.black.withValues(alpha: 0.55), + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), ], ), ), + if ((widget.category ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.categoryIcon != null) ...[ + Icon( + widget.categoryIcon, + size: 15, + color: const Color(0xFF2F9E94), + ), + const SizedBox(width: 6), + ], + Text( + widget.category!, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 12, + color: Color(0xFF2F9E94), + ), + ), + ], + ), + ), + ), + ), Expanded( child: LayoutBuilder( builder: (context, constraints) { @@ -224,16 +309,21 @@ class _QuizQuestionScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget - .questionImagePaths - .isNotEmpty) ...[ - const SizedBox(height: 6), - _QuestionReferenceImages( - paths: - widget.questionImagePaths, - ), - const SizedBox(height: 10), - ], + const SizedBox(height: 6), + widget.questionImagePaths.isNotEmpty + ? _QuestionReferenceImages( + paths: widget + .questionImagePaths, + ) + : _FallbackIconBlock( + icon: + widget.fallbackIcon ?? + Icons.info_outline_rounded, + color: + widget.fallbackColor ?? + const Color(0xFF2F9E94), + ), + const SizedBox(height: 10), if (hasSuggestedVideo) ...[ TextButton.icon( onPressed: () => @@ -324,15 +414,38 @@ class _QuizQuestionScreenState extends State { delay: Duration( milliseconds: 60 * i, ), - child: _QuizAnswerTile( - answer: - widget.answers[i], - selected: - _selected == i, - onTap: () => setState( - () => _selected = i, - ), - ), + child: + widget.answerType == + QuizAnswerType + .yesNo && + widget + .answers[i] + .imagePath == + null + ? _QuizAnswerPill( + answer: widget + .answers[i], + selected: + _selected == i, + onTap: () => + setState( + () => + _selected = + i, + ), + ) + : _QuizAnswerTile( + answer: widget + .answers[i], + selected: + _selected == i, + onTap: () => + setState( + () => + _selected = + i, + ), + ), ), ], ], @@ -357,7 +470,7 @@ class _QuizQuestionScreenState extends State { FilledButton.styleFrom( backgroundColor: const Color( - 0xFF2F9E94, + 0xFFFF55A7, ), foregroundColor: Colors.white, @@ -426,8 +539,10 @@ class _QuizQuestionScreenState extends State { nextScore = widget .currentScore + - (_numberValue ?? - 0); + (_numberDontKnow + ? 0 + : (_numberValue ?? + 0)); } else { final picked = widget .answers[_selected!]; @@ -474,44 +589,21 @@ class _QuizQuestionScreenState extends State { ), ), ), - const SizedBox(height: 10), - TapBounce( - child: SizedBox( - width: size.width * 0.62, - height: 42, - child: OutlinedButton( - style: - OutlinedButton.styleFrom( - foregroundColor: - const Color( - 0xFF2F9E94, - ), - side: const BorderSide( - color: Color( - 0xFF2F9E94, - ), - width: 1.3, - ), - shape: - const StadiumBorder(), - textStyle: - const TextStyle( - fontWeight: - FontWeight - .w900, - ), - ), - onPressed: () => - Navigator.of( - context, - ).popUntil( - (route) => - route.isFirst, - ), - child: const Text( - 'Voltar para homepage', - ), + const SizedBox(height: 6), + TextButton( + style: TextButton.styleFrom( + foregroundColor: const Color( + 0xFF2F9E94, ), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + ), + ), + onPressed: () => Navigator.of( + context, + ).popUntil((route) => route.isFirst), + child: const Text( + 'Voltar para homepage', ), ), ], @@ -540,54 +632,62 @@ class _QuizQuestionScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 150, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.70), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.black.withValues(alpha: 0.12), - width: 1.0, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 18, - offset: const Offset(0, 10), + Opacity( + opacity: _numberDontKnow ? 0.4 : 1, + child: IgnorePointer( + ignoring: _numberDontKnow, + child: Container( + width: 150, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.70), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.black.withValues(alpha: 0.12), + width: 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], ), - ], - ), - child: TextField( - controller: _numberController, - keyboardType: TextInputType.number, - textAlign: TextAlign.center, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(2), - ], - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w900, - color: Color(0xFF2F9E94), - ), - decoration: const InputDecoration( - border: InputBorder.none, - hintText: '0', - hintStyle: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w900, - color: Colors.grey, + child: TextField( + controller: _numberController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + color: Color(0xFF2F9E94), + ), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: '0', + hintStyle: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + color: Colors.grey, + ), + contentPadding: EdgeInsets.symmetric(vertical: 20), + ), + onChanged: (value) { + setState(() { + _numberValue = int.tryParse(value); + }); + }, ), - contentPadding: EdgeInsets.symmetric(vertical: 20), ), - onChanged: (value) { - setState(() { - _numberValue = int.tryParse(value); - }); - }, ), ), - if (_numberValue != null && _numberValue! > _maxTeethCount) ...[ + if (_numberValue != null && + _numberValue! > _maxTeethCount && + !_numberDontKnow) ...[ const SizedBox(height: 10), Text( 'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.', @@ -599,6 +699,62 @@ class _QuizQuestionScreenState extends State { ), ), ], + const SizedBox(height: 14), + TapBounce( + scale: 0.97, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () { + setState(() { + _numberDontKnow = !_numberDontKnow; + if (_numberDontKnow) { + _numberController?.clear(); + _numberValue = null; + } + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: _numberDontKnow + ? const Color(0xFF2F9E94) + : Colors.white.withValues(alpha: 0.70), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: _numberDontKnow + ? const Color(0xFF2F9E94) + : Colors.black.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.help_outline_rounded, + size: 16, + color: _numberDontKnow + ? Colors.white + : Colors.black.withValues(alpha: 0.55), + ), + const SizedBox(width: 6), + Text( + 'Não sei', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 13, + color: _numberDontKnow + ? Colors.white + : Colors.black.withValues(alpha: 0.55), + ), + ), + ], + ), + ), + ), + ), ], ), ); @@ -745,11 +901,38 @@ class _QuestionReferenceImages extends StatelessWidget { borderRadius: BorderRadius.circular(14), child: AspectRatio( aspectRatio: 16 / 9, - child: Image.asset( - paths.first, - fit: BoxFit.cover, - cacheWidth: 800, - errorBuilder: (context, error, stackTrace) => _placeholder(), + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset( + paths.first, + fit: BoxFit.cover, + cacheWidth: 800, + errorBuilder: (context, error, stackTrace) => _placeholder(), + ), + Positioned( + left: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'Imagem de referência', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 10.5, + ), + ), + ), + ), + ], ), ), ); @@ -787,3 +970,150 @@ class _QuestionReferenceImages extends StatelessWidget { ); } } + +/// Mostrado quando a pergunta não tem imagem de referência — em vez de o +/// bloco colapsar (como acontecia antes), mostra um pequeno ícone colorido +/// relevante ao tema, sem ocupar a largura toda (esse destaque é reservado +/// para as perguntas que realmente têm imagem). +class _FallbackIconBlock extends StatelessWidget { + const _FallbackIconBlock({required this.icon, required this.color}); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(18), + ), + child: Icon(icon, size: 32, color: Colors.white), + ), + ); + } +} + +/// Resposta Sim/Não em formato de pílula horizontal: círculo colorido + +/// texto + indicador circular à direita que preenche quando selecionado. +class _QuizAnswerPill extends StatelessWidget { + const _QuizAnswerPill({ + required this.answer, + required this.selected, + required this.onTap, + }); + + final QuizAnswer answer; + final bool selected; + final VoidCallback onTap; + + bool get _isYes => (answer.value ?? '').trim().toLowerCase() == 'sim'; + bool get _isNo => (answer.value ?? '').trim().toLowerCase() == 'nao'; + + @override + Widget build(BuildContext context) { + final accent = _isYes + ? const Color(0xFF2F9E94) + : _isNo + ? const Color(0xFFFF55A7) + : Colors.black.withValues(alpha: 0.35); + final borderColor = selected + ? const Color(0xFF2F9E94) + : Colors.black.withValues(alpha: 0.10); + + return TapBounce( + scale: 0.97, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.92) + : Colors.white.withValues(alpha: 0.70), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: onTap, + splashFactory: InkSparkle.splashFactory, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + ), + child: Icon( + _isYes + ? Icons.check_rounded + : _isNo + ? Icons.close_rounded + : Icons.help_outline_rounded, + color: Colors.white, + size: 17, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + answer.title, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + color: Color(0xFF2F9E94), + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? const Color(0xFF2F9E94) + : Colors.transparent, + border: Border.all( + color: selected + ? const Color(0xFF2F9E94) + : Colors.black.withValues(alpha: 0.25), + width: 1.6, + ), + ), + child: selected + ? const Icon( + Icons.check_rounded, + size: 14, + color: Colors.white, + ) + : null, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d804901..3e5648e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -33,34 +33,29 @@ class _SettingsBodyState extends State { context, title: 'Apagar dados da conta', message: - 'Isso remove permanentemente seu perfil, crianças cadastradas e ' - 'fotos. Essa ação não pode ser desfeita. Deseja continuar?', + 'Isso remove permanentemente a sua conta, perfil, crianças ' + 'cadastradas e fotos — incluindo o login, permitindo criar uma ' + 'nova conta com o mesmo e-mail depois. Essa ação não pode ser ' + 'desfeita. Deseja continuar?', confirmLabel: 'Apagar', confirmColor: _accentPink, ); if (confirmed != true) return; - final uid = supabase.auth.currentUser?.id; - if (uid == null) return; - setState(() => _deletingAccount = true); try { - await supabase.from('children').delete().eq('owner_id', uid); - - // O Supabase não avisa quando uma política de RLS bloqueia silenciosamente - // uma operação: sem `.select()` para devolver as linhas apagadas não há - // como distinguir "0 linhas existiam" de "sem permissão para apagar". - final deletedProfile = await supabase - .from('profiles') - .delete() - .eq('id', uid) - .select('id'); - - if (deletedProfile.isEmpty) { + // A remoção de `auth.users` exige a service role key, que o app nunca + // deve carregar — por isso corre numa Edge Function (server-side); ver + // supabase/functions/delete-account. Sem isto, apagar só as linhas de + // `profiles`/`children` deixava o e-mail "ocupado" no Supabase Auth, + // impedindo criar uma nova conta com o mesmo e-mail. + final response = await supabase.functions.invoke('delete-account'); + final data = response.data; + final errorMessage = (data is Map) ? data['error']?.toString() : null; + if (response.status != 200 || errorMessage != null) { throw StateError( - 'A base de dados recusou apagar o perfil (sem política de RLS ' - 'para DELETE). Os dados não foram removidos.', + errorMessage ?? 'Erro ao apagar conta (status ${response.status})', ); } diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index 0a837c7..0933207 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -7,6 +7,7 @@ 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/tap_bounce.dart'; @@ -97,19 +98,19 @@ final List videoList = [ id: 11, title: 'Episódio 11', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_11.mp4', + youtubeId: '6sYoBUjks_I', ), VideoData( id: 12, title: 'Episódio 12', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_12.mp4', + youtubeId: 'eznKrErQbHo', ), VideoData( id: 13, title: 'Episódio 13', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_13.mp4', + youtubeId: 'VO9CNqHRdeM', ), ]; @@ -156,7 +157,20 @@ void _evictAllVideoControllers() { } } -Future showVideoPlayerDialog(BuildContext context, VideoData video) { +/// Regista o episódio como assistido (localmente, por criança). Chamado +/// quando um player deteta que o vídeo chegou ao fim — sem [scopeId] (nenhuma +/// criança selecionada) não há onde guardar, por isso não faz nada. +void markVideoWatched(String? scopeId, int videoId) { + final scope = (scopeId ?? '').trim(); + if (scope.isEmpty) return; + WatchedVideosPrefs.markWatched(scope, videoId); +} + +Future showVideoPlayerDialog( + BuildContext context, + VideoData video, { + String? scopeId, +}) { if (video.youtubeId != null) { if (video.youtubeId!.isEmpty) { ScaffoldMessenger.of( @@ -165,17 +179,23 @@ Future showVideoPlayerDialog(BuildContext context, VideoData video) { return Future.value(); } return Navigator.of(context).push( - MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)), + MaterialPageRoute( + builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId), + ), ); } return showDialog( context: context, - builder: (context) => _VideoPlayerDialog(video: video), + builder: (context) => _VideoPlayerDialog(video: video, scopeId: scopeId), ); } class VideoScreen extends StatefulWidget { - const VideoScreen({super.key}); + const VideoScreen({super.key, this.scopeId}); + + /// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para + /// guardar localmente quais episódios ela já assistiu até ao fim. + final String? scopeId; static const Color _teal = Color(0xFF2F9E94); static const Color _accentPink = Color(0xFFFF55A7); @@ -329,6 +349,7 @@ class _VideoScreenState extends State { ), child: _VideoButton( video: _filteredVideos[index], + scopeId: widget.scopeId, ), ); }, @@ -509,9 +530,10 @@ class _VideoThumbnailState extends State { } class _VideoButton extends StatelessWidget { - const _VideoButton({required this.video}); + const _VideoButton({required this.video, this.scopeId}); final VideoData video; + final String? scopeId; void _showVideoPlayer(BuildContext context, VideoData video) { if (video.youtubeId == null) { @@ -519,7 +541,7 @@ class _VideoButton extends StatelessWidget { // em dialog, que precisa dos seus próprios decoders de vídeo/áudio. _evictAllVideoControllers(); } - showVideoPlayerDialog(context, video); + showVideoPlayerDialog(context, video, scopeId: scopeId); } @override @@ -588,9 +610,10 @@ class _VideoButton extends StatelessWidget { /// a proporção 16:9 do YouTube e sobrar espaço vazio quando o ecrã tem uma /// proporção mais larga que 16:9 (ex.: a maioria dos telemóveis atuais). class _YoutubePlayerPage extends StatefulWidget { - const _YoutubePlayerPage({required this.video}); + const _YoutubePlayerPage({required this.video, this.scopeId}); final VideoData video; + final String? scopeId; @override State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState(); @@ -599,6 +622,7 @@ class _YoutubePlayerPage extends StatefulWidget { class _YoutubePlayerPageState extends State<_YoutubePlayerPage> with WidgetsBindingObserver { late final YoutubePlayerController _controller; + bool _markedWatched = false; @override void initState() { @@ -607,9 +631,18 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> initialVideoId: widget.video.youtubeId!, flags: const YoutubePlayerFlags(autoPlay: true, mute: false), ); + _controller.addListener(_onControllerValueChanged); WidgetsBinding.instance.addObserver(this); } + void _onControllerValueChanged() { + if (_markedWatched) return; + if (_controller.value.playerState == PlayerState.ended) { + _markedWatched = true; + markVideoWatched(widget.scopeId, widget.video.id); + } + } + @override void didChangeMetrics() { final isLandscape = @@ -628,6 +661,7 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage> void dispose() { WidgetsBinding.instance.removeObserver(this); SystemChrome.restoreSystemUIOverlays(); + _controller.removeListener(_onControllerValueChanged); _controller.dispose(); super.dispose(); } @@ -716,9 +750,10 @@ class _CoverYoutubePlayer extends StatelessWidget { } class _VideoPlayerDialog extends StatefulWidget { - const _VideoPlayerDialog({required this.video}); + const _VideoPlayerDialog({required this.video, this.scopeId}); final VideoData video; + final String? scopeId; @override State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState(); @@ -727,6 +762,7 @@ class _VideoPlayerDialog extends StatefulWidget { class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { late VideoPlayerController _controller; bool _isInitialized = false; + bool _markedWatched = false; @override void initState() { @@ -738,6 +774,7 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { _controller = VideoPlayerController.asset(widget.video.videoPath!); try { await _controller.initialize(); + _controller.addListener(_onControllerValueChanged); if (mounted) { setState(() { _isInitialized = true; @@ -755,8 +792,19 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { } } + void _onControllerValueChanged() { + if (_markedWatched) return; + final value = _controller.value; + if (!value.isInitialized || value.duration == Duration.zero) return; + if (value.position >= value.duration - const Duration(milliseconds: 300)) { + _markedWatched = true; + markVideoWatched(widget.scopeId, widget.video.id); + } + } + @override void dispose() { + _controller.removeListener(_onControllerValueChanged); _controller.dispose(); super.dispose(); } @@ -802,6 +850,8 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> { _VideoControls( controller: _controller, videoPath: widget.video.videoPath!, + videoId: widget.video.id, + scopeId: widget.scopeId, onClose: () => Navigator.of(context).pop(), ), ], @@ -817,11 +867,15 @@ class _VideoControls extends StatefulWidget { const _VideoControls({ required this.controller, required this.videoPath, + required this.videoId, required this.onClose, + this.scopeId, }); final VideoPlayerController controller; final String videoPath; + final int videoId; + final String? scopeId; final VoidCallback onClose; @override @@ -926,6 +980,8 @@ class _VideoControlsState extends State<_VideoControls> { MaterialPageRoute( builder: (context) => _FullscreenVideoPlayer( videoPath: widget.videoPath, + videoId: widget.videoId, + scopeId: widget.scopeId, ), fullscreenDialog: true, ), @@ -949,9 +1005,15 @@ class _VideoControlsState extends State<_VideoControls> { } class _FullscreenVideoPlayer extends StatefulWidget { - const _FullscreenVideoPlayer({required this.videoPath}); + const _FullscreenVideoPlayer({ + required this.videoPath, + required this.videoId, + this.scopeId, + }); final String videoPath; + final int videoId; + final String? scopeId; @override State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState(); @@ -960,6 +1022,7 @@ class _FullscreenVideoPlayer extends StatefulWidget { class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { late VideoPlayerController _controller; bool _isInitialized = false; + bool _markedWatched = false; @override void initState() { @@ -994,6 +1057,16 @@ class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { } void _onControllerUpdate() { + if (!_markedWatched) { + final value = _controller.value; + if (value.isInitialized && + value.duration != Duration.zero && + value.position >= + value.duration - const Duration(milliseconds: 300)) { + _markedWatched = true; + markVideoWatched(widget.scopeId, widget.videoId); + } + } if (mounted) { setState(() {}); } diff --git a/lib/watched_videos_prefs.dart b/lib/watched_videos_prefs.dart new file mode 100644 index 0000000..151e7dc --- /dev/null +++ b/lib/watched_videos_prefs.dart @@ -0,0 +1,36 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Registo local (por criança) de episódios assistidos até ao fim. +/// Segue o mesmo padrão de [QuizPrefs]: chaves com sufixo `_scopeId`, +/// guardadas via [SharedPreferences], sem qualquer chamada ao Supabase. +class WatchedVideosPrefs { + static const String _kWatchedKey = 'watched_videos'; + + static String _key(String scopeId) => '${_kWatchedKey}_$scopeId'; + + static Future markWatched(String scopeId, int videoId) async { + final prefs = await SharedPreferences.getInstance(); + final key = _key(scopeId); + final ids = (prefs.getStringList(key) ?? []).toSet(); + ids.add(videoId.toString()); + await prefs.setStringList(key, ids.toList()); + } + + static Future isWatched(String scopeId, int videoId) async { + final prefs = await SharedPreferences.getInstance(); + final ids = prefs.getStringList(_key(scopeId)) ?? const []; + return ids.contains(videoId.toString()); + } + + static Future getWatchedCount(String scopeId) async { + final prefs = await SharedPreferences.getInstance(); + final ids = prefs.getStringList(_key(scopeId)) ?? const []; + return ids.length; + } + + static Future> getWatchedIds(String scopeId) async { + final prefs = await SharedPreferences.getInstance(); + final ids = prefs.getStringList(_key(scopeId)) ?? const []; + return ids.map((e) => int.tryParse(e) ?? -1).where((e) => e >= 0).toSet(); + } +} diff --git a/lib/widgets/app_gradients.dart b/lib/widgets/app_gradients.dart index c5e0029..40351d9 100644 --- a/lib/widgets/app_gradients.dart +++ b/lib/widgets/app_gradients.dart @@ -14,3 +14,10 @@ const LinearGradient kGreenButtonGradient = LinearGradient( end: Alignment.centerRight, colors: [Color(0xFF2F9E94), Color(0xFF6BB79F)], ); + +/// Gradiente rosa vivo do card do quiz na Home. +const LinearGradient kPinkHeroGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF55A7), Color(0xFFE83E93)], +); diff --git a/pubspec.yaml b/pubspec.yaml index f3177ff..c4c15be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,9 +71,6 @@ flutter: - lottie/ - assets/Check-theeth.png - assets/mockup_images/ - - assets/videos/episodio_11.mp4 - - assets/videos/episodio_12.mp4 - - assets/videos/episodio_13.mp4 flutter_launcher_icons: android: true diff --git a/supabase/.temp/linked-project.json b/supabase/.temp/linked-project.json new file mode 100644 index 0000000..bed07b7 --- /dev/null +++ b/supabase/.temp/linked-project.json @@ -0,0 +1 @@ +{"ref":"mannjismlhlwaqqqnvog","name":"Check_Teeth_Kids","organization_id":"huurggiiridujjbkbayo","organization_slug":"huurggiiridujjbkbayo"} \ No newline at end of file diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..2c0d081 --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,65 @@ +// Edge Function: apaga TODA a conta do utilizador autenticado — linhas em +// `children`/`profiles` e o próprio registo em `auth.users`. Isto é o que +// falta para permitir criar uma nova conta com o mesmo e-mail depois de +// "Apagar dados da conta": o app (client) nunca teve a service role key para +// poder chamar `auth.admin.deleteUser`, por isso essa etapa faltava e o +// e-mail continuava "ocupado" no Supabase Auth. +// +// Deploy (uma vez, com a Supabase CLI já autenticada): +// supabase functions deploy delete-account --project-ref mannjismlhlwaqqqnvog +// +// SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY já ficam disponíveis +// automaticamente dentro de toda Edge Function — não é preciso configurar +// nenhum secret manualmente. + +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!; +const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + +Deno.serve(async (req) => { + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const authHeader = req.headers.get('Authorization') ?? ''; + const jwt = authHeader.replace('Bearer ', '').trim(); + if (!jwt) { + return new Response(JSON.stringify({ error: 'Sessão em falta' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY); + + // Valida o JWT do chamador e identifica o uid — nunca confiar num uid vindo + // do corpo do pedido, sob pena de qualquer pessoa poder apagar outra conta. + const { data: userData, error: userError } = await admin.auth.getUser(jwt); + if (userError || !userData?.user) { + return new Response(JSON.stringify({ error: 'Sessão inválida' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const uid = userData.user.id; + + await admin.from('children').delete().eq('owner_id', uid); + await admin.from('profiles').delete().eq('id', uid); + + const { error: deleteUserError } = await admin.auth.admin.deleteUser(uid); + if (deleteUserError) { + return new Response(JSON.stringify({ error: deleteUserError.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +});