Search address / POI

This commit is contained in:
stopflock
2025-10-02 03:25:04 -05:00
parent 9ad7e82e93
commit 4e072a34c0
7 changed files with 525 additions and 75 deletions

View File

@@ -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<OperatorProfile> get operatorProfiles => _operatorProfileState.profiles;
// Search state
bool get isSearchLoading => _searchState.isLoading;
List<SearchResult> 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<void> search(String query) async {
await _searchState.search(query);
}
void clearSearchResults() {
_searchState.clearResults();
}
// ---------- Settings Methods ----------
Future<void> 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);

View File

@@ -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<String, dynamic> 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;
}
}

View File

@@ -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<HomeScreen> 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<HomeScreen> 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),
),
),
),
),
],
),
),
),
),
),
],
),
),
],

View File

@@ -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<List<SearchResult>> 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<List<SearchResult>> _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<dynamic> jsonResults = json.decode(response.body);
final results = jsonResults
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
.toList();
debugPrint('[SearchService] Found ${results.length} results');
return results;
} catch (e) {
debugPrint('[SearchService] Search failed: $e');
throw Exception('Search failed: $e');
}
}
}

View File

@@ -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<SearchResult> _results = [];
String _lastQuery = '';
// Getters
bool get isLoading => _isLoading;
List<SearchResult> get results => List.unmodifiable(_results);
String get lastQuery => _lastQuery;
/// Search for places by query string
Future<void> 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();
}
}
}

View File

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

195
lib/widgets/search_bar.dart Normal file
View File

@@ -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<LocationSearchBar> createState() => _LocationSearchBarState();
}
class _LocationSearchBarState extends State<LocationSearchBar> {
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<AppState>().clearSearchResults();
return;
}
// Debounce search to avoid too many API calls
_searchDebouncer(() {
if (mounted) {
context.read<AppState>().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<AppState>().clearSearchResults();
setState(() {
_showResults = false;
});
}
Widget _buildResultsList(List<SearchResult> 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<AppState>(
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),
],
);
},
);
}
}