From 863546f0fce744717ee8ffb6b294df8282f8b0cf Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:52:01 -0500 Subject: [PATCH] feat: Google Maps location picker in dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocationPickerDialog: detecta ubicación del dispositivo, pin draggable, reverse geocoding para obtener dirección y ciudad - Dashboard: campo de dirección abre el mapa al tocar - index.html: carga Maps JS API (key pendiente de configurar) - pubspec: google_maps_flutter + geolocator Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/views/dashboard_view.dart | 63 ++++--- lib/ui/widgets/location_picker.dart | 268 ++++++++++++++++++++++++++++ pubspec.yaml | 2 + web/index.html | 2 + 4 files changed, 303 insertions(+), 32 deletions(-) create mode 100644 lib/ui/widgets/location_picker.dart diff --git a/lib/ui/views/dashboard_view.dart b/lib/ui/views/dashboard_view.dart index 4d95fea..1103919 100644 --- a/lib/ui/views/dashboard_view.dart +++ b/lib/ui/views/dashboard_view.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:intl/intl.dart'; import 'package:prosapp_web_app/services/api_service.dart'; import 'package:prosapp_web_app/models/schedules_entity.dart'; @@ -15,6 +16,7 @@ import 'package:prosapp_web_app/services/navigation_service.dart'; import 'package:prosapp_web_app/services/notifications_service.dart'; import 'package:prosapp_web_app/ui/cards/white_card.dart'; import 'package:prosapp_web_app/ui/inputs/custom_inputs.dart'; +import 'package:prosapp_web_app/ui/widgets/location_picker.dart'; import 'package:flutter/material.dart'; import 'package:prosapp_web_app/utils/local_notifications.dart'; import 'package:prosapp_web_app/utils/network_utility.dart'; @@ -37,6 +39,8 @@ class _DashboardViewState extends State { String? selectedProfessionalName; DateTime? selectedDay; TimeOfDay? selectedHour; + LatLng? _selectedLatLng; + String? _selectedCity; @override void initState() { @@ -94,6 +98,20 @@ class _DashboardViewState extends State { super.dispose(); } + Future _openLocationPicker() async { + final result = await LocationPickerDialog.show( + context, + initialPosition: _selectedLatLng, + ); + if (result != null && mounted) { + setState(() { + _addressController.text = result.address; + _selectedLatLng = result.position; + _selectedCity = result.city; + }); + } + } + @override Widget build(BuildContext context) { if (user == null) { @@ -194,39 +212,20 @@ class _DashboardViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - isUserComplete() - ? TextFormField( - controller: _addressController, - onChanged: (value) { - if (_debounce?.isActive ?? false) - _debounce?.cancel(); - - _debounce = Timer( - const Duration(milliseconds: 500), () { - String modifiedValue = - value.replaceAll(' ', '_'); - placeAutoComplete(modifiedValue, _coords); - }); - }, - decoration: CustomInputs.formInputDecoration( - hint: 'Ingresa tu dirección', - label: 'Dirección', - icon: Icons.location_on, - ), - ) - : GestureDetector( - onTap: () => NotificationsService.showSnackBarError( - 'Completa tu perfil para solicitar un servicio'), - child: AbsorbPointer( - child: TextFormField( - decoration: CustomInputs.formInputDecoration( - hint: 'Ingresa tu dirección', - label: 'Dirección', - icon: Icons.location_on, - ), - ), - ), + GestureDetector( + onTap: isUserComplete() ? _openLocationPicker : () => + NotificationsService.showSnackBarError('Completa tu perfil para solicitar un servicio'), + child: AbsorbPointer( + child: TextFormField( + controller: _addressController, + decoration: CustomInputs.formInputDecoration( + hint: 'Toca para seleccionar tu dirección', + label: 'Dirección', + icon: Icons.location_on, ), + ), + ), + ), const SizedBox(height: 10), GestureDetector( onTap: () => isUserComplete() diff --git a/lib/ui/widgets/location_picker.dart b/lib/ui/widgets/location_picker.dart new file mode 100644 index 0000000..c6112c2 --- /dev/null +++ b/lib/ui/widgets/location_picker.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:http/http.dart' as http; + +const _mapsApiKey = String.fromEnvironment('MAPS_API_KEY', defaultValue: ''); + +class LocationResult { + final LatLng position; + final String address; + final String city; + + const LocationResult({required this.position, required this.address, required this.city}); +} + +class LocationPickerDialog extends StatefulWidget { + final LatLng? initialPosition; + + const LocationPickerDialog({super.key, this.initialPosition}); + + static Future show(BuildContext context, {LatLng? initialPosition}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => LocationPickerDialog(initialPosition: initialPosition), + ); + } + + @override + State createState() => _LocationPickerDialogState(); +} + +class _LocationPickerDialogState extends State { + GoogleMapController? _mapController; + LatLng _position = const LatLng(4.6097, -74.0817); // Bogotá por defecto + String _address = ''; + String _city = ''; + bool _loading = true; + bool _geocoding = false; + + @override + void initState() { + super.initState(); + if (widget.initialPosition != null) { + _position = widget.initialPosition!; + _loading = false; + _reverseGeocode(_position); + } else { + _detectLocation(); + } + } + + Future _detectLocation() async { + setState(() => _loading = true); + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + _useFallback(); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + _useFallback(); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + _useFallback(); + return; + } + + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium, timeLimit: Duration(seconds: 8)), + ); + _position = LatLng(pos.latitude, pos.longitude); + await _reverseGeocode(_position); + } catch (_) { + _useFallback(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _useFallback() { + // Colombia por defecto + _position = const LatLng(4.6097, -74.0817); + _address = ''; + _city = ''; + if (mounted) setState(() => _loading = false); + } + + Future _reverseGeocode(LatLng pos) async { + if (_mapsApiKey.isEmpty) return; + setState(() => _geocoding = true); + try { + final url = Uri.parse( + 'https://maps.googleapis.com/maps/api/geocode/json' + '?latlng=${pos.latitude},${pos.longitude}' + '&key=$_mapsApiKey' + '&language=es', + ); + final res = await http.get(url); + if (res.statusCode == 200) { + final data = jsonDecode(res.body); + if (data['results'] != null && data['results'].isNotEmpty) { + final result = data['results'][0]; + _address = result['formatted_address'] ?? ''; + _city = _extractCity(result['address_components'] ?? []); + } + } + } catch (_) {} + if (mounted) setState(() => _geocoding = false); + } + + String _extractCity(List components) { + for (final c in components) { + final types = List.from(c['types'] ?? []); + if (types.contains('locality') || types.contains('administrative_area_level_2')) { + return c['long_name'] ?? ''; + } + } + return ''; + } + + void _onMapTap(LatLng pos) { + setState(() => _position = pos); + _reverseGeocode(pos); + _mapController?.animateCamera(CameraUpdate.newLatLng(pos)); + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: SizedBox( + width: 600, + height: 520, + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF42A4EF), Color(0xFF1565C0)]), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + const Icon(Icons.location_on, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Expanded( + child: Text('Selecciona tu ubicación', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 15)), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // Map + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : Stack( + children: [ + GoogleMap( + initialCameraPosition: CameraPosition(target: _position, zoom: 15), + onMapCreated: (c) => _mapController = c, + onTap: _onMapTap, + markers: { + Marker( + markerId: const MarkerId('selected'), + position: _position, + draggable: true, + onDragEnd: (pos) { + setState(() => _position = pos); + _reverseGeocode(pos); + }, + ), + }, + myLocationEnabled: true, + myLocationButtonEnabled: true, + zoomControlsEnabled: true, + ), + if (_geocoding) + const Positioned( + top: 8, left: 0, right: 0, + child: Center( + child: Card( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), + SizedBox(width: 8), + Text('Obteniendo dirección...', style: TextStyle(fontSize: 12)), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + // Address preview + confirm + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[50], + border: const Border(top: BorderSide(color: Color(0xFFE0E0E0))), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_address.isNotEmpty) ...[ + Text(_address, style: const TextStyle(fontSize: 12, color: Colors.black87)), + const SizedBox(height: 8), + ], + if (_address.isEmpty && !_loading) + const Text('Toca el mapa para seleccionar una dirección', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: (_loading || _geocoding) + ? null + : () => Navigator.pop( + context, + LocationResult(position: _position, address: _address, city: _city), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF42A4EF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Confirmar ubicación'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _mapController?.dispose(); + super.dispose(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2328499..368a2b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: url_launcher: ^6.3.0 flutter_rating_bar: ^4.0.1 http: ^1.2.1 + google_maps_flutter: ^2.9.0 + geolocator: ^13.0.1 dev_dependencies: flutter_lints: ^3.0.0 diff --git a/web/index.html b/web/index.html index 7e3dca6..09e23b3 100644 --- a/web/index.html +++ b/web/index.html @@ -16,6 +16,8 @@ + +