configurable slot duration: UI picker + genRanges stepMinutes
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
46acad0b55
commit
920ae7144d
@@ -23,6 +23,7 @@ class Profesional {
|
|||||||
final List<String> specializationsPictures;
|
final List<String> specializationsPictures;
|
||||||
final Schedules schedules;
|
final Schedules schedules;
|
||||||
final PaymentMethodEntity paymentMethods;
|
final PaymentMethodEntity paymentMethods;
|
||||||
|
final int slotDurationMinutes;
|
||||||
|
|
||||||
const Profesional({
|
const Profesional({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -44,6 +45,7 @@ class Profesional {
|
|||||||
required this.specializationsPictures,
|
required this.specializationsPictures,
|
||||||
required this.schedules,
|
required this.schedules,
|
||||||
required this.paymentMethods,
|
required this.paymentMethods,
|
||||||
|
this.slotDurationMinutes = 30,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Profesional empty() => Profesional(
|
static Profesional empty() => Profesional(
|
||||||
@@ -148,6 +150,7 @@ class Profesional {
|
|||||||
specializationsPictures: specPics,
|
specializationsPictures: specPics,
|
||||||
schedules: parsedSchedules,
|
schedules: parsedSchedules,
|
||||||
paymentMethods: pm,
|
paymentMethods: pm,
|
||||||
|
slotDurationMinutes: (doc['slot_duration_minutes'] as int?) ?? 30,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +174,7 @@ class Profesional {
|
|||||||
List<String>? specializationsPictures,
|
List<String>? specializationsPictures,
|
||||||
Schedules? schedules,
|
Schedules? schedules,
|
||||||
PaymentMethodEntity? paymentMethods,
|
PaymentMethodEntity? paymentMethods,
|
||||||
|
int? slotDurationMinutes,
|
||||||
}) {
|
}) {
|
||||||
return Profesional(
|
return Profesional(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -194,6 +198,7 @@ class Profesional {
|
|||||||
specializationsPictures ?? this.specializationsPictures,
|
specializationsPictures ?? this.specializationsPictures,
|
||||||
schedules: schedules ?? this.schedules,
|
schedules: schedules ?? this.schedules,
|
||||||
paymentMethods: paymentMethods ?? this.paymentMethods,
|
paymentMethods: paymentMethods ?? this.paymentMethods,
|
||||||
|
slotDurationMinutes: slotDurationMinutes ?? this.slotDurationMinutes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
List<String>? specializationsPictures,
|
List<String>? specializationsPictures,
|
||||||
Schedules? schedules,
|
Schedules? schedules,
|
||||||
PaymentMethodEntity? paymentMethods,
|
PaymentMethodEntity? paymentMethods,
|
||||||
|
int? slotDurationMinutes,
|
||||||
}) {
|
}) {
|
||||||
profesional = Profesional(
|
profesional = Profesional(
|
||||||
id: id ?? profesional!.id,
|
id: id ?? profesional!.id,
|
||||||
@@ -53,6 +54,7 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
specializationsPictures: specializationsPictures ?? profesional!.specializationsPictures,
|
specializationsPictures: specializationsPictures ?? profesional!.specializationsPictures,
|
||||||
schedules: schedules ?? profesional!.schedules,
|
schedules: schedules ?? profesional!.schedules,
|
||||||
paymentMethods: paymentMethods ?? profesional!.paymentMethods,
|
paymentMethods: paymentMethods ?? profesional!.paymentMethods,
|
||||||
|
slotDurationMinutes: slotDurationMinutes ?? profesional!.slotDurationMinutes,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -106,9 +108,14 @@ class ProfessionalFormProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<bool> updateProfesionalProfileScheduleInfo(String userId) async {
|
Future<bool> updateProfesionalProfileScheduleInfo(String userId) async {
|
||||||
try {
|
try {
|
||||||
await _api.patch('/professionals/me/schedules', {
|
await Future.wait([
|
||||||
'schedules': profesional!.schedules.toSchedulesArray(),
|
_api.patch('/professionals/me/schedules', {
|
||||||
});
|
'schedules': profesional!.schedules.toSchedulesArray(),
|
||||||
|
}),
|
||||||
|
_api.patch('/professionals/me', {
|
||||||
|
'slot_duration_minutes': profesional!.slotDurationMinutes,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
NotificationsService.showSnackbar('Error al guardar: $e');
|
NotificationsService.showSnackbar('Error al guardar: $e');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
children: [
|
children: [
|
||||||
..._rangesItems(
|
..._rangesItems(
|
||||||
_getScheduleFromNumDay(numDay, profesional),
|
_getScheduleFromNumDay(numDay, profesional),
|
||||||
context),
|
context,
|
||||||
|
stepMinutes: profesional.slotDurationMinutes),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -157,7 +158,7 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context) {
|
List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) {
|
||||||
if (schedule == null) {
|
if (schedule == null) {
|
||||||
return [
|
return [
|
||||||
const Padding(
|
const Padding(
|
||||||
@@ -187,6 +188,7 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
|
||||||
schedule.range1Hour1!,
|
schedule.range1Hour1!,
|
||||||
schedule.range2Hour2!,
|
schedule.range2Hour2!,
|
||||||
|
stepMinutes: stepMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rangesItemList(ranges, _services, today, context);
|
return rangesItemList(ranges, _services, today, context);
|
||||||
@@ -203,10 +205,12 @@ class _CalendarViewState extends State<CalendarView> {
|
|||||||
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
|
||||||
schedule.range1Hour1!,
|
schedule.range1Hour1!,
|
||||||
schedule.range1Hour2!,
|
schedule.range1Hour2!,
|
||||||
|
stepMinutes: stepMinutes,
|
||||||
);
|
);
|
||||||
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
|
||||||
schedule.range2Hour1!,
|
schedule.range2Hour1!,
|
||||||
schedule.range2Hour2!,
|
schedule.range2Hour2!,
|
||||||
|
stepMinutes: stepMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
final pro = fp.profesional!;
|
final pro = fp.profesional!;
|
||||||
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
final sp = Provider.of<ServicesProvider>(context, listen: false);
|
||||||
final schedule = _scheduleFor(_selected.weekday, pro);
|
final schedule = _scheduleFor(_selected.weekday, pro);
|
||||||
final slots = _buildSlots(schedule);
|
final slots = _buildSlots(schedule, pro.slotDurationMinutes);
|
||||||
final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length;
|
final occupied = slots.where((t) => _isOccupied(t, _services, _selected)).length;
|
||||||
final blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length;
|
final blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length;
|
||||||
|
|
||||||
@@ -386,13 +386,15 @@ class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
|
|||||||
_ => pro.schedules.sunday,
|
_ => pro.schedules.sunday,
|
||||||
};
|
};
|
||||||
|
|
||||||
List<TimeOfDay> _buildSlots(ScheduleEntity? s) {
|
List<TimeOfDay> _buildSlots(ScheduleEntity? s, int stepMinutes) {
|
||||||
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
if (s == null || !s.enabled || s.range1Hour1 == null || s.range2Hour2 == null) return [];
|
||||||
if (s.continuousDay) return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!);
|
if (s.continuousDay) {
|
||||||
|
return TimeOfDayUtils.genRanges(s.range1Hour1!, s.range2Hour2!, stepMinutes: stepMinutes);
|
||||||
|
}
|
||||||
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
|
||||||
return [
|
return [
|
||||||
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!),
|
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes),
|
||||||
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!),
|
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,63 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:prosapp_web_app/ui/shared/widgets/schedule_day_tile.dart';
|
import 'package:prosapp_web_app/ui/shared/widgets/schedule_day_tile.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// ── Slot duration options ──────────────────────────────────────────────────
|
||||||
|
const _kSlotOptions = [
|
||||||
|
(15, '15 min'),
|
||||||
|
(20, '20 min'),
|
||||||
|
(30, '30 min'),
|
||||||
|
(45, '45 min'),
|
||||||
|
(60, '1 hora'),
|
||||||
|
(90, '1 h 30'),
|
||||||
|
(120, '2 horas'),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _SlotDurationPicker extends StatelessWidget {
|
||||||
|
final int value;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
const _SlotDurationPicker({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Duración de cada cita',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _kSlotOptions.map((opt) {
|
||||||
|
final (mins, label) = opt;
|
||||||
|
final selected = value == mins;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => onChanged(mins),
|
||||||
|
selectedColor: Colors.blue.shade400,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: selected ? Colors.white : null,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Los usuarios verán bloques de $value min en el calendario.',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ScheduleView extends StatefulWidget {
|
class ScheduleView extends StatefulWidget {
|
||||||
const ScheduleView({super.key});
|
const ScheduleView({super.key});
|
||||||
|
|
||||||
@@ -540,9 +597,13 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(
|
const Divider(height: 20),
|
||||||
height: 20,
|
_SlotDurationPicker(
|
||||||
|
value: profesional.slotDurationMinutes,
|
||||||
|
onChanged: (v) => professionalFormProvider
|
||||||
|
.copyProfesionalWith(slotDurationMinutes: v),
|
||||||
),
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: 10),
|
margin: const EdgeInsets.only(top: 10),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
extension TimeOfDayExtension on TimeOfDay {
|
extension TimeOfDayExtension on TimeOfDay {
|
||||||
TimeOfDay add({int hour = 0, int minute = 0}) {
|
TimeOfDay add({int hour = 0, int minute = 0}) {
|
||||||
return replacing(hour: this.hour + hour, minute: this.minute + minute);
|
final total = this.hour * 60 + this.minute + hour * 60 + minute;
|
||||||
|
return TimeOfDay(hour: (total ~/ 60) % 24, minute: total % 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
int compareTo(TimeOfDay other) {
|
int compareTo(TimeOfDay other) {
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
|
||||||
|
|
||||||
class TimeOfDayUtils {
|
class TimeOfDayUtils {
|
||||||
static List<TimeOfDay> genRanges(TimeOfDay timeStart, TimeOfDay timeEnd) {
|
static List<TimeOfDay> genRanges(
|
||||||
List<TimeOfDay> ranges = [];
|
TimeOfDay timeStart,
|
||||||
TimeOfDay current = timeStart;
|
TimeOfDay timeEnd, {
|
||||||
while (current.isBefore(timeEnd)) {
|
int stepMinutes = 30,
|
||||||
ranges.add(current);
|
}) {
|
||||||
// Sumar 2 horas al objeto DateTime
|
final List<TimeOfDay> ranges = [];
|
||||||
current = current.add(hour: 2);
|
int curr = timeStart.hour * 60 + timeStart.minute;
|
||||||
|
final end = timeEnd.hour * 60 + timeEnd.minute;
|
||||||
|
final step = stepMinutes.clamp(5, 480);
|
||||||
|
while (curr < end) {
|
||||||
|
ranges.add(TimeOfDay(hour: curr ~/ 60, minute: curr % 60));
|
||||||
|
curr += step;
|
||||||
}
|
}
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user