Files
prosappco/lib/screens/user/user_calendar_screen.dart
T
Lizandro GuarnizoandClaude Opus 5 8c6e2ae024 fix: compare service days as dates, not raw strings
DateTime.toString() renders "2026-07-13 00:00:00.000Z" while the backend
sends "2026-07-13T00:00:00.000Z", so every day comparison was false and no
booked slot was ever detected. Both calendars showed fully free days.

The client calendar now also treats an unloaded agenda as busy instead of
free: rendering a taken slot as available leads straight to a double booking.

Note: the client still reads /services/professional/calendar, which returns
the *caller's* calendar. There is no endpoint for another professional's
agenda, so a client cannot yet see which of that professional's slots are
taken. That needs a backend endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:27:40 -05:00

474 lines
15 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/time_of_day_extension.dart';
import 'package:prosappco/utils/service_day.dart';
import 'package:prosappco/utils/time_of_day_utils.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;
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) => setState(() {
settings = value;
}),
);
}
void _loadServices() {
serviceRepository
.getServicesForProfessionalforCalendar(
widget.userProfessional.myUser.id)
.then((services) {
setState(() {
_services = services;
});
});
}
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) {
switch (numDay) {
case 1:
return widget.userProfessional.professionalInfo.schedules.monday;
case 2:
return widget.userProfessional.professionalInfo.schedules.tuesday;
case 3:
return widget.userProfessional.professionalInfo.schedules.wednesday;
case 4:
return widget.userProfessional.professionalInfo.schedules.thursday;
case 5:
return widget.userProfessional.professionalInfo.schedules.friday;
case 6:
return widget.userProfessional.professionalInfo.schedules.saturday;
case 7:
return widget.userProfessional.professionalInfo.schedules.sunday;
default:
return null;
}
}
List<Widget> _rangesItems(ScheduleEntity? schedule) {
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"),
)
];
}
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,
);
return [
...rangesItemList(ranges1, _services, today),
...rangesItemList(ranges2, _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(const Duration(hours: 3)))) {
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();
}
}