Files
CheckTheethKids/lib/logged_home.dart

1910 lines
70 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lottie/lottie.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'dart:async';
import 'dart:math' as math;
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'main.dart' show supabase;
import 'quiz/quiz1.dart';
import 'quiz/quiz_prefs.dart';
import 'screens/settings_screen.dart';
import 'screens/video_screen.dart';
import 'widgets/animated_nav_icon.dart';
import 'widgets/app_dialogs.dart';
import 'widgets/entrance.dart';
import 'widgets/tap_bounce.dart';
class LoggedHomeScreen extends StatefulWidget {
const LoggedHomeScreen({super.key});
@override
State<LoggedHomeScreen> createState() => _LoggedHomeScreenState();
}
class _LoggedHomeScreenState extends State<LoggedHomeScreen>
with SingleTickerProviderStateMixin {
static const Color _teal = Color(0xFF2F9E94);
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1';
static const double _collapsedAppBarHeight = 104;
static const double _expandedAppBarHeight = 180;
static const double _nameOnlyAppBarHeight = 130;
int _index = 0;
int _selectedChildIndex = 0;
String? _selectedChildName;
String? _selectedChildScopeId;
int? _lastScore;
int? _lastMaxScore;
String _cachedUserName = 'Sem nome';
String? _cachedPhotoUrl;
@override
void initState() {
super.initState();
_loadQuizResult();
_loadInitialProfile();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _maybeStartPendingQuiz();
});
}
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(
MaterialPageRoute<void>(builder: (_) => Quiz1Screen(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();
} 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;
int? score;
int? max;
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 s = childDoc?['last_score'];
final m = childDoc?['last_max_score'];
if (s is int && m is int) {
score = s;
max = m;
}
} catch (_) {
// no-op
}
}
}
if (score != null && max != null) {
if (!mounted) return;
setState(() {
_lastScore = score;
_lastMaxScore = max;
});
return;
}
if (scope.isNotEmpty) {
score = await QuizPrefs.getLastScoreForScope(scope);
max = await QuizPrefs.getLastMaxScoreForScope(scope);
} else if (userId != null) {
score = await QuizPrefs.getLastScoreForUser(userId);
max = await QuizPrefs.getLastMaxScoreForUser(userId);
} else {
score = await QuizPrefs.getLastScore();
max = await QuizPrefs.getLastMaxScore();
}
if (!mounted) return;
setState(() {
_lastScore = score;
_lastMaxScore = max;
});
}
void selectChild(String? name, String? scopeId) {
setState(() {
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
}
void updateCachedPhoto(String url) {
setState(() => _cachedPhotoUrl = url);
}
String _greeting() {
final hour = DateTime.now().hour;
if (hour < 12) return 'Bom dia';
if (hour < 18) return 'Boa tarde';
return 'Boa noite';
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final int? score = _lastScore;
final int? maxScore = _lastMaxScore;
final bool hasScore = score != null && maxScore != null && maxScore > 0;
final int percent = hasScore ? ((score / maxScore) * 100).round() : 0;
final double appBarHeight = _index == 0
? (hasScore ? _expandedAppBarHeight : _nameOnlyAppBarHeight)
: _collapsedAppBarHeight;
final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight;
final String title = _index == 0
? ''
: _index == 1
? 'Perfil'
: 'Configurações';
final ShapeBorder appBarShape = _index == 0
? const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
)
: const RoundedRectangleBorder(borderRadius: BorderRadius.zero);
final shownName = _cachedUserName;
final double bodyTopPadding = _index == 0 ? 0 : 10;
return Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(appBarHeight),
child: AnimatedSize(
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: SizedBox(
height: appBarHeight,
child: AppBar(
toolbarHeight: toolbarHeight,
clipBehavior: Clip.antiAlias,
flexibleSpace: _index != 0
? null
: Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: 0.22,
child: Transform.scale(scale: 1.25),
),
if (hasScore)
Positioned(
left: 0,
right: 0,
top: toolbarHeight + 26,
child: Center(
child: Text(
(_selectedChildName ?? '').trim(),
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white.withValues(alpha: 0.92),
fontSize: 14,
),
),
),
)
else if ((_selectedChildName ?? '').trim().isNotEmpty)
Positioned(
left: 0,
right: 0,
top: toolbarHeight,
bottom: 0,
child: Center(
child: Text(
_selectedChildName!.trim(),
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white.withValues(alpha: 0.92),
fontSize: 14,
),
),
),
),
if (hasScore)
Positioned(
left: 0,
right: 0,
bottom: 12,
child: Center(
child: _RiskArcGauge(percent: percent),
),
),
],
),
title: Align(
alignment: _index == 0 ? Alignment.topLeft : Alignment.center,
child: _index == 0
? 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,
),
),
],
),
],
),
),
),
),
),
)
: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
),
centerTitle: _index == 0 ? false : true,
backgroundColor: _teal,
foregroundColor: Colors.white,
surfaceTintColor: _teal,
elevation: 0,
shape: appBarShape,
),
),
),
),
body: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: Container(color: const Color(0xFFFFE6F1))),
Positioned(
left: -size.width * 0.40,
bottom: -size.width * 0.45,
child: IgnorePointer(
child: SizedBox(
width: size.width * 1.05,
height: size.width * 1.05,
child: Transform.rotate(
angle: 35 * math.pi / 180,
child: Opacity(
opacity: 0.95,
child: Lottie.asset(
'lottie/Liquid waves.json',
fit: BoxFit.cover,
repeat: true,
),
),
),
),
),
),
SafeArea(
top: false,
child: Align(
alignment: Alignment.center,
child: Padding(
padding: EdgeInsets.fromLTRB(16, bodyTopPadding, 16, 16),
child: _index == 0
? _InicioTab(onQuizClosed: _loadQuizResult)
: _index == 1
? _PerfilTab(
selectedChildIndex: _selectedChildIndex,
onChildSelected: (index, name, scopeId) {
setState(() {
_selectedChildIndex = index;
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
},
)
: const SettingsBody(),
),
),
),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) {
if (i == _index) return;
HapticFeedback.selectionClick();
setState(() => _index = i);
},
backgroundColor: const Color(0xFFFFE6F1),
selectedItemColor: _teal,
unselectedItemColor: Colors.black54,
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.home_rounded,
selected: _index == 0,
),
label: 'Início',
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.person_rounded,
selected: _index == 1,
),
label: 'Perfil',
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.settings_rounded,
selected: _index == 2,
),
label: 'Ajustes',
),
],
),
);
}
}
class _RiskArcGauge extends StatelessWidget {
const _RiskArcGauge({required this.percent});
final int percent;
@override
Widget build(BuildContext context) {
final clamped = percent.clamp(0, 100);
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 700),
curve: Curves.easeOutCubic,
tween: Tween<double>(begin: 0, end: clamped / 100),
builder: (context, value, _) {
final shown = (value * 100).round();
return SizedBox(
width: 120,
height: 60,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CustomPaint(
painter: _RiskArcGaugePainter(progress: value),
),
),
Positioned(
top: 38,
left: 6,
right: 0,
child: Center(
child: Text(
'$shown%',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w900,
height: 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.butt;
final progressPaint = Paint()
..color = const Color(0xFFFF9AD0)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt;
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(
MaterialPageRoute<void>(builder: (_) => Quiz1Screen(scopeId: scopeId)),
);
onQuizClosed();
}
@override
Widget build(BuildContext context) {
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
final selectedChildName = (state?._selectedChildName ?? '').trim();
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: [
FadeSlideIn(
child: TapBounce(
scale: 0.97,
child: _HeroQuizCard(
childName: selectedChildName,
onStartQuiz: () => _startQuiz(context),
),
),
),
const SizedBox(height: 16),
FadeSlideIn(
delay: const Duration(milliseconds: 90),
child: TapBounce(
scale: 0.97,
child: _VideoLibraryCard(
onOpenLibrary: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const VideoScreen(),
),
);
},
),
),
),
const SizedBox(height: 16),
],
),
),
),
),
);
}
}
Future<Map<String, dynamic>?> _createChildViaSheet(
BuildContext context,
String uid,
) async {
final result = await showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: const Color(0xFFFFE6F1),
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;
} catch (e) {
if (!context.mounted) return null;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Erro ao adicionar criança: $e')));
return null;
}
}
Future<Map<String, dynamic>?> _requireFirstChild(
BuildContext context,
String uid,
) async {
final proceed = await showConfirmDialog(
context,
title: 'Cadastre uma criança',
message: 'Antes de iniciar o quiz, adicione uma criança ao seu perfil.',
confirmLabel: 'Adicionar criança',
);
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 = Color(0xFF2F9E94);
return showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: const Color(0xFFFFE6F1),
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(
'Para qual criança é o quiz?',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
),
),
const SizedBox(height: 14),
...children.map((c) {
final name = (c['name'] ?? '').toString();
final age = c['age'];
final label = age != null ? '$name$age anos' : 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,
),
],
),
),
),
),
),
);
}),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancelar'),
),
],
),
),
);
},
);
}
class _HeroQuizCard extends StatelessWidget {
const _HeroQuizCard({required this.childName, required this.onStartQuiz});
final String childName;
final VoidCallback onStartQuiz;
@override
Widget build(BuildContext context) {
const Color teal = Color(0xFF2F9E94);
const Color pink = Color(0xFFFF55A7);
return Material(
elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(24),
color: Colors.white,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: teal.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.medical_services_rounded,
color: teal,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Avaliação de saúde oral',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 16,
color: pink,
),
),
const SizedBox(height: 2),
Text(
childName.isNotEmpty
? 'Para $childName'
: 'Responda o quiz e descubra como cuidar melhor do sorriso.',
style: TextStyle(
color: Colors.black.withValues(alpha: 0.62),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 48,
width: double.infinity,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: pink,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
),
),
onPressed: onStartQuiz,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Iniciar Quiz'),
),
),
],
),
),
);
}
}
class _VideoLibraryCard extends StatelessWidget {
const _VideoLibraryCard({required this.onOpenLibrary});
final VoidCallback onOpenLibrary;
@override
Widget build(BuildContext context) {
final featured = videoList.firstWhere(
(v) => v.videoPath != null,
orElse: () => videoList.first,
);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
elevation: 12,
shadowColor: Colors.black.withValues(alpha: 0.18),
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onOpenLibrary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
child: SizedBox(
height: 160,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: const Color(0xFF2F9E94).withValues(alpha: 0.14),
),
VideoThumbnail(
video: featured,
borderRadius: 0,
iconSize: 54,
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.35),
],
stops: const [0.6, 1.0],
),
),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 14, 14),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFF2F9E94).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.video_library_rounded,
color: Color(0xFF2F9E94),
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vídeos Educativos',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFFFF55A7),
),
),
SizedBox(height: 2),
Text(
'Aprenda mais sobre saúde oral',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: Color(0xFF2F9E94),
size: 24,
),
],
),
),
],
),
),
);
}
}
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<(int?, int?)> _loadScoreForScope(String scopeId) async {
final score = await QuizPrefs.getLastScoreForScope(scopeId);
final max = await QuizPrefs.getLastMaxScoreForScope(scopeId);
return (score, max);
}
Future<void> _pickAndUploadProfilePhoto(
BuildContext context,
String uid,
) async {
if (_updatingPhoto) return;
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
backgroundColor: const Color(0xFFFFE6F1),
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(
'Foto de perfil',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
),
),
const SizedBox(height: 14),
SizedBox(
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w900),
),
onPressed: () => Navigator.of(ctx).pop(ImageSource.camera),
child: const Text('Câmera'),
),
),
const SizedBox(height: 10),
SizedBox(
height: 46,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w900),
),
onPressed: () => Navigator.of(ctx).pop(ImageSource.gallery),
child: const Text('Galeria'),
),
),
const SizedBox(height: 8),
SizedBox(
height: 42,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF2F9E94),
),
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancelar'),
),
),
],
),
),
);
},
);
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;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Erro ao enviar foto: $e')));
} finally {
if (mounted) setState(() => _updatingPhoto = false);
}
}
Future<void> _confirmDeleteChild(
BuildContext context, {
required String childId,
required String childName,
}) async {
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showConfirmDialog(
context,
title: 'Remover criança',
message:
'Tem certeza que deseja remover "$childName"? Essa ação não pode ser desfeita.',
confirmLabel: 'Remover',
confirmColor: const Color(0xFFFF55A7),
);
if (confirmed != true) return;
if (!context.mounted) return;
try {
await supabase.from('children').delete().eq('id', childId);
widget.onChildSelected(0, null, null);
await _loadPerfilData();
messenger.showSnackBar(
const SnackBar(content: Text('Criança removida')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Erro ao remover: $e')),
);
}
}
Future<void> _addAnotherChild(BuildContext context, String uid) async {
if (_addingChild) return;
final messenger = ScaffoldMessenger.of(context);
final result = await showModalBottomSheet<Map<String, dynamic>?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: const Color(0xFFFFE6F1),
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();
messenger.showSnackBar(
const SnackBar(content: Text('Criança adicionada')),
);
if (mounted) {
setState(() => _addingChild = false);
}
if (!mounted) return;
if (!mounted) return;
final addMore = await showConfirmDialog(
// ignore: use_build_context_synchronously
context,
title: 'Adicionar outra criança?',
cancelLabel: 'Agora não',
confirmLabel: 'Adicionar outra',
);
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) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Tempo esgotado ao adicionar. Tente novamente.'),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Erro ao adicionar: $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 : 'Sem nome';
if (uid.isEmpty) {
return const SizedBox.shrink();
}
if (_initialLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(color: Color(0xFF2F9E94)),
),
);
}
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: const Color(0xFFFFE6F1),
shape: BoxShape.circle,
border: Border.all(
color: const Color(
0xFF2F9E94,
).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: Color(0xFF2F9E94),
),
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: Color(0xFFFF55A7),
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: Color(0xFFFF55A7),
),
),
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(
'Meus filhos',
style: TextStyle(
color: Color(0xFF2F9E94),
fontWeight: FontWeight.w900,
fontSize: 15,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(
0xFF2F9E94,
).withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${children.length}',
style: const TextStyle(
color: Color(0xFF2F9E94),
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: const Color(0xFFFFE6F1),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.child_care_rounded,
color: Color(0xFFFF55A7),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Nenhuma criança adicionada ainda.',
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 = c['age'];
final childGender = (c['gender'] ?? '')
.toString()
.trim();
final scopeId = '${uid}_$childId';
final title = childName.isNotEmpty
? childName
: 'Criança ${i + 1}';
final subtitle = [
if (childAge != null) 'Idade: $childAge',
if (childGender.isNotEmpty)
'Gênero: $childGender',
].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
? const Color(0xFFFFE6F1)
: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? const Color(
0xFF2F9E94,
).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<(int?, int?)>(
future: _loadScoreForScope(scopeId),
builder: (context, snap) {
final tuple = snap.data;
final s = tuple?.$1;
final m = tuple?.$2;
final text =
(s == null || m == null || m <= 0)
? '--'
: '${(((s / m) * 100).round()).clamp(0, 100)}%';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(
0xFF2F9E94,
).withValues(alpha: 0.10),
borderRadius:
BorderRadius.circular(999),
),
child: Text(
text,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Color(0xFF2F9E94),
),
),
);
},
),
const SizedBox(width: 6),
IconButton(
onPressed: () => _confirmDeleteChild(
context,
childId: childId,
childName: title,
),
icon: const Icon(
Icons.delete_outline_rounded,
color: Color(0xFFFF55A7),
),
tooltip: 'Remover',
visualDensity: VisualDensity.compact,
),
],
),
),
),
),
),
);
}),
TapBounce(
child: SizedBox(
height: 48,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
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('Adicionar criança'),
),
),
),
const SizedBox(height: 22),
TapBounce(
child: SizedBox(
height: 46,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFF55A7),
side: const BorderSide(
color: Color(0xFFFF55A7),
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('Sair'),
),
),
),
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 _ageController = TextEditingController();
String? _gender;
@override
void dispose() {
_nameController.dispose();
_ageController.dispose();
super.dispose();
}
void _submit() {
if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop({
'name': _nameController.text.trim(),
'age': int.parse(_ageController.text.trim()),
'gender': (_gender ?? '').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(
'Adicionar outra criança',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
),
),
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,
decoration: const InputDecoration(
labelText: 'Nome da criança',
),
validator: (v) {
final value = (v ?? '').trim();
if (value.isEmpty) return 'Informe o nome';
if (value.length < 2) return 'Nome muito curto';
return null;
},
),
TextFormField(
controller: _ageController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(labelText: 'Idade'),
validator: (v) {
final raw = (v ?? '').trim();
if (raw.isEmpty) return 'Informe a idade';
final age = int.tryParse(raw);
if (age == null) return 'Idade inválida';
if (age < 0 || age > 25) return 'Idade inválida';
return null;
},
),
DropdownButtonFormField<String>(
initialValue: _gender,
items: const [
DropdownMenuItem(
value: 'Masculino',
child: Text('Masculino'),
),
DropdownMenuItem(
value: 'Feminino',
child: Text('Feminino'),
),
DropdownMenuItem(value: 'Outro', child: Text('Outro')),
],
onChanged: (v) => setState(() => _gender = v),
decoration: const InputDecoration(labelText: 'Gênero'),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Selecione o gênero';
}
return null;
},
),
],
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: SizedBox(
height: 44,
child: TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancelar'),
),
),
),
const SizedBox(width: 10),
Expanded(
child: TapBounce(
child: SizedBox(
height: 44,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: _submit,
child: const Text('Adicionar'),
),
),
),
),
],
),
],
),
),
);
}
}