From f26b3d3c035355f9e2e2e046e8e7e8d02ccff66b Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:36:00 -0500 Subject: [PATCH] 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 --- lib/providers/professionals_provider.dart | 26 +++++-- lib/ui/views/dashboard_view.dart | 87 +++++++++++++++++++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/lib/providers/professionals_provider.dart b/lib/providers/professionals_provider.dart index d3f0d6d..36e3d85 100644 --- a/lib/providers/professionals_provider.dart +++ b/lib/providers/professionals_provider.dart @@ -6,24 +6,40 @@ class ProfessionalsProvider extends ChangeNotifier { List professionals = []; bool isLoading = true; bool _initialized = false; + String? _locationCity; + double? _locationLat; + double? _locationLng; final _api = ApiService.instance; void updateCity(String? city) { - // Only used to trigger the initial load when auth becomes available. if (!_initialized) { _initialized = true; + _locationCity = city; getProfessionals(); } } + void setLocationContext({required String? city, required double lat, required double lng}) { + if (city != null && city.isNotEmpty) _locationCity = city; + _locationLat = lat; + _locationLng = lng; + getProfessionals(); + } + Future getProfessionals({String? search}) async { isLoading = true; notifyListeners(); try { - final path = (search != null && search.trim().isNotEmpty) - ? '/professionals?search=${Uri.encodeComponent(search.trim())}' - : '/professionals'; - final res = await _api.get(path); + final params = {}; + if (search != null && search.trim().isNotEmpty) params['search'] = search.trim(); + if (_locationCity != null && _locationCity!.isNotEmpty) params['city'] = _locationCity!.trim(); + 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; professionals = list .map((e) => UsuarioProfesional.fromDocument(e as Map)) diff --git a/lib/ui/views/dashboard_view.dart b/lib/ui/views/dashboard_view.dart index 7a54f25..c5ca888 100644 --- a/lib/ui/views/dashboard_view.dart +++ b/lib/ui/views/dashboard_view.dart @@ -13,6 +13,7 @@ import 'package:prosapp_web_app/models/usuario.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/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/router/router.dart'; import 'package:prosapp_web_app/services/api_service.dart'; @@ -58,9 +59,10 @@ class _DashboardViewState extends State { // Throttle reverse geocode on camera idle DateTime _lastGeocode = DateTime(0); - // Ciudad detectada y disponibilidad + // Ciudad detectada, disponibilidad y coincidencia con perfil String _detectedCity = ''; bool _cityAvailable = true; + bool _cityMismatch = false; @override void initState() { @@ -132,6 +134,8 @@ class _DashboardViewState extends State { final citiesProvider = Provider.of(context, listen: false); final available = city.isEmpty || citiesProvider.isCityAvailable(city); + final userCity = user?.city ?? ''; + final mismatch = userCity.isNotEmpty && city.isNotEmpty && !_citiesMatch(city, userCity); if (mounted) { setState(() { @@ -139,7 +143,15 @@ class _DashboardViewState extends State { _searchController.text = address; _detectedCity = city; _cityAvailable = available; + _cityMismatch = mismatch; }); + if (!mismatch && city.isNotEmpty) { + context.read().setLocationContext( + city: city, + lat: pos.latitude, + lng: pos.longitude, + ); + } } } } @@ -157,6 +169,18 @@ class _DashboardViewState extends State { 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); + return na.contains(nb) || nb.contains(na); + } + Future _geocodeAndMoveMap(String address) async { if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return; try { @@ -188,8 +212,15 @@ class _DashboardViewState extends State { return; } _debounce = Timer(const Duration(milliseconds: 450), () async { + // Bias autocomplete toward user's city when not already mentioned + final userCity = user?.city; + final input = (userCity != null && + userCity.isNotEmpty && + !value.toLowerCase().contains(userCity.toLowerCase())) + ? '$value $userCity' + : value; final uri = Uri.https('admin.prosapp.co', '/autocomplete', { - 'input': value, + 'input': input, 'location': '${_mapCenter.latitude},${_mapCenter.longitude}', }); final response = await NetworkUtility.fetchUrl(uri); @@ -213,6 +244,12 @@ class _DashboardViewState extends State { Future _selectProfessional() async { try { + // Pass current location context to professionals list before navigating + context.read().setLocationContext( + city: _detectedCity.isNotEmpty ? _detectedCity : user?.city, + lat: _mapCenter.latitude, + lng: _mapCenter.longitude, + ); final result = await NavigationService.navigateToFuture(Flurorouter.professionalsRoute); final prof = result[0] as UsuarioProfesional; setState(() { @@ -301,7 +338,10 @@ class _DashboardViewState extends State { final hintColor = isDark ? Colors.white38 : Colors.grey; final inputFill = isDark ? const Color(0xFF0F172A) : Colors.white; - return GestureDetector( + return MediaQuery( + // Prevent keyboard from shifting the map+card layout on tablets + data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero), + child: GestureDetector( onTap: () { _searchFocus.unfocus(); setState(() => _suggestions = []); @@ -656,10 +696,48 @@ class _DashboardViewState extends State { ), ), + // Warning: dirección fuera de la ciudad del usuario + if (_cityMismatch && _detectedCity.isNotEmpty) + 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( width: double.infinity, child: ElevatedButton( - onPressed: (_requesting || !_cityAvailable) ? null : _requestService, + onPressed: (_requesting || !_cityAvailable || _cityMismatch) ? null : _requestService, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF42A4EF), foregroundColor: Colors.white, @@ -686,6 +764,7 @@ class _DashboardViewState extends State { ), ], ), + ), ); } }