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
+71 -44
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:prosappco/utils/slot_generator.dart';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/cupertino.dart';
@@ -22,7 +23,7 @@ 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/time_of_day_extension.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:setting_repository/setting_repository.dart';
@@ -220,11 +221,34 @@ class _UserMapScreenState extends State<UserMapScreen> {
}
if (serviceState is CreateServiceFailure) {
isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'No pudimos agendar tu cita. Revisa tu conexión e inténtalo otra vez.'),
));
}
if (serviceState is CreateServiceSuccess) {
isLoading = false;
final token = profesionalSeleccionado?.myUser.token;
if (token != null) {
LocalNotifications.sendPushNotification(
token,
'Nuevo servicio',
'Tienes una nueva solicitud de servicio pendiente',
);
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
Navigator.push(
context,
CupertinoPageRoute(
@@ -525,7 +549,22 @@ class _UserMapScreenState extends State<UserMapScreen> {
onPressed: isLoading
? null
: () {
if (profesionalSeleccionado == null) return;
// Used to `return` in silence when anything was
// missing, so the main button simply did nothing.
final faltan = <String>[
if (profesionalSeleccionado == null)
'un profesional',
if (fechaSeleccionada == null) 'la fecha',
if (horaSeleccionada == null) 'la hora',
];
if (faltan.isNotEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Falta ${faltan.join(' y ')} para pedir la cita'),
));
return;
}
if (state.user?.name == null ||
state.user?.name == '' ||
@@ -557,15 +596,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude,
longitude: profesionalSeleccionado!
.professionalInfo.longitude,
day: fechaSeleccionada.toString(),
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
@@ -585,15 +624,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude,
longitude: profesionalSeleccionado!
.professionalInfo.longitude,
day: fechaSeleccionada.toString(),
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
@@ -611,15 +650,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: fechaSeleccionada.toString(),
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: profesionalSeleccionado!
.professionalInfo.rate,
location: serviceLocationPreference!,
@@ -634,15 +673,15 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: fechaSeleccionada.toString(),
day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(),
description: _observationController.text,
range1Hour1: horaSeleccionada!,
range1Hour2:
horaSeleccionada!.add(
minute: profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
range1Hour2: SlotGenerator.endOf(
horaSeleccionada!,
profesionalSeleccionado!
.professionalInfo
.slotDurationMinutes),
rate: '0',
location: serviceLocationPreference!,
),
@@ -652,24 +691,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
// TODO: error inesperado
}
if (profesionalSeleccionado!.myUser.token != null) {
LocalNotifications.sendPushNotification(
profesionalSeleccionado!.myUser.token!,
'Nuevo servicio',
'Tienes una nueva solicitud de servicio pendiente',
);
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
setState(() {});
// The form is NOT cleared here and the professional
// is NOT notified here: both used to run right after
// dispatching the event, so a failed booking still
// sent "tienes una nueva solicitud" and wiped the
// patient's date, hour and notes. See the
// CreateServiceSuccess branch in the listener.
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,