Munça da UI de inicio | escovagem semanal adicionada | quantidade de videos assistidos | todos os videos na nuvem | possibilidade de continuar o video de onde parou
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
84
lib/brushing_prefs.dart
Normal file
84
lib/brushing_prefs.dart
Normal file
@@ -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<List<DateTime>> _getEntries(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const [];
|
||||
return raw.map(DateTime.tryParse).whereType<DateTime>().toList();
|
||||
}
|
||||
|
||||
static Future<int> getWeeklyGoal(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_key(_kGoalKey, scopeId)) ?? defaultWeeklyGoal;
|
||||
}
|
||||
|
||||
static Future<void> setWeeklyGoal(String scopeId, int goal) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_key(_kGoalKey, scopeId), goal);
|
||||
}
|
||||
|
||||
static Future<int> 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<bool> 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<void> logToday(String scopeId) async {
|
||||
if (!(await canLogMore(scopeId))) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = _key(_kDatesKey, scopeId);
|
||||
final list = prefs.getStringList(key) ?? <String>[];
|
||||
list.add(DateTime.now().toIso8601String());
|
||||
await prefs.setStringList(key, list);
|
||||
}
|
||||
|
||||
/// Já atingiu o limite diário de [maxPerDay] escovagens hoje?
|
||||
static Future<bool> hasReachedDailyLimit(String scopeId) async {
|
||||
return (await getTodayCount(scopeId)) >= maxPerDay;
|
||||
}
|
||||
|
||||
/// Conta quantas escovagens foram registadas na semana atual
|
||||
/// (segunda-feira a domingo).
|
||||
static Future<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<LoggedHomeScreen>
|
||||
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<LoggedHomeScreen>
|
||||
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<void> 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<void> _maybeStartPendingQuiz() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -133,6 +201,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
}
|
||||
});
|
||||
await _loadQuizResult();
|
||||
await refreshStats();
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
@@ -200,6 +269,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
_selectedChildScopeId = scopeId;
|
||||
});
|
||||
_loadQuizResult();
|
||||
refreshStats();
|
||||
}
|
||||
|
||||
void updateCachedPhoto(String url) {
|
||||
@@ -272,7 +342,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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<LoggedHomeScreen>
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 6,
|
||||
bottom: 4,
|
||||
child: Center(
|
||||
child: _RiskArcGauge(percent: percent),
|
||||
),
|
||||
@@ -323,7 +393,11 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
),
|
||||
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<LoggedHomeScreen>
|
||||
_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,31 +733,58 @@ 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(
|
||||
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<void>(
|
||||
builder: (_) => const VideoScreen(),
|
||||
builder: (_) => VideoScreen(scopeId: scopeId),
|
||||
),
|
||||
);
|
||||
await state?.refreshStats();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
@@ -688,6 +793,193 @@ class _InicioTab extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<Map<String, dynamic>?> _createChildViaSheet(
|
||||
@@ -780,7 +1072,7 @@ Future<Map<String, dynamic>?> _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,77 +1131,95 @@ Future<Map<String, dynamic>?> _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),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: Colors.transparent,
|
||||
child: Ink(
|
||||
decoration: const BoxDecoration(gradient: kPinkHeroGradient),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -30,
|
||||
bottom: -30,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: 0.14,
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: teal.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.medical_services_rounded,
|
||||
color: teal,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Avaliação de saúde oral',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 16,
|
||||
color: pink,
|
||||
fontSize: 19,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
childName.isNotEmpty
|
||||
? 'Para $childName'
|
||||
: 'Responda o quiz e descubra como cuidar melhor do sorriso.',
|
||||
'Leva menos de 3 minutos a completar',
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.62),
|
||||
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: pink,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFFFF55A7),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -924,103 +1234,84 @@ 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,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
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,
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 14, 14),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2F9E94).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: const Color(0xFF2F9E94),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.video_library_rounded,
|
||||
color: Color(0xFF2F9E94),
|
||||
size: 20,
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Vídeos Educativos',
|
||||
const Text(
|
||||
'Continuar onde parou',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: Color(0xFFFF55A7),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Aprenda mais sobre saúde oral',
|
||||
style: TextStyle(
|
||||
'${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,
|
||||
@@ -1037,9 +1328,32 @@ class _VideoLibraryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1281,6 +1595,75 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<int>(
|
||||
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<void> _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<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _ageController = TextEditingController();
|
||||
DateTime? _birthDate;
|
||||
String? _gender;
|
||||
String? _birthDateError;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_ageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
initialValue: _gender,
|
||||
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
@@ -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<void>(
|
||||
|
||||
@@ -45,6 +45,10 @@ class QuizQuestionScreen extends StatefulWidget {
|
||||
this.suggestedVideoPath,
|
||||
this.suggestedYoutubeId,
|
||||
this.suggestedVideoTitle,
|
||||
this.category,
|
||||
this.categoryIcon,
|
||||
this.fallbackIcon,
|
||||
this.fallbackColor,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -61,6 +65,16 @@ class QuizQuestionScreen extends StatefulWidget {
|
||||
final String? suggestedYoutubeId;
|
||||
final String? suggestedVideoTitle;
|
||||
|
||||
/// Rótulo pequeno do tema da pergunta (ex.: "Avaliação facial"), mostrado
|
||||
/// num badge acima da imagem/pergunta.
|
||||
final String? category;
|
||||
final IconData? categoryIcon;
|
||||
|
||||
/// Usados só quando [questionImagePaths] está vazio: em vez de o bloco de
|
||||
/// imagem colapsar, mostra-se um bloco colorido com este ícone.
|
||||
final IconData? fallbackIcon;
|
||||
final Color? fallbackColor;
|
||||
|
||||
@override
|
||||
State<QuizQuestionScreen> createState() => _QuizQuestionScreenState();
|
||||
}
|
||||
@@ -74,8 +88,24 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
int? _selected;
|
||||
TextEditingController? _numberController;
|
||||
int? _numberValue;
|
||||
bool _numberDontKnow = false;
|
||||
bool _navigating = false;
|
||||
|
||||
/// O título ainda chega como texto livre ("Quiz 3/25") em vez de números
|
||||
/// separados — extraímos daqui em vez de acrescentar mais dois parâmetros
|
||||
/// obrigatórios a cada uma das 25 telas de pergunta.
|
||||
static final RegExp _progressPattern = RegExp(r'(\d+)\s*/\s*(\d+)');
|
||||
|
||||
int get _questionIndex {
|
||||
final match = _progressPattern.firstMatch(widget.title);
|
||||
return int.tryParse(match?.group(1) ?? '') ?? 1;
|
||||
}
|
||||
|
||||
int get _totalQuestions {
|
||||
final match = _progressPattern.firstMatch(widget.title);
|
||||
return int.tryParse(match?.group(2) ?? '') ?? 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -96,10 +126,11 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
bool canProceed = _selected != null && !_navigating;
|
||||
if (widget.answerType == QuizAnswerType.number) {
|
||||
canProceed =
|
||||
_numberValue != null &&
|
||||
!_navigating &&
|
||||
(_numberDontKnow ||
|
||||
(_numberValue != null &&
|
||||
_numberValue! >= 0 &&
|
||||
_numberValue! <= _maxTeethCount &&
|
||||
!_navigating;
|
||||
_numberValue! <= _maxTeethCount));
|
||||
}
|
||||
|
||||
final bool hasSuggestedVideo =
|
||||
@@ -146,49 +177,103 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (widget.showBackButton)
|
||||
Positioned(
|
||||
left: 4,
|
||||
child: TapBounce(
|
||||
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,
|
||||
),
|
||||
shadowColor: Colors.black.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
padding: EdgeInsets.all(9),
|
||||
child: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: Color(0xFF2F9E94),
|
||||
size: 22,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 38),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: (_questionIndex / _totalQuestions).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
),
|
||||
minHeight: 8,
|
||||
backgroundColor: const Color(
|
||||
0xFFFF55A7,
|
||||
).withValues(alpha: 0.15),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFFFF55A7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'$_questionIndex/$_totalQuestions',
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((widget.category ?? '').isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.categoryIcon != null) ...[
|
||||
Icon(
|
||||
widget.categoryIcon,
|
||||
size: 15,
|
||||
color: const Color(0xFF2F9E94),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
widget.category!,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 12,
|
||||
color: Color(0xFF2F9E94),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -224,16 +309,21 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget
|
||||
.questionImagePaths
|
||||
.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_QuestionReferenceImages(
|
||||
paths:
|
||||
widget.questionImagePaths,
|
||||
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,13 +414,36 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
delay: Duration(
|
||||
milliseconds: 60 * i,
|
||||
),
|
||||
child: _QuizAnswerTile(
|
||||
answer:
|
||||
widget.answers[i],
|
||||
child:
|
||||
widget.answerType ==
|
||||
QuizAnswerType
|
||||
.yesNo &&
|
||||
widget
|
||||
.answers[i]
|
||||
.imagePath ==
|
||||
null
|
||||
? _QuizAnswerPill(
|
||||
answer: widget
|
||||
.answers[i],
|
||||
selected:
|
||||
_selected == i,
|
||||
onTap: () => setState(
|
||||
() => _selected = i,
|
||||
onTap: () =>
|
||||
setState(
|
||||
() =>
|
||||
_selected =
|
||||
i,
|
||||
),
|
||||
)
|
||||
: _QuizAnswerTile(
|
||||
answer: widget
|
||||
.answers[i],
|
||||
selected:
|
||||
_selected == i,
|
||||
onTap: () =>
|
||||
setState(
|
||||
() =>
|
||||
_selected =
|
||||
i,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -357,7 +470,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
const Color(
|
||||
0xFF2F9E94,
|
||||
0xFFFF55A7,
|
||||
),
|
||||
foregroundColor:
|
||||
Colors.white,
|
||||
@@ -426,8 +539,10 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
nextScore =
|
||||
widget
|
||||
.currentScore +
|
||||
(_numberValue ??
|
||||
0);
|
||||
(_numberDontKnow
|
||||
? 0
|
||||
: (_numberValue ??
|
||||
0));
|
||||
} else {
|
||||
final picked = widget
|
||||
.answers[_selected!];
|
||||
@@ -474,46 +589,23 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 42,
|
||||
child: OutlinedButton(
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
foregroundColor:
|
||||
const Color(
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(
|
||||
0xFF2F9E94,
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Color(
|
||||
0xFF2F9E94,
|
||||
),
|
||||
width: 1.3,
|
||||
),
|
||||
shape:
|
||||
const StadiumBorder(),
|
||||
textStyle:
|
||||
const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.w900,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
Navigator.of(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).popUntil(
|
||||
(route) =>
|
||||
route.isFirst,
|
||||
),
|
||||
).popUntil((route) => route.isFirst),
|
||||
child: const Text(
|
||||
'Voltar para homepage',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -540,7 +632,11 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
Opacity(
|
||||
opacity: _numberDontKnow ? 0.4 : 1,
|
||||
child: IgnorePointer(
|
||||
ignoring: _numberDontKnow,
|
||||
child: Container(
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.70),
|
||||
@@ -587,7 +683,11 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_numberValue != null && _numberValue! > _maxTeethCount) ...[
|
||||
),
|
||||
),
|
||||
if (_numberValue != null &&
|
||||
_numberValue! > _maxTeethCount &&
|
||||
!_numberDontKnow) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.',
|
||||
@@ -599,6 +699,62 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
TapBounce(
|
||||
scale: 0.97,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_numberDontKnow = !_numberDontKnow;
|
||||
if (_numberDontKnow) {
|
||||
_numberController?.clear();
|
||||
_numberValue = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _numberDontKnow
|
||||
? const Color(0xFF2F9E94)
|
||||
: Colors.white.withValues(alpha: 0.70),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: _numberDontKnow
|
||||
? const Color(0xFF2F9E94)
|
||||
: Colors.black.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline_rounded,
|
||||
size: 16,
|
||||
color: _numberDontKnow
|
||||
? Colors.white
|
||||
: Colors.black.withValues(alpha: 0.55),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Não sei',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
color: _numberDontKnow
|
||||
? Colors.white
|
||||
: Colors.black.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -745,12 +901,39 @@ class _QuestionReferenceImages extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.asset(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,34 +33,29 @@ class _SettingsBodyState extends State<SettingsBody> {
|
||||
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})',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VideoData> 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<void> 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<void> showVideoPlayerDialog(
|
||||
BuildContext context,
|
||||
VideoData video, {
|
||||
String? scopeId,
|
||||
}) {
|
||||
if (video.youtubeId != null) {
|
||||
if (video.youtubeId!.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
@@ -165,17 +179,23 @@ Future<void> showVideoPlayerDialog(BuildContext context, VideoData video) {
|
||||
return Future.value();
|
||||
}
|
||||
return Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
|
||||
),
|
||||
);
|
||||
}
|
||||
return showDialog<void>(
|
||||
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<VideoScreen> {
|
||||
),
|
||||
child: _VideoButton(
|
||||
video: _filteredVideos[index],
|
||||
scopeId: widget.scopeId,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -509,9 +530,10 @@ class _VideoThumbnailState extends State<VideoThumbnail> {
|
||||
}
|
||||
|
||||
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(() {});
|
||||
}
|
||||
|
||||
36
lib/watched_videos_prefs.dart
Normal file
36
lib/watched_videos_prefs.dart
Normal file
@@ -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<void> markWatched(String scopeId, int videoId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = _key(scopeId);
|
||||
final ids = (prefs.getStringList(key) ?? <String>[]).toSet();
|
||||
ids.add(videoId.toString());
|
||||
await prefs.setStringList(key, ids.toList());
|
||||
}
|
||||
|
||||
static Future<bool> 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<int> getWatchedCount(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ids = prefs.getStringList(_key(scopeId)) ?? const [];
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
static Future<Set<int>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
1
supabase/.temp/linked-project.json
Normal file
1
supabase/.temp/linked-project.json
Normal file
@@ -0,0 +1 @@
|
||||
{"ref":"mannjismlhlwaqqqnvog","name":"Check_Teeth_Kids","organization_id":"huurggiiridujjbkbayo","organization_slug":"huurggiiridujjbkbayo"}
|
||||
65
supabase/functions/delete-account/index.ts
Normal file
65
supabase/functions/delete-account/index.ts
Normal file
@@ -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' },
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user