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
@@ -51,6 +51,43 @@ class Schedules extends Equatable {
);
}
/// Parses the array the backend returns, keyed by `day_of_week`
/// (0 = Monday … 6 = Sunday). Anything else yields [Schedules.empty]
/// rather than throwing, so one malformed row cannot blank a screen.
static Schedules fromApiArray(dynamic raw) {
if (raw is! List || raw.isEmpty) return Schedules.empty;
final byDay = <int, Map<String, dynamic>>{};
for (final s in raw) {
if (s is! Map) continue;
final day = (s['day_of_week'] as num?)?.toInt();
if (day != null) byDay[day] = s.cast<String, dynamic>();
}
ScheduleEntity entityFor(int day) {
final s = byDay[day];
if (s == null) return ScheduleEntity.empty;
return ScheduleEntity(
enabled: s['enabled'] as bool? ?? false,
continuousDay: s['continuous_day'] as bool? ?? false,
range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()),
range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()),
range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()),
range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()),
);
}
return Schedules(
monday: entityFor(0),
tuesday: entityFor(1),
wednesday: entityFor(2),
thursday: entityFor(3),
friday: entityFor(4),
saturday: entityFor(5),
sunday: entityFor(6),
);
}
/// The array PATCH /professionals/me/schedules expects, ordered
/// 0 = Monday … 6 = Sunday.
///
@@ -137,39 +137,7 @@ class ApiProfessionalRepository {
/// The backend sends `schedules` as an array of rows keyed by
/// `day_of_week` (0 = Monday … 6 = Sunday), not as a map of day names.
Schedules _schedulesFromApi(dynamic raw) {
if (raw is! List || raw.isEmpty) return Schedules.empty;
final byDay = <int, Map<String, dynamic>>{};
for (final s in raw) {
if (s is! Map) continue;
final day = (s['day_of_week'] as num?)?.toInt();
if (day != null) byDay[day] = s.cast<String, dynamic>();
}
ScheduleEntity entityFor(int day) {
final s = byDay[day];
if (s == null) return ScheduleEntity.empty;
return ScheduleEntity(
enabled: s['enabled'] as bool? ?? false,
continuousDay: s['continuous_day'] as bool? ?? false,
range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()),
range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()),
range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()),
range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()),
);
}
return Schedules(
monday: entityFor(0),
tuesday: entityFor(1),
wednesday: entityFor(2),
thursday: entityFor(3),
friday: entityFor(4),
saturday: entityFor(5),
sunday: entityFor(6),
);
}
Schedules _schedulesFromApi(dynamic raw) => Schedules.fromApiArray(raw);
ProfessionalEntity _fromApi(Map<String, dynamic> json) {
return ProfessionalEntity(
@@ -1 +1,2 @@
export '/src/entities/service_entity.dart';
export 'public_calendar.dart';
@@ -0,0 +1,17 @@
import 'package:professional_repository/professional_repository.dart'
show Schedules;
import 'service_entity.dart';
/// What a client needs to draw another professional's availability: the
/// opening hours as they stand right now, plus the slots already taken.
///
/// The two travel together on purpose. Reading the hours from a list snapshot
/// and the appointments from the live endpoint let a patient be offered a slot
/// on a day the professional had already closed.
class PublicCalendar {
final Schedules schedules;
final List<ServiceEntity> services;
const PublicCalendar({required this.schedules, required this.services});
}
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:professional_repository/professional_repository.dart'
show Schedules;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:service_repository/service_repository.dart';
@@ -235,11 +237,17 @@ class ApiServiceRepository {
/// (`ProfessionalEntity.recordId`), not the owning user's id. Clients used to
/// call the endpoint above, which returns the *caller's* own agenda, so every
/// slot looked free and two people could book the same hour.
Future<List<ServiceEntity>> getPublicCalendar(
String professionalRecordId) async {
Future<PublicCalendar> getPublicCalendar(String professionalRecordId) async {
final body = await _get('/services/public-calendar/$professionalRecordId');
final raw = body is Map ? (body['services'] as List? ?? []) : <dynamic>[];
return raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList();
final map = body is Map ? body : const {};
final raw = map['services'] as List? ?? <dynamic>[];
return PublicCalendar(
// The endpoint also returns the professional's current opening hours.
// Reading them here instead of the snapshot cached in the search list
// is what keeps the patient from booking against yesterday's agenda.
schedules: Schedules.fromApiArray(map['schedules']),
services: raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList(),
);
}
Stream<List<ServiceEntity>> getServicesHistoryForUser(String userId) {
+15
View File
@@ -112,6 +112,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
intl:
dependency: transitive
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker:
dependency: transitive
description:
@@ -216,6 +224,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
professional_repository:
dependency: "direct main"
description:
path: "../professional_repository"
relative: true
source: path
version: "1.0.11+11"
shared_preferences:
dependency: "direct main"
description:
+2
View File
@@ -11,6 +11,8 @@ dependencies:
flutter:
sdk: flutter
equatable: ^2.0.5
professional_repository:
path: ../professional_repository
http: ^1.1.0
shared_preferences: ^2.0.10