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:
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",