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 _kPrimary = Color(0xFF1565C0);
const _kAvailable = Color(0xFF16A34A); const _kAvailable = Color(0xFF16A34A);
const _kOccupied = Color(0xFFDC2626); const _kOccupied = Color(0xFFDC2626);
const _kBlocked = Color(0xFF7C3AED);
extension _Th on BuildContext { extension _Th on BuildContext {
ThemeData get _t => Theme.of(this); ThemeData get _t => Theme.of(this);
@@ -82,7 +83,8 @@ class _ProfessionalCalendarScreenState
final slots = _buildSlots(schedule); final slots = _buildSlots(schedule);
final occupied = final occupied =
slots.where((t) => _isOccupied(t, _services, today)).length; 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( return Scaffold(
backgroundColor: context.bg, backgroundColor: context.bg,
@@ -117,8 +119,8 @@ class _ProfessionalCalendarScreenState
if (_loadFailed) if (_loadFailed)
_loadErrorState(context) _loadErrorState(context)
else ...[ else ...[
_dayHeader( _dayHeader(context, schedule, slots.length, occupied,
context, schedule, slots.length, occupied, available), blocked, available),
if (slots.isEmpty) if (slots.isEmpty)
_emptyState(context) _emptyState(context)
else else
@@ -209,7 +211,7 @@ class _ProfessionalCalendarScreenState
} }
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, 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 dayName = DateFormat('EEEE', 'es').format(today);
final dateStr = DateFormat('d MMMM yyyy', 'es').format(today); final dateStr = DateFormat('d MMMM yyyy', 'es').format(today);
final hasSchedule = schedule != null && schedule.enabled && total > 0; final hasSchedule = schedule != null && schedule.enabled && total > 0;
@@ -261,6 +263,11 @@ class _ProfessionalCalendarScreenState
if (hasSchedule) ...[ if (hasSchedule) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied), _StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8), const SizedBox(width: 8),
if (blocked > 0) ...[
_StatPill(
label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
const SizedBox(width: 8),
],
_StatPill( _StatPill(
label: '$available', sublabel: 'libres', color: _kAvailable), label: '$available', sublabel: 'libres', color: _kAvailable),
], ],
@@ -272,7 +279,13 @@ class _ProfessionalCalendarScreenState
BuildContext context, List<TimeOfDay> slots, ServiceState state) { BuildContext context, List<TimeOfDay> slots, ServiceState state) {
return slots.map((time) { return slots.map((time) {
final occ = _isOccupied(time, _services, today); 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( return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -289,7 +302,8 @@ class _ProfessionalCalendarScreenState
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
child: InkWell( child: InkWell(
onTap: () => occ ? _onOccupied(time) : _onAvailable(context, time, state), onTap: () =>
taken ? _onOccupied(time) : _onAvailable(context, time, state),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Row(children: [ child: Row(children: [
Container(width: 4, color: color), Container(width: 4, color: color),
@@ -318,7 +332,12 @@ class _ProfessionalCalendarScreenState
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(occ ? 'Ocupado' : 'Disponible', Text(
occ
? 'Ocupado'
: blocked
? 'Bloqueado'
: 'Disponible',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -326,7 +345,9 @@ class _ProfessionalCalendarScreenState
Text( Text(
occ occ
? 'Toca para ver el servicio' ? 'Toca para ver el servicio'
: 'Horario libre · toca para reservar', : blocked
? 'Toca para liberar el horario'
: 'Horario libre · toca para reservar',
style: TextStyle( style: TextStyle(
fontSize: 11, color: context.subtle), fontSize: 11, color: context.subtle),
), ),
@@ -342,7 +363,9 @@ class _ProfessionalCalendarScreenState
child: Icon( child: Icon(
occ occ
? Icons.event_busy_outlined ? Icons.event_busy_outlined
: Icons.event_available_outlined, : blocked
? Icons.lock_outline
: Icons.event_available_outlined,
size: 17, size: 17,
color: color), color: color),
), ),
@@ -358,21 +381,17 @@ class _ProfessionalCalendarScreenState
} }
void _onOccupied(TimeOfDay time) { void _onOccupied(TimeOfDay time) {
if (_services == null) return; final event = _serviceAt(time, _services, today);
for (final event in _services!) { if (event?.id == null) return;
if (today.toString() == event.day && time == event.range1Hour1) {
if (event.userId == event.professionalId) { if (event!.status == ServiceStatus.selfBooked) {
_confirmUnblock(event.id!, time); _confirmUnblock(event.id!, time);
} else { } else {
Navigator.push( Navigator.push(
context, context,
CupertinoPageRoute( CupertinoPageRoute(
builder: (_) => builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)),
ProfessionalServiceScreen(serviceId: event.id!)), );
);
}
return;
}
} }
} }
@@ -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) { TimeOfDay time, List<ServiceEntity>? services, DateTime day) {
if (services == null) return false; if (services == null) return null;
return services for (final s in services) {
.any((s) => day.toString() == s.day && s.range1Hour1 == time); 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) => String _capitalize(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
} }