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>
474 lines
15 KiB
Dart
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();
|
|
}
|
|
}
|