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:
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(
+ ¤cy_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(¤t_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",