2391 lines
85 KiB
Dart
2391 lines
85 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 'brushing_prefs.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 'watched_videos_prefs.dart';
|
|
import 'widgets/animated_nav_icon.dart';
|
|
import 'widgets/app_dialogs.dart';
|
|
import 'widgets/app_gradients.dart';
|
|
import 'widgets/entrance.dart';
|
|
import 'widgets/name_input_formatter.dart';
|
|
import 'widgets/pill_snackbar.dart';
|
|
import 'widgets/tap_bounce.dart';
|
|
|
|
/// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números.
|
|
final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$");
|
|
|
|
/// Calcula a idade a partir de `birth_date` (novo campo). Para crianças
|
|
/// cadastradas antes desta mudança, que só têm a coluna legada `age`, usa
|
|
/// esse valor como fallback.
|
|
int? _childAge(Map<String, dynamic> child) {
|
|
final birthDateRaw = (child['birth_date'] ?? '').toString().trim();
|
|
if (birthDateRaw.isNotEmpty) {
|
|
final birthDate = DateTime.tryParse(birthDateRaw);
|
|
if (birthDate != null) {
|
|
final now = DateTime.now();
|
|
int age = now.year - birthDate.year;
|
|
if (now.month < birthDate.month ||
|
|
(now.month == birthDate.month && now.day < birthDate.day)) {
|
|
age--;
|
|
}
|
|
return age;
|
|
}
|
|
}
|
|
final legacyAge = child['age'];
|
|
if (legacyAge is int) return legacyAge;
|
|
return int.tryParse((legacyAge ?? '').toString());
|
|
}
|
|
|
|
class LoggedHomeScreen extends StatefulWidget {
|
|
const LoggedHomeScreen({super.key});
|
|
|
|
@override
|
|
State<LoggedHomeScreen> createState() => _LoggedHomeScreenState();
|
|
}
|
|
|
|
class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
static const Color _teal = Color(0xFF2F9E94);
|
|
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1';
|
|
|
|
static const double _collapsedAppBarHeight = 80;
|
|
static const double _expandedAppBarHeight = 190;
|
|
static const double _nameOnlyAppBarHeight = 160;
|
|
|
|
int _index = 0;
|
|
|
|
int _selectedChildIndex = 0;
|
|
String? _selectedChildName;
|
|
String? _selectedChildScopeId;
|
|
|
|
int? _lastScore;
|
|
int? _lastMaxScore;
|
|
|
|
int? _brushingWeekCount;
|
|
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
|
bool _brushingDailyLimitReached = false;
|
|
int? _watchedVideoCount;
|
|
VideoData? _continueVideo;
|
|
|
|
String _cachedUserName = 'Sem nome';
|
|
String? _cachedPhotoUrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadQuizResult();
|
|
_loadInitialProfile();
|
|
refreshStats();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _maybeStartPendingQuiz();
|
|
});
|
|
}
|
|
|
|
/// Recarrega os dados locais de escovagem e vídeos assistidos da criança
|
|
/// atualmente selecionada. Chamado ao trocar de criança, ao editar a meta
|
|
/// semanal e depois de registar uma escovagem ou um vídeo completo.
|
|
Future<void> refreshStats() async {
|
|
final scope = (_selectedChildScopeId ?? '').trim();
|
|
if (scope.isEmpty) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_brushingWeekCount = null;
|
|
_weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
|
_brushingDailyLimitReached = false;
|
|
_watchedVideoCount = null;
|
|
_continueVideo = videoList.first;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final weekCount = await BrushingPrefs.getWeekCount(scope);
|
|
final goal = await BrushingPrefs.getWeeklyGoal(scope);
|
|
final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope);
|
|
final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope);
|
|
final watchedIds = await WatchedVideosPrefs.getWatchedIds(scope);
|
|
final continueVideo = videoList.firstWhere(
|
|
(v) => !watchedIds.contains(v.id),
|
|
orElse: () => videoList.last,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_brushingWeekCount = weekCount;
|
|
_weeklyGoal = goal;
|
|
_brushingDailyLimitReached = dailyLimitReached;
|
|
_watchedVideoCount = watchedCount;
|
|
_continueVideo = continueVideo;
|
|
});
|
|
}
|
|
|
|
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();
|
|
await refreshStats();
|
|
} catch (_) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
Future<void> _loadQuizResult() async {
|
|
final scope = (_selectedChildScopeId ?? '').trim();
|
|
final uid = supabase.auth.currentUser?.id;
|
|
final String? userId = (uid ?? '').trim().isEmpty ? null : uid;
|
|
|
|
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();
|
|
refreshStats();
|
|
}
|
|
|
|
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(40)),
|
|
)
|
|
: 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: ClipRRect(
|
|
borderRadius: BorderRadius.vertical(
|
|
bottom: Radius.circular(_index == 0 ? 40 : 0),
|
|
),
|
|
child: Container(
|
|
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
|
child: _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 + 34,
|
|
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: 4,
|
|
child: Center(
|
|
child: _RiskArcGauge(percent: percent),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
title: Align(
|
|
alignment: _index != 0
|
|
? Alignment.center
|
|
: (_selectedChildName ?? '').trim().isEmpty
|
|
? Alignment.centerLeft
|
|
: Alignment.topLeft,
|
|
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();
|
|
refreshStats();
|
|
},
|
|
)
|
|
: 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();
|
|
final scopeId = state?._selectedChildScopeId;
|
|
final featured = state?._continueVideo ?? videoList.first;
|
|
final hasWatchedAny = (state?._watchedVideoCount ?? 0) > 0;
|
|
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560),
|
|
child: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 18, bottom: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (selectedChildName.isNotEmpty) ...[
|
|
FadeSlideIn(
|
|
child: _HomeSectionLabel('Para $selectedChildName'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
FadeSlideIn(
|
|
child: TapBounce(
|
|
scale: 0.97,
|
|
child: _HeroQuizCard(onStartQuiz: () => _startQuiz(context)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 70),
|
|
child: _StatsRow(
|
|
brushingCount: state?._brushingWeekCount,
|
|
weeklyGoal: state?._weeklyGoal ?? BrushingPrefs.defaultWeeklyGoal,
|
|
brushedToday: state?._brushingDailyLimitReached ?? false,
|
|
watchedCount: state?._watchedVideoCount,
|
|
onTapBrushing: () => _logBrushing(context, state, scopeId),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 110),
|
|
child: const _HomeSectionLabel('Continuar onde parou'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 130),
|
|
child: _ContinueWatchingRow(
|
|
video: featured,
|
|
hasWatchedAny: hasWatchedAny,
|
|
onTap: () async {
|
|
await showVideoPlayerDialog(
|
|
context,
|
|
featured,
|
|
scopeId: scopeId,
|
|
);
|
|
await state?.refreshStats();
|
|
},
|
|
onViewAll: () async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => VideoScreen(scopeId: scopeId),
|
|
),
|
|
);
|
|
await state?.refreshStats();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _logBrushing(
|
|
BuildContext context,
|
|
_LoggedHomeScreenState? state,
|
|
String? scopeId,
|
|
) async {
|
|
final scope = (scopeId ?? '').trim();
|
|
if (scope.isEmpty) {
|
|
final uid = (supabase.auth.currentUser?.id ?? '').trim();
|
|
if (uid.isEmpty) return;
|
|
await _requireFirstChild(context, uid);
|
|
return;
|
|
}
|
|
if (!(await BrushingPrefs.canLogMore(scope))) {
|
|
if (context.mounted) {
|
|
showPillSnackBar(
|
|
context,
|
|
'Já registou as ${BrushingPrefs.maxPerDay} escovagens de hoje!',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
await BrushingPrefs.logToday(scope);
|
|
await state?.refreshStats();
|
|
if (context.mounted) {
|
|
showPillSnackBar(context, 'Escovagem registada!');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pequeno rótulo maiúsculo discreto usado para separar as secções da Home
|
|
/// ("Para {nome}", "Novo episódio", "Continuar a aprender").
|
|
class _HomeSectionLabel extends StatelessWidget {
|
|
const _HomeSectionLabel(this.text);
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(
|
|
text.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.6,
|
|
color: Colors.black.withValues(alpha: 0.45),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatsRow extends StatelessWidget {
|
|
const _StatsRow({
|
|
required this.brushingCount,
|
|
required this.weeklyGoal,
|
|
required this.brushedToday,
|
|
required this.watchedCount,
|
|
required this.onTapBrushing,
|
|
});
|
|
|
|
final int? brushingCount;
|
|
final int weeklyGoal;
|
|
final bool brushedToday;
|
|
final int? watchedCount;
|
|
final VoidCallback onTapBrushing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(
|
|
child: TapBounce(
|
|
scale: 0.97,
|
|
child: _StatCard(
|
|
icon: Icons.brush_rounded,
|
|
iconColor: const Color(0xFFFF55A7),
|
|
value: brushingCount == null
|
|
? '--'
|
|
: '$brushingCount/$weeklyGoal',
|
|
label: 'Escovagens esta semana',
|
|
done: brushedToday,
|
|
onTap: onTapBrushing,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.movie_filter_rounded,
|
|
iconColor: const Color(0xFF8E7CC3),
|
|
value: watchedCount == null ? '--' : '$watchedCount',
|
|
label: 'Episódios completos',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatCard extends StatelessWidget {
|
|
const _StatCard({
|
|
required this.icon,
|
|
required this.iconColor,
|
|
required this.value,
|
|
required this.label,
|
|
this.onTap,
|
|
this.done = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final Color iconColor;
|
|
final String value;
|
|
final String label;
|
|
final VoidCallback? onTap;
|
|
final bool done;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
elevation: 8,
|
|
shadowColor: Colors.black.withValues(alpha: 0.10),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: iconColor.withValues(alpha: 0.14),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: iconColor, size: 20),
|
|
),
|
|
if (done)
|
|
Positioned(
|
|
top: -4,
|
|
right: -4,
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF2F9E94),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.check_rounded,
|
|
size: 11,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 20),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _createChildViaSheet(
|
|
BuildContext context,
|
|
String uid,
|
|
) async {
|
|
final result = await showModalBottomSheet<Map<String, dynamic>?>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: 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;
|
|
showPillSnackBar(context, '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) {
|
|
final maxHeight = MediaQuery.sizeOf(ctx).height * 0.8;
|
|
return SafeArea(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 6, 18, 18),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const Text(
|
|
'Para qual criança é o quiz?',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFFFF55A7),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: children.map((c) {
|
|
final name = (c['name'] ?? '').toString();
|
|
final age = _childAge(c);
|
|
final label = age != null
|
|
? '$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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(null),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _HeroQuizCard extends StatelessWidget {
|
|
const _HeroQuizCard({required this.onStartQuiz});
|
|
|
|
final VoidCallback onStartQuiz;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
elevation: 12,
|
|
shadowColor: Colors.black.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(24),
|
|
clipBehavior: Clip.antiAlias,
|
|
color: Colors.transparent,
|
|
child: Ink(
|
|
decoration: const BoxDecoration(gradient: kPinkHeroGradient),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
right: -30,
|
|
bottom: -30,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.14,
|
|
child: Container(
|
|
width: 140,
|
|
height: 140,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.bolt_rounded, color: Colors.white, size: 14),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Avaliação',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'Avaliação de saúde oral',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 19,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Leva menos de 3 minutos a completar',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: 48,
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFFFF55A7),
|
|
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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Linha compacta que mostra o próximo episódio por assistir (ou o último,
|
|
/// se já viu todos) para retomar o progresso de onde a criança parou. Um
|
|
/// segundo toque, no rodapé, abre a grelha completa de vídeos. Propositadamente
|
|
/// sem preview de vídeo real (sem [VideoThumbnail]/controllers) — evita a
|
|
/// contenção de decodificadores que já causou travamentos nesta app.
|
|
class _ContinueWatchingRow extends StatelessWidget {
|
|
const _ContinueWatchingRow({
|
|
required this.video,
|
|
required this.hasWatchedAny,
|
|
required this.onTap,
|
|
required this.onViewAll,
|
|
});
|
|
|
|
final VideoData video;
|
|
final bool hasWatchedAny;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onViewAll;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
elevation: 8,
|
|
shadowColor: Colors.black.withValues(alpha: 0.10),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TapBounce(
|
|
scale: 0.98,
|
|
child: InkWell(
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(18),
|
|
),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2F9E94),
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
child: const Icon(
|
|
Icons.play_arrow_rounded,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Continuar onde parou',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 15,
|
|
color: Color(0xFFFF55A7),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${video.title} · ${hasWatchedAny ? "continue de onde parou" : "comece a assistir"}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: Color(0xFF2F9E94),
|
|
size: 24,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
TapBounce(
|
|
scale: 0.98,
|
|
child: InkWell(
|
|
borderRadius: const BorderRadius.vertical(
|
|
bottom: Radius.circular(18),
|
|
),
|
|
onTap: onViewAll,
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 10),
|
|
child: Text(
|
|
'Ver todos os episódios',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w800,
|
|
color: Color(0xFF2F9E94),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: kGreenButtonGradient,
|
|
),
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
onPressed: () =>
|
|
Navigator.of(ctx).pop(ImageSource.camera),
|
|
child: const Text('Câmera'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: kGreenButtonGradient,
|
|
),
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
onPressed: () =>
|
|
Navigator.of(ctx).pop(ImageSource.gallery),
|
|
child: const Text('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;
|
|
showPillSnackBar(context, 'Erro ao enviar foto: $e');
|
|
} finally {
|
|
if (mounted) setState(() => _updatingPhoto = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _confirmDeleteChild(
|
|
BuildContext context, {
|
|
required String childId,
|
|
required String childName,
|
|
}) async {
|
|
final confirmed = await showConfirmDialog(
|
|
context,
|
|
title: '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 {
|
|
final deleted = await supabase
|
|
.from('children')
|
|
.delete()
|
|
.eq('id', childId)
|
|
.select('id');
|
|
|
|
if (deleted.isEmpty) {
|
|
throw StateError('Sem permissão para remover esta criança.');
|
|
}
|
|
|
|
widget.onChildSelected(0, null, null);
|
|
await _loadPerfilData();
|
|
if (context.mounted) showPillSnackBar(context, 'Criança removida');
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showPillSnackBar(context, 'Erro ao remover: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _editWeeklyGoal(
|
|
BuildContext context, {
|
|
required String scopeId,
|
|
required String childName,
|
|
}) async {
|
|
final current = await BrushingPrefs.getWeeklyGoal(scopeId);
|
|
if (!context.mounted) return;
|
|
final controller = TextEditingController(text: current.toString());
|
|
|
|
final saved = await showDialog<int>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
return AlertDialog(
|
|
backgroundColor: const Color(0xFFFFE6F1),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
title: Text(
|
|
'Meta semanal de $childName',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFFFF55A7),
|
|
),
|
|
),
|
|
content: TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Escovagens por semana',
|
|
helperText: 'Entre 1 e 21 (até 3 por dia)',
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(gradient: kGreenButtonGradient),
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
),
|
|
onPressed: () {
|
|
final value = int.tryParse(controller.text.trim());
|
|
if (value == null || value < 1 || value > 21) return;
|
|
Navigator.of(ctx).pop(value);
|
|
},
|
|
child: const Text('Guardar'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (saved == null) return;
|
|
await BrushingPrefs.setWeeklyGoal(scopeId, saved);
|
|
if (!context.mounted) return;
|
|
context.findAncestorStateOfType<_LoggedHomeScreenState>()?.refreshStats();
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _addAnotherChild(BuildContext context, String uid) async {
|
|
if (_addingChild) return;
|
|
final result = await showModalBottomSheet<Map<String, dynamic>?>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: 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();
|
|
if (context.mounted) showPillSnackBar(context, '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 || !context.mounted) return;
|
|
showPillSnackBar(context, 'Tempo esgotado ao adicionar. Tente novamente.');
|
|
} catch (e) {
|
|
if (!mounted || !context.mounted) return;
|
|
showPillSnackBar(context, '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 = _childAge(c);
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
onPressed: () => _editWeeklyGoal(
|
|
context,
|
|
scopeId: scopeId,
|
|
childName: title,
|
|
),
|
|
icon: const Icon(
|
|
Icons.edit_outlined,
|
|
color: Color(0xFF2F9E94),
|
|
),
|
|
tooltip: 'Meta semanal de escovagens',
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
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: ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: kGreenButtonGradient,
|
|
),
|
|
child: SizedBox(
|
|
height: 48,
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
onPressed: _addingChild
|
|
? null
|
|
: () => _addAnotherChild(context, uid),
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: const Text('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();
|
|
DateTime? _birthDate;
|
|
String? _gender;
|
|
String? _birthDateError;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickBirthDate() async {
|
|
final now = DateTime.now();
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _birthDate ?? DateTime(now.year - 5, now.month, now.day),
|
|
firstDate: DateTime(now.year - 17, now.month, now.day),
|
|
lastDate: DateTime(now.year - 1, now.month, now.day),
|
|
helpText: 'Data de nascimento',
|
|
cancelText: 'Cancelar',
|
|
confirmText: 'Confirmar',
|
|
locale: const Locale('pt', 'PT'),
|
|
);
|
|
if (picked == null) return;
|
|
setState(() {
|
|
_birthDate = picked;
|
|
_birthDateError = null;
|
|
});
|
|
}
|
|
|
|
void _submit() {
|
|
final formOk = _formKey.currentState?.validate() ?? false;
|
|
setState(() {
|
|
_birthDateError = _birthDate == null
|
|
? 'Informe a data de nascimento'
|
|
: null;
|
|
});
|
|
if (!formOk || _birthDate == null) return;
|
|
Navigator.of(context).pop({
|
|
'name': _nameController.text.trim(),
|
|
'birth_date': _birthDate!.toIso8601String().split('T').first,
|
|
'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,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
inputFormatters: [CapitalizeFirstLetterFormatter()],
|
|
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';
|
|
if (!_namePattern.hasMatch(value)) {
|
|
return 'O nome não pode conter números';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: _pickBirthDate,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Data de nascimento',
|
|
errorText: _birthDateError,
|
|
suffixIcon: const Icon(
|
|
Icons.calendar_today_rounded,
|
|
size: 18,
|
|
),
|
|
),
|
|
child: Text(
|
|
_birthDate == null
|
|
? 'Selecione a data'
|
|
: '${_birthDate!.day.toString().padLeft(2, '0')}/'
|
|
'${_birthDate!.month.toString().padLeft(2, '0')}/'
|
|
'${_birthDate!.year}',
|
|
style: _birthDate == null
|
|
? TextStyle(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _gender,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: '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: ClipRRect(
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: kGreenButtonGradient,
|
|
),
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
),
|
|
),
|
|
onPressed: _submit,
|
|
child: const Text('Adicionar'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|