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 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user