fix(booking): read the live agenda and re-check the slot before confirming

/services/public-calendar also returns the professional's current opening
hours; the client threw them away and drew the day from the snapshot the
search list carried, which can be days old. It now uses the fresh ones.

Between picking an hour and pressing "Pedir cita" another patient can
take the slot. The confirm step asks the agenda again and, if the hour is
gone, says so and clears the selection instead of creating a service the
professional would have to deny. A failed re-check lets the booking
through: a network hiccup should not block a patient.

Also:
- tapping a busy slot with no id explains itself instead of doing nothing
- the profile refuses to save an empty rate with the rate switch on,
  which was reaching the backend as rate: ''

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-25 13:21:08 -05:00
co-authored by Claude Opus 5
parent 0a8e5d11a2
commit 6cb2755858
11 changed files with 176 additions and 49 deletions
+19 -9
View File
@@ -30,6 +30,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
late int numDay;
List<ServiceEntity>? _services;
Schedules? _schedules;
bool _loadFailed = false;
CalendarFormat _calendarFormat = CalendarFormat.month;
@@ -61,9 +62,14 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
// caller's own agenda, so every slot looked free.
serviceRepository
.getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
.then((services) {
.then((calendar) {
if (!mounted) return;
setState(() => _services = services);
setState(() {
_services = calendar.services;
// Opening hours as the professional has them today, not the copy the
// search list handed over, which can be days old.
_schedules = calendar.schedules;
});
}).catchError((e) {
if (!mounted) return;
setState(() => _loadFailed = true);
@@ -149,21 +155,25 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
}
ScheduleEntity? _getScheduleFromNumDay(int numDay) {
// Prefer the hours the calendar endpoint just returned; fall back to the
// snapshot carried by the search list only until it arrives.
final schedules =
_schedules ?? widget.userProfessional.professionalInfo.schedules;
switch (numDay) {
case 1:
return widget.userProfessional.professionalInfo.schedules.monday;
return schedules.monday;
case 2:
return widget.userProfessional.professionalInfo.schedules.tuesday;
return schedules.tuesday;
case 3:
return widget.userProfessional.professionalInfo.schedules.wednesday;
return schedules.wednesday;
case 4:
return widget.userProfessional.professionalInfo.schedules.thursday;
return schedules.thursday;
case 5:
return widget.userProfessional.professionalInfo.schedules.friday;
return schedules.friday;
case 6:
return widget.userProfessional.professionalInfo.schedules.saturday;
return schedules.saturday;
case 7:
return widget.userProfessional.professionalInfo.schedules.sunday;
return schedules.sunday;
default:
return null;
}
+42 -1
View File
@@ -23,9 +23,11 @@ import 'package:prosappco/screens/lists/professional_list_screen.dart';
import 'package:prosappco/screens/profile/profile_screen.dart';
import 'package:prosappco/screens/user/user_service_screen.dart';
import 'package:prosappco/utils/nominatim_geocoder.dart';
import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/service_day_param.dart';
import 'package:prosappco/utils/version_utils.dart';
import 'package:service_repository/service_repository.dart';
import 'package:service_repository/service_repository.dart' as srv;
import 'package:setting_repository/setting_repository.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -37,6 +39,7 @@ class UserMapScreen extends StatefulWidget {
}
class _UserMapScreenState extends State<UserMapScreen> {
final _serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
final Completer<GoogleMapController> _mapController =
Completer<GoogleMapController>();
LatLng? _currentP;
@@ -210,6 +213,38 @@ class _UserMapScreenState extends State<UserMapScreen> {
}
}
/// Re-reads the professional's agenda right before creating the service.
///
/// Returns true when the slot is still free, or when the agenda cannot be
/// read: blocking a booking because the network hiccuped would be worse
/// than the rare collision the backend can still reject.
Future<bool> _slotStillFree() async {
final pro = profesionalSeleccionado;
final day = fechaSeleccionada;
final hour = horaSeleccionada;
if (pro == null || day == null || hour == null) return false;
try {
final calendar = await _serviceRepository
.getPublicCalendar(pro.professionalInfo.recordId);
final taken = calendar.services.any((s) =>
s.status != srv.ServiceStatus.cancelled &&
s.status != srv.ServiceStatus.denied &&
isSameServiceDay(day, s.day) &&
s.range1Hour1 == hour);
if (!taken) return true;
if (!mounted) return false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Ese horario acaba de ocuparse. Elige otro.'),
));
setState(() => horaSeleccionada = null);
return false;
} catch (_) {
return true;
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<ServiceBloc>(
@@ -548,7 +583,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
child: FilledButton(
onPressed: isLoading
? null
: () {
: () async {
// Used to `return` in silence when anything was
// missing, so the main button simply did nothing.
final faltan = <String>[
@@ -580,6 +615,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
return;
}
// Minutes can pass between picking the hour and
// confirming. Ask the agenda again so two patients
// do not walk away holding the same slot.
if (!await _slotStillFree()) return;
if (!context.mounted) return;
if (serviceLocationPreference ==
ServiceLocationPreferences.office) {
if (settings?.tarifas == true) {