Compare commits

...
46 Commits
Author SHA1 Message Date
Lizandro Guarnizo 459d6f7203 fix: require city and phone (not just name) before switching to pro mode
Aligns the profile-completeness gate in the sidebar with the mobile app,
and reports which specific field is missing instead of a generic message.
2026-08-03 22:16:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 aa7b890ce4 fix: notify immediately when isLoading=true in service fetch methods
Without notifyListeners() after isLoading=true, the Consumer would
briefly show stale data (or 'service not found') from a previous
navigation before the API call completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:17:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ea442aafa7 fix: use widget.type in _ServiceViewState (build error)
After converting ServiceView to StatefulWidget, the build() method and
helper callbacks still referenced 'type' as a bare name instead of
widget.type, causing compilation failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 09:41:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 646e44d126 fix: location preference bug and service detail 404
- When professional accepts 'both' modes, add office/delivery toggle so
  the patient can explicitly choose instead of defaulting to delivery.
  Reset _bookAsDelivery when a professional is selected; office-only
  professionals still force office, delivery-only force delivery.
- Fix 'sin servicio' on ServiceView: getServiceForUser was calling
  /users/:professionalId using the professionals-table UUID (not user UUID),
  returning 404 and setting service=null. Use the embedded professionals.users
  data from findById instead.
- Convert ServiceView to StatefulWidget; fetch in initState to avoid
  re-fetching on every parent rebuild.
- Remove client-side FCM notification from _requestService; backend
  create() now notifies the professional directly with the server key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 09:33:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 735d8c2ba0 fix: corregir flujo completo de servicio y geocodificación
- Service.fromJson: lat/lng con Decimal de Prisma llegaban como string y
  el cast 'as num?' lanzaba TypeError silencioso → lista de servicios vacía.
  Mismo patrón que average_score, fix: double.tryParse(?.toString())
- service_status: agregar enumToStringService que mapea el enum de Flutter
  al string que espera el DTO del backend ('pending','accepted', etc.).
  changeServiceStatus enviaba el índice entero → @IsEnum rechazaba → el
  profesional no podía aceptar ni rechazar solicitudes
- maps_service: agregar MapsService.reverseGeocode que usa el Geocoder del
  Maps JS SDK ya cargado, sin requerir la Geocoding API habilitada por separado
- dashboard_view: _reverseGeocode usa el JS Geocoder en vez del endpoint HTTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 09:15:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8296b5a3ea fix: corregir payload de solicitud de servicio al backend
- day: usar formato ISO 8601 "yyyy-MM-dd" — @IsDateString() rechazaba
  el formato de DateTime.toString() que usa espacio en vez de T
- location_preference: enviar string "office"/"delivery" con la clave
  correcta — se enviaba 'location' con un entero (enum index)
- Construir el payload directamente en vez de usar Service.toDocument()
  que no coincide con CreateServiceDto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 08:54:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 462d47d182 fix: mostrar dirección del consultorio en InfoWindow del marcador del mapa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 08:51:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6c248e1f98 fix: corregir flujo de agendamiento del paciente
- 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>
2026-07-22 08:38:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 387b647abc feat: mostrar dirección y distancia en cards de profesionales
- Agrega getters patientLat/patientLng al ProfessionalsProvider
- Calcula distancia Haversine entre la ubicación del paciente y el consultorio
  del profesional, mostrándola en azul (ej: "1.3 km" o "800 m") en la card
- Muestra la dirección del consultorio (icono de tienda) en ambos layouts:
  horizontal (compact) y vertical (grid)
- Distancia solo visible cuando el paciente tiene coordenadas registradas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 08:32:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5fdc87dad7 feat: marcadores de consultorios en mapa + fix ciudad inicial
- Muestra marcadores azules en el mapa para profesionales con coordenadas
  reales y modalidad presencial (office/both), filtrados por ciudad del paciente.
  Tocar un marcador navega directamente al calendario del profesional.
- Agrega tabla de coordenadas hardcodeadas para 30 ciudades colombianas como
  fallback inmediato en _centerOnUserCity, evitando que el mapa cargue en
  Medellín cuando la Geocoding API no está habilitada en el proyecto GCP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 08:09:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 66f861601f feat: inicializar mapa en ciudad del usuario antes de GPS
_initMaps ahora geocodifica la ciudad del perfil del usuario (_centerOnUserCity)
antes de mostrar el mapa. Así el mapa arranca en Bucaramanga si el usuario
es de Bucaramanga, y el autocomplete ya tiene coordenadas correctas desde
el primer keystroke, sin esperar GPS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 21:06:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cfd1c23fe3 fix: autocomplete muestra resultados de Medellín para usuario en Bucaramanga
Default _mapCenter era Medellín (nuevo fallback neutro) pero se usaba como
bias de coordenadas en el autocomplete antes de que GPS/geocoding terminara.
Ahora:
- _mapPositioned=false hasta que GPS o _centerOnUserCity actualice el centro
- Autocomplete omite parámetro location hasta que _mapPositioned=true
- Mientras tanto, la ciudad del usuario se agrega al query de texto para biasear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 21:06:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 495502cfc1 fix: no exigir dirección cuando profesional solo atiende en consultorio
- _requestService: omite validación de dirección si locationPreferences=office
- Usa la dirección y coordenadas del consultorio del profesional en ese caso
- Botón y warnings de ciudad deshabilitados solo en modo domicilio/ambos
- Tarjeta inferior muestra dirección del consultorio cuando es office-only
- location_preference del servicio refleja correctamente office vs delivery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 21:04:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c0fce73d41 fix: average_score Decimal string cast crash silently empties professionals list
Prisma serializa Decimal como string JSON ("0"), no como número.
'(doc['average_score'] as num?)' lanzaba TypeError en Dart cuando el valor
era "0" (string), el catch lo tragaba y professionals quedaba vacío.
Cambiado a double.tryParse que maneja null, string y número.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:57:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 953196df1a fix: autocomplete muestra Bogotá para usuario en Bucaramanga
- _mapCenter default cambiado de Bogotá a coordenadas más neutras
- Cuando GPS no está disponible/denegado, _centerOnUserCity() geocodifica
  la ciudad del perfil del usuario y centra el mapa ahí
- El autocomplete usa _mapCenter como bias → resultados ahora corresponden
  a la ciudad del usuario, no a Bogotá hardcodeado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:48:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54f9cc272e fix: 4 audit findings in professionals location flow
