app

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

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:
Mcrates/launchers/desktop/src/app.rs | 10++++++++++
Mcrates/launchers/desktop/src/runtime.rs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/launchers/desktop/src/source_guards.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 893+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/i18n/src/keys.rs | 36++++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 36++++++++++++++++++++++++++++++++++++
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",