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