app

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

commit b412f283ef77b14441c8926bb43a85e27b309aed
parent 3f38899f5a918f16be78ecea652c4c0b85a4787a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 12:04:00 +0000

core: make home location lookup async

- move the home offline location tool onto the polled reverse-lookup contract instead of running queries on the ui thread
- add explicit idle pending ready and failed lookup state in the home tool and repaint while a lookup is in flight
- request an explicit three-result reverse lookup and require the offline geocoder to be ready before enabling the action
- cover the new state transitions with core tests including the deferred lookup poll path

Diffstat:
Mcrates/core/src/home_location_tools.rs | 276++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/core/src/lib.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 316 insertions(+), 58 deletions(-)

diff --git a/crates/core/src/home_location_tools.rs b/crates/core/src/home_location_tools.rs @@ -1,21 +1,40 @@ use crate::{ RadrootsAppBackend, RadrootsLocationPoint, RadrootsLocationReverseOptions, - RadrootsOfflineGeocoderState, RadrootsResolvedLocation, + 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(crate) struct HomeLocationTools { latitude_input: String, longitude_input: String, - status_message: Option<String>, - lookup_result: Option<HomeLocationLookupResult>, + lookup_state: HomeLocationLookupState, } impl HomeLocationTools { @@ -26,8 +45,17 @@ impl HomeLocationTools { pub(crate) fn clear(&mut self) { self.latitude_input.clear(); self.longitude_input.clear(); - self.status_message = None; - self.lookup_result = None; + self.lookup_state = HomeLocationLookupState::Idle; + } + + #[cfg(test)] + pub(crate) 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(crate) fn render( @@ -59,15 +87,15 @@ impl HomeLocationTools { }); ui.add_space(8.0); - let resolve_enabled = is_resolve_enabled(offline_geocoder_state); + let resolve_enabled = is_resolve_enabled(offline_geocoder_state) && !self.is_pending(); if ui .add_enabled( resolve_enabled, - egui::Button::new("Resolve Offline Location"), + egui::Button::new(self.resolve_button_label()), ) .clicked() { - self.resolve_with_backend(backend); + self.begin_resolve_with_backend(backend); } if let Some(helper_message) = availability_message(offline_geocoder_state) { @@ -75,19 +103,19 @@ impl HomeLocationTools { ui.label(helper_message); } - if let Some(message) = &self.status_message { + if let Some(message) = self.status_message() { ui.add_space(8.0); ui.label(message); } - if let Some(result) = &self.lookup_result { + 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) { + 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 { @@ -107,45 +135,108 @@ impl HomeLocationTools { } } - fn resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) { - self.status_message = None; - self.lookup_result = None; + pub(crate) 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.status_message = Some(message); + self.lookup_state = HomeLocationLookupState::Failed { message }; return; } }; - match backend.reverse_location(query_point, Some(RadrootsLocationReverseOptions::default())) - { + 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(crate) fn apply_reverse_lookup_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.status_message = - Some("No offline location matched that coordinate.".to_owned()); + self.lookup_state = HomeLocationLookupState::Failed { + message: "No offline location matched that coordinate.".to_owned(), + }; } Ok(matches) => { - self.lookup_result = Some(HomeLocationLookupResult { - queried_point: query_point, + self.lookup_state = HomeLocationLookupState::Ready(HomeLocationLookupResult { + queried_point, matches, }); } Err(error) => { - self.status_message = Some(error.user_message().to_owned()); + self.lookup_state = HomeLocationLookupState::Failed { + message: error.user_message().to_owned(), + }; } } } + pub(crate) fn apply_reverse_lookup_poll_error(&mut self, message: String) { + self.lookup_state = HomeLocationLookupState::Failed { message }; + } + + pub(crate) 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(crate) 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(crate) 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, None | Some(RadrootsOfflineGeocoderState::Ready)) + matches!(state, Some(RadrootsOfflineGeocoderState::Ready)) } fn availability_message(state: Option<&RadrootsOfflineGeocoderState>) -> Option<&str> { @@ -189,10 +280,20 @@ mod tests { use crate::{ IdentityGateState, RadrootsLocationCountry, RadrootsLocationResolverError, SetupActionState, }; + use std::cell::RefCell; + use std::rc::Rc; #[derive(Clone)] struct ResolveBackend { - response: Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError>, + start_response: Result<(), RadrootsLocationResolverError>, + requested: Rc< + RefCell< + Vec<( + RadrootsLocationPoint, + Option<RadrootsLocationReverseOptions>, + )>, + >, + >, } impl RadrootsAppBackend for ResolveBackend { @@ -212,12 +313,13 @@ mod tests { Ok(None) } - fn reverse_location( + fn request_reverse_location_lookup( &self, - _point: RadrootsLocationPoint, - _options: Option<RadrootsLocationReverseOptions>, - ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { - self.response.clone() + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + self.requested.borrow_mut().push((point, options)); + self.start_response.clone() } fn list_location_countries( @@ -239,21 +341,15 @@ mod tests { let mut tools = HomeLocationTools::new(); tools.latitude_input = "10.5".to_owned(); tools.longitude_input = "20.5".to_owned(); - tools.status_message = Some("lookup failed".to_owned()); - tools.lookup_result = Some(HomeLocationLookupResult { - queried_point: RadrootsLocationPoint { - lat: 10.5, - lng: 20.5, - }, - matches: Vec::new(), - }); + tools.lookup_state = HomeLocationLookupState::Failed { + message: "lookup failed".to_owned(), + }; tools.clear(); assert_eq!(tools.latitude_input, ""); assert_eq!(tools.longitude_input, ""); - assert_eq!(tools.status_message, None); - assert_eq!(tools.lookup_result, None); + assert_eq!(tools.lookup_state, HomeLocationLookupState::Idle); } #[test] @@ -308,31 +404,72 @@ mod tests { } #[test] - fn resolve_with_backend_stores_matches() { + fn begin_resolve_with_backend_starts_pending_lookup_with_three_result_limit() { + let requested = Rc::new(RefCell::new(Vec::new())); 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 { + start_response: Ok(()), + requested: requested.clone(), + }; + let mut tools = HomeLocationTools::new(); + tools.latitude_input = "59.9139".to_owned(); + tools.longitude_input = "10.7522".to_owned(); + + tools.begin_resolve_with_backend(&backend); + + assert_eq!( + tools.lookup_state, + HomeLocationLookupState::Pending { + queried_point: RadrootsLocationPoint { lat: 59.9139, lng: 10.7522, }, - }]), + } + ); + assert_eq!(requested.borrow().len(), 1); + assert_eq!( + requested.borrow()[0], + ( + RadrootsLocationPoint { + lat: 59.9139, + lng: 10.7522, + }, + Some(RadrootsLocationReverseOptions { + limit: HOME_LOOKUP_RESULT_LIMIT, + degree_offset: 0.5, + }), + ) + ); + } + + #[test] + fn apply_reverse_lookup_result_stores_matches() { + let requested = Rc::new(RefCell::new(Vec::new())); + let backend = ResolveBackend { + start_response: Ok(()), + requested, }; let mut tools = HomeLocationTools::new(); tools.latitude_input = "59.9139".to_owned(); tools.longitude_input = "10.7522".to_owned(); + tools.begin_resolve_with_backend(&backend); + + tools.apply_reverse_lookup_result(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, + }, + }])); - tools.resolve_with_backend(&backend); - - assert_eq!(tools.status_message, None); + assert_eq!(tools.status_message(), None); assert_eq!( tools - .lookup_result + .lookup_result() .as_ref() .map(|result| result.matches.len()), Some(1) @@ -340,20 +477,43 @@ mod tests { } #[test] - fn resolve_with_backend_uses_user_safe_query_error_message() { + fn begin_resolve_with_backend_uses_user_safe_query_error_message() { + let requested = Rc::new(RefCell::new(Vec::new())); + let backend = ResolveBackend { + start_response: Err(RadrootsLocationResolverError::Unavailable), + requested, + }; + let mut tools = HomeLocationTools::new(); + tools.latitude_input = "59.9139".to_owned(); + tools.longitude_input = "10.7522".to_owned(); + + tools.begin_resolve_with_backend(&backend); + + assert_eq!( + tools.status_message(), + Some("Offline location resolution is not available on this device.") + ); + assert_eq!(tools.lookup_result(), None); + } + + #[test] + fn apply_reverse_lookup_result_uses_user_safe_query_error_message() { + let requested = Rc::new(RefCell::new(Vec::new())); let backend = ResolveBackend { - response: Err(RadrootsLocationResolverError::Unavailable), + start_response: Ok(()), + requested, }; let mut tools = HomeLocationTools::new(); tools.latitude_input = "59.9139".to_owned(); tools.longitude_input = "10.7522".to_owned(); + tools.begin_resolve_with_backend(&backend); - tools.resolve_with_backend(&backend); + tools.apply_reverse_lookup_result(Err(RadrootsLocationResolverError::Unavailable)); assert_eq!( - tools.status_message.as_deref(), + tools.status_message(), Some("Offline location resolution is not available on this device.") ); - assert_eq!(tools.lookup_result, None); + assert_eq!(tools.lookup_result(), None); } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -316,6 +316,14 @@ impl RadrootsApp { self.status_message = Some(err); } } + match self.backend.poll_reverse_location_lookup_result() { + Ok(Some(result)) => self.home_location_tools.apply_reverse_lookup_result(result), + Ok(None) => {} + Err(err) => { + self.home_location_tools + .apply_reverse_lookup_poll_error(err); + } + } match self.backend.poll_identity_state() { Ok(Some(state)) => self.apply_identity_state(state), Ok(None) => {} @@ -371,6 +379,9 @@ impl eframe::App for RadrootsApp { ) { ctx.request_repaint_after(Duration::from_millis(100)); } + if self.home_location_tools.is_pending() { + ctx.request_repaint_after(Duration::from_millis(100)); + } egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { @@ -568,6 +579,9 @@ mod tests { import_paste_request: Rc<RefCell<VecDeque<Result<Option<String>, String>>>>, home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>, home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>, + reverse_lookup_request: Rc<RefCell<VecDeque<Result<(), RadrootsLocationResolverError>>>>, + reverse_lookup_poll: + Rc<RefCell<VecDeque<Result<Option<RadrootsReverseLocationLookupResult>, String>>>>, poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, } @@ -591,6 +605,8 @@ mod tests { import_paste_request: Rc::new(RefCell::new(VecDeque::new())), home_request: Rc::new(RefCell::new(VecDeque::new())), home_poll: Rc::new(RefCell::new(VecDeque::new())), + reverse_lookup_request: Rc::new(RefCell::new(VecDeque::new())), + reverse_lookup_poll: Rc::new(RefCell::new(VecDeque::new())), poll: Rc::new(RefCell::new(poll.into())), } } @@ -648,6 +664,16 @@ mod tests { self.home_poll.borrow_mut().extend(poll); self } + + fn with_reverse_lookup( + self, + request: Vec<Result<(), RadrootsLocationResolverError>>, + poll: Vec<Result<Option<RadrootsReverseLocationLookupResult>, String>>, + ) -> Self { + self.reverse_lookup_request.borrow_mut().extend(request); + self.reverse_lookup_poll.borrow_mut().extend(poll); + self + } } impl RadrootsAppBackend for MockBackend { @@ -726,6 +752,26 @@ mod tests { self.home_poll.borrow_mut().pop_front().unwrap_or(Ok(None)) } + fn request_reverse_location_lookup( + &self, + _point: RadrootsLocationPoint, + _options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + self.reverse_lookup_request + .borrow_mut() + .pop_front() + .unwrap_or(Err(RadrootsLocationResolverError::Unsupported)) + } + + fn poll_reverse_location_lookup_result( + &self, + ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { + self.reverse_lookup_poll + .borrow_mut() + .pop_front() + .unwrap_or(Ok(None)) + } + fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { self.poll.borrow_mut().pop_front().unwrap_or(Ok(None)) } @@ -1157,6 +1203,58 @@ mod tests { } #[test] + fn deferred_home_location_lookup_updates_after_poll() { + let mut app = RadrootsApp::new(Box::new( + MockBackend::new( + Ok(IdentityGateState::Ready { + account_id: "abc".into(), + npub: "npub1abc".into(), + }), + vec![], + vec![], + SetupActionState { + label: "Generate New Key".into(), + enabled: true, + pending: false, + }, + ) + .with_offline_geocoder_state(RadrootsOfflineGeocoderState::Ready, vec![]) + .with_reverse_lookup( + vec![Ok(())], + vec![Ok(Some(Ok(vec![RadrootsResolvedLocation { + id: 7, + name: "Paris".into(), + admin1_id: Some(11), + admin1_name: Some("Ile-de-France".into()), + country_id: "FR".into(), + country_name: Some("France".into()), + point: RadrootsLocationPoint { + lat: 48.8566, + lng: 2.3522, + }, + }])))], + ), + )); + + app.home_location_tools + .set_query_inputs("48.8566", "2.3522"); + app.home_location_tools + .begin_resolve_with_backend(app.backend.as_ref()); + assert!(app.home_location_tools.is_pending()); + + app.sync_backend(); + + assert_eq!(app.home_location_tools.status_message(), None); + assert_eq!( + app.home_location_tools + .lookup_result() + .as_ref() + .map(|result| result.matches[0].name.as_str()), + Some("Paris") + ); + } + + #[test] fn startup_uses_initial_offline_geocoder_state() { let app = RadrootsApp::new(Box::new( MockBackend::new(