CTK 1.1.0
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user