app

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

commit d31db8592086531e772a7f63aaf8c1ce162283fc
parent 0fb40a89d7e11234083cd89d7955042e08f38871
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 07:27:21 +0000

home: add farm setup form and local save

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 10++++++++++
Mcrates/launchers/desktop/src/runtime.rs | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/launchers/desktop/src/source_guards.rs | 19+++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/i18n/src/keys.rs | 15+++++++++++++++
Mcrates/shared/sqlite/src/lib.rs | 6+++++-
Mcrates/shared/sqlite/src/today.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/state/src/lib.rs | 53+++++++++++++++++++++++++++++++++++++++--------------
Mi18n/locales/en/messages.json | 15+++++++++++++++
9 files changed, 933 insertions(+), 42 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -264,6 +264,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, home_route: HomeRoute::SetupRequired, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()), }; @@ -296,6 +297,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Blocked, home_route: HomeRoute::Blocked, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -304,6 +306,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, home_route: HomeRoute::SetupRequired, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -319,6 +322,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, home_route: HomeRoute::Personal, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -327,6 +331,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, home_route: HomeRoute::FarmSetupOnboarding, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -342,6 +347,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, home_route: HomeRoute::Personal, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: Some("runtime unavailable".to_owned()), }; @@ -356,6 +362,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, home_route: HomeRoute::SetupRequired, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -364,6 +371,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, home_route: HomeRoute::Personal, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -372,6 +380,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, home_route: HomeRoute::FarmSetupOnboarding, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -380,6 +389,7 @@ mod tests { settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, home_route: HomeRoute::FarmSetupOnboarding, + farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), startup_issue: Some("runtime unavailable".to_owned()), }; diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -3,8 +3,9 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ - ActiveSurface, AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection, - SettingsPreference, SettingsSection, TodayAgendaProjection, + ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, + FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, + SettingsAccountProjection, SettingsPreference, SettingsSection, TodayAgendaProjection, }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, @@ -49,6 +50,7 @@ impl DesktopAppRuntime { settings_account_projection: state.state_store.settings_account_projection(), startup_gate: state.state_store.startup_gate(), home_route: state.state_store.home_route(), + farm_setup_projection: state.state_store.farm_setup_projection().clone(), today_projection: state.state_store.today_projection().clone(), startup_issue: state.startup_issue.clone(), } @@ -146,6 +148,19 @@ impl DesktopAppRuntime { .apply_in_memory(AppStateCommand::select_farm_setup_flow_stage(stage)) } + pub fn save_farm_setup_draft( + &self, + draft: FarmSetupDraft, + ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { + self.lock_state_mut().save_farm_setup_draft(draft) + } + + pub fn finish_farm_setup( + &self, + ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { + self.lock_state_mut().finish_farm_setup() + } + pub fn record_home_opened(&self) -> bool { self.record_activity(AppActivityKind::HomeOpened) } @@ -200,10 +215,17 @@ pub struct DesktopAppRuntimeSummary { pub settings_account_projection: SettingsAccountProjection, pub startup_gate: AppStartupGate, pub home_route: HomeRoute, + pub farm_setup_projection: FarmSetupProjection, pub today_projection: TodayAgendaProjection, pub startup_issue: Option<String>, } +#[derive(Clone, Debug, Default)] +struct DesktopSelectedAccountContext { + farm_setup_projection: FarmSetupProjection, + today_projection: TodayAgendaProjection, +} + struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, default_nostr_relay_url: String, @@ -244,12 +266,17 @@ impl DesktopAppRuntimeState { let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?; - let today_projection = sqlite_store.load_today_agenda(None)?; - let _ = - state_store.apply_in_memory(AppStateCommand::replace_today_agenda(today_projection)); + let selected_account_context = + load_selected_account_context(&sqlite_store, &accounts_bootstrap.identity_projection)?; let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( accounts_bootstrap.identity_projection, )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_setup_projection( + selected_account_context.farm_setup_projection, + )); + let _ = state_store.apply_in_memory(AppStateCommand::replace_today_agenda( + selected_account_context.today_projection, + )); Ok(Self { state_store, @@ -282,7 +309,7 @@ impl DesktopAppRuntimeState { generate_local_account(accounts_manager, sqlite_store, label)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn import_local_account( @@ -295,7 +322,7 @@ impl DesktopAppRuntimeState { import_local_account(accounts_manager, sqlite_store, request)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn select_local_account( @@ -308,7 +335,7 @@ impl DesktopAppRuntimeState { select_local_account(accounts_manager, sqlite_store, account_id)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn select_active_surface( @@ -321,7 +348,7 @@ impl DesktopAppRuntimeState { select_active_surface(accounts_manager, sqlite_store, active_surface)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn remove_selected_local_key(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { @@ -331,7 +358,7 @@ impl DesktopAppRuntimeState { remove_selected_local_key(accounts_manager, sqlite_store)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn reset_local_device_state(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { @@ -342,7 +369,7 @@ impl DesktopAppRuntimeState { reset_local_device_state(accounts_manager, sqlite_store, shared_accounts_paths)? }; - Ok(self.replace_identity_projection(projection)) + self.replace_identity_projection(projection) } fn record_activity(&self, kind: AppActivityKind) -> Result<(), AppSqliteError> { @@ -352,12 +379,103 @@ impl DesktopAppRuntimeState { } } + fn save_farm_setup_draft( + &mut self, + draft: FarmSetupDraft, + ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { + let account_id = self.selected_account_id()?; + let sqlite_store = self.sqlite_store_for_farm_setup()?; + let projection = FarmSetupProjection::from_draft(draft); + sqlite_store.save_farm_setup(account_id.as_str(), &projection)?; + + let selected_account_context = self.refresh_selected_account_context()?; + self.apply_selected_account_context(&selected_account_context); + + Ok(selected_account_context.farm_setup_projection) + } + + fn finish_farm_setup( + &mut self, + ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { + let account = self.selected_account_for_farm_setup()?; + let sqlite_store = self.sqlite_store_for_farm_setup()?; + let draft = self.state_store.farm_setup_projection().draft.clone(); + + if !draft.blockers().is_empty() { + return Err(DesktopAppRuntimeFarmSetupError::IncompleteDraft); + } + + let saved_farm = FarmSummary { + farm_id: account + .farmer_activation + .farm_id + .unwrap_or_else(FarmId::new), + display_name: draft.farm_name.trim().to_owned(), + readiness: FarmReadiness::Incomplete, + }; + let projection = FarmSetupProjection::new(draft, Some(saved_farm.clone())); + + sqlite_store.save_farm_summary(&saved_farm)?; + sqlite_store.save_farm_setup(account.account.account_id.as_str(), &projection)?; + + let selected_account_context = self.refresh_selected_account_context()?; + self.apply_selected_account_context(&selected_account_context); + + Ok(selected_account_context.farm_setup_projection) + } + fn replace_identity_projection( &mut self, - projection: radroots_app_models::AppIdentityProjection, - ) -> bool { + projection: AppIdentityProjection, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let selected_account_context = + load_selected_account_context(self.sqlite_store()?, &projection)?; + let identity_changed = self + .state_store + .apply_in_memory(AppStateCommand::replace_identity_projection(projection)); + let context_changed = self.apply_selected_account_context(&selected_account_context); + + Ok(identity_changed || context_changed) + } + + fn refresh_selected_account_context( + &self, + ) -> Result<DesktopSelectedAccountContext, DesktopAppRuntimeFarmSetupError> { + Ok(load_selected_account_context( + self.sqlite_store_for_farm_setup()?, + self.state_store.identity_projection(), + )?) + } + + fn apply_selected_account_context(&mut self, context: &DesktopSelectedAccountContext) -> bool { + let farm_setup_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_farm_setup_projection( + context.farm_setup_projection.clone(), + )); + let today_changed = + self.state_store + .apply_in_memory(AppStateCommand::replace_today_agenda( + context.today_projection.clone(), + )); + + farm_setup_changed || today_changed + } + + fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> { + self.selected_account_for_farm_setup() + .map(|account| account.account.account_id.clone()) + } + + fn selected_account_for_farm_setup( + &self, + ) -> Result<&radroots_app_models::SelectedAccountProjection, DesktopAppRuntimeFarmSetupError> + { self.state_store - .apply_in_memory(AppStateCommand::replace_identity_projection(projection)) + .identity_projection() + .selected_account + .as_ref() + .ok_or(DesktopAppRuntimeFarmSetupError::AccountRequired) } fn accounts_manager( @@ -374,6 +492,14 @@ impl DesktopAppRuntimeState { .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) } + fn sqlite_store_for_farm_setup( + &self, + ) -> Result<&AppSqliteStore, DesktopAppRuntimeFarmSetupError> { + self.sqlite_store + .as_ref() + .ok_or(DesktopAppRuntimeFarmSetupError::RuntimeUnavailable) + } + fn shared_accounts_paths( &self, ) -> Result<&AppSharedAccountsPaths, DesktopAppRuntimeCommandError> { @@ -394,6 +520,20 @@ pub enum DesktopAppRuntimeCommandError { RuntimeUnavailable, #[error(transparent)] Accounts(#[from] DesktopAccountsCommandError), + #[error(transparent)] + Sqlite(#[from] AppSqliteError), +} + +#[derive(Debug, Error)] +pub enum DesktopAppRuntimeFarmSetupError { + #[error("desktop runtime commands are unavailable while the runtime is degraded")] + RuntimeUnavailable, + #[error("farm setup requires a selected account")] + AccountRequired, + #[error("farm setup is incomplete")] + IncompleteDraft, + #[error(transparent)] + Sqlite(#[from] AppSqliteError), } #[derive(Debug, Error)] @@ -408,6 +548,33 @@ enum DesktopAppRuntimeBootstrapError { State(#[from] AppStateStoreError), } +fn load_selected_account_context( + sqlite_store: &AppSqliteStore, + identity_projection: &AppIdentityProjection, +) -> Result<DesktopSelectedAccountContext, AppSqliteError> { + let Some(selected_account) = identity_projection.selected_account.as_ref() else { + return Ok(DesktopSelectedAccountContext::default()); + }; + let farm_setup_projection = + sqlite_store.load_farm_setup(&selected_account.account.account_id)?; + let today_farm_id = selected_account + .farmer_activation + .farm_id + .or(farm_setup_projection + .saved_farm + .as_ref() + .map(|farm| farm.farm_id)); + let today_projection = match today_farm_id { + Some(farm_id) => sqlite_store.load_today_agenda(Some(farm_id))?, + None => TodayAgendaProjection::default(), + }; + + Ok(DesktopSelectedAccountContext { + farm_setup_projection, + today_projection, + }) +} + #[cfg(test)] mod tests { use std::{ @@ -423,9 +590,9 @@ mod tests { }; use radroots_app_models::{ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, - FarmReadiness, FarmSummary, FarmerActivationProjection, SelectedSurfaceProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, + FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, + FarmerActivationProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ @@ -864,6 +1031,133 @@ mod tests { } #[test] + fn selecting_farmer_account_loads_persisted_farm_setup_draft() { + 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 projection = FarmSetupProjection::from_draft(FarmSetupDraft::new( + "North field farm", + "Stockholm County", + [FarmOrderMethod::Pickup], + )); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_farm_setup(account_id.as_str(), &projection) + .expect("farm setup should save"); + save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); + + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + let summary = runtime.summary(); + + assert_eq!(summary.startup_gate, AppStartupGate::Farmer); + assert_eq!(summary.home_route, HomeRoute::FarmSetupForm); + assert_eq!(summary.farm_setup_projection, projection); + } + + #[test] + fn finishing_farm_setup_persists_saved_farm_and_today_projection() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + let farm_id = + save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupOnboarding); + + let draft = FarmSetupDraft::new( + "North field farm", + "Stockholm County", + [FarmOrderMethod::Pickup, FarmOrderMethod::Delivery], + ); + assert_eq!( + runtime + .save_farm_setup_draft(draft.clone()) + .expect("draft should save") + .draft, + draft + ); + assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupForm); + + let finished_projection = runtime + .finish_farm_setup() + .expect("farm setup should finish"); + let summary = runtime.summary(); + + assert_eq!(summary.home_route, HomeRoute::Today); + assert_eq!( + finished_projection.saved_farm, + Some(FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Incomplete, + }) + ); + assert_eq!( + summary.today_projection.farm, + finished_projection.saved_farm.clone() + ); + assert_eq!(summary.today_projection.setup_checklist.len(), 2); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_farm_setup(account_id.as_str()) + .expect("farm setup should load"), + finished_projection + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_today_agenda(Some(farm_id)) + .expect("today agenda should load") + .farm, + finished_projection.saved_farm + ); + } + + #[test] fn runtime_reset_local_device_state_clears_store_file_and_projection() { let (runtime, paths) = file_backed_runtime("reset"); @@ -1064,6 +1358,27 @@ mod tests { .expect("surface activation should save"); } + fn save_farmer_surface_activation( + runtime: &DesktopAppRuntime, + account_id: &str, + active_surface: ActiveSurface, + ) -> FarmId { + let farm_id = FarmId::new(); + let activation = AccountSurfaceActivationProjection::new( + account_id, + SelectedSurfaceProjection::new(active_surface), + FarmerActivationProjection::active(farm_id), + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_surface_activation(&activation) + .expect("surface activation should save"); + farm_id + } + fn cleanup_paths(paths: &AppSharedAccountsPaths) { let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { return; diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -9,6 +9,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-more", "failed to add relay `{relay_url}`: {error}", "home-create-account", + "home-farm-setup-delivery", + "home-farm-setup-finish", + "home-farm-setup-pickup", + "home-farm-setup-shipping", "home-farm-setup-start", "home-today-scroll", "settings-allow-relay-connections", @@ -29,6 +33,21 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeFarmSetupOnboardingTitle", "AppTextKey::HomeFarmSetupOnboardingBody", "AppTextKey::HomeFarmSetupOnboardingAction", + "AppTextKey::HomeFarmSetupSectionFarm", + "AppTextKey::HomeFarmSetupSectionLocation", + "AppTextKey::HomeFarmSetupSectionOrderMethods", + "AppTextKey::HomeFarmSetupFieldFarmName", + "AppTextKey::HomeFarmSetupFieldLocationOrServiceArea", + "AppTextKey::HomeFarmSetupOrderMethodPickup", + "AppTextKey::HomeFarmSetupOrderMethodDelivery", + "AppTextKey::HomeFarmSetupOrderMethodShipping", + "AppTextKey::HomeFarmSetupBlockerAddFarmName", + "AppTextKey::HomeFarmSetupBlockerAddLocationOrServiceArea", + "AppTextKey::HomeFarmSetupBlockerChooseOrderMethod", + "AppTextKey::HomeFarmSetupSaveAutosavesLocally", + "AppTextKey::HomeFarmSetupSaveSavedLocally", + "AppTextKey::HomeFarmSetupSaveFailedLocally", + "AppTextKey::HomeFarmSetupFinishAction", "AppTextKey::SettingsAccountNoSelectionTitle", "AppTextKey::SettingsAccountNoSelectionBody", "AppTextKey::SettingsAccountStatusLoggedOut", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -1,15 +1,18 @@ use gpui::{ - Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, + Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Timer, Window, WindowBackgroundAppearance, WindowBounds, - WindowOptions, div, prelude::FluentBuilder, px, relative, rgb, size, + StatefulInteractiveElement, Styled, Subscription, Timer, Window, WindowBackgroundAppearance, + WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, relative, rgb, size, +}; +use gpui_component::{ + IconName, Root, Sizable, Size as ComponentSize, + input::{Input, InputEvent, InputState}, }; -use gpui_component::{IconName, Root}; use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ - AppStartupGate, FulfillmentWindowSummary, OrderListRow, ProductListRow, TodayAgendaProjection, - TodaySetupTaskKind, + AppStartupGate, FarmOrderMethod, FarmSetupBlocker, FarmSetupDraft, FulfillmentWindowSummary, + OrderListRow, ProductListRow, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ @@ -140,6 +143,7 @@ pub struct HomeView { runtime: DesktopAppRuntime, startup_view: StartupHomeView, logged_in_view: LoggedInHomeView, + farm_setup_form: Option<FarmSetupFormState>, relay_client: Option<RadrootsNostrClient>, } @@ -149,6 +153,7 @@ impl HomeView { runtime, startup_view: StartupHomeView::new(), logged_in_view: LoggedInHomeView::new(), + farm_setup_form: None, relay_client: None, } } @@ -207,11 +212,132 @@ impl HomeView { cx.notify(); } } + + fn sync_farm_setup_form( + &mut self, + runtime_summary: &DesktopAppRuntimeSummary, + window: &mut Window, + cx: &mut Context<Self>, + ) { + let Some(account_id) = runtime_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.clone()) + else { + self.farm_setup_form = None; + return; + }; + + if runtime_summary.home_route != HomeRoute::FarmSetupForm { + self.farm_setup_form = None; + return; + } + + let draft = runtime_summary.farm_setup_projection.draft.clone(); + let should_reset = self + .farm_setup_form + .as_ref() + .map(|form| form.account_id != account_id) + .unwrap_or(true); + + if should_reset { + self.farm_setup_form = Some(FarmSetupFormState::new(account_id, draft, window, cx)); + } + } + + fn handle_farm_name_input_event( + &mut self, + state: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if matches!(event, InputEvent::Change) { + let value = state.read(cx).value().to_string(); + self.update_farm_setup_draft(cx, |draft| { + draft.farm_name = value; + }); + } + } + + fn handle_location_input_event( + &mut self, + state: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if matches!(event, InputEvent::Change) { + let value = state.read(cx).value().to_string(); + self.update_farm_setup_draft(cx, |draft| { + draft.location_or_service_area = value; + }); + } + } + + fn toggle_farm_order_method( + &mut self, + method: FarmOrderMethod, + enabled: bool, + cx: &mut Context<Self>, + ) { + self.update_farm_setup_draft(cx, |draft| { + if enabled { + draft.order_methods.insert(method); + } else { + draft.order_methods.remove(&method); + } + }); + } + + fn update_farm_setup_draft( + &mut self, + cx: &mut Context<Self>, + update: impl FnOnce(&mut FarmSetupDraft), + ) { + let Some(form) = self.farm_setup_form.as_mut() else { + return; + }; + + update(&mut form.draft); + + match self.runtime.save_farm_setup_draft(form.draft.clone()) { + Ok(projection) => { + form.draft = projection.draft; + form.save_state = FarmSetupSaveState::SavedLocally; + } + Err(_) => { + form.save_state = FarmSetupSaveState::SaveFailed; + } + } + + cx.notify(); + } + + fn finish_farm_setup(&mut self, cx: &mut Context<Self>) { + let Some(form) = self.farm_setup_form.as_mut() else { + return; + }; + + match self.runtime.finish_farm_setup() { + Ok(_) => { + form.save_state = FarmSetupSaveState::SavedLocally; + self.farm_setup_form = None; + } + Err(_) => { + form.save_state = FarmSetupSaveState::SaveFailed; + } + } + + cx.notify(); + } } impl Render for HomeView { - 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 runtime_summary = self.runtime.summary(); + self.sync_farm_setup_form(&runtime_summary, window, cx); match home_stage(&runtime_summary) { HomeStage::Setup => self .startup_view @@ -231,6 +357,31 @@ impl Render for HomeView { .logged_in_view .render_farmer( &runtime_summary, + self.farm_setup_form.as_ref().map(|form| { + home_farm_setup_form_card( + form, + cx.listener(|this, checked: &bool, _, cx| { + this.toggle_farm_order_method(FarmOrderMethod::Pickup, *checked, cx) + }), + cx.listener(|this, checked: &bool, _, cx| { + this.toggle_farm_order_method( + FarmOrderMethod::Delivery, + *checked, + cx, + ) + }), + cx.listener(|this, checked: &bool, _, cx| { + this.toggle_farm_order_method( + FarmOrderMethod::Shipping, + *checked, + cx, + ) + }), + cx.listener(|this, _, _, cx| this.finish_farm_setup(cx)), + cx, + ) + .into_any_element() + }), cx.listener(|this, _, _, cx| this.open_farm_setup(cx)), cx, ) @@ -239,6 +390,63 @@ impl Render for HomeView { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FarmSetupSaveState { + AutosavesLocally, + SavedLocally, + SaveFailed, +} + +struct FarmSetupFormState { + account_id: String, + draft: FarmSetupDraft, + farm_name_input: Entity<InputState>, + location_input: Entity<InputState>, + _farm_name_subscription: Subscription, + _location_subscription: Subscription, + save_state: FarmSetupSaveState, +} + +impl FarmSetupFormState { + fn new( + account_id: String, + draft: FarmSetupDraft, + window: &mut Window, + cx: &mut Context<HomeView>, + ) -> Self { + let farm_name_input = + cx.new(|cx| InputState::new(window, cx).default_value(draft.farm_name.clone())); + let location_input = cx.new(|cx| { + InputState::new(window, cx).default_value(draft.location_or_service_area.clone()) + }); + let farm_name_subscription = cx.subscribe_in( + &farm_name_input, + window, + HomeView::handle_farm_name_input_event, + ); + let location_subscription = cx.subscribe_in( + &location_input, + window, + HomeView::handle_location_input_event, + ); + let save_state = if draft.is_empty() { + FarmSetupSaveState::AutosavesLocally + } else { + FarmSetupSaveState::SavedLocally + }; + + Self { + account_id, + draft, + farm_name_input, + location_input, + _farm_name_subscription: farm_name_subscription, + _location_subscription: location_subscription, + save_state, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] enum StartupPhase { Idle, @@ -313,10 +521,11 @@ impl LoggedInHomeView { fn render_farmer( &self, runtime: &DesktopAppRuntimeSummary, + farm_setup_form: Option<AnyElement>, on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> AnyElement { - farmer_home_shell(runtime, on_open_farm_setup, cx).into_any_element() + farmer_home_shell(runtime, farm_setup_form, on_open_farm_setup, cx).into_any_element() } } @@ -896,6 +1105,7 @@ struct FarmSetupOnboardingCardSpec { fn farmer_home_shell( runtime: &DesktopAppRuntimeSummary, + farm_setup_form: Option<AnyElement>, on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { @@ -905,7 +1115,12 @@ fn farmer_home_shell( .id("home-today-scroll") .size_full() .overflow_y_scroll() - .child(home_view_content(runtime, on_open_farm_setup, cx)) + .child(home_view_content( + runtime, + farm_setup_form, + on_open_farm_setup, + cx, + )) .into_any_element(), ) } @@ -1210,6 +1425,7 @@ fn home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { fn home_view_content( runtime: &DesktopAppRuntimeSummary, + farm_setup_form: Option<AnyElement>, on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { @@ -1232,7 +1448,11 @@ fn home_view_content( ); } - if let Some(spec) = setup_onboarding { + if runtime.home_route == HomeRoute::FarmSetupForm { + if let Some(farm_setup_form) = farm_setup_form { + sections.push(farm_setup_form); + } + } else if let Some(spec) = setup_onboarding { sections .push(home_farm_setup_onboarding_card(spec, on_open_farm_setup, cx).into_any_element()); } else if projection.needs_setup() { @@ -1382,6 +1602,205 @@ fn home_farm_setup_onboarding_card( ) } +fn home_farm_setup_form_card( + form: &FarmSetupFormState, + on_pickup_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + on_delivery_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + on_shipping_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + on_finish_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + let blockers = form.draft.blockers(); + let finish_ready = blockers.is_empty(); + + home_card( + app_shared_text(AppTextKey::HomeFarmSetupOnboardingTitle), + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child(home_body_text(app_shared_text( + AppTextKey::HomeFarmSetupOnboardingBody, + ))) + .child(home_farm_setup_text_field( + AppTextKey::HomeFarmSetupSectionFarm, + AppTextKey::HomeFarmSetupFieldFarmName, + &form.farm_name_input, + blockers + .contains(&FarmSetupBlocker::AddFarmName) + .then_some(AppTextKey::HomeFarmSetupBlockerAddFarmName), + )) + .child(home_farm_setup_text_field( + AppTextKey::HomeFarmSetupSectionLocation, + AppTextKey::HomeFarmSetupFieldLocationOrServiceArea, + &form.location_input, + blockers + .contains(&FarmSetupBlocker::AddLocationOrServiceArea) + .then_some(AppTextKey::HomeFarmSetupBlockerAddLocationOrServiceArea), + )) + .child(home_farm_setup_order_method_section( + form, + blockers + .contains(&FarmSetupBlocker::ChooseOrderMethod) + .then_some(AppTextKey::HomeFarmSetupBlockerChooseOrderMethod), + on_pickup_change, + on_delivery_change, + on_shipping_change, + cx, + )) + .child( + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child(home_body_text(app_shared_text(farm_setup_save_state_key( + form.save_state, + )))) + .child(div().child(if finish_ready { + action_button_primary( + "home-farm-setup-finish", + app_shared_text(AppTextKey::HomeFarmSetupFinishAction), + on_finish_setup, + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "home-farm-setup-finish", + app_shared_text(AppTextKey::HomeFarmSetupFinishAction), + cx, + ) + .into_any_element() + })), + ), + ) +} + +fn home_farm_setup_text_field( + section_key: AppTextKey, + field_label_key: AppTextKey, + input: &Entity<InputState>, + blocker_key: Option<AppTextKey>, +) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(home_farm_setup_section_label(section_key)) + .child( + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(6.0)) + .child(home_farm_setup_field_label(field_label_key)) + .child( + Input::new(input) + .with_size(ComponentSize::Large) + .w_full() + .into_any_element(), + ) + .when_some(blocker_key, |this, blocker_key| { + this.child(home_farm_setup_blocker(blocker_key)) + }), + ) +} + +fn home_farm_setup_order_method_section( + form: &FarmSetupFormState, + blocker_key: Option<AppTextKey>, + on_pickup_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + on_delivery_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + on_shipping_change: impl Fn(&bool, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(home_farm_setup_section_label( + AppTextKey::HomeFarmSetupSectionOrderMethods, + )) + .child( + div() + .w_full() + .flex() + .flex_col() + .items_start() + .gap(px(8.0)) + .child(app_checkbox_field( + AppCheckboxFieldSpec::new( + "home-farm-setup-pickup", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodPickup), + Option::<SharedString>::None, + ), + form.draft.order_methods.contains(&FarmOrderMethod::Pickup), + cx, + move |checked, window, cx| on_pickup_change(&checked, window, cx), + )) + .child(app_checkbox_field( + AppCheckboxFieldSpec::new( + "home-farm-setup-delivery", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodDelivery), + Option::<SharedString>::None, + ), + form.draft + .order_methods + .contains(&FarmOrderMethod::Delivery), + cx, + move |checked, window, cx| on_delivery_change(&checked, window, cx), + )) + .child(app_checkbox_field( + AppCheckboxFieldSpec::new( + "home-farm-setup-shipping", + app_shared_text(AppTextKey::HomeFarmSetupOrderMethodShipping), + Option::<SharedString>::None, + ), + form.draft + .order_methods + .contains(&FarmOrderMethod::Shipping), + cx, + move |checked, window, cx| on_shipping_change(&checked, window, cx), + )) + .when_some(blocker_key, |this, blocker_key| { + this.child(home_farm_setup_blocker(blocker_key)) + }), + ) +} + +fn home_farm_setup_section_label(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.secondary)) + .child(app_shared_text(key)) +} + +fn home_farm_setup_field_label(key: AppTextKey) -> impl IntoElement { + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(app_shared_text(key)) +} + +fn home_farm_setup_blocker(key: AppTextKey) -> impl IntoElement { + div() + .text_size(px(APP_UI_THEME.typography.utility_title_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .child(app_shared_text(key)) +} + fn home_card(title: impl Into<SharedString>, body: impl IntoElement) -> impl IntoElement { div() .w_full() @@ -1679,6 +2098,14 @@ fn farm_setup_onboarding_card_spec(home_route: HomeRoute) -> Option<FarmSetupOnb } } +fn farm_setup_save_state_key(state: FarmSetupSaveState) -> AppTextKey { + match state { + FarmSetupSaveState::AutosavesLocally => AppTextKey::HomeFarmSetupSaveAutosavesLocally, + FarmSetupSaveState::SavedLocally => AppTextKey::HomeFarmSetupSaveSavedLocally, + FarmSetupSaveState::SaveFailed => AppTextKey::HomeFarmSetupSaveFailedLocally, + } +} + fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPresentation { if runtime.startup_issue.is_some() || runtime.startup_gate == AppStartupGate::Blocked { return HomeStatusPresentation { diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -45,6 +45,21 @@ define_app_text_keys! { HomeFarmSetupOnboardingTitle => "home.farm_setup.onboarding.title", HomeFarmSetupOnboardingBody => "home.farm_setup.onboarding.body", HomeFarmSetupOnboardingAction => "home.farm_setup.onboarding.action", + HomeFarmSetupSectionFarm => "home.farm_setup.section.farm", + HomeFarmSetupSectionLocation => "home.farm_setup.section.location", + HomeFarmSetupSectionOrderMethods => "home.farm_setup.section.order_methods", + HomeFarmSetupFieldFarmName => "home.farm_setup.field.farm_name", + HomeFarmSetupFieldLocationOrServiceArea => "home.farm_setup.field.location_or_service_area", + HomeFarmSetupOrderMethodPickup => "home.farm_setup.order_method.pickup", + HomeFarmSetupOrderMethodDelivery => "home.farm_setup.order_method.delivery", + HomeFarmSetupOrderMethodShipping => "home.farm_setup.order_method.shipping", + HomeFarmSetupBlockerAddFarmName => "home.farm_setup.blocker.add_farm_name", + HomeFarmSetupBlockerAddLocationOrServiceArea => "home.farm_setup.blocker.add_location_or_service_area", + HomeFarmSetupBlockerChooseOrderMethod => "home.farm_setup.blocker.choose_order_method", + HomeFarmSetupSaveAutosavesLocally => "home.farm_setup.save.autosaves_locally", + HomeFarmSetupSaveSavedLocally => "home.farm_setup.save.saved_locally", + HomeFarmSetupSaveFailedLocally => "home.farm_setup.save.failed_locally", + HomeFarmSetupFinishAction => "home.farm_setup.finish_action", HomeTodayEmptySetupTitle => "home.today.empty.setup.title", HomeTodayEmptySetupBody => "home.today.empty.setup.body", HomeTodayEmptyNoFarmTitle => "home.today.empty.no_farm.title", diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -11,7 +11,7 @@ use std::{fs, path::PathBuf, time::Duration}; use radroots_app_models::{ AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind, - FarmId, FarmSetupProjection, TodayAgendaProjection, + FarmId, FarmSetupProjection, FarmSummary, TodayAgendaProjection, }; use rusqlite::Connection; @@ -81,6 +81,10 @@ impl AppSqliteStore { self.today_agenda_repository().load(farm_id) } + pub fn save_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { + self.today_agenda_repository().save_farm_summary(farm) + } + pub fn record_activity_event(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> { self.activity_repository().record(kind) } diff --git a/crates/shared/sqlite/src/today.rs b/crates/shared/sqlite/src/today.rs @@ -35,6 +35,35 @@ impl<'a> AppTodayAgendaRepository<'a> { }) } + pub fn save_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { + self.connection + .execute( + "insert into farms (id, display_name, readiness, created_at, updated_at) + values ( + ?1, + ?2, + ?3, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ) + on conflict(id) do update set + display_name = excluded.display_name, + readiness = excluded.readiness, + updated_at = excluded.updated_at", + params![ + farm.farm_id.to_string(), + farm.display_name, + farm_readiness_storage_key(farm.readiness), + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "save today farm summary", + source, + })?; + + Ok(()) + } + fn load_farm_summary( &self, farm_id: Option<FarmId>, @@ -393,6 +422,13 @@ fn parse_farm_readiness( } } +fn farm_readiness_storage_key(readiness: FarmReadiness) -> &'static str { + match readiness { + FarmReadiness::Incomplete => "incomplete", + FarmReadiness::Ready => "ready", + } +} + #[cfg(test)] mod tests { use radroots_app_models::{FarmId, FulfillmentWindowId, ProductId, TodaySetupTaskKind}; @@ -632,6 +668,31 @@ mod tests { assert!(projection.next_fulfillment_window.is_none()); } + #[test] + fn saved_farm_summary_round_trips_into_today_projection() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let farm = radroots_app_models::FarmSummary { + farm_id: FarmId::new(), + display_name: "North field farm".to_owned(), + readiness: radroots_app_models::FarmReadiness::Incomplete, + }; + + store + .save_farm_summary(&farm) + .expect("farm summary should save"); + + let projection = store + .load_today_agenda(Some(farm.farm_id)) + .expect("today agenda should load"); + + assert_eq!(projection.farm, Some(farm)); + assert_eq!( + projection.summary.expect("summary").orders_needing_action, + 0 + ); + assert_eq!(projection.setup_checklist.len(), 2); + } + fn insert_farm( connection: &Connection, farm_id: FarmId, diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -204,17 +204,14 @@ impl AppProjection { AppStartupGate::Blocked => HomeRoute::Blocked, AppStartupGate::SetupRequired => HomeRoute::SetupRequired, AppStartupGate::Personal => HomeRoute::Personal, - AppStartupGate::Farmer => match self.farm_setup.readiness { - FarmSetupReadiness::Ready => HomeRoute::Today, - FarmSetupReadiness::NotStarted - if self.farm_setup_flow_stage == FarmSetupFlowStage::Onboarding => - { - HomeRoute::FarmSetupOnboarding - } - FarmSetupReadiness::NotStarted | FarmSetupReadiness::InProgress => { - HomeRoute::FarmSetupForm - } - }, + AppStartupGate::Farmer if self.farm_setup.has_saved_farm() => HomeRoute::Today, + AppStartupGate::Farmer + if self.farm_setup.readiness == FarmSetupReadiness::NotStarted + && self.farm_setup_flow_stage == FarmSetupFlowStage::Onboarding => + { + HomeRoute::FarmSetupOnboarding + } + AppStartupGate::Farmer => HomeRoute::FarmSetupForm, } } } @@ -541,7 +538,7 @@ fn sync_projection(projection: &mut AppProjection) { sync_farm_setup_flow_stage( &mut projection.farm_setup_flow_stage, projection.startup_gate, - projection.farm_setup.readiness, + projection.farm_setup.has_saved_farm(), ); } @@ -573,9 +570,9 @@ fn sync_farm_setup_to_today(farm_setup: &mut FarmSetupProjection, today: &TodayA fn sync_farm_setup_flow_stage( flow_stage: &mut FarmSetupFlowStage, startup_gate: AppStartupGate, - readiness: FarmSetupReadiness, + has_saved_farm: bool, ) { - if startup_gate != AppStartupGate::Farmer || readiness == FarmSetupReadiness::Ready { + if startup_gate != AppStartupGate::Farmer || has_saved_farm { *flow_stage = FarmSetupFlowStage::Onboarding; } } @@ -957,6 +954,34 @@ mod tests { } #[test] + fn complete_draft_without_saved_farm_stays_on_farm_setup_form() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + assert_eq!( + store.apply(AppStateCommand::replace_identity_projection( + ready_identity(ActiveSurface::Farmer), + )), + Ok(true) + ); + + let changed = store.apply(AppStateCommand::replace_farm_setup_projection( + FarmSetupProjection::from_draft(FarmSetupDraft::new( + "North field farm", + "Stockholm County", + [FarmOrderMethod::Pickup], + )), + )); + + assert_eq!(changed, Ok(true)); + assert_eq!(store.home_route(), HomeRoute::FarmSetupForm); + assert_eq!( + store.projection().farm_setup_flow_stage, + FarmSetupFlowStage::Onboarding + ); + } + + #[test] fn saved_farm_in_today_projection_synchronizes_ready_home_route() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -24,6 +24,21 @@ "home.farm_setup.onboarding.title": "Set up your farm", "home.farm_setup.onboarding.body": "Add the basics now. You can change them later.", "home.farm_setup.onboarding.action": "Set up your farm", + "home.farm_setup.section.farm": "Farm", + "home.farm_setup.section.location": "Location", + "home.farm_setup.section.order_methods": "How customers get orders", + "home.farm_setup.field.farm_name": "Farm name", + "home.farm_setup.field.location_or_service_area": "Location or service area", + "home.farm_setup.order_method.pickup": "Pickup", + "home.farm_setup.order_method.delivery": "Delivery", + "home.farm_setup.order_method.shipping": "Shipping", + "home.farm_setup.blocker.add_farm_name": "Add a farm name.", + "home.farm_setup.blocker.add_location_or_service_area": "Add a location or service area.", + "home.farm_setup.blocker.choose_order_method": "Choose at least one way customers get orders.", + "home.farm_setup.save.autosaves_locally": "Saves locally as you type.", + "home.farm_setup.save.saved_locally": "Saved locally.", + "home.farm_setup.save.failed_locally": "Couldn't save locally. Try again.", + "home.farm_setup.finish_action": "Finish setup", "home.today.empty.setup.title": "Account setup required", "home.today.empty.setup.body": "Add a local account to start using Radroots on this device.", "home.today.empty.no_farm.title": "No farm yet",