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
This commit is contained in:
@@ -33,6 +33,10 @@
|
||||
"btn.ok": "OK",
|
||||
"btn.yes": "Yes",
|
||||
"btn.no": "No",
|
||||
"btn.editLocation": "Edit Location",
|
||||
"btn.searchLocation": "Search Location",
|
||||
"btn.useThisLocation": "Use This Location",
|
||||
"btn.currentLocation": "Use Current Location",
|
||||
"label.category": "Category",
|
||||
"label.severity": "Severity",
|
||||
"label.status": "Status",
|
||||
@@ -81,20 +85,6 @@
|
||||
"settings.theme.dark": "Dark",
|
||||
"lang.en": "English",
|
||||
"lang.ms": "Bahasa Malaysia",
|
||||
"welcome.title": "Snap. Detect. Fix.",
|
||||
"welcome.subtitle": "Report city issues in seconds with AI-powered detection. Help create safer, better communities together.",
|
||||
"cta.continueGuest": "Continue as guest",
|
||||
"cta.signIn": "Sign in",
|
||||
"cta.skip": "Skip",
|
||||
"cta.next": "Next",
|
||||
"cta.getStarted": "Get started",
|
||||
"onboarding.header": "Welcome to CityPulse",
|
||||
"onboarding.title1": "Fast Issue Reporting",
|
||||
"onboarding.body1": "Simply take a photo of any urban issue - our AI automatically identifies and categorizes the problem in seconds.",
|
||||
"onboarding.title2": "Smart City Mapping",
|
||||
"onboarding.body2": "View all reported issues on an interactive map with intelligent clustering and filtering options.",
|
||||
"onboarding.title3": "Track Progress",
|
||||
"onboarding.body3": "Follow the status of your reports from submission to resolution. Help make your community better.",
|
||||
"auth.title": "Sign in",
|
||||
"auth.signInWithApple": "Sign in with Apple",
|
||||
"auth.signInWithGoogle": "Sign in with Google",
|
||||
@@ -153,5 +143,16 @@
|
||||
"confirm.logout.title": "Logout?",
|
||||
"confirm.logout.message": "You will be returned to the welcome screen.",
|
||||
"label.createdAt": "Created At",
|
||||
"error.clearData": "Failed to clear data"
|
||||
"error.clearData": "Failed to clear data",
|
||||
"map.searchHint": "Search for a location...",
|
||||
"map.selectLocation": "Select Location",
|
||||
"map.selectedLocation": "Selected Location",
|
||||
"map.confirmLocation": "Confirm this location?",
|
||||
"map.currentLocation": "Current Location",
|
||||
"map.searching": "Searching...",
|
||||
"map.noResults": "No results found",
|
||||
"map.moveMapToSelect": "Move the map to select a location",
|
||||
"map.loadingMap": "Loading map...",
|
||||
"map.gettingAddress": "Getting address...",
|
||||
"map.continueAnyway": "Continue Anyway"
|
||||
}
|
||||
Binary file not shown.
BIN
backend/static/uploads/53d7083b-9684-47c7-9636-3bf18097be39.jpg
Normal file
BIN
backend/static/uploads/53d7083b-9684-47c7-9636-3bf18097be39.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
backend/static/uploads/8352a900-0344-485d-abb9-c3d58f43ff46.jpg
Normal file
BIN
backend/static/uploads/8352a900-0344-485d-abb9-c3d58f43ff46.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
859
lib/screens/report_flow/location_picker_screen.dart
Normal file
859
lib/screens/report_flow/location_picker_screen.dart
Normal file
@@ -0,0 +1,859 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../l10n/i18n.dart';
|
||||
import '../../models/report.dart';
|
||||
import '../../services/geocoding_service.dart';
|
||||
import '../../services/location_service.dart';
|
||||
|
||||
class LocationPickerScreen extends StatefulWidget {
|
||||
final LocationData? initialLocation;
|
||||
final String? initialAddress;
|
||||
|
||||
const LocationPickerScreen({
|
||||
super.key,
|
||||
this.initialLocation,
|
||||
this.initialAddress,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LocationPickerScreen> createState() => _LocationPickerScreenState();
|
||||
}
|
||||
|
||||
class _LocationPickerScreenState extends State<LocationPickerScreen> {
|
||||
final MapController _mapController = MapController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
LocationData? _selectedLocation;
|
||||
String? _selectedAddress;
|
||||
List<LocationSearchResult> _searchResults = [];
|
||||
bool _isSearching = false;
|
||||
bool _isLoadingLocation = false;
|
||||
bool _isMapLoading = true;
|
||||
bool _isGettingAddress = false;
|
||||
|
||||
// Search optimization
|
||||
Timer? _searchDebounceTimer;
|
||||
final Map<String, List<LocationSearchResult>> _searchCache = {};
|
||||
static const Duration _searchDebounceDuration = Duration(
|
||||
milliseconds: 1200,
|
||||
); // Increased debounce to prevent multiple calls
|
||||
|
||||
// Default center (Kuala Lumpur, Malaysia)
|
||||
static const LatLng _defaultCenter = LatLng(3.1390, 101.6869);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedLocation = widget.initialLocation;
|
||||
_selectedAddress = widget.initialAddress;
|
||||
|
||||
// Set up timeouts first (before map operations)
|
||||
_setupTimeouts();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchDebounceTimer?.cancel();
|
||||
_reverseGeocodeTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
if (!mounted) return;
|
||||
|
||||
// Cancel previous timer
|
||||
_searchDebounceTimer?.cancel();
|
||||
|
||||
if (query.trim().isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (_searchCache.containsKey(query)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = _searchCache[query]!;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search requests
|
||||
_searchDebounceTimer = Timer(_searchDebounceDuration, () async {
|
||||
if (mounted) {
|
||||
await _performSearch(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await GeocodingService.searchLocations(query);
|
||||
|
||||
// Cache only non-empty results to avoid sticky empty cache on transient failures
|
||||
if (results.isNotEmpty) {
|
||||
_searchCache[query] = results;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Geocoding search failed: $e, trying fallback...');
|
||||
|
||||
// Try Photon fallback first, then a simplified Nominatim search
|
||||
try {
|
||||
final photonResults = await GeocodingService.searchLocationsPhoton(
|
||||
query,
|
||||
);
|
||||
if (photonResults.isNotEmpty) {
|
||||
_searchCache[query] = photonResults;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = photonResults;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final fallbackResults = await _performFallbackSearch(query);
|
||||
if (fallbackResults.isNotEmpty) {
|
||||
_searchCache[query] = fallbackResults;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = fallbackResults;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
print('Fallback search also failed: $fallbackError');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_isSearching = false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<LocationSearchResult>> _performFallbackSearch(
|
||||
String query,
|
||||
) async {
|
||||
// Simplified search with basic parameters
|
||||
final encodedQuery = Uri.encodeComponent(query);
|
||||
final url = Uri.parse(
|
||||
'https://nominatim.openstreetmap.org/search?format=json&q=$encodedQuery&limit=3&addressdetails=0',
|
||||
);
|
||||
|
||||
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: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = json.decode(response.body);
|
||||
return data.map((item) => LocationSearchResult.fromJson(item)).toList();
|
||||
} else {
|
||||
throw Exception('Fallback search failed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _useCurrentLocation() async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingLocation = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final position = await LocationService.getCurrentPosition();
|
||||
if (position != null) {
|
||||
final locationData = LocationService.positionToLocationData(position);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedLocation = locationData;
|
||||
});
|
||||
}
|
||||
|
||||
// Move map to current location (only if map is ready)
|
||||
try {
|
||||
_mapController.move(LatLng(locationData.lat, locationData.lng), 16.0);
|
||||
} catch (e) {
|
||||
print('Error moving map to current location: $e');
|
||||
}
|
||||
|
||||
// Get address for current location
|
||||
final address = await GeocodingService.getAddressFromCoordinates(
|
||||
locationData.lat,
|
||||
locationData.lng,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedAddress = address;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Unable to get current location')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error getting location: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingLocation = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _reverseGeocodeTimer;
|
||||
|
||||
void _onMapTap(LatLng point) {
|
||||
setState(() {
|
||||
_selectedLocation = LocationData(
|
||||
lat: point.latitude,
|
||||
lng: point.longitude,
|
||||
accuracy: 10.0, // Default accuracy for manual selection
|
||||
);
|
||||
});
|
||||
|
||||
// Debounce reverse geocoding requests
|
||||
_reverseGeocodeTimer?.cancel();
|
||||
_reverseGeocodeTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
_getAddressForLocation(point.latitude, point.longitude);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _getAddressForLocation(double lat, double lng) async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isGettingAddress = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final address = await GeocodingService.getAddressFromCoordinates(
|
||||
lat,
|
||||
lng,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedAddress = address;
|
||||
_isGettingAddress = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Reverse geocoding failed: $e, trying fallback...');
|
||||
|
||||
// Try a simpler reverse geocoding approach
|
||||
try {
|
||||
final fallbackAddress = await _performFallbackReverseGeocoding(
|
||||
lat,
|
||||
lng,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedAddress = fallbackAddress;
|
||||
_isGettingAddress = false;
|
||||
});
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
print('Fallback reverse geocoding also failed: $fallbackError');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isGettingAddress = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _performFallbackReverseGeocoding(
|
||||
double lat,
|
||||
double lng,
|
||||
) async {
|
||||
// Simplified reverse geocoding with basic parameters
|
||||
final url = Uri.parse(
|
||||
'https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lng&zoom=14',
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.get(
|
||||
url,
|
||||
headers: {'User-Agent': 'CityPulse/1.0 (contact@citypulse.app)'},
|
||||
)
|
||||
.timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
return _formatSimpleAddress(data);
|
||||
} else {
|
||||
throw Exception('Fallback reverse geocoding failed');
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSimpleAddress(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['road'] != null) {
|
||||
parts.add(address['road']);
|
||||
}
|
||||
|
||||
if (address['city'] != null) {
|
||||
parts.add(address['city']);
|
||||
} else if (address['town'] != null) {
|
||||
parts.add(address['town']);
|
||||
}
|
||||
|
||||
if (address['country'] != null) {
|
||||
parts.add(address['country']);
|
||||
}
|
||||
|
||||
return parts.isNotEmpty
|
||||
? parts.join(', ')
|
||||
: '${data['lat']}, ${data['lon']}';
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_isSearching = false;
|
||||
});
|
||||
_searchDebounceTimer?.cancel();
|
||||
}
|
||||
|
||||
void _setupTimeouts() {
|
||||
// Set multiple timeouts to ensure loading screen never stays forever
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted && _isMapLoading) {
|
||||
print('Map loading timeout 800ms - still loading');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted && _isMapLoading) {
|
||||
print('Map loading timeout 2s - still loading');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Future.delayed(const Duration(seconds: 4), () {
|
||||
if (mounted && _isMapLoading) {
|
||||
print('Map loading timeout 4s - still loading');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Final failsafe - absolutely must hide loading screen
|
||||
Future.delayed(const Duration(seconds: 6), () {
|
||||
if (mounted && _isMapLoading) {
|
||||
print('FINAL FAILSAFE: Force hiding loading screen after 6s');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _checkMapReady() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Simple check - if map controller has a position, consider it ready
|
||||
try {
|
||||
final center = _mapController.camera.center;
|
||||
print('Map center check: $center, loading: $_isMapLoading');
|
||||
if (_isMapLoading) {
|
||||
print('Map appears ready, hiding loading screen');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error checking map readiness: $e');
|
||||
// If we can't get center, the map might not be ready yet, but let's be more aggressive
|
||||
if (_isMapLoading) {
|
||||
print('Force hiding loading screen due to error');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeMap() {
|
||||
// This should be called after the map widget is built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
// Center map on initial location or default location
|
||||
if (_selectedLocation != null) {
|
||||
try {
|
||||
_mapController.move(
|
||||
LatLng(_selectedLocation!.lat, _selectedLocation!.lng),
|
||||
16.0,
|
||||
);
|
||||
print('Map centered on initial location');
|
||||
} catch (e) {
|
||||
print('Error moving map to initial location: $e');
|
||||
// If map controller fails, still try to hide loading screen
|
||||
if (_isMapLoading) {
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check after widgets are built
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _isMapLoading) {
|
||||
print('Post-frame callback check - map still loading');
|
||||
_checkMapReady();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _selectSearchResult(LocationSearchResult result) {
|
||||
setState(() {
|
||||
_selectedLocation = result.toLocationData();
|
||||
_selectedAddress = result.displayName;
|
||||
_searchResults = [];
|
||||
_searchController.clear();
|
||||
});
|
||||
|
||||
// Move map to selected location
|
||||
_mapController.move(LatLng(result.lat, result.lng), 16.0);
|
||||
}
|
||||
|
||||
void _confirmSelection() {
|
||||
if (_selectedLocation != null) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop({'location': _selectedLocation, 'address': _selectedAddress});
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select a location first')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
I18n.t('map.selectLocation'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: cs.onSurface,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _confirmSelection,
|
||||
child: Text(
|
||||
I18n.t('btn.ok'),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Map with loading state
|
||||
_isMapLoading
|
||||
? Container(
|
||||
color: cs.surface.withOpacity(0.9),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
I18n.t('map.loadingMap'),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: cs.onSurface),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
print('User manually dismissed loading screen');
|
||||
setState(() {
|
||||
_isMapLoading = false;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
I18n.t('map.continueAnyway'),
|
||||
style: TextStyle(color: cs.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _defaultCenter,
|
||||
initialZoom: 13.0,
|
||||
onTap: (tapPosition, point) => _onMapTap(point),
|
||||
minZoom: 3.0,
|
||||
maxZoom: 18.0,
|
||||
onMapReady: () {
|
||||
print('Map is ready, initializing...');
|
||||
_initializeMap();
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.example.citypulse',
|
||||
// Add retina mode for better quality on high DPI devices
|
||||
retinaMode: true,
|
||||
// Add error handling for tile loading
|
||||
errorTileCallback: (tile, error, stackTrace) {
|
||||
print(
|
||||
'Tile loading error: $error for tile $tile, stackTrace: $stackTrace',
|
||||
);
|
||||
// Return a transparent tile instead of showing error
|
||||
return null;
|
||||
},
|
||||
// Fallback tile server
|
||||
fallbackUrl:
|
||||
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
),
|
||||
// Current location marker (if available)
|
||||
if (_selectedLocation != null)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_selectedLocation!.lat,
|
||||
_selectedLocation!.lng,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Search bar
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Search input
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: I18n.t('map.searchHint'),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: cs.onSurface.withOpacity(0.6),
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSearching)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
else if (_searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: cs.onSurface.withOpacity(0.6),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _clearSearch,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.my_location, color: cs.primary),
|
||||
onPressed: _isLoadingLocation
|
||||
? null
|
||||
: _useCurrentLocation,
|
||||
),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: cs.surface,
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
|
||||
// Search results
|
||||
if (_searchResults.isNotEmpty)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final result = _searchResults[index];
|
||||
return ListTile(
|
||||
leading: const Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: Text(
|
||||
result.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
[result.city, result.country]
|
||||
.where((e) => e != null && e.isNotEmpty)
|
||||
.join(', '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _selectSearchResult(result),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Selected location info
|
||||
if (_selectedLocation != null)
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_on, color: cs.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
I18n.t('map.selectedLocation'),
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_isGettingAddress
|
||||
? Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
I18n.t('map.gettingAddress'),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: cs.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
_selectedAddress ??
|
||||
'${_selectedLocation!.lat.toStringAsFixed(6)}, ${_selectedLocation!.lng.toStringAsFixed(6)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: cs.onSurface),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_selectedLocation!.accuracy != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Accuracy: ${_selectedLocation!.accuracy!.toStringAsFixed(1)}m',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom action button
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: ElevatedButton(
|
||||
onPressed: _confirmSelection,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
I18n.t('btn.useThisLocation'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../../l10n/i18n.dart';
|
||||
import '../../models/report.dart';
|
||||
import '../../models/enums.dart';
|
||||
import '../../services/storage.dart';
|
||||
import 'location_picker_screen.dart';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
final Report report;
|
||||
@@ -23,14 +24,16 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
late Category _selectedCategory;
|
||||
late Severity _selectedSeverity;
|
||||
late TextEditingController _notesController;
|
||||
late Report _currentReport;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCategory = widget.report.category;
|
||||
_selectedSeverity = widget.report.severity;
|
||||
_notesController = TextEditingController(text: widget.report.notes ?? '');
|
||||
_currentReport = widget.report;
|
||||
_selectedCategory = _currentReport.category;
|
||||
_selectedSeverity = _currentReport.severity;
|
||||
_notesController = TextEditingController(text: _currentReport.notes ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,7 +49,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
|
||||
try {
|
||||
// Update report with user selections
|
||||
final updatedReport = widget.report.copyWith(
|
||||
final updatedReport = _currentReport.copyWith(
|
||||
category: _selectedCategory,
|
||||
severity: _selectedSeverity,
|
||||
notes: _notesController.text.isEmpty ? null : _notesController.text,
|
||||
@@ -200,7 +203,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
Text(
|
||||
I18n.t('review.aiConfidence', {
|
||||
'0':
|
||||
(widget.report.aiSuggestion.confidence *
|
||||
(_currentReport.aiSuggestion.confidence *
|
||||
100)
|
||||
.round()
|
||||
.toString(),
|
||||
@@ -243,7 +246,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.report.aiSuggestion.category.displayName,
|
||||
_currentReport
|
||||
.aiSuggestion
|
||||
.category
|
||||
.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -280,7 +286,10 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.report.aiSuggestion.severity.displayName,
|
||||
_currentReport
|
||||
.aiSuggestion
|
||||
.severity
|
||||
.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -300,9 +309,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedCategory =
|
||||
widget.report.aiSuggestion.category;
|
||||
_currentReport.aiSuggestion.category;
|
||||
_selectedSeverity =
|
||||
widget.report.aiSuggestion.severity;
|
||||
_currentReport.aiSuggestion.severity;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.check_circle, size: 18),
|
||||
@@ -320,7 +329,28 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
// Navigate to manual editing screen
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LocationPickerScreen(
|
||||
initialLocation: _currentReport.location,
|
||||
initialAddress: _currentReport.address,
|
||||
),
|
||||
),
|
||||
).then((result) {
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
// Update the report with new location
|
||||
_currentReport = _currentReport.copyWith(
|
||||
location: result['location'],
|
||||
address: result['address'],
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(I18n.t('review.editManually')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -615,6 +645,46 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// Navigate to location picker
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LocationPickerScreen(
|
||||
initialLocation: _currentReport.location,
|
||||
initialAddress: _currentReport.address,
|
||||
),
|
||||
),
|
||||
).then((result) {
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
// Update the report with new location
|
||||
_currentReport = _currentReport.copyWith(
|
||||
location: result['location'],
|
||||
address: result['address'],
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.edit, size: 16, color: cs.primary),
|
||||
label: Text(
|
||||
I18n.t('btn.editLocation'),
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -647,14 +717,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${widget.report.location.lat.toStringAsFixed(6)}, ${widget.report.location.lng.toStringAsFixed(6)}',
|
||||
'${_currentReport.location.lat.toStringAsFixed(6)}, ${_currentReport.location.lng.toStringAsFixed(6)}',
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: cs.onSurface,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
if (widget.report.location.accuracy != null) ...[
|
||||
if (_currentReport.location.accuracy != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -666,7 +736,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
I18n.t('review.accuracy', {
|
||||
'0': widget.report.location.accuracy!
|
||||
'0': _currentReport.location.accuracy!
|
||||
.toStringAsFixed(1),
|
||||
}),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
|
||||
308
lib/services/geocoding_service.dart
Normal file
308
lib/services/geocoding_service.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user