app

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

commit 45b9efc93081ea0bff02a44ab822458ffbbc929a
parent d233ed4d3ce6f0dd29c49a46a9f97f4fea52871a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 12:58:36 +0000

core: split home location tools modules

- replace the single home location tools file with a module directory
- move reverse lookup behavior and tests into a dedicated reverse_lookup module
- keep the public home location tools wrapper and core behavior unchanged
- preserve the existing core verification surface before adding the next consumer

Diffstat:
Acrates/core/src/home_location_tools/mod.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/core/src/home_location_tools/reverse_lookup.rs | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 514 insertions(+), 0 deletions(-)

diff --git a/crates/core/src/home_location_tools/mod.rs b/crates/core/src/home_location_tools/mod.rs @@ -0,0 +1,74 @@ +use crate::{ + RadrootsAppBackend, RadrootsOfflineGeocoderState, RadrootsReverseLocationLookupResult, +}; +use eframe::egui; + +mod reverse_lookup; + +#[cfg(test)] +use reverse_lookup::HomeLocationLookupResult; +use reverse_lookup::ReverseLookupTools; + +#[derive(Debug, Default, Clone, PartialEq)] +pub(crate) struct HomeLocationTools { + reverse_lookup: ReverseLookupTools, +} + +impl HomeLocationTools { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn clear(&mut self) { + self.reverse_lookup.clear(); + } + + #[cfg(test)] + pub(crate) fn set_query_inputs( + &mut self, + latitude: impl Into<String>, + longitude: impl Into<String>, + ) { + self.reverse_lookup.set_query_inputs(latitude, longitude); + } + + pub(crate) fn render( + &mut self, + ui: &mut egui::Ui, + backend: &dyn RadrootsAppBackend, + offline_geocoder_state: Option<&RadrootsOfflineGeocoderState>, + ) { + self.reverse_lookup + .render(ui, backend, offline_geocoder_state); + } + + pub(crate) fn apply_reverse_lookup_result( + &mut self, + result: RadrootsReverseLocationLookupResult, + ) { + self.reverse_lookup.apply_result(result); + } + + pub(crate) fn apply_reverse_lookup_poll_error(&mut self, message: String) { + self.reverse_lookup.apply_poll_error(message); + } + + #[cfg(test)] + pub(crate) fn begin_resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) { + self.reverse_lookup.begin_resolve_with_backend(backend); + } + + pub(crate) fn is_pending(&self) -> bool { + self.reverse_lookup.is_pending() + } + + #[cfg(test)] + pub(crate) fn status_message(&self) -> Option<&str> { + self.reverse_lookup.status_message() + } + + #[cfg(test)] + pub(crate) fn lookup_result(&self) -> Option<&HomeLocationLookupResult> { + self.reverse_lookup.lookup_result() + } +} diff --git a/crates/core/src/home_location_tools/reverse_lookup.rs b/crates/core/src/home_location_tools/reverse_lookup.rs @@ -0,0 +1,440 @@ +use crate::{ + RadrootsAppBackend, RadrootsLocationPoint, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, +}; +use eframe::egui; + +const HOME_LOOKUP_RESULT_LIMIT: usize = 3; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct HomeLocationLookupResult { + pub queried_point: RadrootsLocationPoint, + pub matches: Vec<RadrootsResolvedLocation>, +} + +#[derive(Debug, Clone, PartialEq)] +enum HomeLocationLookupState { + Idle, + Pending { + queried_point: RadrootsLocationPoint, + }, + Ready(HomeLocationLookupResult), + Failed { + message: String, + }, +} + +impl Default for HomeLocationLookupState { + fn default() -> Self { + Self::Idle + } +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub(super) struct ReverseLookupTools { + latitude_input: String, + longitude_input: String, + lookup_state: HomeLocationLookupState, +} + +impl ReverseLookupTools { + pub(super) fn clear(&mut self) { + self.latitude_input.clear(); + self.longitude_input.clear(); + self.lookup_state = HomeLocationLookupState::Idle; + } + + #[cfg(test)] + pub(super) fn set_query_inputs( + &mut self, + latitude: impl Into<String>, + longitude: impl Into<String>, + ) { + self.latitude_input = latitude.into(); + self.longitude_input = longitude.into(); + } + + pub(super) 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) && !self.is_pending(); + if ui + .add_enabled( + resolve_enabled, + egui::Button::new(self.resolve_button_label()), + ) + .clicked() + { + self.begin_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(HOME_LOOKUP_RESULT_LIMIT) { + 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), + )); + } + } + } + + pub(super) fn begin_resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) { + self.lookup_state = HomeLocationLookupState::Idle; + + let query_point = match self.parse_query_point() { + Ok(point) => point, + Err(message) => { + self.lookup_state = HomeLocationLookupState::Failed { message }; + return; + } + }; + + let options = RadrootsLocationReverseOptions { + limit: HOME_LOOKUP_RESULT_LIMIT, + ..RadrootsLocationReverseOptions::default() + }; + match backend.request_reverse_location_lookup(query_point, Some(options)) { + Ok(()) => { + self.lookup_state = HomeLocationLookupState::Pending { + queried_point: query_point, + }; + } + Err(error) => { + self.lookup_state = HomeLocationLookupState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + pub(super) fn apply_result(&mut self, result: RadrootsReverseLocationLookupResult) { + let queried_point = match self.lookup_state { + HomeLocationLookupState::Pending { queried_point } => queried_point, + HomeLocationLookupState::Idle + | HomeLocationLookupState::Ready(_) + | HomeLocationLookupState::Failed { .. } => return, + }; + + match result { + Ok(matches) if matches.is_empty() => { + self.lookup_state = HomeLocationLookupState::Failed { + message: "No offline location matched that coordinate.".to_owned(), + }; + } + Ok(matches) => { + self.lookup_state = HomeLocationLookupState::Ready(HomeLocationLookupResult { + queried_point, + matches, + }); + } + Err(error) => { + self.lookup_state = HomeLocationLookupState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + pub(super) fn apply_poll_error(&mut self, message: String) { + self.lookup_state = HomeLocationLookupState::Failed { message }; + } + + pub(super) fn is_pending(&self) -> bool { + matches!(self.lookup_state, HomeLocationLookupState::Pending { .. }) + } + + 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 resolve_button_label(&self) -> &'static str { + if self.is_pending() { + "Resolving Offline Location..." + } else { + "Resolve Offline Location" + } + } + + pub(super) fn status_message(&self) -> Option<&str> { + match &self.lookup_state { + HomeLocationLookupState::Idle | HomeLocationLookupState::Ready(_) => None, + HomeLocationLookupState::Pending { .. } => Some("Resolving offline location..."), + HomeLocationLookupState::Failed { message } => Some(message.as_str()), + } + } + + pub(super) fn lookup_result(&self) -> Option<&HomeLocationLookupResult> { + match &self.lookup_state { + HomeLocationLookupState::Ready(result) => Some(result), + HomeLocationLookupState::Idle + | HomeLocationLookupState::Pending { .. } + | HomeLocationLookupState::Failed { .. } => None, + } + } +} + +fn is_resolve_enabled(state: Option<&RadrootsOfflineGeocoderState>) -> bool { + matches!(state, 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, + }; + use std::cell::RefCell; + use std::rc::Rc; + + #[derive(Clone)] + struct ResolveBackend { + start_response: Result<(), RadrootsLocationResolverError>, + requested: Rc< + RefCell< + Vec<( + RadrootsLocationPoint, + Option<RadrootsLocationReverseOptions>, + )>, + >, + >, + } + + 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 request_reverse_location_lookup( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + self.requested.borrow_mut().push((point, options)); + self.start_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) + } + } + + fn resolve_backend( + start_response: Result<(), RadrootsLocationResolverError>, + ) -> ( + ResolveBackend, + Rc< + RefCell< + Vec<( + RadrootsLocationPoint, + Option<RadrootsLocationReverseOptions>, + )>, + >, + >, + ) { + let requested = Rc::new(RefCell::new(Vec::new())); + ( + ResolveBackend { + start_response, + requested: requested.clone(), + }, + requested, + ) + } + + #[test] + fn begin_resolve_requests_three_results() { + let (backend, requested) = resolve_backend(Ok(())); + let mut tools = ReverseLookupTools::default(); + tools.set_query_inputs("12.5", "-42.25"); + + tools.begin_resolve_with_backend(&backend); + + let requested = requested.borrow(); + assert_eq!(requested.len(), 1); + assert_eq!( + requested[0].0, + RadrootsLocationPoint { + lat: 12.5, + lng: -42.25, + } + ); + assert_eq!( + requested[0].1, + Some(RadrootsLocationReverseOptions { + limit: 3, + ..RadrootsLocationReverseOptions::default() + }) + ); + assert!(tools.is_pending()); + } + + #[test] + fn begin_resolve_rejects_out_of_range_coordinates() { + let (backend, requested) = resolve_backend(Ok(())); + let mut tools = ReverseLookupTools::default(); + tools.set_query_inputs("200", "10"); + + tools.begin_resolve_with_backend(&backend); + + assert!(requested.borrow().is_empty()); + assert_eq!( + tools.status_message(), + Some("latitude must be between -90 and 90") + ); + assert!(!tools.is_pending()); + } + + #[test] + fn apply_result_keeps_up_to_three_matches_available() { + let mut tools = ReverseLookupTools::default(); + tools.lookup_state = HomeLocationLookupState::Pending { + queried_point: RadrootsLocationPoint { + lat: 1.25, + lng: -2.5, + }, + }; + + tools.apply_result(Ok(vec![ + sample_result(1, "one"), + sample_result(2, "two"), + sample_result(3, "three"), + ])); + + let result = tools.lookup_result().expect("lookup result"); + assert_eq!(result.matches.len(), 3); + assert_eq!(result.matches[0].name, "one"); + assert_eq!(result.matches[2].name, "three"); + } + + #[test] + fn apply_poll_error_sets_failed_status() { + let mut tools = ReverseLookupTools::default(); + + tools.apply_poll_error("background worker failed".to_owned()); + + assert_eq!(tools.status_message(), Some("background worker failed")); + } + + fn sample_result(id: i64, name: &str) -> RadrootsResolvedLocation { + RadrootsResolvedLocation { + id, + name: name.to_owned(), + admin1_id: None, + admin1_name: Some("state".to_owned()), + country_id: "US".to_owned(), + country_name: Some("United States".to_owned()), + point: RadrootsLocationPoint { lat: 1.0, lng: 2.0 }, + } + } +}