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