app

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

commit b18932a171b0fc75f429c6204272ce521a03f52d
parent ef5323bb4967de82b9d01f9aeb6f4f2cc6ac0baa
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Apr 2026 19:06:08 +0000

models: add the radroots_app local-first model crate

Diffstat:
MCargo.lock | 9+++++++++
MCargo.toml | 3+++
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/source_guards.rs | 5++++-
Mcrates/launchers/desktop/src/window.rs | 47+++++++++++++++++++----------------------------
Acrates/shared/models/Cargo.toml | 15+++++++++++++++
Acrates/shared/models/src/lib.rs | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 407 insertions(+), 29 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4571,6 +4571,7 @@ dependencies = [ "gpui-component-assets", "radroots_app_core", "radroots_app_i18n", + "radroots_app_models", "radroots_app_ui", ] @@ -4592,6 +4593,14 @@ dependencies = [ ] [[package]] +name = "radroots_app_models" +version = "0.1.0" +dependencies = [ + "serde", + "uuid", +] + +[[package]] name = "radroots_app_ui" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/shared/core", "crates/shared/i18n", + "crates/shared/models", "crates/shared/ui", "crates/launchers/desktop", ] @@ -27,10 +28,12 @@ mf2-i18n-core = { path = "../../../../vendor/triesap/mf2-i18n/crates/mf2-i18n-co mf2-i18n-native = { path = "../../../../vendor/triesap/mf2-i18n/crates/mf2-i18n-native", version = "0.1.0" } radroots_app_core = { path = "crates/shared/core", version = "0.1.0" } radroots_app_i18n = { path = "crates/shared/i18n", version = "0.1.0" } +radroots_app_models = { path = "crates/shared/models", version = "0.1.0" } radroots_app_ui = { path = "crates/shared/ui", version = "0.1.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" +uuid = { version = "1", features = ["serde", "v7"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -13,6 +13,7 @@ gpui-component.workspace = true gpui-component-assets.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true +radroots_app_models.workspace = true radroots_app_ui.workspace = true [lints] diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -24,7 +24,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ fn desktop_menu_source_uses_localized_copy_paths() { assert_eq!( extract_string_literals(include_str!("menus.rs")), - ALLOWED_MENU_LITERALS.iter().copied().collect::<BTreeSet<_>>() + ALLOWED_MENU_LITERALS + .iter() + .copied() + .collect::<BTreeSet<_>>() ); } diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -6,6 +6,7 @@ use gpui::{ use gpui_component::IconName; use radroots_app_core::AppRuntimeSnapshot; use radroots_app_i18n::AppTextKey; +pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, action_button_compact, action_icon_button, app_checkbox_field, app_shared_label_text, @@ -54,32 +55,6 @@ pub fn open_settings_window(cx: &mut App, initial_view: SettingsPanelViewKey) { .expect("settings window should open"); } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum SettingsPanelViewKey { - #[default] - Account, - Settings, - About, -} - -impl SettingsPanelViewKey { - fn label_key(self) -> AppTextKey { - match self { - Self::Account => AppTextKey::SettingsNavAccounts, - Self::Settings => AppTextKey::SettingsNavSettings, - Self::About => AppTextKey::SettingsNavAbout, - } - } - - fn spec(self) -> (&'static str, IconName) { - match self { - Self::Account => ("settings-nav-accounts", IconName::CircleUser), - Self::Settings => ("settings-nav-settings", IconName::Settings2), - Self::About => ("settings-nav-about", IconName::Info), - } - } -} - pub struct HomeView { metadata_rows: Vec<LabelValueRow>, } @@ -177,11 +152,11 @@ impl SettingsWindowView { view: SettingsPanelViewKey, cx: &mut Context<Self>, ) -> impl IntoElement { - let (navigation_id, navigation_icon) = view.spec(); + let (navigation_id, navigation_icon) = settings_panel_spec(view); icon_segment_button( IconSegmentButtonSpec::new( navigation_id, - app_shared_text(view.label_key()), + app_shared_text(settings_panel_label_key(view)), navigation_icon, ), self.selected_view == view, @@ -705,3 +680,19 @@ impl Render for SettingsWindowView { ) } } + +fn settings_panel_label_key(view: SettingsPanelViewKey) -> AppTextKey { + match view { + SettingsPanelViewKey::Account => AppTextKey::SettingsNavAccounts, + SettingsPanelViewKey::Settings => AppTextKey::SettingsNavSettings, + SettingsPanelViewKey::About => AppTextKey::SettingsNavAbout, + } +} + +fn settings_panel_spec(view: SettingsPanelViewKey) -> (&'static str, IconName) { + match view { + SettingsPanelViewKey::Account => ("settings-nav-accounts", IconName::CircleUser), + SettingsPanelViewKey::Settings => ("settings-nav-settings", IconName::Settings2), + SettingsPanelViewKey::About => ("settings-nav-about", IconName::Info), + } +} diff --git a/crates/shared/models/Cargo.toml b/crates/shared/models/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "radroots_app_models" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde.workspace = true +uuid.workspace = true + +[lints] +workspace = true diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -0,0 +1,356 @@ +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use std::{error::Error, fmt, str::FromStr}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppMode { + #[default] + Farmer, + Buyer, +} + +impl AppMode { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Farmer => "farmer", + Self::Buyer => "buyer", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmerSection { + #[default] + Today, + Products, + Orders, + PackDay, + Farm, +} + +impl FarmerSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Today => "farmer.today", + Self::Products => "farmer.products", + Self::Orders => "farmer.orders", + Self::PackDay => "farmer.pack_day", + Self::Farm => "farmer.farm", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuyerSection { + #[default] + Marketplace, + Search, + Cart, + Orders, +} + +impl BuyerSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Marketplace => "buyer.marketplace", + Self::Search => "buyer.search", + Self::Cart => "buyer.cart", + Self::Orders => "buyer.orders", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettingsSection { + #[default] + Account, + Settings, + About, +} + +impl SettingsSection { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Account => "settings.account", + Self::Settings => "settings.settings", + Self::About => "settings.about", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "surface", content = "section", rename_all = "snake_case")] +pub enum ShellSection { + #[default] + Home, + Farmer(FarmerSection), + Buyer(BuyerSection), + Settings(SettingsSection), +} + +impl ShellSection { + pub const fn mode(self) -> AppMode { + match self { + Self::Buyer(_) => AppMode::Buyer, + Self::Home | Self::Farmer(_) | Self::Settings(_) => AppMode::Farmer, + } + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::Home => "home", + Self::Farmer(section) => section.storage_key(), + Self::Buyer(section) => section.storage_key(), + Self::Settings(section) => section.storage_key(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParseShellSectionError; + +impl fmt::Display for ParseShellSectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid shell section key") + } +} + +impl Error for ParseShellSectionError {} + +impl FromStr for ShellSection { + type Err = ParseShellSectionError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "home" => Ok(Self::Home), + "farmer.today" => Ok(Self::Farmer(FarmerSection::Today)), + "farmer.products" => Ok(Self::Farmer(FarmerSection::Products)), + "farmer.orders" => Ok(Self::Farmer(FarmerSection::Orders)), + "farmer.pack_day" => Ok(Self::Farmer(FarmerSection::PackDay)), + "farmer.farm" => Ok(Self::Farmer(FarmerSection::Farm)), + "buyer.marketplace" => Ok(Self::Buyer(BuyerSection::Marketplace)), + "buyer.search" => Ok(Self::Buyer(BuyerSection::Search)), + "buyer.cart" => Ok(Self::Buyer(BuyerSection::Cart)), + "buyer.orders" => Ok(Self::Buyer(BuyerSection::Orders)), + "settings.account" => Ok(Self::Settings(SettingsSection::Account)), + "settings.settings" => Ok(Self::Settings(SettingsSection::Settings)), + "settings.about" => Ok(Self::Settings(SettingsSection::About)), + _ => Err(ParseShellSectionError), + } + } +} + +macro_rules! typed_id { + ($name:ident) => { + #[derive( + Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + pub fn as_uuid(self) -> Uuid { + self.0 + } + } + + impl From<Uuid> for $name { + fn from(value: Uuid) -> Self { + Self(value) + } + } + + impl From<$name> for Uuid { + fn from(value: $name) -> Self { + value.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } + } + + impl FromStr for $name { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Uuid::parse_str(value).map(Self) + } + } + + impl TryFrom<&str> for $name { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + value.parse() + } + } + }; +} + +typed_id!(FarmId); +typed_id!(ProductId); +typed_id!(OrderId); +typed_id!(FulfillmentWindowId); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmReadiness { + Incomplete, + Ready, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductStatus { + Draft, + Published, + Paused, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrderStatus { + NeedsAction, + Scheduled, + Packed, + Completed, + Refunded, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FarmSummary { + pub farm_id: FarmId, + pub display_name: String, + pub readiness: FarmReadiness, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TodaySummary { + pub farm_id: FarmId, + pub orders_needing_action: u32, + pub low_stock_products: u32, + pub draft_products: u32, +} + +impl TodaySummary { + pub const fn has_attention_items(&self) -> bool { + self.orders_needing_action > 0 || self.low_stock_products > 0 || self.draft_products > 0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductListRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub title: String, + pub status: ProductStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OrderListRow { + pub order_id: OrderId, + pub farm_id: FarmId, + pub order_number: String, + pub customer_display_name: String, + pub status: OrderStatus, +} + +#[cfg(test)] +mod tests { + use super::{ + AppMode, BuyerSection, FarmId, FarmerSection, SettingsSection, ShellSection, TodaySummary, + }; + use std::{collections::BTreeSet, str::FromStr}; + use uuid::Uuid; + + #[test] + fn shell_section_storage_keys_are_unique_and_round_trip() { + let sections = [ + ShellSection::Home, + ShellSection::Farmer(FarmerSection::Today), + ShellSection::Farmer(FarmerSection::Products), + ShellSection::Farmer(FarmerSection::Orders), + ShellSection::Farmer(FarmerSection::PackDay), + ShellSection::Farmer(FarmerSection::Farm), + ShellSection::Buyer(BuyerSection::Marketplace), + ShellSection::Buyer(BuyerSection::Search), + ShellSection::Buyer(BuyerSection::Cart), + ShellSection::Buyer(BuyerSection::Orders), + ShellSection::Settings(SettingsSection::Account), + ShellSection::Settings(SettingsSection::Settings), + ShellSection::Settings(SettingsSection::About), + ]; + let keys = sections + .into_iter() + .map(ShellSection::storage_key) + .collect::<BTreeSet<_>>(); + + assert_eq!(keys.len(), sections.len()); + + for section in sections { + let parsed = + ShellSection::from_str(section.storage_key()).expect("section should parse"); + assert_eq!(parsed, section); + } + } + + #[test] + fn shell_section_mode_tracks_farmer_and_buyer_surfaces() { + assert_eq!(ShellSection::Home.mode(), AppMode::Farmer); + assert_eq!( + ShellSection::Farmer(FarmerSection::Today).mode(), + AppMode::Farmer + ); + assert_eq!( + ShellSection::Buyer(BuyerSection::Marketplace).mode(), + AppMode::Buyer + ); + assert_eq!( + ShellSection::Settings(SettingsSection::Settings).mode(), + AppMode::Farmer + ); + } + + #[test] + fn typed_ids_round_trip_through_strings() { + let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") + .expect("test uuid should parse"); + let farm_id = FarmId::from(uuid); + let parsed = FarmId::from_str(&farm_id.to_string()).expect("farm id should parse"); + + assert_eq!(parsed, farm_id); + assert_eq!(parsed.as_uuid(), uuid); + } + + #[test] + fn today_summary_attention_state_is_explicit() { + let quiet = TodaySummary { + farm_id: FarmId::new(), + orders_needing_action: 0, + low_stock_products: 0, + draft_products: 0, + }; + let busy = TodaySummary { + farm_id: FarmId::new(), + orders_needing_action: 1, + low_stock_products: 0, + draft_products: 0, + }; + + assert!(!quiet.has_attention_items()); + assert!(busy.has_attention_items()); + } +}