diff --git a/lib/ui/views/calendar_view.dart b/lib/ui/views/calendar_view.dart index 391f827..7e396bc 100644 --- a/lib/ui/views/calendar_view.dart +++ b/lib/ui/views/calendar_view.dart @@ -7,7 +7,6 @@ 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/services/notifications_service.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'; @@ -24,417 +23,282 @@ class CalendarView extends StatefulWidget { } class _CalendarViewState extends State { - List? _services; - DateTime today = DateTime.now(); - late int numDay; + // 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(); - - today = DateTime.utc(today.year, today.month, today.day); - numDay = today.weekday; - - _fetchProfessionalAndServices(); + _normalizeDay(); + _fetchProfessional(); } - void _fetchProfessionalAndServices() async { - final professionalFormProvider = - Provider.of(context, listen: false); - final servicesProvider = - Provider.of(context, listen: false); - final proProvider = - Provider.of(context, listen: false); - - // Use public endpoint so we get THIS professional's schedule, not the viewer's own - final professional = - await proProvider.getProfessionalById(widget.professionalId); - professionalFormProvider.setProfesional(professional); - - // Use public calendar endpoint to get services for this specific professional - final services = - await servicesProvider.getPublicServicesForProfessional(professional.id); - setState(() { - _services = services; - }); + void _normalizeDay() { + // Strip time component; keep local date only + _today = DateTime(_today.year, _today.month, _today.day); } - void _onDaySelected(DateTime day, DateTime focusedDay) { + // 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 = day; - numDay = today.weekday; + _today = DateTime(day.year, day.month, day.day); }); - _fetchProfessionalAndServices(); + // Professional data is cached — only re-fetch services + _fetchServices(); } @override Widget build(BuildContext context) { - return Consumer( - builder: (context, professionalFormProvider, child) { - if (professionalFormProvider.profesional == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } + if (_loadingPro || _profesional == null) { + return const Center(child: CircularProgressIndicator()); + } - final profesional = professionalFormProvider.profesional!; - - 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, - ), + 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), + ), + const Divider(height: 0), + if (_loadingServices) + const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: CircularProgressIndicator(), + ) + else Column( children: [ ..._rangesItems( - _getScheduleFromNumDay(numDay, profesional), - context, - stepMinutes: profesional.slotDurationMinutes), + _getScheduleFromNumDay(_today.weekday, _profesional!), + context, + stepMinutes: _profesional!.slotDurationMinutes, + ), const SizedBox(height: 8), ], ), - ], - ), + ], ), ), ), - ], - ); - }); + ), + ], + ); } - ScheduleEntity? _getScheduleFromNumDay( - int numDay, Profesional userProfessional) { + ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional pro) { switch (numDay) { - case 1: - return userProfessional.schedules.monday; - case 2: - return userProfessional.schedules.tuesday; - case 3: - return userProfessional.schedules.wednesday; - case 4: - return userProfessional.schedules.thursday; - case 5: - return userProfessional.schedules.friday; - case 6: - return userProfessional.schedules.saturday; - case 7: - return userProfessional.schedules.sunday; - default: - return null; + 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) { - return [ - const Padding( - padding: EdgeInsets.only(top: 20), - child: Text("No hay horarios disponibles"), - ) - ]; - } - if (!schedule.enabled) { - return [ - const Padding( - padding: EdgeInsets.only(top: 20), - child: Text("No hay horarios disponibles"), - ) - ]; + 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"), - ) - ]; + 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'))]; } - - List ranges = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range2Hour2!, - stepMinutes: stepMinutes, - ); - - return rangesItemList(ranges, _services, today, context); + 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"), - ) - ]; + 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'))]; } - - List ranges1 = TimeOfDayUtils.genRanges( - schedule.range1Hour1!, - schedule.range1Hour2!, - stepMinutes: stepMinutes, - ); - List ranges2 = TimeOfDayUtils.genRanges( - schedule.range2Hour1!, - schedule.range2Hour2!, - stepMinutes: stepMinutes, - ); - - return [ - ...rangesItemList(ranges1, _services, today, context), - ...rangesItemList(ranges2, _services, today, context), + ranges = [ + ...TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range1Hour2!, stepMinutes: stepMinutes), + ...TimeOfDayUtils.genRanges(schedule.range2Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes), ]; } + + return _rangesItemList(ranges, _services, _today, context); } - bool _isBlocked(TimeOfDay hora1, List? events, DateTime selectedDay) { - if (events == null) return false; - final dayStr = selectedDay.toIso8601String().split('T').first; - return events.any((e) => + // 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.selfBooked && + e.status != ServiceStatus.cancelled && + e.status != ServiceStatus.denied); } - bool _isHora1Ocupada( - TimeOfDay hora1, List? events, DateTime selectedDay) { - if (events != null) { - for (Service event in events) { - if (selectedDay.toIso8601String().split('T').first == event.day) { - if (hora1 == event.range1Hour1 && - event.status != ServiceStatus.selfBooked) { - return true; - } - } - } - } - return false; - } + 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? events,DateTime selectedDay, BuildContext context) { - final currentDateTime = DateTime.now(); + List _rangesItemList( + List ranges, List services, DateTime selectedDay, BuildContext context) { + final now = DateTime.now(); return ranges.map((time) { - final selectedDateTime = DateTime( - selectedDay.year, - selectedDay.month, - selectedDay.day, - time.hour, - time.minute, + // 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]), ); - - if (selectedDateTime - .isBefore(currentDateTime.add(const Duration(hours: 3)))) { - return Card( - elevation: 4, - margin: const EdgeInsets.only(top: 15, left: 10, right: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Colors.grey, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.access_time, - color: Colors.white, - ), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - subtitle: const Text( - 'No disponible', - style: TextStyle( - color: Colors.red, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - - if (_isBlocked(time, events, selectedDay)) { - return Card( - elevation: 4, - margin: const EdgeInsets.only(top: 15, left: 10, right: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: Colors.grey, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon(Icons.block, color: Colors.white), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - subtitle: const Text( - 'No disponible', - style: TextStyle( - color: Colors.red, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - - if (_isHora1Ocupada(time, events, selectedDay)) { - return Card( - elevation: 4, - margin: const EdgeInsets.only(top: 15, left: 10, right: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - onTap: () { - if (events != null) { - for (Service event in events) { - if (selectedDay.toIso8601String().split('T').first == - event.day) { - if (time == event.range1Hour1) { - NotificationsService.showSnackbar('Ocupado'); - } - } - } - } - }, - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.yellow, Colors.red, Colors.red], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.access_time, - color: Colors.white, - ), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), - ), - subtitle: const Text( - 'Ocupado', - style: TextStyle( - color: Colors.red, fontSize: 13, fontWeight: FontWeight.bold), - ), - ), - ); - } else { - return Card( - elevation: 4, - margin: const EdgeInsets.only(top: 15, left: 10, right: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: ListTile( - onTap: () { - Navigator.pop(context, [selectedDay, time]); - }, - contentPadding: const EdgeInsets.all(16), - leading: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue, Colors.green], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.access_time, - color: Colors.white, - ), - ), - ), - title: Text( - ScheduleEntity.getFormatTime(time) ?? '', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), - ), - subtitle: const Text( - 'Disponible', - style: TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } }).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), + ), + ), + ); + } } diff --git a/lib/ui/views/dashboard_view.dart b/lib/ui/views/dashboard_view.dart index b0deec6..cc2f338 100644 --- a/lib/ui/views/dashboard_view.dart +++ b/lib/ui/views/dashboard_view.dart @@ -384,7 +384,7 @@ class _DashboardViewState extends State { createdAt: DateTime.now().toIso8601String(), description: '', range1Hour1: _selectedHour!, - range1Hour2: _selectedHour!.add(hour: 2), + range1Hour2: _selectedHour!.add(minute: _professional!.professionalInfo.slotDurationMinutes), rate: '', status: ServiceStatus.pending, location: serviceLocation,