app

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

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:
Mcrates/desktop/src/source_guards.rs | 5+++++
Mcrates/desktop/src/window.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/i18n/src/keys.rs | 4++++
Mcrates/i18n/src/lib.rs | 16++++++++++++++++
Mcrates/ui/src/lib.rs | 18+++++++++---------
Mcrates/ui/src/primitives.rs | 39++++++++++++++++++++++++++++++++++++++-
Mi18n/locales/en/messages.json | 4++++
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 *",