commit 3e068732f42023addc84d17f346ff97ced69eba5
parent cc28645497dfb43210e3b61949048cb60bb90002
Author: triesap <tyson@radroots.org>
Date: Sun, 7 Jun 2026 12:27:11 -0700
app: add account tab surface
Diffstat:
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",