commit f115ed4cadaf82100c3f7d70873c8cb9f86437d0
parent 1511c4f1d511492410b9f8fc45ad653cb0f01dcc
Author: triesap <tyson@radroots.org>
Date: Sun, 7 Jun 2026 16:23:39 -0700
ui: add farm details nested tabs
Diffstat:
7 files changed, 195 insertions(+), 22 deletions(-)
diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs
@@ -54,6 +54,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"account-settings-reset-relays",
"account-settings-save",
"account-settings-save-draft",
+ "account-farm-details-tabs",
"account-farm-save",
"account-farm-save-draft",
"account-farm-add-pickup-window",
@@ -383,6 +384,10 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::AccountNotImplemented",
"AppTextKey::AccountFormSaveAction",
"AppTextKey::AccountFormSaveDraftAction",
+ "AppTextKey::AccountFarmDetailsTabProfile",
+ "AppTextKey::AccountFarmDetailsTabLocation",
+ "AppTextKey::AccountFarmDetailsTabOperations",
+ "AppTextKey::AccountFarmDetailsTabFulfilment",
"AppTextKey::AccountProfilePersonalDetailsTitle",
"AppTextKey::AccountProfilePictureLabel",
"AppTextKey::AccountProfileChangePhotoAction",
diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs
@@ -30,7 +30,7 @@ use radroots_app_sync::{
SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity,
};
use radroots_app_ui::{
- APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec,
+ APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec, AppPillTabSpec,
AppSegmentButtonIconSpec as IconSegmentButtonSpec, AppUnderlineTabSpec, LabelValueRow,
SettingsPreferencesGeneralRowState, app_button_account_selector_row as account_selector_row,
app_button_card, app_button_choice as choice_button,
@@ -46,7 +46,7 @@ use radroots_app_ui::{
app_checkbox_button as action_checkbox_button, app_checkbox_field, app_cluster, app_detail_row,
app_divider as section_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 as app_text_input, app_scroll_panel,
+ app_input_text as app_text_input, app_pill_tabs, app_scroll_panel,
app_segment_button_icon as icon_segment_button, app_shared_label_text, app_shared_text,
app_split_shell, app_stack_h, app_stack_v, app_status_indicator as status_indicator,
app_surface_card, app_surface_card_section as home_card, app_surface_panel,
@@ -219,6 +219,44 @@ impl AccountTab {
}
}
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+enum AccountFarmDetailsTab {
+ #[default]
+ Profile,
+ Location,
+ Operations,
+ Fulfilment,
+}
+
+impl AccountFarmDetailsTab {
+ const ORDERED: [Self; 4] = [
+ Self::Profile,
+ Self::Location,
+ Self::Operations,
+ Self::Fulfilment,
+ ];
+
+ const fn text_key(self) -> AppTextKey {
+ match self {
+ Self::Profile => AppTextKey::AccountFarmDetailsTabProfile,
+ Self::Location => AppTextKey::AccountFarmDetailsTabLocation,
+ Self::Operations => AppTextKey::AccountFarmDetailsTabOperations,
+ Self::Fulfilment => AppTextKey::AccountFarmDetailsTabFulfilment,
+ }
+ }
+
+ 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()
+ }
+}
+
type AccountProfileSelectState = SelectState<SearchableVec<SharedString>>;
type AccountFarmProfileSelectState = SelectState<SearchableVec<SharedString>>;
@@ -847,6 +885,7 @@ pub struct HomeView {
product_editor_form: Option<ProductEditorFormState>,
focused_view: Option<HomeFocusedView>,
selected_account_tab: AccountTab,
+ selected_account_farm_details_tab: AccountFarmDetailsTab,
account_profile_form: Option<AccountProfileFormState>,
account_farm_profile_form: Option<AccountFarmProfileFormState>,
account_settings_form: Option<AccountSettingsFormState>,
@@ -972,6 +1011,7 @@ impl HomeView {
product_editor_form: None,
focused_view: None,
selected_account_tab: AccountTab::default(),
+ selected_account_farm_details_tab: AccountFarmDetailsTab::default(),
account_profile_form: None,
account_farm_profile_form: None,
account_settings_form: None,
@@ -1939,6 +1979,17 @@ impl HomeView {
}
}
+ fn select_account_farm_details_tab(
+ &mut self,
+ tab: AccountFarmDetailsTab,
+ cx: &mut Context<Self>,
+ ) {
+ if self.selected_account_farm_details_tab != tab {
+ self.selected_account_farm_details_tab = tab;
+ cx.notify();
+ }
+ }
+
fn handle_startup_signer_input_event(
&mut self,
state: &Entity<InputState>,
@@ -5542,6 +5593,7 @@ impl HomeView {
AccountTab::FarmDetails => {
self.prepare_account_farm_profile_textarea_wrap(window, cx);
let form = self.account_farm_profile_form(window, cx).clone();
+ let selected_farm_details_tab = self.selected_account_farm_details_tab;
(
AppTextKey::AccountFarmDetailsTitle,
Some(
@@ -5555,7 +5607,14 @@ impl HomeView {
),
account_farm_profile_panel(
&form,
+ selected_farm_details_tab,
self.account_farm_profile_textarea_wrap_ready,
+ cx.listener(|this, index: &usize, _, cx| {
+ this.select_account_farm_details_tab(
+ AccountFarmDetailsTab::from_index(*index),
+ cx,
+ )
+ }),
cx,
)
.into_any_element(),
@@ -9793,27 +9852,66 @@ fn account_farm_profile_select_input(
fn account_farm_profile_panel(
form: &AccountFarmProfileFormState,
+ selected_tab: AccountFarmDetailsTab,
is_textarea_wrap_ready: bool,
+ on_select_tab: impl Fn(&usize, &mut Window, &mut App) + 'static,
cx: &mut Context<HomeView>,
) -> impl IntoElement {
+ let tabs = AccountFarmDetailsTab::ORDERED
+ .into_iter()
+ .map(|tab| AppPillTabSpec::new(app_shared_text(tab.text_key())));
+
app_stack_v(APP_UI_THEME.shells.home_stack_gap_px)
.w_full()
- .child(account_farm_profile_section_row(
+ .min_h(relative(1.0))
+ .child(app_pill_tabs(
+ "account-farm-details-tabs",
+ tabs,
+ selected_tab.selected_index(),
+ on_select_tab,
+ ))
+ .child(
+ div()
+ .w_full()
+ .flex_1()
+ .min_h(relative(1.0))
+ .child(account_farm_details_tab_panel(
+ form,
+ selected_tab,
+ is_textarea_wrap_ready,
+ cx,
+ )),
+ )
+}
+
+fn account_farm_details_tab_panel(
+ form: &AccountFarmProfileFormState,
+ selected_tab: AccountFarmDetailsTab,
+ is_textarea_wrap_ready: bool,
+ cx: &mut Context<HomeView>,
+) -> AnyElement {
+ match selected_tab {
+ AccountFarmDetailsTab::Profile => account_farm_profile_section_row(
account_farm_profile_main_card(form, is_textarea_wrap_ready, cx),
account_farm_profile_summary_card(cx),
- ))
- .child(account_farm_profile_section_row(
+ )
+ .into_any_element(),
+ AccountFarmDetailsTab::Location => account_farm_profile_section_row(
account_farm_location_card(form),
account_farm_location_preview_card(),
- ))
- .child(account_farm_profile_section_row(
+ )
+ .into_any_element(),
+ AccountFarmDetailsTab::Operations => account_farm_profile_section_row(
account_farm_operating_card(form, is_textarea_wrap_ready, cx),
account_farm_profile_preview_card(cx),
- ))
- .child(account_farm_profile_section_row(
+ )
+ .into_any_element(),
+ AccountFarmDetailsTab::Fulfilment => account_farm_profile_section_row(
account_farm_fulfillment_card(form, is_textarea_wrap_ready, cx),
account_farm_customer_experience_card(),
- ))
+ )
+ .into_any_element(),
+ }
}
fn account_farm_profile_section_row(
@@ -9822,11 +9920,18 @@ fn account_farm_profile_section_row(
) -> impl IntoElement {
div()
.w_full()
+ .min_h(relative(1.0))
.flex()
.items_start()
.gap(px(APP_UI_THEME.shells.home_stack_gap_px))
- .child(div().flex_1().min_w_0().child(main))
- .child(div().w(px(336.0)).min_w(px(300.0)).child(rail))
+ .child(div().flex_1().min_w_0().min_h(relative(1.0)).child(main))
+ .child(
+ div()
+ .w(px(336.0))
+ .min_w(px(300.0))
+ .min_h(relative(1.0))
+ .child(rail),
+ )
}
fn account_farm_profile_main_card(
@@ -10358,6 +10463,7 @@ fn account_farm_customer_experience_panel(
fn account_farm_profile_card(content: impl IntoElement) -> impl IntoElement {
div()
.w_full()
+ .min_h(relative(1.0))
.border_1()
.border_color(rgb(APP_UI_THEME.foundation.surfaces.divider))
.rounded(px(APP_UI_THEME.foundation.radii.large_px))
@@ -10365,6 +10471,7 @@ fn account_farm_profile_card(content: impl IntoElement) -> impl IntoElement {
.child(
div()
.w_full()
+ .min_h(relative(1.0))
.p(px(APP_UI_THEME.shells.home_card_padding_px))
.child(content),
)
diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs
@@ -58,6 +58,10 @@ define_app_text_keys! {
AccountProfileLanguageFrenchValue => "account.profile.language.french.value",
AccountProfileLanguageSpanishValue => "account.profile.language.spanish.value",
AccountFarmDetailsTitle => "account.farm_details.title",
+ AccountFarmDetailsTabProfile => "account.farm_details.tab.profile",
+ AccountFarmDetailsTabLocation => "account.farm_details.tab.location",
+ AccountFarmDetailsTabOperations => "account.farm_details.tab.operations",
+ AccountFarmDetailsTabFulfilment => "account.farm_details.tab.fulfilment",
AccountFarmDetailsFarmProfileTitle => "account.farm_details.farm_profile.title",
AccountFarmDetailsFarmProfileIntro => "account.farm_details.farm_profile.intro",
AccountFarmDetailsFarmNameLabel => "account.farm_details.farm_name.label",
diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs
@@ -199,6 +199,22 @@ mod tests {
"Farm details"
);
assert_eq!(
+ app_text(AppTextKey::AccountFarmDetailsTabProfile),
+ "Profile"
+ );
+ assert_eq!(
+ app_text(AppTextKey::AccountFarmDetailsTabLocation),
+ "Location"
+ );
+ assert_eq!(
+ app_text(AppTextKey::AccountFarmDetailsTabOperations),
+ "Operations"
+ );
+ assert_eq!(
+ app_text(AppTextKey::AccountFarmDetailsTabFulfilment),
+ "Fulfilment"
+ );
+ assert_eq!(
app_text(AppTextKey::AccountFarmDetailsFarmProfileTitle),
"Farm profile"
);
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
@@ -5,20 +5,20 @@ mod text;
mod theme;
pub use primitives::{
- AppCheckboxFieldSpec, AppFormFieldSpec, AppIconButtonSpec, AppSegmentButtonIconSpec,
- AppUnderlineTabSpec, LabelValueRow, app_button_account_selector_row, app_button_card,
- app_button_choice, app_button_compact, app_button_ellipsis_menu, app_button_icon,
- app_button_list_row, app_button_primary, app_button_primary_compact,
+ AppCheckboxFieldSpec, AppFormFieldSpec, AppIconButtonSpec, AppPillTabSpec,
+ AppSegmentButtonIconSpec, AppUnderlineTabSpec, LabelValueRow, app_button_account_selector_row,
+ app_button_card, app_button_choice, app_button_compact, app_button_ellipsis_menu,
+ app_button_icon, app_button_list_row, app_button_primary, app_button_primary_compact,
app_button_primary_compact_disabled, app_button_primary_disabled,
app_button_primary_full_width, app_button_secondary, app_button_secondary_disabled,
app_button_secondary_full_width, app_button_square_dropdown_secondary, app_button_text,
app_checkbox_button, 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,
+ app_form_section, app_heading_section, app_heading_view, app_input_text, app_pill_tabs,
+ 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
@@ -98,6 +98,18 @@ impl AppUnderlineTabSpec {
}
}
+pub struct AppPillTabSpec {
+ pub label: SharedString,
+}
+
+impl AppPillTabSpec {
+ pub fn new(label: impl Into<SharedString>) -> Self {
+ Self {
+ label: label.into(),
+ }
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LabelValueRow {
pub label: SharedString,
@@ -267,11 +279,16 @@ pub fn app_scroll_panel(
let content: AnyElement = match content_max_width_px {
Some(content_max_width_px) => div()
.w_full()
+ .min_h(relative(1.0))
.max_w(px(content_max_width_px))
.mx_auto()
.child(content)
.into_any_element(),
- None => div().w_full().child(content).into_any_element(),
+ None => div()
+ .w_full()
+ .min_h(relative(1.0))
+ .child(content)
+ .into_any_element(),
};
div()
@@ -281,6 +298,7 @@ pub fn app_scroll_panel(
.child(
div()
.w_full()
+ .min_h(relative(1.0))
.when(content_padding_px > 0.0, |this| {
this.p(px(content_padding_px))
})
@@ -359,6 +377,25 @@ pub fn app_underline_tabs(
)
}
+pub fn app_pill_tabs(
+ id: &'static str,
+ tabs: impl IntoIterator<Item = AppPillTabSpec>,
+ selected_index: usize,
+ on_click: impl Fn(&usize, &mut Window, &mut App) + 'static,
+) -> impl IntoElement {
+ TabBar::new(id)
+ .pill()
+ .with_size(Size::Small)
+ .w_full()
+ .selected_index(selected_index)
+ .children(
+ tabs.into_iter()
+ .map(|tab| Tab::new().label(tab.label))
+ .collect::<Vec<_>>(),
+ )
+ .on_click(on_click)
+}
+
fn app_underline_tab_width_px(label: &SharedString, tab_text_px: f32) -> f32 {
(label.as_ref().chars().count() as f32 * tab_text_px * 0.56).max(36.0)
}
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -37,6 +37,10 @@
"account.profile.language.french.value": "French",
"account.profile.language.spanish.value": "Spanish",
"account.farm_details.title": "Farm details",
+ "account.farm_details.tab.profile": "Profile",
+ "account.farm_details.tab.location": "Location",
+ "account.farm_details.tab.operations": "Operations",
+ "account.farm_details.tab.fulfilment": "Fulfilment",
"account.farm_details.farm_profile.title": "Farm profile",
"account.farm_details.farm_profile.intro": "Tell us about your farm. This information helps build your public profile and connect you with customers.",
"account.farm_details.farm_name.label": "Farm name *",