commit 201753744a978a2ca956cced626c8aef5f4df18d
parent 622aa11b1318bf8d8e9f8421fcef0cb52ca256af
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 02:45:38 +0000
app: add typed identity and activation projections
Diffstat:
9 files changed, 947 insertions(+), 194 deletions(-)
diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs
@@ -153,7 +153,7 @@ mod tests {
APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeCapture, AppRuntimeMode,
AppRuntimeSnapshot,
};
- use radroots_app_models::TodayAgendaProjection;
+ use radroots_app_models::{AppStartupGate, SettingsAccountProjection, TodayAgendaProjection};
use radroots_app_state::AppShellProjection;
use tracing::{
Event, Level, Subscriber,
@@ -253,6 +253,8 @@ mod tests {
});
let summary = DesktopAppRuntimeSummary {
shell_projection: AppShellProjection::default(),
+ settings_account_projection: SettingsAccountProjection::default(),
+ startup_gate: AppStartupGate::SetupRequired,
today_projection: TodayAgendaProjection::default(),
startup_issue: Some("desktop runtime roots require HOME for macos".to_owned()),
};
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -3,8 +3,8 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use radroots_app_core::{AppRuntimePathsError, AppRuntimeRoots};
use radroots_app_models::{
- ActiveSurface, AppActivityContext, AppActivityKind, SettingsPreference, SettingsSection,
- TodayAgendaProjection,
+ AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection,
+ SettingsPreference, SettingsSection, TodayAgendaProjection,
};
use radroots_app_sqlite::{
APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget,
@@ -38,6 +38,8 @@ impl DesktopAppRuntime {
DesktopAppRuntimeSummary {
shell_projection: state.state_store.shell_projection().clone(),
+ settings_account_projection: state.state_store.settings_account_projection(),
+ startup_gate: state.state_store.startup_gate(),
today_projection: state.state_store.today_projection().clone(),
startup_issue: state.startup_issue.clone(),
}
@@ -140,6 +142,8 @@ impl DesktopAppRuntime {
#[derive(Clone, Debug)]
pub struct DesktopAppRuntimeSummary {
pub shell_projection: AppShellProjection,
+ pub settings_account_projection: SettingsAccountProjection,
+ pub startup_gate: AppStartupGate,
pub today_projection: TodayAgendaProjection,
pub startup_issue: Option<String>,
}
@@ -183,10 +187,7 @@ impl DesktopAppRuntimeState {
fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self {
Self {
- state_store: AppStateStore::in_memory(AppShellProjection {
- active_surface: ActiveSurface::Farmer,
- ..AppShellProjection::default()
- }),
+ state_store: AppStateStore::in_memory(AppShellProjection::default()),
sqlite_store: None,
startup_issue: Some(error.to_string()),
}
@@ -216,7 +217,7 @@ mod tests {
use radroots_app_core::{AppRuntimeHostEnvironment, AppRuntimePlatform, AppRuntimeRoots};
use radroots_app_models::{
- ActiveSurface, AppActivityKind, FarmReadiness, FarmSummary, SettingsPreference,
+ AppActivityKind, AppStartupGate, FarmReadiness, FarmSummary, SettingsPreference,
SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
TodaySummary,
};
@@ -283,6 +284,14 @@ mod tests {
cloned_runtime.selected_settings_section(),
SettingsSection::About
);
+ assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired);
+ assert!(summary.settings_account_projection.roster.is_empty());
+ assert!(
+ summary
+ .settings_account_projection
+ .selected_account
+ .is_none()
+ );
}
#[test]
@@ -324,7 +333,7 @@ mod tests {
assert_eq!(summary.today_projection, today_agenda);
assert_eq!(
summary.shell_projection.active_surface,
- ActiveSurface::Farmer
+ radroots_app_models::ActiveSurface::Personal
);
assert_eq!(
summary.shell_projection.selected_section,
@@ -349,7 +358,7 @@ mod tests {
assert_eq!(
summary.shell_projection.active_surface,
- ActiveSurface::Farmer
+ radroots_app_models::ActiveSurface::Personal
);
assert_eq!(
summary.shell_projection.selected_section,
@@ -359,6 +368,8 @@ mod tests {
summary.shell_projection.settings.selected_section,
SettingsSection::Account
);
+ assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired);
+ assert!(summary.settings_account_projection.roster.is_empty());
assert_eq!(summary.today_projection, TodayAgendaProjection::default());
assert_eq!(
summary.startup_issue.as_deref(),
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -7,8 +7,9 @@ use gpui_component::{IconName, Root};
use radroots_app_i18n::AppTextKey;
pub use radroots_app_models::SettingsSection as SettingsPanelViewKey;
use radroots_app_models::{
- FulfillmentWindowSummary, OrderListRow, ProductListRow, SettingsPreference,
- TodayAgendaProjection, TodaySetupTaskKind,
+ AccountCustody, AccountSummary, ActiveSurface, AppStartupGate, FulfillmentWindowSummary,
+ IdentityReadiness, OrderListRow, ProductListRow, SelectedAccountProjection,
+ SettingsAccountProjection, SettingsPreference, TodayAgendaProjection, TodaySetupTaskKind,
};
use radroots_app_ui::{
APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button,
@@ -193,7 +194,12 @@ impl SettingsWindowView {
fn account_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
let detail_text_px = APP_UI_THEME.typography.settings_account_detail_text_px;
- let account_status_color = APP_UI_THEME.controls.status_indicator.online;
+ let runtime_summary = self.runtime.summary();
+ let account_projection = &runtime_summary.settings_account_projection;
+ let selected_account = account_projection.selected_account.as_ref();
+ let selected_account_id =
+ selected_account.map(|account| account.account.account_id.as_str());
+ let account_status_color = settings_account_status_color(account_projection);
div()
.size_full()
@@ -209,63 +215,72 @@ impl SettingsWindowView {
.child(
div()
.w_full()
- .h(px(APP_UI_THEME.layout.settings_account_sidebar_button_height_px))
- .bg(rgb(APP_UI_THEME.surfaces.chrome_background))
- .rounded(px(
- APP_UI_THEME
- .layout
- .settings_account_sidebar_button_corner_radius_px,
- ))
- .p(px(
- APP_UI_THEME.layout.settings_account_sidebar_button_padding_px,
- ))
.flex()
- .flex_row()
- .justify_start()
- .items_center()
+ .flex_col()
.gap(px(APP_UI_THEME.layout.settings_account_sidebar_button_gap_px))
- .child(
- div()
- .size(px(APP_UI_THEME.layout.settings_account_sidebar_avatar_size_px))
- .bg(rgb(APP_UI_THEME.surfaces.card_background))
- .rounded(px(
- APP_UI_THEME.layout.settings_account_sidebar_avatar_size_px
- / 2.0,
- )),
- )
- .child(
- div()
- .flex()
- .flex_col()
- .gap(px(APP_UI_THEME.layout.settings_account_identity_text_gap_px))
- .justify_center()
- .child(
- div()
- .text_size(px(
- APP_UI_THEME
- .typography
- .settings_account_identity_text_px,
- ))
- .font_weight(gpui::FontWeight::MEDIUM)
- .text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_shared_text(
- AppTextKey::SettingsAccountPlaceholderName,
- )),
- )
- .child(
- div()
- .text_size(px(
- APP_UI_THEME
- .typography
- .settings_account_identity_text_px,
- ))
- .font_weight(gpui::FontWeight::MEDIUM)
- .text_color(rgb(APP_UI_THEME.text.secondary))
- .child(app_shared_text(
- AppTextKey::SettingsAccountPlaceholderHandle,
- )),
- ),
- ),
+ .when(account_projection.roster.is_empty(), |this| {
+ this.child(
+ div()
+ .w_full()
+ .bg(rgb(APP_UI_THEME.surfaces.chrome_background))
+ .rounded(px(
+ APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_corner_radius_px,
+ ))
+ .p(px(
+ APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_padding_px,
+ ))
+ .child(
+ div()
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(
+ div()
+ .text_size(px(
+ APP_UI_THEME
+ .typography
+ .settings_account_identity_text_px,
+ ))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(
+ AppTextKey::SettingsAccountNoSelectionTitle,
+ )),
+ )
+ .child(
+ div()
+ .text_size(px(
+ APP_UI_THEME
+ .typography
+ .settings_account_identity_text_px,
+ ))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .line_height(relative(1.2))
+ .child(app_shared_text(
+ AppTextKey::SettingsAccountNoSelectionBody,
+ )),
+ ),
+ ),
+ )
+ })
+ .when(!account_projection.roster.is_empty(), |this| {
+ this.children(
+ account_projection
+ .roster
+ .iter()
+ .map(|account| {
+ settings_account_sidebar_row(
+ account,
+ selected_account_id,
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
+ }),
)
.child(
div()
@@ -357,9 +372,19 @@ impl SettingsWindowView {
.text_size(px(detail_text_px))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_shared_text(
- AppTextKey::SettingsAccountPlaceholderName,
- )),
+ .child(
+ selected_account
+ .map(|account| {
+ settings_account_display_name(
+ &account.account,
+ )
+ })
+ .unwrap_or_else(|| {
+ app_shared_text(
+ AppTextKey::SettingsAccountNoSelectionTitle,
+ )
+ }),
+ ),
),
)
.child(
@@ -368,110 +393,169 @@ impl SettingsWindowView {
.flex()
.flex_col()
.gap(px(APP_UI_THEME.layout.settings_account_detail_row_gap_px))
- .child(
+ .child(self.settings_account_detail_row(
+ AppTextKey::SettingsAccountProfileLabel,
+ div()
+ .text_size(px(detail_text_px))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(
+ selected_account
+ .map(|account| account.account.npub.clone().into())
+ .unwrap_or_else(|| {
+ app_shared_text(AppTextKey::ValueNone)
+ }),
+ ),
+ ))
+ .child(self.settings_account_detail_row(
+ AppTextKey::SettingsAccountStatusLabel,
div()
- .w_full()
.flex()
.items_center()
.gap(px(
APP_UI_THEME
.layout
- .settings_account_detail_value_gap_px,
+ .settings_account_status_gap_px,
))
- .child(
- div()
- .text_size(px(detail_text_px))
- .font_weight(gpui::FontWeight::SEMIBOLD)
- .text_color(rgb(APP_UI_THEME.text.secondary))
- .child(app_shared_label_text(
- AppTextKey::SettingsAccountProfileLabel,
- )),
- )
+ .child(status_indicator(account_status_color))
.child(
div()
.text_size(px(detail_text_px))
.text_color(rgb(APP_UI_THEME.text.primary))
.child(app_shared_text(
- AppTextKey::SettingsAccountPlaceholderHandle,
+ settings_account_status_key(
+ account_projection,
+ ),
)),
),
- )
- .child(
+ ))
+ .child(self.settings_account_detail_row(
+ AppTextKey::SettingsAccountCustodyLabel,
div()
- .w_full()
- .flex()
- .items_center()
- .gap(px(
- APP_UI_THEME
- .layout
- .settings_account_detail_value_gap_px,
- ))
- .child(
- div()
- .text_size(px(detail_text_px))
- .font_weight(gpui::FontWeight::SEMIBOLD)
- .text_color(rgb(APP_UI_THEME.text.secondary))
- .child(app_shared_label_text(
- AppTextKey::SettingsAccountStatusLabel,
- )),
- )
+ .text_size(px(detail_text_px))
+ .text_color(rgb(APP_UI_THEME.text.primary))
.child(
- div()
- .flex()
- .items_center()
- .gap(px(
- APP_UI_THEME
- .layout
- .settings_account_status_gap_px,
- ))
- .child(status_indicator(account_status_color))
- .child(
- div()
- .text_size(px(detail_text_px))
- .text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_shared_text(
- AppTextKey::SettingsAccountStatusLoggedIn,
- )),
- ),
+ selected_account
+ .map(|account| {
+ app_shared_text(
+ settings_account_custody_key(
+ account.account.custody,
+ ),
+ )
+ })
+ .unwrap_or_else(|| {
+ app_shared_text(AppTextKey::ValueNone)
+ }),
),
- )
- .child(
+ ))
+ .child(self.settings_account_detail_row(
+ AppTextKey::SettingsAccountSurfaceLabel,
div()
- .w_full()
- .flex()
- .min_w_0()
- .items_center()
- .gap(px(
- APP_UI_THEME
- .layout
- .settings_account_action_row_gap_px,
- ))
+ .text_size(px(detail_text_px))
+ .text_color(rgb(APP_UI_THEME.text.primary))
.child(
- div().child(action_button(
- "account-log-out",
- app_shared_text(
- AppTextKey::SettingsAccountLogOutAction,
- ),
- |_, _, _| {},
- cx,
- )),
- )
+ selected_account
+ .map(|account| {
+ app_shared_text(
+ settings_account_surface_key(
+ account.active_surface(),
+ ),
+ )
+ })
+ .unwrap_or_else(|| {
+ app_shared_text(AppTextKey::ValueNone)
+ }),
+ ),
+ ))
+ .child(self.settings_account_detail_row(
+ AppTextKey::SettingsAccountActivationLabel,
+ div()
+ .text_size(px(detail_text_px))
+ .text_color(rgb(APP_UI_THEME.text.primary))
.child(
- div().child(action_button(
- "account-admin-console",
- app_shared_text(
- AppTextKey::SettingsAccountAdminConsoleAction,
- ),
- |_, _, _| {},
- cx,
- )),
+ selected_account
+ .map(|account| {
+ app_shared_text(
+ settings_account_activation_key(
+ account,
+ ),
+ )
+ })
+ .unwrap_or_else(|| {
+ app_shared_text(AppTextKey::ValueNone)
+ }),
),
- ),
+ ))
+ .when(selected_account.is_none(), |this| {
+ this.child(
+ div()
+ .w_full()
+ .text_size(px(detail_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(app_shared_text(
+ AppTextKey::SettingsAccountNoSelectionBody,
+ )),
+ )
+ })
+ .when(selected_account.is_some(), |this| {
+ this.child(
+ div()
+ .w_full()
+ .flex()
+ .min_w_0()
+ .items_center()
+ .gap(px(
+ APP_UI_THEME
+ .layout
+ .settings_account_action_row_gap_px,
+ ))
+ .child(
+ div().child(action_button(
+ "account-log-out",
+ app_shared_text(
+ AppTextKey::SettingsAccountLogOutAction,
+ ),
+ |_, _, _| {},
+ cx,
+ )),
+ )
+ .child(
+ div().child(action_button(
+ "account-admin-console",
+ app_shared_text(
+ AppTextKey::SettingsAccountAdminConsoleAction,
+ ),
+ |_, _, _| {},
+ cx,
+ )),
+ ),
+ )
+ }),
),
),
)
}
+ fn settings_account_detail_row(
+ &self,
+ label_key: AppTextKey,
+ value: impl IntoElement,
+ ) -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(APP_UI_THEME.layout.settings_account_detail_value_gap_px))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.settings_account_detail_text_px))
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(app_shared_label_text(label_key)),
+ )
+ .child(value)
+ }
+
fn settings_checkbox_row(
&mut self,
id: &'static str,
@@ -725,6 +809,124 @@ impl Render for SettingsWindowView {
}
}
+fn settings_account_display_name(account: &AccountSummary) -> SharedString {
+ match account.label.as_deref() {
+ Some(label) if !label.trim().is_empty() => label.to_owned().into(),
+ _ => app_shared_text(settings_account_custody_key(account.custody)),
+ }
+}
+
+fn settings_account_status_color(account_projection: &SettingsAccountProjection) -> u32 {
+ match account_projection.readiness {
+ IdentityReadiness::Ready => APP_UI_THEME.controls.status_indicator.online,
+ IdentityReadiness::MissingAccount => APP_UI_THEME.controls.status_indicator.offline,
+ IdentityReadiness::Blocked(_) => APP_UI_THEME.controls.status_indicator.attention,
+ }
+}
+
+fn settings_account_status_key(account_projection: &SettingsAccountProjection) -> AppTextKey {
+ match account_projection.readiness {
+ IdentityReadiness::Ready => AppTextKey::SettingsAccountStatusLoggedIn,
+ IdentityReadiness::MissingAccount => AppTextKey::SettingsAccountStatusLoggedOut,
+ IdentityReadiness::Blocked(_) => AppTextKey::SettingsAccountStatusBlocked,
+ }
+}
+
+fn settings_account_custody_key(custody: AccountCustody) -> AppTextKey {
+ match custody {
+ AccountCustody::LocalManaged => AppTextKey::SettingsAccountCustodyLocalManaged,
+ AccountCustody::BrowserSigner => AppTextKey::SettingsAccountCustodyBrowserSigner,
+ AccountCustody::RemoteSigner => AppTextKey::SettingsAccountCustodyRemoteSigner,
+ }
+}
+
+fn settings_account_surface_key(surface: ActiveSurface) -> AppTextKey {
+ match surface {
+ ActiveSurface::Personal => AppTextKey::SettingsAccountSurfacePersonal,
+ ActiveSurface::Farmer => AppTextKey::SettingsAccountSurfaceFarmer,
+ }
+}
+
+fn settings_account_activation_key(account: &SelectedAccountProjection) -> AppTextKey {
+ if account.farmer_activation.is_active() {
+ AppTextKey::SettingsAccountActivationActive
+ } else {
+ AppTextKey::SettingsAccountActivationInactive
+ }
+}
+
+fn settings_account_sidebar_row(
+ account: &AccountSummary,
+ selected_account_id: Option<&str>,
+) -> AnyElement {
+ let is_selected = selected_account_id
+ .map(|account_id| account_id == account.account_id.as_str())
+ .unwrap_or(false);
+
+ div()
+ .w_full()
+ .h(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_height_px))
+ .bg(rgb(if is_selected {
+ APP_UI_THEME.surfaces.card_background
+ } else {
+ APP_UI_THEME.surfaces.chrome_background
+ }))
+ .rounded(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_corner_radius_px))
+ .p(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_padding_px))
+ .flex()
+ .flex_row()
+ .justify_start()
+ .items_center()
+ .gap(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_button_gap_px))
+ .child(
+ div()
+ .size(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_avatar_size_px))
+ .bg(rgb(APP_UI_THEME.surfaces.window_background))
+ .rounded(px(APP_UI_THEME
+ .layout
+ .settings_account_sidebar_avatar_size_px
+ / 2.0)),
+ )
+ .child(
+ div()
+ .min_w_0()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME
+ .layout
+ .settings_account_identity_text_gap_px))
+ .justify_center()
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME
+ .typography
+ .settings_account_identity_text_px))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(settings_account_display_name(account)),
+ )
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME
+ .typography
+ .settings_account_identity_text_px))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(account.npub.clone()),
+ ),
+ )
+ .into_any_element()
+}
+
fn settings_panel_label_key(view: SettingsPanelViewKey) -> AppTextKey {
match view {
SettingsPanelViewKey::Account => AppTextKey::SettingsNavAccounts,
@@ -816,7 +1018,15 @@ fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
);
}
- if runtime.startup_issue.is_none() && projection.farm.is_none() {
+ if runtime.startup_issue.is_none() && runtime.startup_gate == AppStartupGate::SetupRequired {
+ sections.push(
+ home_empty_state_card(
+ AppTextKey::HomeTodayEmptySetupTitle,
+ AppTextKey::HomeTodayEmptySetupBody,
+ )
+ .into_any_element(),
+ );
+ } else if runtime.startup_issue.is_none() && projection.farm.is_none() {
sections.push(
home_empty_state_card(
AppTextKey::HomeTodayEmptyNoFarmTitle,
@@ -1159,13 +1369,20 @@ fn home_empty_state_card(title_key: AppTextKey, body_key: AppTextKey) -> impl In
}
fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPresentation {
- if runtime.startup_issue.is_some() {
+ if runtime.startup_issue.is_some() || runtime.startup_gate == AppStartupGate::Blocked {
return HomeStatusPresentation {
indicator_color: APP_UI_THEME.controls.status_indicator.attention,
label_key: AppTextKey::HomeTodayStatusStartupIssue,
};
}
+ if runtime.startup_gate == AppStartupGate::SetupRequired {
+ return HomeStatusPresentation {
+ indicator_color: APP_UI_THEME.controls.status_indicator.offline,
+ label_key: AppTextKey::HomeTodayStatusSetup,
+ };
+ }
+
if runtime.today_projection.farm.is_none() {
return HomeStatusPresentation {
indicator_color: APP_UI_THEME.controls.status_indicator.offline,
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -38,6 +38,8 @@ define_app_text_keys! {
HomeTodayStockCountLabel => "home.today.stock_count.label",
HomeTodaySetupAddFulfillmentWindow => "home.today.setup.add_fulfillment_window",
HomeTodaySetupPublishProduct => "home.today.setup.publish_product",
+ HomeTodayEmptySetupTitle => "home.today.empty.setup.title",
+ HomeTodayEmptySetupBody => "home.today.empty.setup.body",
HomeTodayEmptyNoFarmTitle => "home.today.empty.no_farm.title",
HomeTodayEmptyNoFarmBody => "home.today.empty.no_farm.body",
HomeTodayEmptyQuietTitle => "home.today.empty.quiet.title",
@@ -49,12 +51,23 @@ define_app_text_keys! {
SettingsNavAccounts => "settings.nav.accounts",
SettingsNavSettings => "settings.nav.settings",
SettingsNavAbout => "settings.nav.about",
+ SettingsAccountNoSelectionTitle => "settings.account.no_selection.title",
+ SettingsAccountNoSelectionBody => "settings.account.no_selection.body",
SettingsAccountProfileLabel => "settings.account.profile.label",
SettingsAccountStatusLabel => "settings.account.status.label",
SettingsAccountStatusLoggedIn => "settings.account.status.logged_in",
SettingsAccountStatusLoggedOut => "settings.account.status.logged_out",
- SettingsAccountPlaceholderName => "settings.account.placeholder_name",
- SettingsAccountPlaceholderHandle => "settings.account.placeholder_handle",
+ SettingsAccountStatusBlocked => "settings.account.status.blocked",
+ SettingsAccountCustodyLabel => "settings.account.custody.label",
+ SettingsAccountCustodyLocalManaged => "settings.account.custody.local_managed",
+ SettingsAccountCustodyBrowserSigner => "settings.account.custody.browser_signer",
+ SettingsAccountCustodyRemoteSigner => "settings.account.custody.remote_signer",
+ SettingsAccountSurfaceLabel => "settings.account.surface.label",
+ SettingsAccountSurfacePersonal => "settings.account.surface.personal",
+ SettingsAccountSurfaceFarmer => "settings.account.surface.farmer",
+ SettingsAccountActivationLabel => "settings.account.activation.label",
+ SettingsAccountActivationInactive => "settings.account.activation.inactive",
+ SettingsAccountActivationActive => "settings.account.activation.active",
SettingsAccountAddAction => "settings.account.action.add_account",
SettingsAccountLogOutAction => "settings.account.action.log_out",
SettingsAccountAdminConsoleAction => "settings.account.action.admin_console",
diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs
@@ -208,6 +208,228 @@ typed_id!(ActivityEventId);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
+pub enum AccountCustody {
+ LocalManaged,
+ BrowserSigner,
+ RemoteSigner,
+}
+
+impl AccountCustody {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::LocalManaged => "local_managed",
+ Self::BrowserSigner => "browser_signer",
+ Self::RemoteSigner => "remote_signer",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IdentityBlockedReason {
+ RuntimeUnavailable,
+ HostVaultUnavailable,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "status", content = "reason", rename_all = "snake_case")]
+pub enum IdentityReadiness {
+ #[default]
+ MissingAccount,
+ Ready,
+ Blocked(IdentityBlockedReason),
+}
+
+impl IdentityReadiness {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::MissingAccount => "missing_account",
+ Self::Ready => "ready",
+ Self::Blocked(IdentityBlockedReason::RuntimeUnavailable) => "runtime_unavailable",
+ Self::Blocked(IdentityBlockedReason::HostVaultUnavailable) => "host_vault_unavailable",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct SelectedSurfaceProjection {
+ pub active_surface: ActiveSurface,
+}
+
+impl Default for SelectedSurfaceProjection {
+ fn default() -> Self {
+ Self::new(ActiveSurface::Personal)
+ }
+}
+
+impl SelectedSurfaceProjection {
+ pub const fn new(active_surface: ActiveSurface) -> Self {
+ Self { active_surface }
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct FarmerActivationProjection {
+ pub farm_id: Option<FarmId>,
+}
+
+impl FarmerActivationProjection {
+ pub const fn inactive() -> Self {
+ Self { farm_id: None }
+ }
+
+ pub fn active(farm_id: FarmId) -> Self {
+ Self {
+ farm_id: Some(farm_id),
+ }
+ }
+
+ pub const fn is_active(&self) -> bool {
+ self.farm_id.is_some()
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct AccountSummary {
+ pub account_id: String,
+ pub npub: String,
+ pub label: Option<String>,
+ pub custody: AccountCustody,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct SelectedAccountProjection {
+ pub account: AccountSummary,
+ pub selected_surface: SelectedSurfaceProjection,
+ pub farmer_activation: FarmerActivationProjection,
+}
+
+impl SelectedAccountProjection {
+ pub fn new(
+ account: AccountSummary,
+ selected_surface: SelectedSurfaceProjection,
+ farmer_activation: FarmerActivationProjection,
+ ) -> Self {
+ let active_surface = if farmer_activation.is_active() {
+ selected_surface.active_surface
+ } else {
+ ActiveSurface::Personal
+ };
+
+ Self {
+ account,
+ selected_surface: SelectedSurfaceProjection::new(active_surface),
+ farmer_activation,
+ }
+ }
+
+ pub const fn active_surface(&self) -> ActiveSurface {
+ self.selected_surface.active_surface
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AppStartupGate {
+ Blocked,
+ #[default]
+ SetupRequired,
+ Personal,
+ Farmer,
+}
+
+impl AppStartupGate {
+ pub const fn storage_key(self) -> &'static str {
+ match self {
+ Self::Blocked => "blocked",
+ Self::SetupRequired => "setup_required",
+ Self::Personal => "personal",
+ Self::Farmer => "farmer",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct AppIdentityProjection {
+ pub readiness: IdentityReadiness,
+ pub roster: Vec<AccountSummary>,
+ pub selected_account: Option<SelectedAccountProjection>,
+}
+
+impl AppIdentityProjection {
+ pub fn missing() -> Self {
+ Self::default()
+ }
+
+ pub fn blocked(reason: IdentityBlockedReason) -> Self {
+ Self {
+ readiness: IdentityReadiness::Blocked(reason),
+ ..Self::default()
+ }
+ }
+
+ pub fn ready(
+ mut roster: Vec<AccountSummary>,
+ selected_account: SelectedAccountProjection,
+ ) -> Self {
+ if !roster
+ .iter()
+ .any(|account| account.account_id == selected_account.account.account_id)
+ {
+ roster.insert(0, selected_account.account.clone());
+ }
+
+ Self {
+ readiness: IdentityReadiness::Ready,
+ roster,
+ selected_account: Some(selected_account),
+ }
+ }
+
+ pub fn startup_gate(&self) -> AppStartupGate {
+ match self.readiness {
+ IdentityReadiness::MissingAccount => AppStartupGate::SetupRequired,
+ IdentityReadiness::Blocked(_) => AppStartupGate::Blocked,
+ IdentityReadiness::Ready => self
+ .selected_account
+ .as_ref()
+ .map(|account| {
+ if account.farmer_activation.is_active()
+ && account.active_surface() == ActiveSurface::Farmer
+ {
+ AppStartupGate::Farmer
+ } else {
+ AppStartupGate::Personal
+ }
+ })
+ .unwrap_or(AppStartupGate::SetupRequired),
+ }
+ }
+
+ pub fn settings_account(&self) -> SettingsAccountProjection {
+ self.into()
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct SettingsAccountProjection {
+ pub readiness: IdentityReadiness,
+ pub roster: Vec<AccountSummary>,
+ pub selected_account: Option<SelectedAccountProjection>,
+}
+
+impl From<&AppIdentityProjection> for SettingsAccountProjection {
+ fn from(value: &AppIdentityProjection) -> Self {
+ Self {
+ readiness: value.readiness,
+ roster: value.roster.clone(),
+ selected_account: value.selected_account.clone(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
pub enum FarmReadiness {
Incomplete,
Ready,
@@ -365,9 +587,12 @@ impl TodayAgendaProjection {
#[cfg(test)]
mod tests {
use super::{
- ActiveSurface, ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind,
- FarmId, FarmerSection, OrderListRow, ProductListRow, SettingsPreference, SettingsSection,
- ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
+ AccountCustody, AccountSummary, ActiveSurface, ActivityEventId, AppActivityContext,
+ AppActivityEvent, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId,
+ FarmerActivationProjection, FarmerSection, IdentityBlockedReason, OrderListRow,
+ ProductListRow, SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference,
+ SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
+ TodaySummary,
};
use std::{collections::BTreeSet, str::FromStr};
use uuid::Uuid;
@@ -425,6 +650,93 @@ mod tests {
}
#[test]
+ fn selected_surface_defaults_to_personal() {
+ assert_eq!(
+ SelectedSurfaceProjection::default().active_surface,
+ ActiveSurface::Personal
+ );
+ }
+
+ #[test]
+ fn selected_account_without_farmer_activation_falls_back_to_personal_surface() {
+ let projection = SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_01".to_owned(),
+ npub: "npub1example".to_owned(),
+ label: Some("North field".to_owned()),
+ custody: AccountCustody::LocalManaged,
+ },
+ SelectedSurfaceProjection::new(ActiveSurface::Farmer),
+ FarmerActivationProjection::inactive(),
+ );
+
+ assert_eq!(projection.active_surface(), ActiveSurface::Personal);
+ assert!(!projection.farmer_activation.is_active());
+ }
+
+ #[test]
+ fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() {
+ let farmer_identity = AppIdentityProjection::ready(
+ Vec::new(),
+ SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_02".to_owned(),
+ npub: "npub1farmer".to_owned(),
+ label: None,
+ custody: AccountCustody::LocalManaged,
+ },
+ SelectedSurfaceProjection::new(ActiveSurface::Farmer),
+ FarmerActivationProjection::active(FarmId::new()),
+ ),
+ );
+ let personal_identity = AppIdentityProjection::ready(
+ Vec::new(),
+ SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_03".to_owned(),
+ npub: "npub1personal".to_owned(),
+ label: None,
+ custody: AccountCustody::LocalManaged,
+ },
+ SelectedSurfaceProjection::new(ActiveSurface::Personal),
+ FarmerActivationProjection::inactive(),
+ ),
+ );
+
+ assert_eq!(
+ AppIdentityProjection::missing().startup_gate(),
+ AppStartupGate::SetupRequired
+ );
+ assert_eq!(personal_identity.startup_gate(), AppStartupGate::Personal);
+ assert_eq!(farmer_identity.startup_gate(), AppStartupGate::Farmer);
+ assert_eq!(
+ AppIdentityProjection::blocked(IdentityBlockedReason::HostVaultUnavailable)
+ .startup_gate(),
+ AppStartupGate::Blocked
+ );
+ }
+
+ #[test]
+ fn ready_identity_keeps_selected_account_visible_in_roster() {
+ let selected_account = SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_selected".to_owned(),
+ npub: "npub1selected".to_owned(),
+ label: None,
+ custody: AccountCustody::RemoteSigner,
+ },
+ SelectedSurfaceProjection::new(ActiveSurface::Personal),
+ FarmerActivationProjection::inactive(),
+ );
+ let projection = AppIdentityProjection::ready(Vec::new(), selected_account.clone());
+
+ assert_eq!(projection.readiness.storage_key(), "ready");
+ assert_eq!(projection.roster.len(), 1);
+ assert_eq!(projection.roster[0], selected_account.account);
+ assert_eq!(projection.selected_account, Some(selected_account));
+ }
+
+ #[test]
fn typed_ids_round_trip_through_strings() {
let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11")
.expect("test uuid should parse");
diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs
@@ -1,7 +1,9 @@
#![forbid(unsafe_code)]
use radroots_app_models::{
- ActiveSurface, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection,
+ ActiveSurface, AppIdentityProjection, AppStartupGate, SelectedSurfaceProjection,
+ SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
+ TodayAgendaProjection,
};
use thiserror::Error;
@@ -73,7 +75,7 @@ pub struct AppShellProjection {
impl Default for AppShellProjection {
fn default() -> Self {
- Self::new(ActiveSurface::Farmer, ShellSection::Home)
+ Self::new(ActiveSurface::Personal, ShellSection::Home)
}
}
@@ -92,7 +94,10 @@ impl AppShellProjection {
}
pub fn for_surface(active_surface: ActiveSurface) -> Self {
- Self::new(active_surface, ShellSection::default_for_surface(active_surface))
+ Self::new(
+ active_surface,
+ ShellSection::default_for_surface(active_surface),
+ )
}
pub fn for_settings(active_surface: ActiveSurface, selected_section: SettingsSection) -> Self {
@@ -139,15 +144,39 @@ impl AppShellProjection {
}
}
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppProjection {
pub shell: AppShellProjection,
+ pub identity: AppIdentityProjection,
+ pub startup_gate: AppStartupGate,
pub today: TodayAgendaProjection,
}
impl AppProjection {
- pub fn new(shell: AppShellProjection, today: TodayAgendaProjection) -> Self {
- Self { shell, today }
+ pub fn new(
+ mut shell: AppShellProjection,
+ identity: AppIdentityProjection,
+ today: TodayAgendaProjection,
+ ) -> Self {
+ sync_shell_to_identity(&mut shell, &identity);
+ let startup_gate = identity.startup_gate();
+
+ Self {
+ shell,
+ identity,
+ startup_gate,
+ today,
+ }
+ }
+}
+
+impl Default for AppProjection {
+ fn default() -> Self {
+ Self::new(
+ AppShellProjection::default(),
+ AppIdentityProjection::default(),
+ TodayAgendaProjection::default(),
+ )
}
}
@@ -156,6 +185,7 @@ pub enum AppStateCommand {
SelectActiveSurface(ActiveSurface),
SelectSection(ShellSection),
SelectSettingsSection(SettingsSection),
+ ReplaceIdentityProjection(AppIdentityProjection),
SetSettingsPreference {
preference: SettingsPreference,
enabled: bool,
@@ -172,6 +202,10 @@ impl AppStateCommand {
Self::SelectSettingsSection(section)
}
+ pub fn replace_identity_projection(projection: AppIdentityProjection) -> Self {
+ Self::ReplaceIdentityProjection(projection)
+ }
+
pub fn replace_today_agenda(projection: TodayAgendaProjection) -> Self {
Self::ReplaceTodayAgenda(projection)
}
@@ -263,6 +297,7 @@ impl<R: AppStateRepository> AppStateStore<R> {
pub fn load(repository: R) -> Result<Self, AppStateStoreError> {
let projection = AppProjection::new(
repository.load_shell_projection()?,
+ AppIdentityProjection::default(),
TodayAgendaProjection::default(),
);
@@ -284,6 +319,18 @@ impl<R: AppStateRepository> AppStateStore<R> {
&self.projection.today
}
+ pub fn identity_projection(&self) -> &AppIdentityProjection {
+ &self.projection.identity
+ }
+
+ pub fn settings_account_projection(&self) -> SettingsAccountProjection {
+ self.projection.identity.settings_account()
+ }
+
+ pub fn startup_gate(&self) -> AppStartupGate {
+ self.projection.startup_gate
+ }
+
pub fn repository(&self) -> &R {
&self.repository
}
@@ -313,7 +360,11 @@ impl AppStateStore<InMemoryAppStateRepository> {
pub fn in_memory(projection: AppShellProjection) -> Self {
Self {
repository: InMemoryAppStateRepository::new(projection.clone()),
- projection: AppProjection::new(projection, TodayAgendaProjection::default()),
+ projection: AppProjection::new(
+ projection,
+ AppIdentityProjection::default(),
+ TodayAgendaProjection::default(),
+ ),
}
}
@@ -350,6 +401,15 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
match command {
AppStateCommand::SelectActiveSurface(active_surface) => {
projection.shell.select_active_surface(active_surface);
+ if let Some(selected_account) = projection.identity.selected_account.as_mut() {
+ let selected_surface = if selected_account.farmer_activation.is_active() {
+ active_surface
+ } else {
+ ActiveSurface::Personal
+ };
+ selected_account.selected_surface =
+ SelectedSurfaceProjection::new(selected_surface);
+ }
}
AppStateCommand::SelectSection(selected_section) => {
projection.shell.select_section(selected_section);
@@ -357,6 +417,9 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
AppStateCommand::SelectSettingsSection(selected_section) => {
projection.shell.select_settings_section(selected_section);
}
+ AppStateCommand::ReplaceIdentityProjection(identity_projection) => {
+ projection.identity = identity_projection;
+ }
AppStateCommand::SetSettingsPreference {
preference,
enabled,
@@ -372,6 +435,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
}
}
+ sync_projection(projection);
+
if *projection == before {
AppStateMutation::NoChange
} else if projection.shell != before.shell {
@@ -381,6 +446,28 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap
}
}
+fn sync_projection(projection: &mut AppProjection) {
+ sync_shell_to_identity(&mut projection.shell, &projection.identity);
+ projection.startup_gate = projection.identity.startup_gate();
+}
+
+fn sync_shell_to_identity(shell: &mut AppShellProjection, identity: &AppIdentityProjection) {
+ match identity.startup_gate() {
+ AppStartupGate::Blocked | AppStartupGate::SetupRequired | AppStartupGate::Personal => {
+ shell.active_surface = ActiveSurface::Personal;
+ if matches!(shell.selected_section, ShellSection::Farmer(_)) {
+ shell.selected_section = ShellSection::Home;
+ }
+ }
+ AppStartupGate::Farmer => {
+ shell.active_surface = ActiveSurface::Farmer;
+ if matches!(shell.selected_section, ShellSection::Home) {
+ shell.selected_section = ShellSection::default_for_surface(ActiveSurface::Farmer);
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::{
@@ -389,7 +476,9 @@ mod tests {
SettingsPreference,
};
use radroots_app_models::{
- ActiveSurface, FarmerSection, SettingsSection, ShellSection, TodayAgendaProjection,
+ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate,
+ FarmId, FarmerActivationProjection, FarmerSection, SelectedAccountProjection,
+ SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection,
TodaySetupTask, TodaySetupTaskKind,
};
@@ -408,12 +497,30 @@ mod tests {
}
}
+ fn ready_identity(surface: ActiveSurface) -> AppIdentityProjection {
+ AppIdentityProjection::ready(
+ Vec::new(),
+ SelectedAccountProjection::new(
+ AccountSummary {
+ account_id: "acct_surface".to_owned(),
+ npub: "npub1surface".to_owned(),
+ label: Some("North field".to_owned()),
+ custody: AccountCustody::LocalManaged,
+ },
+ SelectedSurfaceProjection::new(surface),
+ FarmerActivationProjection::active(FarmId::new()),
+ ),
+ )
+ }
+
#[test]
- fn default_projection_starts_on_farmer_home() {
+ fn default_projection_starts_on_personal_setup_gate() {
let projection = AppProjection::default();
- assert_eq!(projection.shell.active_surface, ActiveSurface::Farmer);
+ assert_eq!(projection.shell.active_surface, ActiveSurface::Personal);
assert_eq!(projection.shell.selected_section, ShellSection::Home);
+ assert_eq!(projection.identity, AppIdentityProjection::default());
+ assert_eq!(projection.startup_gate, AppStartupGate::SetupRequired);
assert_eq!(
projection.shell.settings.selected_section,
SettingsSection::Account
@@ -433,7 +540,10 @@ mod tests {
));
let store = AppStateStore::load(repository).expect("in-memory repository should load");
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Farmer);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Personal
+ );
assert_eq!(
store.projection().shell.selected_section,
ShellSection::Settings(SettingsSection::About)
@@ -442,6 +552,7 @@ mod tests {
store.projection().shell.settings.selected_section,
SettingsSection::About
);
+ assert_eq!(store.startup_gate(), AppStartupGate::SetupRequired);
assert_eq!(store.projection().today, TodayAgendaProjection::default());
}
@@ -455,7 +566,10 @@ mod tests {
));
assert_eq!(changed, Ok(true));
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Farmer);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Personal
+ );
assert_eq!(
store.projection().shell.selected_section,
ShellSection::Home
@@ -475,7 +589,7 @@ mod tests {
}
#[test]
- fn select_section_still_updates_the_root_shell() {
+ fn select_farmer_section_without_identity_gate_is_rejected() {
let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
.expect("in-memory repository should load");
@@ -483,16 +597,36 @@ mod tests {
FarmerSection::Products,
)));
- assert_eq!(changed, Ok(true));
+ assert_eq!(changed, Ok(false));
assert_eq!(
store.projection().shell.selected_section,
- ShellSection::Farmer(FarmerSection::Products)
+ ShellSection::Home
);
assert_eq!(
- store.repository().projection().selected_section,
- ShellSection::Farmer(FarmerSection::Products)
+ store.projection().shell.active_surface,
+ ActiveSurface::Personal
+ );
+ }
+
+ #[test]
+ fn replacing_identity_projection_with_farmer_activation_moves_home_to_farmer_today() {
+ let mut store = AppStateStore::load(InMemoryAppStateRepository::default())
+ .expect("in-memory repository should load");
+
+ let changed = store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Farmer),
+ ));
+
+ assert_eq!(changed, Ok(true));
+ assert_eq!(store.startup_gate(), AppStartupGate::Farmer);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Farmer
+ );
+ assert_eq!(
+ store.projection().shell.selected_section,
+ ShellSection::Farmer(FarmerSection::Today)
);
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Farmer);
}
#[test]
@@ -501,17 +635,35 @@ mod tests {
ActiveSurface::Personal,
));
let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
+ assert_eq!(
+ store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Personal,)
+ )),
+ Ok(true)
+ );
let changed = store.apply(AppStateCommand::select_active_surface(
ActiveSurface::Farmer,
));
assert_eq!(changed, Ok(true));
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Farmer);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Farmer
+ );
assert_eq!(
store.projection().shell.selected_section,
ShellSection::Farmer(FarmerSection::Today)
);
+ assert_eq!(
+ store
+ .identity_projection()
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .active_surface(),
+ ActiveSurface::Farmer
+ );
}
#[test]
@@ -521,14 +673,27 @@ mod tests {
ShellSection::Farmer(FarmerSection::Products),
));
let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
+ assert_eq!(
+ store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Farmer,)
+ )),
+ Ok(true)
+ );
let changed = store.apply(AppStateCommand::select_active_surface(
ActiveSurface::Personal,
));
assert_eq!(changed, Ok(true));
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Personal);
- assert_eq!(store.projection().shell.selected_section, ShellSection::Home);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Personal
+ );
+ assert_eq!(
+ store.projection().shell.selected_section,
+ ShellSection::Home
+ );
+ assert_eq!(store.startup_gate(), AppStartupGate::Personal);
}
#[test]
@@ -538,13 +703,22 @@ mod tests {
SettingsSection::About,
));
let mut store = AppStateStore::load(repository).expect("in-memory repository should load");
+ assert_eq!(
+ store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Personal,)
+ )),
+ Ok(true)
+ );
let changed = store.apply(AppStateCommand::select_active_surface(
ActiveSurface::Farmer,
));
assert_eq!(changed, Ok(true));
- assert_eq!(store.projection().shell.active_surface, ActiveSurface::Farmer);
+ assert_eq!(
+ store.projection().shell.active_surface,
+ ActiveSurface::Farmer
+ );
assert_eq!(
store.projection().shell.selected_section,
ShellSection::Settings(SettingsSection::About)
@@ -623,6 +797,30 @@ mod tests {
}
#[test]
+ fn replacing_identity_projection_surfaces_settings_account_state_without_touching_repository() {
+ let mut store =
+ AppStateStore::load(FailingRepository).expect("failing repository should still load");
+
+ let changed = store.apply(AppStateCommand::replace_identity_projection(
+ ready_identity(ActiveSurface::Personal),
+ ));
+
+ assert_eq!(changed, Ok(true));
+ assert_eq!(store.startup_gate(), AppStartupGate::Personal);
+ assert_eq!(store.settings_account_projection().roster.len(), 1);
+ assert_eq!(
+ store
+ .settings_account_projection()
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .account
+ .account_id,
+ "acct_surface"
+ );
+ }
+
+ #[test]
fn in_memory_store_construction_and_updates_are_infallible() {
let mut store = AppStateStore::in_memory(AppShellProjection::for_settings(
ActiveSurface::Farmer,
diff --git a/crates/shared/ui/src/lib.rs b/crates/shared/ui/src/lib.rs
@@ -12,7 +12,7 @@ pub use primitives::{
};
pub use text::{
app_shared_label_text, app_shared_text, runtime_metadata_rows, settings_about_status_rows,
- settings_account_profile_rows, settings_preferences_general_rows,
+ settings_preferences_general_rows,
};
pub use theme::{
APP_UI_THEME, ActionButtonColors, ActionButtonSizing, ActionButtonTokens, AppControlTokens,
diff --git a/crates/shared/ui/src/text.rs b/crates/shared/ui/src/text.rs
@@ -91,19 +91,6 @@ pub fn runtime_metadata_rows(snapshot: &AppRuntimeSnapshot) -> Vec<LabelValueRow
]
}
-pub fn settings_account_profile_rows() -> Vec<LabelValueRow> {
- vec![
- text_row(
- AppTextKey::SettingsAccountProfileLabel,
- AppTextKey::SettingsAccountPlaceholderHandle,
- ),
- text_row(
- AppTextKey::SettingsAccountStatusLabel,
- AppTextKey::SettingsAccountStatusLoggedIn,
- ),
- ]
-}
-
pub fn settings_preferences_general_rows() -> Vec<LabelValueRow> {
vec![
text_row(
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -17,6 +17,8 @@
"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.today.empty.setup.title": "Account setup required",
+ "home.today.empty.setup.body": "Add or import a local account to start using Radroots on this device.",
"home.today.empty.no_farm.title": "No farm yet",
"home.today.empty.no_farm.body": "Create a farm to start using the farmer workspace.",
"home.today.empty.quiet.title": "Nothing urgent right now",
@@ -28,12 +30,23 @@
"settings.nav.accounts": "Accounts",
"settings.nav.settings": "Settings",
"settings.nav.about": "About",
+ "settings.account.no_selection.title": "No account selected",
+ "settings.account.no_selection.body": "Add or import a local account to start using Radroots on this device.",
"settings.account.profile.label": "Profile",
"settings.account.status.label": "Status",
"settings.account.status.logged_in": "Logged In",
"settings.account.status.logged_out": "Logged Out",
- "settings.account.placeholder_name": "Test User",
- "settings.account.placeholder_handle": "test.user",
+ "settings.account.status.blocked": "Blocked",
+ "settings.account.custody.label": "Custody",
+ "settings.account.custody.local_managed": "Local managed",
+ "settings.account.custody.browser_signer": "Browser signer",
+ "settings.account.custody.remote_signer": "Remote signer",
+ "settings.account.surface.label": "Surface",
+ "settings.account.surface.personal": "Personal",
+ "settings.account.surface.farmer": "Farmer",
+ "settings.account.activation.label": "Farmer",
+ "settings.account.activation.inactive": "Not activated",
+ "settings.account.activation.active": "Activated",
"settings.account.action.add_account": "Add Account...",
"settings.account.action.log_out": "Log Out",
"settings.account.action.admin_console": "Admin Console...",