2567 lines
91 KiB
Dart
2567 lines
91 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'dart:io';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'brushing_prefs.dart';
|
|
import 'main.dart' show supabase;
|
|
import 'quiz/quiz1.dart';
|
|
import 'quiz/quiz_prefs.dart';
|
|
import 'quiz/quiz_result.dart' show kSignsMax, kFactorsMax;
|
|
import 'screens/settings_screen.dart';
|
|
import 'screens/video_screen.dart';
|
|
import 'watched_videos_prefs.dart';
|
|
import 'widgets/animated_nav_icon.dart';
|
|
import 'widgets/app_dialogs.dart';
|
|
import 'widgets/app_gradients.dart';
|
|
import 'widgets/entrance.dart';
|
|
import 'widgets/liquid_waves_background.dart';
|
|
import 'widgets/name_input_formatter.dart';
|
|
import 'widgets/pill_snackbar.dart';
|
|
import 'widgets/tap_bounce.dart';
|
|
|
|
/// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números.
|
|
final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$");
|
|
|
|
/// Calcula a idade a partir de `birth_date` (novo campo). Para crianças
|
|
/// cadastradas antes desta mudança, que só têm a coluna legada `age`, usa
|
|
/// esse valor como fallback.
|
|
int? _childAge(Map<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 = kToolbarHeight;
|
|
static const double _expandedAppBarHeight = 226;
|
|
static const double _nameOnlyAppBarHeight = 160;
|
|
|
|
int _index = 0;
|
|
|
|
int _selectedChildIndex = 0;
|
|
String? _selectedChildName;
|
|
String? _selectedChildScopeId;
|
|
|
|
QuizResultData? _lastResult;
|
|
|
|
int? _brushingWeekCount;
|
|
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
|
bool _brushingDailyLimitReached = false;
|
|
int? _watchedVideoCount;
|
|
|
|
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;
|
|
});
|
|
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);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_brushingWeekCount = weekCount;
|
|
_weeklyGoal = goal;
|
|
_brushingDailyLimitReached = dailyLimitReached;
|
|
_watchedVideoCount = watchedCount;
|
|
});
|
|
}
|
|
|
|
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(quizStartRoute(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;
|
|
|
|
QuizResultData? result;
|
|
|
|
if (scope.isNotEmpty && userId != null) {
|
|
final String childId = scope.startsWith('${userId}_')
|
|
? scope.substring(userId.length + 1)
|
|
: '';
|
|
if (childId.trim().isNotEmpty) {
|
|
try {
|
|
final childDoc = await supabase
|
|
.from('children')
|
|
.select()
|
|
.eq('id', childId)
|
|
.maybeSingle();
|
|
final signs = childDoc?['last_signs'];
|
|
final signsMax = childDoc?['last_signs_max'];
|
|
final factors = childDoc?['last_factors'];
|
|
final factorsMax = childDoc?['last_factors_max'];
|
|
if (signs is int &&
|
|
signsMax is int &&
|
|
factors is int &&
|
|
factorsMax is int) {
|
|
result = QuizResultData(
|
|
signs: signs,
|
|
signsMax: signsMax,
|
|
factors: factors,
|
|
factorsMax: factorsMax,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result != null) {
|
|
if (!mounted) return;
|
|
setState(() => _lastResult = result);
|
|
return;
|
|
}
|
|
if (scope.isNotEmpty) {
|
|
result = await QuizPrefs.getLastResultForScope(scope);
|
|
} else if (userId != null) {
|
|
result = await QuizPrefs.getLastResultForUser(userId);
|
|
} else {
|
|
result = await QuizPrefs.getLastResult();
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _lastResult = result);
|
|
}
|
|
|
|
void selectChild(String? name, String? scopeId) {
|
|
setState(() {
|
|
_selectedChildName = name;
|
|
_selectedChildScopeId = scopeId;
|
|
});
|
|
_loadQuizResult();
|
|
refreshStats();
|
|
}
|
|
|
|
void 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';
|
|
}
|
|
|
|
/// Fundo comum (cor sólida + ondas decorativas) atrás de qualquer aba, com
|
|
/// o conteúdo real posicionado por cima via [SafeArea]/[Padding].
|
|
Widget _decoratedBody(Size size, Widget child, double topPadding) {
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
|
|
const LiquidWavesBackground(),
|
|
SafeArea(
|
|
top: false,
|
|
child: Align(
|
|
alignment: Alignment.center,
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(16, topPadding, 16, 16),
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _bottomNav() {
|
|
return BottomNavigationBar(
|
|
currentIndex: _index,
|
|
onTap: (i) {
|
|
if (i == _index) return;
|
|
HapticFeedback.selectionClick();
|
|
setState(() => _index = i);
|
|
},
|
|
backgroundColor: const Color(0xFFFAFAF7),
|
|
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',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
|
|
final QuizResultData? result = _lastResult;
|
|
final bool hasScore = result != null;
|
|
final shownName = _cachedUserName;
|
|
|
|
if (_index == 0) {
|
|
// Home: a app bar é retrátil — encolhe (escondendo o nome da criança e
|
|
// o gauge) à medida que o utilizador scrolla a lista, mantendo só a
|
|
// linha do avatar/saudação fixa no topo. É um SliverAppBar dentro de
|
|
// um NestedScrollView em vez do AppBar fixo usado nas outras abas,
|
|
// para que a altura acompanhe o scroll em vez de ficar sempre cheia
|
|
// (o que cortava o conteúdo do card do quiz contra a barra ao rolar).
|
|
final double expandedHeight = hasScore
|
|
? _expandedAppBarHeight
|
|
: _nameOnlyAppBarHeight;
|
|
|
|
return Scaffold(
|
|
body: NestedScrollView(
|
|
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
|
SliverAppBar(
|
|
expandedHeight: expandedHeight,
|
|
toolbarHeight: kToolbarHeight,
|
|
pinned: true,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
backgroundColor: _teal,
|
|
foregroundColor: Colors.white,
|
|
surfaceTintColor: Colors.transparent,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(
|
|
bottom: Radius.circular(40),
|
|
),
|
|
),
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
titlePadding: EdgeInsets.zero,
|
|
background: ClipRRect(
|
|
borderRadius: const BorderRadius.vertical(
|
|
bottom: Radius.circular(40),
|
|
),
|
|
child: Container(
|
|
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (hasScore)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: kToolbarHeight + 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: kToolbarHeight,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
//posição da app bar relativamente ao nome
|
|
if (hasScore)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: kToolbarHeight + 60,
|
|
child: Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_RiskArcGauge(
|
|
// Usa sempre o máximo atual do quiz (não
|
|
// o que foi gravado na última avaliação)
|
|
// para não mostrar um denominador antigo
|
|
// quando o número de perguntas muda.
|
|
value: result.signs,
|
|
max: kSignsMax,
|
|
label: 'Sinais',
|
|
),
|
|
const SizedBox(width: 18),
|
|
_RiskArcGauge(
|
|
value: result.factors,
|
|
max: kFactorsMax,
|
|
label: 'Fatores de risco',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
title: Padding(
|
|
padding: const EdgeInsets.only(left: 16, right: 10),
|
|
child: TapBounce(
|
|
scale: 0.96,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(30),
|
|
onTap: () => setState(() => _index = 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
centerTitle: false,
|
|
titleSpacing: 0,
|
|
),
|
|
],
|
|
body: _decoratedBody(
|
|
size,
|
|
_InicioTab(onQuizClosed: _loadQuizResult),
|
|
0,
|
|
),
|
|
),
|
|
bottomNavigationBar: _bottomNav(),
|
|
);
|
|
}
|
|
|
|
final String title = _index == 1 ? 'Perfil' : 'Configurações';
|
|
|
|
return Scaffold(
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(_collapsedAppBarHeight),
|
|
// O gradiente é pintado por este Container de tamanho fixo (a
|
|
// própria altura da app bar), em vez de confiar no `flexibleSpace`
|
|
// do AppBar — em alguns aparelhos, o `flexibleSpace` de um AppBar
|
|
// comum (não Sliver) não recebia o tamanho esperado e a gradiente
|
|
// aparecia como cor sólida. Com o Container por baixo e o AppBar
|
|
// totalmente transparente por cima, o gradiente é garantido.
|
|
child: Container(
|
|
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
|
child: AppBar(
|
|
toolbarHeight: _collapsedAppBarHeight,
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: Colors.white,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
centerTitle: true,
|
|
title: Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.zero,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: _decoratedBody(
|
|
size,
|
|
_index == 1
|
|
? _PerfilTab(
|
|
selectedChildIndex: _selectedChildIndex,
|
|
onChildSelected: (index, name, scopeId) {
|
|
setState(() {
|
|
_selectedChildIndex = index;
|
|
_selectedChildName = name;
|
|
_selectedChildScopeId = scopeId;
|
|
});
|
|
_loadQuizResult();
|
|
refreshStats();
|
|
},
|
|
)
|
|
: const SettingsBody(),
|
|
10,
|
|
),
|
|
bottomNavigationBar: _bottomNav(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RiskArcGauge extends StatelessWidget {
|
|
const _RiskArcGauge({
|
|
required this.value,
|
|
required this.max,
|
|
required this.label,
|
|
});
|
|
|
|
final int value;
|
|
final int max;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final progress = max > 0 ? (value / max).clamp(0, 1).toDouble() : 0.0;
|
|
|
|
return TweenAnimationBuilder<double>(
|
|
duration: const Duration(milliseconds: 700),
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween<double>(begin: 0, end: progress),
|
|
builder: (context, animatedProgress, _) {
|
|
return SizedBox(
|
|
width: 108,
|
|
height: 92,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 60,
|
|
child: CustomPaint(
|
|
painter: _RiskArcGaugePainter(progress: animatedProgress),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 38,
|
|
left: 0,
|
|
right: 0,
|
|
child: Center(
|
|
child: Text(
|
|
'$value/$max',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w900,
|
|
height: 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 68,
|
|
left: 0,
|
|
right: 0,
|
|
child: Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.85),
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.15,
|
|
letterSpacing: 0.1,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RiskArcGaugePainter extends CustomPainter {
|
|
const _RiskArcGaugePainter({required this.progress});
|
|
|
|
final double progress;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final rect = Rect.fromLTWH(10, 8, size.width - 20, size.height * 1.7);
|
|
const startAngle = math.pi;
|
|
const sweepAngle = math.pi;
|
|
final strokeWidth = size.width * 0.12;
|
|
|
|
final backgroundPaint = Paint()
|
|
..color = Colors.white.withValues(alpha: 0.72)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = strokeWidth
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
final progressPaint = Paint()
|
|
..color = const Color(0xFFFF9AD0)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = strokeWidth
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint);
|
|
canvas.drawArc(
|
|
rect,
|
|
startAngle,
|
|
sweepAngle * progress.clamp(0, 1),
|
|
false,
|
|
progressPaint,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _RiskArcGaugePainter oldDelegate) {
|
|
return oldDelegate.progress != progress;
|
|
}
|
|
}
|
|
|
|
class _InicioTab extends StatelessWidget {
|
|
const _InicioTab({required this.onQuizClosed});
|
|
|
|
final VoidCallback onQuizClosed;
|
|
|
|
Future<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(quizStartRoute(scopeId: scopeId));
|
|
onQuizClosed();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
|
|
final selectedChildName = (state?._selectedChildName ?? '').trim();
|
|
final scopeId = state?._selectedChildScopeId;
|
|
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560),
|
|
child: SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 18, bottom: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (selectedChildName.isNotEmpty) ...[
|
|
FadeSlideIn(
|
|
child: _HomeSectionLabel('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('Vídeos educativos'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 130),
|
|
child: TapBounce(
|
|
scale: 0.97,
|
|
child: _VideoLibraryCard(
|
|
watchedCount: state?._watchedVideoCount ?? 0,
|
|
onTap: () async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => VideoScreen(scopeId: scopeId),
|
|
),
|
|
);
|
|
await state?.refreshStats();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 150),
|
|
child: Center(
|
|
child: Text(
|
|
'Mais funcionalidades em breve',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12.5,
|
|
color: Colors.black.withValues(alpha: 0.35),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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!');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Faz [child] "respirar" (escala sobe e desce suavemente, em loop) — usado
|
|
/// para chamar a atenção para os dois destaques principais da Home (o quiz
|
|
/// e o vídeo em destaque), que são o carro-chefe da app.
|
|
class _Pulse extends StatefulWidget {
|
|
const _Pulse({
|
|
required this.child,
|
|
this.minScale = 0.94,
|
|
this.maxScale = 1.0,
|
|
this.duration = const Duration(milliseconds: 1100),
|
|
});
|
|
|
|
final Widget child;
|
|
final double minScale;
|
|
final double maxScale;
|
|
final Duration duration;
|
|
|
|
@override
|
|
State<_Pulse> createState() => _PulseState();
|
|
}
|
|
|
|
class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller = AnimationController(
|
|
vsync: this,
|
|
duration: widget.duration,
|
|
)..repeat(reverse: true);
|
|
late final Animation<double> _scale = Tween(
|
|
begin: widget.minScale,
|
|
end: widget.maxScale,
|
|
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ScaleTransition(scale: _scale, child: widget.child);
|
|
}
|
|
}
|
|
|
|
/// Pequeno rótulo maiúsculo discreto usado para separar as secções da Home
|
|
/// ("Para {nome}", "Novo episódio", "Continuar a aprender").
|
|
class _HomeSectionLabel extends StatelessWidget {
|
|
const _HomeSectionLabel(this.text);
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(
|
|
text.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.6,
|
|
color: Colors.black.withValues(alpha: 0.45),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: 'Registe 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: 18,
|
|
shadowColor: const Color(0xFFFF55A7).withValues(alpha: 0.45),
|
|
borderRadius: BorderRadius.circular(28),
|
|
clipBehavior: Clip.antiAlias,
|
|
color: Colors.transparent,
|
|
child: Ink(
|
|
decoration: const BoxDecoration(gradient: kPinkHeroGradient),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
right: -34,
|
|
bottom: -34,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.14,
|
|
child: Container(
|
|
width: 160,
|
|
height: 160,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 4,
|
|
bottom: 10,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.16,
|
|
child: Transform.rotate(
|
|
angle: -0.22,
|
|
child: const Icon(
|
|
Icons.health_and_safety_rounded,
|
|
size: 92,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: -18,
|
|
top: -18,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.10,
|
|
child: Container(
|
|
width: 70,
|
|
height: 70,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.bolt_rounded,
|
|
color: Colors.white,
|
|
size: 14,
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Avaliação gratuita',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_Pulse(
|
|
minScale: 0.85,
|
|
maxScale: 1.15,
|
|
duration: const Duration(milliseconds: 900),
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
const Text(
|
|
'Avaliação de má oclusão dentária',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 22,
|
|
height: 1.1,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
'28 perguntas rápidas · menos de 3 minutos',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.92),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
_Pulse(
|
|
minScale: 0.985,
|
|
maxScale: 1.0,
|
|
duration: const Duration(milliseconds: 1400),
|
|
child: SizedBox(
|
|
height: 50,
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFFFF55A7),
|
|
shape: const StadiumBorder(),
|
|
elevation: 6,
|
|
shadowColor: Colors.black.withValues(alpha: 0.25),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15.5,
|
|
),
|
|
),
|
|
onPressed: onStartQuiz,
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
label: const Text('Iniciar Quiz'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Card de destaque (mesma linguagem visual do card do quiz: gradiente,
|
|
/// badge, título, botão branco) que convida a criança/pai a ir ver a
|
|
/// biblioteca de vídeos — sem nenhuma miniatura/imagem de vídeo específica,
|
|
/// só ícone e texto. Um único toque (no card ou no botão) leva direto à
|
|
/// grelha onde se escolhe qual episódio assistir.
|
|
class _VideoLibraryCard extends StatelessWidget {
|
|
const _VideoLibraryCard({required this.watchedCount, required this.onTap});
|
|
|
|
final int watchedCount;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
elevation: 14,
|
|
shadowColor: const Color(0xFF2F9E94).withValues(alpha: 0.38),
|
|
borderRadius: BorderRadius.circular(28),
|
|
clipBehavior: Clip.antiAlias,
|
|
color: Colors.transparent,
|
|
child: Ink(
|
|
decoration: const BoxDecoration(gradient: kGreenButtonGradient),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
right: -30,
|
|
bottom: -30,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.14,
|
|
child: Container(
|
|
width: 150,
|
|
height: 150,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 6,
|
|
bottom: 6,
|
|
child: IgnorePointer(
|
|
child: Opacity(
|
|
opacity: 0.16,
|
|
child: Transform.rotate(
|
|
angle: 0.2,
|
|
child: const Icon(
|
|
Icons.smart_display_rounded,
|
|
size: 88,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 5,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.play_circle_fill_rounded,
|
|
color: Colors.white,
|
|
size: 14,
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Biblioteca de vídeos',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
const Text(
|
|
'Vídeos educativos',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 22,
|
|
height: 1.1,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
watchedCount > 0
|
|
? '$watchedCount episódio${watchedCount == 1 ? '' : 's'} completo${watchedCount == 1 ? '' : 's'} · ${videoList.length} no total'
|
|
: '${videoList.length} episódios sobre saúde oral para toda a família',
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.92),
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
SizedBox(
|
|
height: 48,
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFF2F9E94),
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
onPressed: onTap,
|
|
icon: const Icon(Icons.video_library_rounded),
|
|
label: const Text('Ver vídeos'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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<QuizResultData?> _loadScoreForScope(String scopeId) {
|
|
return QuizPrefs.getLastResultForScope(scopeId);
|
|
}
|
|
|
|
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âmara'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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 a certeza que quer remover "$childName"? Esta 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<QuizResultData?>(
|
|
future: _loadScoreForScope(scopeId),
|
|
builder: (context, snap) {
|
|
final result = snap.data;
|
|
final text = result == null
|
|
? '--'
|
|
: '${result.signs}/$kSignsMax · ${result.factors}/$kFactorsMax';
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: 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
|
|
? 'Indique 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 'Indique 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'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|