field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit df9848ef1d973ec91b4ba08751337897ac6df356
parent 2295b883a64a4ac9fa8229bebcbfccb7bc009324
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 00:50:27 +0000

feat(mobile): expose typed Today rendering fields

- preserve Event and FoodAvailability locations in projected cards
- expose focused price, currency, unit, and quantity values through UniFFI
- keep older stored projections compatible through optional serde fields
- cover exact rendering-field projection with focused protocol tests

Diffstat:
Mcore/crates/tera_core/src/runtime/product_surface/model.rs | 10++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/projection.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+), 0 deletions(-)

diff --git a/core/crates/tera_core/src/runtime/product_surface/model.rs b/core/crates/tera_core/src/runtime/product_surface/model.rs @@ -187,6 +187,16 @@ pub struct ClassifiedCard { pub effective_at: u64, pub event_start: Option<u64>, pub event_end: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_amount: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_currency: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price_unit: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quantity: Option<String>, pub context_rank: ContextRank, pub inclusion_reason: String, pub media: Vec<MediaReference>, diff --git a/core/crates/tera_core/src/runtime/product_surface/projection.rs b/core/crates/tera_core/src/runtime/product_surface/projection.rs @@ -147,6 +147,24 @@ fn card( }; let tags = event.tags_as_vec(); let title = tag_value(&tags, &["title", "name"]); + let location = matches!( + card_type, + TodayCardType::Event | TodayCardType::FoodAvailability + ) + .then(|| tag_value(&tags, &["location"])) + .flatten(); + let price = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_values(&tags, "price")) + .flatten(); + let price_amount = price.as_ref().and_then(|values| values.first().cloned()); + let price_currency = price.as_ref().and_then(|values| values.get(1).cloned()); + let price_unit = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_value(&tags, &["radroots:price_unit"])) + .flatten(); + let quantity = matches!(card_type, TodayCardType::FoodAvailability) + .then(|| tag_values(&tags, "radroots:quantity")) + .flatten() + .and_then(|values| values.first().cloned()); let (effective_at, event_start, event_end) = match card_type { TodayCardType::Event => { let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64()); @@ -175,6 +193,11 @@ fn card( effective_at, event_start, event_end, + location, + price_amount, + price_currency, + price_unit, + quantity, context_rank: context.rank, inclusion_reason: context.reason.to_owned(), media, @@ -192,6 +215,12 @@ fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { }) } +fn tag_values(tags: &[Vec<String>], name: &str) -> Option<Vec<String>> { + tags.iter().find_map(|tag| { + (tag.first().map(String::as_str) == Some(name) && tag.len() > 1).then(|| tag[1..].to_vec()) + }) +} + fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> { let value = tag_value(tags, &[name])?; value.parse().ok().or_else(|| { @@ -338,6 +367,13 @@ mod tests { } } + fn card(event: RadrootsAdmittedEvent) -> ClassifiedCard { + match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => *card, + other => panic!("expected card, got {other:?}"), + } + } + #[test] fn exact_five_card_classifier_precedence_is_protocol_structural() { assert_eq!( @@ -403,6 +439,44 @@ mod tests { } #[test] + fn event_and_food_cards_preserve_required_rendering_fields() { + let event = card(admitted( + 31_923, + vec![ + vec!["d", "market-2026"], + vec!["title", "Saturday market"], + vec!["start", "2000000100"], + vec!["D", "23148"], + vec!["location", "Town square"], + ], + "Farm market", + )); + assert_eq!(event.location.as_deref(), Some("Town square")); + assert_eq!(event.price_amount, None); + + let food = card(admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh bunches"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["radroots:quantity", "12", "lb"], + vec!["status", "active"], + ], + "Carrots available", + )); + assert_eq!(food.location.as_deref(), Some("Saanich")); + assert_eq!(food.price_amount.as_deref(), Some("3")); + assert_eq!(food.price_currency.as_deref(), Some("CAD")); + assert_eq!(food.price_unit.as_deref(), Some("lb")); + assert_eq!(food.quantity.as_deref(), Some("12")); + } + + #[test] fn ordinary_standard_kind_one_needs_no_product_marker() { let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready"); let ProductEventClassification::Card(card) = classify_admitted_event(&event, context())