DateTime.toString() renders "2026-07-13 00:00:00.000Z" while the backend sends "2026-07-13T00:00:00.000Z", so every day comparison was false and no booked slot was ever detected. Both calendars showed fully free days. The client calendar now also treats an unloaded agenda as busy instead of free: rendering a taken slot as available leads straight to a double booking. Note: the client still reads /services/professional/calendar, which returns the *caller's* calendar. There is no endpoint for another professional's agenda, so a client cannot yet see which of that professional's slots are taken. That needs a backend endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
657 lines
23 KiB
Dart
657 lines
23 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/time_of_day_extension.dart';
|
|
import 'package:prosappco/utils/time_of_day_utils.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);
|
|
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;
|
|
final available = slots.length - occupied - blocked;
|
|
|
|
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;
|
|
_loadServices();
|
|
}
|
|
},
|
|
builder: (context, state) {
|
|
return ListView(
|
|
padding: const EdgeInsets.only(bottom: 32),
|
|
children: [
|
|
_calendarCard(context),
|
|
if (_loadFailed)
|
|
_loadErrorState(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;
|
|
final color = occ
|
|
? _kOccupied
|
|
: blocked
|
|
? _kBlocked
|
|
: _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'
|
|
: 'Disponible',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: color)),
|
|
Text(
|
|
occ
|
|
? 'Toca para ver el servicio'
|
|
: blocked
|
|
? 'Toca para liberar el horario'
|
|
: 'Horario libre · toca para reservar',
|
|
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
|
|
: Icons.event_available_outlined,
|
|
size: 17,
|
|
color: color),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
void _onOccupied(TimeOfDay time) {
|
|
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!)),
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onAvailable(BuildContext context, TimeOfDay time, ServiceState state) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Reservar hora'),
|
|
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Text(
|
|
'¿Reservar a las ${ScheduleEntity.getFormatTime(time)} '
|
|
'del ${DateFormat('dd-MM-yyyy').format(today)}?',
|
|
),
|
|
const SizedBox(height: 10),
|
|
const Text('⚠️ Esta acción no se puede deshacer',
|
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
]),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancelar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
Navigator.pop(context);
|
|
context.read<ServiceBloc>().add(CreateService(
|
|
professionalId: widget.userProfessional.id,
|
|
userId: widget.userProfessional.id,
|
|
address: widget.userProfessional.address,
|
|
aditionalAddress: '',
|
|
latitude: 0,
|
|
longitude: 0,
|
|
day: today.toString(),
|
|
createdAt: DateTime.now().toIso8601String(),
|
|
description: '',
|
|
range1Hour1: time,
|
|
range1Hour2: time.add(
|
|
minute: widget.userProfessional.slotDurationMinutes),
|
|
rate: '0',
|
|
location: ServiceLocationPreferences.office,
|
|
status: ServiceStatus.selfBooked,
|
|
));
|
|
},
|
|
child: const Text('Reservar'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
context.read<ServiceBloc>().add(
|
|
BlockSlot(day: today.toString(), hour1: time),
|
|
);
|
|
},
|
|
child: const Text('Bloquear horario'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
|
|
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) {
|
|
return [];
|
|
}
|
|
final stepMinutes = widget.userProfessional.slotDurationMinutes;
|
|
if (s.continuousDay) {
|
|
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!,
|
|
stepMinutes: stepMinutes);
|
|
}
|
|
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
|
return [
|
|
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!,
|
|
stepMinutes: stepMinutes),
|
|
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!,
|
|
stepMinutes: stepMinutes),
|
|
];
|
|
}
|
|
|
|
/// 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)),
|
|
]),
|
|
);
|
|
}
|
|
}
|