app

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

commit 9f814f5fcdf7010b476624123c5d31da005f8bc8
parent a956d1799eada18fa0d82a6c9f01af5eca66fa3d
Author: triesap <tyson@radroots.org>
Date:   Sun,  7 Jun 2026 15:26:33 -0700

ui: move account form actions into headings

Diffstat:
Mcrates/desktop/src/source_guards.rs | 8++++----
Mcrates/desktop/src/window.rs | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/i18n/src/keys.rs | 5++---
Mcrates/i18n/src/lib.rs | 17+++++------------
Mcrates/ui/src/lib.rs | 3++-
Mcrates/ui/src/primitives.rs | 35+++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 5++---
7 files changed, 272 insertions(+), 76 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -53,7 +53,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-settings-reset-blossom", "account-settings-reset-relays", "account-settings-save", - "account-farm-continue-location", + "account-settings-save-draft", + "account-farm-save", "account-farm-save-draft", "account-farm-view-profile", "account-scroll", @@ -378,6 +379,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::AccountTabPreferences", "AppTextKey::AccountTabSettings", "AppTextKey::AccountNotImplemented", + "AppTextKey::AccountFormSaveAction", + "AppTextKey::AccountFormSaveDraftAction", "AppTextKey::AccountProfilePersonalDetailsTitle", "AppTextKey::AccountProfilePictureLabel", "AppTextKey::AccountProfileChangePhotoAction", @@ -437,8 +440,6 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::AccountFarmDetailsFarmTypeSummaryLabel", "AppTextKey::AccountFarmDetailsEstablishedSummaryLabel", "AppTextKey::AccountFarmDetailsViewFarmProfileAction", - "AppTextKey::AccountFarmDetailsSaveDraftAction", - "AppTextKey::AccountFarmDetailsContinueLocationAction", "AppTextKey::AccountFarmDetailsFarmTypeVegetableFarm", "AppTextKey::AccountFarmDetailsFarmTypeFruitOrchard", "AppTextKey::AccountFarmDetailsFarmTypeBerryFarm", @@ -477,7 +478,6 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::AccountSettingsResetBlossomServerAction", "AppTextKey::AccountSettingsBlossomConnectionHealthy", "AppTextKey::AccountSettingsBlossomUploadsAvailable", - "AppTextKey::AccountSettingsSaveChangesAction", "AppTextKey::HomeSetupBackAction", "AppTextKey::HomeSetupBrowseMarketplaceAction", "AppTextKey::HomeSetupConnectSignerAction", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -9,7 +9,7 @@ use gpui_component::{ input::InputEvent, input::InputState, menu::PopupMenuItem, - select::{SearchableVec, Select, SelectDelegate, SelectState}, + select::{SearchableVec, Select, SelectDelegate, SelectEvent, SelectState}, }; use radroots_app_i18n::{AppTextKey, app_text}; use radroots_app_remote_signer::{ @@ -36,6 +36,8 @@ use radroots_app_ui::{ app_button_card, app_button_choice as choice_button, app_button_compact as action_button_compact, app_button_ellipsis_menu as action_ellipsis_menu, app_button_list_row as list_row_button, app_button_primary as action_button_primary, + app_button_primary_compact as action_button_primary_compact, + app_button_primary_compact_disabled as action_button_primary_compact_disabled, app_button_primary_disabled as action_button_primary_disabled, app_button_primary_full_width as action_button_primary_full_width, app_button_secondary as action_button, app_button_secondary_disabled as action_button_disabled, @@ -284,7 +286,9 @@ fn account_profile_input_state( window: &mut Window, cx: &mut Context<HomeView>, ) -> Entity<InputState> { - cx.new(|cx| InputState::new(window, cx).default_value(app_text(value_key))) + let input = cx.new(|cx| InputState::new(window, cx).default_value(app_text(value_key))); + account_subscribe_input_change(&input, window, cx); + input } fn account_profile_autogrow_input_state( @@ -292,11 +296,13 @@ fn account_profile_autogrow_input_state( window: &mut Window, cx: &mut Context<HomeView>, ) -> Entity<InputState> { - cx.new(|cx| { + let input = cx.new(|cx| { InputState::new(window, cx) .auto_grow(3, 6) .default_value(app_text(value_key)) - }) + }); + account_subscribe_input_change(&input, window, cx); + input } fn account_profile_select_state( @@ -309,14 +315,16 @@ fn account_profile_select_state( .copied() .map(app_shared_text) .collect::<Vec<_>>(); - cx.new(|cx| { + let select = cx.new(|cx| { SelectState::new( SearchableVec::new(values), Some(IndexPath::default().row(0)), window, cx, ) - }) + }); + account_subscribe_profile_select_change(&select, window, cx); + select } #[derive(Clone)] @@ -397,6 +405,42 @@ impl AccountFarmProfileFormState { ), } } + + fn is_dirty(&self, cx: &App) -> bool { + account_input_is_dirty( + &self.farm_name_input, + AppTextKey::AccountFarmDetailsFarmNameValue, + cx, + ) || account_input_is_dirty( + &self.public_farm_name_input, + AppTextKey::AccountFarmDetailsPublicFarmNameValue, + cx, + ) || account_input_is_dirty( + &self.short_description_input, + AppTextKey::AccountFarmDetailsShortDescriptionValue, + cx, + ) || account_input_is_dirty( + &self.contact_email_input, + AppTextKey::AccountFarmDetailsContactEmailValue, + cx, + ) || account_input_is_dirty( + &self.public_phone_input, + AppTextKey::AccountFarmDetailsPublicPhoneValue, + cx, + ) || account_input_is_dirty( + &self.website_input, + AppTextKey::AccountFarmDetailsWebsiteValue, + cx, + ) || account_input_is_dirty( + &self.established_year_input, + AppTextKey::AccountFarmDetailsEstablishedYearValue, + cx, + ) || account_input_is_dirty( + &self.about_farm_input, + AppTextKey::AccountFarmDetailsAboutFarmValue, + cx, + ) || account_select_is_dirty(&self.farm_type_select, cx) + } } #[derive(Clone)] @@ -407,16 +451,27 @@ struct AccountSettingsFormState { impl AccountSettingsFormState { fn new(window: &mut Window, cx: &mut Context<HomeView>) -> Self { + let add_relay_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(app_text(AppTextKey::AccountSettingsAddRelayPlaceholder)) + }); + let blossom_server_input = cx.new(|cx| { + InputState::new(window, cx).default_value(ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER) + }); + account_subscribe_input_change(&add_relay_input, window, cx); + account_subscribe_input_change(&blossom_server_input, window, cx); + Self { - add_relay_input: cx.new(|cx| { - InputState::new(window, cx) - .placeholder(app_text(AppTextKey::AccountSettingsAddRelayPlaceholder)) - }), - blossom_server_input: cx.new(|cx| { - InputState::new(window, cx).default_value(ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER) - }), + add_relay_input, + blossom_server_input, } } + + fn is_dirty(&self, cx: &App) -> bool { + !self.add_relay_input.read(cx).value().trim().is_empty() + || self.blossom_server_input.read(cx).value().as_ref() + != ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER + } } fn account_farm_profile_select_state( @@ -429,14 +484,90 @@ fn account_farm_profile_select_state( .copied() .map(app_shared_text) .collect::<Vec<_>>(); - cx.new(|cx| { + let select = cx.new(|cx| { SelectState::new( SearchableVec::new(values), Some(IndexPath::default().row(0)), window, cx, ) - }) + }); + account_subscribe_farm_select_change(&select, window, cx); + select +} + +fn account_input_is_dirty( + input: &Entity<InputState>, + initial_value_key: AppTextKey, + cx: &App, +) -> bool { + input.read(cx).value().as_ref() != app_text(initial_value_key) +} + +fn account_select_is_dirty(select: &Entity<AccountProfileSelectState>, cx: &App) -> bool { + select + .read(cx) + .selected_index(cx) + .is_some_and(|index| index.row != 0) +} + +fn account_subscribe_input_change( + input: &Entity<InputState>, + window: &mut Window, + cx: &mut Context<HomeView>, +) { + cx.subscribe_in( + input, + window, + |_: &mut HomeView, _: &Entity<InputState>, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + ) + .detach(); +} + +fn account_subscribe_profile_select_change( + select: &Entity<AccountProfileSelectState>, + window: &mut Window, + cx: &mut Context<HomeView>, +) { + cx.subscribe_in( + select, + window, + |_: &mut HomeView, + _: &Entity<AccountProfileSelectState>, + event: &SelectEvent<SearchableVec<SharedString>>, + _, + cx| { + if matches!(event, SelectEvent::Confirm(_)) { + cx.notify(); + } + }, + ) + .detach(); +} + +fn account_subscribe_farm_select_change( + select: &Entity<AccountFarmProfileSelectState>, + window: &mut Window, + cx: &mut Context<HomeView>, +) { + cx.subscribe_in( + select, + window, + |_: &mut HomeView, + _: &Entity<AccountFarmProfileSelectState>, + event: &SelectEvent<SearchableVec<SharedString>>, + _, + cx| { + if matches!(event, SelectEvent::Confirm(_)) { + cx.notify(); + } + }, + ) + .detach(); } fn buyer_order_detail_focus_after_open( @@ -9172,12 +9303,66 @@ fn account_profile_panel( } fn account_section_heading(label_key: AppTextKey) -> impl IntoElement { + account_section_heading_row(label_key, None::<gpui::AnyElement>) +} + +fn account_section_heading_row( + label_key: AppTextKey, + actions: Option<impl IntoElement>, +) -> impl IntoElement { div() .w_full() - .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.5)) - .font_weight(gpui::FontWeight::BOLD) - .text_color(rgb(APP_UI_THEME.foundation.text.primary)) - .child(app_shared_text(label_key)) + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.5)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(app_shared_text(label_key)), + ) + .when_some(actions, |this, actions| { + this.child(div().flex_none().child(actions)) + }) +} + +fn account_form_heading_actions( + draft_id: &'static str, + save_id: &'static str, + save_is_active: bool, + cx: &mut Context<HomeView>, +) -> impl IntoElement { + let save_button = if save_is_active { + action_button_primary_compact( + save_id, + app_shared_text(AppTextKey::AccountFormSaveAction), + |_, _, _| {}, + cx, + ) + .into_any_element() + } else { + action_button_primary_compact_disabled( + save_id, + app_shared_text(AppTextKey::AccountFormSaveAction), + cx, + ) + .into_any_element() + }; + + div() + .flex() + .items_center() + .gap(px(APP_UI_THEME.foundation.spacing.small_px)) + .child(action_button_compact( + draft_id, + app_shared_text(AppTextKey::AccountFormSaveDraftAction), + |_, _, _| {}, + cx, + )) + .child(save_button) } fn account_profile_details_card( @@ -9404,7 +9589,15 @@ fn account_farm_profile_panel( ) -> impl IntoElement { app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .child(account_section_heading(AppTextKey::AccountFarmDetailsTitle)) + .child(account_section_heading_row( + AppTextKey::AccountFarmDetailsTitle, + Some(account_form_heading_actions( + "account-farm-save-draft", + "account-farm-save", + form.is_dirty(cx), + cx, + )), + )) .child( div() .w_full() @@ -9429,7 +9622,6 @@ fn account_farm_profile_panel( .child(account_farm_profile_summary_card(cx)), ), ) - .child(account_farm_profile_action_row(cx)) } fn account_farm_profile_main_card( @@ -9836,38 +10028,21 @@ fn account_farm_profile_summary_row( ) } -fn account_farm_profile_action_row(cx: &mut Context<HomeView>) -> impl IntoElement { - div() - .w_full() - .flex() - .items_center() - .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) - .child(div().w(px(280.0)).child(action_button_full_width( - "account-farm-save-draft", - app_shared_text(AppTextKey::AccountFarmDetailsSaveDraftAction), - |_, _, _| {}, - cx, - ))) - .child( - div() - .flex_1() - .min_w_0() - .child(action_button_primary_full_width( - "account-farm-continue-location", - app_shared_text(AppTextKey::AccountFarmDetailsContinueLocationAction), - |_, _, _| {}, - cx, - )), - ) -} - fn account_settings_panel( form: &AccountSettingsFormState, cx: &mut Context<HomeView>, ) -> impl IntoElement { app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .w_full() - .child(account_section_heading(AppTextKey::AccountSettingsTitle)) + .child(account_section_heading_row( + AppTextKey::AccountSettingsTitle, + Some(account_form_heading_actions( + "account-settings-save-draft", + "account-settings-save", + form.is_dirty(cx), + cx, + )), + )) .child( div() .w_full() @@ -9887,12 +10062,6 @@ fn account_settings_panel( .child(account_settings_blossom_server_card(form, cx)), ), ) - .child(action_button_primary_full_width( - "account-settings-save", - app_shared_text(AppTextKey::AccountSettingsSaveChangesAction), - |_, _, _| {}, - cx, - )) } fn account_settings_nostr_relays_card( diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -33,6 +33,8 @@ define_app_text_keys! { AccountTabPreferences => "account.tab.preferences", AccountTabSettings => "account.tab.settings", AccountNotImplemented => "account.not_implemented", + AccountFormSaveAction => "account.form.save.action", + AccountFormSaveDraftAction => "account.form.save_draft.action", AccountProfilePersonalDetailsTitle => "account.profile.personal_details.title", AccountProfilePictureLabel => "account.profile.picture.label", AccountProfileChangePhotoAction => "account.profile.change_photo.action", @@ -92,8 +94,6 @@ define_app_text_keys! { AccountFarmDetailsFarmTypeSummaryLabel => "account.farm_details.summary.farm_type.label", AccountFarmDetailsEstablishedSummaryLabel => "account.farm_details.summary.established.label", AccountFarmDetailsViewFarmProfileAction => "account.farm_details.view_farm_profile.action", - AccountFarmDetailsSaveDraftAction => "account.farm_details.save_draft.action", - AccountFarmDetailsContinueLocationAction => "account.farm_details.continue_location.action", AccountFarmDetailsFarmTypeVegetableFarm => "account.farm_details.farm_type.vegetable_farm", AccountFarmDetailsFarmTypeFruitOrchard => "account.farm_details.farm_type.fruit_orchard", AccountFarmDetailsFarmTypeBerryFarm => "account.farm_details.farm_type.berry_farm", @@ -132,7 +132,6 @@ define_app_text_keys! { AccountSettingsResetBlossomServerAction => "account.settings.reset_blossom_server.action", AccountSettingsBlossomConnectionHealthy => "account.settings.blossom_connection.healthy", AccountSettingsBlossomUploadsAvailable => "account.settings.blossom_uploads.available", - AccountSettingsSaveChangesAction => "account.settings.save_changes.action", 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 @@ -165,6 +165,11 @@ mod tests { app_text(AppTextKey::AccountNotImplemented), "Not implemented" ); + assert_eq!(app_text(AppTextKey::AccountFormSaveAction), "Save"); + assert_eq!( + app_text(AppTextKey::AccountFormSaveDraftAction), + "Save draft" + ); assert_eq!( app_text(AppTextKey::AccountProfilePersonalDetailsTitle), "Personal details" @@ -219,18 +224,6 @@ mod tests { "Blossom server" ); assert_eq!( - app_text(AppTextKey::AccountSettingsSaveChangesAction), - "Save changes" - ); - assert_eq!( - app_text(AppTextKey::AccountFarmDetailsSaveDraftAction), - "Save draft" - ); - assert_eq!( - app_text(AppTextKey::AccountFarmDetailsContinueLocationAction), - "Continue to location" - ); - assert_eq!( app_text(AppTextKey::HomeTodayEmptySetupBody), "Add a local account to start using Radroots on this device." ); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs @@ -8,7 +8,8 @@ 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_disabled, + 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, diff --git a/crates/ui/src/primitives.rs b/crates/ui/src/primitives.rs @@ -785,6 +785,41 @@ pub fn app_button_primary( ) } +pub fn app_button_primary_compact( + id: impl Into<ElementId>, + label: impl Into<SharedString>, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> impl IntoElement { + app_button_label( + app_button_base(id, AppButtonVariant::Primary, on_click, cx), + label.into(), + APP_UI_THEME + .components + .app_button + .sizing + .compact_horizontal_padding_px, + AppButtonVariant::Primary, + ) +} + +pub fn app_button_primary_compact_disabled( + id: impl Into<ElementId>, + label: impl Into<SharedString>, + cx: &App, +) -> impl IntoElement { + app_button_label( + app_button_base_disabled(id, AppButtonVariant::Primary, cx), + label.into(), + APP_UI_THEME + .components + .app_button + .sizing + .compact_horizontal_padding_px, + AppButtonVariant::Primary, + ) +} + pub fn app_button_primary_full_width( id: impl Into<ElementId>, label: impl Into<SharedString>, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -12,6 +12,8 @@ "account.tab.preferences": "Preferences", "account.tab.settings": "Settings", "account.not_implemented": "Not implemented", + "account.form.save.action": "Save", + "account.form.save_draft.action": "Save draft", "account.profile.personal_details.title": "Personal details", "account.profile.picture.label": "Profile picture", "account.profile.change_photo.action": "Change photo", @@ -71,8 +73,6 @@ "account.farm_details.summary.farm_type.label": "Farm type", "account.farm_details.summary.established.label": "Established", "account.farm_details.view_farm_profile.action": "View farm on profile", - "account.farm_details.save_draft.action": "Save draft", - "account.farm_details.continue_location.action": "Continue to location", "account.farm_details.farm_type.vegetable_farm": "Vegetable farm", "account.farm_details.farm_type.fruit_orchard": "Fruit farm / orchard", "account.farm_details.farm_type.berry_farm": "Berry farm", @@ -111,7 +111,6 @@ "account.settings.reset_blossom_server.action": "Reset default server", "account.settings.blossom_connection.healthy": "Connection status: Healthy", "account.settings.blossom_uploads.available": "Uploads available", - "account.settings.save_changes.action": "Save changes", "home.nav.browse": "Browse", "home.nav.search": "Search", "home.nav.cart": "Cart",