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 'consultorios/address_edit_sheet.dart'; import 'consultorios/clinic.dart'; import 'consultorios/clinic_card.dart'; import 'consultorios/clinic_favorites_prefs.dart'; import 'consultorios/consultorios_screen.dart'; import 'consultorios/overpass_service.dart'; import 'main.dart' show supabase; import 'onboarding_prefs.dart'; import 'quiz/quiz1.dart'; import 'quiz/quiz_prefs.dart'; import 'quiz/quiz_progress_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/address_strings.dart'; import 'strings/consultorios_strings.dart'; import 'strings/home_strings.dart'; import 'strings/onboarding_strings.dart'; import 'widgets/coach_mark.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 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 createState() => _LoggedHomeScreenState(); } class _LoggedHomeScreenState extends State 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? _watchedVideoCount; String _cachedUserName = HomeStrings.noName; String? _cachedPhotoUrl; String? _cachedAddress; double? _cachedAddressLat; double? _cachedAddressLon; List? _cachedNearClinics; List? _cachedFarClinics; bool _loadingClinics = false; String? _clinicsError; Set _favoriteClinicIds = {}; // Âncoras do tutorial guiado (ver [_startOnboardingTutorial]) — cada uma // marca o widget real que o passo correspondente do tour vai destacar. final GlobalKey _gaugesKey = GlobalKey(); final GlobalKey _quizCardKey = GlobalKey(); final GlobalKey _videoCardKey = GlobalKey(); final GlobalKey _navConsultoriosKey = GlobalKey(); final GlobalKey _navPerfilKey = GlobalKey(); final GlobalKey _navAjustesKey = GlobalKey(); @override void initState() { super.initState(); _loadQuizResult(); _loadInitialProfile(); refreshStats(); _loadFavoriteClinics(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; await _maybeShowOnboardingTutorial(); if (!mounted) return; await _maybeStartPendingQuiz(); }); } /// Mostra o tutorial guiado automaticamente na primeira vez que o /// utilizador entra na app — depois disso só reaparece se pedido /// manualmente em Ajustes (ver [replayOnboardingTutorial]). Future _maybeShowOnboardingTutorial() async { final seen = await OnboardingPrefs.hasSeenTutorial(); if (seen || !mounted) return; await _startOnboardingTutorial(); await OnboardingPrefs.markTutorialSeen(); } /// Corre o tour de novo a pedido do utilizador (botão "Rever tutorial" em /// Ajustes) — muda para a aba Início primeiro, já que é onde vivem todos /// os alvos do tour. void replayOnboardingTutorial() { setState(() => _index = 0); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _startOnboardingTutorial(); }); } Future _startOnboardingTutorial() { // Pequena pausa para deixar a animação de entrada dos cards da Home // (FadeSlideIn) terminar antes de medir as suas posições. return Future.delayed(const Duration(milliseconds: 450), () { if (!mounted) return Future.value(); return showCoachMarkTour(context, [ CoachMarkStep( targetKey: _gaugesKey, title: OnboardingStrings.gaugesTitle, description: OnboardingStrings.gaugesDescription, borderRadius: 20, ), CoachMarkStep( targetKey: _quizCardKey, title: OnboardingStrings.quizTitle, description: OnboardingStrings.quizDescription, borderRadius: 28, ), CoachMarkStep( targetKey: _videoCardKey, title: OnboardingStrings.videosTitle, description: OnboardingStrings.videosDescription, borderRadius: 28, ), CoachMarkStep( targetKey: _navConsultoriosKey, title: OnboardingStrings.clinicsTitle, description: OnboardingStrings.clinicsDescription, borderRadius: 40, padding: 14, ), CoachMarkStep( targetKey: _navPerfilKey, title: OnboardingStrings.profileTitle, description: OnboardingStrings.profileDescription, borderRadius: 40, padding: 14, ), CoachMarkStep( targetKey: _navAjustesKey, title: OnboardingStrings.settingsTitle, description: OnboardingStrings.settingsDescription, borderRadius: 40, padding: 14, ), ]); }); } Future _loadFavoriteClinics() async { final ids = await ClinicFavoritesPrefs.getFavoriteIds(); if (!mounted) return; setState(() => _favoriteClinicIds = ids); } Future _toggleFavoriteClinic(String clinicId) async { final ids = await ClinicFavoritesPrefs.toggleFavorite(clinicId); if (!mounted) return; setState(() => _favoriteClinicIds = ids); } /// Recarrega a contagem de vídeos assistidos da criança atualmente /// selecionada. Chamado ao trocar de criança e depois de assistir um vídeo /// completo. Future refreshStats() async { final scope = (_selectedChildScopeId ?? '').trim(); if (scope.isEmpty) { if (!mounted) return; setState(() => _watchedVideoCount = null); return; } final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope); if (!mounted) return; setState(() { _watchedVideoCount = watchedCount; }); } Future _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.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 _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 storedAddress = (userDoc?['address'] ?? '').toString().trim(); final storedLat = userDoc?['address_lat']; final storedLon = userDoc?['address_lon']; 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; } _cachedAddress = storedAddress.isNotEmpty ? storedAddress : null; _cachedAddressLat = (storedLat is num) ? storedLat.toDouble() : null; _cachedAddressLon = (storedLon is num) ? storedLon.toDouble() : null; }); // Não aguarda — os consultórios aparecem assim que a Overpass // responder, sem bloquear o resto da Home. unawaited(_loadClinics()); await _loadQuizResult(); await refreshStats(); } catch (_) { // no-op } } /// Procura consultórios perto da morada guardada. Não faz nada sem /// morada, e evita repetir o pedido à Overpass se as coordenadas não /// mudaram desde o último carregamento (a não ser que [forceRefresh]). Future _loadClinics({bool forceRefresh = false}) async { final lat = _cachedAddressLat; final lon = _cachedAddressLon; if (lat == null || lon == null) return; if (!forceRefresh && _cachedNearClinics != null && _cachedFarClinics != null) { return; } if (!mounted) return; setState(() { _loadingClinics = true; _clinicsError = null; }); try { final result = await OverpassService.fetchNearbyClinics(lat: lat, lon: lon); if (!mounted) return; setState(() { _cachedNearClinics = result.near; _cachedFarClinics = result.far; _loadingClinics = false; }); } catch (e) { if (!mounted) return; setState(() { _clinicsError = e.toString(); _loadingClinics = false; }); } } Future _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 selectConsultoriosTab() { setState(() => _index = 1); } 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( key: _navConsultoriosKey, icon: Icons.medical_services_rounded, selected: _index == 1, ), label: ConsultoriosStrings.navConsultorios, ), BottomNavigationBarItem( icon: AnimatedNavIcon( key: _navPerfilKey, icon: Icons.person_rounded, selected: _index == 2, ), label: HomeStrings.navProfile, ), BottomNavigationBarItem( icon: AnimatedNavIcon( key: _navAjustesKey, icon: Icons.settings_rounded, selected: _index == 3, ), 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), ), ), // Nota: o gradiente é pintado diretamente no slot `flexibleSpace` // em vez de usar `FlexibleSpaceBar.background` — o FlexibleSpaceBar // aplica um fade de opacidade e um deslocamento em parallax ao seu // `background` à medida que a barra colapsa, o que fazia o // gradiente parecer diferente (e perder o border radius) a meio do // scroll. Colocando o gradiente diretamente aqui, ele ocupa sempre // exatamente a altura atual da barra, sem fade nem parallax. flexibleSpace: 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( key: _gaugesKey, 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 = 2), 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 = switch (_index) { 1 => ConsultoriosStrings.pageTitle, 2 => 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, ), ), actions: _index == 1 ? [ IconButton( tooltip: ConsultoriosStrings.editAddressTooltip, icon: const Icon(Icons.location_on_outlined), onPressed: () async { final saved = await showAddressEditSheet( context, initialAddress: _cachedAddress, ); if (saved == true) { await _loadInitialProfile(); await _loadClinics(forceRefresh: true); } }, ), ] : null, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.zero, ), ), ), ), body: _decoratedBody( size, switch (_index) { 1 => ConsultoriosTab( address: _cachedAddress, loading: _loadingClinics, error: _clinicsError, near: _cachedNearClinics ?? const [], far: _cachedFarClinics ?? const [], favoriteIds: _favoriteClinicIds, onToggleFavorite: _toggleFavoriteClinic, onRefresh: () => _loadClinics(forceRefresh: true), onAddAddress: () async { final saved = await showAddressEditSheet( context, initialAddress: _cachedAddress, ); if (saved == true) { await _loadInitialProfile(); await _loadClinics(forceRefresh: true); } }, ), 2 => _PerfilTab( selectedChildIndex: _selectedChildIndex, onChildSelected: (index, name, scopeId) { setState(() { _selectedChildIndex = index; _selectedChildName = name; _selectedChildScopeId = scopeId; }); _loadQuizResult(); refreshStats(); }, ), _ => SettingsBody(onReplayTutorial: replayOnboardingTutorial), }, 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( duration: const Duration(milliseconds: 700), curve: Curves.easeOutCubic, tween: Tween(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 _startQuiz(BuildContext context) async { final uid = (supabase.auth.currentUser?.id ?? '').trim(); if (uid.isEmpty) return; List> 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? 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); final progress = await QuizProgressPrefs.getProgress(scopeId); if (!context.mounted) return; Route route; if (progress != null) { final resume = await _confirmResumeQuiz(context, childName: childName); if (!context.mounted) return; if (resume) { route = quizResumeRoute( questionIndex: progress.questionIndex, score: progress.score, scopeId: scopeId, ); } else { await QuizProgressPrefs.clearProgress(); route = quizStartRoute(scopeId: scopeId); } } else { route = quizStartRoute(scopeId: scopeId); } if (!context.mounted) return; await Navigator.of(context).push(route); onQuizClosed(); } /// Pergunta se quer continuar de onde parou ou recomeçar — mostrado só /// quando há progresso guardado para a mesma criança escolhida agora. /// Sem opção de cancelar: uma das duas ações inicia sempre o quiz. Future _confirmResumeQuiz( BuildContext context, { required String childName, }) async { final resume = await showDialog( context: context, barrierDismissible: false, builder: (ctx) { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), title: const Text( HomeStrings.resumeQuizTitle, textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.w900, color: AppColors.pink), ), content: Text( HomeStrings.resumeQuizMessage(childName), textAlign: TextAlign.center, style: TextStyle(color: Colors.black.withValues(alpha: 0.72)), ), actionsAlignment: MainAxisAlignment.center, actions: [ TapBounce( child: TextButton( style: TextButton.styleFrom(foregroundColor: AppColors.teal), onPressed: () => Navigator.of(ctx).pop(false), child: const Text(HomeStrings.restartQuiz), ), ), TapBounce( child: 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(), textStyle: const TextStyle(fontWeight: FontWeight.w800), ), onPressed: () => Navigator.of(ctx).pop(true), child: const Text(HomeStrings.resumeQuiz), ), ), ), ), ], ); }, ); return resume ?? true; } @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( key: state?._quizCardKey, onStartQuiz: () => _startQuiz(context), ), ), ), 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( key: state?._videoCardKey, watchedCount: state?._watchedVideoCount ?? 0, onTap: () async { await Navigator.of(context).push( MaterialPageRoute( settings: const RouteSettings( name: VideoScreen.routeName, ), builder: (_) => VideoScreen(scopeId: scopeId), ), ); await state?.refreshStats(); }, ), ), ), const SizedBox(height: 20), FadeSlideIn( delay: const Duration(milliseconds: 150), child: const _HomeSectionLabel( ConsultoriosStrings.homePreviewTitle, ), ), const SizedBox(height: 8), FadeSlideIn( delay: const Duration(milliseconds: 170), child: _ClinicsPreview( address: state?._cachedAddress, loading: state?._loadingClinics ?? false, near: state?._cachedNearClinics ?? const [], far: state?._cachedFarClinics ?? const [], favoriteIds: state?._favoriteClinicIds ?? const {}, onToggleFavorite: (id) => state?._toggleFavoriteClinic(id), onAddAddress: () async { final saved = await showAddressEditSheet( context, initialAddress: state?._cachedAddress, ); if (saved == true) { await state?._loadInitialProfile(); await state?._loadClinics(forceRefresh: true); } }, onSeeAll: () => state?.selectConsultoriosTab(), ), ), const SizedBox(height: 14), FadeSlideIn( delay: const Duration(milliseconds: 190), 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), ], ), ), ), ), ); } } /// 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 _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), ), ), ); } } Future?> _createChildViaSheet( BuildContext context, String uid, ) async { final result = await showModalBottomSheet?>( 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?> _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?> _pickChildSheet( BuildContext context, List> children, ) { const Color teal = AppColors.teal; return showModalBottomSheet?>( 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({super.key, 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), ), ), ), ], ), ), ], ), ), ); } } /// Pré-visualização de "Consultórios próximos" na Home — mostra até 2 /// consultórios mais próximos, um convite a registar morada se ainda não /// houver uma, ou nada (silenciosamente) se a Overpass falhar sem haver /// resultados em cache, para a Home não ficar com um erro alarmante. class _ClinicsPreview extends StatelessWidget { const _ClinicsPreview({ required this.address, required this.loading, required this.near, required this.far, required this.favoriteIds, required this.onToggleFavorite, required this.onAddAddress, required this.onSeeAll, }); final String? address; final bool loading; final List near; final List far; final Set favoriteIds; final ValueChanged onToggleFavorite; final VoidCallback onAddAddress; final VoidCallback onSeeAll; bool get _hasAddress => (address ?? '').trim().isNotEmpty; @override Widget build(BuildContext context) { if (!_hasAddress) { return TapBounce( scale: 0.97, child: Material( color: Colors.white, borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: onAddAddress, child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( width: 44, height: 44, alignment: Alignment.center, decoration: const BoxDecoration( color: AppColors.pinkBackground, shape: BoxShape.circle, ), child: const Icon( Icons.location_on_outlined, color: AppColors.pink, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( ConsultoriosStrings.noAddressTitle, style: const TextStyle( fontWeight: FontWeight.w800, fontSize: 13.5, ), ), const SizedBox(height: 2), Text( ConsultoriosStrings.addAddress, style: const TextStyle( color: AppColors.teal, fontWeight: FontWeight.w700, fontSize: 12.5, ), ), ], ), ), const Icon( Icons.chevron_right_rounded, color: Colors.black38, ), ], ), ), ), ), ); } final preview = [...near, ...far].take(2).toList(); if (preview.isEmpty) { if (!loading) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), ), child: const Row( children: [ SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.teal, ), ), SizedBox(width: 12), Text( ConsultoriosStrings.loading, style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5), ), ], ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < preview.length; i++) ...[ if (i > 0) const SizedBox(height: 10), ClinicCard( clinic: preview[i], isFavorite: favoriteIds.contains(preview[i].id), onToggleFavorite: onToggleFavorite, ), ], const SizedBox(height: 8), TapBounce( child: TextButton( onPressed: onSeeAll, child: const Text( ConsultoriosStrings.seeAll, style: TextStyle(color: AppColors.teal, fontWeight: FontWeight.w800), ), ), ), ], ); } } /// 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({ super.key, 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? _profileData; List> _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 _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 _loadScoreForScope(String scopeId) { return QuizPrefs.getLastResultForScope(scopeId); } Future _pickAndUploadProfilePhoto( BuildContext context, String uid, ) async { if (_updatingPhoto) return; final source = await showModalBottomSheet( 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 _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 _editWeeklyGoal( BuildContext context, { required String scopeId, required String childName, }) async { final current = await BrushingPrefs.getWeeklyGoal(scopeId); if (!context.mounted) return; final controller = TextEditingController(text: current.toString()); final saved = await showDialog( context: context, builder: (ctx) { return AlertDialog( backgroundColor: 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 _addAnotherChild(BuildContext context, String uid) async { if (_addingChild) return; final result = await showModalBottomSheet?>( 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.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, ); } on PostgrestException catch (e) { if (!mounted || !context.mounted) return; showPillSnackBar( context, e.code == '23505' ? HomeStrings.childCodeAlreadyInUse : HomeStrings.errorAdding(e), ); } 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 storedAddress = (data?['address'] ?? '').toString().trim(); 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: 14), Material( color: Colors.white, borderRadius: BorderRadius.circular(18), elevation: 4, shadowColor: Colors.black.withValues(alpha: 0.08), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: () async { final saved = await showAddressEditSheet( context, initialAddress: storedAddress.isEmpty ? null : storedAddress, ); if (saved == true) { await _loadPerfilData(); if (!context.mounted) return; final state = context .findAncestorStateOfType<_LoggedHomeScreenState>(); await state?._loadInitialProfile(); await state?._loadClinics(forceRefresh: true); } }, child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( width: 40, height: 40, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.teal.withValues(alpha: 0.12), shape: BoxShape.circle, ), child: const Icon( Icons.location_on_outlined, color: AppColors.teal, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( AddressStrings.perfilSectionLabel, style: TextStyle( fontWeight: FontWeight.w800, fontSize: 12.5, color: Colors.black54, ), ), const SizedBox(height: 2), Text( storedAddress.isNotEmpty ? storedAddress : ConsultoriosStrings.addAddress, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13.5, color: storedAddress.isNotEmpty ? Colors.black87 : AppColors.teal, ), ), ], ), ), const Icon( Icons.edit_outlined, color: AppColors.teal, size: 20, ), ], ), ), ), ), 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( 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(); final _nameController = TextEditingController(); final _codeController = TextEditingController(); DateTime? _birthDate; String? _gender; String? _birthDateError; String? _genderError; @override void dispose() { _nameController.dispose(); _codeController.dispose(); super.dispose(); } Future _pickBirthDate() async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: _birthDate ?? DateTime(now.year - 5, now.month, now.day), firstDate: DateTime(now.year - 17, now.month, now.day), lastDate: DateTime(now.year - 1, now.month, now.day), helpText: 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; final genderMissing = (_gender ?? '').trim().isEmpty; setState(() { _birthDateError = _birthDate == null ? HomeStrings.birthDateRequired : null; _genderError = genderMissing ? HomeStrings.genderRequired : null; }); if (!formOk || _birthDate == null || genderMissing) 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, ), ), ), Padding( padding: const EdgeInsets.only(top: 8), child: _GenderPillSelector( value: _gender, errorText: _genderError, onChanged: (v) => setState(() { _gender = v; _genderError = 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), ), ), ), ), ), ), ], ), ], ), ), ); } } const List _kGenderOptions = [ HomeStrings.male, HomeStrings.female, HomeStrings.other, ]; /// Seletor de género em formato de pílulas selecionáveis, no mesmo estilo /// visual das respostas Sim/Não do quiz — usado em vez de um dropdown /// genérico. class _GenderPillSelector extends StatelessWidget { const _GenderPillSelector({ required this.value, required this.onChanged, this.errorText, }); final String? value; final ValueChanged onChanged; final String? errorText; @override Widget build(BuildContext context) { final hasError = errorText != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( HomeStrings.gender, style: TextStyle( fontSize: 12, color: hasError ? AppColors.pink : Colors.black.withValues(alpha: 0.6), ), ), const SizedBox(height: 8), Row( children: [ for (var i = 0; i < _kGenderOptions.length; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded( child: _GenderPill( label: _kGenderOptions[i], selected: value == _kGenderOptions[i], onTap: () => onChanged(_kGenderOptions[i]), ), ), ], ], ), if (hasError) ...[ const SizedBox(height: 6), Text( errorText!, style: const TextStyle(color: AppColors.pink, fontSize: 12), ), ], const SizedBox(height: 4), ], ); } } class _GenderPill extends StatelessWidget { const _GenderPill({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return TapBounce( scale: 0.96, child: InkWell( borderRadius: BorderRadius.circular(999), onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), alignment: Alignment.center, decoration: BoxDecoration( color: selected ? AppColors.teal : Colors.transparent, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? AppColors.teal : Colors.black.withValues(alpha: 0.22), ), ), child: Text( label, style: TextStyle( fontWeight: FontWeight.w800, fontSize: 12.5, color: selected ? Colors.white : Colors.black87, ), ), ), ), ); } }