Files
prosappweb/lib/ui/views/calendar_view.dart
T
Lizandro GuarnizoandClaude Sonnet 4.6 6c248e1f98 fix: corregir flujo de agendamiento del paciente
- calendar_view: separar carga del profesional (1 vez) de la carga de
  servicios (por cada cambio de día), eliminando re-fetches innecesarios
- calendar_view: usar DateTime local en vez de UTC para evitar desfase
  de fecha en zonas horarias (Colombia UTC-5)
- calendar_view: excluir explícitamente cancelled/denied en _isOccupied
  y refactorizar los 4 tipos de card en un único método _slotCard
- dashboard_view: usar slotDurationMinutes del profesional en range1Hour2
  en vez de 2 horas hardcodeadas, respetando la duración real del slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 08:38:32 -05:00

305 lines
11 KiB
Dart

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<CalendarView> createState() => _CalendarViewState();
}
class _CalendarViewState extends State<CalendarView> {
// Use local time throughout — UTC dates can shift the calendar date near midnight
DateTime _today = DateTime.now();
Profesional? _profesional;
List<Service> _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<void> _fetchProfessional() async {
setState(() => _loadingPro = true);
try {
final proProvider = Provider.of<ProfessionalProvider>(context, listen: false);
final fp = Provider.of<ProfessionalFormProvider>(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<void> _fetchServices() async {
if (_profesional == null) return;
setState(() => _loadingServices = true);
try {
final sp = Provider.of<CalendarServicesProvider>(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<Widget> _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<TimeOfDay> 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<Service> 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<Service> 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<Widget> _rangesItemList(
List<TimeOfDay> ranges, List<Service> 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),
),
),
);
}
}