diff --git a/lib/screens/professional/professional_calendar_screen.dart b/lib/screens/professional/professional_calendar_screen.dart index 316a826..6e75d59 100644 --- a/lib/screens/professional/professional_calendar_screen.dart +++ b/lib/screens/professional/professional_calendar_screen.dart @@ -414,7 +414,15 @@ class _ProfessionalCalendarScreenState void _onOccupied(TimeOfDay time) { 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) { _confirmUnblock(event.id!, time); diff --git a/lib/screens/professional/professional_profile_screen.dart b/lib/screens/professional/professional_profile_screen.dart index 9f3cdb2..4a002d0 100644 --- a/lib/screens/professional/professional_profile_screen.dart +++ b/lib/screens/professional/professional_profile_screen.dart @@ -663,6 +663,26 @@ class _ProfessionalProfileScreenState } 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().add(UpdateProfessionalProfileInfo( address: _addressController.text, aditionalAddress: _aditionalAddressController.text, @@ -682,7 +702,7 @@ class _ProfessionalProfileScreenState ), schedules: schedules, ratePreferences: rateValue, - rate: _rateController.text, + rate: rateValue ? _rateController.text.trim() : '', slotDurationMinutes: _slotDurationMinutes, )); if (_imageFile != null) { diff --git a/lib/screens/user/user_calendar_screen.dart b/lib/screens/user/user_calendar_screen.dart index 448e7d4..8af0247 100644 --- a/lib/screens/user/user_calendar_screen.dart +++ b/lib/screens/user/user_calendar_screen.dart @@ -30,6 +30,7 @@ class UserCalendarScreenState extends State { late int numDay; List? _services; + Schedules? _schedules; bool _loadFailed = false; CalendarFormat _calendarFormat = CalendarFormat.month; @@ -61,9 +62,14 @@ class UserCalendarScreenState extends State { // caller's own agenda, so every slot looked free. serviceRepository .getPublicCalendar(widget.userProfessional.professionalInfo.recordId) - .then((services) { + .then((calendar) { 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) { if (!mounted) return; setState(() => _loadFailed = true); @@ -149,21 +155,25 @@ class UserCalendarScreenState extends State { } 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) { case 1: - return widget.userProfessional.professionalInfo.schedules.monday; + return schedules.monday; case 2: - return widget.userProfessional.professionalInfo.schedules.tuesday; + return schedules.tuesday; case 3: - return widget.userProfessional.professionalInfo.schedules.wednesday; + return schedules.wednesday; case 4: - return widget.userProfessional.professionalInfo.schedules.thursday; + return schedules.thursday; case 5: - return widget.userProfessional.professionalInfo.schedules.friday; + return schedules.friday; case 6: - return widget.userProfessional.professionalInfo.schedules.saturday; + return schedules.saturday; case 7: - return widget.userProfessional.professionalInfo.schedules.sunday; + return schedules.sunday; default: return null; } diff --git a/lib/screens/user/user_map_screen.dart b/lib/screens/user/user_map_screen.dart index 389e020..a328e27 100644 --- a/lib/screens/user/user_map_screen.dart +++ b/lib/screens/user/user_map_screen.dart @@ -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/user/user_service_screen.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/version_utils.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:url_launcher/url_launcher.dart'; @@ -37,6 +39,7 @@ class UserMapScreen extends StatefulWidget { } class _UserMapScreenState extends State { + final _serviceRepository = Injector.appInstance.get(); final Completer _mapController = Completer(); LatLng? _currentP; @@ -210,6 +213,38 @@ class _UserMapScreenState extends State { } } + + /// 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 _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 Widget build(BuildContext context) { return BlocProvider( @@ -548,7 +583,7 @@ class _UserMapScreenState extends State { child: FilledButton( onPressed: isLoading ? null - : () { + : () async { // Used to `return` in silence when anything was // missing, so the main button simply did nothing. final faltan = [ @@ -580,6 +615,12 @@ class _UserMapScreenState extends State { 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 == ServiceLocationPreferences.office) { if (settings?.tarifas == true) { diff --git a/packages/professional_repository/lib/src/entities/schedules.dart b/packages/professional_repository/lib/src/entities/schedules.dart index a8f46d7..7453de6 100644 --- a/packages/professional_repository/lib/src/entities/schedules.dart +++ b/packages/professional_repository/lib/src/entities/schedules.dart @@ -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 = >{}; + 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(); + } + + 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 /// 0 = Monday … 6 = Sunday. /// diff --git a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart index 408c33b..1afd8c8 100644 --- a/packages/professional_repository/lib/src/repositories/api_professional_repository.dart +++ b/packages/professional_repository/lib/src/repositories/api_professional_repository.dart @@ -137,39 +137,7 @@ class ApiProfessionalRepository { /// The backend sends `schedules` as an array of rows keyed by /// `day_of_week` (0 = Monday … 6 = Sunday), not as a map of day names. - Schedules _schedulesFromApi(dynamic raw) { - if (raw is! List || raw.isEmpty) return Schedules.empty; - - final byDay = >{}; - 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(); - } - - 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), - ); - } + Schedules _schedulesFromApi(dynamic raw) => Schedules.fromApiArray(raw); ProfessionalEntity _fromApi(Map json) { return ProfessionalEntity( diff --git a/packages/service_repository/lib/src/entities/entities.dart b/packages/service_repository/lib/src/entities/entities.dart index 8435f2f..febb140 100644 --- a/packages/service_repository/lib/src/entities/entities.dart +++ b/packages/service_repository/lib/src/entities/entities.dart @@ -1 +1,2 @@ export '/src/entities/service_entity.dart'; +export 'public_calendar.dart'; diff --git a/packages/service_repository/lib/src/entities/public_calendar.dart b/packages/service_repository/lib/src/entities/public_calendar.dart new file mode 100644 index 0000000..fed617e --- /dev/null +++ b/packages/service_repository/lib/src/entities/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 services; + + const PublicCalendar({required this.schedules, required this.services}); +} diff --git a/packages/service_repository/lib/src/repositories/api_service_repository.dart b/packages/service_repository/lib/src/repositories/api_service_repository.dart index c8d3824..32d73b8 100644 --- a/packages/service_repository/lib/src/repositories/api_service_repository.dart +++ b/packages/service_repository/lib/src/repositories/api_service_repository.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; 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:service_repository/service_repository.dart'; @@ -235,11 +237,17 @@ class ApiServiceRepository { /// (`ProfessionalEntity.recordId`), not the owning user's id. Clients used to /// call the endpoint above, which returns the *caller's* own agenda, so every /// slot looked free and two people could book the same hour. - Future> getPublicCalendar( - String professionalRecordId) async { + Future getPublicCalendar(String professionalRecordId) async { final body = await _get('/services/public-calendar/$professionalRecordId'); - final raw = body is Map ? (body['services'] as List? ?? []) : []; - return raw.map((e) => _fromApi(e as Map)).toList(); + final map = body is Map ? body : const {}; + final raw = map['services'] as List? ?? []; + 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)).toList(), + ); } Stream> getServicesHistoryForUser(String userId) { diff --git a/packages/service_repository/pubspec.lock b/packages/service_repository/pubspec.lock index 4584669..e628231 100644 --- a/packages/service_repository/pubspec.lock +++ b/packages/service_repository/pubspec.lock @@ -112,6 +112,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: @@ -216,6 +224,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + professional_repository: + dependency: "direct main" + description: + path: "../professional_repository" + relative: true + source: path + version: "1.0.11+11" shared_preferences: dependency: "direct main" description: diff --git a/packages/service_repository/pubspec.yaml b/packages/service_repository/pubspec.yaml index 6b30e9e..2f124e2 100644 --- a/packages/service_repository/pubspec.yaml +++ b/packages/service_repository/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter: sdk: flutter equatable: ^2.0.5 + professional_repository: + path: ../professional_repository http: ^1.1.0 shared_preferences: ^2.0.10