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>
This commit is contained in:
Lizandro Guarnizo
2026-07-22 09:33:33 -05:00
co-authored by Claude Sonnet 4.6
parent 735d8c2ba0
commit 646e44d126
3 changed files with 132 additions and 45 deletions
+16 -4
View File
@@ -69,8 +69,15 @@ class ServicesProvider extends ChangeNotifier {
final data = await _api.get('/services/$serviceId');
final map = data as Map<String, dynamic>;
final servicio = Service.fromJson(map, map['id'] as String);
final userData = await _api.get('/users/${servicio.professionalId}');
final user = Usuario.fromDocument(userData as Map<String, dynamic>);
// findById embeds professionals.users — use it instead of a second /users/:id call
// (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);
} catch (e) {
service = null;
@@ -86,8 +93,13 @@ class ServicesProvider extends ChangeNotifier {
final data = await _api.get('/services/$serviceId');
final map = data as Map<String, dynamic>;
final servicio = Service.fromJson(map, map['id'] as String);
final userData = await _api.get('/users/${servicio.userId}');
final user = Usuario.fromDocument(userData as Map<String, dynamic>);
// findById embeds users (the patient) directly — use it instead of a second /users/:id call
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);
} catch (e) {
service = null;
+99 -30
View File
@@ -8,7 +8,6 @@ import 'package:intl/intl.dart';
import 'package:prosapp_web_app/models/schedules_entity.dart';
import 'package:prosapp_web_app/models/service.dart';
import 'package:prosapp_web_app/models/location_preferences.dart';
import 'package:prosapp_web_app/models/service_location_preferences.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_profesional.dart';
@@ -21,7 +20,6 @@ import 'package:prosapp_web_app/services/api_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/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/time_of_day_extension.dart';
import 'package:provider/provider.dart';
@@ -56,6 +54,9 @@ class _DashboardViewState extends State<DashboardView> {
DateTime? _selectedDay;
TimeOfDay? _selectedHour;
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
DateTime _lastGeocode = DateTime(0);
@@ -310,6 +311,7 @@ class _DashboardViewState extends State<DashboardView> {
_professional = prof;
_selectedDay = result[0] as DateTime;
_selectedHour = result[1] as TimeOfDay;
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
});
}
} catch (_) {}
@@ -329,6 +331,7 @@ class _DashboardViewState extends State<DashboardView> {
_professional = prof;
_selectedDay = result[1];
_selectedHour = result[2];
_bookAsDelivery = prof.professionalInfo.locationPreferences == LocationPreferences.delivery;
});
} catch (_) {}
}
@@ -344,18 +347,16 @@ class _DashboardViewState extends State<DashboardView> {
}
final profPrefs = _professional!.professionalInfo.locationPreferences;
final isOfficeOnly = profPrefs == LocationPreferences.office;
// 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 (!isOfficeOnly && _currentAddress.isEmpty) {
if (!isOfficeBooking && _currentAddress.isEmpty) {
NotificationsService.showSnackBarError('Mueve el mapa para seleccionar tu dirección');
return;
}
final serviceLocation = isOfficeOnly
? ServiceLocationPreferences.office
: ServiceLocationPreferences.delivery;
setState(() => _requesting = true);
try {
final slot = _selectedHour!;
@@ -369,21 +370,14 @@ class _DashboardViewState extends State<DashboardView> {
'day': '${_selectedDay!.year}-${pad(_selectedDay!.month)}-${pad(_selectedDay!.day)}',
'range1_hour1': '${pad(slot.hour)}:${pad(slot.minute)}',
'range1_hour2': '${pad(endSlot.hour)}:${pad(endSlot.minute)}',
'address': isOfficeOnly ? _professional!.professionalInfo.address : _currentAddress,
'latitude': isOfficeOnly ? _professional!.professionalInfo.latitude : _mapCenter.latitude,
'longitude': isOfficeOnly ? _professional!.professionalInfo.longitude : _mapCenter.longitude,
'address': isOfficeBooking ? _professional!.professionalInfo.address : _currentAddress,
'latitude': isOfficeBooking ? _professional!.professionalInfo.latitude : _mapCenter.latitude,
'longitude': isOfficeBooking ? _professional!.professionalInfo.longitude : _mapCenter.longitude,
// @IsEnum(['office','delivery']) expects the string value, not an integer
'location_preference': isOfficeOnly ? 'office' : 'delivery',
'location_preference': isOfficeBooking ? 'office' : 'delivery',
};
await ApiService.instance.post('/services', payload);
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(() {
_professional = null;
_selectedDay = null;
@@ -656,11 +650,88 @@ class _DashboardViewState extends State<DashboardView> {
),
const SizedBox(height: 10),
// Dirección: solo relevante cuando el profesional hace domicilios
// Tipo de servicio (solo cuando el profesional acepta ambas modalidades)
if (_professional?.professionalInfo.locationPreferences == LocationPreferences.both) ...[
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 isOffice = prefs == LocationPreferences.office;
if (isOffice) {
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) {
@@ -692,7 +763,7 @@ class _DashboardViewState extends State<DashboardView> {
}
return const SizedBox.shrink();
}
// Domicilio o ambos: mostrar dirección del cliente
// Domicilio: mostrar dirección del cliente
if (_currentAddress.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -805,9 +876,8 @@ class _DashboardViewState extends State<DashboardView> {
const SizedBox(height: 12),
// Aviso ciudad no disponible
if (!_cityAvailable && _detectedCity.isNotEmpty &&
_professional?.professionalInfo.locationPreferences != LocationPreferences.office)
// Aviso ciudad no disponible (solo aplica cuando el servicio es a domicilio)
if (!_cityAvailable && _detectedCity.isNotEmpty && _bookAsDelivery)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
@@ -845,8 +915,7 @@ class _DashboardViewState extends State<DashboardView> {
),
// Warning: dirección fuera de la ciudad del usuario (solo en modo domicilio)
if (_cityMismatch && _detectedCity.isNotEmpty &&
_professional?.professionalInfo.locationPreferences != LocationPreferences.office)
if (_cityMismatch && _detectedCity.isNotEmpty && _bookAsDelivery)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
@@ -888,9 +957,9 @@ class _DashboardViewState extends State<DashboardView> {
child: ElevatedButton(
onPressed: _requesting
? null
: (_professional?.professionalInfo.locationPreferences == LocationPreferences.office
: ((!_bookAsDelivery || (_cityAvailable && !_cityMismatch))
? _requestService
: (!_cityAvailable || _cityMismatch ? null : _requestService)),
: null),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white,
+17 -11
View File
@@ -16,7 +16,7 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:prosapp_web_app/utils/local_notifications.dart';
class ServiceView extends StatelessWidget {
class ServiceView extends StatefulWidget {
final String type;
final String serviceId;
@@ -26,19 +26,25 @@ class ServiceView extends StatelessWidget {
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
Widget build(BuildContext 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>(
builder: (context, servicesProvider, child) {