fix(booking): read the live agenda and re-check the slot before confirming
/services/public-calendar also returns the professional's current opening hours; the client threw them away and drew the day from the snapshot the search list carried, which can be days old. It now uses the fresh ones. Between picking an hour and pressing "Pedir cita" another patient can take the slot. The confirm step asks the agenda again and, if the hour is gone, says so and clears the selection instead of creating a service the professional would have to deny. A failed re-check lets the booking through: a network hiccup should not block a patient. Also: - tapping a busy slot with no id explains itself instead of doing nothing - the profile refuses to save an empty rate with the rate switch on, which was reaching the backend as rate: '' Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0a8e5d11a2
commit
6cb2755858
@@ -414,7 +414,15 @@ class _ProfessionalCalendarScreenState
|
|||||||
|
|
||||||
void _onOccupied(TimeOfDay time) {
|
void _onOccupied(TimeOfDay time) {
|
||||||
final event = _serviceAt(time, _services, today);
|
final event = _serviceAt(time, _services, today);
|
||||||
if (event?.id == null) return;
|
if (event?.id == null) {
|
||||||
|
// Tapping a busy slot used to do nothing at all when the row came back
|
||||||
|
// without an id, which reads as a frozen screen.
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('No pudimos abrir esta cita. Desliza para recargar.'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event!.status == ServiceStatus.selfBooked) {
|
if (event!.status == ServiceStatus.selfBooked) {
|
||||||
_confirmUnblock(event.id!, time);
|
_confirmUnblock(event.id!, time);
|
||||||
|
|||||||
@@ -663,6 +663,26 @@ class _ProfessionalProfileScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _save() {
|
void _save() {
|
||||||
|
// Saving with the rate switch on and the field empty used to send
|
||||||
|
// `rate: ''`, and the patient then saw a professional with no price.
|
||||||
|
if (rateValue) {
|
||||||
|
final rate = int.tryParse(_rateController.text.trim()) ?? 0;
|
||||||
|
if (rate <= 0) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Escribe el valor de tu tarifa o desactívala'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_addressController.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Escribe la dirección donde atiendes'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo(
|
context.read<ProfessionalProfileBloc>().add(UpdateProfessionalProfileInfo(
|
||||||
address: _addressController.text,
|
address: _addressController.text,
|
||||||
aditionalAddress: _aditionalAddressController.text,
|
aditionalAddress: _aditionalAddressController.text,
|
||||||
@@ -682,7 +702,7 @@ class _ProfessionalProfileScreenState
|
|||||||
),
|
),
|
||||||
schedules: schedules,
|
schedules: schedules,
|
||||||
ratePreferences: rateValue,
|
ratePreferences: rateValue,
|
||||||
rate: _rateController.text,
|
rate: rateValue ? _rateController.text.trim() : '',
|
||||||
slotDurationMinutes: _slotDurationMinutes,
|
slotDurationMinutes: _slotDurationMinutes,
|
||||||
));
|
));
|
||||||
if (_imageFile != null) {
|
if (_imageFile != null) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
|||||||
late int numDay;
|
late int numDay;
|
||||||
|
|
||||||
List<ServiceEntity>? _services;
|
List<ServiceEntity>? _services;
|
||||||
|
Schedules? _schedules;
|
||||||
bool _loadFailed = false;
|
bool _loadFailed = false;
|
||||||
|
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||||
@@ -61,9 +62,14 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
|||||||
// caller's own agenda, so every slot looked free.
|
// caller's own agenda, so every slot looked free.
|
||||||
serviceRepository
|
serviceRepository
|
||||||
.getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
|
.getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
|
||||||
.then((services) {
|
.then((calendar) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _services = services);
|
setState(() {
|
||||||
|
_services = calendar.services;
|
||||||
|
// Opening hours as the professional has them today, not the copy the
|
||||||
|
// search list handed over, which can be days old.
|
||||||
|
_schedules = calendar.schedules;
|
||||||
|
});
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _loadFailed = true);
|
setState(() => _loadFailed = true);
|
||||||
@@ -149,21 +155,25 @@ class UserCalendarScreenState extends State<UserCalendarScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ScheduleEntity? _getScheduleFromNumDay(int numDay) {
|
ScheduleEntity? _getScheduleFromNumDay(int numDay) {
|
||||||
|
// Prefer the hours the calendar endpoint just returned; fall back to the
|
||||||
|
// snapshot carried by the search list only until it arrives.
|
||||||
|
final schedules =
|
||||||
|
_schedules ?? widget.userProfessional.professionalInfo.schedules;
|
||||||
switch (numDay) {
|
switch (numDay) {
|
||||||
case 1:
|
case 1:
|
||||||
return widget.userProfessional.professionalInfo.schedules.monday;
|
return schedules.monday;
|
||||||
case 2:
|
case 2:
|
||||||
return widget.userProfessional.professionalInfo.schedules.tuesday;
|
return schedules.tuesday;
|
||||||
case 3:
|
case 3:
|
||||||
return widget.userProfessional.professionalInfo.schedules.wednesday;
|
return schedules.wednesday;
|
||||||
case 4:
|
case 4:
|
||||||
return widget.userProfessional.professionalInfo.schedules.thursday;
|
return schedules.thursday;
|
||||||
case 5:
|
case 5:
|
||||||
return widget.userProfessional.professionalInfo.schedules.friday;
|
return schedules.friday;
|
||||||
case 6:
|
case 6:
|
||||||
return widget.userProfessional.professionalInfo.schedules.saturday;
|
return schedules.saturday;
|
||||||
case 7:
|
case 7:
|
||||||
return widget.userProfessional.professionalInfo.schedules.sunday;
|
return schedules.sunday;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ 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/service_day.dart';
|
||||||
import 'package:prosappco/utils/service_day_param.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:service_repository/service_repository.dart' as srv;
|
||||||
import 'package:setting_repository/setting_repository.dart';
|
import 'package:setting_repository/setting_repository.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ class UserMapScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserMapScreenState extends State<UserMapScreen> {
|
class _UserMapScreenState extends State<UserMapScreen> {
|
||||||
|
final _serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
|
||||||
final Completer<GoogleMapController> _mapController =
|
final Completer<GoogleMapController> _mapController =
|
||||||
Completer<GoogleMapController>();
|
Completer<GoogleMapController>();
|
||||||
LatLng? _currentP;
|
LatLng? _currentP;
|
||||||
@@ -210,6 +213,38 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Re-reads the professional's agenda right before creating the service.
|
||||||
|
///
|
||||||
|
/// Returns true when the slot is still free, or when the agenda cannot be
|
||||||
|
/// read: blocking a booking because the network hiccuped would be worse
|
||||||
|
/// than the rare collision the backend can still reject.
|
||||||
|
Future<bool> _slotStillFree() async {
|
||||||
|
final pro = profesionalSeleccionado;
|
||||||
|
final day = fechaSeleccionada;
|
||||||
|
final hour = horaSeleccionada;
|
||||||
|
if (pro == null || day == null || hour == null) return false;
|
||||||
|
try {
|
||||||
|
final calendar = await _serviceRepository
|
||||||
|
.getPublicCalendar(pro.professionalInfo.recordId);
|
||||||
|
final taken = calendar.services.any((s) =>
|
||||||
|
s.status != srv.ServiceStatus.cancelled &&
|
||||||
|
s.status != srv.ServiceStatus.denied &&
|
||||||
|
isSameServiceDay(day, s.day) &&
|
||||||
|
s.range1Hour1 == hour);
|
||||||
|
if (!taken) return true;
|
||||||
|
if (!mounted) return false;
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Ese horario acaba de ocuparse. Elige otro.'),
|
||||||
|
));
|
||||||
|
setState(() => horaSeleccionada = null);
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<ServiceBloc>(
|
return BlocProvider<ServiceBloc>(
|
||||||
@@ -548,7 +583,7 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
|||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: isLoading
|
onPressed: isLoading
|
||||||
? null
|
? null
|
||||||
: () {
|
: () async {
|
||||||
// Used to `return` in silence when anything was
|
// Used to `return` in silence when anything was
|
||||||
// missing, so the main button simply did nothing.
|
// missing, so the main button simply did nothing.
|
||||||
final faltan = <String>[
|
final faltan = <String>[
|
||||||
@@ -580,6 +615,12 @@ class _UserMapScreenState extends State<UserMapScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minutes can pass between picking the hour and
|
||||||
|
// confirming. Ask the agenda again so two patients
|
||||||
|
// do not walk away holding the same slot.
|
||||||
|
if (!await _slotStillFree()) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (serviceLocationPreference ==
|
if (serviceLocationPreference ==
|
||||||
ServiceLocationPreferences.office) {
|
ServiceLocationPreferences.office) {
|
||||||
if (settings?.tarifas == true) {
|
if (settings?.tarifas == true) {
|
||||||
|
|||||||
@@ -51,6 +51,43 @@ class Schedules extends Equatable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses the array the backend returns, keyed by `day_of_week`
|
||||||
|
/// (0 = Monday … 6 = Sunday). Anything else yields [Schedules.empty]
|
||||||
|
/// rather than throwing, so one malformed row cannot blank a screen.
|
||||||
|
static Schedules fromApiArray(dynamic raw) {
|
||||||
|
if (raw is! List || raw.isEmpty) return Schedules.empty;
|
||||||
|
|
||||||
|
final byDay = <int, Map<String, dynamic>>{};
|
||||||
|
for (final s in raw) {
|
||||||
|
if (s is! Map) continue;
|
||||||
|
final day = (s['day_of_week'] as num?)?.toInt();
|
||||||
|
if (day != null) byDay[day] = s.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleEntity entityFor(int day) {
|
||||||
|
final s = byDay[day];
|
||||||
|
if (s == null) return ScheduleEntity.empty;
|
||||||
|
return ScheduleEntity(
|
||||||
|
enabled: s['enabled'] as bool? ?? false,
|
||||||
|
continuousDay: s['continuous_day'] as bool? ?? false,
|
||||||
|
range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()),
|
||||||
|
range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()),
|
||||||
|
range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()),
|
||||||
|
range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Schedules(
|
||||||
|
monday: entityFor(0),
|
||||||
|
tuesday: entityFor(1),
|
||||||
|
wednesday: entityFor(2),
|
||||||
|
thursday: entityFor(3),
|
||||||
|
friday: entityFor(4),
|
||||||
|
saturday: entityFor(5),
|
||||||
|
sunday: entityFor(6),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The array PATCH /professionals/me/schedules expects, ordered
|
/// The array PATCH /professionals/me/schedules expects, ordered
|
||||||
/// 0 = Monday … 6 = Sunday.
|
/// 0 = Monday … 6 = Sunday.
|
||||||
///
|
///
|
||||||
|
|||||||
+1
-33
@@ -137,39 +137,7 @@ 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) => Schedules.fromApiArray(raw);
|
||||||
if (raw is! List || raw.isEmpty) return Schedules.empty;
|
|
||||||
|
|
||||||
final byDay = <int, Map<String, dynamic>>{};
|
|
||||||
for (final s in raw) {
|
|
||||||
if (s is! Map) continue;
|
|
||||||
final day = (s['day_of_week'] as num?)?.toInt();
|
|
||||||
if (day != null) byDay[day] = s.cast<String, dynamic>();
|
|
||||||
}
|
|
||||||
|
|
||||||
ScheduleEntity entityFor(int day) {
|
|
||||||
final s = byDay[day];
|
|
||||||
if (s == null) return ScheduleEntity.empty;
|
|
||||||
return ScheduleEntity(
|
|
||||||
enabled: s['enabled'] as bool? ?? false,
|
|
||||||
continuousDay: s['continuous_day'] as bool? ?? false,
|
|
||||||
range1Hour1: ScheduleEntity.parseTime(s['range1_hour1']?.toString()),
|
|
||||||
range1Hour2: ScheduleEntity.parseTime(s['range1_hour2']?.toString()),
|
|
||||||
range2Hour1: ScheduleEntity.parseTime(s['range2_hour1']?.toString()),
|
|
||||||
range2Hour2: ScheduleEntity.parseTime(s['range2_hour2']?.toString()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Schedules(
|
|
||||||
monday: entityFor(0),
|
|
||||||
tuesday: entityFor(1),
|
|
||||||
wednesday: entityFor(2),
|
|
||||||
thursday: entityFor(3),
|
|
||||||
friday: entityFor(4),
|
|
||||||
saturday: entityFor(5),
|
|
||||||
sunday: entityFor(6),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProfessionalEntity _fromApi(Map<String, dynamic> json) {
|
ProfessionalEntity _fromApi(Map<String, dynamic> json) {
|
||||||
return ProfessionalEntity(
|
return ProfessionalEntity(
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export '/src/entities/service_entity.dart';
|
export '/src/entities/service_entity.dart';
|
||||||
|
export 'public_calendar.dart';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:professional_repository/professional_repository.dart'
|
||||||
|
show Schedules;
|
||||||
|
|
||||||
|
import 'service_entity.dart';
|
||||||
|
|
||||||
|
/// What a client needs to draw another professional's availability: the
|
||||||
|
/// opening hours as they stand right now, plus the slots already taken.
|
||||||
|
///
|
||||||
|
/// The two travel together on purpose. Reading the hours from a list snapshot
|
||||||
|
/// and the appointments from the live endpoint let a patient be offered a slot
|
||||||
|
/// on a day the professional had already closed.
|
||||||
|
class PublicCalendar {
|
||||||
|
final Schedules schedules;
|
||||||
|
final List<ServiceEntity> services;
|
||||||
|
|
||||||
|
const PublicCalendar({required this.schedules, required this.services});
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:professional_repository/professional_repository.dart'
|
||||||
|
show Schedules;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:service_repository/service_repository.dart';
|
import 'package:service_repository/service_repository.dart';
|
||||||
|
|
||||||
@@ -235,11 +237,17 @@ class ApiServiceRepository {
|
|||||||
/// (`ProfessionalEntity.recordId`), not the owning user's id. Clients used to
|
/// (`ProfessionalEntity.recordId`), not the owning user's id. Clients used to
|
||||||
/// call the endpoint above, which returns the *caller's* own agenda, so every
|
/// call the endpoint above, which returns the *caller's* own agenda, so every
|
||||||
/// slot looked free and two people could book the same hour.
|
/// slot looked free and two people could book the same hour.
|
||||||
Future<List<ServiceEntity>> getPublicCalendar(
|
Future<PublicCalendar> getPublicCalendar(String professionalRecordId) async {
|
||||||
String professionalRecordId) async {
|
|
||||||
final body = await _get('/services/public-calendar/$professionalRecordId');
|
final body = await _get('/services/public-calendar/$professionalRecordId');
|
||||||
final raw = body is Map ? (body['services'] as List? ?? []) : <dynamic>[];
|
final map = body is Map ? body : const {};
|
||||||
return raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList();
|
final raw = map['services'] as List? ?? <dynamic>[];
|
||||||
|
return PublicCalendar(
|
||||||
|
// The endpoint also returns the professional's current opening hours.
|
||||||
|
// Reading them here instead of the snapshot cached in the search list
|
||||||
|
// is what keeps the patient from booking against yesterday's agenda.
|
||||||
|
schedules: Schedules.fromApiArray(map['schedules']),
|
||||||
|
services: raw.map((e) => _fromApi(e as Map<String, dynamic>)).toList(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<ServiceEntity>> getServicesHistoryForUser(String userId) {
|
Stream<List<ServiceEntity>> getServicesHistoryForUser(String userId) {
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.2"
|
version: "4.0.2"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -216,6 +224,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
professional_repository:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../professional_repository"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "1.0.11+11"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
professional_repository:
|
||||||
|
path: ../professional_repository
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
shared_preferences: ^2.0.10
|
shared_preferences: ^2.0.10
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user