/services/public-calendar also returns the professional's current opening hours; the client threw them away and drew the day from the snapshot the search list carried, which can be days old. It now uses the fresh ones. Between picking an hour and pressing "Pedir cita" another patient can take the slot. The confirm step asks the agenda again and, if the hour is gone, says so and clears the selection instead of creating a service the professional would have to deny. A failed re-check lets the booking through: a network hiccup should not block a patient. Also: - tapping a busy slot with no id explains itself instead of doing nothing - the profile refuses to save an empty rate with the rate switch on, which was reaching the backend as rate: '' Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
700 lines
26 KiB
Dart
700 lines
26 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:injector/injector.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:professional_repository/professional_repository.dart';
|
|
import 'package:prosappco/blocs/service_bloc/service_bloc.dart';
|
|
import 'package:prosappco/screens/service/professional_service_screen.dart';
|
|
import 'package:prosappco/utils/service_day.dart';
|
|
import 'package:prosappco/utils/slot_generator.dart';
|
|
import 'package:service_repository/service_repository.dart';
|
|
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);
|
|
Color get bg => _t.scaffoldBackgroundColor;
|
|
Color get card => _t.cardColor;
|
|
Color get onSurface => _t.colorScheme.onSurface;
|
|
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
|
|
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
|
|
bool get isDark => _t.brightness == Brightness.dark;
|
|
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.07);
|
|
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
|
|
}
|
|
|
|
class ProfessionalCalendarScreen extends StatefulWidget {
|
|
final ProfessionalEntity userProfessional;
|
|
const ProfessionalCalendarScreen({super.key, required this.userProfessional});
|
|
|
|
@override
|
|
State<ProfessionalCalendarScreen> createState() =>
|
|
_ProfessionalCalendarScreenState();
|
|
}
|
|
|
|
class _ProfessionalCalendarScreenState
|
|
extends State<ProfessionalCalendarScreen> {
|
|
final serviceRepository = Injector.appInstance.get<ApiServiceRepository>();
|
|
|
|
DateTime today = DateTime.now();
|
|
late int numDay;
|
|
List<ServiceEntity>? _services;
|
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
|
bool isLoading = false;
|
|
bool _loadFailed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
today = DateTime.utc(today.year, today.month, today.day);
|
|
numDay = today.weekday;
|
|
_loadServices();
|
|
}
|
|
|
|
void _loadServices() {
|
|
setState(() {
|
|
_loadFailed = false;
|
|
_services = null;
|
|
});
|
|
serviceRepository
|
|
.getServicesForProfessionalforCalendar(widget.userProfessional.id)
|
|
.then((services) {
|
|
if (!mounted) return;
|
|
setState(() => _services = services);
|
|
}).catchError((e) {
|
|
if (!mounted) return;
|
|
// Never fall back to an empty list: booked slots would render as free.
|
|
setState(() => _loadFailed = true);
|
|
});
|
|
}
|
|
|
|
void _onDaySelected(DateTime day, DateTime focusedDay) {
|
|
setState(() {
|
|
today = day;
|
|
numDay = today.weekday;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final schedule = _scheduleFromDay(numDay);
|
|
final slots = _buildSlots(schedule);
|
|
final occupied =
|
|
slots.where((t) => _isOccupied(t, _services, today)).length;
|
|
final blocked = slots.where((t) => _isBlocked(t, _services, today)).length;
|
|
// "Libres" means bookable by a patient, not merely empty: a free 15:00
|
|
// at 14:00 is inside the booking lead time and nobody can take it.
|
|
final available = slots
|
|
.where((t) =>
|
|
!_isOccupied(t, _services, today) &&
|
|
!_isBlocked(t, _services, today) &&
|
|
_isBookable(t))
|
|
.length;
|
|
|
|
return Scaffold(
|
|
backgroundColor: context.bg,
|
|
appBar: AppBar(
|
|
title: const Text('Calendario'),
|
|
backgroundColor: _kPrimary,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
),
|
|
body: BlocProvider<ServiceBloc>(
|
|
create: (context) => Injector.appInstance.get<ServiceBloc>(),
|
|
child: BlocConsumer<ServiceBloc, ServiceState>(
|
|
listener: (context, state) {
|
|
if (state is CreateServiceLoading) isLoading = true;
|
|
if (state is CreateServiceFailure) {
|
|
isLoading = false;
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('No se pudo completar la acción')),
|
|
);
|
|
}
|
|
if (state is CreateServiceSuccess || state is ServiceStatusUpdated) {
|
|
isLoading = false;
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(state is CreateServiceSuccess
|
|
? 'Horario bloqueado'
|
|
: 'Horario liberado'),
|
|
));
|
|
_loadServices();
|
|
}
|
|
},
|
|
builder: (context, state) {
|
|
return ListView(
|
|
padding: const EdgeInsets.only(bottom: 32),
|
|
children: [
|
|
_calendarCard(context),
|
|
if (_loadFailed)
|
|
_loadErrorState(context)
|
|
else if (_services == null)
|
|
// Until the agenda arrives every slot would read "Disponible"
|
|
// and could be blocked on top of a real appointment.
|
|
_loadingState(context)
|
|
else ...[
|
|
_dayHeader(context, schedule, slots.length, occupied,
|
|
blocked, available),
|
|
if (slots.isEmpty)
|
|
_emptyState(context)
|
|
else
|
|
..._slotCards(context, slots, state),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _calendarCard(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadow,
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4))
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: TableCalendar(
|
|
locale: 'es_MX',
|
|
firstDay: DateTime.now(),
|
|
lastDay: today.add(const Duration(days: 365)),
|
|
focusedDay: today,
|
|
availableGestures: AvailableGestures.all,
|
|
onDaySelected: _onDaySelected,
|
|
selectedDayPredicate: (day) => isSameDay(day, today),
|
|
calendarFormat: _calendarFormat,
|
|
onFormatChanged: (f) => setState(() => _calendarFormat = f),
|
|
availableCalendarFormats: const {
|
|
CalendarFormat.month: 'Mes',
|
|
CalendarFormat.week: 'Semana',
|
|
CalendarFormat.twoWeeks: '2 Semanas',
|
|
},
|
|
calendarStyle: CalendarStyle(
|
|
todayDecoration: BoxDecoration(
|
|
border: Border.all(color: _kPrimary, width: 2),
|
|
shape: BoxShape.circle),
|
|
todayTextStyle: const TextStyle(
|
|
color: _kPrimary, fontWeight: FontWeight.w700),
|
|
selectedDecoration: const BoxDecoration(
|
|
color: _kPrimary, shape: BoxShape.circle),
|
|
selectedTextStyle: const TextStyle(
|
|
color: Colors.white, fontWeight: FontWeight.w700),
|
|
weekendTextStyle: TextStyle(color: Colors.red.shade400),
|
|
defaultTextStyle: TextStyle(color: context.onSurface),
|
|
outsideDaysVisible: false,
|
|
),
|
|
headerStyle: HeaderStyle(
|
|
formatButtonDecoration: BoxDecoration(
|
|
border: Border.all(color: _kPrimary.withOpacity(0.4)),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
formatButtonTextStyle:
|
|
const TextStyle(color: _kPrimary, fontSize: 12),
|
|
titleCentered: true,
|
|
titleTextStyle: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.onSurface),
|
|
leftChevronIcon:
|
|
const Icon(Icons.chevron_left, color: _kPrimary),
|
|
rightChevronIcon:
|
|
const Icon(Icons.chevron_right, color: _kPrimary),
|
|
),
|
|
daysOfWeekStyle: DaysOfWeekStyle(
|
|
weekdayStyle: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: context.muted),
|
|
weekendStyle: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFFEF4444)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total,
|
|
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;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: Row(children: [
|
|
Container(
|
|
width: 48,
|
|
height: 52,
|
|
decoration:
|
|
BoxDecoration(color: _kPrimary, borderRadius: BorderRadius.circular(12)),
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Text(DateFormat('d').format(today),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1)),
|
|
Text(DateFormat('MMM', 'es').format(today).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600)),
|
|
]),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(_capitalize(dayName),
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.onSurface)),
|
|
Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)),
|
|
]),
|
|
),
|
|
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),
|
|
],
|
|
]),
|
|
);
|
|
}
|
|
|
|
List<Widget> _slotCards(
|
|
BuildContext context, List<TimeOfDay> slots, ServiceState state) {
|
|
return slots.map((time) {
|
|
final occ = _isOccupied(time, _services, today);
|
|
final blocked = _isBlocked(time, _services, today);
|
|
final taken = occ || blocked;
|
|
// Free but too close to reserve: still blockable by the professional,
|
|
// just not offered to patients. Shown greyed so the agenda matches
|
|
// what the patient sees instead of promising a slot nobody can take.
|
|
final soon = !taken && !_isBookable(time);
|
|
final color = occ
|
|
? _kOccupied
|
|
: blocked
|
|
? _kBlocked
|
|
: soon
|
|
? Colors.grey
|
|
: _kAvailable;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(14),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: InkWell(
|
|
onTap: () =>
|
|
taken ? _onOccupied(time) : _onAvailable(context, time, state),
|
|
child: IntrinsicHeight(
|
|
child: Row(children: [
|
|
Container(width: 4, color: color),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14, vertical: 12),
|
|
child: Row(children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8)),
|
|
child: Text(
|
|
ScheduleEntity.getFormatTime(time) ?? '',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w800,
|
|
color: color),
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
occ
|
|
? 'Ocupado'
|
|
: blocked
|
|
? 'Bloqueado'
|
|
: soon
|
|
? 'Sin reserva'
|
|
: 'Disponible',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: color)),
|
|
Text(
|
|
occ
|
|
? 'Toca para ver el servicio'
|
|
: blocked
|
|
? 'Toca para liberar el horario'
|
|
: soon
|
|
? 'Muy pronto para reservar · toca para bloquear'
|
|
: 'Horario libre · toca para bloquear',
|
|
style: TextStyle(
|
|
fontSize: 11, color: context.subtle),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
shape: BoxShape.circle),
|
|
child: Icon(
|
|
occ
|
|
? Icons.event_busy_outlined
|
|
: blocked
|
|
? Icons.lock_outline
|
|
: soon
|
|
? Icons.more_time
|
|
: Icons.event_available_outlined,
|
|
size: 17,
|
|
color: color),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
void _onOccupied(TimeOfDay time) {
|
|
final event = _serviceAt(time, _services, today);
|
|
if (event?.id == null) {
|
|
// Tapping a busy slot used to do nothing at all when the row came back
|
|
// without an id, which reads as a frozen screen.
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content: Text('No pudimos abrir esta cita. Desliza para recargar.'),
|
|
));
|
|
return;
|
|
}
|
|
|
|
if (event!.status == ServiceStatus.selfBooked) {
|
|
_confirmUnblock(event.id!, time);
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(
|
|
builder: (_) => ProfessionalServiceScreen(serviceId: event.id!)),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _confirmUnblock(String serviceId, TimeOfDay time) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Desbloquear horario'),
|
|
content: Text(
|
|
'¿Quieres liberar el horario de las '
|
|
'${ScheduleEntity.getFormatTime(time)} '
|
|
'del ${DateFormat('dd-MM-yyyy').format(today)}?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
context
|
|
.read<ServiceBloc>()
|
|
.add(UpdateServiceStatus(serviceId, ServiceStatus.cancelled));
|
|
},
|
|
child: const Text('Desbloquear'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Blocking is the only action on a free slot.
|
|
///
|
|
/// There used to be a second "Reservar" button that created a service with
|
|
/// the professional as their own patient. It also popped the calendar, and
|
|
/// the selfBooked status never reached the backend, so the slot came back as
|
|
/// a pending request the professional had to approve to themselves.
|
|
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Bloquear horario'),
|
|
content: Text(
|
|
'¿Bloquear las ${ScheduleEntity.getFormatTime(time)} '
|
|
'del ${DateFormat('dd-MM-yyyy').format(today)}? '
|
|
'Nadie podrá agendar en esa hora hasta que la liberes.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
context.read<ServiceBloc>().add(
|
|
BlockSlot(day: _dayParam(today), hour1: time),
|
|
);
|
|
},
|
|
child: const Text('Bloquear'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// yyyy-MM-dd, the canonical form the web sends. DateTime.toString() produces
|
|
/// "2026-08-25 00:00:00.000Z", which happens to pass validation but differs
|
|
/// between the two clients writing to the same table.
|
|
static String _dayParam(DateTime d) =>
|
|
'${d.year.toString().padLeft(4, '0')}-'
|
|
'${d.month.toString().padLeft(2, '0')}-'
|
|
'${d.day.toString().padLeft(2, '0')}';
|
|
|
|
Widget _loadingState(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(children: [
|
|
const CircularProgressIndicator(color: _kPrimary),
|
|
const SizedBox(height: 14),
|
|
Text('Cargando tu agenda…',
|
|
style: TextStyle(fontSize: 13, color: context.muted)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _loadErrorState(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: Column(children: [
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: _kOccupied.withOpacity(0.1), shape: BoxShape.circle),
|
|
child: const Icon(Icons.wifi_off_outlined,
|
|
size: 32, color: _kOccupied),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('No se pudo cargar tu agenda',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: context.muted)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'No mostramos horarios para evitar que reserves\nsobre una cita ya agendada.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
|
|
const SizedBox(height: 16),
|
|
OutlinedButton(
|
|
onPressed: _loadServices, child: const Text('Reintentar')),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _emptyState(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
color: context.card,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: context.shadowSm,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 2))
|
|
],
|
|
),
|
|
child: Column(children: [
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
|
|
child: Icon(Icons.event_busy_outlined, size: 32, color: context.subtle),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('Sin horario este día',
|
|
style: TextStyle(
|
|
fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'No tienes horario de atención configurado\npara este día de la semana.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
ScheduleEntity? _scheduleFromDay(int day) {
|
|
switch (day) {
|
|
case 1: return widget.userProfessional.schedules.monday;
|
|
case 2: return widget.userProfessional.schedules.tuesday;
|
|
case 3: return widget.userProfessional.schedules.wednesday;
|
|
case 4: return widget.userProfessional.schedules.thursday;
|
|
case 5: return widget.userProfessional.schedules.friday;
|
|
case 6: return widget.userProfessional.schedules.saturday;
|
|
default: return widget.userProfessional.schedules.sunday;
|
|
}
|
|
}
|
|
|
|
/// Whether a patient could still reserve [time] today.
|
|
bool _isBookable(TimeOfDay time) {
|
|
final at = DateTime(today.year, today.month, today.day, time.hour, time.minute);
|
|
return !at.isBefore(DateTime.now().add(kBookingLeadTime));
|
|
}
|
|
|
|
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
|
|
// Same generator the patient uses, so "3 libres" here always means three
|
|
// slots a patient can actually take. The professional keeps a shorter
|
|
// horizon than the patient's lead time: blocking the next hour is a
|
|
// legitimate thing to do, booking it is not.
|
|
return SlotGenerator.forDay(
|
|
schedule: s,
|
|
day: today,
|
|
slotDurationMinutes: widget.userProfessional.slotDurationMinutes,
|
|
notBefore: DateTime.now(),
|
|
);
|
|
}
|
|
|
|
/// 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 null;
|
|
for (final s in services) {
|
|
if (!isSameServiceDay(day, 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);
|
|
}
|
|
|
|
class _StatPill extends StatelessWidget {
|
|
final String label;
|
|
final String sublabel;
|
|
final Color color;
|
|
const _StatPill(
|
|
{required this.label, required this.sublabel, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: color.withOpacity(0.25)),
|
|
),
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w800,
|
|
color: color,
|
|
height: 1)),
|
|
Text(sublabel,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: color.withOpacity(0.8),
|
|
fontWeight: FontWeight.w600)),
|
|
]),
|
|
);
|
|
}
|
|
}
|