app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 5247dd45151031fbdf78712215b9530356ea94d3
parent 2d6c259ef6c15d16e849445dfab6504c79b2951f
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 02:23:03 +0000

core: add home offline location lookup

- add a one-shot home lookup tool that resolves manual latitude and longitude input through the shared location resolver boundary
- keep the feature explicit and low-frequency with typed coordinate validation instead of introducing live geocoder query loops
- show ready initializing and unavailable geocoder states in the tool surface without leaking raw debug detail into normal ui
- add core tests for coordinate parsing availability messaging and user-safe lookup error handling

Diffstat:
Mcrates/core/src/home_location_tools.rs | 306++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/core/src/lib.rs | 5+++++
2 files changed, 310 insertions(+), 1 deletion(-)

diff --git a/crates/core/src/home_location_tools.rs b/crates/core/src/home_location_tools.rs @@ -1,4 +1,8 @@ -use crate::{RadrootsLocationPoint, RadrootsResolvedLocation}; +use crate::{ + RadrootsAppBackend, RadrootsLocationPoint, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsResolvedLocation, +}; +use eframe::egui; #[derive(Debug, Clone, PartialEq)] pub(crate) struct HomeLocationLookupResult { @@ -25,11 +29,210 @@ impl HomeLocationTools { self.status_message = None; self.lookup_result = None; } + + pub(crate) fn render( + &mut self, + ui: &mut egui::Ui, + backend: &dyn RadrootsAppBackend, + offline_geocoder_state: Option<&RadrootsOfflineGeocoderState>, + ) { + ui.add_space(20.0); + ui.label("Offline location lookup"); + ui.add_space(8.0); + ui.label("Resolve a latitude and longitude pair using the on-device geocoder."); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Latitude"); + ui.add( + egui::TextEdit::singleline(&mut self.latitude_input) + .hint_text("12.34") + .desired_width(140.0), + ); + ui.add_space(8.0); + ui.label("Longitude"); + ui.add( + egui::TextEdit::singleline(&mut self.longitude_input) + .hint_text("-56.78") + .desired_width(140.0), + ); + }); + ui.add_space(8.0); + + let resolve_enabled = is_resolve_enabled(offline_geocoder_state); + if ui + .add_enabled( + resolve_enabled, + egui::Button::new("Resolve Offline Location"), + ) + .clicked() + { + self.resolve_with_backend(backend); + } + + if let Some(helper_message) = availability_message(offline_geocoder_state) { + ui.add_space(8.0); + ui.label(helper_message); + } + + if let Some(message) = &self.status_message { + ui.add_space(8.0); + ui.label(message); + } + + if let Some(result) = &self.lookup_result { + ui.add_space(12.0); + ui.label(format!( + "Query: {}, {}", + format_coordinate(result.queried_point.lat), + format_coordinate(result.queried_point.lng), + )); + for resolved in result.matches.iter().take(3) { + ui.add_space(8.0); + ui.label(resolved.name.as_str()); + if let Some(admin1_name) = &resolved.admin1_name { + ui.label(admin1_name.as_str()); + } + if let Some(country_name) = &resolved.country_name { + ui.label(country_name.as_str()); + } else { + ui.label(resolved.country_id.as_str()); + } + ui.monospace(format!( + "{}, {}", + format_coordinate(resolved.point.lat), + format_coordinate(resolved.point.lng), + )); + } + } + } + + fn resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) { + self.status_message = None; + self.lookup_result = None; + + let query_point = match self.parse_query_point() { + Ok(point) => point, + Err(message) => { + self.status_message = Some(message); + return; + } + }; + + match backend.reverse_location(query_point, Some(RadrootsLocationReverseOptions::default())) + { + Ok(matches) if matches.is_empty() => { + self.status_message = + Some("No offline location matched that coordinate.".to_owned()); + } + Ok(matches) => { + self.lookup_result = Some(HomeLocationLookupResult { + queried_point: query_point, + matches, + }); + } + Err(error) => { + self.status_message = Some(error.user_message().to_owned()); + } + } + } + + fn parse_query_point(&self) -> Result<RadrootsLocationPoint, String> { + let lat = parse_coordinate(self.latitude_input.as_str(), "latitude", -90.0, 90.0)?; + let lng = parse_coordinate(self.longitude_input.as_str(), "longitude", -180.0, 180.0)?; + Ok(RadrootsLocationPoint { lat, lng }) + } +} + +fn is_resolve_enabled(state: Option<&RadrootsOfflineGeocoderState>) -> bool { + matches!(state, None | Some(RadrootsOfflineGeocoderState::Ready)) +} + +fn availability_message(state: Option<&RadrootsOfflineGeocoderState>) -> Option<&str> { + match state { + Some(RadrootsOfflineGeocoderState::Initializing) => { + Some("Offline location resolution is still initializing on this device.") + } + Some(RadrootsOfflineGeocoderState::Unavailable { .. }) => { + state.and_then(RadrootsOfflineGeocoderState::user_message) + } + Some(RadrootsOfflineGeocoderState::Ready) | None => None, + } +} + +fn parse_coordinate(raw: &str, label: &str, min: f64, max: f64) -> Result<f64, String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(format!("{label} is required")); + } + + let value = trimmed + .parse::<f64>() + .map_err(|_| format!("{label} must be a valid number"))?; + if !value.is_finite() { + return Err(format!("{label} must be a finite number")); + } + if value < min || value > max { + return Err(format!("{label} must be between {min} and {max}")); + } + + Ok(value) +} + +fn format_coordinate(value: f64) -> String { + format!("{value:.4}") } #[cfg(test)] mod tests { use super::*; + use crate::{ + IdentityGateState, RadrootsLocationCountry, RadrootsLocationResolverError, SetupActionState, + }; + + #[derive(Clone)] + struct ResolveBackend { + response: Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError>, + } + + impl RadrootsAppBackend for ResolveBackend { + fn load_identity_state(&self) -> Result<IdentityGateState, String> { + Ok(IdentityGateState::Missing) + } + + fn setup_action_state(&self) -> SetupActionState { + SetupActionState { + label: "Generate New Key".to_owned(), + enabled: true, + pending: false, + } + } + + fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> { + Ok(None) + } + + fn reverse_location( + &self, + _point: RadrootsLocationPoint, + _options: Option<RadrootsLocationReverseOptions>, + ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { + self.response.clone() + } + + fn list_location_countries( + &self, + ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + fn location_country_center( + &self, + _country_id: &str, + ) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + } #[test] fn clear_resets_inputs_and_feedback() { @@ -52,4 +255,105 @@ mod tests { assert_eq!(tools.status_message, None); assert_eq!(tools.lookup_result, None); } + + #[test] + fn parse_query_point_accepts_trimmed_valid_coordinates() { + let mut tools = HomeLocationTools::new(); + tools.latitude_input = " 12.34 ".to_owned(); + tools.longitude_input = "\n-56.78\t".to_owned(); + + assert_eq!( + tools.parse_query_point(), + Ok(RadrootsLocationPoint { + lat: 12.34, + lng: -56.78, + }) + ); + } + + #[test] + fn parse_query_point_rejects_missing_and_out_of_range_values() { + let mut tools = HomeLocationTools::new(); + + assert_eq!( + tools.parse_query_point(), + Err("latitude is required".to_owned()) + ); + + tools.latitude_input = "91".to_owned(); + tools.longitude_input = "20".to_owned(); + assert_eq!( + tools.parse_query_point(), + Err("latitude must be between -90 and 90".to_owned()) + ); + + tools.latitude_input = "10".to_owned(); + tools.longitude_input = "-181".to_owned(); + assert_eq!( + tools.parse_query_point(), + Err("longitude must be between -180 and 180".to_owned()) + ); + } + + #[test] + fn availability_message_matches_geocoder_state() { + assert_eq!( + availability_message(Some(&RadrootsOfflineGeocoderState::Initializing)), + Some("Offline location resolution is still initializing on this device.") + ); + assert_eq!( + availability_message(Some(&RadrootsOfflineGeocoderState::Ready)), + None + ); + } + + #[test] + fn resolve_with_backend_stores_matches() { + let backend = ResolveBackend { + response: Ok(vec![RadrootsResolvedLocation { + id: 1, + name: "Oslo".to_owned(), + admin1_id: Some(2), + admin1_name: Some("Oslo".to_owned()), + country_id: "NO".to_owned(), + country_name: Some("Norway".to_owned()), + point: RadrootsLocationPoint { + lat: 59.9139, + lng: 10.7522, + }, + }]), + }; + let mut tools = HomeLocationTools::new(); + tools.latitude_input = "59.9139".to_owned(); + tools.longitude_input = "10.7522".to_owned(); + + tools.resolve_with_backend(&backend); + + assert_eq!(tools.status_message, None); + assert_eq!( + tools + .lookup_result + .as_ref() + .map(|result| result.matches.len()), + Some(1) + ); + } + + #[test] + fn resolve_with_backend_uses_user_safe_query_error_message() { + let backend = ResolveBackend { + response: Err(RadrootsLocationResolverError::Unavailable), + }; + let mut tools = HomeLocationTools::new(); + tools.latitude_input = "59.9139".to_owned(); + tools.longitude_input = "10.7522".to_owned(); + + tools.resolve_with_backend(&backend); + + assert_eq!( + tools.status_message.as_deref(), + Some("Offline location resolution is not available on this device.") + ); + assert_eq!(tools.lookup_result, None); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -456,6 +456,11 @@ impl eframe::App for RadrootsApp { ui.add_space(12.0); ui.monospace(format!("account id: {account_id}")); ui.monospace(format!("npub: {npub}")); + self.home_location_tools.render( + ui, + self.backend.as_ref(), + self.offline_geocoder_state.as_ref(), + ); let actions = self.backend.home_action_states(); for (index, action) in actions.into_iter().enumerate() {