From 89b27e5e86d2fc9613ebbf563fd64f31e64ef69a Mon Sep 17 00:00:00 2001
From: Carlos Correia <240402@epvc.ptm>
Date: Wed, 22 Jul 2026 01:34:26 +0100
Subject: [PATCH] CTK 1.1.0
---
android/app/src/main/AndroidManifest.xml | 7 +
lib/consultorios/address_edit_sheet.dart | 212 +++++++++
lib/consultorios/clinic.dart | 103 +++++
lib/consultorios/clinic_card.dart | 212 +++++++++
lib/consultorios/clinic_favorites_prefs.dart | 23 +
lib/consultorios/consultorios_screen.dart | 227 ++++++++++
lib/consultorios/geo_utils.dart | 20 +
lib/consultorios/geocoding_service.dart | 52 +++
lib/consultorios/overpass_service.dart | 85 ++++
lib/logged_home.dart | 426 ++++++++++++++++++-
lib/quiz/quiz1.dart | 28 ++
lib/screens/credits_screen.dart | 2 +-
lib/screens/settings_screen.dart | 2 +-
lib/strings/address_strings.dart | 22 +
lib/strings/consultorios_strings.dart | 38 ++
pubspec.lock | 4 +-
pubspec.yaml | 2 +
17 files changed, 1442 insertions(+), 23 deletions(-)
create mode 100644 lib/consultorios/address_edit_sheet.dart
create mode 100644 lib/consultorios/clinic.dart
create mode 100644 lib/consultorios/clinic_card.dart
create mode 100644 lib/consultorios/clinic_favorites_prefs.dart
create mode 100644 lib/consultorios/consultorios_screen.dart
create mode 100644 lib/consultorios/geo_utils.dart
create mode 100644 lib/consultorios/geocoding_service.dart
create mode 100644 lib/consultorios/overpass_service.dart
create mode 100644 lib/strings/address_strings.dart
create mode 100644 lib/strings/consultorios_strings.dart
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 29ad3ba..acbd89a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,5 +42,12 @@
+
+
+
+
+
diff --git a/lib/consultorios/address_edit_sheet.dart b/lib/consultorios/address_edit_sheet.dart
new file mode 100644
index 0000000..67eaf3a
--- /dev/null
+++ b/lib/consultorios/address_edit_sheet.dart
@@ -0,0 +1,212 @@
+import 'package:flutter/material.dart';
+import '../colors/app_colors.dart';
+import '../colors/app_gradients.dart';
+
+import '../main.dart' show supabase;
+import '../strings/address_strings.dart';
+import '../strings/common_strings.dart';
+import '../widgets/tap_bounce.dart';
+import 'geocoding_service.dart';
+
+/// Mostra o formulário de morada num modal bottom sheet, no mesmo estilo
+/// já usado para o formulário de adicionar criança. Devolve `true` se a
+/// morada foi guardada com sucesso.
+Future showAddressEditSheet(
+ BuildContext context, {
+ String? initialAddress,
+}) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showDragHandle: true,
+ backgroundColor: AppColors.pinkBackground,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
+ ),
+ builder: (ctx) => AddressEditSheet(initialAddress: initialAddress),
+ );
+}
+
+class AddressEditSheet extends StatefulWidget {
+ const AddressEditSheet({super.key, this.initialAddress});
+
+ final String? initialAddress;
+
+ @override
+ State createState() => _AddressEditSheetState();
+}
+
+class _AddressEditSheetState extends State {
+ late final _addressController = TextEditingController(
+ text: widget.initialAddress ?? '',
+ );
+ bool _saving = false;
+ String? _errorText;
+
+ @override
+ void dispose() {
+ _addressController.dispose();
+ super.dispose();
+ }
+
+ Future _save() async {
+ final address = _addressController.text.trim();
+ if (address.isEmpty) {
+ setState(() => _errorText = AddressStrings.addressRequired);
+ return;
+ }
+
+ setState(() {
+ _saving = true;
+ _errorText = null;
+ });
+
+ final coords = await GeocodingService.geocodeAddress(address);
+ if (!mounted) return;
+ if (coords == null) {
+ setState(() {
+ _saving = false;
+ _errorText = AddressStrings.geocodeFailed;
+ });
+ return;
+ }
+
+ final uid = supabase.auth.currentUser?.id;
+ if (uid == null) {
+ setState(() => _saving = false);
+ return;
+ }
+
+ try {
+ await supabase.from('profiles').upsert({
+ 'id': uid,
+ 'address': address,
+ 'address_lat': coords.lat,
+ 'address_lon': coords.lon,
+ });
+ if (!mounted) return;
+ Navigator.of(context).pop(true);
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _saving = false;
+ _errorText = AddressStrings.saveError(e);
+ });
+ }
+ }
+
+ @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(
+ AddressStrings.title,
+ 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: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ TextField(
+ controller: _addressController,
+ enabled: !_saving,
+ textInputAction: TextInputAction.done,
+ textCapitalization: TextCapitalization.sentences,
+ onSubmitted: (_) => _save(),
+ decoration: InputDecoration(
+ labelText: AddressStrings.label,
+ hintText: AddressStrings.hint,
+ errorText: _errorText,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ AddressStrings.privacyDisclaimer,
+ style: TextStyle(
+ fontSize: 11.5,
+ fontWeight: FontWeight.w600,
+ color: Colors.black.withValues(alpha: 0.5),
+ height: 1.35,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 14),
+ Row(
+ children: [
+ Expanded(
+ child: SizedBox(
+ height: 44,
+ child: TextButton(
+ onPressed: _saving
+ ? null
+ : () => Navigator.of(context).pop(false),
+ child: const Text(CommonStrings.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: _saving ? null : _save,
+ child: _saving
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Text(AddressStrings.save),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/consultorios/clinic.dart b/lib/consultorios/clinic.dart
new file mode 100644
index 0000000..396aa40
--- /dev/null
+++ b/lib/consultorios/clinic.dart
@@ -0,0 +1,103 @@
+import '../strings/consultorios_strings.dart';
+
+/// Um consultório dentário devolvido pela Overpass API. Os campos de texto
+/// nunca são nulos — qualquer tag em falta no OpenStreetMap já é resolvida
+/// para um texto de substituição aqui — mas [hasPhone]/[hasOpeningHours]
+/// dizem se esse texto é um valor real ou só o substituto, para os widgets
+/// decidirem o que é "acionável" (ex.: só ligar quando há telefone real).
+class Clinic {
+ const Clinic({
+ required this.id,
+ required this.name,
+ required this.lat,
+ required this.lon,
+ required this.address,
+ required this.phone,
+ required this.hasPhone,
+ required this.openingHours,
+ required this.hasOpeningHours,
+ this.distanceKm,
+ });
+
+ final String id;
+ final String name;
+ final double lat;
+ final double lon;
+ final String address;
+ final String phone;
+ final bool hasPhone;
+ final String openingHours;
+ final bool hasOpeningHours;
+
+ /// Distância (km) até à morada guardada — só preenchida depois de
+ /// [OverpassService.fetchNearbyClinics] a calcular; `null` até lá.
+ final double? distanceKm;
+
+ Clinic copyWithDistance(double distanceKm) {
+ return Clinic(
+ id: id,
+ name: name,
+ lat: lat,
+ lon: lon,
+ address: address,
+ phone: phone,
+ hasPhone: hasPhone,
+ openingHours: openingHours,
+ hasOpeningHours: hasOpeningHours,
+ distanceKm: distanceKm,
+ );
+ }
+
+ /// Constrói a partir de um elemento da resposta da Overpass API (`node`
+ /// ou `way`, com `out center tags;` para que `way` também tenha
+ /// coordenadas via `element['center']`).
+ factory Clinic.fromOverpassElement(Map element) {
+ final tags = (element['tags'] as Map?)?.cast() ?? const {};
+
+ final double lat;
+ final double lon;
+ if (element['type'] == 'node') {
+ lat = (element['lat'] as num).toDouble();
+ lon = (element['lon'] as num).toDouble();
+ } else {
+ final center = (element['center'] as Map?)?.cast() ?? const {};
+ lat = ((center['lat'] as num?) ?? 0).toDouble();
+ lon = ((center['lon'] as num?) ?? 0).toDouble();
+ }
+
+ final name = (tags['name'] as String?)?.trim();
+
+ final addressParts = [
+ [
+ tags['addr:street'],
+ tags['addr:housenumber'],
+ ].whereType().join(', '),
+ tags['addr:postcode'],
+ (tags['addr:city'] ?? tags['addr:suburb']),
+ ].whereType().where((s) => s.trim().isNotEmpty).toList();
+
+ final phone = (tags['phone'] as String?)?.trim() ??
+ (tags['contact:phone'] as String?)?.trim();
+ final openingHours = (tags['opening_hours'] as String?)?.trim();
+ final hasPhone = phone != null && phone.isNotEmpty;
+ final hasOpeningHours = openingHours != null && openingHours.isNotEmpty;
+
+ return Clinic(
+ id: '${element['type']}/${element['id']}',
+ name: (name != null && name.isNotEmpty)
+ ? name
+ : ConsultoriosStrings.unnamedClinic,
+ lat: lat,
+ lon: lon,
+ address: addressParts.isNotEmpty
+ ? addressParts.join(' • ')
+ : ConsultoriosStrings.noAddress,
+ phone: hasPhone ? phone : ConsultoriosStrings.noPhone,
+ hasPhone: hasPhone,
+ openingHours: hasOpeningHours
+ ? openingHours
+ : ConsultoriosStrings.noOpeningHours,
+ hasOpeningHours: hasOpeningHours,
+ );
+ }
+}
diff --git a/lib/consultorios/clinic_card.dart b/lib/consultorios/clinic_card.dart
new file mode 100644
index 0000000..8de9154
--- /dev/null
+++ b/lib/consultorios/clinic_card.dart
@@ -0,0 +1,212 @@
+import 'package:flutter/material.dart';
+import '../colors/app_colors.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../strings/consultorios_strings.dart';
+import '../widgets/pill_snackbar.dart';
+import '../widgets/tap_bounce.dart';
+import 'clinic.dart';
+
+/// Cartão de um consultório — reutilizado no separador Consultórios e na
+/// pré-visualização "Consultórios próximos" da Home. A Overpass API não
+/// devolve fotos, por isso a caixa de imagem é sempre um placeholder liso
+/// (sem ícone).
+class ClinicCard extends StatelessWidget {
+ const ClinicCard({
+ super.key,
+ required this.clinic,
+ required this.isFavorite,
+ required this.onToggleFavorite,
+ });
+
+ final Clinic clinic;
+ final bool isFavorite;
+ final ValueChanged onToggleFavorite;
+
+ Future _call(BuildContext context) async {
+ final uri = Uri(scheme: 'tel', path: clinic.phone);
+ try {
+ final launched = await launchUrl(uri);
+ if (!launched && context.mounted) {
+ showPillSnackBar(context, ConsultoriosStrings.noPhoneAppAvailable);
+ }
+ } catch (e) {
+ if (context.mounted) showPillSnackBar(context, ConsultoriosStrings.callFailed(e));
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.06),
+ blurRadius: 14,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ color: AppColors.pinkBackground,
+ borderRadius: BorderRadius.circular(14),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ clinic.name,
+ style: const TextStyle(
+ fontWeight: FontWeight.w900,
+ fontSize: 14.5,
+ color: AppColors.teal,
+ ),
+ ),
+ ),
+ TapBounce(
+ scale: 0.85,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(999),
+ onTap: () => onToggleFavorite(clinic.id),
+ child: Padding(
+ padding: const EdgeInsets.all(2),
+ child: Icon(
+ isFavorite
+ ? Icons.favorite_rounded
+ : Icons.favorite_border_rounded,
+ size: 20,
+ color: isFavorite
+ ? AppColors.pink
+ : Colors.black.withValues(alpha: 0.3),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (clinic.distanceKm != null) ...[
+ const SizedBox(height: 2),
+ Text(
+ '${clinic.distanceKm!.toStringAsFixed(1)} ${ConsultoriosStrings.distanceKm}',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Colors.black.withValues(alpha: 0.45),
+ ),
+ ),
+ ],
+ const SizedBox(height: 6),
+ _ClinicInfoRow(
+ icon: Icons.access_time_rounded,
+ text: clinic.openingHours,
+ muted: !clinic.hasOpeningHours,
+ ),
+ const SizedBox(height: 3),
+ _ClinicInfoRow(
+ icon: Icons.place_outlined,
+ text: clinic.address,
+ muted: false,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ TapBounce(
+ child: Material(
+ color: clinic.hasPhone
+ ? AppColors.teal
+ : Colors.black.withValues(alpha: 0.06),
+ borderRadius: BorderRadius.circular(999),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(999),
+ onTap: clinic.hasPhone ? () => _call(context) : null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 11),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.call_rounded,
+ size: 16,
+ color: clinic.hasPhone
+ ? Colors.white
+ : Colors.black.withValues(alpha: 0.35),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ clinic.phone,
+ style: TextStyle(
+ fontWeight: FontWeight.w800,
+ fontSize: 13,
+ color: clinic.hasPhone
+ ? Colors.white
+ : Colors.black.withValues(alpha: 0.35),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ClinicInfoRow extends StatelessWidget {
+ const _ClinicInfoRow({
+ required this.icon,
+ required this.text,
+ required this.muted,
+ });
+
+ final IconData icon;
+ final String text;
+ final bool muted;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = Colors.black.withValues(alpha: muted ? 0.35 : 0.6);
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(icon, size: 14, color: color),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ text,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ fontStyle: muted ? FontStyle.italic : FontStyle.normal,
+ color: color,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/consultorios/clinic_favorites_prefs.dart b/lib/consultorios/clinic_favorites_prefs.dart
new file mode 100644
index 0000000..4d42e62
--- /dev/null
+++ b/lib/consultorios/clinic_favorites_prefs.dart
@@ -0,0 +1,23 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Consultórios marcados como favoritos, guardados localmente (por
+/// [Clinic.id]) — mesmo padrão simples de outros `*Prefs` da app
+/// (ex.: [WatchedVideosPrefs]), sem conta/servidor.
+class ClinicFavoritesPrefs {
+ const ClinicFavoritesPrefs._();
+
+ static const String _kKey = 'favorite_clinic_ids';
+
+ static Future> getFavoriteIds() async {
+ final prefs = await SharedPreferences.getInstance();
+ return (prefs.getStringList(_kKey) ?? const []).toSet();
+ }
+
+ static Future> toggleFavorite(String clinicId) async {
+ final prefs = await SharedPreferences.getInstance();
+ final ids = (prefs.getStringList(_kKey) ?? const []).toSet();
+ if (!ids.add(clinicId)) ids.remove(clinicId);
+ await prefs.setStringList(_kKey, ids.toList());
+ return ids;
+ }
+}
diff --git a/lib/consultorios/consultorios_screen.dart b/lib/consultorios/consultorios_screen.dart
new file mode 100644
index 0000000..383cd8b
--- /dev/null
+++ b/lib/consultorios/consultorios_screen.dart
@@ -0,0 +1,227 @@
+import 'package:flutter/material.dart';
+import '../colors/app_colors.dart';
+
+import '../strings/consultorios_strings.dart';
+import '../widgets/tap_bounce.dart';
+import 'clinic.dart';
+import 'clinic_card.dart';
+
+/// Conteúdo do separador Consultórios, embutido na navegação inferior do
+/// [LoggedHomeScreen] (sem Scaffold/AppBar próprios). Não busca dados
+/// sozinho — recebe tudo já carregado do ecrã pai (que mantém o cache vivo
+/// entre trocas de separador), e só pede um novo carregamento através dos
+/// callbacks fornecidos.
+class ConsultoriosTab extends StatelessWidget {
+ const ConsultoriosTab({
+ super.key,
+ required this.address,
+ required this.loading,
+ required this.error,
+ required this.near,
+ required this.far,
+ required this.favoriteIds,
+ required this.onToggleFavorite,
+ required this.onRefresh,
+ required this.onAddAddress,
+ });
+
+ /// Morada guardada, ou vazia/nula se o utilizador ainda não registou uma.
+ final String? address;
+ final bool loading;
+ final String? error;
+ final List near;
+ final List far;
+ final Set favoriteIds;
+ final ValueChanged onToggleFavorite;
+ final Future Function() onRefresh;
+ final VoidCallback onAddAddress;
+
+ bool get _hasAddress => (address ?? '').trim().isNotEmpty;
+
+ @override
+ Widget build(BuildContext context) {
+ if (!_hasAddress) {
+ return _EmptyState(
+ icon: Icons.location_on_outlined,
+ title: ConsultoriosStrings.noAddressTitle,
+ message: ConsultoriosStrings.noAddressMessage,
+ actionLabel: ConsultoriosStrings.addAddress,
+ onAction: onAddAddress,
+ );
+ }
+
+ if (loading && near.isEmpty && far.isEmpty && error == null) {
+ return const Center(
+ child: Padding(
+ padding: EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(color: AppColors.teal),
+ SizedBox(height: 14),
+ Text(
+ ConsultoriosStrings.loading,
+ textAlign: TextAlign.center,
+ style: TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ if (error != null) {
+ return _EmptyState(
+ icon: Icons.wifi_off_rounded,
+ title: ConsultoriosStrings.fetchErrorTitle,
+ message: ConsultoriosStrings.fetchErrorMessage,
+ actionLabel: ConsultoriosStrings.retry,
+ onAction: onRefresh,
+ );
+ }
+
+ if (near.isEmpty && far.isEmpty) {
+ return _EmptyState(
+ icon: Icons.search_off_rounded,
+ title: ConsultoriosStrings.noResults,
+ onAction: onRefresh,
+ actionLabel: ConsultoriosStrings.retry,
+ );
+ }
+
+ return RefreshIndicator(
+ color: AppColors.teal,
+ onRefresh: onRefresh,
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+ children: [
+ if (near.isNotEmpty) ...[
+ _SectionLabel(ConsultoriosStrings.sectionNear(near.length)),
+ const SizedBox(height: 10),
+ for (var i = 0; i < near.length; i++) ...[
+ if (i > 0) const SizedBox(height: 12),
+ ClinicCard(
+ clinic: near[i],
+ isFavorite: favoriteIds.contains(near[i].id),
+ onToggleFavorite: onToggleFavorite,
+ ),
+ ],
+ const SizedBox(height: 20),
+ ],
+ if (far.isNotEmpty) ...[
+ _SectionLabel(ConsultoriosStrings.sectionFar(far.length)),
+ const SizedBox(height: 10),
+ for (var i = 0; i < far.length; i++) ...[
+ if (i > 0) const SizedBox(height: 12),
+ ClinicCard(
+ clinic: far[i],
+ isFavorite: favoriteIds.contains(far[i].id),
+ onToggleFavorite: onToggleFavorite,
+ ),
+ ],
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _SectionLabel extends StatelessWidget {
+ const _SectionLabel(this.text);
+
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(left: 4),
+ child: Text(
+ text,
+ style: const TextStyle(
+ color: AppColors.pink,
+ fontWeight: FontWeight.w900,
+ fontSize: 14,
+ ),
+ ),
+ );
+ }
+}
+
+class _EmptyState extends StatelessWidget {
+ const _EmptyState({
+ required this.icon,
+ required this.title,
+ this.message,
+ required this.actionLabel,
+ required this.onAction,
+ });
+
+ final IconData icon;
+ final String title;
+ final String? message;
+ final String actionLabel;
+ final VoidCallback onAction;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 76,
+ height: 76,
+ decoration: BoxDecoration(
+ color: AppColors.pinkBackground,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(icon, color: AppColors.pink, size: 36),
+ ),
+ const SizedBox(height: 18),
+ Text(
+ title,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w900,
+ color: Colors.black87,
+ ),
+ ),
+ if (message != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ message!,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: Colors.black.withValues(alpha: 0.6),
+ height: 1.4,
+ ),
+ ),
+ ],
+ const SizedBox(height: 20),
+ TapBounce(
+ child: FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: AppColors.teal,
+ foregroundColor: Colors.white,
+ shape: const StadiumBorder(),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 24,
+ vertical: 14,
+ ),
+ textStyle: const TextStyle(fontWeight: FontWeight.w800),
+ ),
+ onPressed: onAction,
+ child: Text(actionLabel),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/consultorios/geo_utils.dart b/lib/consultorios/geo_utils.dart
new file mode 100644
index 0000000..90b8aec
--- /dev/null
+++ b/lib/consultorios/geo_utils.dart
@@ -0,0 +1,20 @@
+import 'dart:math';
+
+const double _earthRadiusKm = 6371;
+
+/// Distância em linha reta (km) entre duas coordenadas, pela fórmula de
+/// Haversine — suficiente para ordenar/agrupar consultórios por
+/// proximidade, sem precisar de nenhum pacote de mapas.
+double haversineDistanceKm(double lat1, double lon1, double lat2, double lon2) {
+ final dLat = _toRadians(lat2 - lat1);
+ final dLon = _toRadians(lon2 - lon1);
+ final a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(_toRadians(lat1)) *
+ cos(_toRadians(lat2)) *
+ sin(dLon / 2) *
+ sin(dLon / 2);
+ final c = 2 * atan2(sqrt(a), sqrt(1 - a));
+ return _earthRadiusKm * c;
+}
+
+double _toRadians(double degrees) => degrees * pi / 180;
diff --git a/lib/consultorios/geocoding_service.dart b/lib/consultorios/geocoding_service.dart
new file mode 100644
index 0000000..1c8716d
--- /dev/null
+++ b/lib/consultorios/geocoding_service.dart
@@ -0,0 +1,52 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+/// Geocoding de moradas via Nominatim (OpenStreetMap) — converte texto
+/// livre em coordenadas. Serviço gratuito e público; a política de uso da
+/// Nominatim exige no máximo ~1 pedido/segundo e um `User-Agent`
+/// identificável, por isso só chamamos isto quando o utilizador guarda a
+/// morada (nunca em cada abertura de ecrã).
+class GeocodingService {
+ const GeocodingService._();
+
+ static const String _endpoint = 'https://nominatim.openstreetmap.org/search';
+ static const String _userAgent = 'CheckTeethKids/1.0 (caducorreia740@gmail.com)';
+
+ /// Devolve as coordenadas da morada, ou `null` se a Nominatim não
+ /// encontrar nenhum resultado, a ligação falhar ou expirar o tempo
+ /// limite — quem chama decide como reagir (ex.: mostrar erro no campo).
+ static Future<({double lat, double lon})?> geocodeAddress(
+ String address,
+ ) async {
+ final uri = Uri.parse(_endpoint).replace(
+ queryParameters: {
+ 'q': address,
+ 'format': 'jsonv2',
+ 'limit': '1',
+ },
+ );
+
+ try {
+ final response = await http
+ .get(uri, headers: {'User-Agent': _userAgent})
+ .timeout(const Duration(seconds: 12));
+
+ if (response.statusCode != 200) return null;
+
+ final results = jsonDecode(response.body);
+ if (results is! List || results.isEmpty) return null;
+
+ final first = results.first;
+ if (first is! Map) return null;
+
+ final lat = double.tryParse('${first['lat']}');
+ final lon = double.tryParse('${first['lon']}');
+ if (lat == null || lon == null) return null;
+
+ return (lat: lat, lon: lon);
+ } catch (_) {
+ return null;
+ }
+ }
+}
diff --git a/lib/consultorios/overpass_service.dart b/lib/consultorios/overpass_service.dart
new file mode 100644
index 0000000..ce89de9
--- /dev/null
+++ b/lib/consultorios/overpass_service.dart
@@ -0,0 +1,85 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+import 'clinic.dart';
+import 'geo_utils.dart';
+
+/// Consultórios dentários encontrados perto de uma morada, já separados
+/// pela regra de negócio "menos de 5km" vs "mais distantes" — a única
+/// classe que conhece esse limite; os ecrãs só mostram o que recebem.
+typedef NearbyClinics = ({List near, List far});
+
+/// Pesquisa de consultórios dentários via Overpass API (dados do
+/// OpenStreetMap). `overpass-api.de` é um servidor público e comunitário,
+/// sem SLA — se se tornar um problema em produção, a solução é apontar
+/// para outra instância (ex.: overpass.kumi.systems), não redesenhar isto.
+class OverpassService {
+ const OverpassService._();
+
+ static const String _endpoint = 'https://overpass-api.de/api/interpreter';
+ static const String _userAgent = 'CheckTeethKids/1.0 (caducorreia740@gmail.com)';
+
+ /// Raio de pesquisa efetivo — mais largo que os 5km usados para
+ /// distinguir "perto"/"longe", para haver o que mostrar em "mais
+ /// distantes" mesmo em zonas menos densas.
+ static const int _searchRadiusMeters = 18000;
+ static const double _nearThresholdKm = 5;
+
+ static Future fetchNearbyClinics({
+ required double lat,
+ required double lon,
+ }) async {
+ const overpassTimeoutSeconds = 25;
+ final query =
+ '[out:json][timeout:$overpassTimeoutSeconds];'
+ '('
+ 'node["amenity"="dentist"](around:$_searchRadiusMeters,$lat,$lon);'
+ 'way["amenity"="dentist"](around:$_searchRadiusMeters,$lat,$lon);'
+ 'node["healthcare"="dentist"](around:$_searchRadiusMeters,$lat,$lon);'
+ 'way["healthcare"="dentist"](around:$_searchRadiusMeters,$lat,$lon);'
+ ');'
+ 'out center tags;';
+
+ // O timeout do lado do cliente fica acima do [timeout:] pedido à
+ // Overpass — assim é sempre a própria Overpass a desistir primeiro
+ // (com uma resposta 5xx tratável abaixo), em vez de uma corrida entre
+ // os dois a cortar a ligação a meio de uma resposta que já vinha a
+ // caminho.
+ final response = await http
+ .post(
+ Uri.parse(_endpoint),
+ headers: {'User-Agent': _userAgent},
+ body: {'data': query},
+ )
+ .timeout(const Duration(seconds: overpassTimeoutSeconds + 10));
+
+ if (response.statusCode != 200) {
+ throw Exception(
+ 'Overpass respondeu com status ${response.statusCode}: '
+ '${response.body.substring(0, response.body.length.clamp(0, 300))}',
+ );
+ }
+
+ final decoded = jsonDecode(response.body);
+ final elements = (decoded is Map ? decoded['elements'] : null) as List?;
+ if (elements == null) {
+ throw Exception('Resposta inesperada da Overpass API');
+ }
+
+ final clinics = elements
+ .cast