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:
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(),
+ )
+ }
+ }
}