CTK 1.1.0
This commit is contained in:
212
lib/consultorios/address_edit_sheet.dart
Normal file
212
lib/consultorios/address_edit_sheet.dart
Normal file
@@ -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<bool?> showAddressEditSheet(
|
||||
BuildContext context, {
|
||||
String? initialAddress,
|
||||
}) {
|
||||
return showModalBottomSheet<bool>(
|
||||
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<AddressEditSheet> createState() => _AddressEditSheetState();
|
||||
}
|
||||
|
||||
class _AddressEditSheetState extends State<AddressEditSheet> {
|
||||
late final _addressController = TextEditingController(
|
||||
text: widget.initialAddress ?? '',
|
||||
);
|
||||
bool _saving = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_addressController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/consultorios/clinic.dart
Normal file
103
lib/consultorios/clinic.dart
Normal file
@@ -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<String, dynamic> element) {
|
||||
final tags = (element['tags'] as Map?)?.cast<String, dynamic>() ?? 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<String, dynamic>() ?? 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<String>().join(', '),
|
||||
tags['addr:postcode'],
|
||||
(tags['addr:city'] ?? tags['addr:suburb']),
|
||||
].whereType<String>().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,
|
||||
);
|
||||
}
|
||||
}
|
||||
212
lib/consultorios/clinic_card.dart
Normal file
212
lib/consultorios/clinic_card.dart
Normal file
@@ -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<String> onToggleFavorite;
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/consultorios/clinic_favorites_prefs.dart
Normal file
23
lib/consultorios/clinic_favorites_prefs.dart
Normal file
@@ -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<Set<String>> getFavoriteIds() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getStringList(_kKey) ?? const []).toSet();
|
||||
}
|
||||
|
||||
static Future<Set<String>> 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;
|
||||
}
|
||||
}
|
||||
227
lib/consultorios/consultorios_screen.dart
Normal file
227
lib/consultorios/consultorios_screen.dart
Normal file
@@ -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<Clinic> near;
|
||||
final List<Clinic> far;
|
||||
final Set<String> favoriteIds;
|
||||
final ValueChanged<String> onToggleFavorite;
|
||||
final Future<void> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/consultorios/geo_utils.dart
Normal file
20
lib/consultorios/geo_utils.dart
Normal file
@@ -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;
|
||||
52
lib/consultorios/geocoding_service.dart
Normal file
52
lib/consultorios/geocoding_service.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/consultorios/overpass_service.dart
Normal file
85
lib/consultorios/overpass_service.dart
Normal file
@@ -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<Clinic> near, List<Clinic> 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<NearbyClinics> 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<Map<String, dynamic>>()
|
||||
.map(Clinic.fromOverpassElement)
|
||||
.toList();
|
||||
|
||||
final withDistance = clinics
|
||||
.map((c) => c.copyWithDistance(haversineDistanceKm(lat, lon, c.lat, c.lon)))
|
||||
.toList()
|
||||
..sort((a, b) => a.distanceKm!.compareTo(b.distanceKm!));
|
||||
|
||||
final near = withDistance.where((c) => c.distanceKm! < _nearThresholdKm).toList();
|
||||
final far = withDistance.where((c) => c.distanceKm! >= _nearThresholdKm).toList();
|
||||
|
||||
return (near: near, far: far);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user