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:
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,