diff --git a/lib/screens/professional/components/schedule_item.dart b/lib/screens/professional/components/schedule_item.dart index 0cba99e..d05d772 100644 --- a/lib/screens/professional/components/schedule_item.dart +++ b/lib/screens/professional/components/schedule_item.dart @@ -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)); }, ), diff --git a/lib/screens/professional/professional_calendar_screen.dart b/lib/screens/professional/professional_calendar_screen.dart index 503d083..316a826 100644 --- a/lib/screens/professional/professional_calendar_screen.dart +++ b/lib/screens/professional/professional_calendar_screen.dart @@ -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().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().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 _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. diff --git a/lib/screens/professional/professional_profile_screen.dart b/lib/screens/professional/professional_profile_screen.dart index be73fd9..9f3cdb2 100644 --- a/lib/screens/professional/professional_profile_screen.dart +++ b/lib/screens/professional/professional_profile_screen.dart @@ -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; } diff --git a/lib/screens/professional/professional_schedule_screen.dart b/lib/screens/professional/professional_schedule_screen.dart index 81a9457..463125a 100644 --- a/lib/screens/professional/professional_schedule_screen.dart +++ b/lib/screens/professional/professional_schedule_screen.dart @@ -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 { + final _repo = Injector.appInstance.get(); 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 _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 _confirmLeave() async { + if (!_dirty || _saving) return true; + final leave = await showDialog( + 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), ], ), ), ), + ), ); } } diff --git a/lib/screens/user/user_calendar_screen.dart b/lib/screens/user/user_calendar_screen.dart index f10a657..448e7d4 100644 --- a/lib/screens/user/user_calendar_screen.dart +++ b/lib/screens/user/user_calendar_screen.dart @@ -4,9 +4,8 @@ import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; import 'package:professional_repository/professional_repository.dart'; import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.dart'; -import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/service_day.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:setting_repository/setting_repository.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -47,11 +46,13 @@ class UserCalendarScreenState extends State { } void _loadSettings() { - settingRepository.getSettings().then( - (value) => setState(() { - settings = value; - }), - ); + settingRepository.getSettings().then((value) { + if (!mounted) return; + setState(() => settings = value); + }).catchError((_) { + // Settings only gate optional features; failing to read them must not + // break the screen, but it must not setState after dispose either. + }); } void _loadServices() { @@ -169,6 +170,23 @@ class UserCalendarScreenState extends State { } List _rangesItems(ScheduleEntity? schedule) { + if (_services == null && !_loadFailed) { + // Treating "unknown" as busy is the safe default, but without this the + // patient saw every hour in red and concluded the professional was full. + return [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column(children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text('Consultando disponibilidad…', + style: TextStyle(fontSize: 13, color: Colors.grey)), + ]), + ), + ), + ]; + } if (_loadFailed) { // Slots are hidden rather than shown as free: booking blind is how you // end up with two people in the same hour. @@ -212,55 +230,23 @@ class UserCalendarScreenState extends State { ]; } - if (schedule.continuousDay) { - if (schedule.range1Hour1 == null || - schedule.range2Hour2 == null || - schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { - return [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text("No hay horarios disponibles"), - ) - ]; - } - - List ranges = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range2Hour2!, - stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, - ); - - return rangesItemList(ranges, _services, today); - } else { - if (schedule.range1Hour1 == null || - schedule.range1Hour2 == null || - schedule.range2Hour1 == null || - schedule.range2Hour2 == null || - schedule.range1Hour1!.compareTo(schedule.range1Hour2!) >= 0 || - schedule.range2Hour1!.compareTo(schedule.range2Hour2!) >= 0) { - return [ - const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text("No hay horarios disponibles"), - ) - ]; - } - List ranges1 = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range1Hour2!, - stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, - ); - List ranges2 = TimeOfDayUtils.genRanges( - schedule.range2Hour1!, - schedule.range2Hour2!, - stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes, - ); - + // One generator for both calendars: what the professional counts as free + // in his agenda is exactly what shows up here. + final ranges = SlotGenerator.forDay( + schedule: schedule, + day: today, + slotDurationMinutes: + widget.userProfessional.professionalInfo.slotDurationMinutes, + ); + if (ranges.isEmpty) { return [ - ...rangesItemList(ranges1, _services, today), - ...rangesItemList(ranges2, _services, today), + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Text("No hay horarios disponibles"), + ) ]; } + return rangesItemList(ranges, _services, today); } bool _isHora1Ocupada( @@ -293,7 +279,7 @@ class UserCalendarScreenState extends State { ); if (selectedDateTime - .isBefore(currentDateTime.add(const Duration(hours: 3)))) { + .isBefore(currentDateTime.add(kBookingLeadTime))) { return Card( elevation: 4, margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), diff --git a/lib/screens/user/user_map_screen.dart b/lib/screens/user/user_map_screen.dart index 9915b57..389e020 100644 --- a/lib/screens/user/user_map_screen.dart +++ b/lib/screens/user/user_map_screen.dart @@ -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 { } 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 { 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 = [ + 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 { .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 { .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 { 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 { 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 { // 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, diff --git a/lib/utils/schedule_validation.dart b/lib/utils/schedule_validation.dart new file mode 100644 index 0000000..b5f4fdb --- /dev/null +++ b/lib/utils/schedule_validation.dart @@ -0,0 +1,64 @@ +import 'package:professional_repository/professional_repository.dart'; +import 'package:prosappco/utils/time_of_day_extension.dart'; + +const _dayNames = [ + 'Lunes', + 'Martes', + 'Miércoles', + 'Jueves', + 'Viernes', + 'Sábado', + 'Domingo', +]; + +/// Human-readable problems with a week of opening hours, empty when it is fine. +/// +/// Nothing validated these before: an inverted or half-filled day saved +/// happily and then produced zero bookable slots, with no clue as to why. +List validateSchedules(Schedules schedules) { + final days = [ + schedules.monday, + schedules.tuesday, + schedules.wednesday, + schedules.thursday, + schedules.friday, + schedules.saturday, + schedules.sunday, + ]; + + final problems = []; + for (var i = 0; i < days.length; i++) { + final problem = _validateDay(days[i]); + if (problem != null) problems.add('${_dayNames[i]}: $problem'); + } + return problems; +} + +String? _validateDay(ScheduleEntity day) { + if (!day.enabled) return null; + + if (day.continuousDay) { + if (day.range1Hour1 == null || day.range2Hour2 == null) { + return 'falta la hora de apertura o de cierre'; + } + if (!day.range1Hour1!.isBefore(day.range2Hour2!)) { + return 'la hora de cierre debe ser posterior a la de apertura'; + } + return null; + } + + final r = [day.range1Hour1, day.range1Hour2, day.range2Hour1, day.range2Hour2]; + if (r.any((t) => t == null)) { + return 'faltan horas en la jornada partida'; + } + if (!r[0]!.isBefore(r[1]!)) { + return 'la primera jornada termina antes de empezar'; + } + if (!r[2]!.isBefore(r[3]!)) { + return 'la segunda jornada termina antes de empezar'; + } + if (r[2]!.isBefore(r[1]!)) { + return 'las dos jornadas se cruzan'; + } + return null; +} diff --git a/lib/utils/service_day_param.dart b/lib/utils/service_day_param.dart new file mode 100644 index 0000000..8d95fd4 --- /dev/null +++ b/lib/utils/service_day_param.dart @@ -0,0 +1,9 @@ +/// The `day` value the services API expects: `yyyy-MM-dd`. +/// +/// `DateTime.toString()` produces "2026-08-25 00:00:00.000Z", which happens to +/// slip past the backend validator but is not canonical ISO 8601 and differs +/// from what the web sends for the same table. +String serviceDayParam(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; diff --git a/lib/utils/slot_generator.dart b/lib/utils/slot_generator.dart new file mode 100644 index 0000000..513e74e --- /dev/null +++ b/lib/utils/slot_generator.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:professional_repository/professional_repository.dart'; +import 'package:prosappco/utils/time_of_day_extension.dart'; +import 'package:prosappco/utils/time_of_day_utils.dart'; + +/// Minimum notice a patient has to give before an appointment. +/// +/// It used to live only in the patient's calendar, so the professional's own +/// agenda generated a different list: at 15:00 a professional working +/// 08:00-18:00 with hourly slots saw "3 libres" (15:00, 16:00, 17:00) while +/// the patient could only book 18:00. Both screens now go through +/// [SlotGenerator], so the two lists always agree. +const Duration kBookingLeadTime = Duration(hours: 3); + +/// The single source of truth for "which times exist on this day". +class SlotGenerator { + const SlotGenerator._(); + + /// Times offered on [day] for [schedule]. + /// + /// [notBefore] drops the slots that are already in the past (or inside the + /// lead time). Pass it as `DateTime.now().add(kBookingLeadTime)` for the + /// patient flow and `DateTime.now()` for the professional's own agenda, + /// where the professional may still block the next hour. + static List forDay({ + required ScheduleEntity? schedule, + required DateTime day, + required int slotDurationMinutes, + DateTime? notBefore, + }) { + final raw = _rawSlots(schedule, slotDurationMinutes); + if (notBefore == null) return raw; + return raw.where((t) { + final at = DateTime(day.year, day.month, day.day, t.hour, t.minute); + return !at.isBefore(notBefore); + }).toList(); + } + + /// Where a slot starting at [start] ends. + /// + /// Clamped to 23:59: a 120-minute slot starting at 23:00 used to produce + /// "25:00", which is not a real time and the backend stored verbatim. + static TimeOfDay endOf(TimeOfDay start, int slotDurationMinutes) { + final total = start.hour * 60 + start.minute + slotDurationMinutes; + if (total >= 24 * 60) return const TimeOfDay(hour: 23, minute: 59); + return TimeOfDay(hour: total ~/ 60, minute: total % 60); + } + + static List _rawSlots(ScheduleEntity? s, int stepMinutes) { + if (s == null || !s.enabled) return []; + if (s.continuousDay) { + if (s.range1Hour1 == null || s.range2Hour2 == null) return []; + if (s.range1Hour1!.compareTo(s.range2Hour2!) >= 0) return []; + return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, + stepMinutes: stepMinutes); + } + if (s.range1Hour1 == null || + s.range1Hour2 == null || + s.range2Hour1 == null || + s.range2Hour2 == null) { + return []; + } + if (s.range1Hour1!.compareTo(s.range1Hour2!) >= 0 || + s.range2Hour1!.compareTo(s.range2Hour2!) >= 0) { + return []; + } + return [ + ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, + stepMinutes: stepMinutes), + ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, + stepMinutes: stepMinutes), + ]; + } +} diff --git a/packages/professional_repository/lib/src/entities/professional_entity.dart b/packages/professional_repository/lib/src/entities/professional_entity.dart index e701781..d43680e 100644 --- a/packages/professional_repository/lib/src/entities/professional_entity.dart +++ b/packages/professional_repository/lib/src/entities/professional_entity.dart @@ -48,39 +48,13 @@ class ProfessionalEntity extends Equatable { this.recordId = '', }); - static ProfessionalEntity fromDocument(Map 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.from(doc['specializations']), - specializationsPictures: - List.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, }; diff --git a/packages/professional_repository/lib/src/entities/schedule_entity.dart b/packages/professional_repository/lib/src/entities/schedule_entity.dart index 309882b..425def2 100644 --- a/packages/professional_repository/lib/src/entities/schedule_entity.dart +++ b/packages/professional_repository/lib/src/entities/schedule_entity.dart @@ -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 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 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; diff --git a/packages/professional_repository/lib/src/entities/schedules.dart b/packages/professional_repository/lib/src/entities/schedules.dart index de0b4a3..a8f46d7 100644 --- a/packages/professional_repository/lib/src/entities/schedules.dart +++ b/packages/professional_repository/lib/src/entities/schedules.dart @@ -51,18 +51,6 @@ class Schedules extends Equatable { ); } - factory Schedules.fromDocument(Map 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 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 get props => [monday, tuesday, wednesday, thursday, friday, saturday, sunday]; diff --git a/packages/professional_repository/lib/src/models/location_preferences.dart b/packages/professional_repository/lib/src/models/location_preferences.dart index d65bee5..e0acdd7 100644 --- a/packages/professional_repository/lib/src/models/location_preferences.dart +++ b/packages/professional_repository/lib/src/models/location_preferences.dart @@ -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'; + } +} diff --git a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart index 206d9fc..408c33b 100644 --- a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart +++ b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart @@ -93,6 +93,20 @@ class ApiProfessionalRepository { Future 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 updateSchedules(Schedules schedules) async { + await _patch('/professionals/me/schedules', { + 'schedules': schedules.toSchedulesArray(), + }); + if (_proInfo != null) { + await updateFromFirebase(userId: _proInfo!.id); + } + } + ProfessionalEntity? lastProInfo() => _proInfo; Stream 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); - } if (raw is! List || raw.isEmpty) return Schedules.empty; final byDay = >{}; @@ -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(), diff --git a/test/time_slots_test.dart b/test/time_slots_test.dart index 9b7df89..a18df60 100644 --- a/test/time_slots_test.dart +++ b/test/time_slots_test.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:professional_repository/professional_repository.dart'; +import 'package:prosappco/utils/slot_generator.dart'; import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/time_of_day_utils.dart'; void main() { + _slotGeneratorTests(); group('TimeOfDayExtension.add', () { test('carries minutes into hours', () { expect(const TimeOfDay(hour: 8, minute: 45).add(minute: 45), @@ -55,3 +58,63 @@ void main() { }); }); } + +void _slotGeneratorTests() { + group('SlotGenerator', () { + final monday = DateTime(2026, 8, 24); // lunes + + ScheduleEntity continuous(TimeOfDay a, TimeOfDay b) => ScheduleEntity( + enabled: true, + continuousDay: true, + range1Hour1: a, + range1Hour2: null, + range2Hour1: null, + range2Hour2: b, + ); + + test('a disabled day offers nothing', () { + expect( + SlotGenerator.forDay( + schedule: continuous( + const TimeOfDay(hour: 8, minute: 0), + const TimeOfDay(hour: 18, minute: 0)) + .copyWith(enabled: false), + day: monday, + slotDurationMinutes: 60, + ), + isEmpty, + ); + }); + + test('an inverted range offers nothing instead of looping forever', () { + expect( + SlotGenerator.forDay( + schedule: continuous(const TimeOfDay(hour: 18, minute: 0), + const TimeOfDay(hour: 8, minute: 0)), + day: monday, + slotDurationMinutes: 60, + ), + isEmpty, + ); + }); + + test('notBefore drops the slots already past', () { + final slots = SlotGenerator.forDay( + schedule: continuous(const TimeOfDay(hour: 8, minute: 0), + const TimeOfDay(hour: 18, minute: 0)), + day: monday, + slotDurationMinutes: 60, + notBefore: DateTime(2026, 8, 24, 15, 0), + ); + expect(slots.first, const TimeOfDay(hour: 15, minute: 0)); + expect(slots.length, 3); // 15, 16, 17 + }); + + test('endOf clamps instead of producing 25:00', () { + expect(SlotGenerator.endOf(const TimeOfDay(hour: 23, minute: 0), 120), + const TimeOfDay(hour: 23, minute: 59)); + expect(SlotGenerator.endOf(const TimeOfDay(hour: 9, minute: 30), 45), + const TimeOfDay(hour: 10, minute: 15)); + }); + }); +}