- Fix TimeOfDayExtension.add() to handle minute overflow via total-minutes math - genRanges() now takes stepMinutes param (default 30, clamp 5-480) - Profesional model: slotDurationMinutes field (parsed from slot_duration_minutes) - ProfessionalFormProvider: saves slot duration alongside schedules on Guardar - schedule_view: _SlotDurationPicker chip selector (15/20/30/45/60/90/120 min) - ProfessionalCalendarView + CalendarView: pass slotDurationMinutes to genRanges Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
441 lines
14 KiB
Dart
441 lines
14 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/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';
|
|
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> {
|
|
List<Service>? _services;
|
|
DateTime today = DateTime.now();
|
|
late int numDay;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
today = DateTime.utc(today.year, today.month, today.day);
|
|
numDay = today.weekday;
|
|
|
|
_fetchProfessionalAndServices();
|
|
}
|
|
|
|
void _fetchProfessionalAndServices() async {
|
|
final professionalFormProvider =
|
|
Provider.of<ProfessionalFormProvider>(context, listen: false);
|
|
final servicesProvider =
|
|
Provider.of<CalendarServicesProvider>(context, listen: false);
|
|
final proProvider =
|
|
Provider.of<ProfessionalProvider>(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 _onDaySelected(DateTime day, DateTime focusedDay) {
|
|
setState(() {
|
|
today = day;
|
|
numDay = today.weekday;
|
|
});
|
|
_fetchProfessionalAndServices();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<ProfessionalFormProvider>(
|
|
builder: (context, professionalFormProvider, child) {
|
|
if (professionalFormProvider.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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 0),
|
|
Column(
|
|
children: [
|
|
..._rangesItems(
|
|
_getScheduleFromNumDay(numDay, profesional),
|
|
context,
|
|
stepMinutes: profesional.slotDurationMinutes),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
ScheduleEntity? _getScheduleFromNumDay(
|
|
int numDay, Profesional userProfessional) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
List<Widget> _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.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"),
|
|
)
|
|
];
|
|
}
|
|
|
|
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
|
schedule.range1Hour1!,
|
|
schedule.range2Hour2!,
|
|
stepMinutes: stepMinutes,
|
|
);
|
|
|
|
return rangesItemList(ranges, _services, today, context);
|
|
} 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"),
|
|
)
|
|
];
|
|
}
|
|
|
|
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
|
schedule.range1Hour1!,
|
|
schedule.range1Hour2!,
|
|
stepMinutes: stepMinutes,
|
|
);
|
|
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
|
schedule.range2Hour1!,
|
|
schedule.range2Hour2!,
|
|
stepMinutes: stepMinutes,
|
|
);
|
|
|
|
return [
|
|
...rangesItemList(ranges1, _services, today, context),
|
|
...rangesItemList(ranges2, _services, today, context),
|
|
];
|
|
}
|
|
}
|
|
|
|
bool _isBlocked(TimeOfDay hora1, List<Service>? events, DateTime selectedDay) {
|
|
if (events == null) return false;
|
|
final dayStr = selectedDay.toIso8601String().split('T').first;
|
|
return events.any((e) =>
|
|
e.day == dayStr &&
|
|
e.range1Hour1 == hora1 &&
|
|
e.status == ServiceStatus.selfBooked);
|
|
}
|
|
|
|
bool _isHora1Ocupada(
|
|
TimeOfDay hora1, List<Service>? 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;
|
|
}
|
|
|
|
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,DateTime selectedDay, BuildContext context) {
|
|
final currentDateTime = DateTime.now();
|
|
|
|
return ranges.map((time) {
|
|
final selectedDateTime = DateTime(
|
|
selectedDay.year,
|
|
selectedDay.month,
|
|
selectedDay.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
|
|
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();
|
|
}
|
|
}
|