/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>
501 lines
16 KiB
Dart
501 lines
16 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
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/service_day.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';
|
|
|
|
class UserCalendarScreen extends StatefulWidget {
|
|
final UserProfessional userProfessional;
|
|
|
|
const UserCalendarScreen({super.key, required this.userProfessional});
|
|
|
|
@override
|
|
State<UserCalendarScreen> createState() => UserCalendarScreenState();
|
|
}
|
|
|
|
class UserCalendarScreenState extends State<UserCalendarScreen> {
|
|
final settingRepository = Injector.appInstance.get<SettingRepository>();
|
|
final serviceRepository =
|
|
Injector.appInstance.get<ApiServiceRepository>();
|
|
SettingEntity? settings;
|
|
|
|
DateTime today = DateTime.now();
|
|
DateTime now = DateTime.now();
|
|
late int numDay;
|
|
|
|
List<ServiceEntity>? _services;
|
|
Schedules? _schedules;
|
|
bool _loadFailed = false;
|
|
|
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
today = DateTime.utc(today.year, today.month, today.day);
|
|
numDay = today.weekday;
|
|
|
|
_loadSettings();
|
|
_loadServices();
|
|
}
|
|
|
|
void _loadSettings() {
|
|
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() {
|
|
setState(() => _loadFailed = false);
|
|
// Public calendar of *this* professional. The old call returned the
|
|
// caller's own agenda, so every slot looked free.
|
|
serviceRepository
|
|
.getPublicCalendar(widget.userProfessional.professionalInfo.recordId)
|
|
.then((calendar) {
|
|
if (!mounted) return;
|
|
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);
|
|
});
|
|
}
|
|
|
|
void _onDaySelected(DateTime day, DateTime focusedDay) {
|
|
setState(() {
|
|
today = day;
|
|
numDay = today.weekday;
|
|
});
|
|
}
|
|
|
|
void _onFormatChange(CalendarFormat format) {
|
|
setState(() {
|
|
_calendarFormat = format;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
DateTime lastDay = today.add(const Duration(days: 365));
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Calendario'),
|
|
),
|
|
floatingActionButtonLocation: kIsWeb
|
|
? FloatingActionButtonLocation.startFloat
|
|
: FloatingActionButtonLocation.endFloat,
|
|
resizeToAvoidBottomInset: false,
|
|
body: Column(
|
|
children: [
|
|
Container(
|
|
color: const Color.fromARGB(255, 224, 247, 255),
|
|
child: TableCalendar(
|
|
locale: 'es_MX',
|
|
firstDay: DateTime.now(),
|
|
lastDay: lastDay,
|
|
focusedDay: today,
|
|
availableGestures: AvailableGestures.all,
|
|
onDaySelected: _onDaySelected,
|
|
selectedDayPredicate: (day) => isSameDay(day, today),
|
|
calendarFormat: _calendarFormat,
|
|
onFormatChanged: _onFormatChange,
|
|
availableCalendarFormats: const {
|
|
CalendarFormat.month: 'Mes',
|
|
CalendarFormat.week: 'Semana',
|
|
CalendarFormat.twoWeeks: '2 Semanas',
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
|
child: Text(
|
|
DateFormat('dd MMMM yyyy', 'es').format(today),
|
|
style: const TextStyle(
|
|
color: Colors.black,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Divider(
|
|
height: 0,
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.only(bottom: 15),
|
|
child: Column(
|
|
children: [
|
|
..._rangesItems(_getScheduleFromNumDay(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) {
|
|
case 1:
|
|
return schedules.monday;
|
|
case 2:
|
|
return schedules.tuesday;
|
|
case 3:
|
|
return schedules.wednesday;
|
|
case 4:
|
|
return schedules.thursday;
|
|
case 5:
|
|
return schedules.friday;
|
|
case 6:
|
|
return schedules.saturday;
|
|
case 7:
|
|
return schedules.sunday;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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.
|
|
return [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
const Icon(Icons.wifi_off_outlined, size: 32, color: Colors.grey),
|
|
const SizedBox(height: 10),
|
|
const Text('No se pudo cargar la disponibilidad',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'No mostramos horarios para evitar que reserves sobre una cita ya agendada.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 13, color: Colors.grey)),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton(
|
|
onPressed: _loadServices, child: const Text('Reintentar')),
|
|
],
|
|
),
|
|
),
|
|
];
|
|
}
|
|
if (schedule == null) {
|
|
return [
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 20),
|
|
child: Text("No hay horarios disponibles"),
|
|
)
|
|
];
|
|
}
|
|
if (!schedule.enabled) {
|
|
return [
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 20),
|
|
child: Text("No hay horarios disponibles"),
|
|
)
|
|
];
|
|
}
|
|
|
|
// 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 [
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 20),
|
|
child: Text("No hay horarios disponibles"),
|
|
)
|
|
];
|
|
}
|
|
return rangesItemList(ranges, _services, today);
|
|
}
|
|
|
|
bool _isHora1Ocupada(
|
|
TimeOfDay hora1, List<ServiceEntity>? events, DateTime selectedDay) {
|
|
// Treat "could not load the agenda" as busy rather than free: showing a
|
|
// taken slot as available leads straight to a double booking.
|
|
if (events == null) return true;
|
|
for (final event in events) {
|
|
if (!isSameServiceDay(selectedDay, event.day)) continue;
|
|
if (event.status == ServiceStatus.cancelled ||
|
|
event.status == ServiceStatus.denied) {
|
|
continue;
|
|
}
|
|
if (hora1 == event.range1Hour1) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<Widget> rangesItemList(List<TimeOfDay> ranges,
|
|
List<ServiceEntity>? events, DateTime selectedDay) {
|
|
final currentDateTime = DateTime.now();
|
|
|
|
return ranges.map((time) {
|
|
final selectedDateTime = DateTime(
|
|
selectedDay.year,
|
|
selectedDay.month,
|
|
selectedDay.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
|
|
if (selectedDateTime
|
|
.isBefore(currentDateTime.add(kBookingLeadTime))) {
|
|
return Card(
|
|
elevation: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: ListTile(
|
|
contentPadding: const EdgeInsets.all(16),
|
|
leading: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.grey,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.access_time,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
|
|
|
|
title: Text(
|
|
ScheduleEntity.getFormatTime(time) ?? '',
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: const Text(
|
|
'No disponible',
|
|
style: TextStyle(
|
|
color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
trailing: const Icon(
|
|
Icons.arrow_forward_ios,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_isHora1Ocupada(time, events, selectedDay)) {
|
|
return Card(
|
|
elevation: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: ListTile(
|
|
onTap: () {
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text('Este horario ya fue ocupado'),
|
|
));
|
|
},
|
|
contentPadding: const EdgeInsets.all(16),
|
|
leading: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [Colors.yellow, Colors.red, Colors.red],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.access_time,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
ScheduleEntity.getFormatTime(time) ?? '',
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: const Text(
|
|
'Ocupado',
|
|
style: TextStyle(
|
|
color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
trailing: const Icon(
|
|
Icons.arrow_forward_ios,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return Card(
|
|
elevation: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: ListTile(
|
|
onTap: () {
|
|
if (settings?.domicilios == true) {
|
|
if (widget.userProfessional.professionalInfo
|
|
.locationPreferences ==
|
|
LocationPreferences.both) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
Navigator.pop(context, [
|
|
today,
|
|
time,
|
|
ServiceLocationPreferences.delivery,
|
|
widget.userProfessional,
|
|
]);
|
|
},
|
|
child: const Text('A domicilio'),
|
|
),
|
|
const Divider(color: Colors.black54),
|
|
GestureDetector(
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
Navigator.pop(context, [
|
|
today,
|
|
time,
|
|
ServiceLocationPreferences.office,
|
|
widget.userProfessional,
|
|
]);
|
|
},
|
|
child: const Text('En sitio'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} else if (widget.userProfessional.professionalInfo
|
|
.locationPreferences ==
|
|
LocationPreferences.delivery) {
|
|
Navigator.pop(context, [
|
|
today,
|
|
time,
|
|
ServiceLocationPreferences.delivery,
|
|
widget.userProfessional,
|
|
]);
|
|
} else {
|
|
Navigator.pop(context, [
|
|
today,
|
|
time,
|
|
ServiceLocationPreferences.office,
|
|
widget.userProfessional,
|
|
]);
|
|
}
|
|
} else {
|
|
Navigator.pop(context, [
|
|
today,
|
|
time,
|
|
ServiceLocationPreferences.office,
|
|
widget.userProfessional,
|
|
]);
|
|
}
|
|
},
|
|
contentPadding: const EdgeInsets.all(16),
|
|
leading: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [Colors.blue, Colors.green],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.access_time,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
ScheduleEntity.getFormatTime(time) ?? '',
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: const Text(
|
|
'Disponible',
|
|
style: TextStyle(
|
|
color: Colors.green,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
trailing: const Icon(
|
|
Icons.arrow_forward_ios,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}).toList();
|
|
}
|
|
}
|