CTK 1.1.0

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

View File

@@ -42,5 +42,12 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Necessário para o url_launcher conseguir abrir a app de telefone
(tel:) a partir do Android 11 (API 30) em diante — ver
https://developer.android.com/training/package-visibility. -->
<intent>
<action android:name="android.intent.action.DIAL"/>
<data android:scheme="tel"/>
</intent>
</queries>
</manifest>

View 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),
),
),
),
),
),
),
],
),
],
),
),
);
}
}

View 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,
);
}
}

View 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,
),
),
),
],
);
}
}

View 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;
}
}

View 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),
),
),
],
),
),
);
}
}

View 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;

View 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;
}
}
}

View 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);
}
}

View File

@@ -9,6 +9,12 @@ import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'brushing_prefs.dart';
import 'consultorios/address_edit_sheet.dart';
import 'consultorios/clinic.dart';
import 'consultorios/clinic_card.dart';
import 'consultorios/clinic_favorites_prefs.dart';
import 'consultorios/consultorios_screen.dart';
import 'consultorios/overpass_service.dart';
import 'main.dart' show supabase;
import 'quiz/quiz1.dart';
import 'quiz/quiz_prefs.dart';
@@ -20,6 +26,8 @@ import 'watched_videos_prefs.dart';
import 'widgets/animated_nav_icon.dart';
import 'widgets/app_dialogs.dart';
import 'colors/app_gradients.dart';
import 'strings/address_strings.dart';
import 'strings/consultorios_strings.dart';
import 'strings/home_strings.dart';
import 'widgets/entrance.dart';
import 'widgets/liquid_waves_background.dart';
@@ -83,17 +91,39 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
String _cachedUserName = HomeStrings.noName;
String? _cachedPhotoUrl;
String? _cachedAddress;
double? _cachedAddressLat;
double? _cachedAddressLon;
List<Clinic>? _cachedNearClinics;
List<Clinic>? _cachedFarClinics;
bool _loadingClinics = false;
String? _clinicsError;
Set<String> _favoriteClinicIds = {};
@override
void initState() {
super.initState();
_loadQuizResult();
_loadInitialProfile();
refreshStats();
_loadFavoriteClinics();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _maybeStartPendingQuiz();
});
}
Future<void> _loadFavoriteClinics() async {
final ids = await ClinicFavoritesPrefs.getFavoriteIds();
if (!mounted) return;
setState(() => _favoriteClinicIds = ids);
}
Future<void> _toggleFavoriteClinic(String clinicId) async {
final ids = await ClinicFavoritesPrefs.toggleFavorite(clinicId);
if (!mounted) return;
setState(() => _favoriteClinicIds = ids);
}
/// Recarrega os dados locais de escovagem e vídeos assistidos da criança
/// atualmente selecionada. Chamado ao trocar de criança, ao editar a meta
/// semanal e depois de registar uma escovagem ou um vídeo completo.
@@ -164,6 +194,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
.maybeSingle();
final storedName = (userDoc?['name'] ?? '').toString().trim();
final storedPhotoUrl = (userDoc?['photo_url'] ?? '').toString().trim();
final storedAddress = (userDoc?['address'] ?? '').toString().trim();
final storedLat = userDoc?['address_lat'];
final storedLon = userDoc?['address_lon'];
final childrenSnap = await supabase
.from('children')
@@ -193,7 +226,13 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
(scopeId ?? '').trim().isNotEmpty) {
_selectedChildScopeId = scopeId;
}
_cachedAddress = storedAddress.isNotEmpty ? storedAddress : null;
_cachedAddressLat = (storedLat is num) ? storedLat.toDouble() : null;
_cachedAddressLon = (storedLon is num) ? storedLon.toDouble() : null;
});
// Não aguarda — os consultórios aparecem assim que a Overpass
// responder, sem bloquear o resto da Home.
unawaited(_loadClinics());
await _loadQuizResult();
await refreshStats();
} catch (_) {
@@ -201,6 +240,40 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
}
}
/// Procura consultórios perto da morada guardada. Não faz nada sem
/// morada, e evita repetir o pedido à Overpass se as coordenadas não
/// mudaram desde o último carregamento (a não ser que [forceRefresh]).
Future<void> _loadClinics({bool forceRefresh = false}) async {
final lat = _cachedAddressLat;
final lon = _cachedAddressLon;
if (lat == null || lon == null) return;
if (!forceRefresh && _cachedNearClinics != null && _cachedFarClinics != null) {
return;
}
if (!mounted) return;
setState(() {
_loadingClinics = true;
_clinicsError = null;
});
try {
final result = await OverpassService.fetchNearbyClinics(lat: lat, lon: lon);
if (!mounted) return;
setState(() {
_cachedNearClinics = result.near;
_cachedFarClinics = result.far;
_loadingClinics = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_clinicsError = e.toString();
_loadingClinics = false;
});
}
}
Future<void> _loadQuizResult() async {
final scope = (_selectedChildScopeId ?? '').trim();
final uid = supabase.auth.currentUser?.id;
@@ -265,6 +338,10 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
refreshStats();
}
void selectConsultoriosTab() {
setState(() => _index = 1);
}
void updateCachedPhoto(String url) {
setState(() => _cachedPhotoUrl = url);
}
@@ -320,15 +397,22 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.person_rounded,
icon: Icons.medical_services_rounded,
selected: _index == 1,
),
label: ConsultoriosStrings.navConsultorios,
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.person_rounded,
selected: _index == 2,
),
label: HomeStrings.navProfile,
),
BottomNavigationBarItem(
icon: AnimatedNavIcon(
icon: Icons.settings_rounded,
selected: _index == 2,
selected: _index == 3,
),
label: HomeStrings.navSettings,
),
@@ -443,7 +527,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: () => setState(() => _index = 1),
onTap: () => setState(() => _index = 2),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
@@ -513,7 +597,11 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
);
}
final String title = _index == 1 ? HomeStrings.navProfile : HomeStrings.settingsTitle;
final String title = switch (_index) {
1 => ConsultoriosStrings.pageTitle,
2 => HomeStrings.navProfile,
_ => HomeStrings.settingsTitle,
};
return Scaffold(
appBar: PreferredSize(
@@ -542,6 +630,24 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
color: Colors.white,
),
),
actions: _index == 1
? [
IconButton(
tooltip: ConsultoriosStrings.editAddressTooltip,
icon: const Icon(Icons.location_on_outlined),
onPressed: () async {
final saved = await showAddressEditSheet(
context,
initialAddress: _cachedAddress,
);
if (saved == true) {
await _loadInitialProfile();
await _loadClinics(forceRefresh: true);
}
},
),
]
: null,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
@@ -550,8 +656,28 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
),
body: _decoratedBody(
size,
_index == 1
? _PerfilTab(
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(() {
@@ -562,8 +688,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
_loadQuizResult();
refreshStats();
},
)
: const SettingsBody(),
),
_ => 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),

View File

@@ -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,

View File

@@ -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', ''),

View File

@@ -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',
),
],
),

View 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';
}

View 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';
}

View File

@@ -350,7 +350,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -795,7 +795,7 @@ packages:
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8

View File

@@ -42,6 +42,8 @@ dependencies:
shared_preferences: ^2.3.2
youtube_player_flutter: ^9.0.0
video_player: ^2.9.2
http: ^1.2.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test: