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:
co-authored by
Claude Sonnet 4.6
parent
e0982995e6
commit
f26b3d3c03
@@ -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>))
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user