3171 lines
112 KiB
Dart
3171 lines
112 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'colors/app_colors.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 'consultorios/address_edit_sheet.dart';
|
|
import 'consultorios/clinic.dart';
|
|
import 'consultorios/clinic_card.dart';
|
|
import 'consultorios/clinic_favorites_prefs.dart';
|
|
import 'consultorios/consultorios_screen.dart';
|
|
import 'consultorios/overpass_service.dart';
|
|
import 'main.dart' show supabase;
|
|
import 'quiz/quiz1.dart';
|
|
import 'quiz/quiz_prefs.dart';
|
|
import 'quiz/quiz_progress_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 'colors/app_gradients.dart';
|
|
import 'strings/address_strings.dart';
|
|
import 'strings/consultorios_strings.dart';
|
|
import 'strings/home_strings.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 = AppColors.teal;
|
|
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1';
|
|
|
|
static const double _collapsedAppBarHeight = kToolbarHeight;
|
|
static const double _expandedAppBarHeight = 256;
|
|
|
|
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 = HomeStrings.noName;
|
|
String? _cachedPhotoUrl;
|
|
|
|
String? _cachedAddress;
|
|
double? _cachedAddressLat;
|
|
double? _cachedAddressLon;
|
|
List<Clinic>? _cachedNearClinics;
|
|
List<Clinic>? _cachedFarClinics;
|
|
bool _loadingClinics = false;
|
|
String? _clinicsError;
|
|
Set<String> _favoriteClinicIds = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadQuizResult();
|
|
_loadInitialProfile();
|
|
refreshStats();
|
|
_loadFavoriteClinics();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _maybeStartPendingQuiz();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadFavoriteClinics() async {
|
|
final ids = await ClinicFavoritesPrefs.getFavoriteIds();
|
|
if (!mounted) return;
|
|
setState(() => _favoriteClinicIds = ids);
|
|
}
|
|
|
|
Future<void> _toggleFavoriteClinic(String clinicId) async {
|
|
final ids = await ClinicFavoritesPrefs.toggleFavorite(clinicId);
|
|
if (!mounted) return;
|
|
setState(() => _favoriteClinicIds = ids);
|
|
}
|
|
|
|
/// 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 storedAddress = (userDoc?['address'] ?? '').toString().trim();
|
|
final storedLat = userDoc?['address_lat'];
|
|
final storedLon = userDoc?['address_lon'];
|
|
|
|
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;
|
|
}
|
|
_cachedAddress = storedAddress.isNotEmpty ? storedAddress : null;
|
|
_cachedAddressLat = (storedLat is num) ? storedLat.toDouble() : null;
|
|
_cachedAddressLon = (storedLon is num) ? storedLon.toDouble() : null;
|
|
});
|
|
// Não aguarda — os consultórios aparecem assim que a Overpass
|
|
// responder, sem bloquear o resto da Home.
|
|
unawaited(_loadClinics());
|
|
await _loadQuizResult();
|
|
await refreshStats();
|
|
} catch (_) {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
/// Procura consultórios perto da morada guardada. Não faz nada sem
|
|
/// morada, e evita repetir o pedido à Overpass se as coordenadas não
|
|
/// mudaram desde o último carregamento (a não ser que [forceRefresh]).
|
|
Future<void> _loadClinics({bool forceRefresh = false}) async {
|
|
final lat = _cachedAddressLat;
|
|
final lon = _cachedAddressLon;
|
|
if (lat == null || lon == null) return;
|
|
if (!forceRefresh && _cachedNearClinics != null && _cachedFarClinics != null) {
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadingClinics = true;
|
|
_clinicsError = null;
|
|
});
|
|
|
|
try {
|
|
final result = await OverpassService.fetchNearbyClinics(lat: lat, lon: lon);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_cachedNearClinics = result.near;
|
|
_cachedFarClinics = result.far;
|
|
_loadingClinics = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_clinicsError = e.toString();
|
|
_loadingClinics = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
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 selectConsultoriosTab() {
|
|
setState(() => _index = 1);
|
|
}
|
|
|
|
void updateCachedPhoto(String url) {
|
|
setState(() => _cachedPhotoUrl = url);
|
|
}
|
|
|
|
String _greeting() {
|
|
final hour = DateTime.now().hour;
|
|
if (hour < 12) return HomeStrings.goodMorning;
|
|
if (hour < 18) return HomeStrings.goodAfternoon;
|
|
return HomeStrings.goodEvening;
|
|
}
|
|
|
|
/// 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: AppColors.background)),
|
|
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: AppColors.background,
|
|
selectedItemColor: _teal,
|
|
unselectedItemColor: Colors.black54,
|
|
type: BottomNavigationBarType.fixed,
|
|
items: [
|
|
BottomNavigationBarItem(
|
|
icon: AnimatedNavIcon(
|
|
icon: Icons.home_rounded,
|
|
selected: _index == 0,
|
|
),
|
|
label: HomeStrings.navHome,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: AnimatedNavIcon(
|
|
icon: Icons.medical_services_rounded,
|
|
selected: _index == 1,
|
|
),
|
|
label: ConsultoriosStrings.navConsultorios,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: AnimatedNavIcon(
|
|
icon: Icons.person_rounded,
|
|
selected: _index == 2,
|
|
),
|
|
label: HomeStrings.navProfile,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: AnimatedNavIcon(
|
|
icon: Icons.settings_rounded,
|
|
selected: _index == 3,
|
|
),
|
|
label: HomeStrings.navSettings,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@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).
|
|
// A app bar mantém sempre a mesma altura e mostra sempre os dois
|
|
// gauges — vazios (sem número, sem rótulo) quando ainda não há
|
|
// resultado, para o utilizador já perceber que aquele espaço é para
|
|
// o resultado do quiz mesmo antes de o fazer.
|
|
const double expandedHeight = _expandedAppBarHeight;
|
|
|
|
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 + 50,
|
|
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
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: kToolbarHeight + 96,
|
|
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: hasScore ? result.signs : null,
|
|
max: hasScore ? kSignsMax : null,
|
|
label: HomeStrings.signsGaugeLabel,
|
|
),
|
|
const SizedBox(width: 18),
|
|
_RiskArcGauge(
|
|
value: hasScore ? result.factors : null,
|
|
max: hasScore ? kFactorsMax : null,
|
|
label: HomeStrings.factorsGaugeLabel,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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 = 2),
|
|
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 = switch (_index) {
|
|
1 => ConsultoriosStrings.pageTitle,
|
|
2 => HomeStrings.navProfile,
|
|
_ => HomeStrings.settingsTitle,
|
|
};
|
|
|
|
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,
|
|
),
|
|
),
|
|
actions: _index == 1
|
|
? [
|
|
IconButton(
|
|
tooltip: ConsultoriosStrings.editAddressTooltip,
|
|
icon: const Icon(Icons.location_on_outlined),
|
|
onPressed: () async {
|
|
final saved = await showAddressEditSheet(
|
|
context,
|
|
initialAddress: _cachedAddress,
|
|
);
|
|
if (saved == true) {
|
|
await _loadInitialProfile();
|
|
await _loadClinics(forceRefresh: true);
|
|
}
|
|
},
|
|
),
|
|
]
|
|
: null,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.zero,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: _decoratedBody(
|
|
size,
|
|
switch (_index) {
|
|
1 => ConsultoriosTab(
|
|
address: _cachedAddress,
|
|
loading: _loadingClinics,
|
|
error: _clinicsError,
|
|
near: _cachedNearClinics ?? const [],
|
|
far: _cachedFarClinics ?? const [],
|
|
favoriteIds: _favoriteClinicIds,
|
|
onToggleFavorite: _toggleFavoriteClinic,
|
|
onRefresh: () => _loadClinics(forceRefresh: true),
|
|
onAddAddress: () async {
|
|
final saved = await showAddressEditSheet(
|
|
context,
|
|
initialAddress: _cachedAddress,
|
|
);
|
|
if (saved == true) {
|
|
await _loadInitialProfile();
|
|
await _loadClinics(forceRefresh: true);
|
|
}
|
|
},
|
|
),
|
|
2 => _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, this.label});
|
|
|
|
/// Quando [value]/[max] são nulos (sem criança selecionada, ou criança
|
|
/// ainda sem quiz feito), o gauge mostra-se vazio — só o anel em branco,
|
|
/// sem número nem rótulo — em vez de desaparecer, para o espaço já
|
|
/// indicar onde o resultado vai aparecer assim que existir.
|
|
final int? value;
|
|
final int? max;
|
|
final String? label;
|
|
|
|
bool get _isEmpty => value == null || max == null;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final progress = _isEmpty || max! <= 0
|
|
? 0.0
|
|
: (value! / max!).clamp(0, 1).toDouble();
|
|
|
|
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),
|
|
),
|
|
),
|
|
if (!_isEmpty) ...[
|
|
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 = AppColors.pinkLight
|
|
..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);
|
|
|
|
final progress = await QuizProgressPrefs.getProgress(scopeId);
|
|
if (!context.mounted) return;
|
|
|
|
Route<void> route;
|
|
if (progress != null) {
|
|
final resume = await _confirmResumeQuiz(context, childName: childName);
|
|
if (!context.mounted) return;
|
|
if (resume) {
|
|
route = quizResumeRoute(
|
|
questionIndex: progress.questionIndex,
|
|
score: progress.score,
|
|
scopeId: scopeId,
|
|
);
|
|
} else {
|
|
await QuizProgressPrefs.clearProgress();
|
|
route = quizStartRoute(scopeId: scopeId);
|
|
}
|
|
} else {
|
|
route = quizStartRoute(scopeId: scopeId);
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
await Navigator.of(context).push(route);
|
|
onQuizClosed();
|
|
}
|
|
|
|
/// Pergunta se quer continuar de onde parou ou recomeçar — mostrado só
|
|
/// quando há progresso guardado para a mesma criança escolhida agora.
|
|
/// Sem opção de cancelar: uma das duas ações inicia sempre o quiz.
|
|
Future<bool> _confirmResumeQuiz(
|
|
BuildContext context, {
|
|
required String childName,
|
|
}) async {
|
|
final resume = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) {
|
|
return AlertDialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
title: const Text(
|
|
HomeStrings.resumeQuizTitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontWeight: FontWeight.w900, color: AppColors.pink),
|
|
),
|
|
content: Text(
|
|
HomeStrings.resumeQuizMessage(childName),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.black.withValues(alpha: 0.72)),
|
|
),
|
|
actionsAlignment: MainAxisAlignment.center,
|
|
actions: [
|
|
TapBounce(
|
|
child: TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: AppColors.teal),
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: const Text(HomeStrings.restartQuiz),
|
|
),
|
|
),
|
|
TapBounce(
|
|
child: 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(),
|
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
|
),
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
child: const Text(HomeStrings.resumeQuiz),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
return resume ?? true;
|
|
}
|
|
|
|
@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(HomeStrings.forChild(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(HomeStrings.educationalVideos),
|
|
),
|
|
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>(
|
|
settings: const RouteSettings(
|
|
name: VideoScreen.routeName,
|
|
),
|
|
builder: (_) => VideoScreen(scopeId: scopeId),
|
|
),
|
|
);
|
|
await state?.refreshStats();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 150),
|
|
child: const _HomeSectionLabel(
|
|
ConsultoriosStrings.homePreviewTitle,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 170),
|
|
child: _ClinicsPreview(
|
|
address: state?._cachedAddress,
|
|
loading: state?._loadingClinics ?? false,
|
|
near: state?._cachedNearClinics ?? const [],
|
|
far: state?._cachedFarClinics ?? const [],
|
|
favoriteIds: state?._favoriteClinicIds ?? const {},
|
|
onToggleFavorite: (id) =>
|
|
state?._toggleFavoriteClinic(id),
|
|
onAddAddress: () async {
|
|
final saved = await showAddressEditSheet(
|
|
context,
|
|
initialAddress: state?._cachedAddress,
|
|
);
|
|
if (saved == true) {
|
|
await state?._loadInitialProfile();
|
|
await state?._loadClinics(forceRefresh: true);
|
|
}
|
|
},
|
|
onSeeAll: () => state?.selectConsultoriosTab(),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
FadeSlideIn(
|
|
delay: const Duration(milliseconds: 190),
|
|
child: Center(
|
|
child: Text(
|
|
HomeStrings.moreFeaturesSoon,
|
|
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,
|
|
HomeStrings.brushingLimitReached(BrushingPrefs.maxPerDay),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
await BrushingPrefs.logToday(scope);
|
|
await state?.refreshStats();
|
|
if (context.mounted) {
|
|
showPillSnackBar(context, HomeStrings.brushingLogged);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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: AppColors.pink,
|
|
value: brushingCount == null
|
|
? '--'
|
|
: '$brushingCount/$weeklyGoal',
|
|
label: HomeStrings.brushingThisWeek,
|
|
done: brushedToday,
|
|
onTap: onTapBrushing,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.movie_filter_rounded,
|
|
iconColor: AppColors.purple,
|
|
value: watchedCount == null ? '--' : '$watchedCount',
|
|
label: HomeStrings.completedEpisodes,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: AppColors.teal,
|
|
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: AppColors.pinkBackground,
|
|
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;
|
|
} on PostgrestException catch (e) {
|
|
if (!context.mounted) return null;
|
|
showPillSnackBar(
|
|
context,
|
|
e.code == '23505'
|
|
? HomeStrings.childCodeAlreadyInUse
|
|
: HomeStrings.errorAddingChild(e),
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
if (!context.mounted) return null;
|
|
showPillSnackBar(context, HomeStrings.errorAddingChild(e));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _requireFirstChild(
|
|
BuildContext context,
|
|
String uid,
|
|
) async {
|
|
final proceed = await showConfirmDialog(
|
|
context,
|
|
title: HomeStrings.registerAChild,
|
|
message: HomeStrings.registerAChildMessage,
|
|
confirmLabel: HomeStrings.addChild,
|
|
);
|
|
|
|
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 = AppColors.teal;
|
|
return showModalBottomSheet<Map<String, dynamic>?>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
backgroundColor: AppColors.pinkBackground,
|
|
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(
|
|
HomeStrings.whichChildIsTheQuizFor,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
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
|
|
? HomeStrings.childNameWithAge(name, age)
|
|
: 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(HomeStrings.cancel),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _HeroQuizCard extends StatelessWidget {
|
|
const _HeroQuizCard({required this.onStartQuiz});
|
|
|
|
final VoidCallback onStartQuiz;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
elevation: 18,
|
|
shadowColor: AppColors.pink.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(
|
|
HomeStrings.freeAssessmentBadge,
|
|
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(
|
|
HomeStrings.assessmentTitle,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 22,
|
|
height: 1.1,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
HomeStrings.assessmentSubtitle,
|
|
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: AppColors.pink,
|
|
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(HomeStrings.startQuiz),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pré-visualização de "Consultórios próximos" na Home — mostra até 2
|
|
/// consultórios mais próximos, um convite a registar morada se ainda não
|
|
/// houver uma, ou nada (silenciosamente) se a Overpass falhar sem haver
|
|
/// resultados em cache, para a Home não ficar com um erro alarmante.
|
|
class _ClinicsPreview extends StatelessWidget {
|
|
const _ClinicsPreview({
|
|
required this.address,
|
|
required this.loading,
|
|
required this.near,
|
|
required this.far,
|
|
required this.favoriteIds,
|
|
required this.onToggleFavorite,
|
|
required this.onAddAddress,
|
|
required this.onSeeAll,
|
|
});
|
|
|
|
final String? address;
|
|
final bool loading;
|
|
final List<Clinic> near;
|
|
final List<Clinic> far;
|
|
final Set<String> favoriteIds;
|
|
final ValueChanged<String> onToggleFavorite;
|
|
final VoidCallback onAddAddress;
|
|
final VoidCallback onSeeAll;
|
|
|
|
bool get _hasAddress => (address ?? '').trim().isNotEmpty;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_hasAddress) {
|
|
return TapBounce(
|
|
scale: 0.97,
|
|
child: Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onAddAddress,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
alignment: Alignment.center,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.pinkBackground,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.location_on_outlined,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
ConsultoriosStrings.noAddressTitle,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 13.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
ConsultoriosStrings.addAddress,
|
|
style: const TextStyle(
|
|
color: AppColors.teal,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: Colors.black38,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final preview = [...near, ...far].take(2).toList();
|
|
|
|
if (preview.isEmpty) {
|
|
if (!loading) return const SizedBox.shrink();
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.teal,
|
|
),
|
|
),
|
|
SizedBox(width: 12),
|
|
Text(
|
|
ConsultoriosStrings.loading,
|
|
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12.5),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (var i = 0; i < preview.length; i++) ...[
|
|
if (i > 0) const SizedBox(height: 10),
|
|
ClinicCard(
|
|
clinic: preview[i],
|
|
isFavorite: favoriteIds.contains(preview[i].id),
|
|
onToggleFavorite: onToggleFavorite,
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
TapBounce(
|
|
child: TextButton(
|
|
onPressed: onSeeAll,
|
|
child: const Text(
|
|
ConsultoriosStrings.seeAll,
|
|
style: TextStyle(color: AppColors.teal, fontWeight: FontWeight.w800),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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: AppColors.teal.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(
|
|
HomeStrings.videoLibraryBadge,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
const Text(
|
|
HomeStrings.educationalVideos,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 22,
|
|
height: 1.1,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
watchedCount > 0
|
|
? HomeStrings.watchedEpisodesSummary(
|
|
watchedCount,
|
|
videoList.length,
|
|
)
|
|
: HomeStrings.allEpisodesSummary(videoList.length),
|
|
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: AppColors.teal,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
onPressed: onTap,
|
|
icon: const Icon(Icons.video_library_rounded),
|
|
label: const Text(HomeStrings.watchVideos),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: AppColors.pinkBackground,
|
|
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(
|
|
HomeStrings.profilePhoto,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
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(HomeStrings.camera),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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(HomeStrings.gallery),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 42,
|
|
child: TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.teal,
|
|
),
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text(HomeStrings.cancel),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
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, HomeStrings.errorUploadingPhoto(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: HomeStrings.removeChild,
|
|
message: HomeStrings.removeChildConfirmMessage(childName),
|
|
confirmLabel: HomeStrings.remove,
|
|
confirmColor: AppColors.pink,
|
|
);
|
|
|
|
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(HomeStrings.noPermissionToRemoveChild);
|
|
}
|
|
|
|
widget.onChildSelected(0, null, null);
|
|
await _loadPerfilData();
|
|
if (context.mounted) showPillSnackBar(context, HomeStrings.childRemoved);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
showPillSnackBar(context, HomeStrings.errorRemovingChild(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: AppColors.pinkBackground,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
title: Text(
|
|
HomeStrings.weeklyGoalOf(childName),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
content: TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: HomeStrings.brushingsPerWeek,
|
|
helperText: HomeStrings.brushingGoalRange,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text(HomeStrings.cancel),
|
|
),
|
|
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(HomeStrings.save),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
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: AppColors.pinkBackground,
|
|
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, HomeStrings.childAdded);
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() => _addingChild = false);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
if (!mounted) return;
|
|
final addMore = await showConfirmDialog(
|
|
// ignore: use_build_context_synchronously
|
|
context,
|
|
title: HomeStrings.addAnotherChildQuestion,
|
|
cancelLabel: HomeStrings.notNow,
|
|
confirmLabel: HomeStrings.addAnother,
|
|
);
|
|
|
|
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,
|
|
HomeStrings.timeoutAdding,
|
|
);
|
|
} on PostgrestException catch (e) {
|
|
if (!mounted || !context.mounted) return;
|
|
showPillSnackBar(
|
|
context,
|
|
e.code == '23505'
|
|
? HomeStrings.childCodeAlreadyInUse
|
|
: HomeStrings.errorAdding(e),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted || !context.mounted) return;
|
|
showPillSnackBar(context, HomeStrings.errorAdding(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 : HomeStrings.noName;
|
|
|
|
if (uid.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
if (_initialLoading) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(top: 60),
|
|
child: CircularProgressIndicator(color: AppColors.teal),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 storedAddress = (data?['address'] ?? '').toString().trim();
|
|
|
|
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: AppColors.pinkBackground,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: AppColors.teal.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: AppColors.teal,
|
|
),
|
|
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: AppColors.pink,
|
|
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: AppColors.pink,
|
|
),
|
|
),
|
|
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: 14),
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
elevation: 4,
|
|
shadowColor: Colors.black.withValues(alpha: 0.08),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: () async {
|
|
final saved = await showAddressEditSheet(
|
|
context,
|
|
initialAddress: storedAddress.isEmpty
|
|
? null
|
|
: storedAddress,
|
|
);
|
|
if (saved == true) {
|
|
await _loadPerfilData();
|
|
if (!context.mounted) return;
|
|
final state = context
|
|
.findAncestorStateOfType<_LoggedHomeScreenState>();
|
|
await state?._loadInitialProfile();
|
|
await state?._loadClinics(forceRefresh: true);
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.teal.withValues(alpha: 0.12),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.location_on_outlined,
|
|
color: AppColors.teal,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
AddressStrings.perfilSectionLabel,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12.5,
|
|
color: Colors.black54,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
storedAddress.isNotEmpty
|
|
? storedAddress
|
|
: ConsultoriosStrings.addAddress,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 13.5,
|
|
color: storedAddress.isNotEmpty
|
|
? Colors.black87
|
|
: AppColors.teal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.edit_outlined,
|
|
color: AppColors.teal,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 22),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
|
child: Row(
|
|
children: [
|
|
const Text(
|
|
HomeStrings.myChildren,
|
|
style: TextStyle(
|
|
color: AppColors.teal,
|
|
fontWeight: FontWeight.w900,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.teal.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
'${children.length}',
|
|
style: const TextStyle(
|
|
color: AppColors.teal,
|
|
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: AppColors.pinkBackground,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Icon(
|
|
Icons.child_care_rounded,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
HomeStrings.noChildrenYet,
|
|
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 childCode = (c['child_code'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
final scopeId = '${uid}_$childId';
|
|
|
|
final title = childName.isNotEmpty
|
|
? childName
|
|
: HomeStrings.childFallbackName(i);
|
|
final subtitle = [
|
|
if (childAge != null) HomeStrings.ageLabel(childAge),
|
|
if (childGender.isNotEmpty)
|
|
HomeStrings.genderLabel(childGender),
|
|
if (childCode.isNotEmpty)
|
|
HomeStrings.codeLabel(childCode),
|
|
].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
|
|
? AppColors.pinkBackground
|
|
: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: selected
|
|
? AppColors.teal.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: AppColors.teal.withValues(
|
|
alpha: 0.10,
|
|
),
|
|
borderRadius: BorderRadius.circular(
|
|
999,
|
|
),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
color: AppColors.teal,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
onPressed: () => _editWeeklyGoal(
|
|
context,
|
|
scopeId: scopeId,
|
|
childName: title,
|
|
),
|
|
icon: const Icon(
|
|
Icons.edit_outlined,
|
|
color: AppColors.teal,
|
|
),
|
|
tooltip: HomeStrings.weeklyBrushingGoalTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
IconButton(
|
|
onPressed: () => _confirmDeleteChild(
|
|
context,
|
|
childId: childId,
|
|
childName: title,
|
|
),
|
|
icon: const Icon(
|
|
Icons.delete_outline_rounded,
|
|
color: AppColors.pink,
|
|
),
|
|
tooltip: HomeStrings.remove,
|
|
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(HomeStrings.addChild),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 22),
|
|
TapBounce(
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.pink,
|
|
side: const BorderSide(
|
|
color: AppColors.pink,
|
|
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(HomeStrings.signOut),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddChildSheet extends StatefulWidget {
|
|
const _AddChildSheet();
|
|
|
|
@override
|
|
State<_AddChildSheet> createState() => _AddChildSheetState();
|
|
}
|
|
|
|
class _AddChildSheetState extends State<_AddChildSheet> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _codeController = TextEditingController();
|
|
DateTime? _birthDate;
|
|
String? _gender;
|
|
String? _birthDateError;
|
|
String? _genderError;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_codeController.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: HomeStrings.birthDate,
|
|
cancelText: HomeStrings.cancel,
|
|
confirmText: HomeStrings.confirm,
|
|
locale: const Locale('pt', 'PT'),
|
|
);
|
|
if (picked == null) return;
|
|
setState(() {
|
|
_birthDate = picked;
|
|
_birthDateError = null;
|
|
});
|
|
}
|
|
|
|
void _submit() {
|
|
final formOk = _formKey.currentState?.validate() ?? false;
|
|
final genderMissing = (_gender ?? '').trim().isEmpty;
|
|
setState(() {
|
|
_birthDateError = _birthDate == null
|
|
? HomeStrings.birthDateRequired
|
|
: null;
|
|
_genderError = genderMissing ? HomeStrings.genderRequired : null;
|
|
});
|
|
if (!formOk || _birthDate == null || genderMissing) return;
|
|
Navigator.of(context).pop({
|
|
'name': _nameController.text.trim(),
|
|
'birth_date': _birthDate!.toIso8601String().split('T').first,
|
|
'gender': (_gender ?? '').trim(),
|
|
'child_code': _codeController.text.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(
|
|
HomeStrings.addAnotherChildTitle,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w900,
|
|
color: AppColors.pink,
|
|
),
|
|
),
|
|
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: HomeStrings.childName,
|
|
),
|
|
validator: (v) {
|
|
final value = (v ?? '').trim();
|
|
if (value.isEmpty) return HomeStrings.nameRequired;
|
|
if (value.length < 2) return HomeStrings.nameTooShort;
|
|
if (!_namePattern.hasMatch(value)) {
|
|
return HomeStrings.nameNoNumbers;
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
TextFormField(
|
|
controller: _codeController,
|
|
textInputAction: TextInputAction.next,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
decoration: const InputDecoration(
|
|
labelText: HomeStrings.childCode,
|
|
),
|
|
validator: (v) {
|
|
final value = (v ?? '').trim();
|
|
if (value.isEmpty) return HomeStrings.childCodeRequired;
|
|
return null;
|
|
},
|
|
),
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: _pickBirthDate,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: HomeStrings.birthDate,
|
|
errorText: _birthDateError,
|
|
suffixIcon: const Icon(
|
|
Icons.calendar_today_rounded,
|
|
size: 18,
|
|
),
|
|
),
|
|
child: Text(
|
|
_birthDate == null
|
|
? HomeStrings.selectDate
|
|
: '${_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,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: _GenderPillSelector(
|
|
value: _gender,
|
|
errorText: _genderError,
|
|
onChanged: (v) => setState(() {
|
|
_gender = v;
|
|
_genderError = null;
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: TextButton(
|
|
onPressed: () => Navigator.of(context).pop(null),
|
|
child: const Text(HomeStrings.cancel),
|
|
),
|
|
),
|
|
),
|
|
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(HomeStrings.add),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const List<String> _kGenderOptions = [
|
|
HomeStrings.male,
|
|
HomeStrings.female,
|
|
HomeStrings.other,
|
|
];
|
|
|
|
/// Seletor de género em formato de pílulas selecionáveis, no mesmo estilo
|
|
/// visual das respostas Sim/Não do quiz — usado em vez de um dropdown
|
|
/// genérico.
|
|
class _GenderPillSelector extends StatelessWidget {
|
|
const _GenderPillSelector({
|
|
required this.value,
|
|
required this.onChanged,
|
|
this.errorText,
|
|
});
|
|
|
|
final String? value;
|
|
final ValueChanged<String> onChanged;
|
|
final String? errorText;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasError = errorText != null;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
HomeStrings.gender,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: hasError
|
|
? AppColors.pink
|
|
: Colors.black.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
for (var i = 0; i < _kGenderOptions.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _GenderPill(
|
|
label: _kGenderOptions[i],
|
|
selected: value == _kGenderOptions[i],
|
|
onTap: () => onChanged(_kGenderOptions[i]),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (hasError) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
errorText!,
|
|
style: const TextStyle(color: AppColors.pink, fontSize: 12),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GenderPill extends StatelessWidget {
|
|
const _GenderPill({
|
|
required this.label,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TapBounce(
|
|
scale: 0.96,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(999),
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected ? AppColors.teal : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(
|
|
color: selected
|
|
? AppColors.teal
|
|
: Colors.black.withValues(alpha: 0.22),
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 12.5,
|
|
color: selected ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|