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 = [];
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<void> 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 = <String, String>{};
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<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/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<DashboardView> {
// 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<DashboardView> {
final citiesProvider = Provider.of<CitiesProvider>(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<DashboardView> {
_searchController.text = address;
_detectedCity = city;
_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 '';
}
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 {
if (_mapsApiKey == null || _mapsApiKey!.isEmpty) return;
try {
@@ -188,8 +212,15 @@ class _DashboardViewState extends State<DashboardView> {
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<DashboardView> {
Future<void> _selectProfessional() async {
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 prof = result[0] as UsuarioProfesional;
setState(() {
@@ -301,7 +338,10 @@ class _DashboardViewState extends State<DashboardView> {
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<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(
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<DashboardView> {
),
],
),
),
);
}
}