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>
This commit is contained in:
Lizandro Guarnizo
2026-07-21 20:36:00 -05:00
co-authored by Claude Sonnet 4.6
parent e0982995e6
commit f26b3d3c03
2 changed files with 104 additions and 9 deletions
+21 -5
View File
@@ -6,24 +6,40 @@ class ProfessionalsProvider extends ChangeNotifier {
List<UsuarioProfesional> professionals = []; List<UsuarioProfesional> professionals = [];
bool isLoading = true; bool isLoading = true;
bool _initialized = false; bool _initialized = false;
String? _locationCity;
double? _locationLat;
double? _locationLng;
final _api = ApiService.instance; final _api = ApiService.instance;
void updateCity(String? city) { void updateCity(String? city) {
// Only used to trigger the initial load when auth becomes available.
if (!_initialized) { if (!_initialized) {
_initialized = true; _initialized = true;
_locationCity = city;
getProfessionals(); 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<void> getProfessionals({String? search}) async { Future<void> getProfessionals({String? search}) async {
isLoading = true; isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final path = (search != null && search.trim().isNotEmpty) final params = <String, String>{};
? '/professionals?search=${Uri.encodeComponent(search.trim())}' if (search != null && search.trim().isNotEmpty) params['search'] = search.trim();
: '/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>))
+83 -4
View File
@@ -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/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';
@@ -58,9 +59,10 @@ class _DashboardViewState extends State<DashboardView> {
// Throttle reverse geocode on camera idle // Throttle reverse geocode on camera idle
DateTime _lastGeocode = DateTime(0); DateTime _lastGeocode = DateTime(0);
// Ciudad detectada y disponibilidad // 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() {
@@ -132,6 +134,8 @@ class _DashboardViewState extends State<DashboardView> {
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) { if (mounted) {
setState(() { setState(() {
@@ -139,7 +143,15 @@ class _DashboardViewState extends State<DashboardView> {
_searchController.text = address; _searchController.text = address;
_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,
);
}
} }
} }
} }
@@ -157,6 +169,18 @@ 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);
return na.contains(nb) || nb.contains(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 {
@@ -188,8 +212,15 @@ class _DashboardViewState extends State<DashboardView> {
return; return;
} }
_debounce = Timer(const Duration(milliseconds: 450), () async { _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', { final uri = Uri.https('admin.prosapp.co', '/autocomplete', {
'input': value, 'input': input,
'location': '${_mapCenter.latitude},${_mapCenter.longitude}', 'location': '${_mapCenter.latitude},${_mapCenter.longitude}',
}); });
final response = await NetworkUtility.fetchUrl(uri); final response = await NetworkUtility.fetchUrl(uri);
@@ -213,6 +244,12 @@ class _DashboardViewState extends State<DashboardView> {
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(() {
@@ -301,7 +338,10 @@ 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( 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 = []);
@@ -656,10 +696,48 @@ class _DashboardViewState extends State<DashboardView> {
), ),
), ),
// 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( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_requesting || !_cityAvailable) ? null : _requestService, onPressed: (_requesting || !_cityAvailable || _cityMismatch) ? null : _requestService,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF42A4EF), backgroundColor: const Color(0xFF42A4EF),
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -686,6 +764,7 @@ class _DashboardViewState extends State<DashboardView> {
), ),
], ],
), ),
),
); );
} }
} }