- Clear button (✕) now resets _cityMismatch and _detectedCity so banner and CTA unblock
- _citiesMatch: replace bidirectional contains with equality + word-prefix check to prevent 'Cali' matching 'Calima'
- ProfessionalsProvider: track _currentUserId to reset state on user change (cross-user leak)
- ProfessionalsProvider: store _lastSearch so setLocationContext preserves active search term

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:47:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f26b3d3c03 feat: dashboard city validation + professionals by location
- Professionals filtered by user city + proximity (lat/lng) to selected address
- ProfessionalsProvider: setLocationContext(city, lat, lng) updates filter and reloads
- Dashboard: detect city mismatch after reverse geocode, block booking if wrong city
- Dashboard: autocomplete biased toward user's city
- Dashboard: setLocationContext called before navigating to professionals list
- Dashboard: keyboard no longer shifts map layout on tablet (MediaQuery viewInsets=0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:36:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e0982995e6 feat: server-side search with debounce for professionals list
- getProfessionals() now accepts optional search param sent to ?search=
- updateCity only triggers the initial load (city no longer used as filter)
- Search field debounces 450ms before firing API call
- TextEditingController added so clear button also resets the field
- Client-side filtering removed (API now handles it and returns top 7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:18:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9da159f287 Fix professionals list never loading when user has no city
updateCity skipped getProfessionals() when both _city and the incoming
city were null (null != null = false). Added _initialized flag so the
first call always triggers the load regardless of city value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:14:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4698454cfc Fix stuck loading after save on profile and schedule views
- Remove fp.clear() from profile initState: if profesional is already
  loaded (e.g. navigating from schedule page) the form renders instantly
  without a spinner; if null the Consumer already shows the spinner.
- Add .catchError() so the spinner always resolves even on API failure.
- Remove redundant GET /professionals/me reload inside updateProfesionalProfileInfo:
  data is already correct from copyProfesionalWith, so the save button
  no longer waits for an extra round-trip after the PATCH.
- Wrap save button in try/finally so _saving always resets to false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:44:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fd7b57fd35 fix unblocked slot showing as Ocupado instead of Disponible
GET /services/professional returns all statuses including cancelled.
After unblocking (self_booked → cancelled), _isOccupied matched the
cancelled service because it only excluded selfBooked.

Exclude cancelled and denied from _isOccupied so unblocked slots
correctly revert to Disponible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:26:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 920ae7144d 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>
2026-07-12 19:25:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 46acad0b55 fix blocked/occupied slot detection always returning false
Service.fromJson stored day as the raw Prisma JSON string
"2024-01-15T00:00:00.000Z", but _isSelfBooked/_isOccupied/_isBlocked
compared it against "2024-01-15" (toIso8601String().split('T').first),
so the match always failed and every slot appeared as Disponible.

Normalize day to the date-only portion in fromJson so all comparisons
work correctly across ProfessionalCalendarView and CalendarView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:14:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 577c4737ae persist professional mode across page refreshes
isProModeActive was in-memory only — a browser refresh reset it to
false even when the URL was a professional route.

Fix: read/write 'isProModeActive' from SharedPreferences in
ProfessionalProvider (constructor, toggleProMode, setIsProModeActive,
logout). Also clear the flag in AuthProvider.isAuthenticated() when the
user's proState is not active, so a stale value can't trap a non-pro user
in professional mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:07:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1870159dea hide professional-blocked slots from user-facing calendar
CalendarView now treats self_booked services as unavailable:
- _isBlocked() filters slots with selfBooked status from the range list
- _isHora1Ocupada() excludes selfBooked so they don't show as Ocupado
- Blocked slots render as grey "No disponible" with block icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:56:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 52845c59fd feat(calendar): add block/unblock slots for professionals
- ServicesProvider: add blockSlot() calling POST /services/block and
  unblockSlot() calling PATCH /services/:id/status with cancelled
- ProfessionalCalendarView:
  - Fix _services always-null bug: getServicesForProfessional() returns
    void; now reads from sp.services after it resolves
  - Differentiate self_booked (blocked) vs regular occupied slots
  - Show amber 'Bloqueado' state with lock icon for self_booked services
  - Show lock button on available slots → confirm dialog → blockSlot()
  - Show unlock button on blocked slots → confirm dialog → unblockSlot()
  - Reload calendar after block/unblock so UI reflects new state
  - Header stats now show separate counts: ocupadas / bloqueadas / libres
  - _ActionButton widget for reusable tap-target icon buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:26:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 498481cab0 fix(scheduling): fix schedule save endpoint, time format, and calendar load
Critical fixes for the scheduling flow:

- ScheduleEntity: add toScheduleDto() with snake_case keys, day_of_week,
  and HH:MM zero-padded format required by the backend DTO @Matches validator
- Schedules: add toSchedulesArray() converting the day-keyed object to the
  array format expected by PATCH /professionals/me/schedules
- ProfessionalFormProvider: updateProfesionalProfileScheduleInfo now calls
  the correct endpoint (/professionals/me/schedules) instead of /professionals/me
  which was stripping the schedules field via ValidationPipe whitelist
- Service.formatTimeOfDay: zero-pad hours and minutes so POST /services passes
  the @Matches(/^\d{2}:\d{2}$/) DTO validation
- ProfessionalProvider: add getProfessionalById() calling /professionals/:id
  (public endpoint) to load any professional's data, not the viewer's own
- CalendarServicesProvider: add getPublicServicesForProfessional() calling
  /services/public-calendar/:id so the user calendar shows the target
  professional's booked slots, not the viewer's own services
- CalendarView: use getProfessionalById + getPublicServicesForProfessional
  so the calendar correctly reflects the selected professional's schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 17:52:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 360f88777b fix: add 30s timeout to API calls and catchError fallback on schedule load
Without a timeout, a hanging backend request left the schedule page showing
an infinite spinner forever. Timeout ensures any hung request fails fast and
the spinner resolves. The catchError fallback in schedule_view handles the
edge case where getProfessional rejects unexpectedly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 17:42:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dbeda22e19 feat: auto-fill 12pm-2pm break when disabling continuous day on schedule
When toggling off jornada continua, range1Hour2 defaults to 12:00 and
range2Hour1 defaults to 14:00 if not already set. Existing values are
preserved so the user can still edit them freely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 22:16:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 706b78b737 fix: safe parsing of Prisma Decimal fields to prevent data loss on save
Prisma serializes Decimal fields (rate, latitude, longitude) as JSON
strings, causing 'as num?' casts to throw in fromDocument(). The catch
block in getProfessional() then returned an empty Profesional, so saving
overwrote all existing data with empty strings.

Also added try-catch in all patch API calls to surface errors to user
instead of silently failing with a stuck loading button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 22:00:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7451b7c57b feat: apply default hours (08:00-18:00) when enabling a schedule day
When a day is toggled on and has no hours set yet, automatically
apply continuous-day defaults (08:00 - 18:00) so the user can
save immediately without having to pick times manually.
Also clear fp.profesional in initState to ensure fresh data loads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:08:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ee678b26ff fix: professional profile fetch uses /me endpoint and re-fetches after save
- Use GET /professionals/me (JWT-authenticated) instead of /professionals/{uid}
  to avoid silent failures when the user_id lookup fails
- After save, re-fetch from server so fp.profesional reflects confirmed server state
  on the next page navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 23:05:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b31ff13657 fix: professional profile not loading or saving correctly
- Fix typo aditional_address -> additional_address in toDocument() and fromDocument()
- Clear ProfessionalFormProvider before fetch in initState() so TextFormField initialValue always applies to fresh data
- Add clear() to ProfessionalFormProvider; call it on logout to avoid leaking data between users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 21:44:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 732da2e475 fix(auth): always redirect to phone login when unauthenticated
After logout or on app start with no/invalid token, the browser URL
might be #/dashboard or #/auth/login. Using addPostFrameCallback to
navigate to phoneLoginRoute once the navigator is ready ensures the
user always lands on the phone login screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 15:05:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 13a99d4d34 fix(cache): disable service worker to prevent stale cache on mobile
Flutter's PWA service worker caches the entire app shell aggressively,
causing users to see old versions even after deploys. Disabling it lets
nginx cache headers take effect. Also unregisters any existing service
worker already installed on user devices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:48:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e56ec8619e fix(onboarding): redirect to city setup after name step
Was going directly to dashboard, skipping the city selection screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:44:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2d5ff1581b Fix dark/light theme support in redesigned views
Replace hardcoded colors with Theme.of(context) values via BuildContext
extension (_Th): bg, card, onSurface, muted, subtle, divider, shadow,
inputFill, chipBg. Affects: professional_profile_view, professional_
calendar_view, services_requests_view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:45:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 142e3ffafd Redesign calendar and requests views
- professional_calendar_view: styled TableCalendar with app colors,
  day header with date block + stats pills (occupied/available),
  time slot cards with accent strip + status icon, clean empty state
- services_requests_view: card list with status-colored accent strips,
  avatar initials fallback, date/time row, colored status badges with
  icons for all ServiceStatus values, proper empty state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:41:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 024d2ec5f5 Update mode toggle button: swap_horiz icon + shorter label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:37:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ef8ccb9aee Redesign professional profile UI + sidebar mode indicator
- professional_profile_view: gradient header card with avatar overlay,
  sectioned cards (service modality, address, rate, payment chips, schedule),
  animated payment method chips, full-width save button with loading state
- sidebar: teal-dark background in pro mode vs navy in user mode,
  animated mode indicator pill, muted toggle button in pro mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:34:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9f2612ec70 Add diploma/certificate upload to professional request form (required field)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:50:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3e59a1cb4f Fix retry: show error on failure, reload professional data after reset
- Stop swallowing DELETE errors silently; show snackbar instead
- After successful reset, reload professional from server and notify providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:00:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dba8eaa709 Fix retry button not showing: add missing ApiService import and show button immediately
- Add missing api_service.dart import (without it the file fails to compile)
- Initialize _loadingSettings = false so button renders before async settings load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:49:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 391c32f765 Rejection retry flow with configurable wait days
- Usuario model: proRejectedAt parsed from professionals.updated_at
- _StatusCard: shows countdown or retry button based on rejection_wait_days setting
- Retry clears the rejected record via DELETE /professionals/me

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 18:03:20 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9634aa13ff Add cedula number field to professional request form
Shows document type locked to 'Cédula de ciudadanía' and adds required
number input field above the photo upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 17:49:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 28511491af Fix pro state not updating after submit: use context.watch for AuthProvider
context.read doesn't subscribe to changes, so the form stayed visible
after submitForReview even though AuthProvider was updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 17:27:12 -05:00
31 changed files with 2774 additions and 1526 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ COPY pubspec.yaml pubspec.lock ./
RUN flutter pub get RUN flutter pub get
COPY . . COPY . .
RUN flutter build web --release --base-href / RUN flutter build web --release --base-href / --pwa-strategy=none
# Stage 2: Serve with nginx # Stage 2: Serve with nginx
FROM nginx:alpine FROM nginx:alpine
+1 -1
View File
@@ -50,7 +50,7 @@ class AppState extends StatelessWidget {
ChangeNotifierProvider(create: (_) => ProfessionalFormProvider()), ChangeNotifierProvider(create: (_) => ProfessionalFormProvider()),
ChangeNotifierProxyProvider<AuthProvider, ProfessionalsProvider>( ChangeNotifierProxyProvider<AuthProvider, ProfessionalsProvider>(
create: (_) => ProfessionalsProvider(), create: (_) => ProfessionalsProvider(),
update: (_, auth, prev) => prev!..updateCity(auth.user?.city), update: (_, auth, prev) => prev!..updateCity(auth.user?.city, userId: auth.user?.id),
), ),
ChangeNotifierProvider(create: (_) => ServicesProvider()), ChangeNotifierProvider(create: (_) => ServicesProvider()),
ChangeNotifierProvider(create: (_) => CalendarServicesProvider()), ChangeNotifierProvider(create: (_) => CalendarServicesProvider()),
+13 -6
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(
@@ -132,20 +134,23 @@ class Profesional {
rethusCode: (doc['rethus_code'] as String?) ?? '', rethusCode: (doc['rethus_code'] as String?) ?? '',
rethusValidated: (doc['rethus_validated'] as bool?) ?? false, rethusValidated: (doc['rethus_validated'] as bool?) ?? false,
address: (doc['address'] as String?) ?? '', address: (doc['address'] as String?) ?? '',
aditionalAddress: (doc['aditional_address'] as String?) ?? '', aditionalAddress: (doc['additional_address'] as String?) ?? '',
profession: (doc['profession'] as String?) ?? '', profession: (doc['profession'] as String?) ?? '',
ratePreferences: (doc['rate_preferences'] as bool?) ?? false, ratePreferences: doc['rate_preferences'] is bool
rate: (doc['rate'] as num?)?.toString() ?? '', ? doc['rate_preferences'] as bool
: doc['rate_preferences'] == 'true',
rate: doc['rate']?.toString() ?? '',
locationPreferences: locationPrefsFromValue(doc['location_preferences']), locationPreferences: locationPrefsFromValue(doc['location_preferences']),
bannerPicture: (doc['banner_picture'] as String?) ?? '', bannerPicture: (doc['banner_picture'] as String?) ?? '',
identificationPicture: (doc['identification_picture'] as String?) ?? '', identificationPicture: (doc['identification_picture'] as String?) ?? '',
certificatePicture: (doc['certificate_picture'] as String?) ?? '', certificatePicture: (doc['certificate_picture'] as String?) ?? '',
latitude: (doc['latitude'] as num?)?.toDouble() ?? 0.0, latitude: double.tryParse(doc['latitude']?.toString() ?? '') ?? 0.0,
longitude: (doc['longitude'] as num?)?.toDouble() ?? 0.0, longitude: double.tryParse(doc['longitude']?.toString() ?? '') ?? 0.0,
specializations: specs, specializations: specs,
specializationsPictures: specPics, specializationsPictures: specPics,
schedules: parsedSchedules, schedules: parsedSchedules,
paymentMethods: pm, paymentMethods: pm,
slotDurationMinutes: (doc['slot_duration_minutes'] as int?) ?? 30,
); );
} }
@@ -169,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,
@@ -192,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,
); );
} }
@@ -202,7 +209,7 @@ class Profesional {
'rethus_code': rethusCode, 'rethus_code': rethusCode,
'rethus_validated': rethusValidated, 'rethus_validated': rethusValidated,
'address': address, 'address': address,
'aditional_address': aditionalAddress, 'additional_address': aditionalAddress,
'profession': profession, 'profession': profession,
'rate_preferences': ratePreferences, 'rate_preferences': ratePreferences,
'rate': double.tryParse(rate) ?? 0.0, 'rate': double.tryParse(rate) ?? 0.0,
+14
View File
@@ -73,4 +73,18 @@ class Schedules {
'sunday': sunday.toJson(), 'sunday': sunday.toJson(),
}; };
} }
// Produces the array format expected by PATCH /professionals/me/schedules
// day_of_week: 0=Monday … 6=Sunday (matches backend DB convention)
List<Map<String, dynamic>> toSchedulesArray() {
return [
monday.toScheduleDto(0),
tuesday.toScheduleDto(1),
wednesday.toScheduleDto(2),
thursday.toScheduleDto(3),
friday.toScheduleDto(4),
saturday.toScheduleDto(5),
sunday.toScheduleDto(6),
];
}
} }
+23 -8
View File
@@ -73,20 +73,35 @@ class ScheduleEntity {
return { return {
'habilitado': enabled, 'habilitado': enabled,
'continuous_day': continuousDay, 'continuous_day': continuousDay,
'range1Hour1': formatTimeOfDay(range1Hour1), 'range1Hour1': _formatTimePadded(range1Hour1),
'range1Hour2': formatTimeOfDay(range1Hour2), 'range1Hour2': _formatTimePadded(range1Hour2),
'range2Hour1': formatTimeOfDay(range2Hour1), 'range2Hour1': _formatTimePadded(range2Hour1),
'range2Hour2': formatTimeOfDay(range2Hour2), 'range2Hour2': _formatTimePadded(range2Hour2),
}; };
} }
String? formatTimeOfDay(TimeOfDay? time) { // Produces { day_of_week, enabled, continuous_day, range*_hour* } for PATCH /professionals/me/schedules
if (time != null) { Map<String, dynamic> toScheduleDto(int dayOfWeek) {
return "${time.hour.toString()}:${time.minute.toString()}"; return {
'day_of_week': dayOfWeek,
'enabled': enabled,
'continuous_day': continuousDay,
'range1_hour1': _formatTimePadded(range1Hour1),
'range1_hour2': _formatTimePadded(range1Hour2),
'range2_hour1': _formatTimePadded(range2Hour1),
'range2_hour2': _formatTimePadded(range2Hour2),
};
} }
return null;
static String? _formatTimePadded(TimeOfDay? time) {
if (time == null) return null;
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
return '$h:$m';
} }
String? formatTimeOfDay(TimeOfDay? time) => _formatTimePadded(time);
static String? getFormatTime(TimeOfDay? time) { static String? getFormatTime(TimeOfDay? time) {
if (time == null) { if (time == null) {
return null; return null;
+8 -5
View File
@@ -65,9 +65,10 @@ class Service {
userScored: doc['user_scored'] as bool? ?? false, userScored: doc['user_scored'] as bool? ?? false,
address: doc['address'] as String? ?? '', address: doc['address'] as String? ?? '',
aditionalAddress: (doc['additional_address'] ?? doc['aditional_address']) as String? ?? '', aditionalAddress: (doc['additional_address'] ?? doc['aditional_address']) as String? ?? '',
latitude: (doc['latitude'] as num?)?.toDouble() ?? 0.0, // Prisma Decimal fields serialize as JSON strings — same pattern as average_score
longitude: (doc['longitude'] as num?)?.toDouble() ?? 0.0, latitude: double.tryParse(doc['latitude']?.toString() ?? '') ?? 0.0,
day: doc['day']?.toString() ?? '', longitude: double.tryParse(doc['longitude']?.toString() ?? '') ?? 0.0,
day: (doc['day']?.toString() ?? '').split('T').first,
createdAt: doc['created_at']?.toString() ?? '', createdAt: doc['created_at']?.toString() ?? '',
description: doc['description'] as String? ?? '', description: doc['description'] as String? ?? '',
range1Hour1: _parseTime(doc['range1_hour1']?.toString() ?? '0:0'), range1Hour1: _parseTime(doc['range1_hour1']?.toString() ?? '0:0'),
@@ -113,8 +114,10 @@ class Service {
static TimeOfDay parseTimeOfDay(String timeString) => _parseTime(timeString); static TimeOfDay parseTimeOfDay(String timeString) => _parseTime(timeString);
String? formatTimeOfDay(TimeOfDay? time) { String? formatTimeOfDay(TimeOfDay? time) {
if (time != null) return '${time.hour}:${time.minute}'; if (time == null) return null;
return null; final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
return '$h:$m';
} }
@override @override
+16 -6
View File
@@ -10,10 +10,20 @@ enum ServiceStatus {
selfBooked // 6 selfBooked // 6
} }
int enumToIntService(ServiceStatus state) { // Maps Flutter enum → backend string (matches ServiceStatus enum in the backend DTO)
return state.index; const _serviceStatusStrings = {
} ServiceStatus.pending: 'pending',
ServiceStatus.acepted: 'accepted',
ServiceStatus.denied: 'denied',
ServiceStatus.active: 'active',
ServiceStatus.cancelled: 'cancelled',
ServiceStatus.completed: 'completed',
ServiceStatus.selfBooked:'self_booked',
};
ServiceStatus intToEnumService(int value) { String enumToStringService(ServiceStatus state) =>
return ServiceStatus.values[value]; _serviceStatusStrings[state] ?? 'pending';
}
int enumToIntService(ServiceStatus state) => state.index;
ServiceStatus intToEnumService(int value) => ServiceStatus.values[value];
+15
View File
@@ -11,6 +11,7 @@ class Usuario {
final String? birthday; final String? birthday;
final String? gender; final String? gender;
final ProState proState; final ProState proState;
final DateTime? proRejectedAt;
final String? token; final String? token;
final bool isPhoneVerified; final bool isPhoneVerified;
@@ -26,6 +27,7 @@ class Usuario {
required this.gender, required this.gender,
required this.proState, required this.proState,
required this.token, required this.token,
this.proRejectedAt,
this.isPhoneVerified = false, this.isPhoneVerified = false,
}); });
@@ -57,11 +59,24 @@ class Usuario {
birthday: doc['birthday'], birthday: doc['birthday'],
gender: doc['gender'], gender: doc['gender'],
proState: intToEnum((doc['professional_state'] as int?) ?? 0), proState: intToEnum((doc['professional_state'] as int?) ?? 0),
proRejectedAt: _parseRejectedAt(doc),
token: doc['token'], token: doc['token'],
isPhoneVerified: doc['is_phone_verified'] as bool? ?? false, isPhoneVerified: doc['is_phone_verified'] as bool? ?? false,
); );
} }
static DateTime? _parseRejectedAt(Map<String, dynamic> doc) {
// /auth/me returns professionals nested; if pro_state==3 use professionals.updated_at
final state = (doc['professional_state'] as int?) ?? 0;
if (state != 3) return null;
final pros = doc['professionals'];
if (pros is Map) {
final raw = pros['updated_at'];
if (raw is String) return DateTime.tryParse(raw);
}
return null;
}
@override @override
String toString() { String toString() {
return 'User(id: $id, email: $email, phone: $phone, name: $name, nickname: $nickname, city: $city, picture: $picture, birthday: $birthday, gender: $gender, proState: $proState, token: $token)'; return 'User(id: $id, email: $email, phone: $phone, name: $name, nickname: $nickname, city: $city, picture: $picture, birthday: $birthday, gender: $gender, proState: $proState, token: $token)';
+1 -1
View File
@@ -39,7 +39,7 @@ class UsuarioProfesional {
return UsuarioProfesional( return UsuarioProfesional(
user: Usuario.fromDocument(userDoc ?? {}), user: Usuario.fromDocument(userDoc ?? {}),
professionalInfo: Profesional.fromDocument(proDoc), professionalInfo: Profesional.fromDocument(proDoc),
averageScore: (doc['average_score'] as num?)?.toDouble() ?? 0.0, averageScore: double.tryParse(doc['average_score']?.toString() ?? '') ?? 0.0,
); );
} }
+18
View File
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:prosapp_web_app/models/pro_state.dart';
import 'package:prosapp_web_app/models/usuario.dart'; import 'package:prosapp_web_app/models/usuario.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/professional_provider.dart';
import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart';
import 'package:prosapp_web_app/router/router.dart'; import 'package:prosapp_web_app/router/router.dart';
import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/services/api_service.dart';
import 'package:prosapp_web_app/services/local_storage.dart';
import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:prosapp_web_app/services/notifications_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -170,12 +174,18 @@ class AuthProvider extends ChangeNotifier {
if (token == null) { if (token == null) {
authStatus = AuthStatus.notAuthenticated; authStatus = AuthStatus.notAuthenticated;
notifyListeners(); notifyListeners();
_redirectToPhoneLogin();
return false; return false;
} }
try { try {
final data = await _api.get('/auth/me'); final data = await _api.get('/auth/me');
user = Usuario.fromDocument(data as Map<String, dynamic>); user = Usuario.fromDocument(data as Map<String, dynamic>);
userAverageScore = await _loadAverageScore(user!.id); userAverageScore = await _loadAverageScore(user!.id);
// If the user is not an active professional, clear the persisted pro mode
// flag so a stale value doesn't leave them stuck in professional mode.
if (user!.proState != ProState.active) {
LocalStorage.prefs.setBool('isProModeActive', false);
}
authStatus = AuthStatus.authenticated; authStatus = AuthStatus.authenticated;
notifyListeners(); notifyListeners();
return true; return true;
@@ -183,10 +193,17 @@ class AuthProvider extends ChangeNotifier {
await _api.deleteToken(); await _api.deleteToken();
authStatus = AuthStatus.notAuthenticated; authStatus = AuthStatus.notAuthenticated;
notifyListeners(); notifyListeners();
_redirectToPhoneLogin();
return false; return false;
} }
} }
void _redirectToPhoneLogin() {
SchedulerBinding.instance.addPostFrameCallback((_) {
NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
});
}
Future<void> logout() async { Future<void> logout() async {
await _api.deleteToken(); await _api.deleteToken();
authStatus = AuthStatus.notAuthenticated; authStatus = AuthStatus.notAuthenticated;
@@ -196,6 +213,7 @@ class AuthProvider extends ChangeNotifier {
final ctx = NavigationService.navigatorKey.currentContext!; final ctx = NavigationService.navigatorKey.currentContext!;
Provider.of<ServicesProvider>(ctx, listen: false).logout(); Provider.of<ServicesProvider>(ctx, listen: false).logout();
Provider.of<ProfessionalProvider>(ctx, listen: false).logout(); Provider.of<ProfessionalProvider>(ctx, listen: false).logout();
Provider.of<ProfessionalFormProvider>(ctx, listen: false).clear();
NavigationService.replaceTo(Flurorouter.phoneLoginRoute); NavigationService.replaceTo(Flurorouter.phoneLoginRoute);
} }
@@ -18,6 +18,21 @@ class CalendarServicesProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Returns services for a specific professional via the public endpoint (no auth needed).
// Used by CalendarView when a user browses a professional's availability.
Future<List<Service>> getPublicServicesForProfessional(String professionalId) async {
try {
final res = await _api.get('/services/public-calendar/$professionalId');
final rawServices = (res as Map<String, dynamic>)['services'] as List? ?? [];
return rawServices.map((e) {
final m = e as Map<String, dynamic>;
return Service.fromJson(m, m['id'] as String);
}).toList();
} catch (_) {
return [];
}
}
getServicesForProfessional(String userId) async { getServicesForProfessional(String userId) async {
try { try {
isLoading = true; isLoading = true;
+31 -2
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();
} }
@@ -60,6 +62,11 @@ class ProfessionalFormProvider with ChangeNotifier {
bool _validForm() => formKey.currentState!.validate(); bool _validForm() => formKey.currentState!.validate();
bool _validProfileForm() => profileFormKey.currentState!.validate(); bool _validProfileForm() => profileFormKey.currentState!.validate();
void clear() {
profesional = null;
notifyListeners();
}
setProfesional(Profesional p) { setProfesional(Profesional p) {
profesional = p; profesional = p;
notifyListeners(); notifyListeners();
@@ -67,7 +74,12 @@ class ProfessionalFormProvider with ChangeNotifier {
Future<bool> updateProfesionalInfo(String userId) async { Future<bool> updateProfesionalInfo(String userId) async {
if (!_validForm()) return false; if (!_validForm()) return false;
try {
await _api.patch('/professionals/me', profesional!.toDocument()); await _api.patch('/professionals/me', profesional!.toDocument());
} catch (e) {
NotificationsService.showSnackbar('Error al guardar: $e');
return false;
}
NotificationsService.showSnackbar('Información actualizada'); NotificationsService.showSnackbar('Información actualizada');
return true; return true;
} }
@@ -79,14 +91,31 @@ class ProfessionalFormProvider with ChangeNotifier {
Future<bool> updateProfesionalProfileInfo(String userId) async { Future<bool> updateProfesionalProfileInfo(String userId) async {
if (!_validProfileForm()) return false; if (!_validProfileForm()) return false;
try {
await _api.patch('/professionals/me', profesional!.toDocument()); await _api.patch('/professionals/me', profesional!.toDocument());
} catch (e) {
NotificationsService.showSnackbar('Error al guardar: $e');
return false;
}
NotificationsService.showSnackbar('Información actualizada'); NotificationsService.showSnackbar('Información actualizada');
return true; return true;
} }
Future<bool> updateProfesionalProfileScheduleInfo(String userId) async { Future<bool> updateProfesionalProfileScheduleInfo(String userId) async {
await _api.patch('/professionals/me', profesional!.toDocument()); try {
NotificationsService.showSnackbar('Información actualizada'); 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;
}
NotificationsService.showSnackbar('Horario actualizado');
return true; return true;
} }
+26 -2
View File
@@ -4,19 +4,27 @@ import 'package:prosapp_web_app/models/payment_method_entity.dart';
import 'package:prosapp_web_app/models/profesional.dart'; import 'package:prosapp_web_app/models/profesional.dart';
import 'package:prosapp_web_app/models/schedules.dart'; import 'package:prosapp_web_app/models/schedules.dart';
import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/services/api_service.dart';
import 'package:prosapp_web_app/services/local_storage.dart';
class ProfessionalProvider extends ChangeNotifier { class ProfessionalProvider extends ChangeNotifier {
Profesional? profesional; Profesional? profesional;
bool _isProModeActive = false; bool _isProModeActive = false;
final _api = ApiService.instance; final _api = ApiService.instance;
static const _kProModeKey = 'isProModeActive';
ProfessionalProvider() {
_isProModeActive = LocalStorage.prefs.getBool(_kProModeKey) ?? false;
}
bool get isProModeActive => _isProModeActive; bool get isProModeActive => _isProModeActive;
Future<Profesional> getProfessional(String uid) async { Future<Profesional> getProfessional(String uid) async {
try { try {
final data = await _api.get('/professionals/$uid'); final data = await _api.get('/professionals/me');
profesional = Profesional.fromDocument(data as Map<String, dynamic>); profesional = Profesional.fromDocument(data as Map<String, dynamic>);
} catch (e) { } catch (_) {
// Professional profile doesn't exist yet — return empty so the form shows blank
profesional = Profesional( profesional = Profesional(
id: uid, id: uid,
identification: '', identification: '',
@@ -43,18 +51,34 @@ class ProfessionalProvider extends ChangeNotifier {
return profesional!; return profesional!;
} }
// Fetches any professional by UUID or user_id via the public endpoint.
// Used by CalendarView to load the target professional (not the logged-in user).
Future<Profesional> getProfessionalById(String id) async {
try {
final data = await _api.get('/professionals/$id');
final prof = Profesional.fromDocument(data as Map<String, dynamic>);
notifyListeners();
return prof;
} catch (_) {
return Profesional.empty();
}
}
void logout() { void logout() {
_isProModeActive = false; _isProModeActive = false;
LocalStorage.prefs.setBool(_kProModeKey, false);
notifyListeners(); notifyListeners();
} }
void toggleProMode() { void toggleProMode() {
_isProModeActive = !_isProModeActive; _isProModeActive = !_isProModeActive;
LocalStorage.prefs.setBool(_kProModeKey, _isProModeActive);
notifyListeners(); notifyListeners();
} }
void setIsProModeActive(bool value) { void setIsProModeActive(bool value) {
_isProModeActive = value; _isProModeActive = value;
LocalStorage.prefs.setBool(_kProModeKey, value);
notifyListeners(); notifyListeners();
} }
+42 -9
View File
@@ -5,24 +5,57 @@ import 'package:prosapp_web_app/services/api_service.dart';
class ProfessionalsProvider extends ChangeNotifier { class ProfessionalsProvider extends ChangeNotifier {
List<UsuarioProfesional> professionals = []; List<UsuarioProfesional> professionals = [];
bool isLoading = true; bool isLoading = true;
String? _city; bool _initialized = false;
String? _currentUserId;
String? _locationCity;
double? _locationLat;
double? _locationLng;
String? _lastSearch;
final _api = ApiService.instance; final _api = ApiService.instance;
void updateCity(String? city) { void updateCity(String? city, {String? userId}) {
if (_city != city) { // Reset state when a different user logs in (prevents cross-user data leak)
_city = city; if (_currentUserId != userId) {
_currentUserId = userId;
_initialized = false;
_locationCity = null;
_locationLat = null;
_locationLng = null;
_lastSearch = null;
professionals = [];
}
if (!_initialized) {
_initialized = true;
_locationCity = city;
getProfessionals(); getProfessionals();
} }
} }
getProfessionals() async { double? get patientLat => _locationLat;
double? get patientLng => _locationLng;
void setLocationContext({required String? city, required double lat, required double lng}) {
if (city != null && city.isNotEmpty) _locationCity = city;
_locationLat = lat;
_locationLng = lng;
getProfessionals(search: _lastSearch);
}
Future<void> getProfessionals({String? search}) async {
if (search != null) _lastSearch = search.trim().isEmpty ? null : search.trim();
isLoading = true; isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final path = (_city != null && _city!.isNotEmpty) final params = <String, String>{};
? '/professionals?city=${Uri.encodeComponent(_city!)}' if (_lastSearch != null && _lastSearch!.isNotEmpty) params['search'] = _lastSearch!;
: '/professionals'; if (_locationCity != null && _locationCity!.isNotEmpty) params['city'] = _locationCity!.trim();
final res = await _api.get(path); if (_locationLat != null) params['lat'] = _locationLat.toString();
if (_locationLng != null) params['lng'] = _locationLng.toString();
final query = params.isEmpty
? ''
: '?${params.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}';
final res = await _api.get('/professionals$query');
final list = (res is Map ? res['data'] : res) as List; final list = (res is Map ? res['data'] : res) as List;
professionals = list professionals = list
.map((e) => UsuarioProfesional.fromDocument(e as Map<String, dynamic>)) .map((e) => UsuarioProfesional.fromDocument(e as Map<String, dynamic>))
+31 -5
View File
@@ -21,7 +21,7 @@ class ServicesProvider extends ChangeNotifier {
Future<void> changeServiceStatus(String serviceId, ServiceStatus newStatus) async { Future<void> changeServiceStatus(String serviceId, ServiceStatus newStatus) async {
try { try {
await _api.patch('/services/$serviceId/status', {'status': enumToIntService(newStatus)}); await _api.patch('/services/$serviceId/status', {'status': enumToStringService(newStatus)});
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('Error al actualizar el estado: $e'); print('Error al actualizar el estado: $e');
@@ -51,14 +51,34 @@ class ServicesProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Blocks a slot on the professional's calendar by creating a self_booked service.
Future<void> blockSlot(String day, TimeOfDay time) async {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
await _api.post('/services/block', {'day': day, 'range1_hour1': '$h:$m'});
}
// Unblocks a previously blocked slot by cancelling the self_booked service.
Future<void> unblockSlot(String serviceId) async {
await _api.patch('/services/$serviceId/status', {'status': 'cancelled'});
}
getServiceForUser(String serviceId) async { getServiceForUser(String serviceId) async {
try { try {
isLoading = true; isLoading = true;
notifyListeners();
final data = await _api.get('/services/$serviceId'); final data = await _api.get('/services/$serviceId');
final map = data as Map<String, dynamic>; final map = data as Map<String, dynamic>;
final servicio = Service.fromJson(map, map['id'] as String); final servicio = Service.fromJson(map, map['id'] as String);
final userData = await _api.get('/users/${servicio.professionalId}'); // findById embeds professionals.users — use it instead of a second /users/:id call
final user = Usuario.fromDocument(userData as Map<String, dynamic>); // (professional_id is a professionals-table UUID, not a user UUID)
final profDoc = map['professionals'] as Map<String, dynamic>?;
final userDoc = profDoc?['users'] as Map<String, dynamic>?;
final user = userDoc != null
? Usuario.fromDocument(userDoc)
: Usuario(id: servicio.professionalId, email: null, phone: null,
name: '?', nickname: null, city: null, picture: null,
birthday: null, gender: null, proState: ProState.inactive, token: null);
service = ServicioProfesional(user: user, service: servicio); service = ServicioProfesional(user: user, service: servicio);
} catch (e) { } catch (e) {
service = null; service = null;
@@ -71,11 +91,17 @@ class ServicesProvider extends ChangeNotifier {
getServiceForProfessional(String serviceId) async { getServiceForProfessional(String serviceId) async {
try { try {
isLoading = true; isLoading = true;
notifyListeners();
final data = await _api.get('/services/$serviceId'); final data = await _api.get('/services/$serviceId');
final map = data as Map<String, dynamic>; final map = data as Map<String, dynamic>;
final servicio = Service.fromJson(map, map['id'] as String); final servicio = Service.fromJson(map, map['id'] as String);
final userData = await _api.get('/users/${servicio.userId}'); // findById embeds users (the patient) directly — use it instead of a second /users/:id call
final user = Usuario.fromDocument(userData as Map<String, dynamic>); final userDoc = map['users'] as Map<String, dynamic>?;
final user = userDoc != null
? Usuario.fromDocument(userDoc)
: Usuario(id: servicio.userId, email: null, phone: null,
name: '?', nickname: null, city: null, picture: null,
birthday: null, gender: null, proState: ProState.inactive, token: null);
service = ServicioProfesional(user: user, service: servicio); service = ServicioProfesional(user: user, service: servicio);
} catch (e) { } catch (e) {
service = null; service = null;
+6 -4
View File
@@ -30,8 +30,10 @@ class ApiService {
throw Exception(body['message'] ?? 'Error ${res.statusCode}'); throw Exception(body['message'] ?? 'Error ${res.statusCode}');
} }
static const _timeout = Duration(seconds: 30);
Future<dynamic> get(String path) async { Future<dynamic> get(String path) async {
final res = await http.get(Uri.parse('$baseUrl$path'), headers: await _headers()); final res = await http.get(Uri.parse('$baseUrl$path'), headers: await _headers()).timeout(_timeout);
return _parse(res); return _parse(res);
} }
@@ -40,7 +42,7 @@ class ApiService {
Uri.parse('$baseUrl$path'), Uri.parse('$baseUrl$path'),
headers: await _headers(), headers: await _headers(),
body: jsonEncode(body), body: jsonEncode(body),
); ).timeout(_timeout);
return _parse(res); return _parse(res);
} }
@@ -49,12 +51,12 @@ class ApiService {
Uri.parse('$baseUrl$path'), Uri.parse('$baseUrl$path'),
headers: await _headers(), headers: await _headers(),
body: jsonEncode(body), body: jsonEncode(body),
); ).timeout(_timeout);
return _parse(res); return _parse(res);
} }
Future<dynamic> delete(String path) async { Future<dynamic> delete(String path) async {
final res = await http.delete(Uri.parse('$baseUrl$path'), headers: await _headers()); final res = await http.delete(Uri.parse('$baseUrl$path'), headers: await _headers()).timeout(_timeout);
return _parse(res); return _parse(res);
} }
+60
View File
@@ -4,6 +4,13 @@ import 'dart:js' as js;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/services/api_service.dart';
// Result of a reverse geocode via the Maps JS SDK Geocoder
class GeocodeResult {
final String formattedAddress;
final List<Map<String, dynamic>> addressComponents;
const GeocodeResult({required this.formattedAddress, required this.addressComponents});
}
class MapsService { class MapsService {
static bool _loaded = false; static bool _loaded = false;
static Completer<bool>? _completer; static Completer<bool>? _completer;
@@ -38,6 +45,59 @@ class MapsService {
} }
} }
// Uses the already-loaded Maps JS SDK Geocoder — no separate Geocoding API enablement needed.
static Future<GeocodeResult?> reverseGeocode(double lat, double lng) async {
final completer = Completer<GeocodeResult?>();
final id = '_gc${DateTime.now().millisecondsSinceEpoch}';
try {
js.context['${id}_ok'] = js.allowInterop((String address, String compsJson) {
try { js.context.deleteProperty('${id}_ok'); } catch (_) {}
try {
final rawList = js.context.callMethod('eval', ['JSON.parse(\'${compsJson.replaceAll("'", "\\'")}\')']);
final comps = <Map<String, dynamic>>[];
// rawList is a JS array; iterate by index
final len = (rawList['length'] as num?)?.toInt() ?? 0;
for (var i = 0; i < len; i++) {
final item = rawList[i] as js.JsObject;
final types = <String>[];
final tLen = (item['types']['length'] as num?)?.toInt() ?? 0;
for (var t = 0; t < tLen; t++) types.add(item['types'][t].toString());
comps.add({'long_name': item['long_name'].toString(), 'types': types});
}
completer.complete(GeocodeResult(formattedAddress: address, addressComponents: comps));
} catch (_) {
completer.complete(GeocodeResult(formattedAddress: address, addressComponents: []));
}
});
js.context['${id}_err'] = js.allowInterop(() {
try { js.context.deleteProperty('${id}_err'); } catch (_) {}
completer.complete(null);
});
js.context.callMethod('eval', ['''
(function(){
try {
var g = new google.maps.Geocoder();
g.geocode({location:{lat:$lat,lng:$lng},language:'es'}, function(r,s){
if(s==='OK'&&r&&r.length>0){
var compsStr = JSON.stringify(r[0].address_components.map(function(c){
return {long_name:c.long_name,types:c.types};
}));
window['${id}_ok'](r[0].formatted_address, compsStr);
} else { window['${id}_err'](); }
});
} catch(e){ window['${id}_err'](); }
})();
''']);
} catch (e) {
completer.complete(null);
}
return completer.future.timeout(const Duration(seconds: 10), onTimeout: () => null);
}
static void _injectScript(String apiKey) { static void _injectScript(String apiKey) {
// Verifica si ya está cargado // Verifica si ya está cargado
final existing = js.context.callMethod('eval', [ final existing = js.context.callMethod('eval', [
+90 -20
View File
@@ -29,13 +29,62 @@ class Sidebar extends StatelessWidget {
return Container( return Container(
width: 220, width: 220,
height: double.infinity, height: double.infinity,
decoration: buildBoxDecoration(), decoration: buildBoxDecoration(professionalProvider.isProModeActive),
child: ListView( child: ListView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
children: [ children: [
const Logo(), const Logo(),
const SizedBox(height: 8), const SizedBox(height: 8),
// Mode indicator pill
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: professionalProvider.isProModeActive
? const Color(0xFF16A34A).withOpacity(0.18)
: const Color(0xFF42A4EF).withOpacity(0.14),
border: Border.all(
color: professionalProvider.isProModeActive
? const Color(0xFF22C55E).withOpacity(0.5)
: const Color(0xFF42A4EF).withOpacity(0.4),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
professionalProvider.isProModeActive
? Icons.work_outline
: Icons.person_outline,
size: 13,
color: professionalProvider.isProModeActive
? const Color(0xFF4ADE80)
: const Color(0xFF7DD3FC),
),
const SizedBox(width: 6),
Text(
professionalProvider.isProModeActive
? 'Modo Profesional'
: 'Modo Usuario',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: professionalProvider.isProModeActive
? const Color(0xFF4ADE80)
: const Color(0xFF7DD3FC),
),
),
],
),
),
),
const SizedBox(height: 4),
if (professionalProvider.isProModeActive) ...[ if (professionalProvider.isProModeActive) ...[
const TextSeparator(text: 'Profesional'), const TextSeparator(text: 'Profesional'),
MenuItem( MenuItem(
@@ -130,9 +179,20 @@ class Sidebar extends StatelessWidget {
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
final missing = <String>[
if (authProvider.user?.name == null || if (authProvider.user?.name == null ||
authProvider.user?.name == '') { authProvider.user?.name == '')
NotificationsService.showSnackBarError('Primero completa tu perfil'); 'nombre',
if (authProvider.user?.city == null ||
authProvider.user?.city == '')
'ciudad',
if (authProvider.user?.phone == null ||
authProvider.user?.phone == '')
'teléfono',
];
if (missing.isNotEmpty) {
NotificationsService.showSnackBarError(
'Completa tu perfil: falta ${missing.join(', ')}');
navigateTo(Flurorouter.profileRoute); navigateTo(Flurorouter.profileRoute);
return; return;
} }
@@ -147,29 +207,40 @@ class Sidebar extends StatelessWidget {
navigateTo(Flurorouter.requestProfessionalRoute); navigateTo(Flurorouter.requestProfessionalRoute);
} }
}, },
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(vertical: 11), padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: LinearGradient(
colors: [Color(0xFF42A4EF), Color(0xFF1565C0)], colors: professionalProvider.isProModeActive
? [const Color(0xFF374151), const Color(0xFF1F2937)]
: [const Color(0xFF42A4EF), const Color(0xFF1565C0)],
), ),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: professionalProvider.isProModeActive
? Border.all(
color: Colors.white.withOpacity(0.1), width: 1)
: null,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
professionalProvider.isProModeActive Icons.swap_horiz,
? Icons.person_outlined color: professionalProvider.isProModeActive
: Icons.work_outline, ? Colors.white70
color: Colors.white, : Colors.white,
size: 16, size: 18,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Modo ${professionalProvider.isProModeActive ? 'Usuario' : 'Profesional'}', professionalProvider.isProModeActive
style: const TextStyle( ? 'Cambiar a Usuario'
color: Colors.white, : 'Cambiar a Profesional',
style: TextStyle(
color: professionalProvider.isProModeActive
? Colors.white70
: Colors.white,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -193,16 +264,15 @@ class Sidebar extends StatelessWidget {
); );
} }
BoxDecoration buildBoxDecoration() => const BoxDecoration( BoxDecoration buildBoxDecoration(bool isProMode) => BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: isProMode
Color(0xFF0D1B3E), ? const [Color(0xFF0A1F18), Color(0xFF061510)]
Color(0xFF0A1628), : const [Color(0xFF0D1B3E), Color(0xFF0A1628)],
],
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: Colors.black38, color: Colors.black38,
blurRadius: 12, blurRadius: 12,
+174 -253
View File
@@ -3,10 +3,10 @@ import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/profesional.dart'; import 'package:prosapp_web_app/models/profesional.dart';
import 'package:prosapp_web_app/models/schedules_entity.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.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_form_provider.dart';
import 'package:prosapp_web_app/providers/professional_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/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/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_extension.dart';
import 'package:prosapp_web_app/utils/time_of_day_utils.dart'; import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
@@ -23,59 +23,67 @@ class CalendarView extends StatefulWidget {
} }
class _CalendarViewState extends State<CalendarView> { class _CalendarViewState extends State<CalendarView> {
List<Service>? _services; // Use local time throughout — UTC dates can shift the calendar date near midnight
DateTime today = DateTime.now(); DateTime _today = DateTime.now();
late int numDay; Profesional? _profesional;
List<Service> _services = [];
bool _loadingPro = true;
bool _loadingServices = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_normalizeDay();
today = DateTime.utc(today.year, today.month, today.day); _fetchProfessional();
numDay = today.weekday;
_fetchProfessionalAndServices();
} }
void _fetchProfessionalAndServices() async { void _normalizeDay() {
final professionalFormProvider = // Strip time component; keep local date only
Provider.of<ProfessionalFormProvider>(context, listen: false); _today = DateTime(_today.year, _today.month, _today.day);
final servicesProvider =
Provider.of<CalendarServicesProvider>(context, listen: false);
final proProvider =
Provider.of<ProfessionalProvider>(context, listen: false);
final professional =
await proProvider.getProfessional(widget.professionalId);
professionalFormProvider.setProfesional(professional);
final services =
await servicesProvider.getServicesForProfessional(professional.id);
setState(() {
_services = services;
});
} }
void _onDaySelected(DateTime day, DateTime focusedDay) { // 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(() { setState(() {
today = day; _today = DateTime(day.year, day.month, day.day);
numDay = today.weekday;
}); });
_fetchProfessionalAndServices(); // Professional data is cached — only re-fetch services
_fetchServices();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<ProfessionalFormProvider>( if (_loadingPro || _profesional == null) {
builder: (context, professionalFormProvider, child) { return const Center(child: CircularProgressIndicator());
if (professionalFormProvider.profesional == null) {
return const Center(
child: CircularProgressIndicator(),
);
} }
final profesional = professionalFormProvider.profesional!;
return ListView( return ListView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
children: [ children: [
@@ -90,21 +98,18 @@ class _CalendarViewState extends State<CalendarView> {
locale: 'es_CO', locale: 'es_CO',
firstDay: DateTime.now(), firstDay: DateTime.now(),
lastDay: DateTime.now().add(const Duration(days: 180)), lastDay: DateTime.now().add(const Duration(days: 180)),
focusedDay: today, focusedDay: _today,
availableGestures: AvailableGestures.all, availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected, onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, today), selectedDayPredicate: (day) => isSameDay(day, _today),
), ),
const Divider(height: 0), const Divider(height: 0),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
horizontal: 15,
vertical: 8,
),
child: Text( child: Text(
DateFormat('dd MMMM yyyy', 'es').format(today), DateFormat('dd MMMM yyyy', 'es').format(_today),
style: const TextStyle( style: const TextStyle(
color: Colors.black, color: Colors.black,
fontSize: 16, fontSize: 16,
@@ -114,11 +119,19 @@ class _CalendarViewState extends State<CalendarView> {
), ),
), ),
const Divider(height: 0), const Divider(height: 0),
if (_loadingServices)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: CircularProgressIndicator(),
)
else
Column( Column(
children: [ children: [
..._rangesItems( ..._rangesItems(
_getScheduleFromNumDay(numDay, profesional), _getScheduleFromNumDay(_today.weekday, _profesional!),
context), context,
stepMinutes: _profesional!.slotDurationMinutes,
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -129,255 +142,163 @@ class _CalendarViewState extends State<CalendarView> {
), ),
], ],
); );
});
} }
ScheduleEntity? _getScheduleFromNumDay( ScheduleEntity? _getScheduleFromNumDay(int numDay, Profesional pro) {
int numDay, Profesional userProfessional) {
switch (numDay) { switch (numDay) {
case 1: case 1: return pro.schedules.monday;
return userProfessional.schedules.monday; case 2: return pro.schedules.tuesday;
case 2: case 3: return pro.schedules.wednesday;
return userProfessional.schedules.tuesday; case 4: return pro.schedules.thursday;
case 3: case 5: return pro.schedules.friday;
return userProfessional.schedules.wednesday; case 6: return pro.schedules.saturday;
case 4: case 7: return pro.schedules.sunday;
return userProfessional.schedules.thursday; default: return null;
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) { List<Widget> _rangesItems(ScheduleEntity? schedule, BuildContext context, {int stepMinutes = 30}) {
if (schedule == null) { if (schedule == null || !schedule.enabled) {
return [ return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
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"),
)
];
} }
final List<TimeOfDay> ranges;
if (schedule.continuousDay) { if (schedule.continuousDay) {
if (schedule.range1Hour1 == null || schedule.range2Hour2 == null || schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) { return [ if (schedule.range1Hour1 == null || schedule.range2Hour2 == null ||
const Padding( schedule.range1Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
padding: EdgeInsets.only(top: 20), return [const Padding(padding: EdgeInsets.only(top: 20), child: Text('No hay horarios disponibles'))];
child: Text("No hay horarios disponibles"),
)
];
} }
ranges = TimeOfDayUtils.genRanges(schedule.range1Hour1!, schedule.range2Hour2!, stepMinutes: stepMinutes);
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range2Hour2!,
);
return rangesItemList(ranges, _services, today, context);
} else { } 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) { if (schedule.range1Hour1 == null || schedule.range1Hour2 == null ||
return [ schedule.range2Hour1 == null || schedule.range2Hour2 == null ||
const Padding( schedule.range1Hour1!.compareTo(schedule.range1Hour2!) >= 0 ||
padding: EdgeInsets.only(top: 20), schedule.range2Hour1!.compareTo(schedule.range2Hour2!) >= 0) {
child: Text("No hay horarios disponibles"), 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),
]; ];
} }
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges( return _rangesItemList(ranges, _services, _today, context);
schedule.range1Hour1!,
schedule.range1Hour2!,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
);
return [
...rangesItemList(ranges1, _services, today, context),
...rangesItemList(ranges2, _services, today, context),
];
}
} }
bool _isHora1Ocupada( // A slot is blocked (self-booked by the professional)
TimeOfDay hora1, List<Service>? events, DateTime selectedDay) { bool _isBlocked(TimeOfDay hora1, List<Service> services, DateTime selectedDay) {
if (events != null) { final dayStr = _dayStr(selectedDay);
for (Service event in events) { return services.any((e) =>
if (selectedDay.toIso8601String().split('T').first == event.day) { e.day == dayStr && e.range1Hour1 == hora1 && e.status == ServiceStatus.selfBooked);
if (hora1 == event.range1Hour1) {
return true;
}
}
}
}
return false;
} }
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,DateTime selectedDay, BuildContext context) { // A slot is occupied by a real patient booking
final currentDateTime = DateTime.now(); // 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) { return ranges.map((time) {
final selectedDateTime = DateTime( // Compare using local time to avoid timezone edge cases
selectedDay.year, final slotDt = DateTime(selectedDay.year, selectedDay.month, selectedDay.day, time.hour, time.minute);
selectedDay.month, final isPast = slotDt.isBefore(now.add(const Duration(hours: 3)));
selectedDay.day, final isBlocked = _isBlocked(time, services, selectedDay);
time.hour, final isOccupied = !isBlocked && _isOccupied(time, services, selectedDay);
time.minute,
);
if (selectedDateTime if (isPast || isBlocked) {
.isBefore(currentDateTime.add(const Duration(hours: 3)))) { return _slotCard(
return Card( time: time,
elevation: 4, circleColor: Colors.grey,
margin: const EdgeInsets.only(top: 15, left: 10, right: 10), icon: isBlocked ? Icons.block : Icons.access_time,
shape: RoundedRectangleBorder( label: 'No disponible',
borderRadius: BorderRadius.circular(10), labelColor: Colors.red,
), onTap: null,
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 (_isHora1Ocupada(time, events, selectedDay)) { if (isOccupied) {
return Card( return _slotCard(
elevation: 4, time: time,
margin: const EdgeInsets.only(top: 15, left: 10, right: 10), circleGradient: const LinearGradient(
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], colors: [Colors.yellow, Colors.red, Colors.red],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
shape: BoxShape.circle, icon: Icons.access_time,
), label: 'Ocupado',
child: const Center( labelColor: Colors.red,
child: Icon( onTap: null,
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, return _slotCard(
margin: const EdgeInsets.only(top: 15, left: 10, right: 10), time: time,
shape: RoundedRectangleBorder( circleGradient: const LinearGradient(
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], colors: [Colors.blue, Colors.green],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, 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, shape: BoxShape.circle,
), ),
child: const Center( child: Center(child: Icon(icon, color: Colors.white)),
child: Icon(
Icons.access_time,
color: Colors.white,
),
),
), ),
title: Text( title: Text(
ScheduleEntity.getFormatTime(time) ?? '', ScheduleEntity.getFormatTime(time) ?? '',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
subtitle: const Text(
'Disponible',
style: TextStyle( style: TextStyle(
color: Colors.green, fontSize: 15,
fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: onTap == null ? Colors.grey : Colors.black,
), ),
), ),
subtitle: Text(
label,
style: TextStyle(color: labelColor, fontSize: 13, fontWeight: FontWeight.bold),
),
), ),
); );
} }
}).toList();
}
} }
+373 -72
View File
@@ -7,19 +7,19 @@ import 'package:http/http.dart' as http;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/schedules_entity.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.dart';
import 'package:prosapp_web_app/models/service_location_preferences.dart'; import 'package:prosapp_web_app/models/location_preferences.dart';
import 'package:prosapp_web_app/models/service_status.dart'; import 'package:prosapp_web_app/models/service_status.dart';
import 'package:prosapp_web_app/models/usuario.dart'; import 'package:prosapp_web_app/models/usuario.dart';
import 'package:prosapp_web_app/models/usuario_profesional.dart'; import 'package:prosapp_web_app/models/usuario_profesional.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/cities_provider.dart'; import 'package:prosapp_web_app/providers/cities_provider.dart';
import 'package:prosapp_web_app/providers/professionals_provider.dart';
import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/providers/theme_provider.dart';
import 'package:prosapp_web_app/router/router.dart'; import 'package:prosapp_web_app/router/router.dart';
import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/services/api_service.dart';
import 'package:prosapp_web_app/services/maps_service.dart'; import 'package:prosapp_web_app/services/maps_service.dart';
import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:prosapp_web_app/services/notifications_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:prosapp_web_app/utils/local_notifications.dart';
import 'package:prosapp_web_app/utils/network_utility.dart'; import 'package:prosapp_web_app/utils/network_utility.dart';
import 'package:prosapp_web_app/utils/time_of_day_extension.dart'; import 'package:prosapp_web_app/utils/time_of_day_extension.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -36,7 +36,7 @@ class _DashboardViewState extends State<DashboardView> {
// Map // Map
GoogleMapController? _mapController; GoogleMapController? _mapController;
LatLng _mapCenter = const LatLng(4.6097, -74.0817); LatLng _mapCenter = const LatLng(6.2442, -75.5812); // Medellín — centro geográfico de Colombia como fallback neutro
bool _mapsReady = false; bool _mapsReady = false;
bool _mapsLoading = true; bool _mapsLoading = true;
bool _geocoding = false; bool _geocoding = false;
@@ -54,13 +54,20 @@ class _DashboardViewState extends State<DashboardView> {
DateTime? _selectedDay; DateTime? _selectedDay;
TimeOfDay? _selectedHour; TimeOfDay? _selectedHour;
bool _requesting = false; bool _requesting = false;
// For professionals that accept both office and delivery, the patient chooses.
// true = delivery, false = office. Meaningless when professional only accepts one.
bool _bookAsDelivery = false;
// Throttle reverse geocode on camera idle // Throttle reverse geocode on camera idle
DateTime _lastGeocode = DateTime(0); DateTime _lastGeocode = DateTime(0);
// Ciudad detectada y disponibilidad // Indica si _mapCenter ya fue fijado por GPS o geocoding (no el default)
bool _mapPositioned = false;
// Ciudad detectada, disponibilidad y coincidencia con perfil
String _detectedCity = ''; String _detectedCity = '';
bool _cityAvailable = true; bool _cityAvailable = true;
bool _cityMismatch = false;
@override @override
void initState() { void initState() {
@@ -76,6 +83,8 @@ class _DashboardViewState extends State<DashboardView> {
final data = await ApiService.instance.get('/settings/maps-key'); final data = await ApiService.instance.get('/settings/maps-key');
_mapsApiKey = data['api_key'] as String? ?? ''; _mapsApiKey = data['api_key'] as String? ?? '';
} catch (_) {} } catch (_) {}
// Pre-position map on user's city so autocomplete bias is correct from the start
await _centerOnUserCity();
setState(() { setState(() {
_mapsReady = ok; _mapsReady = ok;
_mapsLoading = false; _mapsLoading = false;
@@ -86,14 +95,14 @@ class _DashboardViewState extends State<DashboardView> {
Future<void> _detectLocation() async { Future<void> _detectLocation() async {
try { try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return; if (!serviceEnabled) { await _centerOnUserCity(); return; }
LocationPermission permission = await Geolocator.checkPermission(); LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission(); permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return; if (permission == LocationPermission.denied) { await _centerOnUserCity(); return; }
} }
if (permission == LocationPermission.deniedForever) return; if (permission == LocationPermission.deniedForever) { await _centerOnUserCity(); return; }
final pos = await Geolocator.getCurrentPosition( final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(
@@ -103,44 +112,106 @@ class _DashboardViewState extends State<DashboardView> {
); );
final latlng = LatLng(pos.latitude, pos.longitude); final latlng = LatLng(pos.latitude, pos.longitude);
if (mounted) setState(() => _mapCenter = latlng); if (mounted) setState(() { _mapCenter = latlng; _mapPositioned = true; });
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(latlng, 16)); _mapController?.animateCamera(CameraUpdate.newLatLngZoom(latlng, 16));
_reverseGeocode(latlng); _reverseGeocode(latlng);
} catch (_) {} } catch (_) {
await _centerOnUserCity();
}
}
static const Map<String, LatLng> _colombiaCityCoords = {
'bucaramanga': LatLng(7.1198, -73.1227),
'bogota': LatLng(4.7110, -74.0721),
'medellin': LatLng(6.2442, -75.5812),
'cali': LatLng(3.4516, -76.5320),
'barranquilla': LatLng(10.9639, -74.7964),
'cartagena': LatLng(10.3932, -75.4832),
'cucuta': LatLng(7.8939, -72.5078),
'pereira': LatLng(4.8087, -75.6906),
'manizales': LatLng(5.0703, -75.5138),
'santa marta': LatLng(11.2408, -74.1990),
'ibague': LatLng(4.4389, -75.2322),
'villavicencio': LatLng(4.1420, -73.6266),
'pasto': LatLng(1.2136, -77.2811),
'monteria': LatLng(8.7575, -75.8845),
'neiva': LatLng(2.9273, -75.2819),
'armenia': LatLng(4.5339, -75.6811),
'sincelejo': LatLng(9.3047, -75.3978),
'tunja': LatLng(5.5353, -73.3678),
'floridablanca': LatLng(7.0640, -73.0868),
'giron': LatLng(7.0730, -73.1701),
'piedecuesta': LatLng(6.9907, -73.0494),
'soledad': LatLng(10.9200, -74.7647),
'bello': LatLng(6.3367, -75.5572),
'soacha': LatLng(4.5797, -74.2172),
'buenaventura': LatLng(3.8833, -77.0311),
'valledupar': LatLng(10.4779, -73.2536),
'palmira': LatLng(3.5394, -76.3035),
'popayan': LatLng(2.4448, -76.6147),
'riohacha': LatLng(11.5444, -72.9072),
'quibdo': LatLng(5.6940, -76.6583),
};
LatLng? _cityFallbackCoords(String city) {
String norm(String s) => s.toLowerCase().trim()
.replaceAll(RegExp(r'[áà]'), 'a').replaceAll(RegExp(r'[éè]'), 'e')
.replaceAll(RegExp(r'[íì]'), 'i').replaceAll(RegExp(r'[óò]'), 'o')
.replaceAll(RegExp(r'[úù]'), 'u').replaceAll('.', '').replaceAll(',', '');
final normalized = norm(city);
for (final entry in _colombiaCityCoords.entries) {
if (normalized == entry.key || normalized.startsWith('${entry.key} ') || entry.key.startsWith('$normalized ')) {
return entry.value;
}
}
return null;
}
Future<void> _centerOnUserCity() async {
final city = user?.city;
if (city == null || city.isEmpty) return;
// Apply hardcoded fallback immediately so the map never loads on the wrong city
final fallback = _cityFallbackCoords(city);
if (fallback != null && mounted) {
setState(() { _mapCenter = fallback; _mapPositioned = true; });
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(fallback, 14));
}
// Then try geocoding for a more precise position
await _geocodeAndMoveMap(city);
} }
Future<void> _reverseGeocode(LatLng pos) async { Future<void> _reverseGeocode(LatLng pos) async {
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
final now = DateTime.now(); final now = DateTime.now();
if (now.difference(_lastGeocode).inMilliseconds < 800) return; if (now.difference(_lastGeocode).inMilliseconds < 800) return;
_lastGeocode = now; _lastGeocode = now;
setState(() => _geocoding = true); if (mounted) setState(() => _geocoding = true);
try { try {
final res = await http.get(Uri.parse( // Use Maps JS SDK Geocoder — works with the same API key as the map,
'https://maps.googleapis.com/maps/api/geocode/json' // no separate Geocoding API billing required
'?latlng=${pos.latitude},${pos.longitude}' final result = await MapsService.reverseGeocode(pos.latitude, pos.longitude);
'&key=$_mapsApiKey&language=es', if (result != null && mounted) {
)); final city = _extractCity(result.addressComponents);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final results = data['results'] as List?;
if (results != null && results.isNotEmpty) {
final address = results[0]['formatted_address'] as String;
final components = results[0]['address_components'] as List? ?? [];
final city = _extractCity(components);
final citiesProvider = Provider.of<CitiesProvider>(context, listen: false); final citiesProvider = Provider.of<CitiesProvider>(context, listen: false);
final available = city.isEmpty || citiesProvider.isCityAvailable(city); final available = city.isEmpty || citiesProvider.isCityAvailable(city);
final userCity = user?.city ?? '';
final mismatch = userCity.isNotEmpty && city.isNotEmpty && !_citiesMatch(city, userCity);
if (mounted) {
setState(() { setState(() {
_currentAddress = address; _currentAddress = result.formattedAddress;
_searchController.text = address; _searchController.text = result.formattedAddress;
_detectedCity = city; _detectedCity = city;
_cityAvailable = available; _cityAvailable = available;
_cityMismatch = mismatch;
}); });
} if (!mismatch && city.isNotEmpty) {
context.read<ProfessionalsProvider>().setLocationContext(
city: city,
lat: pos.latitude,
lng: pos.longitude,
);
} }
} }
} catch (_) {} } catch (_) {}
@@ -157,6 +228,19 @@ class _DashboardViewState extends State<DashboardView> {
return ''; return '';
} }
bool _citiesMatch(String a, String b) {
String normalize(String s) => s.toLowerCase().trim()
.replaceAll(RegExp(r'[áà]'), 'a')
.replaceAll(RegExp(r'[éè]'), 'e')
.replaceAll(RegExp(r'[íì]'), 'i')
.replaceAll(RegExp(r'[óò]'), 'o')
.replaceAll(RegExp(r'[úù]'), 'u');
final na = normalize(a);
final nb = normalize(b);
// Exact match, or one is a full-word prefix of the other (handles "Bogotá D.C." vs "Bogotá")
return na == nb || na.startsWith('$nb ') || nb.startsWith('$na ');
}
Future<void> _geocodeAndMoveMap(String address) async { Future<void> _geocodeAndMoveMap(String address) async {
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return; if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
try { try {
@@ -174,7 +258,7 @@ class _DashboardViewState extends State<DashboardView> {
(loc['lat'] as num).toDouble(), (loc['lat'] as num).toDouble(),
(loc['lng'] as num).toDouble(), (loc['lng'] as num).toDouble(),
); );
if (mounted) setState(() => _mapCenter = pos); if (mounted) setState(() { _mapCenter = pos; _mapPositioned = true; });
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(pos, 16)); _mapController?.animateCamera(CameraUpdate.newLatLngZoom(pos, 16));
} }
} }
@@ -188,10 +272,16 @@ class _DashboardViewState extends State<DashboardView> {
return; return;
} }
_debounce = Timer(const Duration(milliseconds: 450), () async { _debounce = Timer(const Duration(milliseconds: 450), () async {
final uri = Uri.https('admin.prosapp.co', '/autocomplete', { // Bias toward user's city: if map not yet positioned, append city to query
'input': value, // and skip the coordinates bias (which would be a wrong default location)
'location': '${_mapCenter.latitude},${_mapCenter.longitude}', final userCity = user?.city;
}); final cityMissing = userCity != null &&
userCity.isNotEmpty &&
!value.toLowerCase().contains(userCity.toLowerCase());
final input = cityMissing ? '$value $userCity' : value;
final params = <String, String>{'input': input};
if (_mapPositioned) params['location'] = '${_mapCenter.latitude},${_mapCenter.longitude}';
final uri = Uri.https('admin.prosapp.co', '/autocomplete', params);
final response = await NetworkUtility.fetchUrl(uri); final response = await NetworkUtility.fetchUrl(uri);
if (response != null && mounted) { if (response != null && mounted) {
final decoded = jsonDecode(response); final decoded = jsonDecode(response);
@@ -211,23 +301,42 @@ class _DashboardViewState extends State<DashboardView> {
_geocodeAndMoveMap(address); _geocodeAndMoveMap(address);
} }
Future<void> _selectProfessionalFromMarker(UsuarioProfesional prof) async {
try {
final result = await NavigationService.navigateToFuture(
'/dashboard/calendar/${prof.user.id}',
);
if (result is List && result.length >= 2) {
setState(() {
_professional = prof;
_selectedDay = result[0] as DateTime;
_selectedHour = result[1] as TimeOfDay;
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
});
}
} catch (_) {}
}
Future<void> _selectProfessional() async { Future<void> _selectProfessional() async {
try { try {
// Pass current location context to professionals list before navigating
context.read<ProfessionalsProvider>().setLocationContext(
city: _detectedCity.isNotEmpty ? _detectedCity : user?.city,
lat: _mapCenter.latitude,
lng: _mapCenter.longitude,
);
final result = await NavigationService.navigateToFuture(Flurorouter.professionalsRoute); final result = await NavigationService.navigateToFuture(Flurorouter.professionalsRoute);
final prof = result[0] as UsuarioProfesional; final prof = result[0] as UsuarioProfesional;
setState(() { setState(() {
_professional = prof; _professional = prof;
_selectedDay = result[1]; _selectedDay = result[1];
_selectedHour = result[2]; _selectedHour = result[2];
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
}); });
} catch (_) {} } catch (_) {}
} }
Future<void> _requestService() async { Future<void> _requestService() async {
if (_currentAddress.isEmpty) {
NotificationsService.showSnackBarError('Mueve el mapa para seleccionar tu dirección');
return;
}
if (_professional == null) { if (_professional == null) {
NotificationsService.showSnackBarError('Selecciona un profesional'); NotificationsService.showSnackBarError('Selecciona un profesional');
return; return;
@@ -236,36 +345,39 @@ class _DashboardViewState extends State<DashboardView> {
NotificationsService.showSnackBarError('Selecciona fecha y hora'); NotificationsService.showSnackBarError('Selecciona fecha y hora');
return; return;
} }
final profPrefs = _professional!.professionalInfo.locationPreferences;
// Determine if this booking is for office or delivery
final bool isOfficeBooking = profPrefs == LocationPreferences.office ||
(profPrefs == LocationPreferences.both && !_bookAsDelivery);
// Dirección solo requerida cuando el profesional hace domicilios
if (!isOfficeBooking && _currentAddress.isEmpty) {
NotificationsService.showSnackBarError('Mueve el mapa para seleccionar tu dirección');
return;
}
setState(() => _requesting = true); setState(() => _requesting = true);
try { try {
final service = Service( final slot = _selectedHour!;
id: null, final endSlot = slot.add(minute: _professional!.professionalInfo.slotDurationMinutes);
professionalId: _professional!.user.id, String pad(int n) => n.toString().padLeft(2, '0');
professionalScored: false,
userId: user!.id, // Build payload matching CreateServiceDto exactly
userScored: false, final payload = {
address: _currentAddress, 'professional_id': _professional!.user.id,
aditionalAddress: '', // @IsDateString() expects ISO 8601 date — "yyyy-MM-dd"
latitude: _mapCenter.latitude, 'day': '${_selectedDay!.year}-${pad(_selectedDay!.month)}-${pad(_selectedDay!.day)}',
longitude: _mapCenter.longitude, 'range1_hour1': '${pad(slot.hour)}:${pad(slot.minute)}',
day: _selectedDay.toString(), 'range1_hour2': '${pad(endSlot.hour)}:${pad(endSlot.minute)}',
createdAt: DateTime.now().toIso8601String(), 'address': isOfficeBooking ? _professional!.professionalInfo.address : _currentAddress,
description: '', 'latitude': isOfficeBooking ? _professional!.professionalInfo.latitude : _mapCenter.latitude,
range1Hour1: _selectedHour!, 'longitude': isOfficeBooking ? _professional!.professionalInfo.longitude : _mapCenter.longitude,
range1Hour2: _selectedHour!.add(hour: 2), // @IsEnum(['office','delivery']) expects the string value, not an integer
rate: '', 'location_preference': isOfficeBooking ? 'office' : 'delivery',
status: ServiceStatus.pending, };
location: ServiceLocationPreferences.delivery, await ApiService.instance.post('/services', payload);
);
await ApiService.instance.post('/services', service.toDocument());
NotificationsService.showSnackbar('Servicio solicitado exitosamente'); NotificationsService.showSnackbar('Servicio solicitado exitosamente');
if (_professional!.user.token != null) {
LocalNotifications.sendPushNotification(
_professional!.user.token!,
'Nuevo servicio',
'Tienes una nueva solicitud de servicio pendiente',
);
}
setState(() { setState(() {
_professional = null; _professional = null;
_selectedDay = null; _selectedDay = null;
@@ -301,7 +413,34 @@ class _DashboardViewState extends State<DashboardView> {
final hintColor = isDark ? Colors.white38 : Colors.grey; final hintColor = isDark ? Colors.white38 : Colors.grey;
final inputFill = isDark ? const Color(0xFF0F172A) : Colors.white; final inputFill = isDark ? const Color(0xFF0F172A) : Colors.white;
return GestureDetector( // Marcadores de consultorios: profesionales con coordenadas reales y atención presencial
final professionalsProvider = context.watch<ProfessionalsProvider>();
final officeMarkers = <Marker>{};
for (final prof in professionalsProvider.professionals) {
final lat = prof.professionalInfo.latitude;
final lng = prof.professionalInfo.longitude;
if (lat == 0.0 && lng == 0.0) continue;
final prefs = prof.professionalInfo.locationPreferences;
if (prefs == LocationPreferences.delivery) continue;
officeMarkers.add(Marker(
markerId: MarkerId(prof.professionalInfo.id),
position: LatLng(lat, lng),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure),
infoWindow: InfoWindow(
title: prof.user.name,
snippet: [
if (prof.professionalInfo.profession.isNotEmpty) prof.professionalInfo.profession,
if (prof.professionalInfo.address.isNotEmpty) prof.professionalInfo.address,
].join(''),
),
onTap: () => _selectProfessionalFromMarker(prof),
));
}
return MediaQuery(
// Prevent keyboard from shifting the map+card layout on tablets
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
child: GestureDetector(
onTap: () { onTap: () {
_searchFocus.unfocus(); _searchFocus.unfocus();
setState(() => _suggestions = []); setState(() => _suggestions = []);
@@ -318,6 +457,7 @@ class _DashboardViewState extends State<DashboardView> {
myLocationEnabled: true, myLocationEnabled: true,
myLocationButtonEnabled: false, myLocationButtonEnabled: false,
zoomControlsEnabled: false, zoomControlsEnabled: false,
markers: officeMarkers,
) )
else else
Container( Container(
@@ -400,6 +540,8 @@ class _DashboardViewState extends State<DashboardView> {
setState(() { setState(() {
_suggestions = []; _suggestions = [];
_currentAddress = ''; _currentAddress = '';
_detectedCity = '';
_cityMismatch = false;
}); });
}, },
) )
@@ -508,9 +650,122 @@ class _DashboardViewState extends State<DashboardView> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Dirección actual // Tipo de servicio (solo cuando el profesional acepta ambas modalidades)
if (_currentAddress.isNotEmpty) if (_professional?.professionalInfo.locationPreferences == LocationPreferences.both) ...[
Container( Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _bookAsDelivery = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: !_bookAsDelivery
? const Color(0xFF42A4EF)
: (isDark ? const Color(0xFF1E293B) : Colors.grey[100]),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: !_bookAsDelivery
? const Color(0xFF42A4EF)
: cardBorder,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.store_outlined, size: 14,
color: !_bookAsDelivery ? Colors.white : subtextColor),
const SizedBox(width: 5),
Text('Consultorio',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: !_bookAsDelivery ? Colors.white : subtextColor)),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _bookAsDelivery = true),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: _bookAsDelivery
? const Color(0xFF42A4EF)
: (isDark ? const Color(0xFF1E293B) : Colors.grey[100]),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _bookAsDelivery
? const Color(0xFF42A4EF)
: cardBorder,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.home_outlined, size: 14,
color: _bookAsDelivery ? Colors.white : subtextColor),
const SizedBox(width: 5),
Text('Domicilio',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _bookAsDelivery ? Colors.white : subtextColor)),
],
),
),
),
),
],
),
const SizedBox(height: 10),
],
// Dirección: consultorio del profesional o dirección del cliente
Builder(builder: (context) {
final prefs = _professional?.professionalInfo.locationPreferences;
final isOfficeBooking = prefs == LocationPreferences.office ||
(prefs == LocationPreferences.both && !_bookAsDelivery);
if (isOfficeBooking) {
// Mostrar dirección del consultorio del profesional
final profAddress = _professional?.professionalInfo.address ?? '';
if (profAddress.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF42A4EF).withOpacity(0.08)
: const Color(0xFFF0F8FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF42A4EF).withOpacity(0.25)),
),
child: Row(
children: [
const Icon(Icons.store_outlined, size: 16, color: Color(0xFF42A4EF)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Consultorio: $profAddress',
style: TextStyle(fontSize: 12, color: textColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
return const SizedBox.shrink();
}
// Domicilio: mostrar dirección del cliente
if (_currentAddress.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -534,7 +789,10 @@ class _DashboardViewState extends State<DashboardView> {
), ),
], ],
), ),
), );
}
return const SizedBox.shrink();
}),
// Profesional // Profesional
GestureDetector( GestureDetector(
@@ -618,8 +876,8 @@ class _DashboardViewState extends State<DashboardView> {
const SizedBox(height: 12), const SizedBox(height: 12),
// Aviso ciudad no disponible // Aviso ciudad no disponible (solo aplica cuando el servicio es a domicilio)
if (!_cityAvailable && _detectedCity.isNotEmpty) if (!_cityAvailable && _detectedCity.isNotEmpty && _bookAsDelivery)
Container( Container(
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
@@ -656,10 +914,52 @@ class _DashboardViewState extends State<DashboardView> {
), ),
), ),
// Warning: dirección fuera de la ciudad del usuario (solo en modo domicilio)
if (_cityMismatch && _detectedCity.isNotEmpty && _bookAsDelivery)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFFFEBEE),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFEF9A9A)),
),
child: Row(
children: [
const Icon(Icons.location_off_outlined, color: Color(0xFFC62828), size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dirección fuera de tu ciudad',
style: TextStyle(
color: Color(0xFFC62828),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
Text(
'Solo puedes solicitar servicios en ${user?.city ?? 'tu ciudad'}. Mueve el mapa a una dirección de ${user?.city ?? 'tu ciudad'}.',
style: const TextStyle(color: Color(0xFFC62828), fontSize: 11),
),
],
),
),
],
),
),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_requesting || !_cityAvailable) ? null : _requestService, onPressed: _requesting
? null
: ((!_bookAsDelivery || (_cityAvailable && !_cityMismatch))
? _requestService
: null),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF), backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -686,6 +986,7 @@ class _DashboardViewState extends State<DashboardView> {
), ),
], ],
), ),
),
); );
} }
} }
+417 -295
View File
@@ -2,17 +2,33 @@ import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/profesional.dart'; import 'package:prosapp_web_app/models/profesional.dart';
import 'package:prosapp_web_app/models/schedules_entity.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.dart';
import 'package:prosapp_web_app/models/usuario.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/professional_form_provider.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/professional_provider.dart';
import 'package:prosapp_web_app/models/service_status.dart';
import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart';
import 'package:prosapp_web_app/ui/cards/white_card.dart';
import 'package:prosapp_web_app/utils/time_of_day_utils.dart'; import 'package:prosapp_web_app/utils/time_of_day_utils.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const _kPrimary = Color(0xFF1565C0);
const _kAvailable = Color(0xFF16A34A);
const _kOccupied = Color(0xFFDC2626);
const _kBlocked = Color(0xFFF59E0B);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.07);
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
}
class ProfessionalCalendarView extends StatefulWidget { class ProfessionalCalendarView extends StatefulWidget {
const ProfessionalCalendarView({super.key}); const ProfessionalCalendarView({super.key});
@@ -22,335 +38,441 @@ class ProfessionalCalendarView extends StatefulWidget {
} }
class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> { class _ProfessionalCalendarViewState extends State<ProfessionalCalendarView> {
DateTime today = DateTime.now(); DateTime _selected = DateTime.now();
late int numDay;
List<Service>? _services; List<Service>? _services;
bool _loading = true;
Usuario? user;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
numDay = today.weekday; _load();
_fetchProfessionalAndServices();
} }
void _fetchProfessionalAndServices() async { Future<void> _load() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false); setState(() => _loading = true);
final professionalFormProvider = Provider.of<ProfessionalFormProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final servicesProvider = Provider.of<ServicesProvider>(context, listen: false); final fp = Provider.of<ProfessionalFormProvider>(context, listen: false);
final sp = Provider.of<ServicesProvider>(context, listen: false);
final pro = await Provider.of<ProfessionalProvider>(context, listen: false)
.getProfessional(auth.user!.id);
final proProvider = Provider.of<ProfessionalProvider>(context, listen: false); fp.setProfesional(pro);
// getServicesForProfessional returns void; read from sp.services after it resolves
final professional = await proProvider.getProfessional(authProvider.user!.id); await sp.getServicesForProfessional(pro.id);
professionalFormProvider.setProfesional(professional); if (mounted) setState(() {
_services = sp.services.map((s) => s.service).toList();
final services = await servicesProvider.getServicesForProfessional(professional.id); _loading = false;
setState(() {
_services = services;
user = authProvider.user;
}); });
} }
void _onDaySelected(DateTime day, DateTime focusedDay) { void _onDaySelected(DateTime day, DateTime _) =>
setState(() { setState(() => _selected = day);
today = day;
numDay = today.weekday;
});
_fetchProfessionalAndServices();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<ProfessionalFormProvider>( return Container(
builder: (context, professionalFormProvider, child) { color: context.bg,
if (professionalFormProvider.profesional == null) { child: Consumer<ProfessionalFormProvider>(
return const Center( builder: (context, fp, _) {
child: CircularProgressIndicator(), if (fp.profesional == null || _loading) {
); return const Center(child: CircularProgressIndicator(color: _kPrimary));
} }
final pro = fp.profesional!;
final profesional = professionalFormProvider.profesional!; final sp = Provider.of<ServicesProvider>(context, listen: false);
final schedule = _scheduleFor(_selected.weekday, pro);
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;
return ListView( return ListView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 32),
children: [ children: [
Center( _calendarCard(context),
_dayHeader(context, schedule, slots.length, occupied, blocked),
if (slots.isEmpty)
_emptyState(context)
else
..._slotCards(context, slots, sp, pro.id),
],
);
},
),
);
}
Widget _calendarCard(BuildContext context) {
return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900), constraints: const BoxConstraints(maxWidth: 720),
child: WhiteCard( child: Container(
title: 'Calendario', margin: const EdgeInsets.fromLTRB(16, 20, 16, 0),
child: Column( decoration: BoxDecoration(
children: [ color: context.card,
TableCalendar( borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadow, blurRadius: 16, offset: const Offset(0, 4))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: TableCalendar(
locale: 'es_CO', locale: 'es_CO',
firstDay: DateTime.now(), firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.utc(2030, 3, 14), lastDay: DateTime.utc(2030, 12, 31),
focusedDay: today, focusedDay: _selected,
availableGestures: AvailableGestures.all, availableGestures: AvailableGestures.all,
onDaySelected: _onDaySelected, onDaySelected: _onDaySelected,
selectedDayPredicate: (day) => isSameDay(day, today), selectedDayPredicate: (d) => isSameDay(d, _selected),
calendarStyle: CalendarStyle(
todayDecoration: BoxDecoration(
border: Border.all(color: _kPrimary, width: 2), shape: BoxShape.circle),
todayTextStyle: const TextStyle(color: _kPrimary, fontWeight: FontWeight.w700),
selectedDecoration: const BoxDecoration(color: _kPrimary, shape: BoxShape.circle),
selectedTextStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
weekendTextStyle: TextStyle(color: Colors.red.shade400),
defaultTextStyle: TextStyle(color: context.onSurface),
outsideDaysVisible: false,
), ),
const Divider(height: 0), headerStyle: HeaderStyle(
SizedBox( formatButtonVisible: false,
width: double.infinity, titleCentered: true,
titleTextStyle: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700, color: context.onSurface),
leftChevronIcon: const Icon(Icons.chevron_left, color: _kPrimary),
rightChevronIcon: const Icon(Icons.chevron_right, color: _kPrimary),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: context.muted),
weekendStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFEF4444)),
),
),
),
),
),
);
}
Widget _dayHeader(BuildContext context, ScheduleEntity? schedule, int total, int occupied, int blocked) {
final dayName = DateFormat('EEEE', 'es').format(_selected);
final dateStr = DateFormat('d MMMM yyyy', 'es').format(_selected);
final available = total - occupied - blocked;
final hasSchedule = schedule != null && schedule.enabled;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2))],
),
child: Row(children: [
Container(
width: 48, height: 52,
decoration: BoxDecoration(color: _kPrimary, borderRadius: BorderRadius.circular(12)),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(DateFormat('d').format(_selected),
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w800, height: 1)),
Text(DateFormat('MMM', 'es').format(_selected).toUpperCase(),
style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.w600)),
]),
),
const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_capitalize(dayName),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: context.onSurface)),
Text(dateStr, style: TextStyle(fontSize: 12, color: context.subtle)),
])),
if (hasSchedule && total > 0) ...[
_StatPill(label: '$occupied', sublabel: 'ocupadas', color: _kOccupied),
const SizedBox(width: 8),
_StatPill(label: '$blocked', sublabel: 'bloqueadas', color: _kBlocked),
const SizedBox(width: 8),
_StatPill(label: '$available', sublabel: 'libres', color: _kAvailable),
],
]),
),
),
);
}
List<Widget> _slotCards(BuildContext context, List<TimeOfDay> slots, ServicesProvider sp, String professionalId) {
return slots.map((time) {
final selfBooked = _isSelfBooked(time, _services, _selected);
final occ = !selfBooked && _isOccupied(time, _services, _selected);
final matchService = (occ || selfBooked) ? _serviceFor(time, _services, _selected) : null;
final Color color;
final String label;
final IconData icon;
if (selfBooked) {
color = _kBlocked;
label = 'Bloqueado';
icon = Icons.lock_outline;
} else if (occ) {
color = _kOccupied;
label = 'Ocupado';
icon = Icons.event_busy_outlined;
} else {
color = _kAvailable;
label = 'Disponible';
icon = Icons.event_available_outlined;
}
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 8, offset: const Offset(0, 2))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: IntrinsicHeight(
child: Row(children: [
Container(width: 4, color: color),
Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
horizontal: 15, child: Row(children: [
vertical: 8, Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.08), borderRadius: BorderRadius.circular(8)),
child: Text(ScheduleEntity.getFormatTime(time) ?? '',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800, color: color)),
), ),
child: Text( const SizedBox(width: 14),
DateFormat('dd MMMM yyyy', 'es').format(today), Expanded(child: Column(
style: const TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.black, mainAxisAlignment: MainAxisAlignment.center,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const Divider(height: 0),
Column(
children: [ children: [
..._rangesItems(_getScheduleFromNumDay(numDay, profesional),context), Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color)),
if (matchService != null && matchService.description.isNotEmpty)
Text(matchService.description,
style: TextStyle(fontSize: 12, color: context.muted),
maxLines: 1, overflow: TextOverflow.ellipsis)
else if (!occ && !selfBooked)
Text('Horario libre para nuevas citas',
style: TextStyle(fontSize: 11, color: context.subtle)),
], ],
), )),
], // Action button
), if (selfBooked)
), _ActionButton(
), icon: Icons.lock_open_outlined,
), color: _kBlocked,
], tooltip: 'Desbloquear',
); onTap: () => _confirmUnblock(context, sp, matchService!.id!, time),
},
);
}
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) {
if (schedule == null ||
!schedule.enabled ||
schedule.range1Hour1 == null ||
schedule.range2Hour2 == null) {
return [
const Padding(
padding: EdgeInsets.symmetric(vertical: 25),
child: Text("No hay horarios disponibles"),
) )
]; else if (!occ)
} _ActionButton(
icon: Icons.lock_outline,
if (schedule.continuousDay) { color: context.subtle,
List<TimeOfDay> ranges = TimeOfDayUtils.genRanges( tooltip: 'Bloquear horario',
schedule.range1Hour1!, onTap: () => _confirmBlock(context, sp, time),
schedule.range2Hour2!, )
); else
Container(
return rangesItemList(ranges, _services, today, context); width: 32, height: 32,
} decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(icon, size: 17, color: color),
List<TimeOfDay> ranges1 = TimeOfDayUtils.genRanges(
schedule.range1Hour1!,
schedule.range1Hour2!,
);
List<TimeOfDay> ranges2 = TimeOfDayUtils.genRanges(
schedule.range2Hour1!,
schedule.range2Hour2!,
);
return [
...rangesItemList(ranges1, _services, today, context),
...rangesItemList(ranges2, _services, today, context),
];
}
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) {
return true;
}
}
}
}
return false;
}
List<Widget> rangesItemList(List<TimeOfDay> ranges, List<Service>? events,
DateTime selectedDay, BuildContext context) {
return ranges.map((time) {
if (_isHora1Ocupada(time, events, selectedDay)) {
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 5, horizontal: 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) {
if (event.userId == event.professionalId) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Horario ocupado por ti'),
),
);
} else {
// Navigator.push(
// context,
// CupertinoPageRoute(
// builder: (context) => ProfessionalServiceScreen(
// serviceId: event.id!,
// ),
// ),
// );
}
}
}
}
}
},
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.symmetric(vertical: 5, horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
onTap: () {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Reservar hora'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'¿Estás seguro de que deseas reservar a las ${ScheduleEntity.getFormatTime(time)} del ${DateFormat('dd-MM-yyyy').format(today)}?',
),
const SizedBox(height: 10),
const Text(
'⚠️ Esta acción no se puede deshacer ⚠️',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
},
child: const Text('No, cancelar'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
Navigator.pop(context);
},
child: const Text('Sí, reservar'),
),
],
);
},
);
},
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(); }).toList();
} }
Future<void> _confirmBlock(BuildContext context, ServicesProvider sp, TimeOfDay time) async {
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Bloquear horario'),
content: Text('¿Bloquear el horario de $timeStr? Los usuarios no podrán agendarse en este slot.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: _kBlocked),
child: const Text('Bloquear', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirmed != true) return;
try {
final dayStr = _selected.toIso8601String().split('T').first;
await sp.blockSlot(dayStr, time);
await _load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Future<void> _confirmUnblock(BuildContext context, ServicesProvider sp, String serviceId, TimeOfDay time) async {
final timeStr = ScheduleEntity.getFormatTime(time) ?? '';
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Desbloquear horario'),
content: Text('¿Desbloquear el horario de $timeStr? Estará disponible para nuevas citas.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: _kAvailable),
child: const Text('Desbloquear', style: TextStyle(color: Colors.white)),
),
],
),
);
if (confirmed != true) return;
try {
await sp.unblockSlot(serviceId);
await _load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Widget _emptyState(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 24),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 12, offset: const Offset(0, 2))],
),
child: Column(children: [
Container(
width: 64, height: 64,
decoration: BoxDecoration(
color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.event_busy_outlined, size: 32, color: context.subtle),
),
const SizedBox(height: 16),
Text('Sin horario este día',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
const SizedBox(height: 6),
Text('No tienes horario de atención configurado\npara este día de la semana.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
]),
),
),
);
}
ScheduleEntity? _scheduleFor(int weekday, Profesional pro) => switch (weekday) {
1 => pro.schedules.monday, 2 => pro.schedules.tuesday,
3 => pro.schedules.wednesday, 4 => pro.schedules.thursday,
5 => pro.schedules.friday, 6 => pro.schedules.saturday,
_ => pro.schedules.sunday,
};
List<TimeOfDay> _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!, stepMinutes: stepMinutes);
}
if (s.range1Hour2 == null || s.range2Hour1 == null) return [];
return [
...TimeOfDayUtils.genRanges(s.range1Hour1!, s.range1Hour2!, stepMinutes: stepMinutes),
...TimeOfDayUtils.genRanges(s.range2Hour1!, s.range2Hour2!, stepMinutes: stepMinutes),
];
}
bool _isOccupied(TimeOfDay time, List<Service>? services, DateTime day) {
if (services == null) return false;
final dayStr = day.toIso8601String().split('T').first;
return services.any((s) =>
s.day == dayStr &&
s.range1Hour1 == time &&
s.status != ServiceStatus.selfBooked &&
s.status != ServiceStatus.cancelled &&
s.status != ServiceStatus.denied);
}
bool _isSelfBooked(TimeOfDay time, List<Service>? services, DateTime day) {
if (services == null) return false;
final dayStr = day.toIso8601String().split('T').first;
return services.any((s) =>
s.day == dayStr &&
s.range1Hour1 == time &&
s.status == ServiceStatus.selfBooked);
}
Service? _serviceFor(TimeOfDay time, List<Service>? services, DateTime day) {
if (services == null) return null;
final dayStr = day.toIso8601String().split('T').first;
try {
return services.firstWhere((s) => s.day == dayStr && s.range1Hour1 == time);
} catch (_) { return null; }
}
String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final String tooltip;
final VoidCallback onTap;
const _ActionButton({required this.icon, required this.color, required this.tooltip, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 32, height: 32,
decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(icon, size: 17, color: color),
),
),
);
}
}
class _StatPill extends StatelessWidget {
final String label;
final String sublabel;
final Color color;
const _StatPill({required this.label, required this.sublabel, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(label,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: color, height: 1)),
Text(sublabel,
style: TextStyle(fontSize: 9, color: color.withOpacity(0.8), fontWeight: FontWeight.w600)),
]),
);
}
} }
File diff suppressed because it is too large Load Diff
+116 -9
View File
@@ -1,4 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:prosapp_web_app/models/location_preferences.dart';
import 'package:prosapp_web_app/models/usuario_profesional.dart'; import 'package:prosapp_web_app/models/usuario_profesional.dart';
import 'package:prosapp_web_app/providers/professionals_provider.dart'; import 'package:prosapp_web_app/providers/professionals_provider.dart';
import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/providers/theme_provider.dart';
@@ -14,19 +17,35 @@ class ProfessionalsView extends StatefulWidget {
class _ProfessionalsViewState extends State<ProfessionalsView> { class _ProfessionalsViewState extends State<ProfessionalsView> {
String _search = ''; String _search = '';
Timer? _debounce;
final _controller = TextEditingController();
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
setState(() => _search = value);
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 450), () {
context.read<ProfessionalsProvider>().getProfessionals(search: value);
});
}
void _clearSearch() {
_controller.clear();
_onSearchChanged('');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ProfessionalsProvider>(); final provider = context.watch<ProfessionalsProvider>();
final isDark = context.watch<ThemeProvider>().isDark; final isDark = context.watch<ThemeProvider>().isDark;
final filtered = provider.professionals.where((p) { final filtered = provider.professionals;
if (_search.isEmpty) return true;
final q = _search.toLowerCase();
return p.user.name.toLowerCase().contains(q) ||
p.professionalInfo.profession.toLowerCase().contains(q) ||
(p.user.city ?? '').toLowerCase().contains(q);
}).toList();
final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white; final cardBg = isDark ? const Color(0xFF1E293B) : Colors.white;
final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB); final border = isDark ? const Color(0xFF334155) : const Color(0xFFE5E7EB);
@@ -39,7 +58,8 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 14), padding: const EdgeInsets.fromLTRB(0, 0, 0, 14),
child: TextField( child: TextField(
onChanged: (v) => setState(() => _search = v), controller: _controller,
onChanged: _onSearchChanged,
style: TextStyle( style: TextStyle(
color: isDark ? Colors.white : const Color(0xFF111827), color: isDark ? Colors.white : const Color(0xFF111827),
fontSize: 14, fontSize: 14,
@@ -52,7 +72,7 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
suffixIcon: _search.isNotEmpty suffixIcon: _search.isNotEmpty
? IconButton( ? IconButton(
icon: Icon(Icons.close, size: 18, color: hintColor), icon: Icon(Icons.close, size: 18, color: hintColor),
onPressed: () => setState(() => _search = ''), onPressed: _clearSearch,
) )
: null, : null,
filled: true, filled: true,
@@ -109,6 +129,8 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
cardBg: cardBg, cardBg: cardBg,
border: border, border: border,
compact: cols == 1, compact: cols == 1,
patientLat: provider.patientLat,
patientLng: provider.patientLng,
onTap: () async { onTap: () async {
final result = await NavigationService final result = await NavigationService
.navigateToFuture( .navigateToFuture(
@@ -134,12 +156,31 @@ class _ProfessionalsViewState extends State<ProfessionalsView> {
// ── Card ───────────────────────────────────────────────────────────────────── // ── Card ─────────────────────────────────────────────────────────────────────
double? _haversineKm(double? lat1, double? lng1, double? lat2, double? lng2) {
if (lat1 == null || lng1 == null || lat2 == null || lng2 == null) return null;
if (lat2 == 0.0 && lng2 == 0.0) return null;
const r = 6371.0;
final dLat = (lat2 - lat1) * math.pi / 180;
final dLng = (lng2 - lng1) * math.pi / 180;
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(lat1 * math.pi / 180) * math.cos(lat2 * math.pi / 180) *
math.sin(dLng / 2) * math.sin(dLng / 2);
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
}
String _formatDistance(double km) {
if (km < 1) return '${(km * 1000).round()} m';
return '${km.toStringAsFixed(1)} km';
}
class _ProfCard extends StatelessWidget { class _ProfCard extends StatelessWidget {
final UsuarioProfesional data; final UsuarioProfesional data;
final bool isDark; final bool isDark;
final Color cardBg; final Color cardBg;
final Color border; final Color border;
final bool compact; final bool compact;
final double? patientLat;
final double? patientLng;
final VoidCallback onTap; final VoidCallback onTap;
const _ProfCard({ const _ProfCard({
@@ -149,6 +190,8 @@ class _ProfCard extends StatelessWidget {
required this.border, required this.border,
required this.compact, required this.compact,
required this.onTap, required this.onTap,
this.patientLat,
this.patientLng,
}); });
@override @override
@@ -161,6 +204,12 @@ class _ProfCard extends StatelessWidget {
final score = data.averageScore; final score = data.averageScore;
final pm = data.professionalInfo.paymentMethods; final pm = data.professionalInfo.paymentMethods;
final profLat = data.professionalInfo.latitude;
final profLng = data.professionalInfo.longitude;
final distKm = _haversineKm(patientLat, patientLng, profLat, profLng);
final hasOffice = data.professionalInfo.locationPreferences != LocationPreferences.delivery;
final address = data.professionalInfo.address;
if (compact) { if (compact) {
// Fila horizontal en pantallas pequeñas // Fila horizontal en pantallas pequeñas
return Material( return Material(
@@ -209,7 +258,27 @@ class _ProfCard extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 11, color: textSecondary)), fontSize: 11, color: textSecondary)),
]), ]),
if (distKm != null) ...[
const SizedBox(width: 8),
Icon(Icons.near_me_outlined, size: 11, color: const Color(0xFF42A4EF)),
const SizedBox(width: 2),
Text(_formatDistance(distKm),
style: const TextStyle(fontSize: 11, color: Color(0xFF42A4EF), fontWeight: FontWeight.w600)),
],
]), ]),
if (hasOffice && address.isNotEmpty) ...[
const SizedBox(height: 3),
Row(children: [
Icon(Icons.store_outlined, size: 11, color: textSecondary),
const SizedBox(width: 2),
Flexible(
child: Text(address,
style: TextStyle(fontSize: 11, color: textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
]),
],
], ],
), ),
), ),
@@ -360,6 +429,44 @@ class _ProfCard extends StatelessWidget {
), ),
], ],
// Distancia al consultorio
if (distKm != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.near_me_outlined, size: 11, color: Color(0xFF42A4EF)),
const SizedBox(width: 3),
Text(_formatDistance(distKm),
style: const TextStyle(
fontSize: 11,
color: Color(0xFF42A4EF),
fontWeight: FontWeight.w600)),
],
),
],
// Dirección del consultorio
if (hasOffice && address.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.store_outlined, size: 11, color: textSecondary),
const SizedBox(width: 3),
Flexible(
child: Text(
address,
style: TextStyle(fontSize: 11, color: textSecondary),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
const Spacer(), const Spacer(),
// Métodos de pago // Métodos de pago
+184 -59
View File
@@ -10,6 +10,7 @@ import 'package:prosapp_web_app/providers/professional_provider.dart';
import 'package:prosapp_web_app/providers/professions_provider.dart'; import 'package:prosapp_web_app/providers/professions_provider.dart';
import 'package:prosapp_web_app/providers/profile_form_provider.dart'; import 'package:prosapp_web_app/providers/profile_form_provider.dart';
import 'package:prosapp_web_app/providers/theme_provider.dart'; import 'package:prosapp_web_app/providers/theme_provider.dart';
import 'package:prosapp_web_app/services/api_service.dart';
import 'package:prosapp_web_app/services/notifications_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -65,16 +66,20 @@ class _FormBody extends StatefulWidget {
class _FormBodyState extends State<_FormBody> { class _FormBodyState extends State<_FormBody> {
final _rethusCtrl = TextEditingController(); final _rethusCtrl = TextEditingController();
final _specCtrl = TextEditingController(); final _specCtrl = TextEditingController();
final _cedulaNumCtrl = TextEditingController();
String? _selectedProfession; String? _selectedProfession;
List<String> _specs = []; List<String> _specs = [];
bool _loading = false; bool _loading = false;
bool _cedulaError = false; bool _cedulaError = false;
bool _cedulaNumError = false;
bool _certError = false;
bool _profError = false; bool _profError = false;
@override @override
void dispose() { void dispose() {
_rethusCtrl.dispose(); _rethusCtrl.dispose();
_specCtrl.dispose(); _specCtrl.dispose();
_cedulaNumCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -88,25 +93,30 @@ class _FormBodyState extends State<_FormBody> {
} }
} }
bool _validate(String cedulaUrl) { bool _validate(String cedulaUrl, String certUrl) {
final cErr = cedulaUrl.isEmpty; final cErr = cedulaUrl.isEmpty;
final pErr = final nErr = _cedulaNumCtrl.text.trim().isEmpty;
_selectedProfession == null || _selectedProfession!.isEmpty; final dErr = certUrl.isEmpty;
final pErr = _selectedProfession == null || _selectedProfession!.isEmpty;
setState(() { setState(() {
_cedulaError = cErr; _cedulaError = cErr;
_cedulaNumError = nErr;
_certError = dErr;
_profError = pErr; _profError = pErr;
}); });
return !cErr && !pErr; return !cErr && !nErr && !dErr && !pErr;
} }
Future<void> _submit() async { Future<void> _submit() async {
final fp = context.read<ProfessionalFormProvider>(); final fp = context.read<ProfessionalFormProvider>();
final cedulaUrl = fp.profesional?.identificationPicture ?? ''; final cedulaUrl = fp.profesional?.identificationPicture ?? '';
if (!_validate(cedulaUrl)) return; final certUrl = fp.profesional?.certificatePicture ?? '';
if (!_validate(cedulaUrl, certUrl)) return;
setState(() => _loading = true); setState(() => _loading = true);
try { try {
fp.copyProfesionalWith( fp.copyProfesionalWith(
identification: _cedulaNumCtrl.text.trim(),
profession: _selectedProfession, profession: _selectedProfession,
rethusCode: _rethusCtrl.text.trim(), rethusCode: _rethusCtrl.text.trim(),
specializations: _specs, specializations: _specs,
@@ -151,7 +161,7 @@ class _FormBodyState extends State<_FormBody> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = context.watch<ThemeProvider>().isDark; final isDark = context.watch<ThemeProvider>().isDark;
final fp = context.watch<ProfessionalFormProvider>(); final fp = context.watch<ProfessionalFormProvider>();
final user = context.read<AuthProvider>().user!; final user = context.watch<AuthProvider>().user!;
final professions = context.watch<ProfessionsProvider>().professions; final professions = context.watch<ProfessionsProvider>().professions;
final proStateInt = enumToInt(user.proState); final proStateInt = enumToInt(user.proState);
@@ -193,6 +203,8 @@ class _FormBodyState extends State<_FormBody> {
final cedulaUploaded = final cedulaUploaded =
(fp.profesional?.identificationPicture ?? '').isNotEmpty; (fp.profesional?.identificationPicture ?? '').isNotEmpty;
final certUploaded =
(fp.profesional?.certificatePicture ?? '').isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -228,30 +240,58 @@ class _FormBodyState extends State<_FormBody> {
const SizedBox(height: 16), const SizedBox(height: 16),
// ── 1. Foto / PDF de cédula (obligatorio) ────────────────────────── // ── 1. Cédula (número + foto) ───────────────────────────────────────
_SectionCard( _SectionCard(
cardBg: cardBg, cardBg: cardBg,
border: border, border: border,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_Label('Foto / PDF de cédula', Icons.badge_outlined, _Label('Cédula de ciudadanía', Icons.badge_outlined, required: true),
required: true), const SizedBox(height: 4),
const SizedBox(height: 12), // Tipo fijo
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF334155) : const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border),
),
child: Row(
children: [
Icon(Icons.lock_outline, size: 14, color: textSec),
const SizedBox(width: 6),
Text('Tipo: Cédula de ciudadanía', style: TextStyle(fontSize: 13, color: textSec)),
],
),
),
const SizedBox(height: 10),
// Número
TextField(
controller: _cedulaNumCtrl,
keyboardType: TextInputType.number,
style: TextStyle(color: textPrimary, fontSize: 14),
decoration: InputDecoration(
hintText: 'Número de cédula',
hintStyle: TextStyle(color: textSec, fontSize: 13),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
errorText: _cedulaNumError ? 'Ingresa tu número de cédula' : null,
),
onChanged: (_) { if (_cedulaNumError) setState(() => _cedulaNumError = false); },
),
const SizedBox(height: 10),
// Foto
_UploadTile( _UploadTile(
label: cedulaUploaded label: cedulaUploaded ? 'Foto de cédula subida ✓' : 'Subir foto o PDF de la cédula',
? 'Cédula subida ✓'
: 'Subir foto o PDF de la cédula',
uploaded: cedulaUploaded, uploaded: cedulaUploaded,
onTap: () => _pickAndUpload( onTap: () => _pickAndUpload((b) => fp.uploadPdfIdentification(b, user.id)),
(b) => fp.uploadPdfIdentification(b, user.id)),
), ),
if (_cedulaError) if (_cedulaError)
const Padding( const Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: Text('Debes subir la foto de tu cédula', child: Text('Debes subir la foto de tu cédula',
style: TextStyle( style: TextStyle(color: Colors.redAccent, fontSize: 12)),
color: Colors.redAccent, fontSize: 12)),
), ),
], ],
), ),
@@ -259,7 +299,38 @@ class _FormBodyState extends State<_FormBody> {
const SizedBox(height: 10), const SizedBox(height: 10),
// ── 2. Profesión (obligatorio) ────────────────────────────────────── // ── 2. Diploma / Certificado (obligatorio) ─────────────────────────
_SectionCard(
cardBg: cardBg,
border: border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Label('Diploma o certificado profesional', Icons.school_outlined, required: true),
const SizedBox(height: 4),
Text(
'Sube el diploma, acta de grado o tarjeta profesional',
style: TextStyle(fontSize: 11, color: textSec),
),
const SizedBox(height: 10),
_UploadTile(
label: certUploaded ? 'Documento subido ✓' : 'Subir diploma / certificado (PDF o imagen)',
uploaded: certUploaded,
onTap: () => _pickAndUpload((b) => fp.uploadPdfCertificate(b, user.id)),
),
if (_certError)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Debes subir el diploma o certificado',
style: TextStyle(color: Colors.redAccent, fontSize: 12)),
),
],
),
),
const SizedBox(height: 10),
// ── 4. Profesión (obligatorio) ──────────────────────────────────────
_SectionCard( _SectionCard(
cardBg: cardBg, cardBg: cardBg,
border: border, border: border,
@@ -578,7 +649,7 @@ class _UploadTile extends StatelessWidget {
} }
} }
class _StatusCard extends StatelessWidget { class _StatusCard extends StatefulWidget {
final Color cardBg; final Color cardBg;
final Color border; final Color border;
final Color textPrimary; final Color textPrimary;
@@ -593,26 +664,73 @@ class _StatusCard extends StatelessWidget {
required this.isPending, required this.isPending,
}); });
@override
State<_StatusCard> createState() => _StatusCardState();
}
class _StatusCardState extends State<_StatusCard> {
int _waitDays = 7;
bool _loadingSettings = false;
@override
void initState() {
super.initState();
if (!widget.isPending) _loadWaitDays();
}
Future<void> _loadWaitDays() async {
try {
final data = await ApiService.instance.get('/settings') as Map<String, dynamic>;
final v = data['rejection_wait_days'];
if (v != null) setState(() => _waitDays = (v as num).toInt());
} catch (_) {}
if (mounted) setState(() => _loadingSettings = false);
}
Future<void> _retry() async {
try {
await ApiService.instance.delete('/professionals/me');
} catch (e) {
if (mounted) NotificationsService.showSnackbar('Error al reiniciar solicitud: $e');
return;
}
if (!mounted) return;
await context.read<AuthProvider>().isAuthenticated();
if (!mounted) return;
// Reload professional data so the form shows fresh state
final auth = context.read<AuthProvider>();
context.read<ProfessionalProvider>()
.getProfessional(auth.user!.id)
.then((pro) => context.read<ProfessionalFormProvider>().setProfesional(pro))
.catchError((_) => context.read<ProfessionalFormProvider>().setProfesional(Profesional.empty()));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = isPending final accent = widget.isPending
? const Color(0xFFF59E0B) ? const Color(0xFFF59E0B)
: const Color(0xFFEF4444); : const Color(0xFFEF4444);
final user = context.watch<AuthProvider>().user;
final rejectedAt = user?.proRejectedAt;
final daysSince = rejectedAt != null
? DateTime.now().difference(rejectedAt).inDays
: _waitDays;
final daysLeft = (_waitDays - daysSince).clamp(0, _waitDays);
final canRetry = !widget.isPending && daysLeft == 0;
return Container( return Container(
margin: const EdgeInsets.only(top: 24), margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cardBg, color: widget.cardBg,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: border), border: Border.all(color: widget.border),
), ),
child: Column( child: Column(
children: [ children: [
if (isPending) if (widget.isPending)
const Image( const Image(image: AssetImage('assets/checklist.gif'), width: 160)
image: AssetImage('assets/checklist.gif'),
width: 160)
else else
Container( Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@@ -620,63 +738,70 @@ class _StatusCard extends StatelessWidget {
color: accent.withOpacity(0.1), color: accent.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(Icons.sentiment_dissatisfied_outlined, size: 52, color: accent),
isPending
? Icons.hourglass_empty_rounded
: Icons.sentiment_dissatisfied_outlined,
size: 52,
color: accent),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
Text( Text(
isPending widget.isPending ? 'Solicitud en revisión' : 'Solicitud no aprobada',
? 'Solicitud en revisión' style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold, color: widget.textPrimary),
: 'Solicitud no aprobada',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
color: textPrimary),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
isPending widget.isPending
? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.' ? 'Estamos revisando tus datos. Te notificaremos cuando tu cuenta esté aprobada.'
: 'Tu solicitud no fue aprobada. Revisa tus documentos y vuelve a intentarlo.', : canRetry
? 'Puedes volver a enviar tu solicitud con los documentos corregidos.'
: 'Tu solicitud no fue aprobada. Podrás reintentar en $daysLeft día${daysLeft == 1 ? '' : 's'}.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style: TextStyle(color: widget.textSec, fontSize: 13, height: 1.5),
TextStyle(color: textSec, fontSize: 13, height: 1.5),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!widget.isPending && !_loadingSettings) ...[
if (canRetry)
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Volver a solicitar'),
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF42A4EF)),
)
else
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
horizontal: 14, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: accent.withOpacity(0.12), color: accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: border: Border.all(color: accent.withOpacity(0.4)),
Border.all(color: accent.withOpacity(0.4)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(Icons.timer_outlined, color: accent, size: 16),
isPending
? Icons.hourglass_empty_rounded
: Icons.cancel_outlined,
color: accent,
size: 16,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
isPending ? 'Revisión en proceso' : 'No aprobado', 'Disponible en $daysLeft día${daysLeft == 1 ? '' : 's'}',
style: TextStyle( style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13),
color: accent,
fontWeight: FontWeight.w600,
fontSize: 13),
), ),
], ],
), ),
), ),
] else if (widget.isPending)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accent.withOpacity(0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.hourglass_empty_rounded, color: accent, size: 16),
const SizedBox(width: 6),
Text('Revisión en proceso',
style: TextStyle(color: accent, fontWeight: FontWeight.w600, fontSize: 13)),
],
),
),
], ],
), ),
); );
+165 -95
View File
@@ -1,3 +1,4 @@
import 'package:prosapp_web_app/models/profesional.dart';
import 'package:prosapp_web_app/models/usuario.dart'; import 'package:prosapp_web_app/models/usuario.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/professional_form_provider.dart'; import 'package:prosapp_web_app/providers/professional_form_provider.dart';
@@ -10,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});
@@ -36,8 +94,11 @@ class _ScheduleViewState extends State<ScheduleView> {
profileFormProvider.user = authProvider.user; profileFormProvider.user = authProvider.user;
professionalFormProvider.clear();
proProvider.getProfessional(authProvider.user!.id).then((value) { proProvider.getProfessional(authProvider.user!.id).then((value) {
professionalFormProvider.setProfesional(value); professionalFormProvider.setProfesional(value);
}).catchError((_) {
professionalFormProvider.setProfesional(Profesional.empty());
}); });
profileFormProvider.user = authProvider.user; profileFormProvider.user = authProvider.user;
@@ -82,24 +143,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.monday, schedule: profesional.schedules.monday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.monday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
monday: profesional.schedules.monday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(monday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.monday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(monday: updated));
monday: profesional.schedules.monday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -147,24 +209,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.tuesday, schedule: profesional.schedules.tuesday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.tuesday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
tuesday: profesional.schedules.tuesday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(tuesday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.tuesday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(tuesday: updated));
tuesday: profesional.schedules.tuesday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -212,26 +275,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.wednesday, schedule: profesional.schedules.wednesday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.wednesday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
wednesday: ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
profesional.schedules.wednesday.copyWith( : cur.copyWith(enabled: value);
enabled: value, professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(wednesday: updated));
),
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.wednesday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(wednesday: updated));
wednesday:
profesional.schedules.wednesday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -279,24 +341,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.thursday, schedule: profesional.schedules.thursday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.thursday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
thursday: profesional.schedules.thursday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(thursday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.thursday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(thursday: updated));
thursday: profesional.schedules.thursday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -344,24 +407,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.friday, schedule: profesional.schedules.friday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.friday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
friday: profesional.schedules.friday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(friday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.friday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(friday: updated));
friday: profesional.schedules.friday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -409,24 +473,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.saturday, schedule: profesional.schedules.saturday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.saturday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
saturday: profesional.schedules.saturday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(saturday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.saturday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(saturday: updated));
saturday: profesional.schedules.saturday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -474,24 +539,25 @@ class _ScheduleViewState extends State<ScheduleView> {
schedule: profesional.schedules.sunday, schedule: profesional.schedules.sunday,
onEnableChanged: (value) { onEnableChanged: (value) {
setState(() { setState(() {
professionalFormProvider.copyProfesionalWith( final cur = profesional.schedules.sunday;
schedules: profesional.schedules.copyWith( final updated = (value && cur.range1Hour1 == null)
sunday: profesional.schedules.sunday.copyWith( ? cur.copyWith(enabled: true, continuousDay: true, range1Hour1: const TimeOfDay(hour: 8, minute: 0), range2Hour2: const TimeOfDay(hour: 18, minute: 0))
enabled: value, : cur.copyWith(enabled: value);
), professionalFormProvider.copyProfesionalWith(schedules: profesional.schedules.copyWith(sunday: updated));
),
);
}); });
}, },
onContinuousDayChanged: (value) { onContinuousDayChanged: (value) {
setState(() { setState(() {
final cur = profesional.schedules.sunday;
final updated = !value
? cur.copyWith(
continuousDay: false,
range1Hour2: cur.range1Hour2 ?? const TimeOfDay(hour: 12, minute: 0),
range2Hour1: cur.range2Hour1 ?? const TimeOfDay(hour: 14, minute: 0),
)
: cur.copyWith(continuousDay: true);
professionalFormProvider.copyProfesionalWith( professionalFormProvider.copyProfesionalWith(
schedules: profesional.schedules.copyWith( schedules: profesional.schedules.copyWith(sunday: updated));
sunday: profesional.schedules.sunday.copyWith(
continuousDay: value,
),
),
);
}); });
}, },
onRange1Hour1Pick: (pickedTime) { onRange1Hour1Pick: (pickedTime) {
@@ -531,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(
+27 -21
View File
@@ -16,7 +16,7 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:prosapp_web_app/utils/local_notifications.dart'; import 'package:prosapp_web_app/utils/local_notifications.dart';
class ServiceView extends StatelessWidget { class ServiceView extends StatefulWidget {
final String type; final String type;
final String serviceId; final String serviceId;
@@ -26,19 +26,25 @@ class ServiceView extends StatelessWidget {
required this.serviceId, required this.serviceId,
}); });
@override
State<ServiceView> createState() => _ServiceViewState();
}
class _ServiceViewState extends State<ServiceView> {
@override
void initState() {
super.initState();
final sp = Provider.of<ServicesProvider>(context, listen: false);
if (widget.type == 'user') {
sp.getServiceForUser(widget.serviceId);
} else if (widget.type == 'professional') {
sp.getServiceForProfessional(widget.serviceId);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settingsProvider = Provider.of<SettingsProvider>(context); final settingsProvider = Provider.of<SettingsProvider>(context);
final servicesProvider =
Provider.of<ServicesProvider>(context, listen: false);
if (type == 'user') {
servicesProvider.getServiceForUser(serviceId);
}
if (type == 'professional') {
servicesProvider.getServiceForProfessional(serviceId);
}
return Consumer<ServicesProvider>( return Consumer<ServicesProvider>(
builder: (context, servicesProvider, child) { builder: (context, servicesProvider, child) {
@@ -212,12 +218,12 @@ class ServiceView extends StatelessWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
if (type == 'user') { if (widget.type == 'user') {
NavigationService.navigateTo( NavigationService.navigateTo(
'/dashboard/user/service/${service.id}/chat/${user.id}'); '/dashboard/user/service/${service.id}/chat/${user.id}');
} }
if (type == 'professional') { if (widget.type == 'professional') {
NavigationService.navigateTo( NavigationService.navigateTo(
'/dashboard/professional/service/${service.id}/chat/${user.id}'); '/dashboard/professional/service/${service.id}/chat/${user.id}');
} }
@@ -260,7 +266,7 @@ class ServiceView extends StatelessWidget {
service.range1Hour2.minute, service.range1Hour2.minute,
); );
if (type == 'user') { if (widget.type == 'user') {
if (service.status == ServiceStatus.completed && if (service.status == ServiceStatus.completed &&
service.professionalScored == false) { service.professionalScored == false) {
return Stack( return Stack(
@@ -476,7 +482,7 @@ class ServiceView extends StatelessWidget {
} }
} }
if (type == 'professional') { if (widget.type == 'professional') {
if (service.status == ServiceStatus.completed && if (service.status == ServiceStatus.completed &&
service.userScored == false) { service.userScored == false) {
return Stack( return Stack(
@@ -726,7 +732,7 @@ class ServiceView extends StatelessWidget {
), ),
); );
} else { } else {
if (type == 'professional') { if (widget.type == 'professional') {
if (service.status == ServiceStatus.pending) { if (service.status == ServiceStatus.pending) {
return Column( return Column(
children: [ children: [
@@ -847,7 +853,7 @@ class ServiceView extends StatelessWidget {
} }
} }
if (type == 'user') { if (widget.type == 'user') {
if (service.status == ServiceStatus.pending) { if (service.status == ServiceStatus.pending) {
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 130), constraints: const BoxConstraints(maxWidth: 130),
@@ -925,10 +931,10 @@ class ServiceView extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 130), constraints: const BoxConstraints(maxWidth: 130),
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
if (type == 'professional') { if (widget.type == 'professional') {
navigateTo(Flurorouter.professionalServicesRequestsRoute); navigateTo(Flurorouter.professionalServicesRequestsRoute);
} }
if (type == 'user') { if (widget.type == 'user') {
navigateTo(Flurorouter.dashboardRoute); navigateTo(Flurorouter.dashboardRoute);
} }
}, },
@@ -947,13 +953,13 @@ class ServiceView extends StatelessWidget {
} }
String _CustomServiceLocation(ServiceLocationPreferences location) { String _CustomServiceLocation(ServiceLocationPreferences location) {
if (type == 'user') { if (widget.type == 'user') {
if (location == ServiceLocationPreferences.office) { if (location == ServiceLocationPreferences.office) {
return 'Servicio en sitio / consultorio'; return 'Servicio en sitio / consultorio';
} }
} }
if (type == 'professional') { if (widget.type == 'professional') {
if (location == ServiceLocationPreferences.office) { if (location == ServiceLocationPreferences.office) {
return 'Servicio en tu consultorio'; return 'Servicio en tu consultorio';
} }
+220 -85
View File
@@ -3,138 +3,273 @@ import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/schedules_entity.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.dart';
import 'package:prosapp_web_app/models/service_status.dart'; import 'package:prosapp_web_app/models/service_status.dart';
import 'package:prosapp_web_app/models/servicio_profesional.dart';
import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:prosapp_web_app/services/navigation_service.dart';
import 'package:prosapp_web_app/ui/labels/custom_labels.dart';
import 'package:prosapp_web_app/ui/shared/widgets/status_item.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:prosapp_web_app/providers/auth_provider.dart'; import 'package:prosapp_web_app/providers/auth_provider.dart';
import 'package:prosapp_web_app/providers/services_provider.dart'; import 'package:prosapp_web_app/providers/services_provider.dart';
import 'package:prosapp_web_app/ui/cards/white_card.dart'; const _kPrimary = Color(0xFF1565C0);
extension _Th on BuildContext {
ThemeData get _t => Theme.of(this);
Color get bg => _t.scaffoldBackgroundColor;
Color get card => _t.cardColor;
Color get onSurface => _t.colorScheme.onSurface;
Color get muted => _t.colorScheme.onSurface.withOpacity(0.55);
Color get subtle => _t.colorScheme.onSurface.withOpacity(0.35);
bool get isDark => _t.brightness == Brightness.dark;
Color get shadow => isDark ? Colors.transparent : Colors.black.withOpacity(0.05);
Color get shadowSm => isDark ? Colors.transparent : Colors.black.withOpacity(0.04);
}
class ServicesRequestsView extends StatelessWidget { class ServicesRequestsView extends StatelessWidget {
const ServicesRequestsView({super.key}); const ServicesRequestsView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final servicesProvider = final sp = Provider.of<ServicesProvider>(context, listen: false);
Provider.of<ServicesProvider>(context, listen: false); sp.getServicesRequestsForProfessional(
servicesProvider.getServicesRequestsForProfessional(
Provider.of<AuthProvider>(context, listen: false).user!.id); Provider.of<AuthProvider>(context, listen: false).user!.id);
return Container(
color: context.bg,
child: Consumer<ServicesProvider>(
builder: (context, sp, _) {
if (sp.isLoading) {
return const Center(child: CircularProgressIndicator(color: _kPrimary));
}
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 32),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: Row(children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.08),
borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.inbox_outlined, size: 19, color: _kPrimary),
),
const SizedBox(width: 10),
Text('Solicitudes',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w800, color: context.onSurface)),
const Spacer(),
if (sp.services.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _kPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20)),
child: Text('${sp.services.length}',
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w700, color: _kPrimary)),
),
]),
),
),
),
if (sp.services.isEmpty)
_emptyState(context)
else
...sp.services.map((data) => _RequestCard(data: data)),
],
);
},
),
);
}
Widget _emptyState(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24),
decoration: BoxDecoration(
color: context.card,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: context.shadow, blurRadius: 12, offset: const Offset(0, 2))],
),
child: Column(children: [
Container(
width: 64, height: 64,
decoration: BoxDecoration(color: context.subtle.withOpacity(0.1), shape: BoxShape.circle),
child: Icon(Icons.inbox_outlined, size: 32, color: context.subtle),
),
const SizedBox(height: 16),
Text('Sin solicitudes pendientes',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: context.muted)),
const SizedBox(height: 6),
Text('Cuando un cliente solicite tus servicios\naparecerá aquí.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: context.subtle, height: 1.5)),
]),
),
),
);
}
}
class _RequestCard extends StatelessWidget {
final ServicioProfesional data;
const _RequestCard({required this.data});
@override
Widget build(BuildContext context) {
final service = data.service;
final user = data.user;
final statusInfo = _statusInfo(service.status);
final dateStr = DateFormat('dd MMM yyyy', 'es').format(DateTime.parse(service.day));
final timeStr = ScheduleEntity.getFormatTime(service.range1Hour1) ?? '';
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 900), constraints: const BoxConstraints(maxWidth: 720),
child: Consumer<ServicesProvider>( child: Container(
builder: (context, servicesProvider, child) { margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
if (servicesProvider.isLoading) { decoration: BoxDecoration(
return const Center( color: context.card,
child: CircularProgressIndicator(), borderRadius: BorderRadius.circular(14),
); boxShadow: [BoxShadow(color: context.shadowSm, blurRadius: 10, offset: const Offset(0, 2))],
}
if (servicesProvider.services.isEmpty) {
return ListView(
children: const [
WhiteCard(
child: Center(child: Text('No hay servicios disponibles.')),
), ),
], child: ClipRRect(
); borderRadius: BorderRadius.circular(14),
}
return ListView.builder(
itemCount: servicesProvider.services.length,
itemBuilder: (context, index) {
final data = servicesProvider.services[index];
final image =
(data.user.picture == '' || data.user.picture == null)
? const Image(image: AssetImage('no-image.jpg'))
: FadeInImage.assetNetwork(
placeholder: 'loader.gif',
fit: BoxFit.cover,
image: data.user.picture!,
);
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () => NavigationService.replaceTo(
NavigationService.replaceTo( '/dashboard/professional/service/${service.id}'),
'/dashboard/professional/service/${data.service.id}'); child: IntrinsicHeight(
}, child: Row(children: [
child: WhiteCard( Container(width: 4, color: statusInfo.color),
child: Row(
children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 10), padding: const EdgeInsets.all(14),
child: SizedBox( child: Container(
width: 80, width: 52, height: 52,
height: 80, decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: statusInfo.color.withOpacity(0.3), width: 2)),
child: ClipOval( child: ClipOval(
child: image, child: (user.picture == null || user.picture!.isEmpty)
? Container(
color: _kPrimary.withOpacity(0.1),
child: Center(
child: Text(
user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w700, color: _kPrimary),
),
),
)
: FadeInImage.assetNetwork(
placeholder: 'loader.gif', image: user.picture!, fit: BoxFit.cover),
), ),
), ),
), ),
const SizedBox(width: 20),
Expanded( Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(user.name,
data.user.name, style: TextStyle(
style: CustomLabels.h2, fontSize: 14, fontWeight: FontWeight.w700, color: context.onSurface)),
), if (service.description.isNotEmpty) ...[
if (data.service.description != '') const SizedBox(height: 2),
Text( Text('"${service.description}"',
'"${data.service.description}"', style: TextStyle(
style: CustomLabels.h5, fontSize: 12, color: context.muted, fontStyle: FontStyle.italic),
), maxLines: 1, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 6),
Row(children: [
Icon(Icons.calendar_today_outlined, size: 12, color: context.subtle),
const SizedBox(width: 4),
Text('$dateStr · $timeStr',
style: TextStyle(
fontSize: 12, color: context.muted, fontWeight: FontWeight.w500)),
]),
], ],
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.only(right: 10), padding: const EdgeInsets.fromLTRB(8, 14, 12, 14),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( _StatusBadge(info: statusInfo),
'${ScheduleEntity.getFormatTime(data.service.range1Hour1)} - ${DateFormat('dd MMMM yyyy', 'es').format(DateTime.parse(data.service.day))}', const SizedBox(height: 8),
style: const TextStyle( Icon(Icons.chevron_right, size: 18, color: context.subtle),
color: Colors.black54, fontSize: 16),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 10),
child: customStatus(data.service),
),
], ],
), ),
), ),
], ]),
), ),
), ),
), ),
), ),
);
},
);
},
), ),
), ),
); );
} }
} }
Widget customStatus(Service service) { class _StatusBadge extends StatelessWidget {
if (service.status == ServiceStatus.pending) { final _StatusInfo info;
return const StatusItem(text: 'Solicitud', color: Colors.black45); const _StatusBadge({required this.info});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: info.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: info.color.withOpacity(0.3)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(info.icon, size: 11, color: info.color),
const SizedBox(width: 4),
Text(info.label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: info.color)),
]),
);
} }
return const SizedBox();
} }
class _StatusInfo {
final String label;
final Color color;
final IconData icon;
const _StatusInfo(this.label, this.color, this.icon);
}
_StatusInfo _statusInfo(ServiceStatus status) => switch (status) {
ServiceStatus.pending =>
const _StatusInfo('Pendiente', Color(0xFFD97706), Icons.hourglass_top_outlined),
ServiceStatus.acepted =>
const _StatusInfo('Aceptado', Color(0xFF1565C0), Icons.check_circle_outline),
ServiceStatus.active =>
const _StatusInfo('En curso', Color(0xFF16A34A), Icons.play_circle_outline),
ServiceStatus.completed =>
const _StatusInfo('Completado', Color(0xFF64748B), Icons.task_alt_outlined),
ServiceStatus.denied =>
const _StatusInfo('Rechazado', Color(0xFFDC2626), Icons.cancel_outlined),
ServiceStatus.cancelled =>
const _StatusInfo('Cancelado', Color(0xFF9CA3AF), Icons.remove_circle_outline),
ServiceStatus.selfBooked =>
const _StatusInfo('Reservado', Color(0xFF7C3AED), Icons.bookmark_outline),
};
+1 -1
View File
@@ -31,7 +31,7 @@ class _SetupNameViewState extends State<SetupNameView> {
setState(() { _loading = true; _error = null; }); setState(() { _loading = true; _error = null; });
try { try {
await context.read<AuthProvider>().updateName(name); await context.read<AuthProvider>().updateName(name);
NavigationService.replaceTo(Flurorouter.dashboardRoute); NavigationService.replaceTo(Flurorouter.setupCityRoute);
} catch (_) { } catch (_) {
setState(() => _loading = false); setState(() => _loading = false);
} }
+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;
} }
+9
View File
@@ -89,6 +89,15 @@
}); });
</script> </script>
<script>
// Unregister any previously installed service worker so stale cache is cleared
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(r) { r.unregister(); });
});
}
</script>
<script> <script>
// Force cache busting: append build timestamp so stale JS/CSS is never reused // Force cache busting: append build timestamp so stale JS/CSS is never reused
var _v = new Date().getTime(); var _v = new Date().getTime();