app

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

commit 45d65480dfef4667686412bfed68d7bc0af33d5d
parent dc600fb455af305ca8c316eeca2df35a6d8c68de
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 18:55:55 +0000

app: land the startup identity choice shell

- replace the logged out create account button with the continue choice flow
- keep generate key on the existing relay bootstrap and one second starting path
- add the placeholder signer entry field with a no op submit and back action
- update launcher tests and source guards for the new startup copy contract

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 15++++++++++++++-
Mcrates/launchers/desktop/src/runtime.rs | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/launchers/desktop/src/source_guards.rs | 15+++++++++++++--
Mcrates/launchers/desktop/src/window.rs | 479+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
4 files changed, 486 insertions(+), 102 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -160,7 +160,10 @@ mod tests { APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeCapture, AppRuntimeMode, AppRuntimeSnapshot, }; - use radroots_app_models::{AppStartupGate, SettingsAccountProjection, TodayAgendaProjection}; + use radroots_app_models::{ + AppStartupGate, LoggedOutStartupProjection, SettingsAccountProjection, + TodayAgendaProjection, + }; use radroots_app_state::{AppShellProjection, HomeRoute}; use tracing::{ Event, Level, Subscriber, @@ -267,6 +270,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()), }; @@ -301,6 +305,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; let setup = DesktopAppRuntimeSummary { @@ -311,6 +316,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; @@ -328,6 +334,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; let farmer = DesktopAppRuntimeSummary { @@ -338,6 +345,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; @@ -355,6 +363,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: Some("runtime unavailable".to_owned()), }; @@ -371,6 +380,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; let personal = DesktopAppRuntimeSummary { @@ -381,6 +391,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; let farmer = DesktopAppRuntimeSummary { @@ -391,6 +402,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: None, }; let blocked = DesktopAppRuntimeSummary { @@ -401,6 +413,7 @@ mod tests { farm_setup_projection: Default::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), + logged_out_startup: LoggedOutStartupProjection::default(), startup_issue: Some("runtime unavailable".to_owned()), }; diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -5,9 +5,9 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, - ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, - SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, + LoggedOutStartupProjection, ProductEditorDraft, ProductId, ProductsFilter, + ProductsListProjection, ProductsSort, SettingsAccountProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, @@ -51,6 +51,7 @@ 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(), + logged_out_startup: state.state_store.logged_out_startup_projection().clone(), 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(), @@ -77,6 +78,30 @@ impl DesktopAppRuntime { .apply_in_memory(AppStateCommand::select_settings_section(section)) } + pub fn show_startup_identity_choice(&self) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::show_startup_identity_choice()) + } + + pub fn begin_generate_key_startup(&self) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::begin_generate_key_startup()) + } + + pub fn show_startup_signer_entry(&self) -> bool { + self.lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::show_startup_signer_entry()) + } + + pub fn set_startup_signer_source_input(&self, source_input: &str) -> bool { + self.lock_state_mut().state_store.apply_in_memory( + AppStateCommand::set_startup_signer_source_input(source_input), + ) + } + pub fn select_settings_section(&self, section: SettingsSection) -> bool { let changed = self.sync_settings_section(section); @@ -296,6 +321,7 @@ pub struct DesktopAppRuntimeSummary { pub shell_projection: AppShellProjection, pub settings_account_projection: SettingsAccountProjection, pub startup_gate: AppStartupGate, + pub logged_out_startup: LoggedOutStartupProjection, pub home_route: HomeRoute, pub farm_setup_projection: FarmSetupProjection, pub today_projection: TodayAgendaProjection, @@ -1003,8 +1029,8 @@ mod tests { use radroots_app_models::{ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, - FarmerActivationProjection, FarmerSection, ProductEditorDraft, ProductStatus, - ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, + FarmerActivationProjection, FarmerSection, LoggedOutStartupProjection, ProductEditorDraft, + ProductStatus, ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; @@ -1112,6 +1138,45 @@ mod tests { .selected_account .is_none() ); + assert_eq!( + summary.logged_out_startup, + LoggedOutStartupProjection::default() + ); + } + + #[test] + fn cloned_runtime_handles_shared_startup_identity_choice_state() { + 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( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + let cloned_runtime = runtime.clone(); + + assert!(runtime.show_startup_identity_choice()); + assert!(cloned_runtime.show_startup_signer_entry()); + assert!(cloned_runtime.set_startup_signer_source_input( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" + )); + assert!(runtime.begin_generate_key_startup()); + + let summary = runtime.summary(); + + assert_eq!( + summary.logged_out_startup.phase, + radroots_app_models::LoggedOutStartupPhase::GenerateKeyStarting + ); + assert_eq!( + summary.logged_out_startup.signer_entry.source_input, + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" + ); } #[test] @@ -1193,6 +1258,10 @@ mod tests { SettingsSection::Account ); assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); + assert_eq!( + summary.logged_out_startup, + LoggedOutStartupProjection::default() + ); assert!(summary.settings_account_projection.roster.is_empty()); assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert_eq!(summary.today_projection, TodayAgendaProjection::default()); diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -33,15 +33,20 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to update products filter", "failed to update products search query", "failed to update products sort", - "home-create-account", + "home-connect-signer", + "home-connect-signer-submit", + "home-continue", "home-farm-setup-continue", "home-farm-setup-delivery", "home-farm-setup-finish", "home-farm-setup-pickup", "home-farm-setup-shipping", "home-farm-setup-start", + "home-generate-key", "home-nav-products", "home-nav-today", + "home-signer-back", + "home-signer-source-input", "home-today-open-products-drafts", "home-today-open-products-low-stock", "home-products-scroll", @@ -78,6 +83,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "products.search_query_update_failed", "products.stock_update_failed", "products.sort_update_failed", + "runtime unavailable", "settings-allow-relay-connections", "settings-launch-at-login", "settings-manage-media-servers", @@ -93,7 +99,12 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ ]; const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ - "AppTextKey::HomeSetupCreateAccountAction", + "AppTextKey::HomeSetupBackAction", + "AppTextKey::HomeSetupConnectSignerAction", + "AppTextKey::HomeSetupContinueAction", + "AppTextKey::HomeSetupGenerateKeyAction", + "AppTextKey::HomeSetupSignerConnectAction", + "AppTextKey::HomeSetupSignerSourcePlaceholder", "AppTextKey::HomeFarmSetupOnboardingTitle", "AppTextKey::HomeFarmSetupOnboardingBody", "AppTextKey::HomeFarmSetupOnboardingAction", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -14,10 +14,10 @@ use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ AppStartupGate, FarmOrderMethod, FarmReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, - FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, - ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, ProductStatus, - ProductsFilter, ProductsListRow, ProductsSort, ShellSection, TodayAgendaProjection, - TodaySetupTaskKind, + FarmerSection, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderListRow, + ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, + ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, + TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ @@ -155,6 +155,7 @@ pub fn open_settings_window( pub struct HomeView { runtime: DesktopAppRuntime, startup_view: StartupHomeView, + startup_signer_entry: Option<StartupSignerEntryState>, logged_in_view: LoggedInHomeView, farm_setup_form: Option<FarmSetupFormState>, products_search: Option<ProductsSearchState>, @@ -168,6 +169,7 @@ impl HomeView { Self { runtime, startup_view: StartupHomeView::new(), + startup_signer_entry: None, logged_in_view: LoggedInHomeView::new(), farm_setup_form: None, products_search: None, @@ -177,18 +179,36 @@ impl HomeView { } } - fn generate_local_account(&mut self, cx: &mut Context<Self>) { + fn generate_local_account(&mut self, cx: &mut Context<Self>) -> bool { if self.runtime.generate_local_account(None).unwrap_or(false) { cx.refresh_windows(); cx.notify(); + return true; + } + + false + } + + fn show_startup_identity_choice(&mut self, cx: &mut Context<Self>) { + self.startup_view.clear_error(); + if self.runtime.show_startup_identity_choice() { + cx.notify(); + } + } + + fn show_startup_signer_entry(&mut self, cx: &mut Context<Self>) { + self.startup_view.clear_error(); + if self.runtime.show_startup_signer_entry() { + cx.notify(); } } - fn start_create_account(&mut self, window: &mut Window, cx: &mut Context<Self>) { - if !self.startup_view.begin_starting() { + fn start_generate_key(&mut self, window: &mut Window, cx: &mut Context<Self>) { + if !self.runtime.begin_generate_key_startup() { return; } + self.startup_view.clear_error(); let relay_url = self.runtime.default_nostr_relay_url(); cx.notify(); cx.spawn_in(window, async move |this, cx| { @@ -198,13 +218,13 @@ impl HomeView { 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); + this.finish_generate_key(startup_result, cx); }); }) .detach(); } - fn finish_create_account( + fn finish_generate_key( &mut self, startup_result: Result<StartupAppInitResult, String>, cx: &mut Context<Self>, @@ -213,16 +233,46 @@ impl HomeView { Ok(result) => { self.relay_client = Some(result.relay_client); self.startup_view.clear_error(); - self.startup_view.finish_starting(); - self.generate_local_account(cx); + if !self.generate_local_account(cx) { + self.show_startup_identity_choice(cx); + } } Err(error) => { + self.runtime.show_startup_identity_choice(); self.startup_view.fail_starting(error); cx.notify(); } } } + fn sync_startup_signer_entry( + &mut self, + runtime_summary: &DesktopAppRuntimeSummary, + window: &mut Window, + cx: &mut Context<Self>, + ) { + if runtime_summary.startup_gate != AppStartupGate::SetupRequired + || runtime_summary.logged_out_startup.phase != LoggedOutStartupPhase::SignerEntry + { + self.startup_signer_entry = None; + return; + } + + let source_input = runtime_summary + .logged_out_startup + .signer_entry + .source_input + .as_str(); + + match self.startup_signer_entry.as_mut() { + Some(entry) => entry.sync(source_input, window, cx), + None => { + self.startup_signer_entry = + Some(StartupSignerEntryState::new(source_input, window, cx)); + } + } + } + fn open_farm_setup(&mut self, window: &mut Window, cx: &mut Context<Self>) { let runtime_summary = self.runtime.summary(); @@ -424,6 +474,30 @@ impl HomeView { } } + fn handle_startup_signer_input_event( + &mut self, + state: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + + let Some(entry) = self.startup_signer_entry.as_ref() else { + return; + }; + if entry.input != *state { + return; + } + + let value = state.read(cx).value().to_string(); + if self.runtime.set_startup_signer_source_input(value.as_str()) { + cx.notify(); + } + } + fn handle_products_search_input_event( &mut self, state: &Entity<InputState>, @@ -1089,6 +1163,7 @@ impl HomeView { impl Render for HomeView { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let runtime_summary = self.runtime.summary(); + self.sync_startup_signer_entry(&runtime_summary, window, cx); self.sync_farm_setup_form(&runtime_summary, window, cx); self.sync_products_search(&runtime_summary, window, cx); self.sync_products_stock_editor(&runtime_summary); @@ -1098,9 +1173,12 @@ impl Render for HomeView { .startup_view .render( &runtime_summary, - runtime_summary.startup_issue.is_none() - && runtime_summary.startup_gate == AppStartupGate::SetupRequired, - cx.listener(|this, _, window, cx| this.start_create_account(window, cx)), + self.startup_signer_entry.as_ref(), + cx.listener(|this, _, _, cx| this.show_startup_identity_choice(cx)), + cx.listener(|this, _, window, cx| this.start_generate_key(window, cx)), + cx.listener(|this, _, _, cx| this.show_startup_signer_entry(cx)), + cx.listener(|_, _, _, _| {}), + cx.listener(|this, _, _, cx| this.show_startup_identity_choice(cx)), cx, ) .into_any_element(), @@ -1209,6 +1287,40 @@ impl ProductsSearchState { } } +struct StartupSignerEntryState { + input: Entity<InputState>, + _input_subscription: Subscription, +} + +impl StartupSignerEntryState { + fn new(source_input: &str, window: &mut Window, cx: &mut Context<HomeView>) -> Self { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(app_shared_text( + AppTextKey::HomeSetupSignerSourcePlaceholder, + )) + .default_value(source_input.to_owned()) + }); + let input_subscription = + cx.subscribe_in(&input, window, HomeView::handle_startup_signer_input_event); + + Self { + input, + _input_subscription: input_subscription, + } + } + + fn sync(&mut self, source_input: &str, window: &mut Window, cx: &mut Context<HomeView>) { + if self.input.read(cx).value().as_ref() == source_input { + return; + } + + self.input.update(cx, |input, cx| { + input.set_value(source_input.to_owned(), window, cx); + }); + } +} + struct ProductsStockEditorState { account_id: String, product_id: ProductId, @@ -1378,41 +1490,16 @@ impl ProductEditorFormState { } } -#[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; + Self { relay_error: None } } fn fail_starting(&mut self, error: String) { - self.phase = StartupPhase::Idle; self.relay_error = Some(error); } @@ -1423,16 +1510,23 @@ impl StartupHomeView { fn render( &self, runtime: &DesktopAppRuntimeSummary, - allow_create_account: bool, - on_create_account: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + signer_entry: Option<&StartupSignerEntryState>, + on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_back: 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, + signer_entry, + on_continue, + on_generate_key, + on_connect_signer, + on_submit_signer, + on_back, cx, ) } @@ -2071,14 +2165,41 @@ fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { ) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StartupHomeSurface { + IssueCard, + ContinuePrompt, + IdentityChoice, + GenerateKeyStarting, + SignerEntry, +} + +fn startup_home_surface(runtime: &DesktopAppRuntimeSummary) -> StartupHomeSurface { + if runtime.startup_issue.is_some() || runtime.startup_gate != AppStartupGate::SetupRequired { + return StartupHomeSurface::IssueCard; + } + + match runtime.logged_out_startup.phase { + LoggedOutStartupPhase::ContinuePrompt => StartupHomeSurface::ContinuePrompt, + LoggedOutStartupPhase::IdentityChoice => StartupHomeSurface::IdentityChoice, + LoggedOutStartupPhase::GenerateKeyStarting => StartupHomeSurface::GenerateKeyStarting, + LoggedOutStartupPhase::SignerEntry => StartupHomeSurface::SignerEntry, + } +} + 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, + signer_entry: Option<&StartupSignerEntryState>, + on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { + let surface = startup_home_surface(runtime); + app_window_shell( APP_UI_THEME.surfaces.window_background, div() @@ -2103,47 +2224,83 @@ fn startup_home_shell( .flex_col() .items_center() .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) - .child(startup_home_title(is_starting)) + .child(startup_home_title(surface)) .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( + .child(match surface { + StartupHomeSurface::ContinuePrompt => div() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .child(action_button_primary( + "home-continue", + app_shared_text( + AppTextKey::HomeSetupContinueAction, + ), + on_continue, + cx, + )) + .when_some(relay_error, |this, error| { + this.child(startup_home_support_text( + error.to_owned(), + )) + }) + .into_any_element(), + StartupHomeSurface::IdentityChoice => div() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .child(action_button_primary( + "home-generate-key", + app_shared_text( + AppTextKey::HomeSetupGenerateKeyAction, + ), + on_generate_key, + cx, + )) + .child(action_button( + "home-connect-signer", + app_shared_text( + AppTextKey::HomeSetupConnectSignerAction, + ), + on_connect_signer, + cx, + )) + .when_some(relay_error, |this, error| { + this.child(startup_home_support_text( + error.to_owned(), + )) + }) + .into_any_element(), + StartupHomeSurface::GenerateKeyStarting => div() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .child(action_button_primary_disabled( + "home-generate-key", + app_shared_text( + AppTextKey::HomeSetupGenerateKeyAction, + ), + cx, + )) + .into_any_element(), + StartupHomeSurface::SignerEntry => { + startup_signer_entry_surface( + signer_entry, + relay_error, + on_submit_signer, + on_back, + cx, + ) + .into_any_element() + } + StartupHomeSurface::IssueCard => startup_home_card( app_shared_text(AppTextKey::MetadataStartupIssue), startup_home_body(runtime), - )) + ) + .into_any_element(), }), ), ), @@ -2151,8 +2308,8 @@ fn startup_home_shell( ) } -fn startup_home_title(is_starting: bool) -> impl IntoElement { - let (animation_id, title_key) = if is_starting { +fn startup_home_title(surface: StartupHomeSurface) -> impl IntoElement { + let (animation_id, title_key) = if surface == StartupHomeSurface::GenerateKeyStarting { ("startup-title-starting", AppTextKey::HomeSetupStarting) } else { ("startup-title-radroots", AppTextKey::HomeSetupTitle) @@ -2189,6 +2346,75 @@ fn startup_home_support_text(body: impl Into<SharedString>) -> impl IntoElement .child(body.into()) } +fn startup_signer_entry_surface( + signer_entry: Option<&StartupSignerEntryState>, + relay_error: Option<&str>, + on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + div() + .w_full() + .flex() + .flex_col() + .items_center() + .gap(px(APP_UI_THEME.layout.startup_stack_gap_px)) + .when_some(signer_entry, |this, signer_entry| { + this.child( + div() + .w_full() + .max_w(px(APP_UI_THEME.layout.home_card_max_width_px)) + .id("home-signer-source-input") + .child( + Input::new(&signer_entry.input) + .with_size(ComponentSize::Large) + .w_full(), + ), + ) + }) + .child(action_button_primary( + "home-connect-signer-submit", + app_shared_text(AppTextKey::HomeSetupSignerConnectAction), + on_submit_signer, + cx, + )) + .child(startup_text_button( + "home-signer-back", + AppTextKey::HomeSetupBackAction, + on_back, + cx, + )) + .when_some(relay_error, |this, error| { + this.child(startup_home_support_text(error.to_owned())) + }) +} + +fn startup_text_button( + id: &'static str, + key: AppTextKey, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + Button::new(id) + .custom( + ButtonCustomVariant::new(cx) + .color(transparent_black().into()) + .foreground(rgb(APP_UI_THEME.text.secondary).into()) + .border(transparent_black()) + .hover(transparent_black().into()) + .active(transparent_black().into()), + ) + .rounded(ButtonRounded::Size(px(0.0))) + .on_click(on_click) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .child(app_shared_text(key)), + ) +} + fn startup_home_card(title: impl Into<SharedString>, body: impl IntoElement) -> impl IntoElement { div() .w_full() @@ -4232,17 +4458,17 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { #[cfg(test)] mod tests { use super::{ - AppTextKey, FarmerHomeFarmState, farm_setup_onboarding_card_spec, farmer_home_farm_state, - home_saved_farm, home_window_launch_size_px, home_window_minimum_size_px, - parse_optional_product_editor_stock_input, parse_product_editor_price_input, - product_display_title, + AppTextKey, FarmerHomeFarmState, StartupHomeSurface, farm_setup_onboarding_card_spec, + farmer_home_farm_state, home_saved_farm, home_window_launch_size_px, + home_window_minimum_size_px, parse_optional_product_editor_stock_input, + parse_product_editor_price_input, product_display_title, startup_home_surface, }; use crate::runtime::DesktopAppRuntimeSummary; use radroots_app_models::SettingsAccountProjection; use radroots_app_models::{ AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, - FarmSetupProjection, FarmSummary, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, + FarmSetupProjection, FarmSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_state::AppShellProjection; use radroots_app_state::HomeRoute; @@ -4280,6 +4506,54 @@ mod tests { } #[test] + fn startup_home_surface_tracks_the_shared_logged_out_phase_contract() { + let continue_prompt = summary_with_logged_out_phase(LoggedOutStartupPhase::ContinuePrompt); + let identity_choice = summary_with_logged_out_phase(LoggedOutStartupPhase::IdentityChoice); + let generate_key_starting = + summary_with_logged_out_phase(LoggedOutStartupPhase::GenerateKeyStarting); + let signer_entry = summary_with_logged_out_phase(LoggedOutStartupPhase::SignerEntry); + + assert_eq!( + startup_home_surface(&continue_prompt), + StartupHomeSurface::ContinuePrompt + ); + assert_eq!( + startup_home_surface(&identity_choice), + StartupHomeSurface::IdentityChoice + ); + assert_eq!( + startup_home_surface(&generate_key_starting), + StartupHomeSurface::GenerateKeyStarting + ); + assert_eq!( + startup_home_surface(&signer_entry), + StartupHomeSurface::SignerEntry + ); + } + + #[test] + fn startup_home_surface_uses_issue_card_when_setup_is_unavailable() { + let blocked = DesktopAppRuntimeSummary { + startup_gate: AppStartupGate::Blocked, + startup_issue: Some("runtime unavailable".to_owned()), + ..summary_with_logged_out_phase(LoggedOutStartupPhase::IdentityChoice) + }; + + assert_eq!( + startup_home_surface(&blocked), + StartupHomeSurface::IssueCard + ); + assert_eq!( + startup_home_surface(&summary( + HomeRoute::Personal, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + )), + StartupHomeSurface::IssueCard + ); + } + + #[test] fn farmer_home_farm_state_distinguishes_no_farm_incomplete_and_configured() { let farm_id = FarmId::new(); let incomplete_farm = FarmSummary { @@ -4396,6 +4670,7 @@ mod tests { shell_projection: AppShellProjection::default(), settings_account_projection: SettingsAccountProjection::default(), startup_gate: AppStartupGate::Farmer, + logged_out_startup: LoggedOutStartupProjection::default(), home_route, farm_setup_projection, today_projection, @@ -4403,4 +4678,20 @@ mod tests { startup_issue: None, } } + + fn summary_with_logged_out_phase(phase: LoggedOutStartupPhase) -> DesktopAppRuntimeSummary { + DesktopAppRuntimeSummary { + startup_gate: AppStartupGate::SetupRequired, + home_route: HomeRoute::SetupRequired, + logged_out_startup: LoggedOutStartupProjection { + phase, + ..LoggedOutStartupProjection::default() + }, + ..summary( + HomeRoute::SetupRequired, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + ) + } + } }