app

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

commit f364dde6de8d44c0cdf8d92bb0b674b4c7708604
parent decdfc8e17535e79ef2cca50f08bb6310f1cb234
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 04:15:46 +0000

app: add home segmented toggle

- replace home content with activity/profile toggle

- add ios-style segmented control styles

- tune sizing, padding, and rounding for symmetry

- keep recovery reset labels intact

Diffstat:
Mapp/app.css | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/app.rs | 343++++++++++++-------------------------------------------------------------------
2 files changed, 126 insertions(+), 292 deletions(-)

diff --git a/app/app.css b/app/app.css @@ -257,6 +257,81 @@ background: var(--separator); } + .home-toggle { + position: relative; + display: grid; + --home-toggle-seg: 98px; + --home-toggle-pad: 2px; + --home-toggle-inset-x: 0.75px; + --home-toggle-border: 1px; + grid-template-columns: var(--home-toggle-seg) var(--home-toggle-seg); + align-items: center; + width: calc( + (var(--home-toggle-seg) * 2) + + (var(--home-toggle-pad) * 2) + + (var(--home-toggle-border) * 2) + ); + padding: var(--home-toggle-pad); + height: 32px; + border-radius: 12px; + background: var(--material-regular); + border: var(--home-toggle-border) solid var(--stroke); + box-shadow: var(--shadow-press); + gap: 0; + box-sizing: border-box; + } + + .home-toggle__indicator { + position: absolute; + top: var(--home-toggle-pad); + bottom: var(--home-toggle-pad); + left: calc(var(--home-toggle-pad) + var(--home-toggle-inset-x)); + width: calc(var(--home-toggle-seg) - (var(--home-toggle-inset-x) * 2)); + border-radius: 8px; + background: var(--bg-elevated); + box-shadow: var(--shadow-1); + transition: transform var(--dur-2) var(--ease-ios); + will-change: transform; + box-sizing: border-box; + } + + .home-toggle--right .home-toggle__indicator { + transform: translateX(var(--home-toggle-seg)); + } + + .home-toggle__button { + position: relative; + z-index: 1; + border: none; + background: transparent; + height: 28px; + padding: 0 var(--home-toggle-inset-x); + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.01em; + cursor: pointer; + transition: color var(--dur-1) var(--ease-ios); + } + + .home-toggle__button.is-active { + color: var(--text-primary); + } + + .home-toggle__title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + text-align: center; + } + + @media (prefers-reduced-motion: reduce) { + .home-toggle__indicator { + transition: none; + } + } + } @keyframes overlay-fade-in { diff --git a/app/src/app.rs b/app/src/app.rs @@ -1,4 +1,3 @@ -use gloo_timers::future::TimeoutFuture; use leptos::ev::{KeyboardEvent, MouseEvent}; use leptos::prelude::*; use leptos::task::spawn_local; @@ -52,19 +51,12 @@ use crate::{ app_setup_lock_enabled, app_setup_lock_release, app_setup_lock_ttl_ms, - app_state_set_notifications_permission_value, app_state_timestamp_ms, app_setup_eula_date, app_setup_finalize_with_key, app_setup_gate_from_status, app_setup_step_default, - app_health_check_delay_ms, - health_report_summary, - health_result_label, - health_status_class, RadrootsAppBackends, - RadrootsAppHealthCheckResult, - RadrootsAppHealthReport, RadrootsAppInitError, RadrootsAppInitStage, RadrootsAppNotifications, @@ -84,19 +76,20 @@ use crate::{ RadrootsAppUiDemoPage, RadrootsAppSetupStep, RadrootsAppSettingsStatusPage, - spawn_health_checks, }; -fn init_stage_label(stage: RadrootsAppInitStage) -> String { - match stage { - RadrootsAppInitStage::Idle => t!("app.init.stage.idle"), - RadrootsAppInitStage::Storage => t!("app.init.stage.storage"), - RadrootsAppInitStage::DownloadSql => t!("app.init.stage.download_sql"), - RadrootsAppInitStage::DownloadGeo => t!("app.init.stage.download_geo"), - RadrootsAppInitStage::Database => t!("app.init.stage.database"), - RadrootsAppInitStage::Geocoder => t!("app.init.stage.geocoder"), - RadrootsAppInitStage::Ready => t!("app.init.stage.ready"), - RadrootsAppInitStage::Error => t!("app.init.stage.error"), +#[derive(Clone, Copy, PartialEq, Eq)] +enum HomeView { + Activity, + Profile, +} + +impl HomeView { + fn label(self) -> &'static str { + match self { + HomeView::Activity => "Activity", + HomeView::Profile => "Profile", + } } } @@ -129,16 +122,6 @@ fn reset_status_label(value: &str) -> String { } } -fn notifications_status_label(value: &str) -> String { - match value { - "granted" => t!("app.home.notifications.status.granted"), - "denied" => t!("app.home.notifications.status.denied"), - "default" => t!("app.home.notifications.status.default"), - "unavailable" => t!("app.home.notifications.status.unavailable"), - _ => error_label(value).unwrap_or_else(|| value.to_string()), - } -} - fn setup_touch_callback(action: &'static str) -> Callback<MouseEvent> { Callback::new(move |_| { let _ = app_log_debug_emit("log.app.setup.choice", action, None); @@ -1446,270 +1429,46 @@ fn RecoveryPage() -> impl IntoView { #[component] fn HomePage() -> impl IntoView { - let context = app_context(); - let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); - let fallback_init_error = RwSignal::new_local(None::<RadrootsAppInitError>); - let fallback_init_state = RwSignal::new_local(app_init_state_default()); - let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown); - let backends = context - .as_ref() - .map(|value| value.backends) - .unwrap_or(fallback_backends); - let init_state = context - .as_ref() - .map(|value| value.init_state) - .unwrap_or(fallback_init_state); - let setup_status = context - .as_ref() - .map(|value| value.setup_status) - .unwrap_or(fallback_setup_status); - let _init_error = context - .as_ref() - .map(|value| value.init_error) - .unwrap_or(fallback_init_error); - let reset_status = RwSignal::new_local(None::<String>); - let health_report = RwSignal::new_local(RadrootsAppHealthReport::empty()); - let health_running = RwSignal::new_local(false); - let health_autorun = RwSignal::new_local(false); - let active_key = RwSignal::new_local(None::<String>); - let notifications_status = RwSignal::new_local(None::<String>); - let notifications_requesting = RwSignal::new_local(false); - let last_run = RwSignal::new_local(None::<i64>); - Effect::new(move || { - if init_state.get().stage != RadrootsAppInitStage::Ready { - return; - } - if health_autorun.get() { - return; - } - let setup_status = setup_status.get(); - if matches!(setup_status, RadrootsAppSetupStatus::Unknown) { - return; - } - let setup_required_value = !matches!(setup_status, RadrootsAppSetupStatus::Configured); - let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); - let Some(config) = config else { - return; - }; - health_autorun.set(true); - let delay_ms = app_health_check_delay_ms(); - spawn_local(async move { - TimeoutFuture::new(delay_ms).await; - spawn_health_checks( - config, - setup_required_value, - health_report, - health_running, - active_key, - notifications_status, - last_run, - ); - }); - }); - let status_color = move || match init_state.get().stage { - RadrootsAppInitStage::Ready => "green", - RadrootsAppInitStage::Error => "red", - RadrootsAppInitStage::Storage => "orange", - RadrootsAppInitStage::DownloadSql => "orange", - RadrootsAppInitStage::DownloadGeo => "orange", - RadrootsAppInitStage::Database => "orange", - RadrootsAppInitStage::Geocoder => "orange", - RadrootsAppInitStage::Idle => "gray", - }; - let reset_disabled = move || backends.with(|value| value.is_none()); - let reset_label = move || { - reset_status - .get() - .as_deref() - .map(reset_status_label) - .unwrap_or_else(|| t!("app.home.reset.status.idle")) - }; - let notifications_disabled = move || { - backends.with(|value| value.is_none()) || notifications_requesting.get() - }; - let notifications_label = move || { - notifications_status - .get() - .as_deref() - .map(notifications_status_label) - .unwrap_or_else(|| t!("app.common.unknown")) - }; - let notifications_button_label = move || { - if notifications_requesting.get() { - t!("app.home.notifications.button.requesting") - } else { - t!("app.home.notifications.button.request") - } - }; + let current_view = RwSignal::new_local(HomeView::Activity); + let is_activity = move || matches!(current_view.get(), HomeView::Activity); + let is_profile = move || matches!(current_view.get(), HomeView::Profile); view! { - <main id="app-home" class="app-page app-page-scroll"> - <header id="app-home-header"> - <h1 id="app-home-title">{t!("app.home.title")}</h1> - </header> - <section id="app-home-status" aria-label=t!("app.home.status.aria")> - <div id="app-home-status-row" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - status_color() - ) - ></span> - <span>{move || init_stage_label(init_state.get().stage)}</span> - </div> - </section> - <section id="app-home-reset" aria-label=t!("app.home.reset.aria")> - <div id="app-home-reset-row" style="margin-top: 12px; display: flex; align-items: center; gap: 8px;"> - <button - on:click=move |_| { - let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); - reset_status.set(Some("resetting".to_string())); - health_report.set(RadrootsAppHealthReport::empty()); - active_key.set(None); - notifications_status.set(None); - setup_status.set(RadrootsAppSetupStatus::Required); - spawn_local(async move { - let Some(config) = config else { - reset_status.set(Some("reset_missing_backends".to_string())); - return; - }; - let datastore = radroots_app_core::datastore::RadrootsClientWebDatastore::new( - Some(config.datastore.idb_config), - ); - let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( - Some(config.keystore.nostr_store), - ); - match app_init_reset( - Some(&datastore), - Some(&config.datastore.key_maps), - Some(&keystore), - ) - .await - { - Ok(()) => { - let log_datastore = logs_datastore(); - if let Err(err) = log_datastore.reset().await { - let reset_err = RadrootsAppInitError::Datastore(err); - let _ = app_log_error_emit(&reset_err); - reset_status.set(Some(reset_err.to_string())); - return; - } - reset_status.set(Some("reset_done".to_string())); - spawn_health_checks( - config, - true, - health_report, - health_running, - active_key, - notifications_status, - last_run, - ); - } - Err(err) => { - let log_datastore = logs_datastore(); - let _ = app_log_error_store( - &log_datastore, - &config.datastore.key_maps, - &err, - ) - .await; - reset_status.set(Some(err.to_string())); - } - } - }); - } - disabled=reset_disabled - > - {t!("app.home.reset.button")} - </button> - <span>{reset_label}</span> - </div> - </section> - <section id="app-home-notifications" aria-label=t!("app.home.notifications.aria") style="margin-top: 16px;"> - <header id="app-home-notifications-header"> - <h2 id="app-home-notifications-title" style="font-weight: 600;">{t!("app.home.notifications.title")}</h2> - </header> - <div id="app-home-notifications-actions" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;"> - <button - on:click=move |_| { - let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); - notifications_requesting.set(true); - spawn_local(async move { - let Some(config) = config else { - notifications_requesting.set(false); - return; - }; - let datastore = radroots_app_core::datastore::RadrootsClientWebDatastore::new( - Some(config.datastore.idb_config), - ); - let notifications = RadrootsAppNotifications::new(None); - match notifications.request_permission().await { - Ok(permission) => { - let value = permission.as_str().to_string(); - let _ = app_state_set_notifications_permission_value( - &datastore, - &config.datastore.key_maps, - permission, - ) - .await; - notifications_status.set(Some(value)); - spawn_health_checks( - config, - false, - health_report, - health_running, - active_key, - notifications_status, - last_run, - ); - } - Err(err) => { - let log_datastore = logs_datastore(); - let _ = app_log_error_store( - &log_datastore, - &config.datastore.key_maps, - &err, - ) - .await; - notifications_status.set(Some(err.to_string())); - } - } - notifications_requesting.set(false); - }); - } - disabled=notifications_disabled - > - {notifications_button_label} - </button> - <span>{notifications_label}</span> - </div> - </section> - <section id="app-home-health" aria-label=t!("app.home.health.aria") style="margin-top: 16px;"> - <header id="app-home-health-header" style="display:flex;align-items:center;gap:12px;"> - <h2 id="app-home-health-title" style="font-weight: 600;"> - {t!("app.settings.status.title")} - </h2> - <A href="/settings/status" attr:id="app-home-health-link"> - {t!("app.settings.status.view")} - </A> - </header> - <div id="app-home-health-summary" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;"> - <span - class=move || { - let summary = health_report_summary(&health_report.get()); - format!("status-dot {}", health_status_class(summary)) - } - > - {"●"} - </span> - <span>{move || { - let summary = health_report_summary(&health_report.get()); - health_result_label(&RadrootsAppHealthCheckResult { - status: summary, - message: None, - }) - }}</span> - </div> - </section> + <main id="app-home" class="app-page app-page-fixed flex flex-col items-center justify-start gap-6 pt-10"> + <div + id="app-home-toggle" + class="home-toggle" + class:home-toggle--left=is_activity + class:home-toggle--right=is_profile + role="tablist" + aria-label="Home view" + > + <div class="home-toggle__indicator" aria-hidden="true"></div> + <button + id="app-home-toggle-activity" + class="home-toggle__button" + class:is-active=is_activity + type="button" + role="tab" + aria-selected=move || if is_activity() { "true" } else { "false" } + on:click=move |_| current_view.set(HomeView::Activity) + > + {"Activity"} + </button> + <button + id="app-home-toggle-profile" + class="home-toggle__button" + class:is-active=is_profile + type="button" + role="tab" + aria-selected=move || if is_profile() { "true" } else { "false" } + on:click=move |_| current_view.set(HomeView::Profile) + > + {"Profile"} + </button> + </div> + <p id="app-home-view-title" class="home-toggle__title"> + {move || current_view.get().label()} + </p> </main> } }