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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -387,6 +387,13 @@ class Quiz6Screen extends StatelessWidget {
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: QuizStrings.dontKnow,
|
||||
description: QuizStrings.dontKnowDescription,
|
||||
weight: 1,
|
||||
value: 'nao_sei',
|
||||
helpVideoId: 0,
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
scopeId: scopeId,
|
||||
@@ -650,6 +657,13 @@ class Quiz12Screen extends StatelessWidget {
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: QuizStrings.dontKnow,
|
||||
description: QuizStrings.dontKnowDescription,
|
||||
weight: 1,
|
||||
value: 'nao_sei',
|
||||
helpVideoId: 0,
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
scopeId: scopeId,
|
||||
@@ -688,6 +702,13 @@ class Quiz13Screen extends StatelessWidget {
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: QuizStrings.dontKnow,
|
||||
description: QuizStrings.dontKnowDescription,
|
||||
weight: 1,
|
||||
value: 'nao_sei',
|
||||
helpVideoId: 0,
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
scopeId: scopeId,
|
||||
@@ -726,6 +747,13 @@ class Quiz14Screen extends StatelessWidget {
|
||||
weight: 1,
|
||||
value: 'nao',
|
||||
),
|
||||
QuizAnswer(
|
||||
title: QuizStrings.dontKnow,
|
||||
description: QuizStrings.dontKnowDescription,
|
||||
weight: 1,
|
||||
value: 'nao_sei',
|
||||
helpVideoId: 0,
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
scopeId: scopeId,
|
||||
|
||||
@@ -43,7 +43,7 @@ const List<_CreditSection> _kCreditSections = [
|
||||
_CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.medRole),
|
||||
_CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.medRole),
|
||||
_CreditPerson('João Carlos Rodrigues L. Miranda',CreditsStrings.infRole,),
|
||||
_CreditPerson('Tiago Órfão)', CreditsStrings.otoringoRole),
|
||||
_CreditPerson('Tiago Órfãos', CreditsStrings.otoringoRole),
|
||||
]),
|
||||
_CreditSection(CreditsStrings.institutionSection, [
|
||||
_CreditPerson('Universidade Fernando Pessoa', ''),
|
||||
|
||||
@@ -154,7 +154,7 @@ class _SettingsBodyState extends State<SettingsBody> {
|
||||
const _InfoTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: SettingsStrings.appVersion,
|
||||
subtitle: '1.0.0',
|
||||
subtitle: '1.1.0',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
22
lib/strings/address_strings.dart
Normal file
22
lib/strings/address_strings.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
/// Texto do formulário de edição de morada ([AddressEditSheet]), acedido a
|
||||
/// partir do separador Perfil e da pré-visualização de Consultórios na Home.
|
||||
class AddressStrings {
|
||||
const AddressStrings._();
|
||||
|
||||
static const String title = 'A sua morada';
|
||||
static const String label = 'Morada';
|
||||
static const String hint = 'Rua, número, código postal, cidade';
|
||||
static const String privacyDisclaimer =
|
||||
'Esta morada é usada apenas para mostrar consultórios dentários '
|
||||
'perto de si; não é partilhada com terceiros.';
|
||||
|
||||
static const String addressRequired = 'Indique uma morada';
|
||||
static const String geocodeFailed =
|
||||
'Não foi possível localizar esta morada. Tente ser mais específico.';
|
||||
static String saveError(Object e) => 'Erro ao guardar morada: $e';
|
||||
|
||||
static const String save = 'Guardar';
|
||||
|
||||
static const String perfilSectionLabel = 'Morada';
|
||||
static const String editTooltip = 'Editar morada';
|
||||
}
|
||||
38
lib/strings/consultorios_strings.dart
Normal file
38
lib/strings/consultorios_strings.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Texto do separador "Consultórios" ([ConsultoriosTab]) e da sua
|
||||
/// pré-visualização na Home.
|
||||
class ConsultoriosStrings {
|
||||
const ConsultoriosStrings._();
|
||||
|
||||
static const String navConsultorios = 'Consultórios';
|
||||
static const String pageTitle = 'Consultórios';
|
||||
|
||||
static String _localCount(int count) => '$count locais${count == 1 ? '' : 'is'}';
|
||||
static String sectionNear(int count) => 'A menos de 5km · ${_localCount(count)}';
|
||||
static String sectionFar(int count) => 'Mais distantes · ${_localCount(count)}';
|
||||
|
||||
static const String unnamedClinic = 'Consultório sem nome';
|
||||
static const String noAddress = 'Endereço não informado';
|
||||
static const String noPhone = 'Telefone não informado';
|
||||
static const String noOpeningHours = 'Horário não informado';
|
||||
static const String editAddressTooltip = 'Editar morada';
|
||||
static const String noPhoneAppAvailable = 'Não foi possível abrir a app de telefone';
|
||||
static String callFailed(Object e) => 'Não foi possível ligar: $e';
|
||||
static const String distanceKm = 'km';
|
||||
|
||||
static const String noAddressTitle = 'Ainda não registou uma morada';
|
||||
static const String noAddressMessage =
|
||||
'Registe a sua morada para ver consultórios dentários perto de si.';
|
||||
static const String addAddress = 'Adicionar morada';
|
||||
|
||||
static const String loading = 'A procurar consultórios perto de si...';
|
||||
|
||||
static const String fetchErrorTitle = 'Não foi possível carregar';
|
||||
static const String fetchErrorMessage =
|
||||
'Não conseguimos obter os consultórios perto de si. Verifique a sua ligação e tente novamente.';
|
||||
static const String retry = 'Tentar novamente';
|
||||
|
||||
static const String noResults = 'Nenhum consultório encontrado perto de si.';
|
||||
|
||||
static const String homePreviewTitle = 'Consultórios próximos';
|
||||
static const String seeAll = 'Ver todos';
|
||||
}
|
||||
Reference in New Issue
Block a user