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( Switch(
value: schedule.enabled, value: schedule.enabled,
onChanged: (value) { 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)); onChanged.call(schedule.copyWith(enabled: value));
}, },
), ),
@@ -53,6 +65,17 @@ class ScheduleItem extends StatelessWidget {
Switch( Switch(
value: schedule.continuousDay, value: schedule.continuousDay,
onChanged: (value) { 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)); 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/blocs/service_bloc/service_bloc.dart';
import 'package:prosappco/screens/service/professional_service_screen.dart'; import 'package:prosappco/screens/service/professional_service_screen.dart';
import 'package:prosappco/utils/service_day.dart'; import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/time_of_day_extension.dart'; import 'package:prosappco/utils/slot_generator.dart';
import 'package:prosappco/utils/time_of_day_utils.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
@@ -58,7 +57,10 @@ class _ProfessionalCalendarScreenState
} }
void _loadServices() { void _loadServices() {
setState(() => _loadFailed = false); setState(() {
_loadFailed = false;
_services = null;
});
serviceRepository serviceRepository
.getServicesForProfessionalforCalendar(widget.userProfessional.id) .getServicesForProfessionalforCalendar(widget.userProfessional.id)
.then((services) { .then((services) {
@@ -85,7 +87,14 @@ class _ProfessionalCalendarScreenState
final occupied = final occupied =
slots.where((t) => _isOccupied(t, _services, today)).length; slots.where((t) => _isOccupied(t, _services, today)).length;
final blocked = slots.where((t) => _isBlocked(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( return Scaffold(
backgroundColor: context.bg, backgroundColor: context.bg,
@@ -109,6 +118,12 @@ class _ProfessionalCalendarScreenState
} }
if (state is CreateServiceSuccess || state is ServiceStatusUpdated) { if (state is CreateServiceSuccess || state is ServiceStatusUpdated) {
isLoading = false; isLoading = false;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state is CreateServiceSuccess
? 'Horario bloqueado'
: 'Horario liberado'),
));
_loadServices(); _loadServices();
} }
}, },
@@ -119,6 +134,10 @@ class _ProfessionalCalendarScreenState
_calendarCard(context), _calendarCard(context),
if (_loadFailed) if (_loadFailed)
_loadErrorState(context) _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 ...[ else ...[
_dayHeader(context, schedule, slots.length, occupied, _dayHeader(context, schedule, slots.length, occupied,
blocked, available), blocked, available),
@@ -282,10 +301,16 @@ class _ProfessionalCalendarScreenState
final occ = _isOccupied(time, _services, today); final occ = _isOccupied(time, _services, today);
final blocked = _isBlocked(time, _services, today); final blocked = _isBlocked(time, _services, today);
final taken = occ || blocked; 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 final color = occ
? _kOccupied ? _kOccupied
: blocked : blocked
? _kBlocked ? _kBlocked
: soon
? Colors.grey
: _kAvailable; : _kAvailable;
return Container( return Container(
@@ -338,6 +363,8 @@ class _ProfessionalCalendarScreenState
? 'Ocupado' ? 'Ocupado'
: blocked : blocked
? 'Bloqueado' ? 'Bloqueado'
: soon
? 'Sin reserva'
: 'Disponible', : 'Disponible',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
@@ -348,7 +375,9 @@ class _ProfessionalCalendarScreenState
? 'Toca para ver el servicio' ? 'Toca para ver el servicio'
: blocked : blocked
? 'Toca para liberar el horario' ? '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( style: TextStyle(
fontSize: 11, color: context.subtle), fontSize: 11, color: context.subtle),
), ),
@@ -366,6 +395,8 @@ class _ProfessionalCalendarScreenState
? Icons.event_busy_outlined ? Icons.event_busy_outlined
: blocked : blocked
? Icons.lock_outline ? Icons.lock_outline
: soon
? Icons.more_time
: Icons.event_available_outlined, : Icons.event_available_outlined,
size: 17, size: 17,
color: color), 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) { void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Reservar hora'), title: const Text('Bloquear horario'),
content: Column(mainAxisSize: MainAxisSize.min, children: [ content: Text(
Text( '¿Bloquear las ${ScheduleEntity.getFormatTime(time)} '
'¿Reservar a las ${ScheduleEntity.getFormatTime(time)} ' 'del ${DateFormat('dd-MM-yyyy').format(today)}? '
'del ${DateFormat('dd-MM-yyyy').format(today)}?', 'Nadie podrá agendar en esa hora hasta que la liberes.',
), ),
const SizedBox(height: 10),
const Text('⚠️ Esta acción no se puede deshacer',
style: TextStyle(fontWeight: FontWeight.bold)),
]),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancelar'), 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( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
context.read<ServiceBloc>().add( 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) { Widget _loadErrorState(BuildContext context) {
return Container( return Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), 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) { List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) { // Same generator the patient uses, so "3 libres" here always means three
return []; // slots a patient can actually take. The professional keeps a shorter
} // horizon than the patient's lead time: blocking the next hour is a
final stepMinutes = widget.userProfessional.slotDurationMinutes; // legitimate thing to do, booking it is not.
if (s.continuousDay) { return SlotGenerator.forDay(
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, schedule: s,
stepMinutes: stepMinutes); day: today,
} slotDurationMinutes: widget.userProfessional.slotDurationMinutes,
if (s.range1Hour2 == null || s.range2Hour1 == null) return []; notBefore: DateTime.now(),
return [ );
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!,
stepMinutes: stepMinutes),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!,
stepMinutes: stepMinutes),
];
} }
/// The still-live service sitting on [time], if any. /// The still-live service sitting on [time], if any.
@@ -65,9 +65,10 @@ class _ProfessionalProfileScreenState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
settingRepository.getSettings().then( settingRepository.getSettings().then((v) {
(v) => setState(() => settings = v), if (!mounted) return;
); setState(() => settings = v);
}).catchError((_) {});
} }
@override @override
@@ -174,6 +175,9 @@ class _ProfessionalProfileScreenState
deliveryValue = true; deliveryValue = true;
} }
_rateController.text = proInfo.rate; _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; _slotDurationMinutes = proInfo.slotDurationMinutes;
loadFinish = true; loadFinish = true;
} }
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:injector/injector.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/components/general_primary_button.dart'; import 'package:prosappco/components/general_primary_button.dart';
import 'package:prosappco/screens/professional/components/schedule_item.dart'; import 'package:prosappco/screens/professional/components/schedule_item.dart';
import 'package:prosappco/utils/schedule_validation.dart';
class ProfessionalScheduleScreen extends StatefulWidget { class ProfessionalScheduleScreen extends StatefulWidget {
Schedules schedules; Schedules schedules;
@@ -17,16 +19,85 @@ class ProfessionalScheduleScreen extends StatefulWidget {
class _ProfessionalScheduleScreenState class _ProfessionalScheduleScreenState
extends State<ProfessionalScheduleScreen> { extends State<ProfessionalScheduleScreen> {
final _repo = Injector.appInstance.get<ApiProfessionalRepository>();
Schedules schedules = Schedules.empty; Schedules schedules = Schedules.empty;
bool _saving = false;
bool _dirty = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
schedules = widget.schedules; 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 @override
Widget build(BuildContext context) { 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( appBar: AppBar(
title: const Text('Horario'), title: const Text('Horario'),
), ),
@@ -39,63 +110,62 @@ class _ProfessionalScheduleScreenState
label: "Lunes", label: "Lunes",
schedule: schedules.monday, schedule: schedules.monday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(monday: s)); _update(schedules.copyWith(monday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Martes", label: "Martes",
schedule: schedules.tuesday, schedule: schedules.tuesday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(tuesday: s)); _update(schedules.copyWith(tuesday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Miercoles", label: "Miercoles",
schedule: schedules.wednesday, schedule: schedules.wednesday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(wednesday: s)); _update(schedules.copyWith(wednesday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Jueves", label: "Jueves",
schedule: schedules.thursday, schedule: schedules.thursday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(thursday: s)); _update(schedules.copyWith(thursday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Viernes", label: "Viernes",
schedule: schedules.friday, schedule: schedules.friday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(friday: s)); _update(schedules.copyWith(friday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Sabado", label: "Sabado",
schedule: schedules.saturday, schedule: schedules.saturday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(saturday: s)); _update(schedules.copyWith(saturday: s));
}, },
), ),
ScheduleItem( ScheduleItem(
label: "Domingo", label: "Domingo",
schedule: schedules.sunday, schedule: schedules.sunday,
onChanged: (s) { onChanged: (s) {
setState(() => schedules = schedules.copyWith(sunday: s)); _update(schedules.copyWith(sunday: s));
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
GeneralPrimaryButton( GeneralPrimaryButton(
label: "Guardar", label: _saving ? "Guardando…" : "Guardar",
onPressed: () { onPressed: _saving ? () {} : _save,
Navigator.of(context).pop(schedules);
},
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
), ),
), ),
),
); );
} }
} }
+35 -49
View File
@@ -4,9 +4,8 @@ import 'package:injector/injector.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:professional_repository/professional_repository.dart'; import 'package:professional_repository/professional_repository.dart';
import 'package:prosappco/blocs/professional_list_bloc/professional_list_bloc.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/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:service_repository/service_repository.dart';
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
@@ -47,11 +46,13 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
} }
void _loadSettings() { void _loadSettings() {
settingRepository.getSettings().then( settingRepository.getSettings().then((value) {
(value) => setState(() { if (!mounted) return;
settings = value; 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() { void _loadServices() {
@@ -169,6 +170,23 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
} }
List<Widget> _rangesItems(ScheduleEntity? schedule) { List<Widget> _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) { if (_loadFailed) {
// Slots are hidden rather than shown as free: booking blind is how you // Slots are hidden rather than shown as free: booking blind is how you
// end up with two people in the same hour. // end up with two people in the same hour.
@@ -212,10 +230,15 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
]; ];
} }
if (schedule.continuousDay) { // One generator for both calendars: what the professional counts as free
if (schedule.range1Hour1 == null || // in his agenda is exactly what shows up here.
schedule.range2Hour2 == null || final ranges = SlotGenerator.forDay(
schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { schedule: schedule,
day: today,
slotDurationMinutes:
widget.userProfessional.professionalInfo.slotDurationMinutes,
);
if (ranges.isEmpty) {
return [ return [
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 20), padding: EdgeInsets.symmetric(vertical: 20),
@@ -223,44 +246,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
) )
]; ];
} }
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
return rangesItemList(ranges, _services, today); 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<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
stepMinutes: widget.userProfessional.professionalInfo.slotDurationMinutes,
);
return [
...rangesItemList(ranges1, _services, today),
...rangesItemList(ranges2, _services, today),
];
}
} }
bool _isHora1Ocupada( bool _isHora1Ocupada(
@@ -293,7 +279,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
); );
if (selectedDateTime if (selectedDateTime
.isBefore(currentDateTime.add(const Duration(hours: 3)))) { .isBefore(currentDateTime.add(kBookingLeadTime))) {
return Card( return Card(
elevation: 4, elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
+63 -36
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:prosappco/utils/slot_generator.dart';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/cupertino.dart'; 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/profile/profile_screen.dart';
import 'package:prosappco/screens/user/user_service_screen.dart'; import 'package:prosappco/screens/user/user_service_screen.dart';
import 'package:prosappco/utils/nominatim_geocoder.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:prosappco/utils/version_utils.dart';
import 'package:service_repository/service_repository.dart'; import 'package:service_repository/service_repository.dart';
import 'package:setting_repository/setting_repository.dart'; import 'package:setting_repository/setting_repository.dart';
@@ -220,11 +221,34 @@ class _UserMapScreenState extends State<UserMapScreen> {
} }
if (serviceState is CreateServiceFailure) { if (serviceState is CreateServiceFailure) {
isLoading = false; 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) { if (serviceState is CreateServiceSuccess) {
isLoading = false; 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( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
@@ -525,7 +549,22 @@ class _UserMapScreenState extends State<UserMapScreen> {
onPressed: isLoading onPressed: isLoading
? null ? 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 || if (state.user?.name == null ||
state.user?.name == '' || state.user?.name == '' ||
@@ -557,13 +596,13 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude, .professionalInfo.latitude,
longitude: profesionalSeleccionado! longitude: profesionalSeleccionado!
.professionalInfo.longitude, .professionalInfo.longitude,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add( horaSeleccionada!,
minute: profesionalSeleccionado! profesionalSeleccionado!
.professionalInfo .professionalInfo
.slotDurationMinutes), .slotDurationMinutes),
rate: profesionalSeleccionado! rate: profesionalSeleccionado!
@@ -585,13 +624,13 @@ class _UserMapScreenState extends State<UserMapScreen> {
.professionalInfo.latitude, .professionalInfo.latitude,
longitude: profesionalSeleccionado! longitude: profesionalSeleccionado!
.professionalInfo.longitude, .professionalInfo.longitude,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add( horaSeleccionada!,
minute: profesionalSeleccionado! profesionalSeleccionado!
.professionalInfo .professionalInfo
.slotDurationMinutes), .slotDurationMinutes),
rate: '0', rate: '0',
@@ -611,13 +650,13 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '', aditionalAddress: '',
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add( horaSeleccionada!,
minute: profesionalSeleccionado! profesionalSeleccionado!
.professionalInfo .professionalInfo
.slotDurationMinutes), .slotDurationMinutes),
rate: profesionalSeleccionado! rate: profesionalSeleccionado!
@@ -634,13 +673,13 @@ class _UserMapScreenState extends State<UserMapScreen> {
aditionalAddress: '', aditionalAddress: '',
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
day: fechaSeleccionada.toString(), day: serviceDayParam(fechaSeleccionada!),
createdAt: DateTime.now().toIso8601String(), createdAt: DateTime.now().toIso8601String(),
description: _observationController.text, description: _observationController.text,
range1Hour1: horaSeleccionada!, range1Hour1: horaSeleccionada!,
range1Hour2: range1Hour2: SlotGenerator.endOf(
horaSeleccionada!.add( horaSeleccionada!,
minute: profesionalSeleccionado! profesionalSeleccionado!
.professionalInfo .professionalInfo
.slotDurationMinutes), .slotDurationMinutes),
rate: '0', rate: '0',
@@ -652,24 +691,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
// TODO: error inesperado // TODO: error inesperado
} }
if (profesionalSeleccionado!.myUser.token != null) { // The form is NOT cleared here and the professional
LocalNotifications.sendPushNotification( // is NOT notified here: both used to run right after
profesionalSeleccionado!.myUser.token!, // dispatching the event, so a failed booking still
'Nuevo servicio', // sent "tienes una nueva solicitud" and wiped the
'Tienes una nueva solicitud de servicio pendiente', // patient's date, hour and notes. See the
); // CreateServiceSuccess branch in the listener.
}
fechaSeleccionada = null;
horaSeleccionada = null;
profesionalSeleccionado = null;
isClearButtonVisible = false;
_observationController.text = '';
serviceLocationPreference = null;
polylines.clear();
markers.clear();
setState(() {});
}, },
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
+64
View File
@@ -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<String> validateSchedules(Schedules schedules) {
final days = [
schedules.monday,
schedules.tuesday,
schedules.wednesday,
schedules.thursday,
schedules.friday,
schedules.saturday,
schedules.sunday,
];
final problems = <String>[];
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;
}
+9
View File
@@ -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')}';
+74
View File
@@ -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<TimeOfDay> 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<TimeOfDay> _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),
];
}
}
@@ -48,39 +48,13 @@ class ProfessionalEntity extends Equatable {
this.recordId = '', 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({ ProfessionalEntity copyWith({
String? id, String? id,
String? identification, String? identification,
String? address, String? address,
String? aditionalAddress, String? aditionalAddress,
String? profession, String? profession,
bool ratePreferences = false, bool? ratePreferences,
String? rate, String? rate,
LocationPreferences? locationPreferences, LocationPreferences? locationPreferences,
String? bannerPicture, String? bannerPicture,
@@ -101,7 +75,7 @@ class ProfessionalEntity extends Equatable {
address: address ?? this.address, address: address ?? this.address,
aditionalAddress: aditionalAddress ?? this.aditionalAddress, aditionalAddress: aditionalAddress ?? this.aditionalAddress,
profession: profession ?? this.profession, profession: profession ?? this.profession,
ratePreferences: ratePreferences, ratePreferences: ratePreferences ?? this.ratePreferences,
rate: rate ?? this.rate, rate: rate ?? this.rate,
locationPreferences: locationPreferences ?? this.locationPreferences, locationPreferences: locationPreferences ?? this.locationPreferences,
bannerPicture: bannerPicture ?? this.bannerPicture, bannerPicture: bannerPicture ?? this.bannerPicture,
@@ -125,11 +99,13 @@ class ProfessionalEntity extends Equatable {
'id': id, 'id': id,
'identification': identification, 'identification': identification,
'address': address, 'address': address,
'aditional_address': aditionalAddress, 'additional_address': aditionalAddress,
'profession': profession, 'profession': profession,
'rate_preferences': ratePreferences, 'rate_preferences': ratePreferences,
'rate': rate, '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, 'banner_picture': bannerPicture,
'identification_picture': identificationPicture, 'identification_picture': identificationPicture,
'certificate_picture': certificatePicture, 'certificate_picture': certificatePicture,
@@ -137,7 +113,6 @@ class ProfessionalEntity extends Equatable {
'longitude': longitude, 'longitude': longitude,
'specializations': specializations, 'specializations': specializations,
'specializations_pictures': specializationsPictures, 'specializations_pictures': specializationsPictures,
'schedules': schedules.toJson(),
'payment_methods': paymentMethods.toDocument(), 'payment_methods': paymentMethods.toDocument(),
'slot_duration_minutes': slotDurationMinutes, 'slot_duration_minutes': slotDurationMinutes,
}; };
@@ -28,38 +28,37 @@ class ScheduleEntity extends Equatable {
range2Hour2: null, 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({ ScheduleEntity copyWith({
bool? enabled, bool? enabled,
bool? continuousDay, bool? continuousDay,
TimeOfDay? range1Hour1, Object? range1Hour1 = _unset,
TimeOfDay? range1Hour2, Object? range1Hour2 = _unset,
TimeOfDay? range2Hour1, Object? range2Hour1 = _unset,
TimeOfDay? range2Hour2, Object? range2Hour2 = _unset,
}) { }) {
return ScheduleEntity( return ScheduleEntity(
enabled: enabled ?? this.enabled, enabled: enabled ?? this.enabled,
continuousDay: continuousDay ?? this.continuousDay, continuousDay: continuousDay ?? this.continuousDay,
range1Hour1: range1Hour1 ?? this.range1Hour1, range1Hour1: identical(range1Hour1, _unset)
range1Hour2: range1Hour2 ?? this.range1Hour2, ? this.range1Hour1
range2Hour1: range2Hour1 ?? this.range2Hour1, : range1Hour1 as TimeOfDay?,
range2Hour2: range2Hour2 ?? this.range2Hour2, 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 /// 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 — /// (e.g. "1970-01-01T08:30:00.000Z"). The time is read as wall clock —
/// no timezone conversion — so 08:00 stays 08:00. /// 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}$/. /// "08:05", never "8:5" — the backend matches /^\d{2}:\d{2}$/.
static String? formatTimePadded(TimeOfDay? time) { static String? formatTimePadded(TimeOfDay? time) {
if (time == null) return null; 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 /// The array PATCH /professionals/me/schedules expects, ordered
/// 0 = Monday … 6 = Sunday. /// 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 @override
List<Object?> get props => List<Object?> get props =>
[monday, tuesday, wednesday, thursday, friday, saturday, sunday]; [monday, tuesday, wednesday, thursday, friday, saturday, sunday];
@@ -7,5 +7,21 @@ int enumToInt(LocationPreferences state) {
} }
LocationPreferences intToEnum(int value) { LocationPreferences intToEnum(int value) {
if (value < 0 || value >= LocationPreferences.values.length) {
return LocationPreferences.office;
}
return LocationPreferences.values[value]; 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'); 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; ProfessionalEntity? lastProInfo() => _proInfo;
Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream; Stream<ProfessionalEntity?> streamProInfo() => _proInfoBroadcast.stream;
@@ -124,9 +138,6 @@ class ApiProfessionalRepository {
/// The backend sends `schedules` as an array of rows keyed by /// 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. /// `day_of_week` (0 = Monday … 6 = Sunday), not as a map of day names.
Schedules _schedulesFromApi(dynamic raw) { 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; if (raw is! List || raw.isEmpty) return Schedules.empty;
final byDay = <int, Map<String, dynamic>>{}; final byDay = <int, Map<String, dynamic>>{};
@@ -242,7 +253,7 @@ class ApiProfessionalRepository {
'aditional_address': aditionalAddress, 'aditional_address': aditionalAddress,
'rate_preferences': ratePreferences, 'rate_preferences': ratePreferences,
'rate': rate, 'rate': rate,
'location_preferences': enumToInt(locationPreferences), 'location_preferences': enumToString(locationPreferences),
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'payment_methods': paymentMethods.toDocument(), 'payment_methods': paymentMethods.toDocument(),
+63
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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_extension.dart';
import 'package:prosappco/utils/time_of_day_utils.dart'; import 'package:prosappco/utils/time_of_day_utils.dart';
void main() { void main() {
_slotGeneratorTests();
group('TimeOfDayExtension.add', () { group('TimeOfDayExtension.add', () {
test('carries minutes into hours', () { test('carries minutes into hours', () {
expect(const TimeOfDay(hour: 8, minute: 45).add(minute: 45), 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));
});
});
}