CTK 1.1.0

This commit is contained in:
Carlos Correia
2026-07-22 01:34:26 +01:00
parent f5884d8c36
commit 89b27e5e86
17 changed files with 1442 additions and 23 deletions

View File

@@ -9,6 +9,12 @@ 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';
@@ -20,6 +26,8 @@ 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';
@@ -83,17 +91,39 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
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.
@@ -164,6 +194,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
.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')
@@ -193,7 +226,13 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
(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 (_) {
@@ -201,6 +240,40 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
}
}
/// 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;
@@ -265,6 +338,10 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
refreshStats();
}
void selectConsultoriosTab() {
setState(() => _index = 1);
}
void updateCachedPhoto(String url) {
setState(() => _cachedPhotoUrl = url);
}
@@ -320,15 +397,22 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.person_rounded,
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 == 2,
selected: _index == 3,
),
label: HomeStrings.navSettings,
),
@@ -443,7 +527,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () => setState(() => _index = 1),
onTap: () => setState(() => _index = 2),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
@@ -513,7 +597,11 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
);
}
final String title = _index == 1 ? HomeStrings.navProfile : HomeStrings.settingsTitle;
final String title = switch (_index) {
1 => ConsultoriosStrings.pageTitle,
2 => HomeStrings.navProfile,
_ => HomeStrings.settingsTitle,
};
return Scaffold(
appBar: PreferredSize(
@@ -542,6 +630,24 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
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,
),
@@ -550,20 +656,41 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
),
body: _decoratedBody(
size,
_index == 1
? _PerfilTab(
selectedChildIndex: _selectedChildIndex,
onChildSelected: (index, name, scopeId) {
setState(() {
_selectedChildIndex = index;
_selectedChildName = name;
_selectedChildScopeId = scopeId;
});
_loadQuizResult();
refreshStats();
},
)
: const SettingsBody(),
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(),
@@ -888,9 +1015,40 @@ class _InicioTab extends StatelessWidget {
),
),
),
const SizedBox(height: 14),
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,
@@ -1477,6 +1635,153 @@ class _HeroQuizCard extends StatelessWidget {
}
}
/// 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,
@@ -2031,6 +2336,7 @@ class _PerfilTabState extends State<_PerfilTab> {
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
@@ -2180,6 +2486,88 @@ class _PerfilTabState extends State<_PerfilTab> {
),
),
),
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),