app

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

commit 881c00414cf060c4c4c51980bc6da2bbb70faaf9
parent 39f3f9bb31b98ffc77bc1df31a81d23116561f21
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 15:54:40 +0000

app: add config status gating

- add config status and gate helpers for flow control
- wire config status into context and setup navigation
- add config placeholder page and gating before app routes
- show setup and config status on settings status page

Diffstat:
Mapp/src/app.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/src/configuration.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/context.rs | 19++++++++++++++++++-
Mapp/src/lib.rs | 4++++
Mapp/src/settings_status.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 263 insertions(+), 3 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -48,7 +48,9 @@ use crate::{ app_log_debug_emit, app_log_error_emit, app_log_error_store, + app_config_gate_from_status, app_config_default, + app_config_status, app_datastore_clear_setup_draft, app_datastore_write_profile_seed, app_datastore_write_setup_draft, @@ -65,6 +67,7 @@ use crate::{ app_setup_gate_from_status, app_setup_step_default, RadrootsAppBackends, + RadrootsAppConfigStatus, RadrootsAppInitError, RadrootsAppInitStage, RadrootsAppNotifications, @@ -1514,6 +1517,22 @@ fn RecoveryPage() -> impl IntoView { } #[component] +fn ConfigPage() -> impl IntoView { + view! { + <main id="app-config" class="app-page app-page-fixed"> + <section id="app-config-body" class="flex flex-col gap-2 px-4 pt-6"> + <h1 id="app-config-title" class="text-xl font-semibold"> + {"Configure your profile"} + </h1> + <p id="app-config-subtitle" class="text-sm text-[var(--text-secondary)]"> + {"Complete your configuration to continue."} + </p> + </section> + </main> + } +} + +#[component] fn HomePage() -> impl IntoView { let current_view = RwSignal::new_local(HomeView::Activity); let is_activity = move || matches!(current_view.get(), HomeView::Activity); @@ -1579,11 +1598,13 @@ fn AppShell() -> impl IntoView { let init_error = RwSignal::new_local(None::<RadrootsAppInitError>); let init_state = RwSignal::new_local(app_init_state_default()); let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown); + let config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); let navigate = use_navigate(); provide_context(backends); provide_context(init_error); provide_context(init_state); provide_context(setup_status); + provide_context(config_status); provide_context(app_i18n_init()); Effect::new(move || { let navigate = navigate.clone(); @@ -1639,6 +1660,7 @@ fn AppShell() -> impl IntoView { log_init_stage(stage); let navigate = navigate.clone(); let setup_status = setup_status.clone(); + let config_status = config_status.clone(); spawn_local(async move { let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( Some(keystore_config), @@ -1648,17 +1670,30 @@ fn AppShell() -> impl IntoView { setup_status.set(status); match status { RadrootsAppSetupStatus::Required | RadrootsAppSetupStatus::Locked => { + config_status.set(RadrootsAppConfigStatus::Unknown); navigate("/setup", Default::default()); } RadrootsAppSetupStatus::Corrupt => { + config_status.set(RadrootsAppConfigStatus::Unknown); navigate("/recovery", Default::default()); } - _ => {} + RadrootsAppSetupStatus::Configured => { + let config_state = app_config_status(datastore.as_ref(), &key_maps).await; + config_status.set(config_state); + if matches!( + config_state, + RadrootsAppConfigStatus::Required | RadrootsAppConfigStatus::Corrupt + ) { + navigate("/setup/config", Default::default()); + } + } + RadrootsAppSetupStatus::Unknown => {} } } Err(err) => { let _ = app_log_error_emit(&err); setup_status.set(RadrootsAppSetupStatus::Corrupt); + config_status.set(RadrootsAppConfigStatus::Unknown); navigate("/recovery", Default::default()); } } @@ -1688,11 +1723,21 @@ fn AppShell() -> impl IntoView { }) }); let setup_gate = move || app_setup_gate_from_status(setup_status.get()); + let config_gate = move || app_config_gate_from_status(config_status.get()); + let config_ready = move || { + let status = setup_status.get(); + if matches!(status, RadrootsAppSetupStatus::Configured) { + !matches!(config_status.get(), RadrootsAppConfigStatus::Unknown) + } else { + true + } + }; view! { <Show when=move || { init_state.get().stage == RadrootsAppInitStage::Ready && !matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown) + && config_ready() } fallback=|| view! { <SplashPage /> } > @@ -1705,6 +1750,10 @@ fn AppShell() -> impl IntoView { return view! { <SetupPage /> }.into_any(); } if gate.show_app { + let config_gate = config_gate(); + if config_gate.show_config { + return view! { <ConfigPage /> }.into_any(); + } return view! { <div id="app-shell"> <Routes diff --git a/app/src/configuration.rs b/app/src/configuration.rs @@ -122,6 +122,35 @@ impl Default for RadrootsAppConfigData { pub const APP_CONFIG_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppConfigStatus { + Unknown, + Required, + Configured, + Corrupt, +} + +impl Default for RadrootsAppConfigStatus { + fn default() -> Self { + RadrootsAppConfigStatus::Unknown + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RadrootsAppConfigGate { + pub show_config: bool, + pub show_app: bool, +} + +impl RadrootsAppConfigGate { + pub const fn splash() -> Self { + Self { + show_config: false, + show_app: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsAppConfigRecordError { Missing, Corrupt, @@ -236,6 +265,44 @@ impl std::error::Error for RadrootsAppConfigStoreError {} pub type RadrootsAppConfigStoreResult<T> = Result<T, RadrootsAppConfigStoreError>; +pub async fn app_config_status<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppConfigStatus { + match app_datastore_read_config_record(datastore, key_maps).await { + Ok(_) => RadrootsAppConfigStatus::Configured, + Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Missing)) => { + RadrootsAppConfigStatus::Required + } + Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::InvalidChecksum)) + | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::UnsupportedVersion(_))) + | Err(RadrootsAppConfigStoreError::Datastore(_)) => RadrootsAppConfigStatus::Corrupt, + Err(RadrootsAppConfigStoreError::Config(_)) + | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Corrupt)) + | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::AlreadyExists)) => { + RadrootsAppConfigStatus::Corrupt + } + } +} + +pub const fn app_config_gate_from_status( + status: RadrootsAppConfigStatus, +) -> RadrootsAppConfigGate { + match status { + RadrootsAppConfigStatus::Unknown => RadrootsAppConfigGate::splash(), + RadrootsAppConfigStatus::Required | RadrootsAppConfigStatus::Corrupt => { + RadrootsAppConfigGate { + show_config: true, + show_app: false, + } + } + RadrootsAppConfigStatus::Configured => RadrootsAppConfigGate { + show_config: false, + show_app: true, + }, + } +} + pub async fn app_datastore_write_config_record<T: RadrootsClientDatastore>( datastore: &T, key_maps: &RadrootsAppKeyMapConfig, @@ -333,10 +400,13 @@ pub async fn app_datastore_clear_config<T: RadrootsClientDatastore>( #[cfg(test)] mod tests { use super::{ + app_config_gate_from_status, app_config_record_new, app_config_record_validate, + RadrootsAppConfigGate, RadrootsAppConfigData, RadrootsAppConfigRecordError, + RadrootsAppConfigStatus, APP_CONFIG_SCHEMA_VERSION, }; @@ -359,4 +429,18 @@ mod tests { let err = app_config_record_validate(&record).expect_err("checksum"); assert_eq!(err, RadrootsAppConfigRecordError::InvalidChecksum); } + + #[test] + fn config_gate_maps_status() { + assert_eq!( + app_config_gate_from_status(RadrootsAppConfigStatus::Unknown), + RadrootsAppConfigGate::splash() + ); + let required = app_config_gate_from_status(RadrootsAppConfigStatus::Required); + assert!(required.show_config); + assert!(!required.show_app); + let configured = app_config_gate_from_status(RadrootsAppConfigStatus::Configured); + assert!(configured.show_app); + assert!(!configured.show_config); + } } diff --git a/app/src/context.rs b/app/src/context.rs @@ -2,7 +2,13 @@ use leptos::prelude::{use_context, LocalStorage, RwSignal}; -use crate::{RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitState, RadrootsAppSetupStatus}; +use crate::{ + RadrootsAppBackends, + RadrootsAppConfigStatus, + RadrootsAppInitError, + RadrootsAppInitState, + RadrootsAppSetupStatus, +}; #[derive(Clone)] pub struct RadrootsAppContext { @@ -10,6 +16,7 @@ pub struct RadrootsAppContext { pub init_error: RwSignal<Option<RadrootsAppInitError>, LocalStorage>, pub init_state: RwSignal<RadrootsAppInitState, LocalStorage>, pub setup_status: RwSignal<RadrootsAppSetupStatus, LocalStorage>, + pub config_status: RwSignal<RadrootsAppConfigStatus, LocalStorage>, } pub fn app_context() -> Option<RadrootsAppContext> { @@ -18,6 +25,7 @@ pub fn app_context() -> Option<RadrootsAppContext> { init_error: use_context::<RwSignal<Option<RadrootsAppInitError>, LocalStorage>>()?, init_state: use_context::<RwSignal<RadrootsAppInitState, LocalStorage>>()?, setup_status: use_context::<RwSignal<RadrootsAppSetupStatus, LocalStorage>>()?, + config_status: use_context::<RwSignal<RadrootsAppConfigStatus, LocalStorage>>()?, }) } @@ -29,6 +37,7 @@ mod tests { RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitStage, + RadrootsAppConfigStatus, RadrootsAppSetupStatus, }; use leptos::prelude::{provide_context, Owner, RwSignal, WithUntracked}; @@ -48,10 +57,12 @@ mod tests { let init_error = RwSignal::new_local(None::<RadrootsAppInitError>); let init_state = RwSignal::new_local(app_init_state_default()); let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown); + let config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); provide_context(backends); provide_context(init_error); provide_context(init_state); provide_context(setup_status); + provide_context(config_status); let context = app_context().expect("context"); assert!(context.backends.with_untracked(|value| value.is_none())); assert!(context.init_error.with_untracked(|value| value.is_none())); @@ -65,5 +76,11 @@ mod tests { .with_untracked(|value| *value), RadrootsAppSetupStatus::Unknown ); + assert_eq!( + context + .config_status + .with_untracked(|value| *value), + RadrootsAppConfigStatus::Unknown + ); } } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -60,6 +60,8 @@ pub use data::{ pub use configuration::{ app_config_record_new, app_config_record_validate, + app_config_gate_from_status, + app_config_status, app_datastore_clear_config, app_datastore_create_config, app_datastore_has_config, @@ -71,10 +73,12 @@ pub use configuration::{ RadrootsAppConfigData, RadrootsAppConfigFarmer, RadrootsAppConfigIndividual, + RadrootsAppConfigGate, RadrootsAppConfigPreferences, RadrootsAppConfigProfile, RadrootsAppConfigRecord, RadrootsAppConfigRecordError, + RadrootsAppConfigStatus, RadrootsAppConfigStoreError, RadrootsAppConfigStoreResult, APP_CONFIG_SCHEMA_VERSION, diff --git a/app/src/settings_status.rs b/app/src/settings_status.rs @@ -14,6 +14,7 @@ use crate::{ spawn_health_checks, t, RadrootsAppBackends, + RadrootsAppConfigStatus, RadrootsAppHealthCheckResult, RadrootsAppHealthReport, RadrootsAppSetupStatus, @@ -54,6 +55,43 @@ fn status_text(value: String) -> RadrootsAppUiListLabelValue { } } +fn config_status_label(status: RadrootsAppConfigStatus) -> String { + match status { + RadrootsAppConfigStatus::Unknown => t!("app.common.unknown"), + RadrootsAppConfigStatus::Required => String::from("required"), + RadrootsAppConfigStatus::Configured => String::from("configured"), + RadrootsAppConfigStatus::Corrupt => String::from("corrupt"), + } +} + +fn config_status_class(status: RadrootsAppConfigStatus) -> &'static str { + match status { + RadrootsAppConfigStatus::Configured => "status-good", + RadrootsAppConfigStatus::Required => "status-warn", + RadrootsAppConfigStatus::Corrupt => "status-error", + RadrootsAppConfigStatus::Unknown => "status-neutral", + } +} + +fn setup_status_label(status: RadrootsAppSetupStatus) -> String { + match status { + RadrootsAppSetupStatus::Unknown => t!("app.common.unknown"), + RadrootsAppSetupStatus::Required => String::from("required"), + RadrootsAppSetupStatus::Configured => String::from("configured"), + RadrootsAppSetupStatus::Corrupt => String::from("corrupt"), + RadrootsAppSetupStatus::Locked => String::from("locked"), + } +} + +fn setup_status_class(status: RadrootsAppSetupStatus) -> &'static str { + match status { + RadrootsAppSetupStatus::Configured => "status-good", + RadrootsAppSetupStatus::Required | RadrootsAppSetupStatus::Locked => "status-warn", + RadrootsAppSetupStatus::Corrupt => "status-error", + RadrootsAppSetupStatus::Unknown => "status-neutral", + } +} + fn status_row(label: String, result: RadrootsAppHealthCheckResult) -> RadrootsAppUiListItem { let status_label = health_result_label(&result); let status_class = health_status_class(result.status); @@ -97,6 +135,7 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { let context = app_context(); let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown); + let fallback_config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); let backends = context .as_ref() .map(|value| value.backends) @@ -105,6 +144,10 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { .as_ref() .map(|value| value.setup_status) .unwrap_or(fallback_setup_status); + let config_status = context + .as_ref() + .map(|value| value.config_status) + .unwrap_or(fallback_config_status); let health_report = RwSignal::new_local(RadrootsAppHealthReport::empty()); let health_running = RwSignal::new_local(false); let health_autorun = RwSignal::new_local(false); @@ -189,6 +232,63 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { {move || { let report = health_report.get(); let active = active_key_label(active_key.get()); + let setup_value = setup_status.get(); + let config_value = config_status.get(); + let config_list = RadrootsAppUiList { + id: Some("settings-config-status-list".to_string()), + view: Some("settings-config-status".to_string()), + classes: None, + title: Some(RadrootsAppUiListTitle { + value: RadrootsAppUiListTitleValue::Text(String::from("Configuration")), + classes: None, + mod_value: None, + link: None, + on_click: None, + }), + default_state: None, + list: Some(vec![ + Some(RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { + left: vec![status_text(String::from("setup status"))], + right: vec![ + status_text(setup_status_label(setup_value)), + status_dot(setup_status_class(setup_value)), + ], + }, + display: None, + end: None, + on_click: None, + }), + loading: false, + hide_active: true, + hide_field: false, + full_rounded: false, + offset: None, + }), + Some(RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { + left: vec![status_text(String::from("config status"))], + right: vec![ + status_text(config_status_label(config_value)), + status_dot(config_status_class(config_value)), + ], + }, + display: None, + end: None, + on_click: None, + }), + loading: false, + hide_active: true, + hide_field: false, + full_rounded: false, + offset: None, + }), + ]), + hide_offset: false, + styles: None, + }; let list = RadrootsAppUiList { id: Some("settings-status-list".to_string()), view: Some("settings-status".to_string()), @@ -241,7 +341,13 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { hide_offset: false, styles: None, }; - view! { <RadrootsAppUiListView basis=list /> }.into_any() + view! { + <div class="flex flex-col gap-4"> + <RadrootsAppUiListView basis=config_list /> + <RadrootsAppUiListView basis=list /> + </div> + } + .into_any() }} </section> </AppPageChrome>