fix(calendar): distinguish blocked slots from booked ones
ci-651288 / run (push) Waiting to run
ci-946620 / run (push) Waiting to run

_isOccupied matched any service on the slot regardless of status. Unblocking
sets the service to cancelled, so a released slot still rendered as occupied
and the unblock looked like it did nothing. Cancelled and denied services are
now ignored, and self-booked slots get their own "Bloqueado" state with a
release action, mirroring the web.

Self-booking now spans the professional's configured slot duration instead of
a hardcoded two hours, which no longer matched the generated slots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 16:50:57 -05:00
co-authored by Claude Opus 5
parent 30b8689814
commit 2c1ba7f6d5
@@ -14,6 +14,7 @@ import 'package:table_calendar/table_calendar.dart';
const _kPrimary = Color(0xFF1565C0);
const _kAvailable = Color(0xFF16A34A);
const _kOccupied = Color(0xFFDC2626);
const _kBlocked = Color(0xFF7C3AED);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
@@ -82,7 +83,8 @@ class _ProfessionalCalendarScreenState
final slots = _buildSlots(schedule);
final occupied =
slots.where((t) => _isOccupied(t, _services, today)).length;
final available = slots.length - occupied;
final blocked = slots.where((t) => _isBlocked(t, _services, today)).length;
final available = slots.length - occupied - blocked;
return Scaffold(
backgroundColor: context.bg,
@@ -117,8 +119,8 @@ class _ProfessionalCalendarScreenState
if (_loadFailed)
_loadErrorState(context)
else ...[
_dayHeader(
context, schedule, slots.length, occupied, available),
_dayHeader(context, schedule, slots.length, occupied,
blocked, available),
if (slots.isEmpty)
_emptyState(context)
else
@@ -209,7 +211,7 @@ class _ProfessionalCalendarScreenState
}
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total,
int occupied, int available) {
int occupied, int blocked, int available) {
final dayName = DateFormat('EEEE', 'es').format(today);
final dateStr = DateFormat('d MMMM yyyy', 'es').format(today);
final hasSchedule = schedule != null && schedule.enabled && total > 0;
@@ -261,6 +263,11 @@ class _ProfessionalCalendarScreenState
if (hasSchedule) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8),
if (blocked > 0) ...[
_StatPill(
label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
const SizedBox(width: 8),
],
_StatPill(
label: '$available', sublabel: 'libres', color: _kAvailable),
],
@@ -272,7 +279,13 @@ class _ProfessionalCalendarScreenState
BuildContext context, List<TimeOfDay> slots, ServiceState state) {
return slots.map((time) {
final occ = _isOccupied(time, _services, today);
final color = occ ? _kOccupied : _kAvailable;
final blocked = _isBlocked(time, _services, today);
final taken = occ || blocked;
final color = occ
? _kOccupied
: blocked
? _kBlocked
: _kAvailable;
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -289,7 +302,8 @@ class _ProfessionalCalendarScreenState
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => occ ? _onOccupied(time) : _onAvailable(context, time, state),
onTap: () =>
taken ? _onOccupied(time) : _onAvailable(context, time, state),
child: IntrinsicHeight(
child: Row(children: [
Container(width: 4, color: color),
@@ -318,7 +332,12 @@ class _ProfessionalCalendarScreenState
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(occ ? 'Ocupado' : 'Disponible',
Text(
occ
? 'Ocupado'
: blocked
? 'Bloqueado'
: 'Disponible',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
@@ -326,7 +345,9 @@ class _ProfessionalCalendarScreenState
Text(
occ
? 'Toca para ver el servicio'
: 'Horario libre · toca para reservar',
: blocked
? 'Toca para liberar el horario'
: 'Horario libre · toca para reservar',
style: TextStyle(
fontSize: 11, color: context.subtle),
),
@@ -342,7 +363,9 @@ class _ProfessionalCalendarScreenState
child: Icon(
occ
? Icons.event_busy_outlined
: Icons.event_available_outlined,
: blocked
? Icons.lock_outline
: Icons.event_available_outlined,
size: 17,
color: color),
),
@@ -358,21 +381,17 @@ class _ProfessionalCalendarScreenState
}
void _onOccupied(TimeOfDay time) {
if (_services == null) return;
for (final event in _services!) {
if (today.toString() == event.day && time == event.range1Hour1) {
if (event.userId == event.professionalId) {
_confirmUnblock(event.id!, time);
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
ProfessionalServiceScreen(serviceId: event.id!)),
);
}
return;
}
final event = _serviceAt(time, _services, today);
if (event?.id == null) return;
if (event!.status == ServiceStatus.selfBooked) {
_confirmUnblock(event.id!, time);
} else {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)),
);
}
}
@@ -568,13 +587,36 @@ class _ProfessionalCalendarScreenState
];
}
bool _isOccupied(
/// The still-live service sitting on [time], if any.
///
/// Cancelled and denied services are ignored on purpose: unblocking a slot
/// cancels its service, so counting those would leave the slot looking
/// occupied forever and make "desbloquear" appear to do nothing.
ServiceEntity? _serviceAt(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) {
if (services == null) return false;
return services
.any((s) => day.toString() == s.day && s.range1Hour1 == time);
if (services == null) return null;
for (final s in services) {
if (day.toString() != s.day || s.range1Hour1 != time) continue;
if (s.status == ServiceStatus.cancelled ||
s.status == ServiceStatus.denied) {
continue;
}
return s;
}
return null;
}
/// Booked by a client — tapping it opens the service detail.
bool _isOccupied(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) =>
_serviceAt(time, services, day)?.status != null &&
_serviceAt(time, services, day)!.status != ServiceStatus.selfBooked;
/// Reserved by the professional themselves — tapping it offers to release it.
bool _isBlocked(
TimeOfDay time, List<ServiceEntity>? services, DateTime day) =>
_serviceAt(time, services, day)?.status == ServiceStatus.selfBooked;
String _capitalize(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}