Files
CheckTheethKids/lib/logged_home.dart
2026-05-22 11:10:49 +01:00

1687 lines
62 KiB
Dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lottie/lottie.dart';
import 'dart:async';
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import 'dart:math' as math;
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'quiz/quiz1.dart';
import 'quiz/quiz_prefs.dart';
import 'screens/video_screen.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;
int _index = 0;
int _selectedChildIndex = 0;
String? _selectedChildName;
String? _selectedChildScopeId;
int? _lastScore;
int? _lastMaxScore;
String _cachedUserName = 'Sem nome';
@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 = (FirebaseAuth.instance.currentUser?.uid ?? '').trim();
if (uid.isEmpty) return;
try {
final userDoc = await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.get();
final data = userDoc.data();
final storedName = (data?['name'] ?? '').toString().trim();
QuerySnapshot<Map<String, dynamic>> childrenSnap;
try {
childrenSnap = await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('children')
.orderBy('createdAt', descending: false)
.limit(1)
.get();
} catch (_) {
childrenSnap = await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('children')
.limit(1)
.get();
}
String? childName;
String? scopeId;
if (childrenSnap.docs.isNotEmpty) {
final c = childrenSnap.docs.first.data();
final childId = (c['id'] ?? childrenSnap.docs.first.id)
.toString()
.trim();
childName = (c['name'] ?? '').toString().trim();
scopeId = '${uid}_$childId';
}
if (!mounted) return;
setState(() {
_cachedUserName = storedName.isNotEmpty ? storedName : _cachedUserName;
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 = FirebaseAuth.instance.currentUser?.uid;
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 FirebaseFirestore.instance
.collection('users')
.doc(userId)
.collection('children')
.doc(childId)
.get();
final data = childDoc.data();
final s = data?['lastScore'];
final m = data?['lastMaxScore'];
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;
});
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final double appBarHeight = _index == 0
? _expandedAppBarHeight
: _collapsedAppBarHeight;
final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight;
final String title = _index == 0 ? '' : 'Perfil';
final ShapeBorder appBarShape = _index == 0
? const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
)
: const RoundedRectangleBorder(borderRadius: BorderRadius.zero);
final shownName = _cachedUserName;
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 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),
),
Positioned(
left: 0,
right: 0,
top: toolbarHeight + 26,
child: Center(
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white.withValues(alpha: 0.92),
fontSize: 14,
),
children: [
if (((_selectedChildName ?? '')
.trim()
.isNotEmpty))
TextSpan(text: _selectedChildName!.trim()),
if (((_selectedChildName ?? '')
.trim()
.isNotEmpty) &&
hasScore)
const WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12,
),
child: Text(
'',
style: TextStyle(color: Colors.white),
),
),
),
if (hasScore)
TextSpan(text: '$score/$maxScore'),
],
),
),
),
),
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: Text(
'Olá, $shownName',
textAlign: TextAlign.left,
style: const TextStyle(
fontWeight: FontWeight.w900,
color: Colors.white,
fontSize: 20,
),
),
)
: 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)
: _PerfilTab(
selectedChildIndex: _selectedChildIndex,
onChildSelected: (index, name, scopeId) {
setState(() {
_selectedChildIndex = index;
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
},
),
),
),
),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) => setState(() => _index = i),
backgroundColor: const Color(0xFFFFE6F1),
selectedItemColor: _teal,
unselectedItemColor: Colors.black54,
type: BottomNavigationBarType.fixed,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_rounded),
label: 'Início',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_rounded),
label: 'Perfil',
),
],
),
);
}
}
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: 178,
height: 94,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: CustomPaint(
painter: _RiskArcGaugePainter(progress: value),
),
),
Positioned(
bottom: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$shown%',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(height: 4),
Text(
'Risco de Má Oclusão',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontSize: 9,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
);
},
);
}
}
class _RiskArcGaugePainter extends CustomPainter {
const _RiskArcGaugePainter({required this.progress});
final double progress;
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromLTWH(16, 12, size.width - 32, size.height * 1.55);
const startAngle = math.pi;
const sweepAngle = math.pi;
final strokeWidth = size.width * 0.14;
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;
@override
Widget build(BuildContext context) {
final uid = (FirebaseAuth.instance.currentUser?.uid ?? '').trim();
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
final childScope = (state?._selectedChildScopeId ?? '').trim();
final scopeId = childScope.isNotEmpty
? childScope
: (uid.isNotEmpty ? uid : null);
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: [
_HeroQuizCard(
childName: selectedChildName,
onStartQuiz: () {
Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (_) => Quiz1Screen(scopeId: scopeId),
),
)
.then((_) => onQuizClosed());
},
),
const SizedBox(height: 16),
_VideoLibraryCard(
onOpenLibrary: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const VideoScreen(),
),
);
},
),
const SizedBox(height: 16),
],
),
),
),
),
);
}
}
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 item = VideoScreen.library.isEmpty ? null : VideoScreen.library.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: [
if (item == null)
Container(
height: 160,
decoration: BoxDecoration(
color: const Color(0xFFFFE6F1),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
)
else
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
child: SizedBox(
height: 160,
child: Stack(
fit: StackFit.expand,
children: [
_VideoThumbnail(url: item.url),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.black.withValues(alpha: 0.45),
],
),
),
),
const Align(
alignment: Alignment.center,
child: Icon(
Icons.play_circle_fill_rounded,
size: 54,
color: Colors.white,
),
),
],
),
),
),
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 _VideoThumbnail extends StatelessWidget {
const _VideoThumbnail({required this.url});
final String url;
@override
Widget build(BuildContext context) {
final id = YoutubePlayer.convertUrlToId(url);
final thumb = id == null ? null : 'https://img.youtube.com/vi/$id/0.jpg';
if (thumb == null) {
return Container(color: Colors.white.withValues(alpha: 0.12));
}
return Image.network(
thumb,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(color: Colors.white.withValues(alpha: 0.12)),
);
}
}
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;
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,
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Foto de perfil',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 16),
),
const SizedBox(height: 12),
SizedBox(
height: 46,
child: FilledButton(
onPressed: () => Navigator.of(ctx).pop(ImageSource.camera),
child: const Text('Câmera'),
),
),
const SizedBox(height: 10),
SizedBox(
height: 46,
child: FilledButton(
onPressed: () => Navigator.of(ctx).pop(ImageSource.gallery),
child: const Text('Galeria'),
),
),
const SizedBox(height: 10),
SizedBox(
height: 42,
child: TextButton(
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 ref = FirebaseStorage.instance
.ref()
.child('users')
.child(uid)
.child('profile.jpg');
await ref.putFile(file);
final url = await ref.getDownloadURL();
await FirebaseFirestore.instance.collection('users').doc(uid).set({
'photoUrl': url,
}, SetOptions(merge: true));
} 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> _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 childId = FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('children')
.doc()
.id;
final childMap = {
...result,
'id': childId,
'createdAt': FieldValue.serverTimestamp(),
};
setState(() => _addingChild = true);
try {
await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('children')
.doc(childId)
.set(childMap, SetOptions(merge: true))
.timeout(const Duration(seconds: 20));
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('Criança adicionada')),
);
if (mounted) {
setState(() => _addingChild = false);
}
if (!mounted) return;
if (!mounted) return;
final addMore = await showDialog<bool>(
// ignore: use_build_context_synchronously
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Adicionar outra criança?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Agora não'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('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 = FirebaseAuth.instance.currentUser;
final uid = (user?.uid ?? '').trim();
final name = (user?.displayName ?? '').trim();
final email = (user?.email ?? '').trim();
final shownName = name.isNotEmpty ? name : 'Sem nome';
if (uid.isEmpty) {
return const SizedBox.shrink();
}
return StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
stream: FirebaseFirestore.instance
.collection('users')
.doc(uid)
.snapshots(),
builder: (context, userSnapshot) {
final data = userSnapshot.data?.data();
final storedName = (data?['name'] ?? '').toString().trim();
final profileName = storedName.isNotEmpty ? storedName : shownName;
final photoUrl = (data?['photoUrl'] ?? '').toString().trim();
final storedEmail = (data?['email'] ?? '').toString().trim();
final profileEmail = storedEmail.isNotEmpty ? storedEmail : email;
return StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('children')
.orderBy('createdAt', descending: false)
.snapshots(),
builder: (context, childSnapshot) {
final docs = childSnapshot.data?.docs ?? const [];
final children = docs.map((d) => d.data()).toList();
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: [
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: [
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().isEmpty
? docs[i].id
: (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 Padding(
padding: const EdgeInsets.only(bottom: 12),
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),
),
),
);
},
),
],
),
),
),
);
}),
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),
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 FirebaseAuth.instance.signOut();
},
icon: const Icon(Icons.logout_rounded),
label: const Text('Sair'),
),
),
const SizedBox(height: 12),
],
),
),
),
),
);
},
);
},
);
}
}
class _AddChildDialog extends StatefulWidget {
const _AddChildDialog();
@override
State<_AddChildDialog> createState() => _AddChildDialogState();
}
class _AddChildDialogState extends State<_AddChildDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _ageController = TextEditingController();
String? _gender;
@override
void dispose() {
_nameController.dispose();
_ageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Adicionar outra criança'),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
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;
},
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop({
'name': _nameController.text.trim(),
'age': int.parse(_ageController.text.trim()),
'gender': (_gender ?? '').trim(),
});
},
child: const Text('Adicionar'),
),
],
);
}
}
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: 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'),
),
),
),
],
),
],
),
),
);
}
}