fix: corregir flujo de agendamiento del paciente

- calendar_view: separar carga del profesional (1 vez) de la carga de
  servicios (por cada cambio de día), eliminando re-fetches innecesarios
- calendar_view: usar DateTime local en vez de UTC para evitar desfase
  de fecha en zonas horarias (Colombia UTC-5)
- calendar_view: excluir explícitamente cancelled/denied en _isOccupied
  y refactorizar los 4 tipos de card en un único método _slotCard
- dashboard_view: usar slotDurationMinutes del profesional en range1Hour2
  en vez de 2 horas hardcodeadas, respetando la duración real del slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-22 08:38:32 -05:00
co-authored by Claude Sonnet 4.6
parent 387b647abc
commit 6c248e1f98
2 changed files with 225 additions and 361 deletions
+224 -360
View File
@@ -7,7 +7,6 @@ import 'package:prosapp_web_app/models/service_status.dart';
import 'package:prosapp_web_app/providers/professional_form_provider.dart';
import 'package:prosapp_web_app/providers/professional_provider.dart';
import 'package:prosapp_web_app/providers/calendar_services_provider.dart';
import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:prosapp_web_app/ui/cards/white_card.dart';
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
@@ -24,417 +23,282 @@ class CalendarView extends StatefulWidget {
}
class _CalendarViewState extends State<CalendarView> {
List<Service>? _services;
DateTime today = DateTime.now();
late int numDay;
// Use local time throughout — UTC dates can shift the calendar date near midnight
DateTime _today = DateTime.now();
Profesional? _profesional;
List<Service> _services = [];
bool _loadingPro = true;
bool _loadingServices = false;
@override
void initState() {
super.initState();
today = DateTime.utc(today.year, today.month, today.day);
numDay = today.weekday;
_fetchProfessionalAndServices();
_normalizeDay();
_fetchProfessional();
}
void _fetchProfessionalAndServices() async {
final professionalFormProvider =
Provider.of<ProfessionalFormProvider>(context, listen: false);
final servicesProvider =
Provider.of<CalendarServicesProvider>(context, listen: false);
final proProvider =
Provider.of<ProfessionalProvider>(context, listen: false);
// Use public endpoint so we get THIS professional's schedule, not the viewer's own
final professional =
await proProvider.getProfessionalById(widget.professionalId);
professionalFormProvider.setProfesional(professional);
// Use public calendar endpoint to get services for this specific professional
final services =
await servicesProvider.getPublicServicesForProfessional(professional.id);
setState(() {
_services = services;
});
void _normalizeDay() {
// Strip time component; keep local date only
_today = DateTime(_today.year, _today.month, _today.day);
}
void _onDaySelected(DateTime day, DateTime focusedDay) {
// Loads professional data once — does NOT re-run on day change
Future<void> _fetchProfessional() async {
setState(() => _loadingPro = true);
try {
final proProvider = Provider.of<ProfessionalProvider>(context, listen: false);
final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
final pro = await proProvider.getProfessionalById(widget.professionalId);
fp.setProfesional(pro);
if (mounted) setState(() { _profesional = pro; _loadingPro = false; });
await _fetchServices();
} catch (_) {
if (mounted) setState(() => _loadingPro = false);
}
}
// Fetches only services — called on day change and after initial load
Future<void> _fetchServices() async {
if (_profesional == null) return;
setState(() => _loadingServices = true);
try {
final sp = Provider.of<CalendarServicesProvider>(context, listen: false);
final list = await sp.getPublicServicesForProfessional(_profesional!.id);
if (mounted) setState(() { _services = list; _loadingServices = false; });
} catch (_) {
if (mounted) setState(() => _loadingServices = false);
}
}
void _onDaySelected(DateTime day, DateTime _) {
setState(() {
today = day;
numDay = today.weekday;
_today = DateTime(day.year, day.month, day.day);
});
_fetchProfessionalAndServices();
// Professional data is cached — only re-fetch services
_fetchServices();
}
@override
Widget build(BuildContext context) {
return Consumer<ProfessionalFormProvider>(
builder: (context, professionalFormProvider, child) {
if (professionalFormProvider.profesional == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_loadingPro || _profesional == null) {
return const Center(child: CircularProgressIndicator());
}
final profesional = professionalFormProvider.profesional!;
return ListView(
physics: const ClampingScrollPhysics(),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: WhiteCard(
title: 'Calendario',
child: Column(
children: [
TableCalendar(
locale: 'es_CO',
firstDay: DateTime.now(),
lastDay: DateTime.now().add(const Duration(days: 180)),
focusedDay: today,
availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, today),
),
const Divider(height: 0),
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,
),
return ListView(
physics: const ClampingScrollPhysics(),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900),
child: WhiteCard(
title: 'Calendario',
child: Column(
children: [
TableCalendar(
locale: 'es_CO',
firstDay: DateTime.now(),
lastDay: DateTime.now().add(const Duration(days: 180)),
focusedDay: _today,
availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, _today),
),
const Divider(height: 0),
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),
),
const Divider(height: 0),
if (_loadingServices)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: CircularProgressIndicator(),
)
else
Column(
children: [
..._rangesItems(
_getScheduleFromNumDay(numDay, profesional),
context,
stepMinutes: profesional.slotDurationMinutes),
_getScheduleFromNumDay(_today.weekday, _profesional!),
context,
stepMinutes: _profesional!.slotDurationMinutes,
),
const SizedBox(height: 8),
],
),
],
),
],
),
),
),
],
);
});
),
],
);
}
ScheduleEntity? _getScheduleFromNumDay(
int numDay, Profesional userProfessional) {
ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional pro) {
switch (numDay) {
case 1:
return userProfessional.schedules.monday;
case 2:
return userProfessional.schedules.tuesday;
case 3:
return userProfessional.schedules.wednesday;
case 4:
return userProfessional.schedules.thursday;
case 5:
return userProfessional.schedules.friday;
case 6:
return userProfessional.schedules.saturday;
case 7:
return userProfessional.schedules.sunday;
default:
return null;
case 1: return pro.schedules.monday;
case 2: return pro.schedules.tuesday;
case 3: return pro.schedules.wednesday;
case 4: return pro.schedules.thursday;
case 5: return pro.schedules.friday;
case 6: return pro.schedules.saturday;
case 7: return pro.schedules.sunday;
default: return null;
}
}
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) {
if (schedule == null) {
return [
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text("No hay horarios disponibles"),
)
];
}
if (!schedule.enabled) {
return [
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text("No hay horarios disponibles"),
)
];
if (schedule == null || !schedule.enabled) {
return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
}
final List<TimeOfDay> ranges;
if (schedule.continuousDay) {
if (schedule.range1Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { return [
const Padding(
padding: EdgeInsets.only(top: 20),
child: Text("No hay horarios disponibles"),
)
];
if (schedule.range1Hour1 == null || schedule.range2Hour2 == null ||
schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
}
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
stepMinutes: stepMinutes,
);
return rangesItemList(ranges, _services, today, context);
ranges = TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes);
} 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.only(top: 20),
child: Text("No hay horarios disponibles"),
)
];
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.only(top: 20), child: Text('No hay horarios disponibles'))];
}
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
stepMinutes: stepMinutes,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
stepMinutes: stepMinutes,
);
return [
...rangesItemList(ranges1, _services, today, context),
...rangesItemList(ranges2, _services, today, context),
ranges = [
...TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range1Hour2!, stepMinutes: stepMinutes),
...TimeOfDayUtils.genRanges(schedule.range2Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes),
];
}
return _rangesItemList(ranges, _services, _today, context);
}
bool _isBlocked(TimeOfDay hora1, List<Service>? events, DateTime selectedDay) {
if (events == null) return false;
final dayStr = selectedDay.toIso8601String().split('T').first;
return events.any((e) =>
// A slot is blocked (self-booked by the professional)
bool _isBlocked(TimeOfDay hora1, List<Service> services, DateTime selectedDay) {
final dayStr = _dayStr(selectedDay);
return services.any((e) =>
e.day == dayStr && e.range1Hour1 == hora1 && e.status == ServiceStatus.selfBooked);
}
// A slot is occupied by a real patient booking
// Excludes: selfBooked (professional blocks), cancelled, denied
bool _isOccupied(TimeOfDay hora1, List<Service> services, DateTime selectedDay) {
final dayStr = _dayStr(selectedDay);
return services.any((e) =>
e.day == dayStr &&
e.range1Hour1 == hora1 &&
e.status == ServiceStatus.selfBooked);
e.status != ServiceStatus.selfBooked &&
e.status != ServiceStatus.cancelled &&
e.status != ServiceStatus.denied);
}
bool _isHora1Ocupada(
TimeOfDay hora1, List<Service>? events, DateTime selectedDay) {
if (events != null) {
for (Service event in events) {
if (selectedDay.toIso8601String().split('T').first == event.day) {
if (hora1 == event.range1Hour1 &&
event.status != ServiceStatus.selfBooked) {
return true;
}
}
}
}
return false;
}
String _dayStr(DateTime d) => '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,DateTime selectedDay, BuildContext context) {
final currentDateTime = DateTime.now();
List<Widget> _rangesItemList(
List<TimeOfDay> ranges, List<Service> services, DateTime selectedDay, BuildContext context) {
final now = DateTime.now();
return ranges.map((time) {
final selectedDateTime = DateTime(
selectedDay.year,
selectedDay.month,
selectedDay.day,
time.hour,
time.minute,
// Compare using local time to avoid timezone edge cases
final slotDt = DateTime(selectedDay.year, selectedDay.month, selectedDay.day, time.hour, time.minute);
final isPast = slotDt.isBefore(now.add(const Duration(hours: 3)));
final isBlocked = _isBlocked(time, services, selectedDay);
final isOccupied = !isBlocked && _isOccupied(time, services, selectedDay);
if (isPast || isBlocked) {
return _slotCard(
time: time,
circleColor: Colors.grey,
icon: isBlocked ? Icons.block : Icons.access_time,
label: 'No disponible',
labelColor: Colors.red,
onTap: null,
);
}
if (isOccupied) {
return _slotCard(
time: time,
circleGradient: const LinearGradient(
colors: [Colors.yellow, Colors.red, Colors.red],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
icon: Icons.access_time,
label: 'Ocupado',
labelColor: Colors.red,
onTap: null,
);
}
return _slotCard(
time: time,
circleGradient: const LinearGradient(
colors: [Colors.blue, Colors.green],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
icon: Icons.access_time,
label: 'Disponible',
labelColor: Colors.green,
onTap: () => Navigator.pop(context, [selectedDay, time]),
);
if (selectedDateTime
.isBefore(currentDateTime.add(const Duration(hours: 3)))) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(top: 15, left: 10, right: 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,
color: Colors.grey,
),
),
subtitle: const Text(
'No disponible',
style: TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
);
}
if (_isBlocked(time, events, selectedDay)) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(top: 15, left: 10, right: 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.block, color: Colors.white),
),
),
title: Text(
ScheduleEntity.getFormatTime(time) ?? '',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
subtitle: const Text(
'No disponible',
style: TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
);
}
if (_isHora1Ocupada(time, events, selectedDay)) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
onTap: () {
if (events != null) {
for (Service event in events) {
if (selectedDay.toIso8601String().split('T').first ==
event.day) {
if (time == event.range1Hour1) {
NotificationsService.showSnackbar('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),
),
),
);
} else {
return Card(
elevation: 4,
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
onTap: () {
Navigator.pop(context, [selectedDay, time]);
},
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,
),
),
),
);
}
}).toList();
}
Widget _slotCard({
required TimeOfDay time,
Color? circleColor,
Gradient? circleGradient,
required IconData icon,
required String label,
required Color labelColor,
VoidCallback? onTap,
}) {
return Card(
elevation: 4,
margin: const EdgeInsets.only(top: 15, left: 10, right: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.all(16),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: circleGradient == null ? circleColor : null,
gradient: circleGradient,
shape: BoxShape.circle,
),
child: Center(child: Icon(icon, color: Colors.white)),
),
title: Text(
ScheduleEntity.getFormatTime(time) ?? '',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: onTap == null ? Colors.grey : Colors.black,
),
),
subtitle: Text(
label,
style: TextStyle(color: labelColor, fontSize: 13, fontWeight: FontWeight.bold),
),
),
);
}
}
+1 -1
View File
@@ -384,7 +384,7 @@ class _DashboardViewState extends State<DashboardView> {
createdAt: DateTime.now().toIso8601String(),
description: '',
range1Hour1: _selectedHour!,
range1Hour2: _selectedHour!.add(hour: 2),
range1Hour2: _selectedHour!.add(minute: _professional!.professionalInfo.slotDurationMinutes),
rate: '',
status: ServiceStatus.pending,
location: serviceLocation,