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