From 4e072a34c0aa1af6c700779cd43a517298e791e5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 03:25:04 -0500 Subject: [PATCH] Search address / POI --- lib/app_state.dart | 20 +++ lib/models/search_result.dart | 47 ++++++ lib/screens/home_screen.dart | 180 ++++++++++++-------- lib/services/search_service.dart | 91 ++++++++++ lib/state/search_state.dart | 65 ++++++++ lib/widgets/network_status_indicator.dart | 2 +- lib/widgets/search_bar.dart | 195 ++++++++++++++++++++++ 7 files changed, 525 insertions(+), 75 deletions(-) create mode 100644 lib/models/search_result.dart create mode 100644 lib/services/search_service.dart create mode 100644 lib/state/search_state.dart create mode 100644 lib/widgets/search_bar.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index bc5009c..e7846ad 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -7,6 +7,7 @@ import 'models/operator_profile.dart'; import 'models/osm_node.dart'; import 'models/pending_upload.dart'; import 'models/tile_provider.dart'; +import 'models/search_result.dart'; import 'services/offline_area_service.dart'; import 'services/node_cache.dart'; import 'services/tile_preview_service.dart'; @@ -14,6 +15,7 @@ import 'widgets/camera_provider_with_cache.dart'; import 'state/auth_state.dart'; import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; +import 'state/search_state.dart'; import 'state/session_state.dart'; import 'state/settings_state.dart'; import 'state/upload_queue_state.dart'; @@ -30,6 +32,7 @@ class AppState extends ChangeNotifier { late final AuthState _authState; late final OperatorProfileState _operatorProfileState; late final ProfileState _profileState; + late final SearchState _searchState; late final SessionState _sessionState; late final SettingsState _settingsState; late final UploadQueueState _uploadQueueState; @@ -41,6 +44,7 @@ class AppState extends ChangeNotifier { _authState = AuthState(); _operatorProfileState = OperatorProfileState(); _profileState = ProfileState(); + _searchState = SearchState(); _sessionState = SessionState(); _settingsState = SettingsState(); _uploadQueueState = UploadQueueState(); @@ -49,6 +53,7 @@ class AppState extends ChangeNotifier { _authState.addListener(_onStateChanged); _operatorProfileState.addListener(_onStateChanged); _profileState.addListener(_onStateChanged); + _searchState.addListener(_onStateChanged); _sessionState.addListener(_onStateChanged); _settingsState.addListener(_onStateChanged); _uploadQueueState.addListener(_onStateChanged); @@ -71,6 +76,11 @@ class AppState extends ChangeNotifier { // Operator profile state List get operatorProfiles => _operatorProfileState.profiles; + // Search state + bool get isSearchLoading => _searchState.isLoading; + List get searchResults => _searchState.results; + String get lastSearchQuery => _searchState.lastQuery; + // Session state AddNodeSession? get session => _sessionState.session; EditNodeSession? get editSession => _sessionState.editSession; @@ -231,6 +241,15 @@ class AppState extends ChangeNotifier { _startUploader(); } + // ---------- Search Methods ---------- + Future search(String query) async { + await _searchState.search(query); + } + + void clearSearchResults() { + _searchState.clearResults(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); @@ -330,6 +349,7 @@ class AppState extends ChangeNotifier { _authState.removeListener(_onStateChanged); _operatorProfileState.removeListener(_onStateChanged); _profileState.removeListener(_onStateChanged); + _searchState.removeListener(_onStateChanged); _sessionState.removeListener(_onStateChanged); _settingsState.removeListener(_onStateChanged); _uploadQueueState.removeListener(_onStateChanged); diff --git a/lib/models/search_result.dart b/lib/models/search_result.dart new file mode 100644 index 0000000..c704b77 --- /dev/null +++ b/lib/models/search_result.dart @@ -0,0 +1,47 @@ +import 'package:latlong2/latlong.dart'; + +/// Represents a search result from a geocoding service +class SearchResult { + final String displayName; + final LatLng coordinates; + final String? category; + final String? type; + + const SearchResult({ + required this.displayName, + required this.coordinates, + this.category, + this.type, + }); + + /// Create SearchResult from Nominatim JSON response + factory SearchResult.fromNominatim(Map json) { + final lat = double.parse(json['lat'] as String); + final lon = double.parse(json['lon'] as String); + + return SearchResult( + displayName: json['display_name'] as String, + coordinates: LatLng(lat, lon), + category: json['category'] as String?, + type: json['type'] as String?, + ); + } + + @override + String toString() { + return 'SearchResult(displayName: $displayName, coordinates: $coordinates)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SearchResult && + other.displayName == displayName && + other.coordinates == coordinates; + } + + @override + int get hashCode { + return displayName.hashCode ^ coordinates.hashCode; + } +} \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index cc8ebfe..eb3fe7a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -15,7 +15,9 @@ import '../widgets/node_tag_sheet.dart'; import '../widgets/camera_provider_with_cache.dart'; import '../widgets/download_area_dialog.dart'; import '../widgets/measured_sheet.dart'; +import '../widgets/search_bar.dart'; import '../models/osm_node.dart'; +import '../models/search_result.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -160,6 +162,25 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } + void _onSearchResultSelected(SearchResult result) { + // Jump to the search result location + try { + _mapController.animateTo( + dest: result.coordinates, + zoom: 16.0, // Good zoom level for viewing the area + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + } catch (_) { + // Map controller not ready, fallback to immediate move + try { + _mapController.mapController.move(result.coordinates, 16.0); + } catch (_) { + debugPrint('[HomeScreen] Could not move to search result: ${result.coordinates}'); + } + } + } + void openNodeTagSheet(OsmNode node) { setState(() { _selectedNodeId = node.id; // Track selected node for highlighting @@ -269,83 +290,94 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ], ), - body: Stack( + body: Column( children: [ - MapView( - key: _mapViewKey, - controller: _mapController, - followMeMode: appState.followMeMode, - sheetHeight: activeSheetHeight, - selectedNodeId: _selectedNodeId, - onNodeTap: openNodeTagSheet, - onUserGesture: () { - if (appState.followMeMode != FollowMeMode.off) { - appState.setFollowMeMode(FollowMeMode.off); - } - }, + // Search bar at the top + LocationSearchBar( + onResultSelected: _onSearchResultSelected, ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset, - left: 8, - right: 8, - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Theme.of(context).shadowColor.withOpacity(0.3), - blurRadius: 10, - offset: Offset(0, -2), - ) - ], - ), - margin: EdgeInsets.only(bottom: kBottomButtonBarOffset), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - Expanded( - child: AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) => ElevatedButton.icon( - icon: Icon(Icons.add_location_alt), - label: Text(LocalizationService.instance.tagNode), - onPressed: _openAddNodeSheet, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), - ), - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) => ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text(LocalizationService.instance.download), - onPressed: () => showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ), - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), - ), - ), - ), - ), - ], + // Map takes the rest of the space + Expanded( + child: Stack( + children: [ + MapView( + key: _mapViewKey, + controller: _mapController, + followMeMode: appState.followMeMode, + sheetHeight: activeSheetHeight, + selectedNodeId: _selectedNodeId, + onNodeTap: openNodeTagSheet, + onUserGesture: () { + if (appState.followMeMode != FollowMeMode.off) { + appState.setFollowMeMode(FollowMeMode.off); + } + }, ), - ), - ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset, + left: 8, + right: 8, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.3), + blurRadius: 10, + offset: Offset(0, -2), + ) + ], + ), + margin: EdgeInsets.only(bottom: kBottomButtonBarOffset), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + Expanded( + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.add_location_alt), + label: Text(LocalizationService.instance.tagNode), + onPressed: _openAddNodeSheet, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: () => showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), + ), + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], ), ), ], diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart new file mode 100644 index 0000000..eb47dca --- /dev/null +++ b/lib/services/search_service.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +import '../models/search_result.dart'; + +class SearchService { + static const String _baseUrl = 'https://nominatim.openstreetmap.org'; + static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; + static const int _maxResults = 5; + static const Duration _timeout = Duration(seconds: 10); + + /// Search for places using Nominatim geocoding service + Future> search(String query) async { + if (query.trim().isEmpty) { + return []; + } + + // Check if query looks like coordinates first + final coordResult = _tryParseCoordinates(query.trim()); + if (coordResult != null) { + return [coordResult]; + } + + // Otherwise, use Nominatim API + return await _searchNominatim(query.trim()); + } + + /// Try to parse various coordinate formats + SearchResult? _tryParseCoordinates(String query) { + // Remove common separators and normalize + final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim(); + final parts = normalized.split(RegExp(r'\s+')); + + if (parts.length != 2) return null; + + final lat = double.tryParse(parts[0]); + final lon = double.tryParse(parts[1]); + + if (lat == null || lon == null) return null; + + // Basic validation for Earth coordinates + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + + return SearchResult( + displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}', + coordinates: LatLng(lat, lon), + category: 'coordinates', + type: 'point', + ); + } + + /// Search using Nominatim API + Future> _searchNominatim(String query) async { + final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: { + 'q': query, + 'format': 'json', + 'limit': _maxResults.toString(), + 'addressdetails': '1', + 'extratags': '1', + }); + + debugPrint('[SearchService] Searching Nominatim: $uri'); + + try { + final response = await http.get( + uri, + headers: { + 'User-Agent': _userAgent, + }, + ).timeout(_timeout); + + if (response.statusCode != 200) { + throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + } + + final List jsonResults = json.decode(response.body); + final results = jsonResults + .map((json) => SearchResult.fromNominatim(json as Map)) + .toList(); + + debugPrint('[SearchService] Found ${results.length} results'); + return results; + + } catch (e) { + debugPrint('[SearchService] Search failed: $e'); + throw Exception('Search failed: $e'); + } + } +} \ No newline at end of file diff --git a/lib/state/search_state.dart b/lib/state/search_state.dart new file mode 100644 index 0000000..3adccea --- /dev/null +++ b/lib/state/search_state.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/search_result.dart'; +import '../services/search_service.dart'; + +class SearchState extends ChangeNotifier { + final SearchService _searchService = SearchService(); + + bool _isLoading = false; + List _results = []; + String _lastQuery = ''; + + // Getters + bool get isLoading => _isLoading; + List get results => List.unmodifiable(_results); + String get lastQuery => _lastQuery; + + /// Search for places by query string + Future search(String query) async { + if (query.trim().isEmpty) { + _clearResults(); + return; + } + + // Don't search if query hasn't changed + if (query.trim() == _lastQuery.trim()) { + return; + } + + _setLoading(true); + _lastQuery = query.trim(); + + try { + final results = await _searchService.search(query.trim()); + _results = results; + debugPrint('[SearchState] Found ${results.length} results for "$query"'); + } catch (e) { + debugPrint('[SearchState] Search failed: $e'); + _results = []; + } + + _setLoading(false); + } + + /// Clear search results + void clearResults() { + _clearResults(); + } + + void _clearResults() { + if (_results.isNotEmpty || _lastQuery.isNotEmpty) { + _results = []; + _lastQuery = ''; + notifyListeners(); + } + } + + void _setLoading(bool loading) { + if (_isLoading != loading) { + _isLoading = loading; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/widgets/network_status_indicator.dart b/lib/widgets/network_status_indicator.dart index afee6b0..d68c0da 100644 --- a/lib/widgets/network_status_indicator.dart +++ b/lib/widgets/network_status_indicator.dart @@ -67,7 +67,7 @@ class NetworkStatusIndicator extends StatelessWidget { } return Positioned( - top: MediaQuery.of(context).padding.top + 8, + top: 8, // Position relative to the map area (not the screen) left: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart new file mode 100644 index 0000000..fb166f9 --- /dev/null +++ b/lib/widgets/search_bar.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app_state.dart'; +import '../models/search_result.dart'; +import '../widgets/debouncer.dart'; + +class LocationSearchBar extends StatefulWidget { + final void Function(SearchResult)? onResultSelected; + + const LocationSearchBar({ + super.key, + this.onResultSelected, + }); + + @override + State createState() => _LocationSearchBarState(); +} + +class _LocationSearchBarState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final Debouncer _searchDebouncer = Debouncer(const Duration(milliseconds: 500)); + + bool _showResults = false; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChanged); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + _searchDebouncer.dispose(); + super.dispose(); + } + + void _onFocusChanged() { + setState(() { + _showResults = _focusNode.hasFocus && _controller.text.isNotEmpty; + }); + } + + void _onSearchChanged(String query) { + setState(() { + _showResults = query.isNotEmpty && _focusNode.hasFocus; + }); + + if (query.isEmpty) { + context.read().clearSearchResults(); + return; + } + + // Debounce search to avoid too many API calls + _searchDebouncer(() { + if (mounted) { + context.read().search(query); + } + }); + } + + void _onResultTap(SearchResult result) { + _controller.text = result.displayName; + setState(() { + _showResults = false; + }); + _focusNode.unfocus(); + + widget.onResultSelected?.call(result); + } + + void _onClear() { + _controller.clear(); + context.read().clearSearchResults(); + setState(() { + _showResults = false; + }); + } + + Widget _buildResultsList(List results, bool isLoading) { + if (!_showResults) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + const Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Searching...'), + ], + ), + ) + else if (results.isEmpty && _controller.text.isNotEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('No results found'), + ) + else + ...results.map((result) => ListTile( + leading: Icon( + result.category == 'coordinates' ? Icons.place : Icons.location_on, + size: 20, + ), + title: Text( + result.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: result.type != null + ? Text(result.type!, style: Theme.of(context).textTheme.bodySmall) + : null, + dense: true, + onTap: () => _onResultTap(result), + )).toList(), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + return Column( + children: [ + Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Search places or coordinates...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: _onClear, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: _onSearchChanged, + ), + ), + _buildResultsList(appState.searchResults, appState.isSearchLoading), + ], + ); + }, + ); + } +} \ No newline at end of file