app

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

commit f2e1a0b3ebc5f7fb0a7fdad63d617c206d13be89
parent f2dfac1519fb35ee12ca781233eb69912be931d2
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 00:19:19 +0000

settings: add farm profile and pickup editing

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/launchers/desktop/src/source_guards.rs | 21+++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 754++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/i18n/src/keys.rs | 11+++++++++++
Mcrates/shared/i18n/src/lib.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/farm_rules.rs | 179++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/shared/sqlite/src/lib.rs | 16++++++++++++----
Mi18n/locales/en/messages.json | 11+++++++++++
8 files changed, 1373 insertions(+), 69 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -4,16 +4,18 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, - FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, - LoggedOutStartupProjection, ProductEditorDraft, ProductId, ProductsFilter, - ProductsListProjection, ProductsSort, SettingsAccountProjection, SettingsPreference, - SettingsSection, ShellSection, TodayAgendaProjection, + FarmId, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, + FarmSetupProjection, FarmSummary, FarmerSection, LoggedOutStartupProjection, + PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, + ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, + derive_farm_rules_readiness, }; use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage, @@ -301,6 +303,19 @@ impl DesktopAppRuntime { self.lock_state_mut().finish_farm_setup() } + pub fn load_farm_rules_projection( + &self, + ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { + self.lock_state().load_farm_rules_projection() + } + + pub fn save_farm_rules_projection( + &self, + projection: FarmRulesProjection, + ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { + self.lock_state_mut().save_farm_rules_projection(projection) + } + pub fn record_home_opened(&self) -> bool { self.record_activity(AppActivityKind::HomeOpened) } @@ -831,6 +846,77 @@ impl DesktopAppRuntimeState { Ok(selected_account_context.farm_setup_projection) } + fn load_farm_rules_projection( + &self, + ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { + let farm_id = self + .selected_farm_id() + .ok_or(DesktopAppRuntimeFarmRulesError::FarmRequired)?; + let fallback_profile = self.fallback_farm_profile(farm_id); + let projection = self + .sqlite_store_for_farm_rules()? + .load_farm_rules(farm_id) + .map(|projection| { + prepare_loaded_farm_rules_projection(projection, &fallback_profile) + })?; + + Ok(projection) + } + + fn save_farm_rules_projection( + &mut self, + projection: FarmRulesProjection, + ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { + let account_id = self.selected_account_id_for_farm_rules()?; + let farm_id = self + .selected_farm_id() + .ok_or(DesktopAppRuntimeFarmRulesError::FarmRequired)?; + let fallback_profile = self.fallback_farm_profile(farm_id); + let normalized = normalize_farm_rules_projection(projection, &fallback_profile); + + let saved_projection = { + let sqlite_store = self.sqlite_store_for_farm_rules()?; + sqlite_store.save_farm_rules(&normalized)?; + + let mut refreshed = sqlite_store.load_farm_rules(farm_id)?; + refreshed = prepare_loaded_farm_rules_projection(refreshed, &fallback_profile); + + let saved_farm = FarmSummary { + farm_id, + display_name: refreshed + .farm_profile + .as_ref() + .map(|profile| profile.display_name.clone()) + .unwrap_or_default(), + readiness: if refreshed.is_ready() { + FarmReadiness::Ready + } else { + FarmReadiness::Incomplete + }, + }; + let mut farm_setup_projection = self.state_store.farm_setup_projection().clone(); + farm_setup_projection.draft.farm_name = saved_farm.display_name.clone(); + farm_setup_projection.saved_farm = Some(saved_farm.clone()); + + sqlite_store.save_farm_summary(&saved_farm)?; + sqlite_store.save_farm_setup(account_id.as_str(), &farm_setup_projection)?; + + refreshed + }; + + let selected_account_context = { + let sqlite_store = self.sqlite_store_for_farm_rules()?; + load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + )? + }; + self.apply_selected_account_context(&selected_account_context); + + Ok(saved_projection) + } + fn replace_identity_projection( &mut self, projection: AppIdentityProjection, @@ -891,6 +977,13 @@ impl DesktopAppRuntimeState { .map(|account| account.account.account_id.clone()) } + fn selected_account_id_for_farm_rules( + &self, + ) -> Result<String, DesktopAppRuntimeFarmRulesError> { + self.selected_account_for_farm_rules() + .map(|account| account.account.account_id.clone()) + } + fn selected_account_for_farm_setup( &self, ) -> Result<&radroots_app_models::SelectedAccountProjection, DesktopAppRuntimeFarmSetupError> @@ -902,6 +995,17 @@ impl DesktopAppRuntimeState { .ok_or(DesktopAppRuntimeFarmSetupError::AccountRequired) } + fn selected_account_for_farm_rules( + &self, + ) -> Result<&radroots_app_models::SelectedAccountProjection, DesktopAppRuntimeFarmRulesError> + { + self.state_store + .identity_projection() + .selected_account + .as_ref() + .ok_or(DesktopAppRuntimeFarmRulesError::AccountRequired) + } + fn accounts_manager( &self, ) -> Result<&RadrootsNostrAccountsManager, DesktopAppRuntimeCommandError> { @@ -924,6 +1028,14 @@ impl DesktopAppRuntimeState { .ok_or(DesktopAppRuntimeFarmSetupError::RuntimeUnavailable) } + fn sqlite_store_for_farm_rules( + &self, + ) -> Result<&AppSqliteStore, DesktopAppRuntimeFarmRulesError> { + self.sqlite_store + .as_ref() + .ok_or(DesktopAppRuntimeFarmRulesError::RuntimeUnavailable) + } + fn replace_products_query( &mut self, query: ProductsScreenQueryState, @@ -968,6 +1080,33 @@ impl DesktopAppRuntimeState { } } + fn fallback_farm_profile(&self, farm_id: FarmId) -> FarmProfileRecord { + let saved_farm_name = self + .state_store + .farm_setup_projection() + .saved_farm + .as_ref() + .filter(|farm| farm.farm_id == farm_id) + .map(|farm| farm.display_name.clone()); + let drafted_farm_name = self + .state_store + .farm_setup_projection() + .draft + .farm_name + .trim() + .to_owned(); + let display_name = saved_farm_name + .or_else(|| (!drafted_farm_name.is_empty()).then_some(drafted_farm_name)) + .unwrap_or_default(); + + FarmProfileRecord { + farm_id, + display_name, + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + } + } + fn load_products_list_for_query( &self, query: &ProductsScreenQueryState, @@ -1113,6 +1252,18 @@ pub enum DesktopAppRuntimeFarmSetupError { } #[derive(Debug, Error)] +pub enum DesktopAppRuntimeFarmRulesError { + #[error("desktop runtime commands are unavailable while the runtime is degraded")] + RuntimeUnavailable, + #[error("farm settings require a selected account")] + AccountRequired, + #[error("farm settings require a configured farm")] + FarmRequired, + #[error(transparent)] + Sqlite(#[from] AppSqliteError), +} + +#[derive(Debug, Error)] enum DesktopAppRuntimeBootstrapError { #[error(transparent)] RuntimePaths(#[from] AppRuntimePathsError), @@ -1166,6 +1317,60 @@ fn load_selected_account_context( }) } +fn prepare_loaded_farm_rules_projection( + mut projection: FarmRulesProjection, + fallback_profile: &FarmProfileRecord, +) -> FarmRulesProjection { + if projection.farm_profile.is_none() { + projection.farm_profile = Some(fallback_profile.clone()); + } + + normalize_pickup_location_defaults(&mut projection.pickup_locations); + projection.readiness = derive_farm_rules_readiness(&projection); + projection +} + +fn normalize_farm_rules_projection( + mut projection: FarmRulesProjection, + fallback_profile: &FarmProfileRecord, +) -> FarmRulesProjection { + let mut farm_profile = projection + .farm_profile + .take() + .unwrap_or_else(|| fallback_profile.clone()); + farm_profile.farm_id = fallback_profile.farm_id; + farm_profile.display_name = farm_profile.display_name.trim().to_owned(); + farm_profile.timezone = farm_profile.timezone.trim().to_owned(); + farm_profile.currency_code = farm_profile.currency_code.trim().to_uppercase(); + projection.farm_profile = Some(farm_profile); + + for pickup_location in &mut projection.pickup_locations { + pickup_location.farm_id = fallback_profile.farm_id; + pickup_location.label = pickup_location.label.trim().to_owned(); + pickup_location.address_line = pickup_location.address_line.trim().to_owned(); + pickup_location.directions = pickup_location + .directions + .take() + .map(|directions| directions.trim().to_owned()) + .filter(|directions| !directions.is_empty()); + } + + normalize_pickup_location_defaults(&mut projection.pickup_locations); + projection.readiness = derive_farm_rules_readiness(&projection); + projection +} + +fn normalize_pickup_location_defaults(pickup_locations: &mut [PickupLocationRecord]) { + let default_index = pickup_locations + .iter() + .position(|pickup_location| pickup_location.is_default) + .or_else(|| (!pickup_locations.is_empty()).then_some(0)); + + for (index, pickup_location) in pickup_locations.iter_mut().enumerate() { + pickup_location.is_default = Some(index) == default_index; + } +} + #[cfg(test)] mod tests { use std::{ @@ -1181,8 +1386,9 @@ mod tests { }; use radroots_app_models::{ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, - FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FarmerActivationProjection, FarmerSection, LoggedOutStartupProjection, ProductEditorDraft, + FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft, + FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, + LoggedOutStartupProjection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, @@ -2529,6 +2735,200 @@ mod tests { } #[test] + fn loading_farm_rules_projection_seeds_profile_from_saved_farm() { + 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::Incomplete, + }); + 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()) + .expect("account should select") + ); + + let projection = runtime + .load_farm_rules_projection() + .expect("farm rules projection should load"); + + assert_eq!( + projection.farm_profile, + Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }) + ); + assert_eq!( + projection.readiness.blockers, + vec![ + FarmReadinessBlocker::MissingPickupLocation, + FarmReadinessBlocker::MissingOperatingRules, + FarmReadinessBlocker::MissingFulfillmentWindow, + ] + ); + } + + #[test] + fn saving_farm_rules_projection_refreshes_saved_farm_summary_and_pickup_defaults() { + 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::Incomplete, + }); + 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()) + .expect("account should select") + ); + + let saved_projection = runtime + .save_farm_rules_projection(radroots_app_models::FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "Harbor farm".to_owned(), + timezone: "Europe/Stockholm".to_owned(), + currency_code: "sek".to_owned(), + }), + pickup_locations: vec![ + PickupLocationRecord { + pickup_location_id: PickupLocationId::new(), + farm_id, + label: " Barn pickup ".to_owned(), + address_line: " 14 Orchard Lane ".to_owned(), + directions: Some(" Drive to the red barn. ".to_owned()), + is_default: false, + }, + PickupLocationRecord { + pickup_location_id: PickupLocationId::new(), + farm_id, + label: "Market stall".to_owned(), + address_line: "2 Harbor Road".to_owned(), + directions: None, + is_default: false, + }, + ], + ..runtime + .load_farm_rules_projection() + .expect("farm rules projection should load") + }) + .expect("farm rules projection should save"); + + assert_eq!( + saved_projection.farm_profile, + Some(FarmProfileRecord { + farm_id, + display_name: "Harbor farm".to_owned(), + timezone: "Europe/Stockholm".to_owned(), + currency_code: "SEK".to_owned(), + }) + ); + assert_eq!(saved_projection.pickup_locations.len(), 2); + assert!(saved_projection.pickup_locations[0].is_default); + assert_eq!(saved_projection.pickup_locations[0].label, "Barn pickup"); + assert_eq!( + saved_projection.pickup_locations[0].address_line, + "14 Orchard Lane" + ); + assert_eq!( + saved_projection.pickup_locations[0].directions.as_deref(), + Some("Drive to the red barn.") + ); + + let summary = runtime.summary(); + assert_eq!( + summary.farm_setup_projection.saved_farm, + Some(FarmSummary { + farm_id, + display_name: "Harbor farm".to_owned(), + readiness: FarmReadiness::Incomplete, + }) + ); + assert_eq!(summary.farm_setup_projection.draft.farm_name, "Harbor farm"); + assert_eq!( + summary.today_projection.farm, + summary.farm_setup_projection.saved_farm + ); + } + + #[test] fn runtime_reset_local_device_state_clears_store_file_and_projection() { let (runtime, paths) = file_backed_runtime("reset"); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -30,8 +30,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "bunker uri", "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", "failed to add relay `{relay_url}`: {error}", + "failed to load farm settings projection", "failed to open existing product editor", "failed to open new product editor", + "failed to save farm settings projection", "failed to save product editor draft", "failed to route into products view", "failed to update product stock", @@ -96,7 +98,12 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "remote signer connection failed: relay refused the request", "remote signer did not respond yet", "runtime unavailable", + "settings", "settings-allow-relay-connections", + "settings-farm-add-pickup", + "settings-farm-default-pickup", + "settings-farm-remove-pickup", + "settings-farm-save", "settings-launch-at-login", "settings-manage-media-servers", "settings-nav-about", @@ -106,11 +113,14 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "settings-panel-scroll", "settings-use-media-servers", "settings-use-nip05", + "settings.farm.load_failed", + "settings.farm.save_failed", "sign_event:kind:1, switch_relays", "startup-title-radroots", "startup-title-starting", "wss://relay.radroots.example", "{quantity} {unit_label}", + "{} {}", ]; const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ @@ -223,9 +233,19 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::SettingsAccountOpenWorkspaceAction", "AppTextKey::SettingsNavFarm", "AppTextKey::SettingsFarmPanelBody", + "AppTextKey::SettingsFarmUnavailableBody", + "AppTextKey::SettingsFarmSaveAction", + "AppTextKey::SettingsFarmSaveSaved", + "AppTextKey::SettingsFarmSavePending", + "AppTextKey::SettingsFarmSaveFailed", "AppTextKey::SettingsFarmFieldTimezone", "AppTextKey::SettingsFarmFieldCurrency", "AppTextKey::SettingsPickupLocationsSectionLabel", + "AppTextKey::SettingsPickupLocationsEmptyBody", + "AppTextKey::SettingsPickupLocationsAddAction", + "AppTextKey::SettingsPickupLocationsMakeDefaultAction", + "AppTextKey::SettingsPickupLocationsDefaultBadge", + "AppTextKey::SettingsPickupLocationsRemoveAction", "AppTextKey::SettingsPickupLocationsFieldLabel", "AppTextKey::SettingsPickupLocationsFieldAddress", "AppTextKey::SettingsPickupLocationsFieldDirections", @@ -251,6 +271,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow", "AppTextKey::SettingsReadinessFieldMissingOperatingRules", "AppTextKey::SettingsReadinessFieldInvalidTimingConflicts", + "AppTextKey::SettingsReadinessReady", ]; #[test] diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -13,11 +13,12 @@ use gpui_component::{ use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ - AppStartupGate, FarmOrderMethod, FarmReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, - FarmerSection, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderListRow, - ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, - ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, - TodayAgendaProjection, TodaySetupTaskKind, + AppStartupGate, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, + FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupDraft, FarmSummary, + FarmerSection, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderListRow, PickupLocationId, + PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, + ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, + ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -25,6 +26,7 @@ use radroots_app_remote_signer::{ radroots_app_remote_signer_poll_pending_session_with_progress, radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions, }; +use radroots_app_sqlite::derive_farm_rules_readiness; use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, @@ -1877,14 +1879,241 @@ impl LoggedInHomeView { } } +struct SettingsPickupLocationFormState { + pickup_location_id: PickupLocationId, + label_input: Entity<InputState>, + address_input: Entity<InputState>, + directions_input: Entity<InputState>, + is_default: bool, + can_remove: bool, + _label_subscription: Subscription, + _address_subscription: Subscription, + _directions_subscription: Subscription, +} + +impl SettingsPickupLocationFormState { + fn new( + record: &PickupLocationRecord, + can_remove: bool, + window: &mut Window, + cx: &mut Context<SettingsWindowView>, + ) -> Self { + let label_input = + cx.new(|cx| InputState::new(window, cx).default_value(record.label.clone())); + let address_input = + cx.new(|cx| InputState::new(window, cx).default_value(record.address_line.clone())); + let directions_input = cx.new(|cx| { + InputState::new(window, cx).default_value(record.directions.clone().unwrap_or_default()) + }); + let label_subscription = cx.subscribe_in( + &label_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + let address_subscription = cx.subscribe_in( + &address_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + let directions_subscription = cx.subscribe_in( + &directions_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + + Self { + pickup_location_id: record.pickup_location_id, + label_input, + address_input, + directions_input, + is_default: record.is_default, + can_remove, + _label_subscription: label_subscription, + _address_subscription: address_subscription, + _directions_subscription: directions_subscription, + } + } + + fn current_record(&self, farm_id: FarmId, cx: &App) -> PickupLocationRecord { + let directions = self.directions_input.read(cx).value().trim().to_owned(); + + PickupLocationRecord { + pickup_location_id: self.pickup_location_id, + farm_id, + label: self.label_input.read(cx).value().to_string(), + address_line: self.address_input.read(cx).value().to_string(), + directions: (!directions.is_empty()).then_some(directions), + is_default: self.is_default, + } + } +} + +struct SettingsFarmPanelState { + account_id: String, + farm_id: FarmId, + initial_projection: FarmRulesProjection, + farm_name_input: Entity<InputState>, + timezone_input: Entity<InputState>, + currency_input: Entity<InputState>, + pickup_locations: Vec<SettingsPickupLocationFormState>, + _farm_name_subscription: Subscription, + _timezone_subscription: Subscription, + _currency_subscription: Subscription, + save_failed: bool, +} + +impl SettingsFarmPanelState { + fn new( + account_id: String, + projection: FarmRulesProjection, + window: &mut Window, + cx: &mut Context<SettingsWindowView>, + ) -> Self { + let farm_profile = projection + .farm_profile + .as_ref() + .cloned() + .unwrap_or(FarmProfileRecord { + farm_id: FarmId::new(), + display_name: String::new(), + timezone: String::new(), + currency_code: String::new(), + }); + let farm_name_input = + cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.display_name)); + let timezone_input = + cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.timezone)); + let currency_input = + cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.currency_code)); + let farm_name_subscription = cx.subscribe_in( + &farm_name_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + let timezone_subscription = cx.subscribe_in( + &timezone_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + let currency_subscription = cx.subscribe_in( + &currency_input, + window, + SettingsWindowView::handle_farm_rules_input_event, + ); + let pickup_locations = projection + .pickup_locations + .iter() + .map(|record| { + let can_remove = projection.fulfillment_windows.iter().all(|window_record| { + window_record.pickup_location_id != record.pickup_location_id + }); + SettingsPickupLocationFormState::new(record, can_remove, window, cx) + }) + .collect(); + let farm_id = projection + .farm_profile + .as_ref() + .map(|farm_profile| farm_profile.farm_id) + .unwrap_or_else(FarmId::new); + + Self { + account_id, + farm_id, + initial_projection: projection, + farm_name_input, + timezone_input, + currency_input, + pickup_locations, + _farm_name_subscription: farm_name_subscription, + _timezone_subscription: timezone_subscription, + _currency_subscription: currency_subscription, + save_failed: false, + } + } + + fn add_pickup_location(&mut self, window: &mut Window, cx: &mut Context<SettingsWindowView>) { + let record = PickupLocationRecord { + pickup_location_id: PickupLocationId::new(), + farm_id: self.farm_id, + label: String::new(), + address_line: String::new(), + directions: None, + is_default: self.pickup_locations.is_empty(), + }; + let pickup_location = SettingsPickupLocationFormState::new(&record, true, window, cx); + + self.pickup_locations.push(pickup_location); + self.save_failed = false; + } + + fn set_default_pickup_location(&mut self, pickup_location_id: PickupLocationId) { + for pickup_location in &mut self.pickup_locations { + pickup_location.is_default = pickup_location.pickup_location_id == pickup_location_id; + } + self.save_failed = false; + } + + fn remove_pickup_location(&mut self, pickup_location_id: PickupLocationId) { + self.pickup_locations + .retain(|pickup_location| pickup_location.pickup_location_id != pickup_location_id); + if !self + .pickup_locations + .iter() + .any(|pickup_location| pickup_location.is_default) + { + if let Some(first_pickup_location) = self.pickup_locations.first_mut() { + first_pickup_location.is_default = true; + } + } + self.save_failed = false; + } + + fn current_projection(&self, cx: &App) -> FarmRulesProjection { + let mut projection = self.initial_projection.clone(); + projection.farm_profile = Some(FarmProfileRecord { + farm_id: self.farm_id, + display_name: self.farm_name_input.read(cx).value().to_string(), + timezone: self.timezone_input.read(cx).value().to_string(), + currency_code: self.currency_input.read(cx).value().to_string(), + }); + projection.pickup_locations = self + .pickup_locations + .iter() + .map(|pickup_location| pickup_location.current_record(self.farm_id, cx)) + .collect(); + projection.readiness = derive_farm_rules_readiness(&projection); + projection + } + + fn has_changes(&self, cx: &App) -> bool { + self.current_projection(cx) != self.initial_projection + } + + fn save_status_key(&self, cx: &App) -> AppTextKey { + if self.save_failed { + AppTextKey::SettingsFarmSaveFailed + } else if self.has_changes(cx) { + AppTextKey::SettingsFarmSavePending + } else { + AppTextKey::SettingsFarmSaveSaved + } + } +} + pub struct SettingsWindowView { runtime: DesktopAppRuntime, + farm_panel_state: Option<SettingsFarmPanelState>, + farm_panel_error: Option<String>, } impl SettingsWindowView { pub fn new(runtime: DesktopAppRuntime, initial_view: SettingsPanelViewKey) -> Self { let _ = initial_view; - Self { runtime } + Self { + runtime, + farm_panel_state: None, + farm_panel_error: None, + } } fn select_view(&mut self, view: SettingsPanelViewKey, cx: &mut Context<Self>) { @@ -1897,6 +2126,135 @@ impl SettingsWindowView { self.runtime.selected_settings_section() } + fn handle_farm_rules_input_event( + &mut self, + _: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + + if let Some(form) = self.farm_panel_state.as_mut() { + form.save_failed = false; + } + + cx.notify(); + } + + fn sync_farm_panel_state(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let runtime = self.runtime.summary(); + let Some((account_id, farm_id)) = settings_panel_farm_context(&runtime) else { + self.farm_panel_state = None; + self.farm_panel_error = None; + return; + }; + + if self + .farm_panel_state + .as_ref() + .is_some_and(|form| form.account_id == account_id && form.farm_id == farm_id) + { + return; + } + + match self.runtime.load_farm_rules_projection() { + Ok(projection) => { + self.farm_panel_state = Some(SettingsFarmPanelState::new( + account_id, projection, window, cx, + )); + self.farm_panel_error = None; + } + Err(runtime_error) => { + error!( + target: "settings", + event = "settings.farm.load_failed", + error = %runtime_error, + "failed to load farm settings projection" + ); + self.farm_panel_state = None; + self.farm_panel_error = Some(runtime_error.to_string()); + } + } + } + + fn add_pickup_location(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let Some(form) = self.farm_panel_state.as_mut() else { + return; + }; + + form.add_pickup_location(window, cx); + cx.notify(); + } + + fn select_default_pickup_location( + &mut self, + pickup_location_id: PickupLocationId, + cx: &mut Context<Self>, + ) { + let Some(form) = self.farm_panel_state.as_mut() else { + return; + }; + + form.set_default_pickup_location(pickup_location_id); + cx.notify(); + } + + fn remove_pickup_location( + &mut self, + pickup_location_id: PickupLocationId, + cx: &mut Context<Self>, + ) { + let Some(form) = self.farm_panel_state.as_mut() else { + return; + }; + + form.remove_pickup_location(pickup_location_id); + cx.notify(); + } + + fn save_farm_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let Some(current_projection) = self + .farm_panel_state + .as_ref() + .map(|form| form.current_projection(cx)) + else { + return; + }; + + match self.runtime.save_farm_rules_projection(current_projection) { + Ok(saved_projection) => { + let account_id = self + .farm_panel_state + .as_ref() + .map(|form| form.account_id.clone()) + .unwrap_or_default(); + self.farm_panel_state = Some(SettingsFarmPanelState::new( + account_id, + saved_projection, + window, + cx, + )); + self.farm_panel_error = None; + cx.notify(); + } + Err(runtime_error) => { + error!( + target: "settings", + event = "settings.farm.save_failed", + error = %runtime_error, + "failed to save farm settings projection" + ); + if let Some(form) = self.farm_panel_state.as_mut() { + form.save_failed = true; + } + cx.notify(); + } + } + } + fn navigation_button( &mut self, view: SettingsPanelViewKey, @@ -2302,16 +2660,138 @@ impl SettingsWindowView { settings_inventory_panel(AppTextKey::SettingsSettingsPanelBody, cards) } - fn farm_panel(&self) -> impl IntoElement { - settings_inventory_panel( - AppTextKey::SettingsFarmPanelBody, - SETTINGS_FARM_PANEL_SECTIONS - .iter() - .copied() - .map(settings_inventory_card) - .map(IntoElement::into_any_element) - .collect(), - ) + fn farm_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + self.sync_farm_panel_state(window, cx); + + let mut cards = Vec::new(); + + if let Some(error) = self.farm_panel_error.as_ref() { + cards.push( + home_card( + app_shared_text(AppTextKey::SettingsNavFarm), + home_body_text(error.clone()), + ) + .into_any_element(), + ); + return settings_inventory_panel(AppTextKey::SettingsFarmPanelBody, cards); + } + + let Some(form) = self.farm_panel_state.as_ref() else { + cards.push( + home_card( + app_shared_text(AppTextKey::SettingsNavFarm), + home_body_text(app_shared_text(AppTextKey::SettingsFarmUnavailableBody)), + ) + .into_any_element(), + ); + return settings_inventory_panel(AppTextKey::SettingsFarmPanelBody, cards); + }; + + let current_projection = form.current_projection(cx); + let save_action = if form.has_changes(cx) { + action_button_primary( + "settings-farm-save", + app_shared_text(AppTextKey::SettingsFarmSaveAction), + cx.listener(|this, _, window, cx| this.save_farm_panel(window, cx)), + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "settings-farm-save", + app_shared_text(AppTextKey::SettingsFarmSaveAction), + cx, + ) + .into_any_element() + }; + + cards.push( + home_card( + app_shared_text(AppTextKey::HomeFarmSetupSectionFarm), + div() + .w_full() + .flex() + .flex_col() + .gap(px(12.0)) + .child(settings_text_field( + AppTextKey::HomeFarmSetupFieldFarmName, + &form.farm_name_input, + )) + .child(settings_text_field( + AppTextKey::SettingsFarmFieldTimezone, + &form.timezone_input, + )) + .child(settings_text_field( + AppTextKey::SettingsFarmFieldCurrency, + &form.currency_input, + )), + ) + .into_any_element(), + ); + cards.push( + home_card( + app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel), + div() + .w_full() + .flex() + .flex_col() + .gap(px(12.0)) + .when(form.pickup_locations.is_empty(), |this| { + this.child(home_body_text(app_shared_text( + AppTextKey::SettingsPickupLocationsEmptyBody, + ))) + }) + .children( + form.pickup_locations + .iter() + .enumerate() + .map(|(index, pickup_location)| { + let pickup_location_id = pickup_location.pickup_location_id; + settings_pickup_location_card( + index, + pickup_location, + cx.listener(move |this, _, _, cx| { + this.select_default_pickup_location(pickup_location_id, cx) + }), + cx.listener(move |this, _, _, cx| { + this.remove_pickup_location(pickup_location_id, cx) + }), + cx, + ) + .into_any_element() + }) + .collect::<Vec<_>>(), + ) + .child( + settings_dynamic_action_button( + "settings-farm-add-pickup", + app_shared_text(AppTextKey::SettingsPickupLocationsAddAction), + false, + cx.listener(|this, _, window, cx| this.add_pickup_location(window, cx)), + cx, + ) + .into_any_element(), + ), + ) + .into_any_element(), + ); + cards.push( + home_card( + app_shared_text(AppTextKey::SettingsReadinessSectionLabel), + div() + .w_full() + .flex() + .flex_col() + .gap(px(12.0)) + .children(settings_farm_readiness_rows(&current_projection.readiness)) + .child(section_divider()) + .child(home_body_text(app_shared_text(form.save_status_key(cx)))) + .child(div().child(save_action)), + ) + .into_any_element(), + ); + + settings_inventory_panel(AppTextKey::SettingsFarmPanelBody, cards) } fn about_panel(&self) -> impl IntoElement { @@ -2366,10 +2846,14 @@ impl SettingsWindowView { ) } - fn settings_panel_content(&mut self, cx: &mut Context<Self>) -> AnyElement { + fn settings_panel_content( + &mut self, + window: &mut Window, + cx: &mut Context<Self>, + ) -> AnyElement { match self.selected_view() { SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(), - SettingsPanelViewKey::Farm => self.farm_panel().into_any_element(), + SettingsPanelViewKey::Farm => self.farm_panel(window, cx).into_any_element(), SettingsPanelViewKey::Settings => self.settings_panel(cx).into_any_element(), SettingsPanelViewKey::About => self.about_panel().into_any_element(), } @@ -2377,7 +2861,7 @@ impl SettingsWindowView { } impl Render for SettingsWindowView { - fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let navigation_buttons = SETTINGS_NAVIGATION_ORDER .iter() .copied() @@ -2418,7 +2902,7 @@ impl Render for SettingsWindowView { div() .flex_1() .overflow_hidden() - .child(self.settings_panel_content(cx)), + .child(self.settings_panel_content(window, cx)), ), ) } @@ -4631,6 +5115,236 @@ fn home_farm_setup_blocker(key: AppTextKey) -> impl IntoElement { .child(app_shared_text(key)) } +fn settings_panel_farm_context(runtime: &DesktopAppRuntimeSummary) -> Option<(String, FarmId)> { + let account_id = runtime + .settings_account_projection + .selected_account + .as_ref()? + .account + .account_id + .clone(); + let farm_id = runtime + .settings_account_projection + .selected_account + .as_ref() + .and_then(|account| account.farmer_activation.farm_id) + .or(runtime + .farm_setup_projection + .saved_farm + .as_ref() + .map(|farm| farm.farm_id))?; + + Some((account_id, farm_id)) +} + +fn settings_text_field(label_key: AppTextKey, input: &Entity<InputState>) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .gap(px(6.0)) + .child(home_farm_setup_field_label(label_key)) + .child( + Input::new(input) + .with_size(ComponentSize::Large) + .w_full() + .into_any_element(), + ) +} + +fn settings_pickup_location_card( + index: usize, + pickup_location: &SettingsPickupLocationFormState, + on_make_default: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let title = { + let label = pickup_location + .label_input + .read(cx) + .value() + .trim() + .to_owned(); + if label.is_empty() { + format!( + "{} {}", + app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel), + index + 1 + ) + } else { + label + } + }; + let action_row = div() + .flex() + .items_center() + .gap(px(8.0)) + .child(if pickup_location.is_default { + settings_badge_text(AppTextKey::SettingsPickupLocationsDefaultBadge).into_any_element() + } else { + settings_dynamic_action_button( + ("settings-farm-default-pickup", index), + app_shared_text(AppTextKey::SettingsPickupLocationsMakeDefaultAction), + false, + on_make_default, + cx, + ) + .into_any_element() + }) + .when(pickup_location.can_remove, |this| { + this.child( + settings_dynamic_action_button( + ("settings-farm-remove-pickup", index), + app_shared_text(AppTextKey::SettingsPickupLocationsRemoveAction), + false, + on_remove, + cx, + ) + .into_any_element(), + ) + }); + + div() + .w_full() + .bg(rgb(APP_UI_THEME.surfaces.chrome_background)) + .rounded(px(APP_UI_THEME + .controls + .action_button + .sizing + .corner_radius_px)) + .p(px(12.0)) + .flex() + .flex_col() + .gap(px(10.0)) + .child( + div() + .w_full() + .flex() + .items_start() + .justify_between() + .gap(px(8.0)) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(title), + ) + .child(action_row), + ) + .child(settings_text_field( + AppTextKey::SettingsPickupLocationsFieldLabel, + &pickup_location.label_input, + )) + .child(settings_text_field( + AppTextKey::SettingsPickupLocationsFieldAddress, + &pickup_location.address_input, + )) + .child(settings_text_field( + AppTextKey::SettingsPickupLocationsFieldDirections, + &pickup_location.directions_input, + )) +} + +fn settings_farm_readiness_rows( + readiness: &radroots_app_models::FarmRulesReadiness, +) -> Vec<AnyElement> { + let mut rows = readiness + .blockers + .iter() + .copied() + .map(settings_readiness_key) + .map(settings_inventory_field_row) + .map(IntoElement::into_any_element) + .collect::<Vec<_>>(); + + if !readiness.timing_conflicts.is_empty() { + rows.push( + settings_inventory_field_row(AppTextKey::SettingsReadinessFieldInvalidTimingConflicts) + .into_any_element(), + ); + } + + if rows.is_empty() { + rows.push( + settings_inventory_field_row(AppTextKey::SettingsReadinessReady).into_any_element(), + ); + } + + rows +} + +fn settings_readiness_key(blocker: FarmReadinessBlocker) -> AppTextKey { + match blocker { + FarmReadinessBlocker::MissingProfileBasics => { + AppTextKey::SettingsReadinessFieldMissingProfileBasics + } + FarmReadinessBlocker::MissingPickupLocation => { + AppTextKey::SettingsReadinessFieldMissingPickupLocation + } + FarmReadinessBlocker::MissingFulfillmentWindow => { + AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow + } + FarmReadinessBlocker::MissingOperatingRules => { + AppTextKey::SettingsReadinessFieldMissingOperatingRules + } + } +} + +fn settings_badge_text(key: AppTextKey) -> impl IntoElement { + div() + .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.text.accent)) + .child(app_shared_text(key)) +} + +fn settings_dynamic_action_button( + id: impl Into<gpui::ElementId>, + label: impl Into<SharedString>, + is_primary: bool, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let sizing = APP_UI_THEME.controls.action_button.sizing; + let colors = if is_primary { + APP_UI_THEME.controls.action_button.primary_colors + } else { + APP_UI_THEME.controls.action_button.colors + }; + let hover_background = if colors.hover_changes_background { + colors.hover_background + } else { + colors.background + }; + let label = label.into(); + + Button::new(id) + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(colors.background).into()) + .foreground(rgb(colors.foreground).into()) + .border(transparent_black()) + .hover(rgb(hover_background).into()) + .active(rgb(colors.active_background).into()), + ) + .rounded(ButtonRounded::Size(px(sizing.corner_radius_px))) + .h(px(sizing.height_px)) + .on_click(on_click) + .child( + div() + .h_full() + .flex() + .items_center() + .justify_center() + .px(px(sizing.compact_horizontal_padding_px)) + .text_size(px(sizing.label_size_px)) + .text_color(rgb(colors.foreground)) + .child(label), + ) +} + fn settings_inventory_panel(intro_key: AppTextKey, cards: Vec<AnyElement>) -> impl IntoElement { let content_max_width_px = 560.0; diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -180,9 +180,19 @@ define_app_text_keys! { SettingsViewSettings => "settings.view.settings", SettingsViewAbout => "settings.view.about", SettingsFarmPanelBody => "settings.farm.panel.body", + SettingsFarmUnavailableBody => "settings.farm.unavailable.body", + SettingsFarmSaveAction => "settings.farm.save.action", + SettingsFarmSaveSaved => "settings.farm.save.saved", + SettingsFarmSavePending => "settings.farm.save.pending", + SettingsFarmSaveFailed => "settings.farm.save.failed", SettingsFarmFieldTimezone => "settings.farm.field.timezone", SettingsFarmFieldCurrency => "settings.farm.field.currency", SettingsPickupLocationsSectionLabel => "settings.pickup_locations.section.label", + SettingsPickupLocationsEmptyBody => "settings.pickup_locations.empty.body", + SettingsPickupLocationsAddAction => "settings.pickup_locations.add.action", + SettingsPickupLocationsMakeDefaultAction => "settings.pickup_locations.make_default.action", + SettingsPickupLocationsDefaultBadge => "settings.pickup_locations.default.badge", + SettingsPickupLocationsRemoveAction => "settings.pickup_locations.remove.action", SettingsPickupLocationsFieldLabel => "settings.pickup_locations.field.label", SettingsPickupLocationsFieldAddress => "settings.pickup_locations.field.address", SettingsPickupLocationsFieldDirections => "settings.pickup_locations.field.directions", @@ -208,6 +218,7 @@ define_app_text_keys! { SettingsReadinessFieldMissingFulfillmentWindow => "settings.readiness.field.missing_fulfillment_window", SettingsReadinessFieldMissingOperatingRules => "settings.readiness.field.missing_operating_rules", SettingsReadinessFieldInvalidTimingConflicts => "settings.readiness.field.invalid_timing_conflicts", + SettingsReadinessReady => "settings.readiness.ready", SettingsGeneralSectionLabel => "settings.general.section.label", SettingsGeneralAllowRelayConnections => "settings.general.allow_relay_connections", SettingsGeneralUseMediaServers => "settings.general.use_media_servers", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -175,10 +175,47 @@ mod tests { "Farm profile and pickup details stay local on this device." ); assert_eq!( + app_text(AppTextKey::SettingsFarmUnavailableBody), + "Finish setting up a farm before editing farm settings on this device." + ); + assert_eq!(app_text(AppTextKey::SettingsFarmSaveAction), "Save changes"); + assert_eq!( + app_text(AppTextKey::SettingsFarmSaveSaved), + "Saved locally on this device." + ); + assert_eq!( + app_text(AppTextKey::SettingsFarmSavePending), + "Save changes to keep this on this device." + ); + assert_eq!( + app_text(AppTextKey::SettingsFarmSaveFailed), + "Could not save farm settings on this device." + ); + assert_eq!( app_text(AppTextKey::SettingsPickupLocationsSectionLabel), "Pickup locations" ); assert_eq!( + app_text(AppTextKey::SettingsPickupLocationsEmptyBody), + "Add a pickup location so customers know where to collect orders." + ); + assert_eq!( + app_text(AppTextKey::SettingsPickupLocationsAddAction), + "Add pickup location" + ); + assert_eq!( + app_text(AppTextKey::SettingsPickupLocationsMakeDefaultAction), + "Make default" + ); + assert_eq!( + app_text(AppTextKey::SettingsPickupLocationsDefaultBadge), + "Default" + ); + assert_eq!( + app_text(AppTextKey::SettingsPickupLocationsRemoveAction), + "Remove" + ); + assert_eq!( app_text(AppTextKey::SettingsOperatingRulesSectionLabel), "Operating rules" ); @@ -198,6 +235,7 @@ mod tests { app_text(AppTextKey::SettingsReadinessFieldInvalidTimingConflicts), "Invalid timing conflicts" ); + assert_eq!(app_text(AppTextKey::SettingsReadinessReady), "Ready"); } #[test] diff --git a/crates/shared/sqlite/src/farm_rules.rs b/crates/shared/sqlite/src/farm_rules.rs @@ -29,7 +29,7 @@ impl<'a> AppFarmRulesRepository<'a> { let operating_rules = self.load_operating_rules(farm_id)?; let fulfillment_windows = self.load_fulfillment_windows(farm_id)?; let blackout_periods = self.load_blackout_periods(farm_id)?; - let readiness = compute_farm_rules_readiness( + let readiness = derive_farm_rules_readiness_parts( farm_profile.as_ref(), &pickup_locations, operating_rules.as_ref(), @@ -49,19 +49,14 @@ impl<'a> AppFarmRulesRepository<'a> { pub fn save_farm_rules(&self, projection: &FarmRulesProjection) -> Result<(), AppSqliteError> { let farm_id = validate_projection(projection)?; - let readiness = compute_farm_rules_readiness( - projection.farm_profile.as_ref(), - &projection.pickup_locations, - projection.operating_rules.as_ref(), - &projection.fulfillment_windows, - &projection.blackout_periods, - ); - let farm_profile = projection - .farm_profile - .as_ref() - .ok_or(AppSqliteError::InvalidProjection { - reason: "farm rules projection must include a farm profile", - })?; + let readiness = derive_farm_rules_readiness(projection); + let farm_profile = + projection + .farm_profile + .as_ref() + .ok_or(AppSqliteError::InvalidProjection { + reason: "farm rules projection must include a farm profile", + })?; self.connection .execute_batch("BEGIN IMMEDIATE") @@ -99,12 +94,12 @@ impl<'a> AppFarmRulesRepository<'a> { match result { Ok(()) => { - self.connection - .execute_batch("COMMIT") - .map_err(|source| AppSqliteError::Query { + self.connection.execute_batch("COMMIT").map_err(|source| { + AppSqliteError::Query { operation: "commit save farm rules transaction", source, - })?; + } + })?; Ok(()) } Err(error) => { @@ -675,12 +670,13 @@ impl<'a> AppFarmRulesRepository<'a> { } fn validate_projection(projection: &FarmRulesProjection) -> Result<FarmId, AppSqliteError> { - let farm_profile = projection - .farm_profile - .as_ref() - .ok_or(AppSqliteError::InvalidProjection { - reason: "farm rules projection must include a farm profile", - })?; + let farm_profile = + projection + .farm_profile + .as_ref() + .ok_or(AppSqliteError::InvalidProjection { + reason: "farm rules projection must include a farm profile", + })?; let farm_id = farm_profile.farm_id; if projection @@ -722,7 +718,9 @@ fn validate_projection(projection: &FarmRulesProjection) -> Result<FarmId, AppSq if projection .fulfillment_windows .iter() - .any(|fulfillment_window| !pickup_location_ids.contains(&fulfillment_window.pickup_location_id)) + .any(|fulfillment_window| { + !pickup_location_ids.contains(&fulfillment_window.pickup_location_id) + }) { return Err(AppSqliteError::InvalidProjection { reason: "fulfillment windows must reference a saved pickup location", @@ -742,7 +740,17 @@ fn validate_projection(projection: &FarmRulesProjection) -> Result<FarmId, AppSq Ok(farm_id) } -fn compute_farm_rules_readiness( +pub fn derive_farm_rules_readiness(projection: &FarmRulesProjection) -> FarmRulesReadiness { + derive_farm_rules_readiness_parts( + projection.farm_profile.as_ref(), + &projection.pickup_locations, + projection.operating_rules.as_ref(), + &projection.fulfillment_windows, + &projection.blackout_periods, + ) +} + +fn derive_farm_rules_readiness_parts( farm_profile: Option<&FarmProfileRecord>, pickup_locations: &[PickupLocationRecord], operating_rules: Option<&FarmOperatingRulesRecord>, @@ -760,7 +768,10 @@ fn compute_farm_rules_readiness( blockers.push(FarmReadinessBlocker::MissingProfileBasics); } - if pickup_locations.is_empty() { + if !pickup_locations + .iter() + .any(|pickup_location| pickup_location_is_present(pickup_location)) + { blockers.push(FarmReadinessBlocker::MissingPickupLocation); } @@ -829,6 +840,10 @@ fn compute_farm_rules_readiness( } } +fn pickup_location_is_present(pickup_location: &PickupLocationRecord) -> bool { + !pickup_location.label.trim().is_empty() && !pickup_location.address_line.trim().is_empty() +} + fn delete_missing_rows<T>( connection: &Connection, table_name: &str, @@ -902,12 +917,10 @@ fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteE } fn parse_u16(field: &'static str, value: i64) -> Result<u16, AppSqliteError> { - value - .try_into() - .map_err(|_| AppSqliteError::DecodeEnum { - field, - value: value.to_string(), - }) + value.try_into().map_err(|_| AppSqliteError::DecodeEnum { + field, + value: value.to_string(), + }) } fn farm_readiness_storage_key(ready: bool) -> &'static str { @@ -926,14 +939,15 @@ mod tests { }; use radroots_app_models::{ - BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmProfileRecord, - FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmTimingConflictKind, - FulfillmentWindowId, FulfillmentWindowRecord, PickupLocationId, PickupLocationRecord, + BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, + FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, + FarmTimingConflictKind, FulfillmentWindowId, FulfillmentWindowRecord, PickupLocationId, + PickupLocationRecord, }; use crate::{AppSqliteStore, DatabaseTarget}; - use super::AppFarmRulesRepository; + use super::{AppFarmRulesRepository, derive_farm_rules_readiness}; #[test] fn load_farm_rules_returns_default_when_farm_is_missing() { @@ -1003,8 +1017,8 @@ mod tests { .expect("farm rules should save"); } - let reopened = AppSqliteStore::open(DatabaseTarget::Path(path.clone())) - .expect("store should reopen"); + let reopened = + AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should reopen"); let loaded = reopened .load_farm_rules(farm_id) .expect("farm rules should load after restart"); @@ -1084,6 +1098,93 @@ mod tests { ); } + #[test] + fn blank_pickup_location_rows_do_not_count_as_present_for_readiness() { + let farm_id = FarmId::new(); + let readiness = derive_farm_rules_readiness(&FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id: PickupLocationId::new(), + farm_id, + label: " ".to_owned(), + address_line: String::new(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: Vec::new(), + blackout_periods: Vec::new(), + readiness: FarmRulesReadiness::ready(), + }); + + assert!( + readiness + .blockers + .contains(&FarmReadinessBlocker::MissingPickupLocation) + ); + assert!( + readiness + .blockers + .contains(&FarmReadinessBlocker::MissingFulfillmentWindow) + ); + } + + #[test] + fn complete_pickup_location_row_counts_as_present_for_readiness() { + let farm_id = FarmId::new(); + let pickup_location_id = PickupLocationId::new(); + let readiness = derive_farm_rules_readiness(&FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![FulfillmentWindowRecord { + fulfillment_window_id: FulfillmentWindowId::new(), + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2026-04-25T14:00:00Z".to_owned(), + ends_at: "2026-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: Vec::new(), + readiness: FarmRulesReadiness::ready(), + }); + + assert!( + !readiness + .blockers + .contains(&FarmReadinessBlocker::MissingPickupLocation) + ); + assert!(readiness.blockers.is_empty()); + } + fn temp_database_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -3,8 +3,8 @@ mod activation; mod activity; mod error; -mod farm_setup; mod farm_rules; +mod farm_setup; mod migrations; mod products; mod today; @@ -24,8 +24,8 @@ pub use activity::{ APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository, }; pub use error::AppSqliteError; +pub use farm_rules::{AppFarmRulesRepository, derive_farm_rules_readiness}; pub use farm_setup::AppFarmSetupRepository; -pub use farm_rules::AppFarmRulesRepository; pub use migrations::latest_schema_version; pub use products::AppProductsRepository; pub use today::{ @@ -347,9 +347,17 @@ mod tests { assert!(table_exists(connection, "blackout_periods")); assert!(column_exists(connection, "farms", "timezone")); assert!(column_exists(connection, "farms", "currency_code")); - assert!(column_exists(connection, "fulfillment_windows", "pickup_location_id")); + assert!(column_exists( + connection, + "fulfillment_windows", + "pickup_location_id" + )); assert!(column_exists(connection, "fulfillment_windows", "label")); - assert!(column_exists(connection, "fulfillment_windows", "order_cutoff_at")); + assert!(column_exists( + connection, + "fulfillment_windows", + "order_cutoff_at" + )); assert_eq!(row_count(connection, "sync_checkpoints"), 1); drop(store); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -159,9 +159,19 @@ "settings.view.settings": "settings", "settings.view.about": "about", "settings.farm.panel.body": "Farm profile and pickup details stay local on this device.", + "settings.farm.unavailable.body": "Finish setting up a farm before editing farm settings on this device.", + "settings.farm.save.action": "Save changes", + "settings.farm.save.saved": "Saved locally on this device.", + "settings.farm.save.pending": "Save changes to keep this on this device.", + "settings.farm.save.failed": "Could not save farm settings on this device.", "settings.farm.field.timezone": "Timezone", "settings.farm.field.currency": "Currency", "settings.pickup_locations.section.label": "Pickup locations", + "settings.pickup_locations.empty.body": "Add a pickup location so customers know where to collect orders.", + "settings.pickup_locations.add.action": "Add pickup location", + "settings.pickup_locations.make_default.action": "Make default", + "settings.pickup_locations.default.badge": "Default", + "settings.pickup_locations.remove.action": "Remove", "settings.pickup_locations.field.label": "Label", "settings.pickup_locations.field.address": "Address", "settings.pickup_locations.field.directions": "Directions", @@ -187,6 +197,7 @@ "settings.readiness.field.missing_fulfillment_window": "Missing fulfillment window", "settings.readiness.field.missing_operating_rules": "Missing operating rules", "settings.readiness.field.invalid_timing_conflicts": "Invalid timing conflicts", + "settings.readiness.ready": "Ready", "settings.general.section.label": "General", "settings.general.allow_relay_connections": "Allow relay connections", "settings.general.use_media_servers": "Use Radroots media servers",