fix(agenda): one slot generator for professional and patient

The two calendars each built their own list of hours. The patient's
enforced a 3-hour booking lead time; the professional's did not, so at
15:00 an 08:00-18:00 agenda reported "3 libres" (15, 16, 17) that no
patient could actually take.

Both now go through SlotGenerator. The professional still sees the
near-term hours (blocking the next hour is legitimate) but they are
labelled "Sin reserva" and excluded from the "libres" count, so the
number on his agenda means what the patient sees.

Also in this pass:

- endOf() clamps the slot end at 23:59; a 120-minute slot booked at
  23:00 was sending "25:00" to the backend.
- ScheduleEntity.copyWith can set an hour back to null, so returning a
  day to jornada continua no longer keeps the split hours around.
- Removed the Firebase-era schedule map ('habilitado', 'range1Hour1')
  together with the dead ProfessionalEntity.fromDocument that fed it.
- Settings loads guard on mounted and swallow failures instead of
  calling setState after dispose.
- "Pedir cita" says what is missing instead of doing nothing.

Tests: 9 passing, including the clamp and the empty/inverted ranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-25 13:17:03 -05:00
co-authored by Claude Opus 5
parent 3327220557
commit 0a8e5d11a2
15 changed files with 585 additions and 266 deletions
@@ -48,39 +48,13 @@ class ProfessionalEntity extends Equatable {
this.recordId = '',
});
static ProfessionalEntity fromDocument(Map<String, dynamic> doc) {
return ProfessionalEntity(
id: doc['id'] as String,
identification: doc['identification'] as String,
address: doc['address'] as String,
aditionalAddress: doc['aditional_address'] as String,
profession: doc['profession'] as String,
ratePreferences: doc['rate_preferences'] as bool,
rate: doc['rate'] as String,
locationPreferences: intToEnum(doc['location_preferences'] as int),
bannerPicture: doc['banner_picture'] as String,
identificationPicture: doc['identification_picture'] as String,
certificatePicture: doc['certificate_picture'] as String,
latitude: double.parse(doc['latitude'].toString()),
longitude: double.parse(doc['longitude'].toString()),
specializations: List<String>.from(doc['specializations']),
specializationsPictures:
List<String>.from(doc['specializations_pictures']),
schedules: Schedules.fromDocument(doc['schedules']),
paymentMethods: PaymentMethodEntity.fromDocument(doc['payment_methods']),
slotDurationMinutes:
(doc['slot_duration_minutes'] as num?)?.toInt() ?? 120,
recordId: doc['id']?.toString() ?? '',
);
}
ProfessionalEntity copyWith({
String? id,
String? identification,
String? address,
String? aditionalAddress,
String? profession,
bool ratePreferences = false,
bool? ratePreferences,
String? rate,
LocationPreferences? locationPreferences,
String? bannerPicture,
@@ -101,7 +75,7 @@ class ProfessionalEntity extends Equatable {
address: address ?? this.address,
aditionalAddress: aditionalAddress ?? this.aditionalAddress,
profession: profession ?? this.profession,
ratePreferences: ratePreferences,
ratePreferences: ratePreferences ?? this.ratePreferences,
rate: rate ?? this.rate,
locationPreferences: locationPreferences ?? this.locationPreferences,
bannerPicture: bannerPicture ?? this.bannerPicture,
@@ -125,11 +99,13 @@ class ProfessionalEntity extends Equatable {
'id': id,
'identification': identification,
'address': address,
'aditional_address': aditionalAddress,
'additional_address': aditionalAddress,
'profession': profession,
'rate_preferences': ratePreferences,
'rate': rate,
'location_preferences': enumToInt(locationPreferences),
// String, not the enum index: the backend answers 400
// "location_preferences must be a string".
'location_preferences': enumToString(locationPreferences),
'banner_picture': bannerPicture,
'identification_picture': identificationPicture,
'certificate_picture': certificatePicture,
@@ -137,7 +113,6 @@ class ProfessionalEntity extends Equatable {
'longitude': longitude,
'specializations': specializations,
'specializations_pictures': specializationsPictures,
'schedules': schedules.toJson(),
'payment_methods': paymentMethods.toDocument(),
'slot_duration_minutes': slotDurationMinutes,
};
@@ -28,38 +28,37 @@ class ScheduleEntity extends Equatable {
range2Hour2: null,
);
// copy func
/// Marks "argument not given", so that passing an explicit `null` clears
/// the hour instead of being read as "leave it as it was" — that is why
/// going back to jornada continua used to keep the split hours around.
static const _unset = Object();
ScheduleEntity copyWith({
bool? enabled,
bool? continuousDay,
TimeOfDay? range1Hour1,
TimeOfDay? range1Hour2,
TimeOfDay? range2Hour1,
TimeOfDay? range2Hour2,
Object? range1Hour1 = _unset,
Object? range1Hour2 = _unset,
Object? range2Hour1 = _unset,
Object? range2Hour2 = _unset,
}) {
return ScheduleEntity(
enabled: enabled ?? this.enabled,
continuousDay: continuousDay ?? this.continuousDay,
range1Hour1: range1Hour1 ?? this.range1Hour1,
range1Hour2: range1Hour2 ?? this.range1Hour2,
range2Hour1: range2Hour1 ?? this.range2Hour1,
range2Hour2: range2Hour2 ?? this.range2Hour2,
range1Hour1: identical(range1Hour1, _unset)
? this.range1Hour1
: range1Hour1 as TimeOfDay?,
range1Hour2: identical(range1Hour2, _unset)
? this.range1Hour2
: range1Hour2 as TimeOfDay?,
range2Hour1: identical(range2Hour1, _unset)
? this.range2Hour1
: range2Hour1 as TimeOfDay?,
range2Hour2: identical(range2Hour2, _unset)
? this.range2Hour2
: range2Hour2 as TimeOfDay?,
);
}
static ScheduleEntity fromDocument(Map<String, dynamic> doc) {
return ScheduleEntity(
enabled: doc['habilitado'] as bool,
continuousDay: doc['continuous_day'] as bool,
range1Hour1: _parseTime(doc['range1Hour1']),
range1Hour2: _parseTime(doc['range1Hour2']),
range2Hour1: _parseTime(doc['range2Hour1']),
range2Hour2: _parseTime(doc['range2Hour2']),
);
}
static TimeOfDay? _parseTime(String? time) => parseTime(time);
/// Accepts both "HH:MM" and the ISO8601 the backend returns
/// (e.g. "1970-01-01T08:30:00.000Z"). The time is read as wall clock —
/// no timezone conversion — so 08:00 stays 08:00.
@@ -79,19 +78,6 @@ class ScheduleEntity extends Equatable {
}
}
Map<String, dynamic> toJson() {
return {
'habilitado': enabled,
'continuous_day': continuousDay,
'range1Hour1': formatTimeOfDay(range1Hour1),
'range1Hour2': formatTimeOfDay(range1Hour2),
'range2Hour1': formatTimeOfDay(range2Hour1),
'range2Hour2': formatTimeOfDay(range2Hour2),
};
}
String? formatTimeOfDay(TimeOfDay? time) => formatTimePadded(time);
/// "08:05", never "8:5" — the backend matches /^\d{2}:\d{2}$/.
static String? formatTimePadded(TimeOfDay? time) {
if (time == null) return null;
@@ -51,18 +51,6 @@ class Schedules extends Equatable {
);
}
factory Schedules.fromDocument(Map<String, dynamic> doc) {
return Schedules(
monday: ScheduleEntity.fromDocument(doc['monday']),
tuesday: ScheduleEntity.fromDocument(doc['tuesday']),
wednesday: ScheduleEntity.fromDocument(doc['wednesday']),
thursday: ScheduleEntity.fromDocument(doc['thursday']),
friday: ScheduleEntity.fromDocument(doc['friday']),
saturday: ScheduleEntity.fromDocument(doc['saturday']),
sunday: ScheduleEntity.fromDocument(doc['sunday']),
);
}
/// The array PATCH /professionals/me/schedules expects, ordered
/// 0 = Monday … 6 = Sunday.
///
@@ -81,18 +69,6 @@ class Schedules extends Equatable {
];
}
Map<String, dynamic> toJson() {
return {
'monday': monday.toJson(),
'tuesday': tuesday.toJson(),
'wednesday': wednesday.toJson(),
'thursday': thursday.toJson(),
'friday': friday.toJson(),
'saturday': saturday.toJson(),
'sunday': sunday.toJson(),
};
}
@override
List<Object?> get props =>
[monday, tuesday, wednesday, thursday, friday, saturday, sunday];
@@ -7,5 +7,21 @@ int enumToInt(LocationPreferences state) {
}
LocationPreferences intToEnum(int value) {
if (value < 0 || value >= LocationPreferences.values.length) {
return LocationPreferences.office;
}
return LocationPreferences.values[value];
}
/// The wire format the backend expects: it rejects the enum index with
/// "location_preferences must be a string".
String enumToString(LocationPreferences state) {
switch (state) {
case LocationPreferences.delivery:
return 'delivery';
case LocationPreferences.both:
return 'both';
case LocationPreferences.office:
return 'office';
}
}
@@ -93,6 +93,20 @@ class ApiProfessionalRepository {
Future<void> deleteProfessionalInfo() => _delete('/professionals/me');
/// Saves only the weekly opening hours.
///
/// Lets the schedule editor persist on its own instead of handing the object
/// back and hoping the profile screen gets saved afterwards, and keeps it
/// from overwriting address, rate or payment methods along the way.
Future<void> updateSchedules(Schedules schedules) async {
await _patch('/professionals/me/schedules', {
'schedules': schedules.toSchedulesArray(),
});
if (_proInfo != null) {
await updateFromFirebase(userId: _proInfo!.id);
}
}
ProfessionalEntity? lastProInfo() => _proInfo;
Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream;
@@ -124,9 +138,6 @@ 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 Map) {
return Schedules.fromDocument(raw as Map<String, dynamic>);
}
if (raw is! List || raw.isEmpty) return Schedules.empty;
final byDay = <int, Map<String, dynamic>>{};
@@ -242,7 +253,7 @@ class ApiProfessionalRepository {
'aditional_address': aditionalAddress,
'rate_preferences': ratePreferences,
'rate': rate,
'location_preferences': enumToInt(locationPreferences),
'location_preferences': enumToString(locationPreferences),
'latitude': latitude,
'longitude': longitude,
'payment_methods': paymentMethods.toDocument(),