diff --git a/assets/lang/en.json b/assets/lang/en.json index 1590fa1..5650278 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -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" } \ No newline at end of file diff --git a/backend/app/db/fixmate.db b/backend/app/db/fixmate.db index bff2e90..e4d12b3 100644 Binary files a/backend/app/db/fixmate.db and b/backend/app/db/fixmate.db differ diff --git a/backend/static/uploads/53d7083b-9684-47c7-9636-3bf18097be39.jpg b/backend/static/uploads/53d7083b-9684-47c7-9636-3bf18097be39.jpg new file mode 100644 index 0000000..859ffa1 Binary files /dev/null and b/backend/static/uploads/53d7083b-9684-47c7-9636-3bf18097be39.jpg differ diff --git a/backend/static/uploads/8352a900-0344-485d-abb9-c3d58f43ff46.jpg b/backend/static/uploads/8352a900-0344-485d-abb9-c3d58f43ff46.jpg new file mode 100644 index 0000000..da1166b Binary files /dev/null and b/backend/static/uploads/8352a900-0344-485d-abb9-c3d58f43ff46.jpg differ diff --git a/backend/static/uploads/d35f86a2-08b5-47d6-bcfd-c59c8ccee275.jpg b/backend/static/uploads/d35f86a2-08b5-47d6-bcfd-c59c8ccee275.jpg deleted file mode 100644 index 08da479..0000000 Binary files a/backend/static/uploads/d35f86a2-08b5-47d6-bcfd-c59c8ccee275.jpg and /dev/null differ diff --git a/backend/static/uploads/f58deb8d-d49a-4bc8-870e-e0c1d3048ab6.jpg b/backend/static/uploads/f58deb8d-d49a-4bc8-870e-e0c1d3048ab6.jpg deleted file mode 100644 index 60ba961..0000000 Binary files a/backend/static/uploads/f58deb8d-d49a-4bc8-870e-e0c1d3048ab6.jpg and /dev/null differ diff --git a/lib/screens/report_flow/location_picker_screen.dart b/lib/screens/report_flow/location_picker_screen.dart new file mode 100644 index 0000000..4f724ae --- /dev/null +++ b/lib/screens/report_flow/location_picker_screen.dart @@ -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 createState() => _LocationPickerScreenState(); +} + +class _LocationPickerScreenState extends State { + final MapController _mapController = MapController(); + final TextEditingController _searchController = TextEditingController(); + + LocationData? _selectedLocation; + String? _selectedAddress; + List _searchResults = []; + bool _isSearching = false; + bool _isLoadingLocation = false; + bool _isMapLoading = true; + bool _isGettingAddress = false; + + // Search optimization + Timer? _searchDebounceTimer; + final Map> _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 _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> _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 data = json.decode(response.body); + return data.map((item) => LocationSearchResult.fromJson(item)).toList(); + } else { + throw Exception('Fallback search failed'); + } + } + + Future _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 _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 _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 data) { + final address = data['address'] as Map?; + + if (address == null) { + return '${data['lat']}, ${data['lon']}'; + } + + final parts = []; + + 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/report_flow/review_screen.dart b/lib/screens/report_flow/review_screen.dart index a9a3fa4..16f2fdf 100644 --- a/lib/screens/report_flow/review_screen.dart +++ b/lib/screens/report_flow/review_screen.dart @@ -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 { 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 { 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 { Text( I18n.t('review.aiConfidence', { '0': - (widget.report.aiSuggestion.confidence * + (_currentReport.aiSuggestion.confidence * 100) .round() .toString(), @@ -243,7 +246,10 @@ class _ReviewScreenState extends State { ), 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 { ), 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 { 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 { 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 { 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 { ), 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 { 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 diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart new file mode 100644 index 0000000..c5a2864 --- /dev/null +++ b/lib/services/geocoding_service.dart @@ -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> 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 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 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 data) { + final address = data['address'] as Map?; + + if (address == null) { + return '${data['lat']}, ${data['lon']}'; + } + + final parts = []; + + 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 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> 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 data = json.decode(response.body); + final List features = + (data['features'] as List?) ?? []; + return features + .whereType>() + .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 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 feature) { + final Map? properties = + feature['properties'] as Map?; + final Map? geometry = + feature['geometry'] as Map?; + final List? coordinates = geometry != null + ? geometry['coordinates'] as List? + : 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 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?, + ); + } +}