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:
2025-09-27 12:55:36 +08:00
parent ed63a0cbc8
commit a8eae96f7f
9 changed files with 1266 additions and 28 deletions

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

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

View 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,
),
),
),
),
],
),
);
}
}

View File

@@ -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

View 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?,
);
}
}