| 1 | import 'dart:async'; |
| 2 | |
| 3 | import 'package:geocoding/geocoding.dart'; |
| 4 | import 'package:geolocator/geolocator.dart'; |
| 5 | |
| 6 | /// Thin wrapper around [Geolocator] + [Geocoding] for the built-in |
| 7 | /// location tools. |
| 8 | /// |
| 9 | /// All methods return plain Dart types (strings / maps) so tool results can |
| 10 | /// be serialised without leaking platform model classes into the tool layer. |
| 11 | /// |
| 12 | /// Location is fetched on demand only (no background tracking, no caching, |
| 13 | /// no persistence). Reverse-geocoding is best-effort: when it fails the |
| 14 | /// raw coordinates are still returned with a `geocoding_error` note. |
| 15 | class LocationService { |
| 16 | LocationService._(); |
| 17 | static final instance = LocationService._(); |
| 18 | |
| 19 | final Geocoding _geocoding = Geocoding(); |
| 20 | |
| 21 | // --------------------------------------------------------------------------- |
| 22 | // Permissions |
| 23 | // --------------------------------------------------------------------------- |
| 24 | |
| 25 | /// Request when-in-use location access. Returns `true` when granted. |
| 26 | Future<bool> requestAccess() async { |
| 27 | final serviceEnabled = await Geolocator.isLocationServiceEnabled(); |
| 28 | if (!serviceEnabled) return false; |
| 29 | |
| 30 | var permission = await Geolocator.checkPermission(); |
| 31 | if (permission == LocationPermission.denied) { |
| 32 | permission = await Geolocator.requestPermission(); |
| 33 | } |
| 34 | return permission == LocationPermission.always || |
| 35 | permission == LocationPermission.whileInUse; |
| 36 | } |
| 37 | |
| 38 | /// Check current permission status without prompting. |
| 39 | Future<bool> hasAccess() async { |
| 40 | final permission = await Geolocator.checkPermission(); |
| 41 | return permission == LocationPermission.always || |
| 42 | permission == LocationPermission.whileInUse; |
| 43 | } |
| 44 | |
| 45 | // --------------------------------------------------------------------------- |
| 46 | // Location |
| 47 | // --------------------------------------------------------------------------- |
| 48 | |
| 49 | /// Returns a JSON-friendly map with coordinates and reverse-geocoded place. |
| 50 | /// |
| 51 | /// Throws a [StateError] with a human-readable message when location |
| 52 | /// services are disabled, permission is missing, or the fix times out. |
| 53 | Future<Map<String, dynamic>> getCurrentLocation() async { |
| 54 | final serviceEnabled = await Geolocator.isLocationServiceEnabled(); |
| 55 | if (!serviceEnabled) { |
| 56 | throw StateError( |
| 57 | 'Location services are disabled. ' |
| 58 | 'Please enable location services on your device.', |
| 59 | ); |
| 60 | } |
| 61 | |
| 62 | final permission = await Geolocator.checkPermission(); |
| 63 | if (permission == LocationPermission.deniedForever) { |
| 64 | throw StateError( |
| 65 | 'Location permission permanently denied. ' |
| 66 | 'Please grant location access in your device settings.', |
| 67 | ); |
| 68 | } |
| 69 | if (permission != LocationPermission.always && |
| 70 | permission != LocationPermission.whileInUse) { |
| 71 | throw StateError( |
| 72 | 'Location permission not granted. ' |
| 73 | 'Please enable Location Access in Settings.', |
| 74 | ); |
| 75 | } |
| 76 | |
| 77 | late final Position position; |
| 78 | try { |
| 79 | position = await Geolocator.getCurrentPosition( |
| 80 | locationSettings: const LocationSettings( |
| 81 | accuracy: LocationAccuracy.high, |
| 82 | timeLimit: Duration(seconds: 15), |
| 83 | ), |
| 84 | ); |
| 85 | } on TimeoutException { |
| 86 | throw StateError( |
| 87 | 'Timed out while getting your location. Please try again.', |
| 88 | ); |
| 89 | } |
| 90 | |
| 91 | final result = <String, dynamic>{ |
| 92 | 'latitude': position.latitude, |
| 93 | 'longitude': position.longitude, |
| 94 | 'accuracy_m': position.accuracy, |
| 95 | 'timestamp': position.timestamp.toIso8601String(), |
| 96 | }; |
| 97 | |
| 98 | try { |
| 99 | final placemarks = await _geocoding.placemarkFromCoordinates( |
| 100 | position.latitude, |
| 101 | position.longitude, |
| 102 | ); |
| 103 | if (placemarks.isNotEmpty) { |
| 104 | final place = placemarks.first; |
| 105 | final placemark = <String, dynamic>{ |
| 106 | 'name': _nullIfEmpty(place.name), |
| 107 | 'street': _nullIfEmpty(place.street), |
| 108 | 'locality': _nullIfEmpty(place.locality), |
| 109 | 'sub_admin_area': _nullIfEmpty(place.subAdministrativeArea), |
| 110 | 'admin_area': _nullIfEmpty(place.administrativeArea), |
| 111 | 'postal_code': _nullIfEmpty(place.postalCode), |
| 112 | 'country': _nullIfEmpty(place.country), |
| 113 | 'iso_country_code': _nullIfEmpty(place.isoCountryCode), |
| 114 | }; |
| 115 | result['placemark'] = placemark; |
| 116 | result['formatted_address'] = _formatAddress(place); |
| 117 | } |
| 118 | } catch (e) { |
| 119 | result['geocoding_error'] = |
| 120 | 'Reverse-geocoding unavailable ($e). Coordinates are still valid.'; |
| 121 | } |
| 122 | |
| 123 | return result; |
| 124 | } |
| 125 | |
| 126 | // --------------------------------------------------------------------------- |
| 127 | // Helpers |
| 128 | // --------------------------------------------------------------------------- |
| 129 | |
| 130 | String? _nullIfEmpty(String? value) { |
| 131 | if (value == null || value.trim().isEmpty) return null; |
| 132 | return value; |
| 133 | } |
| 134 | |
| 135 | String _formatAddress(Placemark place) { |
| 136 | final parts = [ |
| 137 | place.street, |
| 138 | place.locality, |
| 139 | place.administrativeArea, |
| 140 | place.postalCode, |
| 141 | place.country, |
| 142 | ].where((p) => p != null && p.trim().isNotEmpty).toList(growable: false); |
| 143 | return parts.join(', '); |
| 144 | } |
| 145 | } |