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:
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",