From 52845c59fdb94fb977f0eec141affb8a9e52cf69 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:26:45 -0500 Subject: [PATCH] feat(calendar): add block/unblock slots for professionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServicesProvider: add blockSlot() calling POST /services/block and unblockSlot() calling PATCH /services/:id/status with cancelled - ProfessionalCalendarView: - Fix _services always-null bug: getServicesForProfessional() returns void; now reads from sp.services after it resolves - Differentiate self_booked (blocked) vs regular occupied slots - Show amber 'Bloqueado' state with lock icon for self_booked services - Show lock button on available slots → confirm dialog → blockSlot() - Show unlock button on blocked slots → confirm dialog → unblockSlot() - Reload calendar after block/unblock so UI reflects new state - Header stats now show separate counts: ocupadas / bloqueadas / libres - _ActionButton widget for reusable tap-target icon buttons Co-Authored-By: Claude Sonnet 4.6 --- lib/providers/services_provider.dart | 12 ++ lib/ui/views/professional_calendar_view.dart | 175 ++++++++++++++++--- 2 files changed, 163 insertions(+), 24 deletions(-) diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart index fee7f5b..e472221 100644 --- a/lib/providers/services_provider.dart +++ b/lib/providers/services_provider.dart @@ -51,6 +51,18 @@ class ServicesProvider extends ChangeNotifier { notifyListeners(); } + // Blocks a slot on the professional's calendar by creating a self_booked service. + Future blockSlot(String day, TimeOfDay time) async { + final h = time.hour.toString().padLeft(2, '0'); + final m = time.minute.toString().padLeft(2, '0'); + await _api.post('/services/block', {'day': day, 'range1_hour1': '$h:$m'}); + } + + // Unblocks a previously blocked slot by cancelling the self_booked service. + Future unblockSlot(String serviceId) async { + await _api.patch('/services/$serviceId/status', {'status': 'cancelled'}); + } + getServiceForUser(String serviceId) async { try { isLoading = true; diff --git a/lib/ui/views/professional_calendar_view.dart b/lib/ui/views/professional_calendar_view.dart index c71744d..19f0976 100644 --- a/lib/ui/views/professional_calendar_view.dart +++ b/lib/ui/views/professional_calendar_view.dart @@ -5,15 +5,17 @@ import 'package:prosapp_web_app/models/service.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/professional_form_provider.dart'; import 'package:prosapp_web_app/providers/professional_provider.dart'; +import 'package:prosapp_web_app/models/service_status.dart'; import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/utils/time_of_day_utils.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:flutter/material.dart'; -const _kPrimary = Color(0xFF1565C0); +const _kPrimary = Color(0xFF1565C0); const _kAvailable = Color(0xFF16A34A); -const _kOccupied = Color(0xFFDC2626); +const _kOccupied = Color(0xFFDC2626); +const _kBlocked = Color(0xFFF59E0B); extension _Th on BuildContext { ThemeData get _t => Theme.of(this); @@ -54,8 +56,12 @@ class _ProfessionalCalendarViewState extends State { final pro = await Provider.of(context, listen: false) .getProfessional(auth.user!.id); fp.setProfesional(pro); - final services = await sp.getServicesForProfessional(pro.id); - if (mounted) setState(() { _services = services; _loading = false; }); + // getServicesForProfessional returns void; read from sp.services after it resolves + await sp.getServicesForProfessional(pro.id); + if (mounted) setState(() { + _services = sp.services.map((s) => s.service).toList(); + _loading = false; + }); } void _onDaySelected(DateTime day, DateTime _) => @@ -71,20 +77,22 @@ class _ProfessionalCalendarViewState extends State { return const Center(child: CircularProgressIndicator(color: _kPrimary)); } final pro = fp.profesional!; + final sp = Provider.of(context, listen: false); final schedule = _scheduleFor(_selected.weekday, pro); final slots = _buildSlots(schedule); final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length; + final blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length; return ListView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.only(bottom: 32), children: [ _calendarCard(context), - _dayHeader(context, schedule, slots.length, occupied), + _dayHeader(context, schedule, slots.length, occupied, blocked), if (slots.isEmpty) _emptyState(context) else - ..._slotCards(context, slots), + ..._slotCards(context, slots, sp, pro.id), ], ); }, @@ -142,10 +150,10 @@ class _ProfessionalCalendarViewState extends State { ); } - Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied) { + Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int blocked) { final dayName = DateFormat('EEEE', 'es').format(_selected); final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected); - final available = total - occupied; + final available = total - occupied - blocked; final hasSchedule = schedule != null && schedule.enabled; return Center( @@ -179,6 +187,8 @@ class _ProfessionalCalendarViewState extends State { if (hasSchedule && total > 0) ...[ _StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied), const SizedBox(width: 8), + _StatPill(label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked), + const SizedBox(width: 8), _StatPill(label: '$available', sublabel: 'libres', color: _kAvailable), ], ]), @@ -187,11 +197,28 @@ class _ProfessionalCalendarViewState extends State { ); } - List _slotCards(BuildContext context, List slots) { + List _slotCards(BuildContext context, List slots, ServicesProvider sp, String professionalId) { return slots.map((time) { - final occ = _isOccupied(time, _services, _selected); - final matchService = occ ? _serviceFor(time, _services, _selected) : null; - final color = occ ? _kOccupied : _kAvailable; + final selfBooked = _isSelfBooked(time, _services, _selected); + final occ = !selfBooked && _isOccupied(time, _services, _selected); + final matchService = (occ || selfBooked) ? _serviceFor(time, _services, _selected) : null; + + final Color color; + final String label; + final IconData icon; + if (selfBooked) { + color = _kBlocked; + label = 'Bloqueado'; + icon = Icons.lock_outline; + } else if (occ) { + color = _kOccupied; + label = 'Ocupado'; + icon = Icons.event_busy_outlined; + } else { + color = _kAvailable; + label = 'Disponible'; + icon = Icons.event_available_outlined; + } return Center( child: ConstrainedBox( @@ -224,25 +251,37 @@ class _ProfessionalCalendarViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(occ ? 'Ocupado' : 'Disponible', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)), + Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)), if (matchService != null && matchService.description.isNotEmpty) Text(matchService.description, style: TextStyle(fontSize: 12, color: context.muted), maxLines: 1, overflow: TextOverflow.ellipsis) - else if (!occ) + else if (!occ && !selfBooked) Text('Horario libre para nuevas citas', 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 : Icons.event_available_outlined, - size: 17, color: color), - ), + // Action button + if (selfBooked) + _ActionButton( + icon: Icons.lock_open_outlined, + color: _kBlocked, + tooltip: 'Desbloquear', + onTap: () => _confirmUnblock(context, sp, matchService!.id!, time), + ) + else if (!occ) + _ActionButton( + icon: Icons.lock_outline, + color: context.subtle, + tooltip: 'Bloquear horario', + onTap: () => _confirmBlock(context, sp, time), + ) + else + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(icon, size: 17, color: color), + ), ]), ), ), @@ -255,6 +294,59 @@ class _ProfessionalCalendarViewState extends State { }).toList(); } + Future _confirmBlock(BuildContext context, ServicesProvider sp, TimeOfDay time) async { + final timeStr = ScheduleEntity.getFormatTime(time) ?? ''; + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Bloquear horario'), + content: Text('¿Bloquear el horario de $timeStr? Los usuarios no podrán agendarse en este slot.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: _kBlocked), + child: const Text('Bloquear', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + if (confirmed != true) return; + try { + final dayStr = _selected.toIso8601String().split('T').first; + await sp.blockSlot(dayStr, time); + await _load(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + + Future _confirmUnblock(BuildContext context, ServicesProvider sp, String serviceId, TimeOfDay time) async { + final timeStr = ScheduleEntity.getFormatTime(time) ?? ''; + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Desbloquear horario'), + content: Text('¿Desbloquear el horario de $timeStr? Estará disponible para nuevas citas.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: _kAvailable), + child: const Text('Desbloquear', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await sp.unblockSlot(serviceId); + await _load(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + Widget _emptyState(BuildContext context) { return Center( child: ConstrainedBox( @@ -307,7 +399,19 @@ class _ProfessionalCalendarViewState extends State { bool _isOccupied(TimeOfDay time, List? services, DateTime day) { if (services == null) return false; final dayStr = day.toIso8601String().split('T').first; - return services.any((s) => s.day == dayStr && s.range1Hour1 == time); + return services.any((s) => + s.day == dayStr && + s.range1Hour1 == time && + s.status != ServiceStatus.selfBooked); + } + + bool _isSelfBooked(TimeOfDay time, List? services, DateTime day) { + if (services == null) return false; + final dayStr = day.toIso8601String().split('T').first; + return services.any((s) => + s.day == dayStr && + s.range1Hour1 == time && + s.status == ServiceStatus.selfBooked); } Service? _serviceFor(TimeOfDay time, List? services, DateTime day) { @@ -321,6 +425,29 @@ class _ProfessionalCalendarViewState extends State { String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); } +class _ActionButton extends StatelessWidget { + final IconData icon; + final Color color; + final String tooltip; + final VoidCallback onTap; + const _ActionButton({required this.icon, required this.color, required this.tooltip, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 32, height: 32, + decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle), + child: Icon(icon, size: 17, color: color), + ), + ), + ); + } +} + class _StatPill extends StatelessWidget { final String label; final String sublabel;