app

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

commit 3e068732f42023addc84d17f346ff97ced69eba5
parent cc28645497dfb43210e3b61949048cb60bb90002
Author: triesap <tyson@radroots.org>
Date:   Sun,  7 Jun 2026 12:27:11 -0700

app: add account tab surface

Diffstat:
Mcrates/desktop/src/runtime.rs | 15++++++++++++++-
Mcrates/desktop/src/source_guards.rs | 8++++++++
Mcrates/desktop/src/window.rs | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/i18n/src/keys.rs | 6++++++
Mcrates/i18n/src/lib.rs | 9+++++++++
Mcrates/state/src/lib.rs | 7+++++--
Mcrates/ui/src/lib.rs | 21+++++++++++----------
Mcrates/ui/src/primitives.rs | 28++++++++++++++++++++++++++++
Mcrates/view/src/lib.rs | 7++++++-
Mi18n/locales/en/messages.json | 6++++++
10 files changed, 268 insertions(+), 24 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -634,6 +634,16 @@ impl DesktopAppRuntime { section_changed || editor_changed } + pub fn select_account(&self) -> bool { + let mut state = self.lock_state_mut(); + let section_changed = state + .state_store + .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Account)); + let editor_changed = state.close_product_editor(); + + section_changed || editor_changed + } + pub fn select_personal_section( &self, section: PersonalSection, @@ -6328,7 +6338,10 @@ impl DesktopAppRuntimeState { !self.has_saved_farm() || !self.has_pack_day_context() } ShellSection::Farmer(FarmerSection::Farm) => true, - ShellSection::Home | ShellSection::Personal(_) | ShellSection::Settings(_) => false, + ShellSection::Home + | ShellSection::Account + | ShellSection::Personal(_) + | ShellSection::Settings(_) => false, }; should_reset_to_today diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -41,6 +41,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-open-workspace", "account-log-out", "account-more", + "account-scroll", + "account-tabs", "account_1", "buyer", "buyer-detail-add-to-cart", @@ -352,6 +354,12 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeHeaderFarmMode", "AppTextKey::HomeHeaderAccountSetupAction", "AppTextKey::HomeHeaderGuestLabel", + "AppTextKey::AccountTitle", + "AppTextKey::AccountTabProfile", + "AppTextKey::AccountTabFarmDetails", + "AppTextKey::AccountTabPreferences", + "AppTextKey::AccountTabSecurity", + "AppTextKey::AccountNotImplemented", "AppTextKey::HomeSetupBackAction", "AppTextKey::HomeSetupBrowseMarketplaceAction", "AppTextKey::HomeSetupConnectSignerAction", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -25,7 +25,7 @@ use radroots_app_sync::{ }; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec, - AppSegmentButtonIconSpec as IconSegmentButtonSpec, LabelValueRow, + AppSegmentButtonIconSpec as IconSegmentButtonSpec, AppUnderlineTabSpec, LabelValueRow, SettingsPreferencesGeneralRowState, app_button_account_selector_row as account_selector_row, app_button_card, app_button_choice as choice_button, app_button_compact as action_button_compact, app_button_list_row as list_row_button, @@ -42,8 +42,8 @@ use radroots_app_ui::{ app_surface_card_section as home_card, app_surface_panel, app_surface_sidebar, app_surface_window as app_window_shell, app_text_badge as settings_badge_text, app_text_body_subtle as home_body_text, app_text_label, - app_text_label as home_farm_setup_field_label, app_text_value, label_value_list, - runtime_metadata_rows, settings_preferences_general_rows, utility_title_row, + app_text_label as home_farm_setup_field_label, app_text_value, app_underline_tabs, + label_value_list, runtime_metadata_rows, settings_preferences_general_rows, utility_title_row, }; pub use radroots_app_view::SettingsSection as SettingsPanelViewKey; use radroots_app_view::{ @@ -121,6 +121,7 @@ pub enum PrimaryWindowTarget { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HomeStage { Setup, + AccountWorkspace, BuyerWorkspace, FarmerWorkspace, } @@ -133,6 +134,11 @@ pub fn primary_window_target(_: &DesktopAppRuntimeSummary) -> PrimaryWindowTarge pub fn home_stage(summary: &DesktopAppRuntimeSummary) -> HomeStage { if summary.startup_issue.is_some() || summary.startup_gate == AppStartupGate::Blocked { HomeStage::Setup + } else if matches!( + summary.shell_projection.selected_section, + ShellSection::Account + ) { + HomeStage::AccountWorkspace } else if summary.startup_gate == AppStartupGate::Farmer { HomeStage::FarmerWorkspace } else if matches!( @@ -157,6 +163,51 @@ enum HomeFocusedView { BuyerReceiptIssue(OrderId), } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum AccountTab { + #[default] + Profile, + FarmDetails, + Preferences, + Security, +} + +impl AccountTab { + const ORDERED: [Self; 4] = [ + Self::Profile, + Self::FarmDetails, + Self::Preferences, + Self::Security, + ]; + + const fn text_key(self) -> AppTextKey { + match self { + Self::Profile => AppTextKey::AccountTabProfile, + Self::FarmDetails => AppTextKey::AccountTabFarmDetails, + Self::Preferences => AppTextKey::AccountTabPreferences, + Self::Security => AppTextKey::AccountTabSecurity, + } + } + + const fn panel_text_key(self) -> AppTextKey { + match self { + Self::Profile | Self::FarmDetails => self.text_key(), + Self::Preferences | Self::Security => AppTextKey::AccountNotImplemented, + } + } + + fn selected_index(self) -> usize { + Self::ORDERED + .iter() + .position(|tab| *tab == self) + .unwrap_or(0) + } + + fn from_index(index: usize) -> Self { + Self::ORDERED.get(index).copied().unwrap_or_default() + } +} + fn buyer_order_detail_focus_after_open( runtime_changed: bool, runtime: &DesktopAppRuntimeSummary, @@ -283,6 +334,7 @@ pub struct HomeView { products_stock_editor: Option<ProductsStockEditorState>, product_editor_form: Option<ProductEditorFormState>, focused_view: Option<HomeFocusedView>, + selected_account_tab: AccountTab, relay_client: Option<RadrootsNostrClient>, buyer_workspace_notice: Option<String>, } @@ -402,6 +454,7 @@ impl HomeView { products_stock_editor: None, product_editor_form: None, focused_view: None, + selected_account_tab: AccountTab::default(), relay_client: None, buyer_workspace_notice: None, } @@ -1349,9 +1402,17 @@ impl HomeView { } fn open_account_entry(&mut self, cx: &mut Context<Self>) { - if self.runtime.select_home() { + if self.runtime.select_account() { self.products_stock_editor = None; self.product_editor_form = None; + self.clear_focused_view(); + cx.notify(); + } + } + + fn select_account_tab(&mut self, tab: AccountTab, cx: &mut Context<Self>) { + if self.selected_account_tab != tab { + self.selected_account_tab = tab; cx.notify(); } } @@ -4867,12 +4928,101 @@ impl Render for HomeView { cx, ) .into_any_element(), + HomeStage::AccountWorkspace => self.render_account_workspace(&runtime_summary, cx), HomeStage::BuyerWorkspace => self.render_buyer_workspace(&runtime_summary, cx), HomeStage::FarmerWorkspace => self.render_farmer_workspace(&runtime_summary, cx), } } } +impl HomeView { + fn render_account_workspace( + &mut self, + runtime: &DesktopAppRuntimeSummary, + cx: &mut Context<Self>, + ) -> AnyElement { + let sidebar = if runtime.shell_projection.active_surface == ActiveSurface::Farmer { + home_sidebar( + runtime, + cx.listener(|this, _, _, cx| this.select_farmer_section(FarmerSection::Today, cx)), + cx.listener(|this, _, _, cx| { + this.select_farmer_section(FarmerSection::Products, cx) + }), + cx.listener(|this, _, _, cx| this.open_orders(cx)), + cx.listener(|this, _, _, cx| this.open_pack_day(None, cx)), + cx, + ) + .into_any_element() + } else { + buyer_sidebar( + runtime, + cx.listener(|this, _, _, cx| { + this.select_personal_section(PersonalSection::Browse, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_personal_section(PersonalSection::Search, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_personal_section(PersonalSection::Cart, cx) + }), + cx.listener(|this, _, _, cx| { + this.select_personal_section(PersonalSection::Orders, cx) + }), + cx, + ) + .into_any_element() + }; + + app_split_shell( + sidebar, + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .size_full() + .child(shared_shell_header( + runtime, + cx.listener(|this, _, _, cx| this.switch_to_marketplace(cx)), + cx.listener(|this, _, _, cx| this.switch_to_farmer_workspace(cx)), + cx.listener(|this, _, _, cx| this.open_account_entry(cx)), + cx, + )) + .child(app_scroll_panel( + "account-scroll", + 0.0, + None, + self.render_account_content(cx), + )) + .into_any_element(), + ) + .into_any_element() + } + + fn render_account_content(&mut self, cx: &mut Context<Self>) -> AnyElement { + let selected_tab = self.selected_account_tab; + let tabs = AccountTab::ORDERED + .into_iter() + .map(|tab| AppUnderlineTabSpec::new(app_shared_text(tab.text_key()))); + + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(app_text_value(app_shared_text(AppTextKey::AccountTitle))) + .child(app_surface_card( + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .child(app_underline_tabs( + "account-tabs", + tabs, + selected_tab.selected_index(), + cx.listener(|this, index: &usize, _, cx| { + this.select_account_tab(AccountTab::from_index(*index), cx) + }), + )) + .child(account_placeholder_panel(selected_tab.panel_text_key())), + )) + .into_any_element() + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FarmSetupSaveState { AutosavesLocally, @@ -8120,6 +8270,7 @@ fn home_auto_focus_target( ) -> Option<HomeAutoFocusTarget> { match home_stage(runtime) { HomeStage::Setup => startup_auto_focus_target(runtime, state), + HomeStage::AccountWorkspace => None, HomeStage::BuyerWorkspace => buyer_auto_focus_target(runtime, state), HomeStage::FarmerWorkspace => farmer_auto_focus_target(runtime, state), } @@ -8631,6 +8782,18 @@ fn buyer_workspace_title_block(title_key: AppTextKey, body_key: AppTextKey) -> i ) } +fn account_placeholder_panel(text_key: AppTextKey) -> impl IntoElement { + div() + .w_full() + .min_h(px(320.0)) + .flex() + .items_center() + .justify_center() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(text_key)) +} + fn buyer_listings_feed( section: PersonalSection, rows: &[BuyerListingRow], @@ -10682,18 +10845,20 @@ fn home_sidebar_navigation_sections( fn selected_farmer_section(runtime: &DesktopAppRuntimeSummary) -> FarmerSection { match runtime.shell_projection.selected_section { ShellSection::Farmer(section) => section, - ShellSection::Home | ShellSection::Personal(_) | ShellSection::Settings(_) => { - FarmerSection::Today - } + ShellSection::Home + | ShellSection::Account + | ShellSection::Personal(_) + | ShellSection::Settings(_) => FarmerSection::Today, } } fn selected_personal_section(runtime: &DesktopAppRuntimeSummary) -> PersonalSection { match runtime.shell_projection.selected_section { ShellSection::Personal(section) => section, - ShellSection::Home | ShellSection::Farmer(_) | ShellSection::Settings(_) => { - PersonalSection::Browse - } + ShellSection::Home + | ShellSection::Account + | ShellSection::Farmer(_) + | ShellSection::Settings(_) => PersonalSection::Browse, } } diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -27,6 +27,12 @@ define_app_text_keys! { HomeHeaderAccountSetupAction => "home.header.account_setup_action", HomeHeaderAccountLabel => "home.header.account_label", HomeHeaderGuestLabel => "home.header.guest_label", + AccountTitle => "account.title", + AccountTabProfile => "account.tab.profile", + AccountTabFarmDetails => "account.tab.farm_details", + AccountTabPreferences => "account.tab.preferences", + AccountTabSecurity => "account.tab.security", + AccountNotImplemented => "account.not_implemented", HomeNavBrowse => "home.nav.browse", HomeNavSearch => "home.nav.search", HomeNavCart => "home.nav.cart", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -156,6 +156,15 @@ mod tests { #[test] fn english_auth_copy_matches_the_local_account_workflow_contract() { + assert_eq!(app_text(AppTextKey::AccountTitle), "Account"); + assert_eq!(app_text(AppTextKey::AccountTabProfile), "Profile"); + assert_eq!(app_text(AppTextKey::AccountTabFarmDetails), "Farm details"); + assert_eq!(app_text(AppTextKey::AccountTabPreferences), "Preferences"); + assert_eq!(app_text(AppTextKey::AccountTabSecurity), "Security"); + assert_eq!( + app_text(AppTextKey::AccountNotImplemented), + "Not implemented" + ); assert_eq!( app_text(AppTextKey::HomeTodayEmptySetupBody), "Add a local account to start using Radroots on this device." diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs @@ -725,14 +725,17 @@ impl AppShellProjection { self.active_surface = active_surface; match active_surface { ActiveSurface::Personal => { - if matches!(self.selected_section, ShellSection::Farmer(_)) { + if matches!( + self.selected_section, + ShellSection::Account | ShellSection::Farmer(_) + ) { self.selected_section = ShellSection::default_for_surface(active_surface); } } ActiveSurface::Farmer => { if matches!( self.selected_section, - ShellSection::Home | ShellSection::Personal(_) + ShellSection::Home | ShellSection::Account | ShellSection::Personal(_) ) { self.selected_section = ShellSection::default_for_surface(active_surface); } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs @@ -6,16 +6,17 @@ mod theme; pub use primitives::{ AppCheckboxFieldSpec, AppFormFieldSpec, AppIconButtonSpec, AppSegmentButtonIconSpec, - LabelValueRow, app_button_account_selector_row, app_button_card, app_button_choice, - app_button_compact, app_button_icon, app_button_list_row, app_button_primary, - app_button_primary_disabled, app_button_secondary, app_button_secondary_disabled, - app_button_square_dropdown_secondary, app_button_text, app_checkbox_field, app_cluster, - app_detail_row, app_divider, app_focused_detail_view, app_focused_task_view, app_form_field, - app_form_input_text, app_form_section, app_heading_section, app_heading_view, app_input_text, - app_scroll_panel, app_segment_button_icon, app_split_shell, app_stack_h, app_stack_v, - app_status_indicator, app_surface_card, app_surface_card_section, app_surface_panel, - app_surface_sidebar, app_surface_window, app_text_badge, app_text_body, app_text_body_subtle, - app_text_label, app_text_value, label_value_list, utility_title_row, + AppUnderlineTabSpec, LabelValueRow, app_button_account_selector_row, app_button_card, + app_button_choice, app_button_compact, app_button_icon, app_button_list_row, + app_button_primary, app_button_primary_disabled, app_button_secondary, + app_button_secondary_disabled, app_button_square_dropdown_secondary, app_button_text, + app_checkbox_field, app_cluster, app_detail_row, app_divider, app_focused_detail_view, + app_focused_task_view, app_form_field, app_form_input_text, app_form_section, + app_heading_section, app_heading_view, app_input_text, app_scroll_panel, + app_segment_button_icon, app_split_shell, app_stack_h, app_stack_v, app_status_indicator, + app_surface_card, app_surface_card_section, app_surface_panel, app_surface_sidebar, + app_surface_window, app_text_badge, app_text_body, app_text_body_subtle, app_text_label, + app_text_value, app_underline_tabs, label_value_list, utility_title_row, }; pub use text::{ SettingsPreferencesGeneralRowState, app_shared_label_text, app_shared_text, diff --git a/crates/ui/src/primitives.rs b/crates/ui/src/primitives.rs @@ -8,6 +8,7 @@ use gpui_component::{ button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants, DropdownButton}, input::{Input, InputState}, menu::PopupMenu, + tab::{Tab, TabBar}, }; use std::rc::Rc; @@ -85,6 +86,18 @@ impl AppFormFieldSpec { } } +pub struct AppUnderlineTabSpec { + pub label: SharedString, +} + +impl AppUnderlineTabSpec { + pub fn new(label: impl Into<SharedString>) -> Self { + Self { + label: label.into(), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct LabelValueRow { pub label: SharedString, @@ -283,6 +296,21 @@ pub fn app_divider() -> impl IntoElement { .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)) } +pub fn app_underline_tabs( + id: &'static str, + tabs: impl IntoIterator<Item = AppUnderlineTabSpec>, + selected_index: usize, + on_click: impl Fn(&usize, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + TabBar::new(id) + .underline() + .with_size(Size::Medium) + .w_full() + .selected_index(selected_index) + .children(tabs.into_iter().map(|tab| Tab::new().label(tab.label))) + .on_click(on_click) +} + pub fn app_heading_view(content: impl Into<SharedString>) -> impl IntoElement { div() .w_full() diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -82,6 +82,7 @@ impl PersonalSection { pub enum ShellSection { #[default] Home, + Account, Personal(PersonalSection), Farmer(FarmerSection), Settings(SettingsSection), @@ -90,7 +91,7 @@ pub enum ShellSection { impl ShellSection { pub const fn surface(self) -> Option<ActiveSurface> { match self { - Self::Home | Self::Settings(_) => None, + Self::Home | Self::Account | Self::Settings(_) => None, Self::Personal(_) => Some(ActiveSurface::Personal), Self::Farmer(_) => Some(ActiveSurface::Farmer), } @@ -106,6 +107,7 @@ impl ShellSection { pub const fn storage_key(self) -> &'static str { match self { Self::Home => "home", + Self::Account => "account", Self::Personal(section) => section.storage_key(), Self::Farmer(section) => section.storage_key(), Self::Settings(section) => section.storage_key(), @@ -130,6 +132,7 @@ impl FromStr for ShellSection { fn from_str(value: &str) -> Result<Self, Self::Err> { match value { "home" => Ok(Self::Home), + "account" => Ok(Self::Account), "personal.browse" => Ok(Self::Personal(PersonalSection::Browse)), "personal.search" => Ok(Self::Personal(PersonalSection::Search)), "personal.cart" => Ok(Self::Personal(PersonalSection::Cart)), @@ -2513,6 +2516,7 @@ mod tests { ShellSection::Personal(PersonalSection::Search), ShellSection::Personal(PersonalSection::Cart), ShellSection::Personal(PersonalSection::Orders), + ShellSection::Account, ShellSection::Farmer(FarmerSection::Today), ShellSection::Farmer(FarmerSection::Products), ShellSection::Farmer(FarmerSection::Orders), @@ -2540,6 +2544,7 @@ mod tests { #[test] fn shell_section_surface_is_explicit_for_surface_routes_only() { assert_eq!(ShellSection::Home.surface(), None); + assert_eq!(ShellSection::Account.surface(), None); assert_eq!( ShellSection::Personal(PersonalSection::Browse).surface(), Some(ActiveSurface::Personal) diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -6,6 +6,12 @@ "home.header.account_setup_action": "Set up account", "home.header.account_label": "Account", "home.header.guest_label": "Guest", + "account.title": "Account", + "account.tab.profile": "Profile", + "account.tab.farm_details": "Farm details", + "account.tab.preferences": "Preferences", + "account.tab.security": "Security", + "account.not_implemented": "Not implemented", "home.nav.browse": "Browse", "home.nav.search": "Search", "home.nav.cart": "Cart",