import 'package:flutter/material.dart'; 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/models/service_status.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/providers/calendar_services_provider.dart'; import 'package:prosapp_web_app/ui/cards/white_card.dart'; import 'package:prosapp_web_app/utils/time_of_day_extension.dart'; import 'package:prosapp_web_app/utils/time_of_day_utils.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; class CalendarView extends StatefulWidget { final String professionalId; const CalendarView({super.key, required this.professionalId}); @override State createState() => _CalendarViewState(); } class _CalendarViewState extends State { // Use local time throughout — UTC dates can shift the calendar date near midnight DateTime _today = DateTime.now(); Profesional? _profesional; List _services = []; bool _loadingPro = true; bool _loadingServices = false; @override void initState() { super.initState(); _normalizeDay(); _fetchProfessional(); } void _normalizeDay() { // Strip time component; keep local date only _today = DateTime(_today.year, _today.month, _today.day); } // Loads professional data once — does NOT re-run on day change Future _fetchProfessional() async { setState(() => _loadingPro = true); try { final proProvider = Provider.of(context, listen: false); final fp = Provider.of(context, listen: false); final pro = await proProvider.getProfessionalById(widget.professionalId); fp.setProfesional(pro); if (mounted) setState(() { _profesional = pro; _loadingPro = false; }); await _fetchServices(); } catch (_) { if (mounted) setState(() => _loadingPro = false); } } // Fetches only services — called on day change and after initial load Future _fetchServices() async { if (_profesional == null) return; setState(() => _loadingServices = true); try { final sp = Provider.of(context, listen: false); final list = await sp.getPublicServicesForProfessional(_profesional!.id); if (mounted) setState(() { _services = list; _loadingServices = false; }); } catch (_) { if (mounted) setState(() => _loadingServices = false); } } void _onDaySelected(DateTime day, DateTime _) { setState(() { _today = DateTime(day.year, day.month, day.day); }); // Professional data is cached — only re-fetch services _fetchServices(); } @override Widget build(BuildContext context) { if (_loadingPro || _profesional == null) { return const Center(child: CircularProgressIndicator()); } return ListView( physics: const ClampingScrollPhysics(), children: [ Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900), child: WhiteCard( title: 'Calendario', child: Column( children: [ TableCalendar( locale: 'es_CO', firstDay: DateTime.now(), lastDay: DateTime.now().add(const Duration(days: 180)), focusedDay: _today, availableGestures: AvailableGestures.all, onDaySelected: _onDaySelected, selectedDayPredicate: (day) => isSameDay(day, _today), ), const Divider(height: 0), SizedBox( width: double.infinity, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), child: Text( DateFormat('dd MMMM yyyy', 'es').format(_today), style: const TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), const Divider(height: 0), if (_loadingServices) const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: CircularProgressIndicator(), ) else Column( children: [ ..._rangesItems( _getScheduleFromNumDay(_today.weekday, _profesional!), context, stepMinutes: _profesional!.slotDurationMinutes, ), const SizedBox(height: 8), ], ), ], ), ), ), ), ], ); } ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional pro) { switch (numDay) { case 1: return pro.schedules.monday; case 2: return pro.schedules.tuesday; case 3: return pro.schedules.wednesday; case 4: return pro.schedules.thursday; case 5: return pro.schedules.friday; case 6: return pro.schedules.saturday; case 7: return pro.schedules.sunday; default: return null; } } List _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) { if (schedule == null || !schedule.enabled) { return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))]; } final List ranges; if (schedule.continuousDay) { if (schedule.range1Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))]; } ranges = TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes); } else { if (schedule.range1Hour1 == null || schedule.range1Hour2 == null || schedule.range2Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range1Hour2!) >= 0 || schedule.range2Hour1!.compareTo(schedule.range2Hour2!) >= 0) { return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))]; } ranges = [ ...TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range1Hour2!, stepMinutes: stepMinutes), ...TimeOfDayUtils.genRanges(schedule.range2Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes), ]; } return _rangesItemList(ranges, _services, _today, context); } // A slot is blocked (self-booked by the professional) bool _isBlocked(TimeOfDay hora1, List services, DateTime selectedDay) { final dayStr = _dayStr(selectedDay); return services.any((e) => e.day == dayStr && e.range1Hour1 == hora1 && e.status == ServiceStatus.selfBooked); } // A slot is occupied by a real patient booking // Excludes: selfBooked (professional blocks), cancelled, denied bool _isOccupied(TimeOfDay hora1, List services, DateTime selectedDay) { final dayStr = _dayStr(selectedDay); return services.any((e) => e.day == dayStr && e.range1Hour1 == hora1 && e.status != ServiceStatus.selfBooked && e.status != ServiceStatus.cancelled && e.status != ServiceStatus.denied); } String _dayStr(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; List _rangesItemList( List ranges, List services, DateTime selectedDay, BuildContext context) { final now = DateTime.now(); return ranges.map((time) { // Compare using local time to avoid timezone edge cases final slotDt = DateTime(selectedDay.year, selectedDay.month, selectedDay.day, time.hour, time.minute); final isPast = slotDt.isBefore(now.add(const Duration(hours: 3))); final isBlocked = _isBlocked(time, services, selectedDay); final isOccupied = !isBlocked && _isOccupied(time, services, selectedDay); if (isPast || isBlocked) { return _slotCard( time: time, circleColor: Colors.grey, icon: isBlocked ? Icons.block : Icons.access_time, label: 'No disponible', labelColor: Colors.red, onTap: null, ); } if (isOccupied) { return _slotCard( time: time, circleGradient: const LinearGradient( colors: [Colors.yellow, Colors.red, Colors.red], begin: Alignment.topLeft, end: Alignment.bottomRight, ), icon: Icons.access_time, label: 'Ocupado', labelColor: Colors.red, onTap: null, ); } return _slotCard( time: time, circleGradient: const LinearGradient( colors: [Colors.blue, Colors.green], begin: Alignment.topLeft, end: Alignment.bottomRight, ), icon: Icons.access_time, label: 'Disponible', labelColor: Colors.green, onTap: () => Navigator.pop(context, [selectedDay, time]), ); }).toList(); } Widget _slotCard({ required TimeOfDay time, Color? circleColor, Gradient? circleGradient, required IconData icon, required String label, required Color labelColor, VoidCallback? onTap, }) { return Card( elevation: 4, margin: const EdgeInsets.only(top: 15, left: 10, right: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), child: ListTile( onTap: onTap, contentPadding: const EdgeInsets.all(16), leading: Container( width: 40, height: 40, decoration: BoxDecoration( color: circleGradient == null ? circleColor : null, gradient: circleGradient, shape: BoxShape.circle, ), child: Center(child: Icon(icon, color: Colors.white)), ), title: Text( ScheduleEntity.getFormatTime(time) ?? '', style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: onTap == null ? Colors.grey : Colors.black, ), ), subtitle: Text( label, style: TextStyle(color: labelColor, fontSize: 13, fontWeight: FontWeight.bold), ), ), ); } }