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 = [];
|
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>))
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user