CTK 1.2.0

This commit is contained in:
Carlos Correia
2026-07-30 00:53:51 +01:00
parent 89b27e5e86
commit fc875d559c
7 changed files with 298 additions and 311 deletions

View File

@@ -12,8 +12,7 @@ class Clinic {
required this.lat,
required this.lon,
required this.address,
required this.phone,
required this.hasPhone,
required this.phoneNumbers,
required this.openingHours,
required this.hasOpeningHours,
this.distanceKm,
@@ -24,11 +23,17 @@ class Clinic {
final double lat;
final double lon;
final String address;
final String phone;
final bool hasPhone;
/// Um consultório na OSM pode ter mais do que um contacto na mesma tag
/// `phone` (separados por `;`/`,`) — já vêm separados aqui, para quem usa
/// isto poder oferecer escolha em vez de tentar ligar para o texto todo
/// de uma vez. Lista vazia quando não há telefone.
final List<String> phoneNumbers;
final String openingHours;
final bool hasOpeningHours;
bool get hasPhone => phoneNumbers.isNotEmpty;
/// Distância (km) até à morada guardada — só preenchida depois de
/// [OverpassService.fetchNearbyClinics] a calcular; `null` até lá.
final double? distanceKm;
@@ -40,8 +45,7 @@ class Clinic {
lat: lat,
lon: lon,
address: address,
phone: phone,
hasPhone: hasPhone,
phoneNumbers: phoneNumbers,
openingHours: openingHours,
hasOpeningHours: hasOpeningHours,
distanceKm: distanceKm,
@@ -76,10 +80,16 @@ class Clinic {
(tags['addr:city'] ?? tags['addr:suburb']),
].whereType<String>().where((s) => s.trim().isNotEmpty).toList();
final phone = (tags['phone'] as String?)?.trim() ??
final phoneRaw = (tags['phone'] as String?)?.trim() ??
(tags['contact:phone'] as String?)?.trim();
final phoneNumbers = (phoneRaw == null || phoneRaw.isEmpty)
? const <String>[]
: phoneRaw
.split(RegExp(r'[;,]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final openingHours = (tags['opening_hours'] as String?)?.trim();
final hasPhone = phone != null && phone.isNotEmpty;
final hasOpeningHours = openingHours != null && openingHours.isNotEmpty;
return Clinic(
@@ -92,8 +102,7 @@ class Clinic {
address: addressParts.isNotEmpty
? addressParts.join('')
: ConsultoriosStrings.noAddress,
phone: hasPhone ? phone : ConsultoriosStrings.noPhone,
hasPhone: hasPhone,
phoneNumbers: phoneNumbers,
openingHours: hasOpeningHours
? openingHours
: ConsultoriosStrings.noOpeningHours,

View File

@@ -23,8 +23,8 @@ class ClinicCard extends StatelessWidget {
final bool isFavorite;
final ValueChanged<String> onToggleFavorite;
Future<void> _call(BuildContext context) async {
final uri = Uri(scheme: 'tel', path: clinic.phone);
Future<void> _call(BuildContext context, String number) async {
final uri = Uri(scheme: 'tel', path: number);
try {
final launched = await launchUrl(uri);
if (!launched && context.mounted) {
@@ -35,6 +35,82 @@ class ClinicCard extends StatelessWidget {
}
}
/// Um consultório na OSM pode listar vários contactos na mesma tag — em
/// vez de tentar ligar para o texto todo (o que nem é um número válido),
/// mostra-se este seletor para escolher qual dos números ligar.
Future<void> _showNumberPicker(BuildContext context) async {
final chosen = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
backgroundColor: AppColors.pinkBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
ConsultoriosStrings.chooseNumberTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: AppColors.pink,
),
),
const SizedBox(height: 14),
for (final number in clinic.phoneNumbers) ...[
TapBounce(
scale: 0.98,
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Navigator.of(ctx).pop(number),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Row(
children: [
const Icon(
Icons.call_rounded,
color: AppColors.teal,
size: 18,
),
const SizedBox(width: 10),
Expanded(
child: Text(
number,
style: const TextStyle(
fontWeight: FontWeight.w800,
),
),
),
],
),
),
),
),
),
const SizedBox(height: 10),
],
],
),
),
);
},
);
if (chosen != null && context.mounted) await _call(context, chosen);
}
@override
Widget build(BuildContext context) {
return Container(
@@ -56,15 +132,6 @@ class ClinicCard extends StatelessWidget {
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,
@@ -132,45 +199,63 @@ class ClinicCard extends StatelessWidget {
],
),
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),
),
),
],
_buildPhoneButton(context),
],
),
);
}
Widget _buildPhoneButton(BuildContext context) {
final numbers = clinic.phoneNumbers;
final String label;
final VoidCallback? onTap;
if (numbers.isEmpty) {
label = ConsultoriosStrings.noPhone;
onTap = null;
} else if (numbers.length == 1) {
label = numbers.first;
onTap = () => _call(context, numbers.first);
} else {
label = ConsultoriosStrings.chooseNumber(numbers.length);
onTap = () => _showNumberPicker(context);
}
final enabled = onTap != null;
final color = enabled ? Colors.white : Colors.black.withValues(alpha: 0.35);
return TapBounce(
child: Material(
color: enabled ? AppColors.teal : Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
enabled ? Icons.call_rounded : Icons.call_outlined,
size: 16,
color: color,
),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 13,
color: color,
),
),
),
),
],
),
),
],
),
),
);
}