From 2c1ba7f6d5733aefd79928163410826d079bffbd Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:50:57 -0500 Subject: [PATCH] fix(calendar): distinguish blocked slots from booked ones _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 --- .../professional_calendar_screen.dart | 98 +++++++++++++------ 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/lib/screens/professional/professional_calendar_screen.dart b/lib/screens/professional/professional_calendar_screen.dart index d799039..bae7a34 100644 --- a/lib/screens/professional/professional_calendar_screen.dart +++ b/lib/screens/professional/professional_calendar_screen.dart @@ -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 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? 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? 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? services, DateTime day) => + _serviceAt(time, services, day)?.status == ServiceStatus.selfBooked; + String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); }