- 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>
305 lines
11 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|