From 920ae7144ddb6dfed03c5d9a96bfe2588b284a8b Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:25:08 -0500 Subject: [PATCH] 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 --- lib/models/profesional.dart | 5 ++ lib/providers/professional_form_provider.dart | 13 +++- lib/ui/views/calendar_view.dart | 8 ++- lib/ui/views/professional_calendar_view.dart | 12 ++-- lib/ui/views/schedule_view.dart | 65 ++++++++++++++++++- lib/utils/time_of_day_extension.dart | 3 +- lib/utils/time_of_day_utils.dart | 19 ++++-- 7 files changed, 105 insertions(+), 20 deletions(-) diff --git a/lib/models/profesional.dart b/lib/models/profesional.dart index 4838b9f..be28f8d 100644 --- a/lib/models/profesional.dart +++ b/lib/models/profesional.dart @@ -23,6 +23,7 @@ class Profesional { final List specializationsPictures; final Schedules schedules; final PaymentMethodEntity paymentMethods; + final int slotDurationMinutes; const Profesional({ required this.id, @@ -44,6 +45,7 @@ class Profesional { required this.specializationsPictures, required this.schedules, required this.paymentMethods, + this.slotDurationMinutes = 30, }); static Profesional empty() => Profesional( @@ -148,6 +150,7 @@ class Profesional { specializationsPictures: specPics, schedules: parsedSchedules, paymentMethods: pm, + slotDurationMinutes: (doc['slot_duration_minutes'] as int?) ?? 30, ); } @@ -171,6 +174,7 @@ class Profesional { List? specializationsPictures, Schedules? schedules, PaymentMethodEntity? paymentMethods, + int? slotDurationMinutes, }) { return Profesional( id: id ?? this.id, @@ -194,6 +198,7 @@ class Profesional { specializationsPictures ?? this.specializationsPictures, schedules: schedules ?? this.schedules, paymentMethods: paymentMethods ?? this.paymentMethods, + slotDurationMinutes: slotDurationMinutes ?? this.slotDurationMinutes, ); } diff --git a/lib/providers/professional_form_provider.dart b/lib/providers/professional_form_provider.dart index 04b9233..4179a6d 100644 --- a/lib/providers/professional_form_provider.dart +++ b/lib/providers/professional_form_provider.dart @@ -32,6 +32,7 @@ class ProfessionalFormProvider with ChangeNotifier { List? specializationsPictures, Schedules? schedules, PaymentMethodEntity? paymentMethods, + int? slotDurationMinutes, }) { profesional = Profesional( id: id ?? profesional!.id, @@ -53,6 +54,7 @@ class ProfessionalFormProvider with ChangeNotifier { specializationsPictures: specializationsPictures ?? profesional!.specializationsPictures, schedules: schedules ?? profesional!.schedules, paymentMethods: paymentMethods ?? profesional!.paymentMethods, + slotDurationMinutes: slotDurationMinutes ?? profesional!.slotDurationMinutes, ); notifyListeners(); } @@ -106,9 +108,14 @@ class ProfessionalFormProvider with ChangeNotifier { Future updateProfesionalProfileScheduleInfo(String userId) async { try { - await _api.patch('/professionals/me/schedules', { - 'schedules': profesional!.schedules.toSchedulesArray(), - }); + await Future.wait([ + _api.patch('/professionals/me/schedules', { + 'schedules': profesional!.schedules.toSchedulesArray(), + }), + _api.patch('/professionals/me', { + 'slot_duration_minutes': profesional!.slotDurationMinutes, + }), + ]); } catch (e) { NotificationsService.showSnackbar('Error al guardar: $e'); return false; diff --git a/lib/ui/views/calendar_view.dart b/lib/ui/views/calendar_view.dart index 5d457bb..391f827 100644 --- a/lib/ui/views/calendar_view.dart +++ b/lib/ui/views/calendar_view.dart @@ -121,7 +121,8 @@ class _CalendarViewState extends State { children: [ ..._rangesItems( _getScheduleFromNumDay(numDay, profesional), - context), + context, + stepMinutes: profesional.slotDurationMinutes), const SizedBox(height: 8), ], ), @@ -157,7 +158,7 @@ class _CalendarViewState extends State { } } - List _rangesItems(ScheduleEntity? schedule, BuildContext context) { + List _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) { if (schedule == null) { return [ const Padding( @@ -187,6 +188,7 @@ class _CalendarViewState extends State { List ranges = TimeOfDayUtils.genRanges( schedule.range1Hour1!, schedule.range2Hour2!, + stepMinutes: stepMinutes, ); return rangesItemList(ranges, _services, today, context); @@ -203,10 +205,12 @@ class _CalendarViewState extends State { List ranges1 = TimeOfDayUtils.genRanges( schedule.range1Hour1!, schedule.range1Hour2!, + stepMinutes: stepMinutes, ); List ranges2 = TimeOfDayUtils.genRanges( schedule.range2Hour1!, schedule.range2Hour2!, + stepMinutes: stepMinutes, ); return [ diff --git a/lib/ui/views/professional_calendar_view.dart b/lib/ui/views/professional_calendar_view.dart index 19f0976..4a4f72f 100644 --- a/lib/ui/views/professional_calendar_view.dart +++ b/lib/ui/views/professional_calendar_view.dart @@ -79,7 +79,7 @@ class _ProfessionalCalendarViewState extends State { final pro = fp.profesional!; final sp = Provider.of(context, listen: false); 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 blocked = slots.where((t) => _isSelfBooked(t, _services, _selected)).length; @@ -386,13 +386,15 @@ class _ProfessionalCalendarViewState extends State { _ => pro.schedules.sunday, }; - List _buildSlots(ScheduleEntity? s) { + List _buildSlots(ScheduleEntity? s, int stepMinutes) { 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 []; return [ - ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!), - ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!), + ...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes), + ...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes), ]; } diff --git a/lib/ui/views/schedule_view.dart b/lib/ui/views/schedule_view.dart index d67b211..61214ec 100644 --- a/lib/ui/views/schedule_view.dart +++ b/lib/ui/views/schedule_view.dart @@ -11,6 +11,63 @@ import 'package:flutter/material.dart'; import 'package:prosapp_web_app/ui/shared/widgets/schedule_day_tile.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 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 { const ScheduleView({super.key}); @@ -540,9 +597,13 @@ class _ScheduleViewState extends State { ); }, ), - const Divider( - height: 20, + const Divider(height: 20), + _SlotDurationPicker( + value: profesional.slotDurationMinutes, + onChanged: (v) => professionalFormProvider + .copyProfesionalWith(slotDurationMinutes: v), ), + const Divider(height: 20), Container( margin: const EdgeInsets.only(top: 10), child: ConstrainedBox( diff --git a/lib/utils/time_of_day_extension.dart b/lib/utils/time_of_day_extension.dart index a631b51..93209c3 100644 --- a/lib/utils/time_of_day_extension.dart +++ b/lib/utils/time_of_day_extension.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; extension TimeOfDayExtension on TimeOfDay { 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) { diff --git a/lib/utils/time_of_day_utils.dart b/lib/utils/time_of_day_utils.dart index 43c7818..6836dd3 100644 --- a/lib/utils/time_of_day_utils.dart +++ b/lib/utils/time_of_day_utils.dart @@ -2,13 +2,18 @@ import 'package:flutter/material.dart'; import 'package:prosapp_web_app/utils/time_of_day_extension.dart'; class TimeOfDayUtils { - static List genRanges(TimeOfDay timeStart, TimeOfDay timeEnd) { - List ranges = []; - TimeOfDay current = timeStart; - while (current.isBefore(timeEnd)) { - ranges.add(current); - // Sumar 2 horas al objeto DateTime - current = current.add(hour: 2); + static List genRanges( + TimeOfDay timeStart, + TimeOfDay timeEnd, { + int stepMinutes = 30, + }) { + final List ranges = []; + 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; }