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 _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,6 +345,8 @@ class _ProfessionalCalendarScreenState
|
|||||||
Text(
|
Text(
|
||||||
occ
|
occ
|
||||||
? 'Toca para ver el servicio'
|
? 'Toca para ver el servicio'
|
||||||
|
: blocked
|
||||||
|
? 'Toca para liberar el horario'
|
||||||
: 'Horario libre · toca para reservar',
|
: 'Horario libre · toca para reservar',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11, color: context.subtle),
|
fontSize: 11, color: context.subtle),
|
||||||
@@ -342,6 +363,8 @@ class _ProfessionalCalendarScreenState
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
occ
|
occ
|
||||||
? Icons.event_busy_outlined
|
? Icons.event_busy_outlined
|
||||||
|
: blocked
|
||||||
|
? Icons.lock_outline
|
||||||
: Icons.event_available_outlined,
|
: Icons.event_available_outlined,
|
||||||
size: 17,
|
size: 17,
|
||||||
color: color),
|
color: color),
|
||||||
@@ -358,22 +381,18 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _confirmUnblock(String serviceId, TimeOfDay time) {
|
void _confirmUnblock(String serviceId, TimeOfDay time) {
|
||||||
@@ -568,12 +587,35 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user