Files
CheckTheethKids/lib/logged_home.dart
Carlos Correia a8af2c6845 CKT String|Colors
2026-07-15 19:13:15 +01:00

2591 lines
92 KiB
Dart

import 'package:flutter/material.dart';
import 'colors/app_colors.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'dart:async';
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 'quiz/quiz_result.dart' show kSignsMax, kFactorsMax;
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 'colors/app_gradients.dart';
import 'strings/home_strings.dart';
import 'widgets/entrance.dart';
import 'widgets/liquid_waves_background.dart';
import 'widgets/name_input_formatter.dart';
import 'widgets/pill_snackbar.dart';
import 'widgets/tap_bounce.dart';
/// 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});
@override
State<LoggedHomeScreen> createState() => _LoggedHomeScreenState();
}
class _LoggedHomeScreenState extends State<LoggedHomeScreen>
with SingleTickerProviderStateMixin {
static const Color _teal = AppColors.teal;
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1';
static const double _collapsedAppBarHeight = kToolbarHeight;
static const double _expandedAppBarHeight = 256;
int _index = 0;
int _selectedChildIndex = 0;
String? _selectedChildName;
String? _selectedChildScopeId;
QuizResultData? _lastResult;
int? _brushingWeekCount;
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
bool _brushingDailyLimitReached = false;
int? _watchedVideoCount;
String _cachedUserName = HomeStrings.noName;
String? _cachedPhotoUrl;
@override
void initState() {
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;
});
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);
if (!mounted) return;
setState(() {
_brushingWeekCount = weekCount;
_weeklyGoal = goal;
_brushingDailyLimitReached = dailyLimitReached;
_watchedVideoCount = watchedCount;
});
}
Future<void> _maybeStartPendingQuiz() async {
try {
final prefs = await SharedPreferences.getInstance();
String scopeId = (prefs.getString(_kPendingQuizScopeKey) ?? '').trim();
// O AuthGate pode trocar para o LoggedHome ANTES do register sheet terminar
// de gravar a key. Então tentamos por um curto período.
int tries = 0;
while (scopeId.isEmpty && tries < 12) {
await Future<void>.delayed(const Duration(milliseconds: 250));
scopeId = (prefs.getString(_kPendingQuizScopeKey) ?? '').trim();
tries++;
}
if (scopeId.isEmpty) return;
// Limpa antes de navegar para evitar loop se o usuário voltar.
await prefs.remove(_kPendingQuizScopeKey);
if (!mounted) return;
await Navigator.of(context).push(quizStartRoute(scopeId: scopeId));
if (!mounted) return;
await _loadQuizResult();
} catch (_) {
// no-op
}
}
Future<void> _loadInitialProfile() async {
final uid = (supabase.auth.currentUser?.id ?? '').trim();
if (uid.isEmpty) return;
try {
final userDoc = await supabase
.from('profiles')
.select()
.eq('id', uid)
.maybeSingle();
final storedName = (userDoc?['name'] ?? '').toString().trim();
final storedPhotoUrl = (userDoc?['photo_url'] ?? '').toString().trim();
final childrenSnap = await supabase
.from('children')
.select()
.eq('owner_id', uid)
.order('created_at')
.limit(1);
String? childName;
String? scopeId;
if (childrenSnap.isNotEmpty) {
final c = childrenSnap.first;
final childId = (c['id'] ?? '').toString().trim();
childName = (c['name'] ?? '').toString().trim();
scopeId = '${uid}_$childId';
}
if (!mounted) return;
setState(() {
_cachedUserName = storedName.isNotEmpty ? storedName : _cachedUserName;
if (storedPhotoUrl.isNotEmpty) _cachedPhotoUrl = storedPhotoUrl;
if ((_selectedChildName ?? '').trim().isEmpty &&
(childName ?? '').trim().isNotEmpty) {
_selectedChildName = childName;
}
if ((_selectedChildScopeId ?? '').trim().isEmpty &&
(scopeId ?? '').trim().isNotEmpty) {
_selectedChildScopeId = scopeId;
}
});
await _loadQuizResult();
await refreshStats();
} catch (_) {
// no-op
}
}
Future<void> _loadQuizResult() async {
final scope = (_selectedChildScopeId ?? '').trim();
final uid = supabase.auth.currentUser?.id;
final String? userId = (uid ?? '').trim().isEmpty ? null : uid;
QuizResultData? result;
if (scope.isNotEmpty && userId != null) {
final String childId = scope.startsWith('${userId}_')
? scope.substring(userId.length + 1)
: '';
if (childId.trim().isNotEmpty) {
try {
final childDoc = await supabase
.from('children')
.select()
.eq('id', childId)
.maybeSingle();
final signs = childDoc?['last_signs'];
final signsMax = childDoc?['last_signs_max'];
final factors = childDoc?['last_factors'];
final factorsMax = childDoc?['last_factors_max'];
if (signs is int &&
signsMax is int &&
factors is int &&
factorsMax is int) {
result = QuizResultData(
signs: signs,
signsMax: signsMax,
factors: factors,
factorsMax: factorsMax,
);
}
} catch (_) {
// no-op
}
}
}
if (result != null) {
if (!mounted) return;
setState(() => _lastResult = result);
return;
}
if (scope.isNotEmpty) {
result = await QuizPrefs.getLastResultForScope(scope);
} else if (userId != null) {
result = await QuizPrefs.getLastResultForUser(userId);
} else {
result = await QuizPrefs.getLastResult();
}
if (!mounted) return;
setState(() => _lastResult = result);
}
void selectChild(String? name, String? scopeId) {
setState(() {
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
refreshStats();
}
void updateCachedPhoto(String url) {
setState(() => _cachedPhotoUrl = url);
}
String _greeting() {
final hour = DateTime.now().hour;
if (hour < 12) return HomeStrings.goodMorning;
if (hour < 18) return HomeStrings.goodAfternoon;
return HomeStrings.goodEvening;
}
/// Fundo comum (cor sólida + ondas decorativas) atrás de qualquer aba, com
/// o conteúdo real posicionado por cima via [SafeArea]/[Padding].
Widget _decoratedBody(Size size, Widget child, double topPadding) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: Container(color: AppColors.background)),
const LiquidWavesBackground(),
SafeArea(
top: false,
child: Align(
alignment: Alignment.center,
child: Padding(
padding: EdgeInsets.fromLTRB(16, topPadding, 16, 16),
child: child,
),
),
),
],
);
}
Widget _bottomNav() {
return BottomNavigationBar(
currentIndex: _index,
onTap: (i) {
if (i == _index) return;
HapticFeedback.selectionClick();
setState(() => _index = i);
},
backgroundColor: AppColors.background,
selectedItemColor: _teal,
unselectedItemColor: Colors.black54,
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.home_rounded,
selected: _index == 0,
),
label: HomeStrings.navHome,
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.person_rounded,
selected: _index == 1,
),
label: HomeStrings.navProfile,
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.settings_rounded,
selected: _index == 2,
),
label: HomeStrings.navSettings,
),
],
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final QuizResultData? result = _lastResult;
final bool hasScore = result != null;
final shownName = _cachedUserName;
if (_index == 0) {
// Home: a app bar é retrátil — encolhe (escondendo o nome da criança e
// o gauge) à medida que o utilizador scrolla a lista, mantendo só a
// linha do avatar/saudação fixa no topo. É um SliverAppBar dentro de
// um NestedScrollView em vez do AppBar fixo usado nas outras abas,
// para que a altura acompanhe o scroll em vez de ficar sempre cheia
// (o que cortava o conteúdo do card do quiz contra a barra ao rolar).
// A app bar mantém sempre a mesma altura e mostra sempre os dois
// gauges — vazios (sem número, sem rótulo) quando ainda não há
// resultado, para o utilizador já perceber que aquele espaço é para
// o resultado do quiz mesmo antes de o fazer.
const double expandedHeight = _expandedAppBarHeight;
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: expandedHeight,
toolbarHeight: kToolbarHeight,
pinned: true,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: _teal,
foregroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(40),
),
),
flexibleSpace: FlexibleSpaceBar(
titlePadding: EdgeInsets.zero,
background: ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(40),
),
child: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient),
child: Stack(
fit: StackFit.expand,
children: [
if (hasScore)
Positioned(
left: 0,
right: 0,
top: kToolbarHeight + 50,
child: Center(
child: Text(
(_selectedChildName ?? '').trim(),
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white.withValues(alpha: 0.92),
fontSize: 14,
),
),
),
),
//posição da app bar relativamente ao nome
Positioned(
left: 0,
right: 0,
top: kToolbarHeight + 96,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RiskArcGauge(
// Usa sempre o máximo atual do quiz (não
// o que foi gravado na última avaliação)
// para não mostrar um denominador antigo
// quando o número de perguntas muda.
value: hasScore ? result.signs : null,
max: hasScore ? kSignsMax : null,
label: HomeStrings.signsGaugeLabel,
),
const SizedBox(width: 18),
_RiskArcGauge(
value: hasScore ? result.factors : null,
max: hasScore ? kFactorsMax : null,
label: HomeStrings.factorsGaugeLabel,
),
],
),
),
),
],
),
),
),
),
title: Padding(
padding: const EdgeInsets.only(left: 16, right: 10),
child: TapBounce(
scale: 0.96,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () => setState(() => _index = 1),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.white.withValues(
alpha: 0.25,
),
backgroundImage:
(_cachedPhotoUrl ?? '').isNotEmpty
? NetworkImage(_cachedPhotoUrl!)
: null,
child: (_cachedPhotoUrl ?? '').isEmpty
? const Icon(
Icons.person_rounded,
color: Colors.white,
)
: null,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_greeting(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.85),
fontSize: 12,
),
),
Text(
shownName,
textAlign: TextAlign.left,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Colors.white,
fontSize: 19,
),
),
],
),
],
),
),
),
),
),
),
centerTitle: false,
titleSpacing: 0,
),
],
body: _decoratedBody(
size,
_InicioTab(onQuizClosed: _loadQuizResult),
0,
),
),
bottomNavigationBar: _bottomNav(),
);
}
final String title = _index == 1 ? HomeStrings.navProfile : HomeStrings.settingsTitle;
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(_collapsedAppBarHeight),
// O gradiente é pintado por este Container de tamanho fixo (a
// própria altura da app bar), em vez de confiar no `flexibleSpace`
// do AppBar — em alguns aparelhos, o `flexibleSpace` de um AppBar
// comum (não Sliver) não recebia o tamanho esperado e a gradiente
// aparecia como cor sólida. Com o Container por baixo e o AppBar
// totalmente transparente por cima, o gradiente é garantido.
child: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient),
child: AppBar(
toolbarHeight: _collapsedAppBarHeight,
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
title: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
),
),
),
body: _decoratedBody(
size,
_index == 1
? _PerfilTab(
selectedChildIndex: _selectedChildIndex,
onChildSelected: (index, name, scopeId) {
setState(() {
_selectedChildIndex = index;
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
refreshStats();
},
)
: const SettingsBody(),
10,
),
bottomNavigationBar: _bottomNav(),
);
}
}
class _RiskArcGauge extends StatelessWidget {
const _RiskArcGauge({required this.value, required this.max, this.label});
/// Quando [value]/[max] são nulos (sem criança selecionada, ou criança
/// ainda sem quiz feito), o gauge mostra-se vazio — só o anel em branco,
/// sem número nem rótulo — em vez de desaparecer, para o espaço já
/// indicar onde o resultado vai aparecer assim que existir.
final int? value;
final int? max;
final String? label;
bool get _isEmpty => value == null || max == null;
@override
Widget build(BuildContext context) {
final progress = _isEmpty || max! <= 0
? 0.0
: (value! / max!).clamp(0, 1).toDouble();
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 700),
curve: Curves.easeOutCubic,
tween: Tween<double>(begin: 0, end: progress),
builder: (context, animatedProgress, _) {
return SizedBox(
width: 108,
height: 92,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 60,
child: CustomPaint(
painter: _RiskArcGaugePainter(progress: animatedProgress),
),
),
if (!_isEmpty) ...[
Positioned(
top: 38,
left: 0,
right: 0,
child: Center(
child: Text(
'$value/$max',
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w900,
height: 1,
),
),
),
),
Positioned(
top: 68,
left: 0,
right: 0,
child: Text(
label ?? '',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
fontSize: 10.5,
fontWeight: FontWeight.w700,
height: 1.15,
letterSpacing: 0.1,
),
),
),
],
],
),
);
},
);
}
}
class _RiskArcGaugePainter extends CustomPainter {
const _RiskArcGaugePainter({required this.progress});
final double progress;
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(10, 8, size.width - 20, size.height * 1.7);
const startAngle = math.pi;
const sweepAngle = math.pi;
final strokeWidth = size.width * 0.12;
final backgroundPaint = Paint()
..color = Colors.white.withValues(alpha: 0.72)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final progressPaint = Paint()
..color = AppColors.pinkLight
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint);
canvas.drawArc(
rect,
startAngle,
sweepAngle * progress.clamp(0, 1),
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _RiskArcGaugePainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
class _InicioTab extends StatelessWidget {
const _InicioTab({required this.onQuizClosed});
final VoidCallback onQuizClosed;
Future<void> _startQuiz(BuildContext context) async {
final uid = (supabase.auth.currentUser?.id ?? '').trim();
if (uid.isEmpty) return;
List<Map<String, dynamic>> children = const [];
try {
children = await supabase
.from('children')
.select()
.eq('owner_id', uid)
.order('created_at');
} catch (_) {
// segue com lista vazia; tratado abaixo
}
if (!context.mounted) return;
Map<String, dynamic>? chosen;
if (children.isEmpty) {
chosen = await _requireFirstChild(context, uid);
if (chosen == null) return;
} else if (children.length == 1) {
chosen = children.first;
} else {
if (!context.mounted) return;
chosen = await _pickChildSheet(context, children);
if (chosen == null) return;
}
final childId = (chosen['id'] ?? '').toString().trim();
final childName = (chosen['name'] ?? '').toString().trim();
final scopeId = childId.isEmpty ? uid : '${uid}_$childId';
if (!context.mounted) return;
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
state?.selectChild(childName, scopeId);
await Navigator.of(context).push(quizStartRoute(scopeId: scopeId));
onQuizClosed();
}
@override
Widget build(BuildContext context) {
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
final selectedChildName = (state?._selectedChildName ?? '').trim();
final scopeId = state?._selectedChildScopeId;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 18, bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (selectedChildName.isNotEmpty) ...[
FadeSlideIn(
child: _HomeSectionLabel(HomeStrings.forChild(selectedChildName)),
),
const SizedBox(height: 8),
],
FadeSlideIn(
child: TapBounce(
scale: 0.97,
child: _HeroQuizCard(
onStartQuiz: () => _startQuiz(context),
),
),
),
const SizedBox(height: 16),
FadeSlideIn(
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(HomeStrings.educationalVideos),
),
const SizedBox(height: 8),
FadeSlideIn(
delay: const Duration(milliseconds: 130),
child: TapBounce(
scale: 0.97,
child: _VideoLibraryCard(
watchedCount: state?._watchedVideoCount ?? 0,
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoScreen(scopeId: scopeId),
),
);
await state?.refreshStats();
},
),
),
),
const SizedBox(height: 14),
FadeSlideIn(
delay: const Duration(milliseconds: 150),
child: Center(
child: Text(
HomeStrings.moreFeaturesSoon,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12.5,
color: Colors.black.withValues(alpha: 0.35),
),
),
),
),
const SizedBox(height: 16),
],
),
),
),
),
);
}
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;
}
if (!(await BrushingPrefs.canLogMore(scope))) {
if (context.mounted) {
showPillSnackBar(
context,
HomeStrings.brushingLimitReached(BrushingPrefs.maxPerDay),
);
}
return;
}
await BrushingPrefs.logToday(scope);
await state?.refreshStats();
if (context.mounted) {
showPillSnackBar(context, HomeStrings.brushingLogged);
}
}
}
/// Faz [child] "respirar" (escala sobe e desce suavemente, em loop) — usado
/// para chamar a atenção para os dois destaques principais da Home (o quiz
/// e o vídeo em destaque), que são o carro-chefe da app.
class _Pulse extends StatefulWidget {
const _Pulse({
required this.child,
this.minScale = 0.94,
this.maxScale = 1.0,
this.duration = const Duration(milliseconds: 1100),
});
final Widget child;
final double minScale;
final double maxScale;
final Duration duration;
@override
State<_Pulse> createState() => _PulseState();
}
class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: widget.duration,
)..repeat(reverse: true);
late final Animation<double> _scale = Tween(
begin: widget.minScale,
end: widget.maxScale,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(scale: _scale, child: widget.child);
}
}
/// 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: AppColors.pink,
value: brushingCount == null
? '--'
: '$brushingCount/$weeklyGoal',
label: HomeStrings.brushingThisWeek,
done: brushedToday,
onTap: onTapBrushing,
),
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
icon: Icons.movie_filter_rounded,
iconColor: AppColors.purple,
value: watchedCount == null ? '--' : '$watchedCount',
label: HomeStrings.completedEpisodes,
),
),
],
),
);
}
}
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: AppColors.teal,
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(
BuildContext context,
String uid,
) async {
final result = await showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: AppColors.pinkBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => const _AddChildSheet(),
);
if (result == null) return null;
if (!context.mounted) return null;
try {
final inserted = await supabase
.from('children')
.insert({...result, 'owner_id': uid})
.select()
.single();
return inserted;
} on PostgrestException catch (e) {
if (!context.mounted) return null;
showPillSnackBar(
context,
e.code == '23505'
? HomeStrings.childCodeAlreadyInUse
: HomeStrings.errorAddingChild(e),
);
return null;
} catch (e) {
if (!context.mounted) return null;
showPillSnackBar(context, HomeStrings.errorAddingChild(e));
return null;
}
}
Future<Map<String, dynamic>?> _requireFirstChild(
BuildContext context,
String uid,
) async {
final proceed = await showConfirmDialog(
context,
title: HomeStrings.registerAChild,
message: HomeStrings.registerAChildMessage,
confirmLabel: HomeStrings.addChild,
);
if (proceed != true) return null;
if (!context.mounted) return null;
return _createChildViaSheet(context, uid);
}
Future<Map<String, dynamic>?> _pickChildSheet(
BuildContext context,
List<Map<String, dynamic>> children,
) {
const Color teal = AppColors.teal;
return showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: AppColors.pinkBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
final maxHeight = MediaQuery.sizeOf(ctx).height * 0.8;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 6, 18, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
HomeStrings.whichChildIsTheQuizFor,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
const SizedBox(height: 14),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children.map((c) {
final name = (c['name'] ?? '').toString();
final age = _childAge(c);
final label = age != null
? HomeStrings.childNameWithAge(name, age)
: name;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TapBounce(
scale: 0.97,
child: Material(
color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.of(ctx).pop(c),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w800,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: teal,
),
],
),
),
),
),
),
);
}).toList(),
),
),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text(HomeStrings.cancel),
),
],
),
),
),
);
},
);
}
class _HeroQuizCard extends StatelessWidget {
const _HeroQuizCard({required this.onStartQuiz});
final VoidCallback onStartQuiz;
@override
Widget build(BuildContext context) {
return Material(
elevation: 18,
shadowColor: AppColors.pink.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
color: Colors.transparent,
child: Ink(
decoration: const BoxDecoration(gradient: kPinkHeroGradient),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
right: -34,
bottom: -34,
child: IgnorePointer(
child: Opacity(
opacity: 0.14,
child: Container(
width: 160,
height: 160,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
Positioned(
right: 4,
bottom: 10,
child: IgnorePointer(
child: Opacity(
opacity: 0.16,
child: Transform.rotate(
angle: -0.22,
child: const Icon(
Icons.health_and_safety_rounded,
size: 92,
color: Colors.white,
),
),
),
),
),
Positioned(
left: -18,
top: -18,
child: IgnorePointer(
child: Opacity(
opacity: 0.10,
child: Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
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(
HomeStrings.freeAssessmentBadge,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
_Pulse(
minScale: 0.85,
maxScale: 1.15,
duration: const Duration(milliseconds: 900),
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
],
),
const SizedBox(height: 14),
const Text(
HomeStrings.assessmentTitle,
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 22,
height: 1.1,
color: Colors.white,
),
),
const SizedBox(height: 5),
Text(
HomeStrings.assessmentSubtitle,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 18),
_Pulse(
minScale: 0.985,
maxScale: 1.0,
duration: const Duration(milliseconds: 1400),
child: SizedBox(
height: 50,
width: double.infinity,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.pink,
shape: const StadiumBorder(),
elevation: 6,
shadowColor: Colors.black.withValues(alpha: 0.25),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15.5,
),
),
onPressed: onStartQuiz,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text(HomeStrings.startQuiz),
),
),
),
],
),
),
],
),
),
);
}
}
/// Card de destaque (mesma linguagem visual do card do quiz: gradiente,
/// badge, título, botão branco) que convida a criança/pai a ir ver a
/// biblioteca de vídeos — sem nenhuma miniatura/imagem de vídeo específica,
/// só ícone e texto. Um único toque (no card ou no botão) leva direto à
/// grelha onde se escolhe qual episódio assistir.
class _VideoLibraryCard extends StatelessWidget {
const _VideoLibraryCard({required this.watchedCount, required this.onTap});
final int watchedCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
elevation: 14,
shadowColor: AppColors.teal.withValues(alpha: 0.38),
borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias,
color: Colors.transparent,
child: Ink(
decoration: const BoxDecoration(gradient: kGreenButtonGradient),
child: InkWell(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
right: -30,
bottom: -30,
child: IgnorePointer(
child: Opacity(
opacity: 0.14,
child: Container(
width: 150,
height: 150,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
Positioned(
right: 6,
bottom: 6,
child: IgnorePointer(
child: Opacity(
opacity: 0.16,
child: Transform.rotate(
angle: 0.2,
child: const Icon(
Icons.smart_display_rounded,
size: 88,
color: Colors.white,
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
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.play_circle_fill_rounded,
color: Colors.white,
size: 14,
),
SizedBox(width: 4),
Text(
HomeStrings.videoLibraryBadge,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 14),
const Text(
HomeStrings.educationalVideos,
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 22,
height: 1.1,
color: Colors.white,
),
),
const SizedBox(height: 5),
Text(
watchedCount > 0
? HomeStrings.watchedEpisodesSummary(
watchedCount,
videoList.length,
)
: HomeStrings.allEpisodesSummary(videoList.length),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 18),
SizedBox(
height: 48,
width: double.infinity,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.teal,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
),
),
onPressed: onTap,
icon: const Icon(Icons.video_library_rounded),
label: const Text(HomeStrings.watchVideos),
),
),
],
),
),
],
),
),
),
);
}
}
class _PerfilTab extends StatefulWidget {
const _PerfilTab({
required this.selectedChildIndex,
required this.onChildSelected,
});
final int selectedChildIndex;
final void Function(int index, String? name, String? scopeId) onChildSelected;
@override
State<_PerfilTab> createState() => _PerfilTabState();
}
class _PerfilTabState extends State<_PerfilTab> {
bool _addingChild = false;
bool _updatingPhoto = false;
bool _initialLoading = true;
Map<String, dynamic>? _profileData;
List<Map<String, dynamic>> _children = const [];
@override
void initState() {
super.initState();
_loadPerfilData().whenComplete(() {
if (mounted) setState(() => _initialLoading = false);
});
}
// Busca simples (sem Realtime): o Realtime do Supabase pode travar depois de
// várias trocas de aba/reconexões, deixando a lista de filhos e a foto sem
// atualizar. Como só o próprio usuário edita esses dados, buscamos uma vez
// e recarregamos manualmente após cada ação (adicionar/remover filho, trocar
// foto), o que é bem mais confiável.
Future<void> _loadPerfilData() async {
final uid = (supabase.auth.currentUser?.id ?? '').trim();
if (uid.isEmpty) return;
try {
final profile = await supabase
.from('profiles')
.select()
.eq('id', uid)
.maybeSingle();
final children = await supabase
.from('children')
.select()
.eq('owner_id', uid)
.order('created_at');
if (!mounted) return;
setState(() {
_profileData = profile;
_children = children;
});
} catch (_) {
// mantém os dados já carregados; usuário pode tentar de novo
}
}
Future<QuizResultData?> _loadScoreForScope(String scopeId) {
return QuizPrefs.getLastResultForScope(scopeId);
}
Future<void> _pickAndUploadProfilePhoto(
BuildContext context,
String uid,
) async {
if (_updatingPhoto) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
backgroundColor: AppColors.pinkBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 6, 18, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
HomeStrings.profilePhoto,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: () =>
Navigator.of(ctx).pop(ImageSource.camera),
child: const Text(HomeStrings.camera),
),
),
),
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: () =>
Navigator.of(ctx).pop(ImageSource.gallery),
child: const Text(HomeStrings.gallery),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
height: 42,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: AppColors.teal,
),
onPressed: () => Navigator.of(ctx).pop(),
child: const Text(HomeStrings.cancel),
),
),
],
),
),
);
},
);
if (source == null) return;
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source,
imageQuality: 82,
maxWidth: 1024,
);
if (picked == null) return;
setState(() => _updatingPhoto = true);
try {
final file = File(picked.path);
final path = '$uid/profile.jpg';
await supabase.storage
.from('photos')
.upload(path, file, fileOptions: const FileOptions(upsert: true));
final publicUrl = supabase.storage.from('photos').getPublicUrl(path);
final url = '$publicUrl?t=${DateTime.now().millisecondsSinceEpoch}';
await supabase.from('profiles').upsert({'id': uid, 'photo_url': url});
await _loadPerfilData();
if (context.mounted) {
context
.findAncestorStateOfType<_LoggedHomeScreenState>()
?.updateCachedPhoto(url);
}
} catch (e) {
if (!context.mounted) return;
showPillSnackBar(context, HomeStrings.errorUploadingPhoto(e));
} finally {
if (mounted) setState(() => _updatingPhoto = false);
}
}
Future<void> _confirmDeleteChild(
BuildContext context, {
required String childId,
required String childName,
}) async {
final confirmed = await showConfirmDialog(
context,
title: HomeStrings.removeChild,
message: HomeStrings.removeChildConfirmMessage(childName),
confirmLabel: HomeStrings.remove,
confirmColor: AppColors.pink,
);
if (confirmed != true) return;
if (!context.mounted) return;
try {
final deleted = await supabase
.from('children')
.delete()
.eq('id', childId)
.select('id');
if (deleted.isEmpty) {
throw StateError(HomeStrings.noPermissionToRemoveChild);
}
widget.onChildSelected(0, null, null);
await _loadPerfilData();
if (context.mounted) showPillSnackBar(context, HomeStrings.childRemoved);
} catch (e) {
if (context.mounted) {
showPillSnackBar(context, HomeStrings.errorRemovingChild(e));
}
}
}
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: AppColors.pinkBackground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
HomeStrings.weeklyGoalOf(childName),
style: const TextStyle(
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(
labelText: HomeStrings.brushingsPerWeek,
helperText: HomeStrings.brushingGoalRange,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text(HomeStrings.cancel),
),
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(HomeStrings.save),
),
),
),
],
);
},
);
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 result = await showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: AppColors.pinkBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => const _AddChildSheet(),
);
if (!mounted) return;
if (result == null) return;
final childMap = {...result, 'owner_id': uid};
setState(() => _addingChild = true);
try {
await supabase
.from('children')
.insert(childMap)
.timeout(const Duration(seconds: 20));
if (!mounted) return;
await _loadPerfilData();
if (context.mounted) {
showPillSnackBar(context, HomeStrings.childAdded);
}
if (mounted) {
setState(() => _addingChild = false);
}
if (!mounted) return;
if (!mounted) return;
final addMore = await showConfirmDialog(
// ignore: use_build_context_synchronously
context,
title: HomeStrings.addAnotherChildQuestion,
cancelLabel: HomeStrings.notNow,
confirmLabel: HomeStrings.addAnother,
);
if (!mounted) return;
if (addMore == true) {
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) return;
if (!mounted) return;
if (!mounted) return;
// ignore: use_build_context_synchronously
await _addAnotherChild(context, uid);
}
} on TimeoutException {
if (!mounted || !context.mounted) return;
showPillSnackBar(
context,
HomeStrings.timeoutAdding,
);
} catch (e) {
if (!mounted || !context.mounted) return;
showPillSnackBar(context, HomeStrings.errorAdding(e));
} finally {
if (mounted) setState(() => _addingChild = false);
}
}
@override
Widget build(BuildContext context) {
final user = supabase.auth.currentUser;
final uid = (user?.id ?? '').trim();
final name = (user?.userMetadata?['name'] ?? '').toString().trim();
final email = (user?.email ?? '').trim();
final shownName = name.isNotEmpty ? name : HomeStrings.noName;
if (uid.isEmpty) {
return const SizedBox.shrink();
}
if (_initialLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(color: AppColors.teal),
),
);
}
final data = _profileData;
final storedName = (data?['name'] ?? '').toString().trim();
final profileName = storedName.isNotEmpty ? storedName : shownName;
final photoUrl = (data?['photo_url'] ?? '').toString().trim();
final storedEmail = (data?['email'] ?? '').toString().trim();
final profileEmail = storedEmail.isNotEmpty ? storedEmail : email;
final children = _children;
final int selectedIndex = children.isEmpty
? 0
: widget.selectedChildIndex.clamp(
0,
(children.length - 1).clamp(0, 999999),
);
return Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FadeSlideIn(
child: Material(
elevation: 10,
color: Colors.white,
borderRadius: BorderRadius.circular(20),
shadowColor: Colors.black.withValues(alpha: 0.16),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TapBounce(
scale: 0.92,
child: InkWell(
borderRadius: BorderRadius.circular(40),
onTap: _updatingPhoto
? null
: () => _pickAndUploadProfilePhoto(
context,
uid,
),
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: AppColors.pinkBackground,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.teal.withValues(
alpha: 0.35,
),
width: 2,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
if (photoUrl.isNotEmpty)
Image.network(
photoUrl,
fit: BoxFit.cover,
)
else
const Icon(
Icons.person_rounded,
size: 42,
color: AppColors.teal,
),
if (_updatingPhoto)
Container(
color: Colors.black.withValues(
alpha: 0.25,
),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
),
),
),
],
),
),
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: AppColors.pink,
shape: BoxShape.circle,
),
child: const Icon(
Icons.camera_alt_rounded,
size: 14,
color: Colors.white,
),
),
),
],
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
if (profileEmail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
profileEmail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black.withValues(
alpha: 0.55,
),
),
),
],
],
),
),
],
),
),
),
),
const SizedBox(height: 22),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Row(
children: [
const Text(
HomeStrings.myChildren,
style: TextStyle(
color: AppColors.teal,
fontWeight: FontWeight.w900,
fontSize: 15,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.teal.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${children.length}',
style: const TextStyle(
color: AppColors.teal,
fontWeight: FontWeight.w900,
fontSize: 12,
),
),
),
],
),
),
if (children.isEmpty)
Container(
padding: const EdgeInsets.all(18),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.black.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.pinkBackground,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.child_care_rounded,
color: AppColors.pink,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
HomeStrings.noChildrenYet,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.62),
fontWeight: FontWeight.w600,
),
),
),
],
),
)
else
...children.asMap().entries.map((entry) {
final i = entry.key;
final c = entry.value;
final childId = (c['id'] ?? '').toString().trim();
final childName = (c['name'] ?? '').toString().trim();
final childAge = _childAge(c);
final childGender = (c['gender'] ?? '').toString().trim();
final childCode = (c['child_code'] ?? '')
.toString()
.trim();
final scopeId = '${uid}_$childId';
final title = childName.isNotEmpty
? childName
: HomeStrings.childFallbackName(i);
final subtitle = [
if (childAge != null) HomeStrings.ageLabel(childAge),
if (childGender.isNotEmpty)
HomeStrings.genderLabel(childGender),
if (childCode.isNotEmpty)
HomeStrings.codeLabel(childCode),
].join('');
final bool selected = i == selectedIndex;
return FadeSlideIn(
delay: Duration(milliseconds: 60 * i.clamp(0, 6)),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TapBounce(
scale: 0.97,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => widget.onChildSelected(
i,
childName.isEmpty ? null : childName,
scopeId,
),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: selected
? AppColors.pinkBackground
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? AppColors.teal.withValues(alpha: 0.45)
: Colors.black.withValues(alpha: 0.10),
width: selected ? 1.6 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w900,
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(subtitle),
],
],
),
),
const SizedBox(width: 10),
FutureBuilder<QuizResultData?>(
future: _loadScoreForScope(scopeId),
builder: (context, snap) {
final result = snap.data;
final text = result == null
? '--'
: '${result.signs}/$kSignsMax · ${result.factors}/$kFactorsMax';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: AppColors.teal.withValues(
alpha: 0.10,
),
borderRadius: BorderRadius.circular(
999,
),
),
child: Text(
text,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: AppColors.teal,
),
),
);
},
),
IconButton(
onPressed: () => _editWeeklyGoal(
context,
scopeId: scopeId,
childName: title,
),
icon: const Icon(
Icons.edit_outlined,
color: AppColors.teal,
),
tooltip: HomeStrings.weeklyBrushingGoalTooltip,
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: () => _confirmDeleteChild(
context,
childId: childId,
childName: title,
),
icon: const Icon(
Icons.delete_outline_rounded,
color: AppColors.pink,
),
tooltip: HomeStrings.remove,
visualDensity: VisualDensity.compact,
),
],
),
),
),
),
),
);
}),
TapBounce(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
height: 48,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
),
),
onPressed: _addingChild
? null
: () => _addAnotherChild(context, uid),
icon: const Icon(Icons.add_rounded),
label: const Text(HomeStrings.addChild),
),
),
),
),
),
const SizedBox(height: 22),
TapBounce(
child: SizedBox(
height: 46,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.pink,
side: const BorderSide(
color: AppColors.pink,
width: 1.4,
),
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
onPressed: () async {
await supabase.auth.signOut();
},
icon: const Icon(Icons.logout_rounded),
label: const Text(HomeStrings.signOut),
),
),
),
const SizedBox(height: 12),
],
),
),
),
),
);
}
}
class _AddChildSheet extends StatefulWidget {
const _AddChildSheet();
@override
State<_AddChildSheet> createState() => _AddChildSheetState();
}
class _AddChildSheetState extends State<_AddChildSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _codeController = TextEditingController();
DateTime? _birthDate;
String? _gender;
String? _birthDateError;
@override
void dispose() {
_nameController.dispose();
_codeController.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: HomeStrings.birthDate,
cancelText: HomeStrings.cancel,
confirmText: HomeStrings.confirm,
locale: const Locale('pt', 'PT'),
);
if (picked == null) return;
setState(() {
_birthDate = picked;
_birthDateError = null;
});
}
void _submit() {
final formOk = _formKey.currentState?.validate() ?? false;
setState(() {
_birthDateError = _birthDate == null
? HomeStrings.birthDateRequired
: null;
});
if (!formOk || _birthDate == null) return;
Navigator.of(context).pop({
'name': _nameController.text.trim(),
'birth_date': _birthDate!.toIso8601String().split('T').first,
'gender': (_gender ?? '').trim(),
'child_code': _codeController.text.trim(),
});
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 6, 18, 18 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
HomeStrings.addAnotherChildTitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.sentences,
inputFormatters: [CapitalizeFirstLetterFormatter()],
decoration: const InputDecoration(
labelText: HomeStrings.childName,
),
validator: (v) {
final value = (v ?? '').trim();
if (value.isEmpty) return HomeStrings.nameRequired;
if (value.length < 2) return HomeStrings.nameTooShort;
if (!_namePattern.hasMatch(value)) {
return HomeStrings.nameNoNumbers;
}
return null;
},
),
TextFormField(
controller: _codeController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
labelText: HomeStrings.childCode,
),
validator: (v) {
final value = (v ?? '').trim();
if (value.isEmpty) return HomeStrings.childCodeRequired;
return null;
},
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _pickBirthDate,
child: InputDecorator(
decoration: InputDecoration(
labelText: HomeStrings.birthDate,
errorText: _birthDateError,
suffixIcon: const Icon(
Icons.calendar_today_rounded,
size: 18,
),
),
child: Text(
_birthDate == null
? HomeStrings.selectDate
: '${_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,
items: const [
DropdownMenuItem(
value: HomeStrings.male,
child: Text(HomeStrings.male),
),
DropdownMenuItem(
value: HomeStrings.female,
child: Text(HomeStrings.female),
),
DropdownMenuItem(value: HomeStrings.other, child: Text(HomeStrings.other)),
],
onChanged: (v) => setState(() => _gender = v),
decoration: const InputDecoration(labelText: HomeStrings.gender),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return HomeStrings.genderRequired;
}
return null;
},
),
],
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: SizedBox(
height: 44,
child: TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text(HomeStrings.cancel),
),
),
),
const SizedBox(width: 10),
Expanded(
child: TapBounce(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: kGreenButtonGradient,
),
child: SizedBox(
height: 44,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: _submit,
child: const Text(HomeStrings.add),
),
),
),
),
),
),
],
),
],
),
),
);
}
}