app

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

commit 40be6db8ff06ba14046e076c88b51c706388998e
parent c1ab0ac224b2822d7b1fe1ca564530e6370f19be
Author: triesap <tyson@radroots.org>
Date:   Sun,  7 Jun 2026 12:50:09 -0700

ui: add account profile details panel

Diffstat:
Mcrates/desktop/src/source_guards.rs | 18++++++++++++++++++
Mcrates/desktop/src/window.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/i18n/src/keys.rs | 16++++++++++++++++
Mcrates/i18n/src/lib.rs | 12++++++++++++
Mcrates/ui/src/primitives.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mi18n/locales/en/messages.json | 16++++++++++++++++
6 files changed, 309 insertions(+), 35 deletions(-)

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-profile-change-photo", + "account-profile-remove-photo", "account-scroll", "account-tabs", "account_1", @@ -360,6 +362,22 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::AccountTabPreferences", "AppTextKey::AccountTabSecurity", "AppTextKey::AccountNotImplemented", + "AppTextKey::AccountProfilePersonalDetailsTitle", + "AppTextKey::AccountProfilePictureLabel", + "AppTextKey::AccountProfileChangePhotoAction", + "AppTextKey::AccountProfileRemovePhotoAction", + "AppTextKey::AccountProfileFullNameLabel", + "AppTextKey::AccountProfileEmailLabel", + "AppTextKey::AccountProfilePhoneLabel", + "AppTextKey::AccountProfileRoleLabel", + "AppTextKey::AccountProfileTimeZoneLabel", + "AppTextKey::AccountProfileLanguageLabel", + "AppTextKey::AccountProfileFullNameValue", + "AppTextKey::AccountProfileEmailValue", + "AppTextKey::AccountProfilePhoneValue", + "AppTextKey::AccountProfileRoleValue", + "AppTextKey::AccountProfileTimeZoneValue", + "AppTextKey::AccountProfileLanguageValue", "AppTextKey::HomeSetupBackAction", "AppTextKey::HomeSetupBrowseMarketplaceAction", "AppTextKey::HomeSetupConnectSignerAction", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -2,9 +2,11 @@ use gpui::{ Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, ElementId, Entity, Image, ImageFormat, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription, Timer, Window, WindowBounds, WindowOptions, - div, img, prelude::FluentBuilder, px, relative, rgb, size, + div, img, prelude::FluentBuilder, px, relative, rgb, size, transparent_black, +}; +use gpui_component::{ + Icon, IconName, Root, Sizable, input::InputEvent, input::InputState, menu::PopupMenuItem, }; -use gpui_component::{IconName, Root, input::InputEvent, input::InputState, menu::PopupMenuItem}; use radroots_app_i18n::{AppTextKey, app_text}; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -5017,7 +5019,7 @@ impl HomeView { this.select_account_tab(AccountTab::from_index(*index), cx) }), )) - .child(account_placeholder_panel(selected_tab.panel_text_key())), + .child(account_panel(selected_tab, cx)), ) .into_any_element() } @@ -8782,6 +8784,15 @@ fn buyer_workspace_title_block(title_key: AppTextKey, body_key: AppTextKey) -> i ) } +fn account_panel(tab: AccountTab, cx: &mut Context<HomeView>) -> AnyElement { + match tab { + AccountTab::Profile => account_profile_panel(cx).into_any_element(), + AccountTab::FarmDetails | AccountTab::Preferences | AccountTab::Security => { + account_placeholder_panel(tab.panel_text_key()).into_any_element() + } + } +} + fn account_placeholder_panel(text_key: AppTextKey) -> impl IntoElement { div() .w_full() @@ -8794,6 +8805,186 @@ fn account_placeholder_panel(text_key: AppTextKey) -> impl IntoElement { .child(app_shared_text(text_key)) } +fn account_profile_panel(cx: &mut Context<HomeView>) -> impl IntoElement { + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .child( + 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( + AppTextKey::AccountProfilePersonalDetailsTitle, + )), + ) + .child(account_profile_details_card(cx)) +} + +fn account_profile_details_card(cx: &mut Context<HomeView>) -> impl IntoElement { + div() + .w_full() + .border_1() + .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) + .rounded(px(APP_UI_THEME.foundation.radii.large_px)) + .bg(transparent_black()) + .child( + div() + .w_full() + .p(px(APP_UI_THEME.shells.home_card_padding_px)) + .flex() + .items_start() + .gap(px(APP_UI_THEME.shells.home_card_padding_px)) + .child(account_profile_photo_actions(cx)) + .child( + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .flex_1() + .min_w_0() + .child(account_profile_field_row( + account_profile_field( + AppTextKey::AccountProfileFullNameLabel, + AppTextKey::AccountProfileFullNameValue, + false, + ), + account_profile_field( + AppTextKey::AccountProfileEmailLabel, + AppTextKey::AccountProfileEmailValue, + false, + ), + )) + .child(account_profile_field_row( + account_profile_field( + AppTextKey::AccountProfilePhoneLabel, + AppTextKey::AccountProfilePhoneValue, + false, + ), + account_profile_field( + AppTextKey::AccountProfileRoleLabel, + AppTextKey::AccountProfileRoleValue, + true, + ), + )) + .child(account_profile_field_row( + account_profile_field( + AppTextKey::AccountProfileTimeZoneLabel, + AppTextKey::AccountProfileTimeZoneValue, + true, + ), + account_profile_field( + AppTextKey::AccountProfileLanguageLabel, + AppTextKey::AccountProfileLanguageValue, + true, + ), + )), + ), + ) +} + +fn account_profile_photo_actions(cx: &mut Context<HomeView>) -> impl IntoElement { + app_stack_v(8.0) + .w(px(190.0)) + .min_w(px(190.0)) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(AppTextKey::AccountProfilePictureLabel)), + ) + .child( + div() + .size(px(72.0)) + .rounded(px(36.0)) + .border_1() + .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) + .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) + .flex() + .items_center() + .justify_center() + .child( + Icon::new(IconName::CircleUser) + .with_size(gpui_component::Size::Size(px(34.0))) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), + ), + ) + .child( + app_stack_h(8.0) + .child(action_button( + "account-profile-change-photo", + app_shared_text(AppTextKey::AccountProfileChangePhotoAction), + |_, _, _| {}, + cx, + )) + .child(text_button( + "account-profile-remove-photo", + app_shared_text(AppTextKey::AccountProfileRemovePhotoAction), + |_, _, _| {}, + cx, + )), + ) +} + +fn account_profile_field_row( + first: impl IntoElement, + second: impl IntoElement, +) -> impl IntoElement { + div() + .w_full() + .flex() + .items_start() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child(div().flex_1().min_w_0().child(first)) + .child(div().flex_1().min_w_0().child(second)) +} + +fn account_profile_field( + label_key: AppTextKey, + value_key: AppTextKey, + selectable: bool, +) -> impl IntoElement { + app_stack_v(6.0) + .w_full() + .child( + div() + .w_full() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(label_key)), + ) + .child( + div() + .w_full() + .min_w_0() + .h(px(38.0)) + .border_1() + .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) + .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) + .bg(transparent_black()) + .px(px(12.0)) + .flex() + .items_center() + .justify_between() + .gap(px(8.0)) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(app_shared_text(value_key)), + ) + .when(selectable, |this| { + this.child( + Icon::new(IconName::ChevronDown) + .with_size(gpui_component::Size::Size(px(16.0))) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), + ) + }), + ) +} + fn buyer_listings_feed( section: PersonalSection, rows: &[BuyerListingRow], diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -33,6 +33,22 @@ define_app_text_keys! { AccountTabPreferences => "account.tab.preferences", AccountTabSecurity => "account.tab.security", AccountNotImplemented => "account.not_implemented", + AccountProfilePersonalDetailsTitle => "account.profile.personal_details.title", + AccountProfilePictureLabel => "account.profile.picture.label", + AccountProfileChangePhotoAction => "account.profile.change_photo.action", + AccountProfileRemovePhotoAction => "account.profile.remove_photo.action", + AccountProfileFullNameLabel => "account.profile.full_name.label", + AccountProfileEmailLabel => "account.profile.email.label", + AccountProfilePhoneLabel => "account.profile.phone.label", + AccountProfileRoleLabel => "account.profile.role.label", + AccountProfileTimeZoneLabel => "account.profile.time_zone.label", + AccountProfileLanguageLabel => "account.profile.language.label", + AccountProfileFullNameValue => "account.profile.full_name.value", + AccountProfileEmailValue => "account.profile.email.value", + AccountProfilePhoneValue => "account.profile.phone.value", + AccountProfileRoleValue => "account.profile.role.value", + AccountProfileTimeZoneValue => "account.profile.time_zone.value", + AccountProfileLanguageValue => "account.profile.language.value", 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 @@ -166,6 +166,18 @@ mod tests { "Not implemented" ); assert_eq!( + app_text(AppTextKey::AccountProfilePersonalDetailsTitle), + "Personal details" + ); + assert_eq!( + app_text(AppTextKey::AccountProfileChangePhotoAction), + "Change photo" + ); + assert_eq!( + app_text(AppTextKey::AccountProfileRemovePhotoAction), + "Remove" + ); + assert_eq!( app_text(AppTextKey::HomeTodayEmptySetupBody), "Add a local account to start using Radroots on this device." ); diff --git a/crates/ui/src/primitives.rs b/crates/ui/src/primitives.rs @@ -305,41 +305,62 @@ pub fn app_underline_tabs( let tab_text_px = APP_UI_THEME.foundation.typography.body_text_px + 1.0; let active_foreground = APP_UI_THEME.components.app_button.primary_colors.background; let inactive_foreground = APP_UI_THEME.foundation.text.secondary; + let tab_gap_px = 16.0; + let tabs = tabs.into_iter().collect::<Vec<_>>(); + let tab_widths = tabs + .iter() + .map(|tab| app_underline_tab_width_px(&tab.label, tab_text_px)) + .collect::<Vec<_>>(); + let selected_width_px = tab_widths.get(selected_index).copied().unwrap_or(0.0); + let selected_offset_px = tab_widths.iter().take(selected_index).copied().sum::<f32>() + + tab_gap_px * selected_index as f32; - TabBar::new(id) - .underline() - .with_size(Size::Medium) + div() + .relative() .w_full() - .children(tabs.into_iter().enumerate().map(|(index, tab)| { - let is_selected = index == selected_index; - let foreground = if is_selected { - active_foreground - } else { - inactive_foreground - }; - - let tab_label = div() - .text_size(px(tab_text_px)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(rgb(foreground)) - .child(tab.label); - let tab = Tab::new().relative().child(tab_label); - if is_selected { - tab.suffix( - div() - .absolute() - .left_0() - .right_0() - .bottom_0() - .h(px(2.0)) - .rounded(px(1.0)) - .bg(rgb(active_foreground)), + .child( + TabBar::new(id) + .underline() + .with_size(Size::Medium) + .w_full() + .children( + tabs.into_iter() + .enumerate() + .map(|(index, tab)| { + let is_selected = index == selected_index; + let foreground = if is_selected { + active_foreground + } else { + inactive_foreground + }; + + Tab::new().child( + div() + .w(px(tab_widths.get(index).copied().unwrap_or(36.0))) + .text_size(px(tab_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(rgb(foreground)) + .child(tab.label), + ) + }) + .collect::<Vec<_>>(), ) - } else { - tab - } - })) - .on_click(on_click) + .on_click(on_click), + ) + .child( + div() + .absolute() + .left(px(selected_offset_px)) + .bottom_0() + .w(px(selected_width_px)) + .h(px(2.0)) + .rounded(px(1.0)) + .bg(rgb(active_foreground)), + ) +} + +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) } pub fn app_heading_view(content: impl Into<SharedString>) -> impl IntoElement { diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -12,6 +12,22 @@ "account.tab.preferences": "Preferences", "account.tab.security": "Security", "account.not_implemented": "Not implemented", + "account.profile.personal_details.title": "Personal details", + "account.profile.picture.label": "Profile picture", + "account.profile.change_photo.action": "Change photo", + "account.profile.remove_photo.action": "Remove", + "account.profile.full_name.label": "Full name", + "account.profile.email.label": "Email", + "account.profile.phone.label": "Phone number", + "account.profile.role.label": "Role", + "account.profile.time_zone.label": "Time zone", + "account.profile.language.label": "Language", + "account.profile.full_name.value": "Avery Field", + "account.profile.email.value": "avery@example.com", + "account.profile.phone.value": "+1 250 555 0198", + "account.profile.role.value": "Farm owner", + "account.profile.time_zone.value": "Pacific Time", + "account.profile.language.value": "English", "home.nav.browse": "Browse", "home.nav.search": "Search", "home.nav.cart": "Cart",