import 'package:intl/intl.dart'; import 'package:prosapp_web_app/models/profesional.dart'; import 'package:prosapp_web_app/models/schedules_entity.dart'; 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 _kAvailable = Color(0xFF16A34A); const _kOccupied = Color(0xFFDC2626); const _kBlocked = Color(0xFFF59E0B); 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 ProfessionalCalendarView extends StatefulWidget { const ProfessionalCalendarView({super.key}); @override State createState() => _ProfessionalCalendarViewState(); } class _ProfessionalCalendarViewState extends State { DateTime _selected = DateTime.now(); List? _services; bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); final auth = Provider.of(context, listen: false); final fp = Provider.of(context, listen: false); final sp = Provider.of(context, listen: false); final pro = await Provider.of(context, listen: false) .getProfessional(auth.user!.id); fp.setProfesional(pro); // 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 _) => setState(() => _selected = day); @override Widget build(BuildContext context) { return Container( color: context.bg, child: Consumer( builder: (context, fp, _) { if (fp.profesional == null || _loading) { 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, pro.slotDurationMinutes); 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, blocked), if (slots.isEmpty) _emptyState(context) else ..._slotCards(context, slots, sp, pro.id), ], ); }, ), ); } Widget _calendarCard(BuildContext context) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Container( margin: const EdgeInsets.fromLTRB(16, 20, 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_CO', firstDay: DateTime.now().subtract(const Duration(days: 365)), lastDay: DateTime.utc(2030, 12, 31), focusedDay: _selected, availableGestures: AvailableGestures.all, onDaySelected: _onDaySelected, selectedDayPredicate: (d) => isSameDay(d, _selected), 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( formatButtonVisible: false, 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) { final dayName = DateFormat('EEEE', 'es').format(_selected); final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected); final available = total - occupied - blocked; final hasSchedule = schedule != null && schedule.enabled; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: 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(_selected), style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w800, height: 1)), Text(DateFormat('MMM', 'es').format(_selected).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 && 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), ], ]), ), ), ); } List _slotCards(BuildContext context, List slots, ServicesProvider sp, String professionalId) { return slots.map((time) { 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( constraints: const BoxConstraints(maxWidth: 720), child: 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: 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(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 && !selfBooked) Text('Horario libre para nuevas citas', style: TextStyle(fontSize: 11, color: context.subtle)), ], )), // 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), ), ]), ), ), ]), ), ), ), ), ); }).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( constraints: const BoxConstraints(maxWidth: 720), child: 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? _scheduleFor(int weekday, Profesional pro) => switch (weekday) { 1 => pro.schedules.monday, 2 => pro.schedules.tuesday, 3 => pro.schedules.wednesday, 4 => pro.schedules.thursday, 5 => pro.schedules.friday, 6 => pro.schedules.saturday, _ => pro.schedules.sunday, }; List _buildSlots(ScheduleEntity? s, int stepMinutes) { if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return []; 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), ]; } 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 && s.status != ServiceStatus.selfBooked && s.status != ServiceStatus.cancelled && s.status != ServiceStatus.denied); } 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) { if (services == null) return null; final dayStr = day.toIso8601String().split('T').first; try { return services.firstWhere((s) => s.day == dayStr && s.range1Hour1 == time); } catch (_) { return null; } } 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; 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)), ]), ); } }