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
@@ -31,6 +31,18 @@ class ScheduleItem extends StatelessWidget {
Switch(
value: schedule.enabled,
onChanged: (value) {
// Turning a day on used to leave every hour empty, so the day
// read as "open" and offered zero slots. Seed a normal
// workday; the professional can adjust it right below.
if (value && schedule.range1Hour1 == null) {
onChanged.call(schedule.copyWith(
enabled: true,
continuousDay: true,
range1Hour1: const TimeOfDay(hour: 8, minute: 0),
range2Hour2: const TimeOfDay(hour: 18, minute: 0),
));
return;
}
onChanged.call(schedule.copyWith(enabled: value));
},
),
@@ -53,6 +65,17 @@ class ScheduleItem extends StatelessWidget {
Switch(
value: schedule.continuousDay,
onChanged: (value) {
// Same reasoning as the day switch: splitting the day
// reveals two new empty fields, and a half-filled day
// generates no slots at all.
if (!value && schedule.range1Hour2 == null) {
onChanged.call(schedule.copyWith(
continuousDay: false,
range1Hour2: const TimeOfDay(hour: 12, minute: 0),
range2Hour1: const TimeOfDay(hour: 14, minute: 0),
));
return;
}
onChanged.call(schedule.copyWith(continuousDay: value));
},
),
@@ -7,8 +7,7 @@ import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/screens/service/professional_service_screen.dart';
import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/time_of_day_extension.dart';
import 'package:prosappco/utils/time_of_day_utils.dart';
import 'package:prosappco/utils/slot_generator.dart';
import 'package:service_repository/service_repository.dart';
import 'package:table_calendar/table_calendar.dart';
@@ -58,7 +57,10 @@ class _ProfessionalCalendarScreenState
}
void _loadServices() {
setState(() => _loadFailed = false);
setState(() {
_loadFailed = false;
_services = null;
});
serviceRepository
.getServicesForProfessionalforCalendar(widget.userProfessional.id)
.then((services) {
@@ -85,7 +87,14 @@ class _ProfessionalCalendarScreenState
final occupied =
slots.where((t) => _isOccupied(t, _services, today)).length;
final blocked = slots.where((t) => _isBlocked(t, _services, today)).length;
final available = slots.length - occupied - blocked;
// "Libres" means bookable by a patient, not merely empty: a free 15:00
// at 14:00 is inside the booking lead time and nobody can take it.
final available = slots
.where((t) =>
!_isOccupied(t, _services, today) &&
!_isBlocked(t, _services, today) &&
_isBookable(t))
.length;
return Scaffold(
backgroundColor: context.bg,
@@ -109,6 +118,12 @@ class _ProfessionalCalendarScreenState
}
if (state is CreateServiceSuccess || state is ServiceStatusUpdated) {
isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state is CreateServiceSuccess
? 'Horario bloqueado'
: 'Horario liberado'),
));
_loadServices();
}
},
@@ -119,6 +134,10 @@ class _ProfessionalCalendarScreenState
_calendarCard(context),
if (_loadFailed)
_loadErrorState(context)
else if (_services == null)
// Until the agenda arrives every slot would read "Disponible"
// and could be blocked on top of a real appointment.
_loadingState(context)
else ...[
_dayHeader(context, schedule, slots.length, occupied,
blocked, available),
@@ -282,11 +301,17 @@ class _ProfessionalCalendarScreenState
final occ = _isOccupied(time, _services, today);
final blocked = _isBlocked(time, _services, today);
final taken = occ || blocked;
// Free but too close to reserve: still blockable by the professional,
// just not offered to patients. Shown greyed so the agenda matches
// what the patient sees instead of promising a slot nobody can take.
final soon = !taken && !_isBookable(time);
final color = occ
? _kOccupied
: blocked
? _kBlocked
: _kAvailable;
: soon
? Colors.grey
: _kAvailable;
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -338,7 +363,9 @@ class _ProfessionalCalendarScreenState
? 'Ocupado'
: blocked
? 'Bloqueado'
: 'Disponible',
: soon
? 'Sin reserva'
: 'Disponible',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
@@ -348,7 +375,9 @@ class _ProfessionalCalendarScreenState
? 'Toca para ver el servicio'
: blocked
? 'Toca para liberar el horario'
: 'Horario libre · toca para reservar',
: soon
? 'Muy pronto para reservar · toca para bloquear'
: 'Horario libre · toca para bloquear',
style: TextStyle(
fontSize: 11, color: context.subtle),
),
@@ -366,7 +395,9 @@ class _ProfessionalCalendarScreenState
? Icons.event_busy_outlined
: blocked
? Icons.lock_outline
: Icons.event_available_outlined,
: soon
? Icons.more_time
: Icons.event_available_outlined,
size: 17,
color: color),
),
@@ -425,63 +456,66 @@ class _ProfessionalCalendarScreenState
);
}
/// Blocking is the only action on a free slot.
///
/// There used to be a second "Reservar" button that created a service with
/// the professional as their own patient. It also popped the calendar, and
/// the selfBooked status never reached the backend, so the slot came back as
/// a pending request the professional had to approve to themselves.
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Reservar hora'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
Text(
'¿Reservar a las ${ScheduleEntity.getFormatTime(time)} '
'del ${DateFormat('dd-MM-yyyy').format(today)}?',
),
const SizedBox(height: 10),
const Text('⚠️ Esta acción no se puede deshacer',
style: TextStyle(fontWeight: FontWeight.bold)),
]),
title: const Text('Bloquear horario'),
content: Text(
'¿Bloquear las ${ScheduleEntity.getFormatTime(time)} '
'del ${DateFormat('dd-MM-yyyy').format(today)}? '
'Nadie podrá agendar en esa hora hasta que la liberes.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
Navigator.pop(context);
context.read<ServiceBloc>().add(CreateService(
professionalId: widget.userProfessional.id,
userId: widget.userProfessional.id,
address: widget.userProfessional.address,
aditionalAddress: '',
latitude: 0,
longitude: 0,
day: today.toString(),
createdAt: DateTime.now().toIso8601String(),
description: '',
range1Hour1: time,
range1Hour2: time.add(
minute: widget.userProfessional.slotDurationMinutes),
rate: '0',
location: ServiceLocationPreferences.office,
status: ServiceStatus.selfBooked,
));
},
child: const Text('Reservar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
context.read<ServiceBloc>().add(
BlockSlot(day: today.toString(), hour1: time),
BlockSlot(day: _dayParam(today), hour1: time),
);
},
child: const Text('Bloquear horario'),
child: const Text('Bloquear'),
),
],
),
);
}
/// yyyy-MM-dd, the canonical form the web sends. DateTime.toString() produces
/// "2026-08-25 00:00:00.000Z", which happens to pass validation but differs
/// between the two clients writing to the same table.
static String _dayParam(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
Widget _loadingState(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(vertical: 48),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
),
child: Column(children: [
const CircularProgressIndicator(color: _kPrimary),
const SizedBox(height: 14),
Text('Cargando tu agenda…',
style: TextStyle(fontSize: 13, color: context.muted)),
]),
);
}
Widget _loadErrorState(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -570,22 +604,23 @@ class _ProfessionalCalendarScreenState
}
}
/// Whether a patient could still reserve [time] today.
bool _isBookable(TimeOfDay time) {
final at = DateTime(today.year, today.month, today.day, time.hour, time.minute);
return !at.isBefore(DateTime.now().add(kBookingLeadTime));
}
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) {
return [];
}
final stepMinutes = widget.userProfessional.slotDurationMinutes;
if (s.continuousDay) {
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!,
stepMinutes: stepMinutes);
}
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
return [
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!,
stepMinutes: stepMinutes),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!,
stepMinutes: stepMinutes),
];
// Same generator the patient uses, so "3 libres" here always means three
// slots a patient can actually take. The professional keeps a shorter
// horizon than the patient's lead time: blocking the next hour is a
// legitimate thing to do, booking it is not.
return SlotGenerator.forDay(
schedule: s,
day: today,
slotDurationMinutes: widget.userProfessional.slotDurationMinutes,
notBefore: DateTime.now(),
);
}
/// The still-live service sitting on [time], if any.
@@ -65,9 +65,10 @@ class _ProfessionalProfileScreenState
@override
void initState() {
super.initState();
settingRepository.getSettings().then(
(v) => setState(() => settings = v),
);
settingRepository.getSettings().then((v) {
if (!mounted) return;
setState(() => settings = v);
}).catchError((_) {});
}
@override
@@ -174,6 +175,9 @@ class _ProfessionalProfileScreenState
deliveryValue = true;
}
_rateController.text = proInfo.rate;
// Restored from the record, not left at its false default: opening the
// profile to fix an address and saving used to switch the rate off.
rateValue = proInfo.ratePreferences;
_slotDurationMinutes = proInfo.slotDurationMinutes;
loadFinish = true;
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:injector/injector.dart';
import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/components/general_primary_button.dart';
import 'package:prosappco/screens/professional/components/schedule_item.dart';
import 'package:prosappco/utils/schedule_validation.dart';
class ProfessionalScheduleScreen extends StatefulWidget {
Schedules schedules;
@@ -17,16 +19,85 @@ class ProfessionalScheduleScreen extends StatefulWidget {
class _ProfessionalScheduleScreenState
extends State<ProfessionalScheduleScreen> {
final _repo = Injector.appInstance.get<ApiProfessionalRepository>();
Schedules schedules = Schedules.empty;
bool _saving = false;
bool _dirty = false;
@override
void initState() {
super.initState();
schedules = widget.schedules;
}
void _update(Schedules next) {
setState(() {
schedules = next;
_dirty = true;
});
}
/// Saving now writes to the backend instead of just handing the object back:
/// the old flow depended on the profile screen being saved afterwards, so
/// leaving without that second save silently discarded everything.
Future<void> _save() async {
final problems = validateSchedules(schedules);
if (problems.isNotEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(problems.first)),
);
return;
}
setState(() => _saving = true);
try {
await _repo.updateSchedules(schedules);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Horario guardado')),
);
Navigator.of(context).pop(schedules);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No se pudo guardar el horario. Revisa tu conexión e inténtalo de nuevo.')),
);
}
}
Future<bool> _confirmLeave() async {
if (!_dirty || _saving) return true;
final leave = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Salir sin guardar?'),
content: const Text('Perderás los cambios que hiciste en tu horario.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Seguir editando')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Salir sin guardar')),
],
),
);
return leave ?? false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
if (await _confirmLeave() && mounted) Navigator.of(context).pop();
},
child: Scaffold(
appBar: AppBar(
title: const Text('Horario'),
),
@@ -39,63 +110,62 @@ class _ProfessionalScheduleScreenState
label: "Lunes",
schedule: schedules.monday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(monday: s));
_update(schedules.copyWith(monday: s));
},
),
ScheduleItem(
label: "Martes",
schedule: schedules.tuesday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(tuesday: s));
_update(schedules.copyWith(tuesday: s));
},
),
ScheduleItem(
label: "Miercoles",
schedule: schedules.wednesday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(wednesday: s));
_update(schedules.copyWith(wednesday: s));
},
),
ScheduleItem(
label: "Jueves",
schedule: schedules.thursday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(thursday: s));
_update(schedules.copyWith(thursday: s));
},
),
ScheduleItem(
label: "Viernes",
schedule: schedules.friday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(friday: s));
_update(schedules.copyWith(friday: s));
},
),
ScheduleItem(
label: "Sabado",
schedule: schedules.saturday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(saturday: s));
_update(schedules.copyWith(saturday: s));
},
),
ScheduleItem(
label: "Domingo",
schedule: schedules.sunday,
onChanged: (s) {
setState(() => schedules = schedules.copyWith(sunday: s));
_update(schedules.copyWith(sunday: s));
},
),
const SizedBox(height: 20),
GeneralPrimaryButton(
label: "Guardar",
onPressed: () {
Navigator.of(context).pop(schedules);
},
label: _saving ? "Guardando…" : "Guardar",
onPressed: _saving ? () {} : _save,
),
const SizedBox(height: 20),
],
),
),
),
),
);
}
}