app

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

commit 0fb40a89d7e11234083cd89d7955042e08f38871
parent e73658326106bd70fcd05fe441bb2d171e304d71
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 07:04:37 +0000

window: add farm setup onboarding state

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 14++++++++++++--
Mcrates/launchers/desktop/src/runtime.rs | 53++++++++++++++++++++++++++++++++++++++---------------
Mcrates/launchers/desktop/src/source_guards.rs | 7+++++++
Mcrates/launchers/desktop/src/window.rs | 485++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/shared/i18n/src/keys.rs | 6++++++
Mi18n/locales/en/messages.json | 6++++++
6 files changed, 516 insertions(+), 55 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -29,7 +29,7 @@ pub fn launch() -> Result<(), AppLaunchError> { bootstrap_logging(&snapshot, runtime_config.local_log_root.as_path())?; install_panic_hook(); - let runtime = DesktopAppRuntime::bootstrap(); + let runtime = DesktopAppRuntime::bootstrap(runtime_config.default_nostr_relay_url.clone()); let runtime_summary = runtime.summary(); emit_runtime_events(&snapshot, &runtime_summary); let launch_target = primary_window_target(&runtime_summary); @@ -161,7 +161,7 @@ mod tests { AppRuntimeSnapshot, }; use radroots_app_models::{AppStartupGate, SettingsAccountProjection, TodayAgendaProjection}; - use radroots_app_state::AppShellProjection; + use radroots_app_state::{AppShellProjection, HomeRoute}; use tracing::{ Event, Level, Subscriber, field::{Field, Visit}, @@ -263,6 +263,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, + home_route: HomeRoute::SetupRequired, today_projection: TodayAgendaProjection::default(), startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()), }; @@ -294,6 +295,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Blocked, + home_route: HomeRoute::Blocked, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -301,6 +303,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, + home_route: HomeRoute::SetupRequired, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -315,6 +318,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, + home_route: HomeRoute::Personal, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -322,6 +326,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, + home_route: HomeRoute::FarmSetupOnboarding, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -336,6 +341,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, + home_route: HomeRoute::Personal, today_projection: TodayAgendaProjection::default(), startup_issue: Some("runtime unavailable".to_owned()), }; @@ -349,6 +355,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::SetupRequired, + home_route: HomeRoute::SetupRequired, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -356,6 +363,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Personal, + home_route: HomeRoute::Personal, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -363,6 +371,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, + home_route: HomeRoute::FarmSetupOnboarding, today_projection: TodayAgendaProjection::default(), startup_issue: None, }; @@ -370,6 +379,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, + home_route: HomeRoute::FarmSetupOnboarding, 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 @@ -10,8 +10,8 @@ use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, }; use radroots_app_state::{ - AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, - InMemoryAppStateRepository, + AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage, + HomeRoute, InMemoryAppStateRepository, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use thiserror::Error; @@ -32,8 +32,8 @@ pub struct DesktopAppRuntime { } impl DesktopAppRuntime { - pub fn bootstrap() -> Self { - let state = match DesktopAppRuntimeState::try_bootstrap() { + pub fn bootstrap(default_nostr_relay_url: String) -> Self { + let state = match DesktopAppRuntimeState::try_bootstrap(default_nostr_relay_url) { Ok(state) => state, Err(error) => DesktopAppRuntimeState::degraded(error), }; @@ -48,11 +48,16 @@ impl DesktopAppRuntime { shell_projection: state.state_store.shell_projection().clone(), settings_account_projection: state.state_store.settings_account_projection(), startup_gate: state.state_store.startup_gate(), + home_route: state.state_store.home_route(), today_projection: state.state_store.today_projection().clone(), startup_issue: state.startup_issue.clone(), } } + pub fn default_nostr_relay_url(&self) -> String { + self.lock_state().default_nostr_relay_url.clone() + } + pub fn selected_settings_section(&self) -> SettingsSection { self.lock_state() .state_store @@ -135,6 +140,12 @@ impl DesktopAppRuntime { .apply_in_memory(AppStateCommand::replace_today_agenda(projection)) } + pub fn select_farm_setup_flow_stage(&self, stage: FarmSetupFlowStage) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::select_farm_setup_flow_stage(stage)) + } + pub fn record_home_opened(&self) -> bool { self.record_activity(AppActivityKind::HomeOpened) } @@ -188,12 +199,14 @@ pub struct DesktopAppRuntimeSummary { pub shell_projection: AppShellProjection, pub settings_account_projection: SettingsAccountProjection, pub startup_gate: AppStartupGate, + pub home_route: HomeRoute, pub today_projection: TodayAgendaProjection, pub startup_issue: Option<String>, } struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, + default_nostr_relay_url: String, shared_accounts_paths: Option<AppSharedAccountsPaths>, accounts_manager: Option<RadrootsNostrAccountsManager>, sqlite_store: Option<AppSqliteStore>, @@ -223,7 +236,9 @@ impl fmt::Debug for DesktopAppRuntimeState { } impl DesktopAppRuntimeState { - fn try_bootstrap() -> Result<Self, DesktopAppRuntimeBootstrapError> { + fn try_bootstrap( + default_nostr_relay_url: String, + ) -> Result<Self, DesktopAppRuntimeBootstrapError> { let paths = AppDesktopRuntimePaths::current_desktop()?; let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; @@ -238,6 +253,7 @@ impl DesktopAppRuntimeState { Ok(Self { state_store, + default_nostr_relay_url, shared_accounts_paths: Some(paths.shared_accounts.clone()), accounts_manager: accounts_bootstrap.accounts_manager, sqlite_store: Some(sqlite_store), @@ -248,6 +264,7 @@ impl DesktopAppRuntimeState { fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self { Self { state_store: AppStateStore::in_memory(AppShellProjection::default()), + default_nostr_relay_url: String::new(), shared_accounts_paths: None, accounts_manager: None, sqlite_store: None, @@ -366,11 +383,8 @@ impl DesktopAppRuntimeState { } fn command_unavailable_error(&self) -> DesktopAppRuntimeCommandError { - if self.startup_issue.is_some() || self.sqlite_store.is_none() { - DesktopAppRuntimeCommandError::RuntimeUnavailable - } else { - DesktopAppRuntimeCommandError::HostVaultUnavailable - } + let _ = self; + DesktopAppRuntimeCommandError::RuntimeUnavailable } } @@ -378,8 +392,6 @@ impl DesktopAppRuntimeState { pub enum DesktopAppRuntimeCommandError { #[error("desktop runtime commands are unavailable while the runtime is degraded")] RuntimeUnavailable, - #[error("desktop runtime commands require an available host vault")] - HostVaultUnavailable, #[error(transparent)] Accounts(#[from] DesktopAccountsCommandError), } @@ -417,7 +429,8 @@ mod tests { }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ - AppStateRepositoryError, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, + AppStateRepositoryError, AppStateStore, AppStateStoreError, HomeRoute, + InMemoryAppStateRepository, }; use radroots_identity::RadrootsIdentity; use radroots_nostr_accounts::prelude::{ @@ -480,6 +493,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( @@ -509,6 +523,7 @@ mod tests { SettingsSection::About ); assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); + assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); assert!( summary @@ -523,6 +538,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( @@ -557,6 +573,7 @@ mod tests { let summary = runtime.summary(); assert_eq!(summary.today_projection, today_agenda); + assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert_eq!( summary.shell_projection.active_surface, radroots_app_models::ActiveSurface::Personal @@ -596,6 +613,7 @@ mod tests { ); assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); + assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert_eq!(summary.today_projection, TodayAgendaProjection::default()); assert_eq!( summary.startup_issue.as_deref(), @@ -608,6 +626,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( @@ -705,6 +724,7 @@ mod tests { ); let selected_summary = runtime.summary(); assert_eq!(selected_summary.startup_gate, AppStartupGate::Farmer); + assert_eq!(selected_summary.home_route, HomeRoute::FarmSetupOnboarding); assert_eq!( selected_summary .settings_account_projection @@ -930,11 +950,12 @@ mod tests { } #[test] - fn runtime_account_commands_fail_closed_without_host_vault_manager() { + fn runtime_account_commands_fail_closed_without_accounts_manager() { let paths = temp_shared_accounts_paths("blocked"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths), accounts_manager: None, sqlite_store: Some( @@ -950,7 +971,7 @@ mod tests { assert!(matches!( error, - DesktopAppRuntimeCommandError::HostVaultUnavailable + DesktopAppRuntimeCommandError::RuntimeUnavailable )); } @@ -958,6 +979,7 @@ mod tests { DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, accounts_manager: Some( RadrootsNostrAccountsManager::new( @@ -983,6 +1005,7 @@ mod tests { DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths.clone()), accounts_manager: Some( RadrootsNostrAccountsManager::new( diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -7,7 +7,9 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-open-workspace", "account-log-out", "account-more", + "failed to add relay `{relay_url}`: {error}", "home-create-account", + "home-farm-setup-start", "home-today-scroll", "settings-allow-relay-connections", "settings-launch-at-login", @@ -18,10 +20,15 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "settings-panel-scroll", "settings-use-media-servers", "settings-use-nip05", + "startup-title-radroots", + "startup-title-starting", ]; const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeSetupCreateAccountAction", + "AppTextKey::HomeFarmSetupOnboardingTitle", + "AppTextKey::HomeFarmSetupOnboardingBody", + "AppTextKey::HomeFarmSetupOnboardingAction", "AppTextKey::SettingsAccountNoSelectionTitle", "AppTextKey::SettingsAccountNoSelectionBody", "AppTextKey::SettingsAccountStatusLoggedOut", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -1,8 +1,8 @@ use gpui::{ - AnyElement, App, AppContext, Bounds, ClickEvent, Context, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, - WindowBackgroundAppearance, WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, - relative, rgb, size, + Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Timer, Window, WindowBackgroundAppearance, WindowBounds, + WindowOptions, div, prelude::FluentBuilder, px, relative, rgb, size, }; use gpui_component::{IconName, Root}; use radroots_app_i18n::AppTextKey; @@ -11,12 +11,16 @@ use radroots_app_models::{ AppStartupGate, FulfillmentWindowSummary, OrderListRow, ProductListRow, TodayAgendaProjection, TodaySetupTaskKind, }; +use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, - action_button_compact, action_icon_button, app_center_stage, app_checkbox_field, - app_shared_label_text, app_shared_text, app_window_shell, icon_segment_button, - label_value_list, section_divider, status_indicator, utility_title_row, + action_button_compact, action_button_primary, action_button_primary_disabled, + action_icon_button, app_checkbox_field, app_shared_label_text, app_shared_text, + app_window_shell, icon_segment_button, label_value_list, section_divider, status_indicator, + utility_title_row, }; +use radroots_nostr::prelude::RadrootsNostrClient; +use std::time::Duration; use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary}; @@ -134,11 +138,19 @@ pub fn open_settings_window( pub struct HomeView { runtime: DesktopAppRuntime, + startup_view: StartupHomeView, + logged_in_view: LoggedInHomeView, + relay_client: Option<RadrootsNostrClient>, } impl HomeView { pub fn new(runtime: DesktopAppRuntime) -> Self { - Self { runtime } + Self { + runtime, + startup_view: StartupHomeView::new(), + logged_in_view: LoggedInHomeView::new(), + relay_client: None, + } } fn generate_local_account(&mut self, cx: &mut Context<Self>) { @@ -147,31 +159,167 @@ impl HomeView { cx.notify(); } } + + fn start_create_account(&mut self, window: &mut Window, cx: &mut Context<Self>) { + if !self.startup_view.begin_starting() { + return; + } + + let relay_url = self.runtime.default_nostr_relay_url(); + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let startup_task = cx + .background_executor() + .spawn(run_startup_app_init(relay_url)); + Timer::after(Duration::from_secs(1)).await; + let startup_result = startup_task.await; + let _ = this.update(cx, |this, cx| { + this.finish_create_account(startup_result, cx); + }); + }) + .detach(); + } + + fn finish_create_account( + &mut self, + startup_result: Result<StartupAppInitResult, String>, + cx: &mut Context<Self>, + ) { + match startup_result { + Ok(result) => { + self.relay_client = Some(result.relay_client); + self.startup_view.clear_error(); + self.startup_view.finish_starting(); + self.generate_local_account(cx); + } + Err(error) => { + self.startup_view.fail_starting(error); + cx.notify(); + } + } + } + + fn open_farm_setup(&mut self, cx: &mut Context<Self>) { + if self + .runtime + .select_farm_setup_flow_stage(FarmSetupFlowStage::Editing) + { + cx.notify(); + } + } } impl Render for HomeView { fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let runtime_summary = self.runtime.summary(); match home_stage(&runtime_summary) { - HomeStage::FarmerWorkspace => farmer_home_shell(&runtime_summary).into_any_element(), - HomeStage::Setup - if runtime_summary.startup_issue.is_none() - && runtime_summary.startup_gate == AppStartupGate::SetupRequired => - { - setup_home_shell( + HomeStage::Setup => self + .startup_view + .render( &runtime_summary, - cx.listener(|this, _, _, cx| this.generate_local_account(cx)), + runtime_summary.startup_issue.is_none() + && runtime_summary.startup_gate == AppStartupGate::SetupRequired, + cx.listener(|this, _, window, cx| this.start_create_account(window, cx)), cx, ) - .into_any_element() - } - HomeStage::Setup | HomeStage::PersonalHolding => { - holding_home_shell(&runtime_summary).into_any_element() - } + .into_any_element(), + HomeStage::PersonalHolding => self + .logged_in_view + .render_holding(&runtime_summary) + .into_any_element(), + HomeStage::FarmerWorkspace => self + .logged_in_view + .render_farmer( + &runtime_summary, + cx.listener(|this, _, _, cx| this.open_farm_setup(cx)), + cx, + ) + .into_any_element(), } } } +#[derive(Clone, Debug, Eq, PartialEq)] +enum StartupPhase { + Idle, + Starting, +} + +struct StartupHomeView { + phase: StartupPhase, + relay_error: Option<String>, +} + +impl StartupHomeView { + fn new() -> Self { + Self { + phase: StartupPhase::Idle, + relay_error: None, + } + } + + fn begin_starting(&mut self) -> bool { + if self.phase == StartupPhase::Starting { + return false; + } + + self.phase = StartupPhase::Starting; + self.relay_error = None; + true + } + + fn finish_starting(&mut self) { + self.phase = StartupPhase::Idle; + } + + fn fail_starting(&mut self, error: String) { + self.phase = StartupPhase::Idle; + self.relay_error = Some(error); + } + + fn clear_error(&mut self) { + self.relay_error = None; + } + + fn render( + &self, + runtime: &DesktopAppRuntimeSummary, + allow_create_account: bool, + on_create_account: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, + ) -> impl IntoElement { + startup_home_shell( + runtime, + self.phase == StartupPhase::Starting, + self.relay_error.as_deref(), + allow_create_account, + on_create_account, + cx, + ) + } +} + +struct LoggedInHomeView; + +impl LoggedInHomeView { + fn new() -> Self { + Self + } + + fn render_holding(&self, runtime: &DesktopAppRuntimeSummary) -> AnyElement { + holding_home_shell(runtime).into_any_element() + } + + fn render_farmer( + &self, + runtime: &DesktopAppRuntimeSummary, + 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() + } +} + pub struct SettingsWindowView { runtime: DesktopAppRuntime, selected_view: SettingsPanelViewKey, @@ -739,14 +887,25 @@ struct HomeStatusPresentation { label_key: AppTextKey, } -fn farmer_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct FarmSetupOnboardingCardSpec { + title_key: AppTextKey, + body_key: AppTextKey, + action_key: Option<AppTextKey>, +} + +fn farmer_home_shell( + runtime: &DesktopAppRuntimeSummary, + on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { home_shell_frame( runtime, div() .id("home-today-scroll") .size_full() .overflow_y_scroll() - .child(home_view_content(runtime)) + .child(home_view_content(runtime, on_open_farm_setup, cx)) .into_any_element(), ) } @@ -783,39 +942,201 @@ fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { runtime, div() .size_full() - .child(app_center_stage( + .child( div() .w_full() .max_w(px(APP_UI_THEME.layout.home_card_max_width_px)) + .mx_auto() .flex() .flex_col() .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) .child(home_status_row(&home_status)) .children(sections), - )) + ) .into_any_element(), ) } -fn setup_home_shell( +fn startup_home_shell( runtime: &DesktopAppRuntimeSummary, + is_starting: bool, + relay_error: Option<&str>, + allow_create_account: bool, on_create_account: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { - home_shell_frame( - runtime, + app_window_shell( + APP_UI_THEME.surfaces.window_background, div() .size_full() - .child(app_center_stage(action_button( - "home-create-account", - app_shared_text(AppTextKey::HomeSetupCreateAccountAction), - on_create_account, - cx, - ))) - .into_any_element(), + .bg(rgb(APP_UI_THEME.surfaces.window_background)) + .child( + div() + .size_full() + .p(px(APP_UI_THEME.layout.home_window_padding_px)) + .child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + div() + .w_full() + .max_w(px(APP_UI_THEME.layout.home_card_max_width_px)) + .mx_auto() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .child(startup_home_title(is_starting)) + .child(startup_home_tagline()) + .when(allow_create_account, |this| { + this.child( + div() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .child(if is_starting { + action_button_primary_disabled( + "home-create-account", + app_shared_text( + AppTextKey::HomeSetupCreateAccountAction, + ), + cx, + ) + .into_any_element() + } else { + action_button_primary( + "home-create-account", + app_shared_text( + AppTextKey::HomeSetupCreateAccountAction, + ), + on_create_account, + cx, + ) + .into_any_element() + }) + .when_some(relay_error, |this, error| { + this.child(startup_home_support_text( + error.to_owned(), + )) + }), + ) + }) + .when(!allow_create_account, |this| { + this.child(startup_home_card( + app_shared_text(AppTextKey::MetadataStartupIssue), + startup_home_body(runtime), + )) + }), + ), + ), + ), ) } +fn startup_home_title(is_starting: bool) -> impl IntoElement { + let (animation_id, title_key) = if is_starting { + ("startup-title-starting", AppTextKey::HomeSetupStarting) + } else { + ("startup-title-radroots", AppTextKey::HomeSetupTitle) + }; + + div() + .text_size(px(APP_UI_THEME.typography.startup_title_text_px)) + .font_weight(gpui::FontWeight::NORMAL) + .text_color(rgb(APP_UI_THEME.text.primary)) + .text_center() + .child(app_shared_text(title_key)) + .with_animation( + animation_id, + Animation::new(Duration::from_millis(180)), + |this, delta| this.opacity(delta), + ) +} + +fn startup_home_tagline() -> impl IntoElement { + div() + .text_size(px(APP_UI_THEME.typography.startup_tagline_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.text.primary)) + .text_center() + .child(app_shared_text(AppTextKey::HomeSetupTagline)) +} + +fn startup_home_support_text(body: impl Into<SharedString>) -> impl IntoElement { + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .text_center() + .child(body.into()) +} + +fn startup_home_card(title: impl Into<SharedString>, body: impl IntoElement) -> impl IntoElement { + div() + .w_full() + .bg(rgb(APP_UI_THEME.surfaces.card_background)) + .rounded(px(APP_UI_THEME + .controls + .action_button + .sizing + .corner_radius_px)) + .child( + div() + .w_full() + .p(px(APP_UI_THEME.layout.home_card_padding_px)) + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .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.into()), + ) + .child(body), + ) +} + +fn startup_home_body(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { + let body = runtime + .startup_issue + .clone() + .unwrap_or_else(|| app_shared_text(AppTextKey::HomeTodayEmptySetupBody).to_string()); + + div() + .w_full() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .text_center() + .child(body) +} + +async fn connect_default_relay(relay_url: String) -> Result<RadrootsNostrClient, String> { + let client = RadrootsNostrClient::new_signerless(); + client + .add_relay(relay_url.as_str()) + .await + .map_err(|error| format!("failed to add relay `{relay_url}`: {error}"))?; + client.connect().await; + Ok(client) +} + +struct StartupAppInitResult { + relay_client: RadrootsNostrClient, +} + +async fn run_startup_app_init(relay_url: String) -> Result<StartupAppInitResult, String> { + let relay_client = connect_default_relay(relay_url).await?; + Ok(StartupAppInitResult { relay_client }) +} + fn home_shell_frame( runtime: &DesktopAppRuntimeSummary, main_content: AnyElement, @@ -887,9 +1208,14 @@ fn home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { ) } -fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { +fn home_view_content( + runtime: &DesktopAppRuntimeSummary, + on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { let projection = &runtime.today_projection; let home_status = home_status_presentation(runtime); + let setup_onboarding = farm_setup_onboarding_card_spec(runtime.home_route); let mut sections = Vec::<AnyElement>::new(); if let Some(summary) = projection.summary.as_ref() { @@ -906,7 +1232,10 @@ fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { ); } - if projection.needs_setup() { + 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() { sections.push(home_setup_card(projection).into_any_element()); } @@ -964,7 +1293,10 @@ fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { ) .into_any_element(), ); - } else if runtime.startup_issue.is_none() && projection.farm.is_none() { + } else if runtime.startup_issue.is_none() + && projection.farm.is_none() + && setup_onboarding.is_none() + { sections.push( home_empty_state_card( AppTextKey::HomeTodayEmptyNoFarmTitle, @@ -1025,6 +1357,31 @@ fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { .children(sections) } +fn home_farm_setup_onboarding_card( + spec: FarmSetupOnboardingCardSpec, + on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + home_card( + app_shared_text(spec.title_key), + 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(spec.body_key))) + .when_some(spec.action_key, |this, action_key| { + this.child(div().child(action_button_primary( + "home-farm-setup-start", + app_shared_text(action_key), + on_open_farm_setup, + cx, + ))) + }), + ) +} + fn home_card(title: impl Into<SharedString>, body: impl IntoElement) -> impl IntoElement { div() .w_full() @@ -1306,6 +1663,22 @@ fn home_empty_state_card(title_key: AppTextKey, body_key: AppTextKey) -> impl In ) } +fn farm_setup_onboarding_card_spec(home_route: HomeRoute) -> Option<FarmSetupOnboardingCardSpec> { + match home_route { + HomeRoute::FarmSetupOnboarding => Some(FarmSetupOnboardingCardSpec { + title_key: AppTextKey::HomeFarmSetupOnboardingTitle, + body_key: AppTextKey::HomeFarmSetupOnboardingBody, + action_key: Some(AppTextKey::HomeFarmSetupOnboardingAction), + }), + HomeRoute::FarmSetupForm => Some(FarmSetupOnboardingCardSpec { + title_key: AppTextKey::HomeFarmSetupOnboardingTitle, + body_key: AppTextKey::HomeFarmSetupOnboardingBody, + action_key: None, + }), + _ => None, + } +} + fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPresentation { if runtime.startup_issue.is_some() || runtime.startup_gate == AppStartupGate::Blocked { return HomeStatusPresentation { @@ -1321,7 +1694,11 @@ fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPre }; } - if runtime.today_projection.farm.is_none() { + if matches!( + runtime.home_route, + HomeRoute::FarmSetupOnboarding | HomeRoute::FarmSetupForm + ) || runtime.today_projection.farm.is_none() + { return HomeStatusPresentation { indicator_color: APP_UI_THEME.controls.status_indicator.offline, label_key: AppTextKey::HomeTodayStatusNoFarm, @@ -1354,3 +1731,35 @@ fn home_setup_task_label_key(kind: TodaySetupTaskKind) -> AppTextKey { TodaySetupTaskKind::PublishProduct => AppTextKey::HomeTodaySetupPublishProduct, } } + +#[cfg(test)] +mod tests { + use super::{AppTextKey, farm_setup_onboarding_card_spec}; + use radroots_app_state::HomeRoute; + + #[test] + fn farm_setup_onboarding_uses_frozen_copy_and_primary_action() { + let spec = farm_setup_onboarding_card_spec(HomeRoute::FarmSetupOnboarding).unwrap(); + + assert_eq!(spec.title_key, AppTextKey::HomeFarmSetupOnboardingTitle); + assert_eq!(spec.body_key, AppTextKey::HomeFarmSetupOnboardingBody); + assert_eq!( + spec.action_key, + Some(AppTextKey::HomeFarmSetupOnboardingAction) + ); + } + + #[test] + fn farm_setup_form_route_keeps_onboarding_copy_without_no_farm_empty_state() { + let spec = farm_setup_onboarding_card_spec(HomeRoute::FarmSetupForm).unwrap(); + + assert_eq!(spec.title_key, AppTextKey::HomeFarmSetupOnboardingTitle); + assert_eq!(spec.body_key, AppTextKey::HomeFarmSetupOnboardingBody); + assert_eq!(spec.action_key, None); + } + + #[test] + fn today_route_has_no_setup_onboarding_card() { + assert!(farm_setup_onboarding_card_spec(HomeRoute::Today).is_none()); + } +} diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -38,7 +38,13 @@ define_app_text_keys! { HomeTodayStockCountLabel => "home.today.stock_count.label", HomeTodaySetupAddFulfillmentWindow => "home.today.setup.add_fulfillment_window", HomeTodaySetupPublishProduct => "home.today.setup.publish_product", + HomeSetupTitle => "home.setup.title", + HomeSetupTagline => "home.setup.tagline", + HomeSetupStarting => "home.setup.starting", HomeSetupCreateAccountAction => "home.setup.create_account", + HomeFarmSetupOnboardingTitle => "home.farm_setup.onboarding.title", + HomeFarmSetupOnboardingBody => "home.farm_setup.onboarding.body", + HomeFarmSetupOnboardingAction => "home.farm_setup.onboarding.action", HomeTodayEmptySetupTitle => "home.today.empty.setup.title", HomeTodayEmptySetupBody => "home.today.empty.setup.body", HomeTodayEmptyNoFarmTitle => "home.today.empty.no_farm.title", diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -17,7 +17,13 @@ "home.today.stock_count.label": "Stock", "home.today.setup.add_fulfillment_window": "Add a fulfillment window", "home.today.setup.publish_product": "Publish a product", + "home.setup.title": "Radroots", + "home.setup.tagline": "Grow from the root", + "home.setup.starting": "Starting...", "home.setup.create_account": "Create account", + "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.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",