Files
citypulse/lib/services/geocoding_service.dart
Zahar a8eae96f7f feat(app,ui): add map-based location picker and geocoding
- introduce LocationPickerScreen with flutter_map for selecting and confirming
  locations, including markers and loading states
- integrate manual location editing in ReviewScreen and persist updated
  coordinates/address in the report
- add GeocodingService with Nominatim search, Photon fallback, reverse
  geocoding, debounced queries, caching, and timeouts
- update i18n (en) with new map/location strings; remove unused onboarding
  and welcome texts
2025-09-27 12:55:36 +08:00

309 lines
9.0 KiB
Dart

import 'dart:convert';
import 'dart:async';
import 'package:http/http.dart' as http;
import '../models/report.dart';
import '../l10n/i18n.dart';
/// Service for geocoding operations (converting addresses to coordinates and vice versa)
class GeocodingService {
/// Nominatim API base URL (OpenStreetMap)
static const String _nominatimBaseUrl = 'https://nominatim.openstreetmap.org';
/// Search for locations by text query with timeout and better error handling
static Future<List<LocationSearchResult>> searchLocations(
String query, {
int limit = 5,
}) async {
if (query.trim().isEmpty) return [];
try {
final encodedQuery = Uri.encodeComponent(query);
final url = Uri.parse(
'$_nominatimBaseUrl/search?format=json&q=$encodedQuery&limit=$limit&addressdetails=1&dedupe=1', // Simplified parameters for faster response
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': I18n.currentLocale,
},
)
.timeout(const Duration(seconds: 4));
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
// Filter out low-quality results
final filteredData = data.where((item) {
final importance = item['importance'] as num?;
return importance == null ||
importance > 0.1; // Filter out very low importance results
}).toList();
return filteredData
.map((item) => LocationSearchResult.fromJson(item))
.toList();
} else {
print('Geocoding search failed: ${response.statusCode}');
return [];
}
} on TimeoutException catch (e) {
// Let the caller handle timeout (UI may trigger a fallback)
print('Error searching locations: $e');
rethrow;
} catch (e) {
print('Error searching locations: $e');
return [];
}
}
/// Get address from coordinates (reverse geocoding) with timeout
static Future<String?> getAddressFromCoordinates(
double lat,
double lng,
) async {
try {
final url = Uri.parse(
'$_nominatimBaseUrl/reverse?format=json&lat=$lat&lon=$lng&zoom=14',
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': I18n.currentLocale,
},
)
.timeout(
const Duration(seconds: 2),
); // Shorter timeout for reverse geocoding
if (response.statusCode == 200) {
final data = json.decode(response.body);
return _formatAddress(data);
} else {
print('Reverse geocoding failed: ${response.statusCode}');
return null;
}
} catch (e) {
print('Error getting address: $e');
return null;
}
}
/// Format address from Nominatim response
static String _formatAddress(Map<String, dynamic> data) {
final address = data['address'] as Map<String, dynamic>?;
if (address == null) {
return '${data['lat']}, ${data['lon']}';
}
final parts = <String>[];
if (address['house_number'] != null) {
parts.add(address['house_number']);
}
if (address['road'] != null) {
parts.add(address['road']);
}
if (address['suburb'] != null) {
parts.add(address['suburb']);
} else if (address['neighbourhood'] != null) {
parts.add(address['neighbourhood']);
}
if (address['city'] != null) {
parts.add(address['city']);
} else if (address['town'] != null) {
parts.add(address['town']);
} else if (address['village'] != null) {
parts.add(address['village']);
}
if (address['state'] != null) {
parts.add(address['state']);
}
if (address['country'] != null) {
parts.add(address['country']);
}
return parts.isNotEmpty
? parts.join(', ')
: '${data['lat']}, ${data['lon']}';
}
/// Get current location as a formatted address
static Future<String?> getCurrentLocationAddress() async {
try {
// This would need the LocationService to be integrated
// For now, return a placeholder
return null;
} catch (e) {
print('Error getting current location address: $e');
return null;
}
}
/// Photon (Komoot) search fallback
static Future<List<LocationSearchResult>> searchLocationsPhoton(
String query, {
int limit = 5,
}) async {
if (query.trim().isEmpty) return [];
try {
final String lang = I18n.currentLocale;
final encodedQuery = Uri.encodeComponent(query);
final url = Uri.parse(
'https://photon.komoot.io/api/?q=$encodedQuery&limit=$limit&lang=$lang',
);
final response = await http
.get(
url,
headers: {
'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)',
'Accept': 'application/json',
'Accept-Language': lang,
},
)
.timeout(const Duration(seconds: 4));
if (response.statusCode == 200) {
final Map<String, dynamic> data = json.decode(response.body);
final List<dynamic> features =
(data['features'] as List<dynamic>?) ?? [];
return features
.whereType<Map<String, dynamic>>()
.map((f) => LocationSearchResult.fromPhotonFeature(f))
.toList();
} else {
return [];
}
} on TimeoutException catch (e) {
// propagate to let UI decide
// ignore: avoid_print
print('Photon search timeout: $e');
rethrow;
} catch (e) {
// ignore: avoid_print
print('Photon search error: $e');
return [];
}
}
}
/// Represents a location search result
class LocationSearchResult {
final String displayName;
final double lat;
final double lng;
final String? address;
final String? city;
final String? country;
final String? type;
LocationSearchResult({
required this.displayName,
required this.lat,
required this.lng,
this.address,
this.city,
this.country,
this.type,
});
factory LocationSearchResult.fromJson(Map<String, dynamic> json) {
return LocationSearchResult(
displayName: json['display_name'] ?? '',
lat: double.parse(json['lat'] ?? '0'),
lng: double.parse(json['lon'] ?? '0'),
address:
json['address']?['road'] ??
json['address']?['pedestrian'] ??
json['address']?['path'],
city:
json['address']?['city'] ??
json['address']?['town'] ??
json['address']?['village'],
country: json['address']?['country'],
type: json['type'],
);
}
/// Convert to LocationData
LocationData toLocationData({double? accuracy}) {
return LocationData(
lat: lat,
lng: lng,
accuracy: accuracy ?? 10.0, // Default accuracy for searched locations
);
}
@override
String toString() {
return 'LocationSearchResult(displayName: $displayName, lat: $lat, lng: $lng)';
}
/// Construct from a Photon feature
factory LocationSearchResult.fromPhotonFeature(Map<String, dynamic> feature) {
final Map<String, dynamic>? properties =
feature['properties'] as Map<String, dynamic>?;
final Map<String, dynamic>? geometry =
feature['geometry'] as Map<String, dynamic>?;
final List<dynamic>? coordinates = geometry != null
? geometry['coordinates'] as List<dynamic>?
: null;
final double lat = coordinates != null && coordinates.length >= 2
? (coordinates[1] as num).toDouble()
: 0.0;
final double lng = coordinates != null && coordinates.length >= 2
? (coordinates[0] as num).toDouble()
: 0.0;
final String? name = properties?['name'] as String?;
final String? street =
(properties?['street'] ?? properties?['road']) as String?;
final String? houseNumber = properties?['housenumber'] as String?;
final String? city =
(properties?['city'] ??
properties?['town'] ??
properties?['village'] ??
properties?['county'])
as String?;
final String? country = properties?['country'] as String?;
final List<String> addressParts = [];
if (street != null && street.isNotEmpty) addressParts.add(street);
if (houseNumber != null && houseNumber.isNotEmpty)
addressParts.add(houseNumber);
final String address = addressParts.join(' ');
final String display = [
if (name != null && name.isNotEmpty) name,
if (city != null && city.isNotEmpty) city,
if (country != null && country.isNotEmpty) country,
].join(', ');
return LocationSearchResult(
displayName: display.isNotEmpty ? display : (name ?? ''),
lat: lat,
lng: lng,
address: address.isNotEmpty ? address : null,
city: city,
country: country,
type: properties?['osm_key'] as String?,
);
}
}