commit 880fd367a4e82a0e20e0ebdbd6085c225ce44637
parent ef6b5f2517df4ea88404ef9a1b2d3b5b0555b9e3
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 09:18:51 +0000
desktop: add truthful farmer products surface
Diffstat:
6 files changed, 1403 insertions(+), 28 deletions(-)
diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs
@@ -266,6 +266,7 @@ mod tests {
home_route: HomeRoute::SetupRequired,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()),
};
@@ -299,6 +300,7 @@ mod tests {
home_route: HomeRoute::Blocked,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
let setup = DesktopAppRuntimeSummary {
@@ -308,6 +310,7 @@ mod tests {
home_route: HomeRoute::SetupRequired,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
@@ -324,6 +327,7 @@ mod tests {
home_route: HomeRoute::Personal,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
let farmer = DesktopAppRuntimeSummary {
@@ -333,6 +337,7 @@ mod tests {
home_route: HomeRoute::FarmSetupOnboarding,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
@@ -349,6 +354,7 @@ mod tests {
home_route: HomeRoute::Personal,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: Some("runtime unavailable".to_owned()),
};
@@ -364,6 +370,7 @@ mod tests {
home_route: HomeRoute::SetupRequired,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
let personal = DesktopAppRuntimeSummary {
@@ -373,6 +380,7 @@ mod tests {
home_route: HomeRoute::Personal,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
let farmer = DesktopAppRuntimeSummary {
@@ -382,6 +390,7 @@ mod tests {
home_route: HomeRoute::FarmSetupOnboarding,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: None,
};
let blocked = DesktopAppRuntimeSummary {
@@ -391,6 +400,7 @@ mod tests {
home_route: HomeRoute::FarmSetupOnboarding,
farm_setup_projection: Default::default(),
today_projection: TodayAgendaProjection::default(),
+ products_projection: Default::default(),
startup_issue: Some("runtime unavailable".to_owned()),
};
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -5,15 +5,15 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA
use radroots_app_models::{
ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate,
FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection,
- SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
- TodayAgendaProjection,
+ ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection,
+ SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection,
};
use radroots_app_sqlite::{
APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget,
};
use radroots_app_state::{
AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage,
- HomeRoute, InMemoryAppStateRepository,
+ HomeRoute, InMemoryAppStateRepository, ProductsScreenProjection, ProductsScreenQueryState,
};
use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager;
use thiserror::Error;
@@ -53,6 +53,7 @@ impl DesktopAppRuntime {
home_route: state.state_store.home_route(),
farm_setup_projection: state.state_store.farm_setup_projection().clone(),
today_projection: state.state_store.today_projection().clone(),
+ products_projection: state.state_store.products_projection().clone(),
startup_issue: state.startup_issue.clone(),
}
}
@@ -100,11 +101,20 @@ impl DesktopAppRuntime {
}
pub fn select_farmer_section(&self, section: FarmerSection) -> bool {
+ self.lock_state_mut().select_farmer_section(section)
+ }
+
+ pub fn set_products_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> {
self.lock_state_mut()
- .state_store
- .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
- section,
- )))
+ .set_products_search_query(search_query)
+ }
+
+ pub fn select_products_filter(&self, filter: ProductsFilter) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().select_products_filter(filter)
+ }
+
+ pub fn select_products_sort(&self, sort: ProductsSort) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().select_products_sort(sort)
}
pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool {
@@ -249,6 +259,7 @@ pub struct DesktopAppRuntimeSummary {
pub home_route: HomeRoute,
pub farm_setup_projection: FarmSetupProjection,
pub today_projection: TodayAgendaProjection,
+ pub products_projection: ProductsScreenProjection,
pub startup_issue: Option<String>,
}
@@ -264,6 +275,7 @@ pub enum DesktopAppRuntimeActivityContextError {
struct DesktopSelectedAccountContext {
farm_setup_projection: FarmSetupProjection,
today_projection: TodayAgendaProjection,
+ products_list: ProductsListProjection,
}
struct DesktopAppRuntimeState {
@@ -306,8 +318,11 @@ impl DesktopAppRuntimeState {
let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?;
let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?;
let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?;
- let selected_account_context =
- load_selected_account_context(&sqlite_store, &accounts_bootstrap.identity_projection)?;
+ let selected_account_context = load_selected_account_context(
+ &sqlite_store,
+ &accounts_bootstrap.identity_projection,
+ state_store.products_projection().query.clone(),
+ )?;
let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection(
accounts_bootstrap.identity_projection,
));
@@ -317,6 +332,9 @@ impl DesktopAppRuntimeState {
let _ = state_store.apply_in_memory(AppStateCommand::replace_today_agenda(
selected_account_context.today_projection,
));
+ let _ = state_store.apply_in_memory(AppStateCommand::replace_products_list(
+ selected_account_context.products_list,
+ ));
Ok(Self {
state_store,
@@ -419,6 +437,68 @@ impl DesktopAppRuntimeState {
}
}
+ fn select_farmer_section(&mut self, section: FarmerSection) -> bool {
+ match section {
+ FarmerSection::Today => {
+ self.state_store
+ .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
+ FarmerSection::Today,
+ )))
+ }
+ FarmerSection::Products
+ if self.state_store.farm_setup_projection().has_saved_farm() =>
+ {
+ self.state_store
+ .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
+ FarmerSection::Products,
+ )))
+ }
+ FarmerSection::Products
+ | FarmerSection::Orders
+ | FarmerSection::PackDay
+ | FarmerSection::Farm => false,
+ }
+ }
+
+ fn set_products_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> {
+ let query = self.state_store.products_projection().query.clone();
+ if query.search_query == search_query {
+ return Ok(false);
+ }
+
+ self.replace_products_query(ProductsScreenQueryState::new(
+ search_query,
+ query.filter,
+ query.sort,
+ ))
+ }
+
+ fn select_products_filter(&mut self, filter: ProductsFilter) -> Result<bool, AppSqliteError> {
+ let query = self.state_store.products_projection().query.clone();
+ if query.filter == filter {
+ return Ok(false);
+ }
+
+ self.replace_products_query(ProductsScreenQueryState::new(
+ query.search_query,
+ filter,
+ query.sort,
+ ))
+ }
+
+ fn select_products_sort(&mut self, sort: ProductsSort) -> Result<bool, AppSqliteError> {
+ let query = self.state_store.products_projection().query.clone();
+ if query.sort == sort {
+ return Ok(false);
+ }
+
+ self.replace_products_query(ProductsScreenQueryState::new(
+ query.search_query,
+ query.filter,
+ sort,
+ ))
+ }
+
fn save_farm_setup_draft(
&mut self,
draft: FarmSetupDraft,
@@ -468,8 +548,11 @@ impl DesktopAppRuntimeState {
&mut self,
projection: AppIdentityProjection,
) -> Result<bool, DesktopAppRuntimeCommandError> {
- let selected_account_context =
- load_selected_account_context(self.sqlite_store()?, &projection)?;
+ let selected_account_context = load_selected_account_context(
+ self.sqlite_store()?,
+ &projection,
+ self.state_store.products_projection().query.clone(),
+ )?;
let identity_changed = self
.state_store
.apply_in_memory(AppStateCommand::replace_identity_projection(projection));
@@ -484,6 +567,7 @@ impl DesktopAppRuntimeState {
Ok(load_selected_account_context(
self.sqlite_store_for_farm_setup()?,
self.state_store.identity_projection(),
+ self.state_store.products_projection().query.clone(),
)?)
}
@@ -498,8 +582,14 @@ impl DesktopAppRuntimeState {
.apply_in_memory(AppStateCommand::replace_today_agenda(
context.today_projection.clone(),
));
+ let products_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::replace_products_list(
+ context.products_list.clone(),
+ ));
+ let shell_changed = self.sync_truthful_farmer_section();
- farm_setup_changed || today_changed
+ farm_setup_changed || today_changed || products_changed || shell_changed
}
fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> {
@@ -540,6 +630,78 @@ impl DesktopAppRuntimeState {
.ok_or(DesktopAppRuntimeFarmSetupError::RuntimeUnavailable)
}
+ fn replace_products_query(
+ &mut self,
+ query: ProductsScreenQueryState,
+ ) -> Result<bool, AppSqliteError> {
+ let products_list = self.load_products_list_for_query(&query)?;
+ let query_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::set_products_search_query(
+ query.search_query.clone(),
+ ));
+ let filter_changed = self
+ .state_store
+ .apply_in_memory(AppStateCommand::select_products_filter(query.filter));
+ let sort_changed = self
+ .state_store
+ .apply_in_memory(AppStateCommand::select_products_sort(query.sort));
+ let list_changed = self
+ .state_store
+ .apply_in_memory(AppStateCommand::replace_products_list(products_list));
+
+ Ok(query_changed || filter_changed || sort_changed || list_changed)
+ }
+
+ fn load_products_list_for_query(
+ &self,
+ query: &ProductsScreenQueryState,
+ ) -> Result<ProductsListProjection, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(ProductsListProjection::default());
+ };
+ let Some(selected_account) = self
+ .state_store
+ .identity_projection()
+ .selected_account
+ .as_ref()
+ else {
+ return Ok(ProductsListProjection::default());
+ };
+ let farm_id = selected_account.farmer_activation.farm_id.or(self
+ .state_store
+ .farm_setup_projection()
+ .saved_farm
+ .as_ref()
+ .map(|farm| farm.farm_id));
+ let Some(farm_id) = farm_id else {
+ return Ok(ProductsListProjection::default());
+ };
+
+ sqlite_store.load_products(farm_id, &query.search_query, query.filter, query.sort)
+ }
+
+ fn sync_truthful_farmer_section(&mut self) -> bool {
+ let selected_section = self.state_store.shell_projection().selected_section;
+ let should_reset_to_today = match selected_section {
+ ShellSection::Farmer(FarmerSection::Today) => false,
+ ShellSection::Farmer(FarmerSection::Products) => {
+ !self.state_store.farm_setup_projection().has_saved_farm()
+ }
+ ShellSection::Farmer(
+ FarmerSection::Orders | FarmerSection::PackDay | FarmerSection::Farm,
+ ) => true,
+ ShellSection::Home | ShellSection::Settings(_) => false,
+ };
+
+ should_reset_to_today
+ && self
+ .state_store
+ .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
+ FarmerSection::Today,
+ )))
+ }
+
fn shared_accounts_paths(
&self,
) -> Result<&AppSharedAccountsPaths, DesktopAppRuntimeCommandError> {
@@ -591,6 +753,7 @@ enum DesktopAppRuntimeBootstrapError {
fn load_selected_account_context(
sqlite_store: &AppSqliteStore,
identity_projection: &AppIdentityProjection,
+ products_query: ProductsScreenQueryState,
) -> Result<DesktopSelectedAccountContext, AppSqliteError> {
let Some(selected_account) = identity_projection.selected_account.as_ref() else {
return Ok(DesktopSelectedAccountContext::default());
@@ -608,10 +771,20 @@ fn load_selected_account_context(
Some(farm_id) => sqlite_store.load_today_agenda(Some(farm_id))?,
None => TodayAgendaProjection::default(),
};
+ let products_list = match today_farm_id {
+ Some(farm_id) => sqlite_store.load_products(
+ farm_id,
+ &products_query.search_query,
+ products_query.filter,
+ products_query.sort,
+ )?,
+ None => ProductsListProjection::default(),
+ };
Ok(DesktopSelectedAccountContext {
farm_setup_projection,
today_projection,
+ products_list,
})
}
@@ -631,9 +804,9 @@ mod tests {
use radroots_app_models::{
AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId,
FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
- FarmerActivationProjection, FarmerSection, SelectedSurfaceProjection, SettingsPreference,
- SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
- TodaySummary,
+ FarmerActivationProjection, FarmerSection, ProductsFilter, ProductsSort,
+ SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection,
+ TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
};
use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget};
use radroots_app_state::{
@@ -979,7 +1152,32 @@ mod tests {
.account
.account_id
.clone();
- save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true);
+ let farm_id =
+ save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer);
+ let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ });
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(
+ farm_setup_projection
+ .saved_farm
+ .as_ref()
+ .expect("saved farm should exist"),
+ )
+ .expect("farm summary should save");
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_setup(account_id.as_str(), &farm_setup_projection)
+ .expect("farm setup should save");
assert!(
runtime
.select_local_account(account_id.as_str())
@@ -1000,6 +1198,117 @@ mod tests {
}
#[test]
+ fn runtime_products_queries_refresh_the_repository_backed_projection() {
+ let runtime = memory_runtime();
+
+ assert!(
+ runtime
+ .generate_local_account(Some("Farmer".to_owned()))
+ .expect("account should generate")
+ );
+ let account_id = runtime
+ .summary()
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .account
+ .account_id
+ .clone();
+ let farm_id =
+ save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer);
+ let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ });
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(
+ farm_setup_projection
+ .saved_farm
+ .as_ref()
+ .expect("saved farm should exist"),
+ )
+ .expect("farm summary should save");
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_setup(account_id.as_str(), &farm_setup_projection)
+ .expect("farm setup should save");
+ seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "published",
+ Some(2),
+ "2026-04-18T10:00:00Z",
+ );
+ seed_product(
+ &runtime,
+ farm_id,
+ "Pea shoots",
+ "Tray-grown",
+ "draft",
+ None,
+ "2026-04-18T09:00:00Z",
+ );
+
+ assert!(
+ runtime
+ .select_local_account(account_id.as_str())
+ .expect("account should select")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(summary.products_projection.list.summary.total_products, 2);
+ assert_eq!(summary.products_projection.list.rows[0].title, "Salad mix");
+ assert_eq!(
+ summary.products_projection.query.filter,
+ ProductsFilter::default()
+ );
+ assert_eq!(
+ summary.products_projection.query.sort,
+ ProductsSort::default()
+ );
+
+ assert!(
+ runtime
+ .select_products_filter(ProductsFilter::NeedAttention)
+ .expect("filter should apply")
+ );
+ assert_eq!(runtime.summary().products_projection.list.rows.len(), 2);
+
+ assert!(
+ runtime
+ .set_products_search_query("pea")
+ .expect("search should apply")
+ );
+ let searched = runtime.summary();
+ assert_eq!(searched.products_projection.list.rows.len(), 1);
+ assert_eq!(
+ searched.products_projection.list.rows[0].title,
+ "Pea shoots"
+ );
+
+ assert!(
+ runtime
+ .select_products_sort(ProductsSort::Name)
+ .expect("sort should apply")
+ );
+ assert_eq!(
+ runtime.summary().products_projection.query.sort,
+ ProductsSort::Name
+ );
+ }
+
+ #[test]
fn runtime_account_commands_refresh_identity_projection() {
let runtime = memory_runtime();
@@ -1543,6 +1852,62 @@ mod tests {
farm_id
}
+ fn seed_product(
+ runtime: &DesktopAppRuntime,
+ farm_id: FarmId,
+ title: &str,
+ subtitle: &str,
+ status: &str,
+ stock_count: Option<u32>,
+ updated_at: &str,
+ ) {
+ let stock_count = stock_count
+ .map(|value| value.to_string())
+ .unwrap_or_else(|| "null".to_owned());
+ let sql = format!(
+ "insert into products (
+ id,
+ farm_id,
+ title,
+ subtitle,
+ status,
+ unit_label,
+ price_minor_units,
+ price_currency,
+ stock_count,
+ availability_window_id,
+ updated_at
+ ) values (
+ '{product_id}',
+ '{farm_id}',
+ '{title}',
+ '{subtitle}',
+ '{status}',
+ 'box',
+ 600,
+ 'USD',
+ {stock_count},
+ null,
+ '{updated_at}'
+ )",
+ product_id = radroots_app_models::ProductId::new(),
+ farm_id = farm_id,
+ title = title,
+ subtitle = subtitle,
+ status = status,
+ stock_count = stock_count,
+ updated_at = updated_at,
+ );
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .connection()
+ .execute_batch(&sql)
+ .expect("product should seed");
+ }
+
fn cleanup_paths(paths: &AppSharedAccountsPaths) {
let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else {
return;
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -3,12 +3,16 @@ use std::collections::BTreeSet;
const ALLOWED_MENU_LITERALS: &[&str] = &["cmd-q", "settings window should open"];
const ALLOWED_WINDOW_LITERALS: &[&str] = &[
+ "${dollars}.{cents:02} / {}",
", ",
"account-add",
"account-open-workspace",
"account-log-out",
"account-more",
"failed to add relay `{relay_url}`: {error}",
+ "failed to update products filter",
+ "failed to update products search query",
+ "failed to update products sort",
"home-create-account",
"home-farm-setup-continue",
"home-farm-setup-delivery",
@@ -16,7 +20,25 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"home-farm-setup-pickup",
"home-farm-setup-shipping",
"home-farm-setup-start",
+ "home-nav-products",
+ "home-nav-today",
+ "home-products-scroll",
"home-today-scroll",
+ "products",
+ "products-filter-all",
+ "products-filter-archived",
+ "products-filter-drafts",
+ "products-filter-live",
+ "products-filter-need-attention",
+ "products-filter-paused",
+ "products-sort-availability",
+ "products-sort-name",
+ "products-sort-price",
+ "products-sort-stock",
+ "products-sort-updated",
+ "products.filter_update_failed",
+ "products.search_query_update_failed",
+ "products.sort_update_failed",
"settings-allow-relay-connections",
"settings-launch-at-login",
"settings-manage-media-servers",
@@ -28,6 +50,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"settings-use-nip05",
"startup-title-radroots",
"startup-title-starting",
+ "{quantity} {unit_label}",
];
const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
@@ -51,6 +74,42 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::HomeFarmSetupSaveFailedLocally",
"AppTextKey::HomeFarmSetupFinishAction",
"AppTextKey::HomeFarmSetupContinueAction",
+ "AppTextKey::HomeNavToday",
+ "AppTextKey::HomeNavProducts",
+ "AppTextKey::ProductsTitle",
+ "AppTextKey::ProductsFiltersTitle",
+ "AppTextKey::ProductsSearchPlaceholder",
+ "AppTextKey::ProductsSummaryTotal",
+ "AppTextKey::ProductsSummaryLive",
+ "AppTextKey::ProductsSummaryNeedAttention",
+ "AppTextKey::ProductsSummaryDrafts",
+ "AppTextKey::ProductsFilterAll",
+ "AppTextKey::ProductsFilterLive",
+ "AppTextKey::ProductsFilterDrafts",
+ "AppTextKey::ProductsFilterNeedAttention",
+ "AppTextKey::ProductsFilterPaused",
+ "AppTextKey::ProductsFilterArchived",
+ "AppTextKey::ProductsSortTitle",
+ "AppTextKey::ProductsSortUpdated",
+ "AppTextKey::ProductsSortName",
+ "AppTextKey::ProductsSortAvailability",
+ "AppTextKey::ProductsSortStock",
+ "AppTextKey::ProductsSortPrice",
+ "AppTextKey::ProductsTableTitle",
+ "AppTextKey::ProductsColumnProduct",
+ "AppTextKey::ProductsColumnStatus",
+ "AppTextKey::ProductsColumnAvailability",
+ "AppTextKey::ProductsColumnStock",
+ "AppTextKey::ProductsColumnPrice",
+ "AppTextKey::ProductsColumnUpdated",
+ "AppTextKey::ProductsStatusDraft",
+ "AppTextKey::ProductsStatusLive",
+ "AppTextKey::ProductsStatusPaused",
+ "AppTextKey::ProductsStatusArchived",
+ "AppTextKey::ProductsEmptyTitle",
+ "AppTextKey::ProductsEmptyBody",
+ "AppTextKey::ProductsEmptyNeedAttentionTitle",
+ "AppTextKey::ProductsEmptyNeedAttentionBody",
"AppTextKey::SettingsAccountNoSelectionTitle",
"AppTextKey::SettingsAccountNoSelectionBody",
"AppTextKey::SettingsAccountStatusLoggedOut",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -12,8 +12,9 @@ use radroots_app_i18n::AppTextKey;
pub use radroots_app_models::SettingsSection as SettingsPanelViewKey;
use radroots_app_models::{
AppStartupGate, FarmOrderMethod, FarmReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary,
- FulfillmentWindowSummary, OrderListRow, ProductListRow, TodayAgendaProjection,
- TodaySetupTaskKind,
+ FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, ProductListRow,
+ ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection,
+ TodayAgendaProjection, TodaySetupTaskKind,
};
use radroots_app_state::{FarmSetupFlowStage, HomeRoute};
use radroots_app_ui::{
@@ -25,6 +26,7 @@ use radroots_app_ui::{
};
use radroots_nostr::prelude::RadrootsNostrClient;
use std::time::Duration;
+use tracing::error;
use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary};
@@ -152,6 +154,7 @@ pub struct HomeView {
startup_view: StartupHomeView,
logged_in_view: LoggedInHomeView,
farm_setup_form: Option<FarmSetupFormState>,
+ products_search: Option<ProductsSearchState>,
relay_client: Option<RadrootsNostrClient>,
}
@@ -162,6 +165,7 @@ impl HomeView {
startup_view: StartupHomeView::new(),
logged_in_view: LoggedInHomeView::new(),
farm_setup_form: None,
+ products_search: None,
relay_client: None,
}
}
@@ -277,6 +281,117 @@ impl HomeView {
}
}
+ fn sync_products_search(
+ &mut self,
+ runtime_summary: &DesktopAppRuntimeSummary,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(account_id) = runtime_summary
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .map(|account| account.account.account_id.clone())
+ else {
+ self.products_search = None;
+ return;
+ };
+
+ if !runtime_summary.farm_setup_projection.has_saved_farm() {
+ self.products_search = None;
+ return;
+ }
+
+ let search_query = runtime_summary
+ .products_projection
+ .query
+ .search_query
+ .as_str();
+ let should_reset = self
+ .products_search
+ .as_ref()
+ .map(|state| state.account_id != account_id)
+ .unwrap_or(true);
+
+ if should_reset {
+ self.products_search = Some(ProductsSearchState::new(
+ account_id,
+ search_query,
+ window,
+ cx,
+ ));
+ return;
+ }
+
+ if let Some(products_search) = self.products_search.as_mut() {
+ products_search.sync(search_query, window, cx);
+ }
+ }
+
+ fn select_farmer_section(&mut self, section: FarmerSection, cx: &mut Context<Self>) {
+ if self.runtime.select_farmer_section(section) {
+ cx.notify();
+ }
+ }
+
+ fn handle_products_search_input_event(
+ &mut self,
+ state: &Entity<InputState>,
+ event: &InputEvent,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if !matches!(event, InputEvent::Change) {
+ return;
+ }
+
+ let value = state.read(cx).value().to_string();
+ match self.runtime.set_products_search_query(value.as_str()) {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.search_query_update_failed",
+ error = %runtime_error,
+ "failed to update products search query"
+ );
+ }
+ }
+ }
+
+ fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) {
+ match self.runtime.select_products_filter(filter) {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.filter_update_failed",
+ error = %runtime_error,
+ filter = filter.storage_key(),
+ "failed to update products filter"
+ );
+ }
+ }
+ }
+
+ fn select_products_sort(&mut self, sort: ProductsSort, cx: &mut Context<Self>) {
+ match self.runtime.select_products_sort(sort) {
+ Ok(true) => cx.notify(),
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.sort_update_failed",
+ error = %runtime_error,
+ sort = sort.storage_key(),
+ "failed to update products sort"
+ );
+ }
+ }
+ }
+
fn handle_farm_name_input_event(
&mut self,
state: &Entity<InputState>,
@@ -369,6 +484,7 @@ impl Render for HomeView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let runtime_summary = self.runtime.summary();
self.sync_farm_setup_form(&runtime_summary, window, cx);
+ self.sync_products_search(&runtime_summary, window, cx);
match home_stage(&runtime_summary) {
HomeStage::Setup => self
.startup_view
@@ -413,8 +529,46 @@ impl Render for HomeView {
)
.into_any_element()
}),
+ self.products_search.as_ref(),
+ cx.listener(|this, _, _, cx| {
+ this.select_farmer_section(FarmerSection::Today, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_farmer_section(FarmerSection::Products, cx)
+ }),
cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::All, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Live, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Drafts, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::NeedAttention, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Paused, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Archived, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_sort(ProductsSort::Updated, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Name, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_sort(ProductsSort::Availability, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_sort(ProductsSort::Stock, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_sort(ProductsSort::Price, cx)
+ }),
cx,
)
.into_any_element(),
@@ -479,6 +633,45 @@ impl FarmSetupFormState {
}
}
+struct ProductsSearchState {
+ account_id: String,
+ input: Entity<InputState>,
+ _input_subscription: Subscription,
+}
+
+impl ProductsSearchState {
+ fn new(
+ account_id: String,
+ search_query: &str,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+ ) -> Self {
+ let input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .placeholder(app_shared_text(AppTextKey::ProductsSearchPlaceholder))
+ .default_value(search_query.to_owned())
+ });
+ let input_subscription =
+ cx.subscribe_in(&input, window, HomeView::handle_products_search_input_event);
+
+ Self {
+ account_id,
+ input,
+ _input_subscription: input_subscription,
+ }
+ }
+
+ fn sync(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<HomeView>) {
+ if self.input.read(cx).value().as_ref() == search_query {
+ return;
+ }
+
+ self.input.update(cx, |input, cx| {
+ input.set_value(search_query.to_owned(), window, cx);
+ });
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
enum StartupPhase {
Idle,
@@ -554,15 +747,43 @@ impl LoggedInHomeView {
&self,
runtime: &DesktopAppRuntimeSummary,
farm_setup_form: Option<AnyElement>,
+ products_search: Option<&ProductsSearchState>,
+ on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> AnyElement {
farmer_home_shell(
runtime,
farm_setup_form,
+ products_search,
+ on_select_today,
+ on_select_products,
on_start_farm_setup,
on_continue_farm_setup,
+ on_select_all_products,
+ on_select_live_products,
+ on_select_draft_products,
+ on_select_products_needing_attention,
+ on_select_paused_products,
+ on_select_archived_products,
+ on_sort_products_by_updated,
+ on_sort_products_by_name,
+ on_sort_products_by_availability,
+ on_sort_products_by_stock,
+ on_sort_products_by_price,
cx,
)
.into_any_element()
@@ -1146,21 +1367,49 @@ struct FarmSetupOnboardingCardSpec {
fn farmer_home_shell(
runtime: &DesktopAppRuntimeSummary,
farm_setup_form: Option<AnyElement>,
+ products_search: Option<&ProductsSearchState>,
+ on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> impl IntoElement {
+ let selected_farmer_section = selected_farmer_section(runtime);
+
home_shell_frame(
- runtime,
+ home_sidebar(runtime, on_select_today, on_select_products, cx).into_any_element(),
div()
- .id("home-today-scroll")
+ .id(home_content_scroll_id(selected_farmer_section))
.size_full()
.overflow_y_scroll()
.child(home_view_content(
runtime,
farm_setup_form,
+ products_search,
on_start_farm_setup,
on_continue_farm_setup,
+ on_select_all_products,
+ on_select_live_products,
+ on_select_draft_products,
+ on_select_products_needing_attention,
+ on_select_paused_products,
+ on_select_archived_products,
+ on_sort_products_by_updated,
+ on_sort_products_by_name,
+ on_sort_products_by_availability,
+ on_sort_products_by_stock,
+ on_sort_products_by_price,
cx,
))
.into_any_element(),
@@ -1196,7 +1445,7 @@ fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
}
home_shell_frame(
- runtime,
+ holding_home_sidebar(runtime).into_any_element(),
div()
.size_full()
.child(
@@ -1394,17 +1643,14 @@ async fn run_startup_app_init(relay_url: String) -> Result<StartupAppInitResult,
Ok(StartupAppInitResult { relay_client })
}
-fn home_shell_frame(
- runtime: &DesktopAppRuntimeSummary,
- main_content: AnyElement,
-) -> impl IntoElement {
+fn home_shell_frame(sidebar: AnyElement, main_content: AnyElement) -> impl IntoElement {
app_window_shell(
APP_UI_THEME.surfaces.window_background,
div()
.size_full()
.overflow_hidden()
.flex()
- .child(home_sidebar(runtime))
+ .child(sidebar)
.child(
div()
.h_full()
@@ -1427,8 +1673,15 @@ fn home_shell_frame(
)
}
-fn home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
+fn home_sidebar(
+ runtime: &DesktopAppRuntimeSummary,
+ on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
let home_status = home_status_presentation(runtime);
+ let selected_section = selected_farmer_section(runtime);
+ let products_available = farmer_products_available(runtime);
div()
.h_full()
@@ -1448,7 +1701,69 @@ fn home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
.text_size(px(APP_UI_THEME.typography.body_text_px * 2.0))
.font_weight(gpui::FontWeight::BOLD)
.text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_shared_text(AppTextKey::HomeTodayTitle)),
+ .child(app_shared_text(AppTextKey::AppName)),
+ )
+ .child(home_status_row(&home_status)),
+ )
+ .child(
+ div()
+ .flex_1()
+ .flex()
+ .flex_col()
+ .justify_start()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(home_sidebar_nav_button(
+ "home-nav-today",
+ AppTextKey::HomeNavToday,
+ selected_section == FarmerSection::Today,
+ on_select_today,
+ cx,
+ ))
+ .when(products_available, |this| {
+ this.child(home_sidebar_nav_button(
+ "home-nav-products",
+ AppTextKey::HomeNavProducts,
+ selected_section == FarmerSection::Products,
+ on_select_products,
+ cx,
+ ))
+ }),
+ )
+ .child(
+ div().child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .when_some(home_saved_farm(runtime), |this, farm| {
+ this.child(farm.display_name.clone())
+ }),
+ ),
+ )
+}
+
+fn holding_home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
+ let home_status = home_status_presentation(runtime);
+
+ div()
+ .h_full()
+ .w(px(APP_UI_THEME.layout.home_sidebar_width_px))
+ .bg(rgb(APP_UI_THEME.surfaces.card_background))
+ .p(px(APP_UI_THEME.layout.home_window_padding_px))
+ .flex()
+ .flex_col()
+ .justify_between()
+ .child(
+ div()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0))
+ .font_weight(gpui::FontWeight::BOLD)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(AppTextKey::AppName)),
)
.child(home_status_row(&home_status)),
)
@@ -1468,6 +1783,58 @@ fn home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
fn home_view_content(
runtime: &DesktopAppRuntimeSummary,
farm_setup_form: Option<AnyElement>,
+ products_search: Option<&ProductsSearchState>,
+ on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ match selected_farmer_section(runtime) {
+ FarmerSection::Products if farmer_products_available(runtime) => home_products_content(
+ runtime,
+ products_search,
+ on_select_all_products,
+ on_select_live_products,
+ on_select_draft_products,
+ on_select_products_needing_attention,
+ on_select_paused_products,
+ on_select_archived_products,
+ on_sort_products_by_updated,
+ on_sort_products_by_name,
+ on_sort_products_by_availability,
+ on_sort_products_by_stock,
+ on_sort_products_by_price,
+ cx,
+ )
+ .into_any_element(),
+ FarmerSection::Today
+ | FarmerSection::Products
+ | FarmerSection::Orders
+ | FarmerSection::PackDay
+ | FarmerSection::Farm => home_today_content(
+ runtime,
+ farm_setup_form,
+ on_start_farm_setup,
+ on_continue_farm_setup,
+ cx,
+ )
+ .into_any_element(),
+ }
+}
+
+fn home_today_content(
+ runtime: &DesktopAppRuntimeSummary,
+ farm_setup_form: Option<AnyElement>,
on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
@@ -1643,6 +2010,507 @@ fn home_view_content(
.children(sections)
}
+fn home_products_content(
+ runtime: &DesktopAppRuntimeSummary,
+ products_search: Option<&ProductsSearchState>,
+ on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let projection = &runtime.products_projection;
+ let summary = &projection.list.summary;
+
+ div()
+ .w_full()
+ .max_w(px(APP_UI_THEME.layout.home_card_max_width_px))
+ .mx_auto()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(products_title_row(runtime))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryTotal,
+ summary.total_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryLive,
+ summary.live_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryNeedAttention,
+ summary.need_attention_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryDrafts,
+ summary.draft_products,
+ )),
+ )
+ .child(products_controls_card(
+ runtime,
+ products_search,
+ on_select_all_products,
+ on_select_live_products,
+ on_select_draft_products,
+ on_select_products_needing_attention,
+ on_select_paused_products,
+ on_select_archived_products,
+ on_sort_products_by_updated,
+ on_sort_products_by_name,
+ on_sort_products_by_availability,
+ on_sort_products_by_stock,
+ on_sort_products_by_price,
+ cx,
+ ))
+ .child(if projection.list.is_empty() {
+ products_empty_state_card(projection.query.filter).into_any_element()
+ } else {
+ products_table_card(&projection.list.rows).into_any_element()
+ })
+}
+
+fn selected_farmer_section(runtime: &DesktopAppRuntimeSummary) -> FarmerSection {
+ match runtime.shell_projection.selected_section {
+ ShellSection::Farmer(section) => section,
+ ShellSection::Home | ShellSection::Settings(_) => FarmerSection::Today,
+ }
+}
+
+fn farmer_products_available(runtime: &DesktopAppRuntimeSummary) -> bool {
+ runtime.farm_setup_projection.has_saved_farm()
+}
+
+fn home_content_scroll_id(section: FarmerSection) -> &'static str {
+ match section {
+ FarmerSection::Products => "home-products-scroll",
+ FarmerSection::Today
+ | FarmerSection::Orders
+ | FarmerSection::PackDay
+ | FarmerSection::Farm => "home-today-scroll",
+ }
+}
+
+fn home_sidebar_nav_button(
+ id: &'static str,
+ key: AppTextKey,
+ is_active: bool,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ if is_active {
+ action_button_primary(id, app_shared_text(key), on_click, cx).into_any_element()
+ } else {
+ action_button(id, app_shared_text(key), on_click, cx).into_any_element()
+ }
+}
+
+fn products_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(4.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0))
+ .font_weight(gpui::FontWeight::BOLD)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(AppTextKey::ProductsTitle)),
+ )
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .when_some(home_saved_farm(runtime), |this, farm| {
+ this.child(farm.display_name.clone())
+ }),
+ )
+}
+
+fn products_controls_card(
+ runtime: &DesktopAppRuntimeSummary,
+ products_search: Option<&ProductsSearchState>,
+ on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let selected_filter = runtime.products_projection.query.filter;
+ let selected_sort = runtime.products_projection.query.sort;
+
+ home_card(
+ app_shared_text(AppTextKey::ProductsFiltersTitle),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .when_some(products_search, |this, products_search| {
+ this.child(
+ Input::new(&products_search.input)
+ .with_size(ComponentSize::Large)
+ .cleanable(true)
+ .w_full(),
+ )
+ })
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(8.0))
+ .child(products_filter_button(
+ "products-filter-all",
+ AppTextKey::ProductsFilterAll,
+ selected_filter == ProductsFilter::All,
+ on_select_all_products,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-filter-live",
+ AppTextKey::ProductsFilterLive,
+ selected_filter == ProductsFilter::Live,
+ on_select_live_products,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-filter-drafts",
+ AppTextKey::ProductsFilterDrafts,
+ selected_filter == ProductsFilter::Drafts,
+ on_select_draft_products,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-filter-need-attention",
+ AppTextKey::ProductsFilterNeedAttention,
+ selected_filter == ProductsFilter::NeedAttention,
+ on_select_products_needing_attention,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-filter-paused",
+ AppTextKey::ProductsFilterPaused,
+ selected_filter == ProductsFilter::Paused,
+ on_select_paused_products,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-filter-archived",
+ AppTextKey::ProductsFilterArchived,
+ selected_filter == ProductsFilter::Archived,
+ on_select_archived_products,
+ cx,
+ )),
+ )
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .justify_between()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(app_shared_text(AppTextKey::ProductsSortTitle)),
+ )
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(8.0))
+ .child(products_filter_button(
+ "products-sort-updated",
+ AppTextKey::ProductsSortUpdated,
+ selected_sort == ProductsSort::Updated,
+ on_sort_products_by_updated,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-sort-name",
+ AppTextKey::ProductsSortName,
+ selected_sort == ProductsSort::Name,
+ on_sort_products_by_name,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-sort-availability",
+ AppTextKey::ProductsSortAvailability,
+ selected_sort == ProductsSort::Availability,
+ on_sort_products_by_availability,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-sort-stock",
+ AppTextKey::ProductsSortStock,
+ selected_sort == ProductsSort::Stock,
+ on_sort_products_by_stock,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-sort-price",
+ AppTextKey::ProductsSortPrice,
+ selected_sort == ProductsSort::Price,
+ on_sort_products_by_price,
+ cx,
+ )),
+ ),
+ ),
+ )
+}
+
+fn products_filter_button(
+ id: &'static str,
+ key: AppTextKey,
+ is_active: bool,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ if is_active {
+ action_button_primary(id, app_shared_text(key), on_click, cx).into_any_element()
+ } else {
+ action_button_compact(id, app_shared_text(key), on_click, cx).into_any_element()
+ }
+}
+
+fn products_table_card(rows: &[ProductsListRow]) -> impl IntoElement {
+ home_card(
+ app_shared_text(AppTextKey::ProductsTableTitle),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .child(products_table_header())
+ .child(section_divider())
+ .children(
+ rows.iter()
+ .enumerate()
+ .flat_map(|(index, row)| {
+ let mut items = vec![products_table_row(row).into_any_element()];
+ if index + 1 < rows.len() {
+ items.push(section_divider().into_any_element());
+ }
+ items
+ })
+ .collect::<Vec<_>>(),
+ ),
+ )
+}
+
+fn products_table_header() -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnProduct,
+ None,
+ true,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnStatus,
+ Some(112.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnAvailability,
+ Some(192.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnStock,
+ Some(128.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnPrice,
+ Some(128.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnUpdated,
+ Some(164.0),
+ false,
+ ))
+}
+
+fn products_table_header_column(
+ key: AppTextKey,
+ width_px: Option<f32>,
+ grows: bool,
+) -> impl IntoElement {
+ div()
+ .when_some(width_px, |this, width_px| this.w(px(width_px)))
+ .when(grows, |this| this.flex_1().min_w_0())
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(app_shared_text(key))
+}
+
+fn products_table_row(row: &ProductsListRow) -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .flex()
+ .flex_col()
+ .gap(px(4.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(row.title.clone()),
+ )
+ .when_some(row.subtitle.as_ref(), |this, subtitle| {
+ this.child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(subtitle.clone()),
+ )
+ }),
+ )
+ .child(
+ div()
+ .w(px(112.0))
+ .flex()
+ .items_center()
+ .gap(px(6.0))
+ .child(status_indicator(products_row_status_color(row)))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(products_status_key(row.status))),
+ ),
+ )
+ .child(
+ div()
+ .w(px(192.0))
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(row.availability.label.clone()),
+ )
+ .child(
+ div()
+ .w(px(128.0))
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(products_stock_text(row)),
+ )
+ .child(
+ div()
+ .w(px(128.0))
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(products_price_text(row)),
+ )
+ .child(
+ div()
+ .w(px(164.0))
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(row.updated_at.clone()),
+ )
+}
+
+fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement {
+ let (title_key, body_key) = if filter == ProductsFilter::NeedAttention {
+ (
+ AppTextKey::ProductsEmptyNeedAttentionTitle,
+ AppTextKey::ProductsEmptyNeedAttentionBody,
+ )
+ } else {
+ (
+ AppTextKey::ProductsEmptyTitle,
+ AppTextKey::ProductsEmptyBody,
+ )
+ };
+
+ home_empty_state_card(title_key, body_key)
+}
+
+fn products_status_key(status: ProductStatus) -> AppTextKey {
+ match status {
+ ProductStatus::Draft => AppTextKey::ProductsStatusDraft,
+ ProductStatus::Published => AppTextKey::ProductsStatusLive,
+ ProductStatus::Paused => AppTextKey::ProductsStatusPaused,
+ ProductStatus::Archived => AppTextKey::ProductsStatusArchived,
+ }
+}
+
+fn products_row_status_color(row: &ProductsListRow) -> u32 {
+ if row.attention_state != ProductAttentionState::Healthy {
+ APP_UI_THEME.controls.status_indicator.attention
+ } else {
+ match row.status {
+ ProductStatus::Published => APP_UI_THEME.controls.status_indicator.online,
+ ProductStatus::Draft | ProductStatus::Paused | ProductStatus::Archived => {
+ APP_UI_THEME.controls.status_indicator.offline
+ }
+ }
+ }
+}
+
+fn products_stock_text(row: &ProductsListRow) -> String {
+ match row.stock.quantity {
+ Some(quantity) => match row.stock.unit_label.as_ref() {
+ Some(unit_label) => format!("{quantity} {unit_label}"),
+ None => quantity.to_string(),
+ },
+ None => app_shared_text(AppTextKey::ValueNone).to_string(),
+ }
+}
+
+fn products_price_text(row: &ProductsListRow) -> String {
+ let Some(price) = row.price.as_ref() else {
+ return app_shared_text(AppTextKey::ValueNone).to_string();
+ };
+ let dollars = price.amount_minor_units / 100;
+ let cents = price.amount_minor_units % 100;
+
+ format!("${dollars}.{cents:02} / {}", price.unit_label)
+}
+
fn home_farm_setup_onboarding_card(
spec: FarmSetupOnboardingCardSpec,
on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -2458,6 +3326,7 @@ mod tests {
home_route,
farm_setup_projection,
today_projection,
+ products_projection: Default::default(),
startup_issue: None,
}
}
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -22,6 +22,8 @@ macro_rules! define_app_text_keys {
define_app_text_keys! {
AppName => "app.name",
HomeBrand => "home.brand",
+ HomeNavToday => "home.nav.today",
+ HomeNavProducts => "home.nav.products",
HomeTodayTitle => "home.today.title",
HomeTodayStatusNoFarm => "home.today.status.no_farm",
HomeTodayStatusSetup => "home.today.status.setup",
@@ -67,6 +69,40 @@ define_app_text_keys! {
HomeTodayEmptyNoFarmBody => "home.today.empty.no_farm.body",
HomeTodayEmptyQuietTitle => "home.today.empty.quiet.title",
HomeTodayEmptyQuietBody => "home.today.empty.quiet.body",
+ ProductsTitle => "products.title",
+ ProductsFiltersTitle => "products.filters.title",
+ ProductsSearchPlaceholder => "products.search.placeholder",
+ ProductsSummaryTotal => "products.summary.total",
+ ProductsSummaryLive => "products.summary.live",
+ ProductsSummaryNeedAttention => "products.summary.need_attention",
+ ProductsSummaryDrafts => "products.summary.drafts",
+ ProductsFilterAll => "products.filter.all",
+ ProductsFilterLive => "products.filter.live",
+ ProductsFilterDrafts => "products.filter.drafts",
+ ProductsFilterNeedAttention => "products.filter.need_attention",
+ ProductsFilterPaused => "products.filter.paused",
+ ProductsFilterArchived => "products.filter.archived",
+ ProductsSortTitle => "products.sort.title",
+ ProductsSortUpdated => "products.sort.updated",
+ ProductsSortName => "products.sort.name",
+ ProductsSortAvailability => "products.sort.availability",
+ ProductsSortStock => "products.sort.stock",
+ ProductsSortPrice => "products.sort.price",
+ ProductsTableTitle => "products.table.title",
+ ProductsColumnProduct => "products.column.product",
+ ProductsColumnStatus => "products.column.status",
+ ProductsColumnAvailability => "products.column.availability",
+ ProductsColumnStock => "products.column.stock",
+ ProductsColumnPrice => "products.column.price",
+ ProductsColumnUpdated => "products.column.updated",
+ ProductsStatusDraft => "products.status.draft",
+ ProductsStatusLive => "products.status.live",
+ ProductsStatusPaused => "products.status.paused",
+ ProductsStatusArchived => "products.status.archived",
+ ProductsEmptyTitle => "products.empty.title",
+ ProductsEmptyBody => "products.empty.body",
+ ProductsEmptyNeedAttentionTitle => "products.empty.need_attention.title",
+ ProductsEmptyNeedAttentionBody => "products.empty.need_attention.body",
MenuQuit => "menu.quit",
MenuAbout => "menu.about",
MenuServices => "menu.services",
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -1,6 +1,8 @@
{
"app.name": "Radroots",
"home.brand": "radroots",
+ "home.nav.today": "Today",
+ "home.nav.products": "Products",
"home.today.title": "Today",
"home.today.status.no_farm": "No farm configured",
"home.today.status.setup": "Setup required",
@@ -46,6 +48,40 @@
"home.today.empty.no_farm.body": "Create a farm to start using the farmer workspace.",
"home.today.empty.quiet.title": "Nothing urgent right now",
"home.today.empty.quiet.body": "Orders, stock, and drafts will appear here when they need attention.",
+ "products.title": "Products",
+ "products.filters.title": "View",
+ "products.search.placeholder": "Search products",
+ "products.summary.total": "Total products",
+ "products.summary.live": "Live products",
+ "products.summary.need_attention": "Need attention",
+ "products.summary.drafts": "Draft products",
+ "products.filter.all": "All",
+ "products.filter.live": "Live",
+ "products.filter.drafts": "Drafts",
+ "products.filter.need_attention": "Need attention",
+ "products.filter.paused": "Paused",
+ "products.filter.archived": "Archived",
+ "products.sort.title": "Sort",
+ "products.sort.updated": "Updated",
+ "products.sort.name": "Name",
+ "products.sort.availability": "Availability",
+ "products.sort.stock": "Stock",
+ "products.sort.price": "Price",
+ "products.table.title": "Product list",
+ "products.column.product": "Product",
+ "products.column.status": "Status",
+ "products.column.availability": "Availability",
+ "products.column.stock": "Stock",
+ "products.column.price": "Price",
+ "products.column.updated": "Updated",
+ "products.status.draft": "Draft",
+ "products.status.live": "Live",
+ "products.status.paused": "Paused",
+ "products.status.archived": "Archived",
+ "products.empty.title": "No products yet",
+ "products.empty.body": "Products will appear here when this farm has them.",
+ "products.empty.need_attention.title": "Nothing needs attention",
+ "products.empty.need_attention.body": "All current products are in a healthy state right now.",
"menu.about": "About Radroots",
"menu.services": "Services",
"menu.quit": "Quit Radroots",