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:
Lizandro Guarnizo
2026-07-12 19:25:08 -05:00
co-authored by Claude Sonnet 4.6
parent 46acad0b55
commit 920ae7144d
7 changed files with 105 additions and 20 deletions
+5
View File
@@ -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,
); );
} }
+10 -3
View File
@@ -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;
+6 -2
View File
@@ -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 [
+7 -5
View File
@@ -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),
]; ];
} }
+63 -2
View File
@@ -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 -1
View File
@@ -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) {
+12 -7
View File
@@ -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;
} }