app

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

commit fc873b10183c6b0af47c7d48b0700501f946b42c
parent 45b9efc93081ea0bff02a44ab822458ffbbc929a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:03:05 +0000

core: add async home country lookup

- add async request and poll contracts for country list and country center queries
- add a home country lookup tool with non-blocking state transitions and result rendering
- export typed country lookup result aliases alongside the existing location resolver boundary
- remove the legacy flat home location tools file as part of the module split transition

Diffstat:
Dcrates/core/src/home_location_tools.rs | 519-------------------------------------------------------------------------------
Acrates/core/src/home_location_tools/country_lookup.rs | 536+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/home_location_tools/mod.rs | 31+++++++++++++++++++++++++++++--
Mcrates/core/src/lib.rs | 37++++++++++++++++++++++++++++++++++++-
Mcrates/core/src/location_resolver.rs | 6++++++
5 files changed, 607 insertions(+), 522 deletions(-)

diff --git a/crates/core/src/home_location_tools.rs b/crates/core/src/home_location_tools.rs @@ -1,519 +0,0 @@ -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(crate) struct HomeLocationTools { - latitude_input: String, - longitude_input: String, - lookup_state: HomeLocationLookupState, -} - -impl HomeLocationTools { - pub(crate) fn new() -> Self { - Self::default() - } - - pub(crate) fn clear(&mut self) { - self.latitude_input.clear(); - self.longitude_input.clear(); - 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( - &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(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.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(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.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(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, 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) - } - } - - #[test] - fn clear_resets_inputs_and_feedback() { - let mut tools = HomeLocationTools::new(); - tools.latitude_input = "10.5".to_owned(); - tools.longitude_input = "20.5".to_owned(); - 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.lookup_state, HomeLocationLookupState::Idle); - } - - #[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 begin_resolve_with_backend_starts_pending_lookup_with_three_result_limit() { - let requested = Rc::new(RefCell::new(Vec::new())); - let backend = ResolveBackend { - 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, - }, - }])); - - assert_eq!(tools.status_message(), None); - assert_eq!( - tools - .lookup_result() - .as_ref() - .map(|result| result.matches.len()), - Some(1) - ); - } - - #[test] - 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 { - 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(Err(RadrootsLocationResolverError::Unavailable)); - - assert_eq!( - tools.status_message(), - Some("Offline location resolution is not available on this device.") - ); - assert_eq!(tools.lookup_result(), None); - } -} diff --git a/crates/core/src/home_location_tools/country_lookup.rs b/crates/core/src/home_location_tools/country_lookup.rs @@ -0,0 +1,536 @@ +use crate::{ + RadrootsAppBackend, RadrootsLocationCountry, RadrootsLocationCountryCenterLookupResult, + RadrootsLocationCountryListResult, RadrootsLocationPoint, RadrootsOfflineGeocoderState, +}; +use eframe::egui; + +#[derive(Debug, Clone, PartialEq)] +enum CountryListState { + Idle, + Pending, + Ready(Vec<RadrootsLocationCountry>), + Failed { message: String }, +} + +impl Default for CountryListState { + fn default() -> Self { + Self::Idle + } +} + +#[derive(Debug, Clone, PartialEq)] +struct CountryCenterLookupResult { + country_id: String, + country_name: Option<String>, + center: RadrootsLocationPoint, +} + +#[derive(Debug, Clone, PartialEq)] +enum CountryCenterState { + Idle, + Pending { country_id: String }, + Ready(CountryCenterLookupResult), + Failed { message: String }, +} + +impl Default for CountryCenterState { + fn default() -> Self { + Self::Idle + } +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub(super) struct CountryLookupTools { + countries: CountryListState, + selected_country_id: Option<String>, + center: CountryCenterState, +} + +impl CountryLookupTools { + pub(super) fn clear(&mut self) { + self.countries = CountryListState::Idle; + self.selected_country_id = None; + self.center = CountryCenterState::Idle; + } + + 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 country lookup"); + ui.add_space(8.0); + ui.label("Load country data and resolve a country center using the on-device geocoder."); + ui.add_space(8.0); + + let load_enabled = + is_country_action_enabled(offline_geocoder_state) && !self.is_list_pending(); + if ui + .add_enabled(load_enabled, egui::Button::new(self.load_button_label())) + .clicked() + { + self.begin_load_countries(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.list_status_message() { + ui.add_space(8.0); + ui.label(message); + } + + if let Some(countries) = self.ready_countries().cloned() { + ui.add_space(8.0); + let selected_country_id = &mut self.selected_country_id; + let selected_text = + country_label_for_id(countries.as_slice(), selected_country_id.as_deref()); + egui::ComboBox::from_label("Country") + .selected_text(selected_text) + .show_ui(ui, |ui| { + for country in countries.as_slice() { + let response = ui.selectable_value( + selected_country_id, + Some(country.country_id.clone()), + country_label(country), + ); + if response.clicked() { + self.center = CountryCenterState::Idle; + } + } + }); + + ui.add_space(8.0); + let center_enabled = + is_country_action_enabled(offline_geocoder_state) && !self.is_center_pending(); + if ui + .add_enabled( + center_enabled, + egui::Button::new(self.center_button_label()), + ) + .clicked() + { + self.begin_resolve_country_center(backend); + } + } + + if let Some(message) = self.center_status_message() { + ui.add_space(8.0); + ui.label(message); + } + + if let Some(result) = self.center_result() { + ui.add_space(12.0); + ui.label( + result + .country_name + .as_deref() + .unwrap_or(result.country_id.as_str()), + ); + ui.monospace(format!( + "{}, {}", + format_coordinate(result.center.lat), + format_coordinate(result.center.lng), + )); + } + } + + pub(super) fn apply_list_result(&mut self, result: RadrootsLocationCountryListResult) { + match result { + Ok(countries) if countries.is_empty() => { + self.countries = CountryListState::Failed { + message: "No offline countries are available.".to_owned(), + }; + self.selected_country_id = None; + self.center = CountryCenterState::Idle; + } + Ok(countries) => { + self.selected_country_id = selected_country_id_after_refresh( + self.selected_country_id.as_deref(), + countries.as_slice(), + ); + self.countries = CountryListState::Ready(countries); + self.center = CountryCenterState::Idle; + } + Err(error) => { + self.countries = CountryListState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + pub(super) fn apply_list_poll_error(&mut self, message: String) { + self.countries = CountryListState::Failed { message }; + } + + pub(super) fn apply_center_result( + &mut self, + result: RadrootsLocationCountryCenterLookupResult, + ) { + let country_id = match &self.center { + CountryCenterState::Pending { country_id } => country_id.clone(), + CountryCenterState::Idle + | CountryCenterState::Ready(_) + | CountryCenterState::Failed { .. } => return, + }; + + match result { + Ok(center) => { + self.center = CountryCenterState::Ready(CountryCenterLookupResult { + country_name: self.country_name_for_id(country_id.as_str()), + country_id, + center, + }); + } + Err(error) => { + self.center = CountryCenterState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + pub(super) fn apply_center_poll_error(&mut self, message: String) { + self.center = CountryCenterState::Failed { message }; + } + + pub(super) fn is_pending(&self) -> bool { + self.is_list_pending() || self.is_center_pending() + } + + fn begin_load_countries(&mut self, backend: &dyn RadrootsAppBackend) { + self.countries = CountryListState::Idle; + self.center = CountryCenterState::Idle; + + match backend.request_location_country_list() { + Ok(()) => { + self.countries = CountryListState::Pending; + } + Err(error) => { + self.countries = CountryListState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + fn begin_resolve_country_center(&mut self, backend: &dyn RadrootsAppBackend) { + let Some(country_id) = self.selected_country_id.clone() else { + self.center = CountryCenterState::Failed { + message: "Select a country first.".to_owned(), + }; + return; + }; + + match backend.request_location_country_center_lookup(country_id.as_str()) { + Ok(()) => { + self.center = CountryCenterState::Pending { country_id }; + } + Err(error) => { + self.center = CountryCenterState::Failed { + message: error.user_message().to_owned(), + }; + } + } + } + + fn is_list_pending(&self) -> bool { + matches!(self.countries, CountryListState::Pending) + } + + fn is_center_pending(&self) -> bool { + matches!(self.center, CountryCenterState::Pending { .. }) + } + + fn load_button_label(&self) -> &'static str { + if self.is_list_pending() { + "Loading Offline Countries..." + } else { + "Load Offline Countries" + } + } + + fn center_button_label(&self) -> &'static str { + if self.is_center_pending() { + "Resolving Country Center..." + } else { + "Resolve Country Center" + } + } + + fn list_status_message(&self) -> Option<&str> { + match &self.countries { + CountryListState::Idle | CountryListState::Ready(_) => None, + CountryListState::Pending => Some("Loading offline countries..."), + CountryListState::Failed { message } => Some(message.as_str()), + } + } + + fn center_status_message(&self) -> Option<&str> { + match &self.center { + CountryCenterState::Idle | CountryCenterState::Ready(_) => None, + CountryCenterState::Pending { .. } => Some("Resolving country center..."), + CountryCenterState::Failed { message } => Some(message.as_str()), + } + } + + fn ready_countries(&self) -> Option<&Vec<RadrootsLocationCountry>> { + match &self.countries { + CountryListState::Ready(countries) => Some(countries), + CountryListState::Idle + | CountryListState::Pending + | CountryListState::Failed { .. } => None, + } + } + + fn center_result(&self) -> Option<&CountryCenterLookupResult> { + match &self.center { + CountryCenterState::Ready(result) => Some(result), + CountryCenterState::Idle + | CountryCenterState::Pending { .. } + | CountryCenterState::Failed { .. } => None, + } + } + + fn country_name_for_id(&self, country_id: &str) -> Option<String> { + self.ready_countries() + .and_then(|countries| { + countries + .iter() + .find(|country| country.country_id == country_id) + .map(|country| country.country_name.clone()) + }) + .flatten() + } +} + +fn is_country_action_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 country lookup is still initializing on this device.") + } + Some(RadrootsOfflineGeocoderState::Unavailable { .. }) => { + state.and_then(RadrootsOfflineGeocoderState::user_message) + } + Some(RadrootsOfflineGeocoderState::Ready) | None => None, + } +} + +fn selected_country_id_after_refresh( + selected_country_id: Option<&str>, + countries: &[RadrootsLocationCountry], +) -> Option<String> { + if let Some(selected_country_id) = selected_country_id { + if countries + .iter() + .any(|country| country.country_id == selected_country_id) + { + return Some(selected_country_id.to_owned()); + } + } + + countries.first().map(|country| country.country_id.clone()) +} + +fn country_label(country: &RadrootsLocationCountry) -> String { + country + .country_name + .clone() + .unwrap_or_else(|| country.country_id.clone()) +} + +fn country_label_for_id(countries: &[RadrootsLocationCountry], country_id: Option<&str>) -> String { + country_id + .and_then(|country_id| { + countries + .iter() + .find(|country| country.country_id == country_id) + .map(country_label) + }) + .unwrap_or_else(|| "Select a country".to_owned()) +} + +fn format_coordinate(value: f64) -> String { + format!("{value:.4}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + IdentityGateState, RadrootsLocationResolverError, RadrootsReverseLocationLookupResult, + SetupActionState, + }; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + #[derive(Clone)] + struct CountryBackend { + list_request: Rc<RefCell<VecDeque<Result<(), RadrootsLocationResolverError>>>>, + center_request: Rc<RefCell<VecDeque<Result<(), RadrootsLocationResolverError>>>>, + requested_country_ids: Rc<RefCell<Vec<String>>>, + } + + impl RadrootsAppBackend for CountryBackend { + 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<crate::RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + + fn poll_reverse_location_lookup_result( + &self, + ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { + Ok(None) + } + + fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { + self.list_request.borrow_mut().pop_front().unwrap_or(Ok(())) + } + + fn request_location_country_center_lookup( + &self, + country_id: &str, + ) -> Result<(), RadrootsLocationResolverError> { + self.requested_country_ids + .borrow_mut() + .push(country_id.to_owned()); + self.center_request + .borrow_mut() + .pop_front() + .unwrap_or(Ok(())) + } + } + + fn country_backend( + list_request: Vec<Result<(), RadrootsLocationResolverError>>, + center_request: Vec<Result<(), RadrootsLocationResolverError>>, + ) -> (CountryBackend, Rc<RefCell<Vec<String>>>) { + let requested_country_ids = Rc::new(RefCell::new(Vec::new())); + ( + CountryBackend { + list_request: Rc::new(RefCell::new(list_request.into())), + center_request: Rc::new(RefCell::new(center_request.into())), + requested_country_ids: requested_country_ids.clone(), + }, + requested_country_ids, + ) + } + + #[test] + fn begin_load_countries_enters_pending_state() { + let (backend, _) = country_backend(vec![Ok(())], Vec::new()); + let mut tools = CountryLookupTools::default(); + + tools.begin_load_countries(&backend); + + assert_eq!( + tools.list_status_message(), + Some("Loading offline countries...") + ); + assert!(tools.is_pending()); + } + + #[test] + fn apply_list_result_selects_first_country() { + let mut tools = CountryLookupTools::default(); + + tools.apply_list_result(Ok(vec![ + sample_country("BR", Some("Brazil"), -14.235, -51.9253), + sample_country("KE", Some("Kenya"), 0.0236, 37.9062), + ])); + + assert_eq!(tools.selected_country_id.as_deref(), Some("BR")); + assert!(matches!(tools.ready_countries(), Some(countries) if countries.len() == 2)); + } + + #[test] + fn begin_resolve_country_center_uses_selected_country_id() { + let (backend, requested_country_ids) = country_backend(Vec::new(), vec![Ok(())]); + let mut tools = CountryLookupTools::default(); + tools.apply_list_result(Ok(vec![ + sample_country("BR", Some("Brazil"), -14.235, -51.9253), + sample_country("KE", Some("Kenya"), 0.0236, 37.9062), + ])); + tools.selected_country_id = Some("KE".to_owned()); + + tools.begin_resolve_country_center(&backend); + + assert_eq!(requested_country_ids.borrow().as_slice(), ["KE"]); + assert_eq!( + tools.center_status_message(), + Some("Resolving country center...") + ); + } + + #[test] + fn apply_center_result_records_country_center() { + let mut tools = CountryLookupTools::default(); + tools.apply_list_result(Ok(vec![sample_country( + "BR", + Some("Brazil"), + -14.235, + -51.9253, + )])); + tools.center = CountryCenterState::Pending { + country_id: "BR".to_owned(), + }; + + tools.apply_center_result(Ok(RadrootsLocationPoint { + lat: -14.235, + lng: -51.9253, + })); + + let result = tools.center_result().expect("country center result"); + assert_eq!(result.country_id, "BR"); + assert_eq!(result.country_name.as_deref(), Some("Brazil")); + assert_eq!( + result.center, + RadrootsLocationPoint { + lat: -14.235, + lng: -51.9253, + } + ); + } + + fn sample_country( + country_id: &str, + country_name: Option<&str>, + lat: f64, + lng: f64, + ) -> RadrootsLocationCountry { + RadrootsLocationCountry { + country_id: country_id.to_owned(), + country_name: country_name.map(str::to_owned), + center: RadrootsLocationPoint { lat, lng }, + } + } +} diff --git a/crates/core/src/home_location_tools/mod.rs b/crates/core/src/home_location_tools/mod.rs @@ -1,16 +1,21 @@ use crate::{ - RadrootsAppBackend, RadrootsOfflineGeocoderState, RadrootsReverseLocationLookupResult, + RadrootsAppBackend, RadrootsLocationCountryCenterLookupResult, + RadrootsLocationCountryListResult, RadrootsOfflineGeocoderState, + RadrootsReverseLocationLookupResult, }; use eframe::egui; +mod country_lookup; mod reverse_lookup; +use country_lookup::CountryLookupTools; #[cfg(test)] use reverse_lookup::HomeLocationLookupResult; use reverse_lookup::ReverseLookupTools; #[derive(Debug, Default, Clone, PartialEq)] pub(crate) struct HomeLocationTools { + country_lookup: CountryLookupTools, reverse_lookup: ReverseLookupTools, } @@ -20,6 +25,7 @@ impl HomeLocationTools { } pub(crate) fn clear(&mut self) { + self.country_lookup.clear(); self.reverse_lookup.clear(); } @@ -40,6 +46,8 @@ impl HomeLocationTools { ) { self.reverse_lookup .render(ui, backend, offline_geocoder_state); + self.country_lookup + .render(ui, backend, offline_geocoder_state); } pub(crate) fn apply_reverse_lookup_result( @@ -53,13 +61,32 @@ impl HomeLocationTools { self.reverse_lookup.apply_poll_error(message); } + pub(crate) fn apply_country_list_result(&mut self, result: RadrootsLocationCountryListResult) { + self.country_lookup.apply_list_result(result); + } + + pub(crate) fn apply_country_list_poll_error(&mut self, message: String) { + self.country_lookup.apply_list_poll_error(message); + } + + pub(crate) fn apply_country_center_result( + &mut self, + result: RadrootsLocationCountryCenterLookupResult, + ) { + self.country_lookup.apply_center_result(result); + } + + pub(crate) fn apply_country_center_poll_error(&mut self, message: String) { + self.country_lookup.apply_center_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() + self.reverse_lookup.is_pending() || self.country_lookup.is_pending() } #[cfg(test)] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -11,7 +11,8 @@ mod offline_geocoder; pub const APP_NAME: &str = "Rad Roots"; pub use location_resolver::{ - RadrootsLocationCountry, RadrootsLocationPoint, RadrootsLocationResolverError, + RadrootsLocationCountry, RadrootsLocationCountryCenterLookupResult, + RadrootsLocationCountryListResult, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, }; pub use offline_geocoder::{ @@ -128,6 +129,25 @@ pub trait RadrootsAppBackend { ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { Ok(None) } + fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + fn poll_location_country_list_result( + &self, + ) -> Result<Option<RadrootsLocationCountryListResult>, String> { + Ok(None) + } + fn request_location_country_center_lookup( + &self, + _country_id: &str, + ) -> Result<(), RadrootsLocationResolverError> { + Err(RadrootsLocationResolverError::Unsupported) + } + fn poll_location_country_center_lookup_result( + &self, + ) -> Result<Option<RadrootsLocationCountryCenterLookupResult>, String> { + Ok(None) + } fn list_location_countries( &self, ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { @@ -324,6 +344,21 @@ impl RadrootsApp { .apply_reverse_lookup_poll_error(err); } } + match self.backend.poll_location_country_list_result() { + Ok(Some(result)) => self.home_location_tools.apply_country_list_result(result), + Ok(None) => {} + Err(err) => { + self.home_location_tools.apply_country_list_poll_error(err); + } + } + match self.backend.poll_location_country_center_lookup_result() { + Ok(Some(result)) => self.home_location_tools.apply_country_center_result(result), + Ok(None) => {} + Err(err) => { + self.home_location_tools + .apply_country_center_poll_error(err); + } + } match self.backend.poll_identity_state() { Ok(Some(state)) => self.apply_identity_state(state), Ok(None) => {} diff --git a/crates/core/src/location_resolver.rs b/crates/core/src/location_resolver.rs @@ -40,6 +40,12 @@ pub struct RadrootsLocationCountry { pub center: RadrootsLocationPoint, } +pub type RadrootsLocationCountryListResult = + Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError>; + +pub type RadrootsLocationCountryCenterLookupResult = + Result<RadrootsLocationPoint, RadrootsLocationResolverError>; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsLocationResolverError { Unsupported,