1833 lines
64 KiB
Dart
1833 lines
64 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/curiosidade_screen.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
|
|
? ''
|
|
: _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 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: 24,
|
|
child: Center(
|
|
child: TweenAnimationBuilder<double>(
|
|
duration: const Duration(milliseconds: 520),
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween<double>(
|
|
begin: 0,
|
|
end: percent / 100.0,
|
|
),
|
|
builder: (context, value, _) {
|
|
final shown = (value * 100).round();
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(
|
|
alpha: 0.18,
|
|
),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(
|
|
alpha: 0.22,
|
|
),
|
|
),
|
|
),
|
|
child: Text(
|
|
'$shown%',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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)
|
|
: _index == 1
|
|
? _PerfilTab(
|
|
selectedChildIndex: _selectedChildIndex,
|
|
onChildSelected: (index, name, scopeId) {
|
|
setState(() {
|
|
_selectedChildIndex = index;
|
|
_selectedChildName = name;
|
|
_selectedChildScopeId = scopeId;
|
|
});
|
|
_loadQuizResult();
|
|
},
|
|
)
|
|
: const _ConfigTab(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.settings_rounded),
|
|
label: 'Config.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560),
|
|
child: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 18, bottom: 10),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_MenuButton(
|
|
label: 'Iniciar Quiz',
|
|
onPressed: () {
|
|
Navigator.of(context)
|
|
.push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => Quiz1Screen(scopeId: scopeId),
|
|
),
|
|
)
|
|
.then((_) => onQuizClosed());
|
|
},
|
|
),
|
|
const SizedBox(height: 14),
|
|
_VideoLibraryCard(
|
|
onOpenLibrary: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const VideoScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 14),
|
|
_MenuButton(
|
|
label: 'Curiosidades',
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const CuriosidadeScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 14),
|
|
const _ClinicsSection(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ClinicItem {
|
|
const ClinicItem({
|
|
required this.name,
|
|
required this.subtitle,
|
|
required this.rating,
|
|
required this.description,
|
|
required this.address,
|
|
required this.phone,
|
|
});
|
|
|
|
final String name;
|
|
final String subtitle;
|
|
final double rating;
|
|
final String description;
|
|
final String address;
|
|
final String phone;
|
|
}
|
|
|
|
class _ClinicsSection extends StatelessWidget {
|
|
const _ClinicsSection();
|
|
|
|
static const List<ClinicItem> _clinics = [
|
|
ClinicItem(
|
|
name: 'PóvaMed',
|
|
subtitle: 'Clínica Médica e Dentária',
|
|
rating: 4.9,
|
|
description:
|
|
'Atendimento odontopediátrico e familiar. Agende e tire dúvidas pelo telefone.',
|
|
address: 'R. Patrão Lagoa 12, 4490-578 Póvoa de Varzim',
|
|
phone: '+351 000 000 000',
|
|
),
|
|
ClinicItem(
|
|
name: 'ORTO-M',
|
|
subtitle: 'Ortodontia e Implantes',
|
|
rating: 3.8,
|
|
description:
|
|
'Avaliação inicial e planos de ortodontia. Atendimento por marcação.',
|
|
address: 'R. de 31 de Janeiro, 4490-533 Póvoa de Varzim',
|
|
phone: '+351 000 000 001',
|
|
),
|
|
ClinicItem(
|
|
name: 'Dentart',
|
|
subtitle: 'Clínica Médica E Dentária',
|
|
rating: 5.0,
|
|
description:
|
|
'Avaliação inicial e planos de ortodontia. Atendimento por marcação.',
|
|
address: 'Rua Ramalho Ortigão 198 R/C, 4490-678 Póvoa De Varzim',
|
|
phone: '+351 000 000 001',
|
|
),
|
|
ClinicItem(
|
|
name: 'S. Cipriano',
|
|
subtitle: 'Clínica Médico-Cirúrgica',
|
|
rating: 3.0,
|
|
description:
|
|
'Avaliação inicial e planos de ortodontia. Atendimento por marcação.',
|
|
address: 'Praça do Almada 7 2º, 4490-438 Póvoa De Varzim',
|
|
phone: '+351 000 000 001',
|
|
),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final double cardWidth = math.min(
|
|
constraints.maxWidth,
|
|
size.width * 0.78,
|
|
);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 8, top: 8),
|
|
child: Text(
|
|
'Clínicas Parceiras',
|
|
style: TextStyle(
|
|
color: Color(0xFF2F9E94),
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
height: 2.6,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2F9E94).withValues(alpha: 0.85),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
for (final clinic in _clinics) ...[
|
|
Center(
|
|
child: SizedBox(
|
|
width: cardWidth,
|
|
child: _ClinicCard(item: clinic),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClinicCard extends StatelessWidget {
|
|
const _ClinicCard({required this.item});
|
|
|
|
final ClinicItem item;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: const Color(0xFFFF55A7),
|
|
borderRadius: BorderRadius.circular(22),
|
|
elevation: 10,
|
|
shadowColor: Colors.black.withValues(alpha: 0.18),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(22),
|
|
onTap: () {
|
|
showModalBottomSheet<void>(
|
|
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: [
|
|
Text(
|
|
item.name,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFFFF55A7),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
item.subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.black.withValues(alpha: 0.72),
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_ClinicInfoRow(
|
|
label: 'Avaliação',
|
|
value: item.rating.toStringAsFixed(1),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_ClinicInfoRow(
|
|
label: 'Endereço',
|
|
value: item.address,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_ClinicInfoRow(label: 'Contato', value: item.phone),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
item.description,
|
|
style: TextStyle(
|
|
color: Colors.black.withValues(alpha: 0.72),
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.25,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.subtitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.90),
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
item.address,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.88),
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.18),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.star_rounded,
|
|
color: Colors.white,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
item.rating.toStringAsFixed(1),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClinicInfoRow extends StatelessWidget {
|
|
const _ClinicInfoRow({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 86,
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Color(0xFF2F9E94),
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: TextStyle(
|
|
color: Colors.black.withValues(alpha: 0.74),
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VideoLibraryCard extends StatelessWidget {
|
|
const _VideoLibraryCard({required this.onOpenLibrary});
|
|
|
|
final VoidCallback onOpenLibrary;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
final item = VideoScreen.library.isEmpty ? null : VideoScreen.library.first;
|
|
|
|
return SizedBox(
|
|
width: size.width * 0.78,
|
|
child: Material(
|
|
color: const Color(0xFFFF55A7),
|
|
borderRadius: BorderRadius.circular(22),
|
|
elevation: 10,
|
|
shadowColor: Colors.black.withValues(alpha: 0.18),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(22),
|
|
onTap: onOpenLibrary,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'Vídeos Educativos',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: Colors.white.withValues(alpha: 0.95),
|
|
size: 26,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (item == null)
|
|
Container(
|
|
height: 140,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.18),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
)
|
|
else
|
|
_VideoLargePreview(
|
|
title: item.title,
|
|
url: item.url,
|
|
onTap: onOpenLibrary,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VideoLargePreview extends StatelessWidget {
|
|
const _VideoLargePreview({
|
|
required this.title,
|
|
required this.url,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String title;
|
|
final String url;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 140,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
_VideoThumbnail(url: url),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withValues(alpha: 0.10),
|
|
Colors.black.withValues(alpha: 0.58),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Align(
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.play_circle_fill_rounded,
|
|
size: 58,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomLeft,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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();
|
|
|
|
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),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(16),
|
|
onTap: _updatingPhoto
|
|
? null
|
|
: () => _pickAndUploadProfilePhoto(
|
|
context,
|
|
uid,
|
|
),
|
|
child: Container(
|
|
width: 72,
|
|
height: 72,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFE6F1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
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: 40,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
profileName,
|
|
style: const TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFFFF55A7),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (children.isEmpty)
|
|
const SizedBox.shrink()
|
|
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: 46,
|
|
child: FilledButton(
|
|
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),
|
|
child: const Text('Adicionar outra criança'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ConfigTab extends StatelessWidget {
|
|
const _ConfigTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
final Color accentPink = const Color(0xFFFF55A7);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: size.width * 0.60,
|
|
height: 44,
|
|
child: FilledButton(
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor: accentPink,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
).copyWith(
|
|
animationDuration: const Duration(milliseconds: 180),
|
|
splashFactory: InkSparkle.splashFactory,
|
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
|
states,
|
|
) {
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return Colors.white.withValues(alpha: 0.14);
|
|
}
|
|
if (states.contains(WidgetState.hovered) ||
|
|
states.contains(WidgetState.focused)) {
|
|
return Colors.white.withValues(alpha: 0.08);
|
|
}
|
|
return null;
|
|
}),
|
|
),
|
|
onPressed: () async {
|
|
await FirebaseAuth.instance.signOut();
|
|
},
|
|
child: const Text('Sair'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MenuButton extends StatelessWidget {
|
|
const _MenuButton({required this.label, required this.onPressed});
|
|
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
const Color accentPink = Color(0xFFFF55A7);
|
|
|
|
return SizedBox(
|
|
width: size.width * 0.78,
|
|
height: 50,
|
|
child: FilledButton(
|
|
style:
|
|
FilledButton.styleFrom(
|
|
backgroundColor: accentPink,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
).copyWith(
|
|
animationDuration: const Duration(milliseconds: 180),
|
|
splashFactory: InkSparkle.splashFactory,
|
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((states) {
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return Colors.white.withValues(alpha: 0.14);
|
|
}
|
|
if (states.contains(WidgetState.hovered) ||
|
|
states.contains(WidgetState.focused)) {
|
|
return Colors.white.withValues(alpha: 0.08);
|
|
}
|
|
return null;
|
|
}),
|
|
),
|
|
onPressed: onPressed,
|
|
child: Text(label, textAlign: TextAlign.center),
|
|
),
|
|
);
|
|
}
|
|
}
|