Files
prosappco/lib/screens/user/user_calendar_screen.dart
Lizandro GuarnizoandClaude Opus 5 7930890d09 fix: clients read the professional's real agenda, not their own
/services/public-calendar/{professionalId} exists after all — the web has been
using it all along. My earlier probes missed it because they guessed the URL
shape, and because it is keyed by the professionals row id rather than the
owning user id, which ProfessionalEntity was discarding.

Adds ProfessionalEntity.recordId and points the client calendar at the public
endpoint. Until now a client browsing a professional's availability was served
their own agenda, so every slot looked free and two people could book the same
hour. A failed load now hides the slots and offers a retry instead of showing
them as available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:29:31 -05:00

505 lines
17 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;
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) => setState(() {
settings = value;
}),
);
}
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((services) {
if (!mounted) return;
setState(() => _services = services);
}).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) {
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 (_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"),
)
];
}
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();
}
}