From 778fa99de959493e744ab242bf95b6c9abc77087 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 10:01:38 -0300 Subject: [PATCH 01/18] fix: align profile create/edit and home header with Figma Match the v62 handoff for the create and edit profile screens: header spacing and inset divider, pubky key font, section dividers, bio and link input styling, brand-coloured delete button, image-square avatar placeholder, and a gradient footer that lets the form scroll behind the Cancel/Save buttons. Names render in display caps and skip autocorrect. Count the hardware wallets row in the home activity slot budget and top-align the wallet page so extra content no longer pushes the balance up under the header. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Components/Button/IconActionButton.swift | 9 +- Bitkit/Components/ProfileEditFormView.swift | 183 +++++++----------- Bitkit/Components/ProfileNameField.swift | 38 ++++ Bitkit/Views/HomeScreen.swift | 2 +- Bitkit/Views/Profile/CreateProfileView.swift | 34 +--- Bitkit/Views/Profile/EditProfileView.swift | 6 +- .../Wallets/Activity/ActivityLatest.swift | 8 +- changelog.d/next/781.fixed.md | 1 + 8 files changed, 138 insertions(+), 143 deletions(-) create mode 100644 Bitkit/Components/ProfileNameField.swift create mode 100644 changelog.d/next/781.fixed.md diff --git a/Bitkit/Components/Button/IconActionButton.swift b/Bitkit/Components/Button/IconActionButton.swift index b2c1f1ae2..1f9f28c0d 100644 --- a/Bitkit/Components/Button/IconActionButton.swift +++ b/Bitkit/Components/Button/IconActionButton.swift @@ -5,6 +5,7 @@ struct IconActionButton: View { let icon: String let isSystemIcon: Bool let title: String + let tint: Color let accessibilityId: String let action: () -> Void @@ -12,12 +13,14 @@ struct IconActionButton: View { icon: String, isSystemIcon: Bool = false, title: String, + tint: Color = .white, accessibilityId: String, action: @escaping () -> Void ) { self.icon = icon self.isSystemIcon = isSystemIcon self.title = title + self.tint = tint self.accessibilityId = accessibilityId self.action = action } @@ -28,16 +31,16 @@ struct IconActionButton: View { if isSystemIcon { Image(systemName: icon) .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white) + .foregroundColor(tint) } else { Image(icon) .resizable() .scaledToFit() - .foregroundColor(.white) + .foregroundColor(tint) .frame(width: 14, height: 14) } - BodySSBText(title) + BodySSBText(title, textColor: tint) .lineLimit(1) } .padding(.horizontal, 16) diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index a5c99bf12..2b6b7790a 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -27,72 +27,56 @@ struct ProfileEditFormView: View { @State private var showAddTagSheet = false var body: some View { - ScrollView { - VStack(spacing: 0) { - avatar() - .padding(.top, 24) - .padding(.bottom, 16) - - SwiftUI.TextField( - t("profile__create_name_placeholder"), - text: $name - ) - .font(Fonts.black(size: 44)) - .kerning(-1) - .textCase(.uppercase) - .multilineTextAlignment(.center) - .foregroundColor(.textPrimary) - .padding(.horizontal, 16) - .padding(.bottom, 16) - .accessibilityIdentifier("ProfileEditName") + ZStack(alignment: .bottom) { + ScrollView { + VStack(spacing: 0) { + VStack(spacing: 32) { + avatar() - CustomDivider() - .padding(.bottom, 16) + ProfileNameField(name: $name, accessibilityId: "ProfileEditName") - pubkyKeySection - .padding(.bottom, 24) + CustomDivider() - VStack(alignment: .leading, spacing: 0) { - bioSection - .padding(.bottom, 16) + pubkyKeySection + } + .padding(.horizontal, 16) + .padding(.vertical, 32) - linksSection - .padding(.bottom, 16) + VStack(alignment: .leading, spacing: 16) { + CustomDivider() - if !links.isEmpty { - CustomDivider(color: .white16) - .padding(.bottom, 16) - } + bioSection - tagsSection - .padding(.bottom, 24) + CustomDivider() - if let footerNote { - CustomDivider(color: .white16) - .padding(.bottom, 16) + linksSection - footnoteSection(footerNote) - .padding(.bottom, 24) - } + CustomDivider() + + tagsSection + + if let footerNote { + CustomDivider() - if let deleteLabel, let onDelete { - CustomDivider(color: .white16) - .padding(.bottom, 16) + footnoteSection(footerNote) + } - deleteSection(label: deleteLabel, action: onDelete) - .padding(.bottom, 24) + if let deleteLabel, let onDelete { + CustomDivider() + + deleteSection(label: deleteLabel, action: onDelete) + } } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, ScreenLayout.floatingFooterClearance + 16) } - .padding(.horizontal, 16) - .padding(.bottom, 24) - } - .scrollDismissesKeyboard(.interactively) - .onTapGesture { - dismissKeyboard() - } - .safeAreaInset(edge: .bottom, spacing: 0) { + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + dismissKeyboard() + } + footerBar } .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { @@ -113,13 +97,10 @@ struct ProfileEditFormView: View { VStack(spacing: 8) { CaptionMText(publicKeyLabel, textColor: .white64) - BodySText( - publicKey, - textColor: .white - ) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .center) + BodyMSBText(publicKey, textColor: .white) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) } } @@ -132,8 +113,6 @@ struct ProfileEditFormView: View { TextField( bioPlaceholder, text: $bio, - backgroundColor: .gray6, - font: .custom(Fonts.regular, size: 17), axis: .vertical, testIdentifier: "ProfileEditBio" ) @@ -143,7 +122,7 @@ struct ProfileEditFormView: View { // MARK: - Links Section private var linksSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 16) { ForEach(links.indices, id: \.self) { index in linkRow(index: index) } @@ -164,7 +143,7 @@ struct ProfileEditFormView: View { private func linkRow(index: Int) -> some View { let link = links[index] - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 8) { CaptionMText(link.label, textColor: .white64) HStack { @@ -172,7 +151,7 @@ struct ProfileEditFormView: View { if link.url.isEmpty { SwiftUI.Text(t("profile__add_link_url_placeholder")) .foregroundColor(.white32) - .font(.custom(Fonts.regular, size: 17)) + .font(.custom(Fonts.semiBold, size: 15)) } SwiftUI.TextField( @@ -182,7 +161,7 @@ struct ProfileEditFormView: View { set: { links[index].url = $0 } ) ) - .font(.custom(Fonts.regular, size: 17)) + .font(.custom(Fonts.semiBold, size: 15)) .foregroundColor(.textPrimary) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -198,18 +177,13 @@ struct ProfileEditFormView: View { .resizable() .scaledToFit() .foregroundColor(.white50) - .frame(width: 18, height: 18) + .frame(width: 16, height: 16) } .accessibilityIdentifier("ProfileEditLinkRemove_\(index)") .accessibilityLabel(t("common__delete")) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.gray6) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.white10, lineWidth: 1) - ) + .padding(16) + .background(Color.white10) .cornerRadius(8) } } @@ -222,27 +196,13 @@ struct ProfileEditFormView: View { switch deleteActionStyle { case .buttonWithIcon: - Button(action: action) { - HStack(spacing: 8) { - Image("trash") - .resizable() - .scaledToFit() - .foregroundColor(.redAccent) - .frame(width: 16, height: 16) - - BodySSBText(label, textColor: .redAccent) - } - .padding(.horizontal, 16) - .frame(height: 40) - .background(Color.gray6) - .clipShape(Capsule()) - .overlay { - Capsule() - .stroke(Color.white10, lineWidth: 1) - } - } - .buttonStyle(.plain) - .accessibilityLabel(label) + IconActionButton( + icon: "trash", + title: label, + tint: .brandAccent, + accessibilityId: "ProfileEditDelete", + action: action + ) case .textOnly: Button(action: action) { HStack { @@ -293,23 +253,28 @@ struct ProfileEditFormView: View { } private var footerBar: some View { - BottomActionBar { - HStack(spacing: 16) { - CustomButton(title: t("common__cancel"), variant: .secondary) { - onCancel() - } - .accessibilityIdentifier("ProfileEditCancel") + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + onCancel() + } + .accessibilityIdentifier("ProfileEditCancel") - CustomButton( - title: t("common__save"), - isLoading: isSaving - ) { - await onSave() - } - .accessibilityIdentifier("ProfileEditSave") - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + CustomButton( + title: t("common__save"), + isLoading: isSaving + ) { + await onSave() } + .accessibilityIdentifier("ProfileEditSave") + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 16) + .background( + LinearGradient(colors: [.customBlack.opacity(0), .customBlack], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea(edges: .bottom) + ) } private func dismissKeyboard() { diff --git a/Bitkit/Components/ProfileNameField.swift b/Bitkit/Components/ProfileNameField.swift new file mode 100644 index 000000000..f924374f2 --- /dev/null +++ b/Bitkit/Components/ProfileNameField.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Display-caps name input. `textCase` does not apply to typed `TextField` text, so the stored name keeps +/// its original casing and an uppercased copy is drawn over the field while it is not being edited. +struct ProfileNameField: View { + @Binding var name: String + let accessibilityId: String + + @FocusState private var isFocused: Bool + + private var showsUppercasedName: Bool { + !isFocused && !name.isEmpty + } + + var body: some View { + SwiftUI.TextField(t("profile__create_name_placeholder"), text: $name) + .font(Fonts.black(size: 44)) + .kerning(-1) + .textCase(.uppercase) + .multilineTextAlignment(.center) + .foregroundColor(showsUppercasedName ? .clear : .textPrimary) + .autocorrectionDisabled() + .focused($isFocused) + .overlay { + if showsUppercasedName { + SwiftUI.Text(name.uppercased()) + .font(Fonts.black(size: 44)) + .kerning(-1) + .foregroundColor(.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.5) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .accessibilityIdentifier(accessibilityId) + } +} diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 71e05f010..c8f88e24a 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -26,7 +26,7 @@ struct HomeScreen: View { ScrollView(showsIndicators: false) { LazyVStack { HomeWalletView() - .frame(height: geometry.size.height) + .frame(height: geometry.size.height, alignment: .top) .id(0) if settings.showWidgets { diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 3081f1c7a..b4890d501 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -42,21 +42,14 @@ struct CreateProfileView: View { @ViewBuilder private var formContent: some View { ScrollView { - VStack(spacing: 0) { + VStack(spacing: 32) { avatarSection - .padding(.top, 32) - .padding(.bottom, 32) - nameInput - .padding(.bottom, 16) - CustomDivider() - .padding(.horizontal, 32) - .padding(.bottom, 16) - pubkyKeySection - .padding(.bottom, 24) } + .padding(.horizontal, 32) + .padding(.vertical, 32) } .scrollDismissesKeyboard(.interactively) .onTapGesture { @@ -99,12 +92,14 @@ struct CreateProfileView: View { .clipShape(Circle()) } else { Circle() - .fill(Color.gray5) + .fill(Color.gray6) .frame(width: 96, height: 96) .overlay { - Image(systemName: "photo") - .font(.system(size: 32, weight: .medium)) + Image("picture") + .resizable() + .scaledToFit() .foregroundColor(.white32) + .frame(width: 32, height: 32) } } } @@ -112,17 +107,7 @@ struct CreateProfileView: View { // MARK: - Name Input private var nameInput: some View { - SwiftUI.TextField( - t("profile__create_name_placeholder"), - text: $username - ) - .font(Fonts.black(size: 44)) - .kerning(-1) - .textCase(.uppercase) - .multilineTextAlignment(.center) - .foregroundColor(.textPrimary) - .padding(.horizontal, 32) - .accessibilityIdentifier("CreateProfileUsername") + ProfileNameField(name: $username, accessibilityId: "CreateProfileUsername") } // MARK: - Pubky Key Section @@ -138,7 +123,6 @@ struct CreateProfileView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 16) } } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 4876a5b39..0dfaba9b2 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -110,14 +110,14 @@ struct EditProfileView: View { PubkyImage(uri: imageUrl, size: 96) } else { Circle() - .fill(Color.gray5) + .fill(Color.gray6) .frame(width: 96, height: 96) .overlay { - Image("user-square") + Image("picture") .resizable() .scaledToFit() .foregroundColor(.white32) - .frame(width: 48, height: 48) + .frame(width: 32, height: 32) } } } diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 54e422277..d8c400ece 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -11,6 +11,7 @@ struct ActivityLatest: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var wallet: WalletViewModel + @Environment(HwWalletManager.self) private var hwWalletManager private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled @@ -40,11 +41,14 @@ struct ActivityLatest: View { return BlockTimeHelpers.getDurationForBlocks(blocksRemaining) } - /// Three or four vertical slots (by screen size) shared by: transfer banner, widgets onboarding - /// and activity items; only the item count shrinks so the total stays within the cap. + /// Three or four vertical slots (by screen size) shared by: hardware wallets row, transfer banner, + /// widgets onboarding and activity items; only the item count shrinks so the total stays within the cap. private var maxActivityItemsOnHome: Int { let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems var nonItemSlots = 0 + if !hwWalletManager.wallets.isEmpty { + nonItemSlots += 1 + } if shouldShowBanner { nonItemSlots += 1 } diff --git a/changelog.d/next/781.fixed.md b/changelog.d/next/781.fixed.md new file mode 100644 index 000000000..4c3438bcd --- /dev/null +++ b/changelog.d/next/781.fixed.md @@ -0,0 +1 @@ +Aligned the create and edit profile screens and the home header spacing with the latest designs. From 5ce4aa94647b4005c8be4216836eb7d66e7aabeb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 10:07:53 -0300 Subject: [PATCH 02/18] fix: reserve a home slot per hardware wallet row Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Wallets/Activity/ActivityLatest.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index d8c400ece..f05ae9ead 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -41,14 +41,12 @@ struct ActivityLatest: View { return BlockTimeHelpers.getDurationForBlocks(blocksRemaining) } - /// Three or four vertical slots (by screen size) shared by: hardware wallets row, transfer banner, - /// widgets onboarding and activity items; only the item count shrinks so the total stays within the cap. + /// Three or four vertical slots (by screen size) shared by: hardware wallet rows (two wallets per row), + /// transfer banner, widgets onboarding and activity items; only the item count shrinks so the total stays within the cap. private var maxActivityItemsOnHome: Int { let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems var nonItemSlots = 0 - if !hwWalletManager.wallets.isEmpty { - nonItemSlots += 1 - } + nonItemSlots += (hwWalletManager.wallets.count + 1) / 2 if shouldShowBanner { nonItemSlots += 1 } From 45fec6d674d126b5d89044db33e0fadcd3fbbb5d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 11:52:36 -0300 Subject: [PATCH 03/18] fix: remove tags section from own profile screen Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 69 +------------------------- 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index c01a97f93..7fedc4807 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -6,8 +6,6 @@ struct ProfileView: View { @EnvironmentObject var pubkyProfile: PubkyProfileManager @State private var showSignOutConfirmation = false - @State private var showAddTagSheet = false - @State private var isUpdatingTags = false @State private var isSigningOut = false var body: some View { @@ -72,23 +70,12 @@ struct ProfileView: View { CustomDivider() - VStack(alignment: .leading, spacing: 0) { - if !profile.links.isEmpty { - profileLinks(profile) - } - - profileTags(profile) - .padding(.top, 16) + if !profile.links.isEmpty { + profileLinks(profile) } - .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) } - .sheet(isPresented: $showAddTagSheet) { - AddProfileTagSheet { tag in - addTag(tag, to: profile) - } - } } // MARK: - Actions (edit, copy, share) @@ -154,58 +141,6 @@ struct ProfileView: View { } } - // MARK: - Tags - - private func profileTags(_ profile: PubkyProfile) -> some View { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("profile__create_tags_label"), textColor: .white64) - .accessibilityIdentifier("ProfileViewTagsHeader") - - WrappingHStack(spacing: 8) { - ForEach(profile.tags, id: \.self) { tag in - Tag(tag, icon: .close, onDelete: { - updateTags(profile.tags.filter { $0 != tag }, profile: profile) - }) - } - - IconActionButton( - icon: "tag", - title: t("profile__create_add_tag"), - accessibilityId: "ProfileAddTag" - ) { - showAddTagSheet = true - } - } - .disabled(isUpdatingTags) - } - } - - private func addTag(_ tag: String, to profile: PubkyProfile) { - guard !profile.tags.contains(tag) else { return } - updateTags(profile.tags + [tag], profile: profile) - } - - private func updateTags(_ tags: [String], profile: PubkyProfile) { - guard !isUpdatingTags else { return } - isUpdatingTags = true - - Task { - defer { isUpdatingTags = false } - - do { - try await pubkyProfile.saveProfile( - name: profile.name, - bio: profile.bio, - links: profile.links, - tags: tags - ) - } catch { - Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") - app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) - } - } - } - // MARK: - Loading / Empty States private var loadingContent: some View { From a4c7a7a868060766c6dc5c0bb9a244f0374e62e6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 12:08:26 -0300 Subject: [PATCH 04/18] fix: drop divider below own profile actions Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 7fedc4807..cd665b80d 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -68,8 +68,6 @@ struct ProfileView: View { profileActions .padding(.bottom, 24) - CustomDivider() - if !profile.links.isEmpty { profileLinks(profile) } From 9f572db9f29fffab3cf0921442d43a53e5a4e1bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 12:13:17 -0300 Subject: [PATCH 05/18] chore: update changelog fragment Co-Authored-By: Claude Opus 5.5 (1M context) --- changelog.d/next/781.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/781.fixed.md b/changelog.d/next/781.fixed.md index 4c3438bcd..223b364b3 100644 --- a/changelog.d/next/781.fixed.md +++ b/changelog.d/next/781.fixed.md @@ -1 +1 @@ -Aligned the create and edit profile screens and the home header spacing with the latest designs. +Aligned the profile screens and the home header spacing with the latest designs. From b63aa14c3a4353a554fd04a77e9b2aeb1efafd2d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 14:38:48 -0300 Subject: [PATCH 06/18] fix: address design review on profile screens - Letter avatar ("Y") for the home header before a profile exists - Choice screen copy from Figma and illustration placement/order - Auto-focus the name on Create Profile; Continue stays behind the keyboard - Pay Contacts layout matches the onboarding pattern - Profile header uses uniform 16pt gaps and an inset bio; copy icon sized like the others - Edit Profile: 3-line bio, Tags caption, footer hidden while typing - Small pill buttons use 13pt text and 16pt icons; suggestion sheets use them and pad the back button Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Button/GradientCircleButton.swift | 8 ++- .../Components/Button/IconActionButton.swift | 50 +++++++++---------- Bitkit/Components/CenteredProfileHeader.swift | 7 +-- Bitkit/Components/Header.swift | 20 ++------ Bitkit/Components/ProfileEditFormView.swift | 11 ++-- Bitkit/Components/ProfileNameField.swift | 6 +++ Bitkit/Components/TextField.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 6 +-- Bitkit/Views/Contacts/EditContactView.swift | 1 + Bitkit/Views/Profile/CreateProfileView.swift | 3 +- Bitkit/Views/Profile/EditProfileView.swift | 1 + .../Views/Profile/LinkSuggestionsSheet.swift | 5 +- Bitkit/Views/Profile/PayContactsView.swift | 42 +++++++++------- Bitkit/Views/Profile/ProfileView.swift | 8 +-- Bitkit/Views/Profile/PubkyChoiceView.swift | 16 +++--- .../Views/Profile/TagSuggestionsSheet.swift | 5 +- 16 files changed, 100 insertions(+), 91 deletions(-) diff --git a/Bitkit/Components/Button/GradientCircleButton.swift b/Bitkit/Components/Button/GradientCircleButton.swift index 8426f7b18..967fe0b92 100644 --- a/Bitkit/Components/Button/GradientCircleButton.swift +++ b/Bitkit/Components/Button/GradientCircleButton.swift @@ -4,12 +4,15 @@ import SwiftUI struct GradientCircleButton: View { let icon: String? let systemIcon: String? + let iconSize: CGFloat let accessibilityLabel: String let action: () -> Void - init(icon: String, accessibilityLabel: String, action: @escaping () -> Void) { + /// `iconSize` compensates for assets exported without the standard icon padding. + init(icon: String, iconSize: CGFloat = 24, accessibilityLabel: String, action: @escaping () -> Void) { self.icon = icon systemIcon = nil + self.iconSize = iconSize self.accessibilityLabel = accessibilityLabel self.action = action } @@ -17,6 +20,7 @@ struct GradientCircleButton: View { init(systemIcon: String, accessibilityLabel: String, action: @escaping () -> Void) { icon = nil self.systemIcon = systemIcon + iconSize = 24 self.accessibilityLabel = accessibilityLabel self.action = action } @@ -43,7 +47,7 @@ struct GradientCircleButton: View { .resizable() .scaledToFit() .foregroundColor(.textPrimary) - .frame(width: 24, height: 24) + .frame(width: iconSize, height: iconSize) } else if let systemIcon { Image(systemName: systemIcon) .font(.system(size: 18, weight: .medium)) diff --git a/Bitkit/Components/Button/IconActionButton.swift b/Bitkit/Components/Button/IconActionButton.swift index 1f9f28c0d..3650ab54e 100644 --- a/Bitkit/Components/Button/IconActionButton.swift +++ b/Bitkit/Components/Button/IconActionButton.swift @@ -1,8 +1,8 @@ import SwiftUI -/// A pill-shaped button with an icon and label, used for "Add Link", "Add Tag" actions. +/// A small pill button with an optional icon, used for "Add Link", "Add Tag" and suggestion pills. struct IconActionButton: View { - let icon: String + let icon: String? let isSystemIcon: Bool let title: String let tint: Color @@ -10,7 +10,7 @@ struct IconActionButton: View { let action: () -> Void init( - icon: String, + icon: String? = nil, isSystemIcon: Bool = false, title: String, tint: Color = .white, @@ -28,38 +28,36 @@ struct IconActionButton: View { var body: some View { Button(action: action) { HStack(spacing: 8) { - if isSystemIcon { - Image(systemName: icon) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(tint) - } else { - Image(icon) - .resizable() - .scaledToFit() - .foregroundColor(tint) - .frame(width: 14, height: 14) + if let icon { + iconImage(icon) + .frame(width: 16, height: 16) } - BodySSBText(title, textColor: tint) + CaptionBText(title, textColor: tint) .lineLimit(1) } .padding(.horizontal, 16) .frame(height: 40) - .background( - LinearGradient( - colors: [Color(hex: 0x2A2A2A), Color(hex: 0x1C1C1C)], - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 64) - .stroke(Color.white10, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.32), radius: 2, x: 0, y: 2) + .background(ButtonGradient()) .cornerRadius(64) + .shadow(color: .white10, radius: 0, x: 0, y: -1) + .shadow(color: .black.opacity(0.32), radius: 2, x: 0, y: 2) } .buttonStyle(.plain) .accessibilityIdentifier(accessibilityId) } + + @ViewBuilder + private func iconImage(_ icon: String) -> some View { + if isSystemIcon { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(tint) + } else { + Image(icon) + .resizable() + .scaledToFit() + .foregroundColor(tint) + } + } } diff --git a/Bitkit/Components/CenteredProfileHeader.swift b/Bitkit/Components/CenteredProfileHeader.swift index 41cab676d..5082efde4 100644 --- a/Bitkit/Components/CenteredProfileHeader.swift +++ b/Bitkit/Components/CenteredProfileHeader.swift @@ -13,12 +13,10 @@ struct CenteredProfileHeader: View { var notesAccessibilityIdentifier: String? var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { CaptionMText(truncatedKey, textColor: .white64) - .padding(.bottom, 16) avatarView - .padding(.bottom, 16) Text(name.uppercased()) .font(Fonts.black(size: 44)) @@ -27,7 +25,6 @@ struct CenteredProfileHeader: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, showBio && !bio.isEmpty ? 8 : 0) .accessibilityIdentifierIfPresent(nameAccessibilityIdentifier) if showBio, !bio.isEmpty { @@ -35,7 +32,7 @@ struct CenteredProfileHeader: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 16) + .padding(.horizontal, 16) .accessibilityIdentifierIfPresent(notesAccessibilityIdentifier) } diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index d8106aa55..e69b9a8d0 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -153,21 +153,11 @@ struct Header: View { return true } - @ViewBuilder private var profileAvatar: some View { - if let imageUri = pubkyProfile.displayImageUri { - PubkyImage(uri: imageUri, size: 32) - } else { - Circle() - .fill(Color.gray4) - .frame(width: 32, height: 32) - .overlay { - Image("user-square") - .resizable() - .scaledToFit() - .foregroundColor(.white32) - .frame(width: 16, height: 16) - } - } + PubkyContactAvatar( + name: pubkyProfile.displayName ?? t("slashtags__your_name_capital"), + imageUrl: pubkyProfile.displayImageUri, + size: 32 + ) } } diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index 2b6b7790a..267268d3c 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -23,6 +23,8 @@ struct ProfileEditFormView: View { let onDelete: (() -> Void)? @ViewBuilder let avatar: () -> Avatar + @Environment(KeyboardManager.self) private var keyboard + @State private var showAddLinkSheet = false @State private var showAddTagSheet = false @@ -77,7 +79,9 @@ struct ProfileEditFormView: View { dismissKeyboard() } - footerBar + if !keyboard.isPresented { + footerBar + } } .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { AddLinkSheet { label, url in @@ -116,6 +120,7 @@ struct ProfileEditFormView: View { axis: .vertical, testIdentifier: "ProfileEditBio" ) + .lineLimit(3, reservesSpace: true) } } @@ -229,9 +234,9 @@ struct ProfileEditFormView: View { private var tagsSection: some View { VStack(alignment: .leading, spacing: 8) { - if !tags.isEmpty { - CaptionMText(t("profile__create_tags_label"), textColor: .white64) + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + if !tags.isEmpty { WrappingHStack(spacing: 8) { ForEach(tags, id: \.self) { tag in Tag(tag, icon: .close, onDelete: { diff --git a/Bitkit/Components/ProfileNameField.swift b/Bitkit/Components/ProfileNameField.swift index f924374f2..7463b70ab 100644 --- a/Bitkit/Components/ProfileNameField.swift +++ b/Bitkit/Components/ProfileNameField.swift @@ -5,6 +5,7 @@ import SwiftUI struct ProfileNameField: View { @Binding var name: String let accessibilityId: String + var focusesWhenEmpty = false @FocusState private var isFocused: Bool @@ -34,5 +35,10 @@ struct ProfileNameField: View { } } .accessibilityIdentifier(accessibilityId) + .task { + if focusesWhenEmpty, name.isEmpty { + isFocused = true + } + } } } diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift index 20c1dc49f..cee07afa7 100644 --- a/Bitkit/Components/TextField.swift +++ b/Bitkit/Components/TextField.swift @@ -31,7 +31,7 @@ struct TextField: View { } var body: some View { - ZStack(alignment: .leading) { + ZStack(alignment: .topLeading) { if text.isEmpty { Text(placeholder) .foregroundColor(.secondary) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index da850afd4..f17e2ee2d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1192,9 +1192,9 @@ "profile__sign_out_description" = "This will disconnect your Pubky profile from Bitkit. You can reconnect at any time."; "profile__ring_waiting" = "Waiting for authorization from Pubky Ring…"; "profile__ring_loading" = "Loading your profile…"; -"profile__choice_title" = "Join the\npubky web"; -"profile__choice_description" = "Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring."; -"profile__choice_create" = "Create profile with Bitkit"; +"profile__choice_title" = "Enter the\nfreedom web"; +"profile__choice_description" = "Create a new pubky and profile in Bitkit."; +"profile__choice_create" = "Create profile"; "profile__choice_import" = "Import with Pubky Ring"; "profile__deriving_keys" = "Deriving your keys…"; "profile__create_nav_title" = "Create Profile"; diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 1a51795a5..1905547fa 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -182,6 +182,7 @@ struct EditContactView: View { .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environment(KeyboardManager()) .environmentObject(PubkyProfileManager()) } .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index b4890d501..f08e7605c 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -30,6 +30,7 @@ struct CreateProfileView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .bottomSafeAreaPadding() + .ignoresSafeArea(.keyboard, edges: .bottom) .background(Color.customBlack) .navigationBarHidden(true) .task { @@ -107,7 +108,7 @@ struct CreateProfileView: View { // MARK: - Name Input private var nameInput: some View { - ProfileNameField(name: $username, accessibilityId: "CreateProfileUsername") + ProfileNameField(name: $username, accessibilityId: "CreateProfileUsername", focusesWhenEmpty: true) } // MARK: - Pubky Key Section diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 0dfaba9b2..1570321ae 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -227,6 +227,7 @@ struct EditProfileView: View { .environmentObject(NavigationViewModel()) .environmentObject(PubkyProfileManager()) .environmentObject(ContactsManager()) + .environment(KeyboardManager()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Profile/LinkSuggestionsSheet.swift b/Bitkit/Views/Profile/LinkSuggestionsSheet.swift index f18805799..b5c46e993 100644 --- a/Bitkit/Views/Profile/LinkSuggestionsSheet.swift +++ b/Bitkit/Views/Profile/LinkSuggestionsSheet.swift @@ -16,13 +16,14 @@ struct LinkSuggestionsSheet: View { var body: some View { VStack(spacing: 0) { SheetHeader(title: t("profile__suggestions_title"), showBackButton: true) + .padding(.horizontal, 16) WrappingHStack(spacing: 8) { ForEach(suggestions, id: \.self) { suggestion in - Tag(suggestion, onPress: { + IconActionButton(title: suggestion, accessibilityId: "Tag-\(suggestion)") { onSelect(suggestion) dismiss() - }) + } } } .padding(.horizontal, 16) diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 6692d9ef9..1d617b462 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -14,36 +14,40 @@ struct PayContactsView: View { NavigationBar(title: t("profile__pay_contacts_nav_title")) .padding(.horizontal, 16) - Spacer() + VStack(spacing: 0) { + VStack { + Spacer() - Image("coin-stack") - .resizable() - .scaledToFit() - .frame(width: 279) - .padding(.bottom, 32) - - VStack(alignment: .leading, spacing: 8) { - DisplayText( - t("profile__pay_contacts_title"), - accentColor: .pubkyGreen - ) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + Image("coin-stack") + .resizable() + .scaledToFit() + .frame(width: 279) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) .padding(.bottom, 16) - BodyMText(t("profile__pay_contacts_description"), textColor: .white64) + VStack(alignment: .leading, spacing: 8) { + DisplayText( + t("profile__pay_contacts_title"), + accentColor: .pubkyGreen + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 32) - Spacer() + BodyMText(t("profile__pay_contacts_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + } + .padding(.horizontal, 16) CustomButton(title: t("common__continue"), isLoading: isSaving) { await continueFlow() } .accessibilityIdentifier("PayContactsContinue") - .padding(.top, 16) + .padding(.top, 32) .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index cd665b80d..0d8fe3afd 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -58,12 +58,12 @@ struct ProfileView: View { nameAccessibilityIdentifier: "ProfileViewName", notesAccessibilityIdentifier: "ProfileViewNotes" ) - .padding(.top, 24) - .padding(.bottom, 24) + .padding(.top, 32) + .padding(.bottom, 16) profileQRCode(profile) .frame(width: 279) - .padding(.bottom, 24) + .padding(.bottom, 16) profileActions .padding(.bottom, 24) @@ -85,7 +85,7 @@ struct ProfileView: View { } .accessibilityIdentifier("ProfileEdit") - GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) { + GradientCircleButton(icon: "copy", iconSize: 18, accessibilityLabel: t("common__copy")) { if let pk = pubkyProfile.publicKey { UIPasteboard.general.string = pk app.toast(type: .success, title: t("common__copied"), accessibilityIdentifier: "ProfilePubkyCopiedToast") diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 026b37af4..cc28a52e4 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -238,23 +238,23 @@ struct PubkyChoiceView: View { private var backgroundIllustrations: some View { GeometryReader { geo in - Image("tag-pubky") + Image("keyring") .resizable() .scaledToFit() .frame(width: geo.size.width * 0.83) + .opacity(0.9) .position( - x: geo.size.width * 0.321, - y: geo.size.height * 0.376 + 200 + x: geo.size.width * 0.756, + y: geo.size.height * 0.753 ) - Image("keyring") + Image("tag-pubky") .resizable() .scaledToFit() - .frame(width: geo.size.width * 0.83) - .opacity(0.9) + .frame(width: geo.size.width * 0.736) .position( - x: geo.size.width * 0.841, - y: geo.size.height * 0.305 + 200 + x: geo.size.width * 0.125, + y: geo.size.height * 0.839 ) } .ignoresSafeArea() diff --git a/Bitkit/Views/Profile/TagSuggestionsSheet.swift b/Bitkit/Views/Profile/TagSuggestionsSheet.swift index 700c28399..f64926249 100644 --- a/Bitkit/Views/Profile/TagSuggestionsSheet.swift +++ b/Bitkit/Views/Profile/TagSuggestionsSheet.swift @@ -14,13 +14,14 @@ struct TagSuggestionsSheet: View { var body: some View { VStack(spacing: 0) { SheetHeader(title: t("profile__suggestions_title"), showBackButton: true) + .padding(.horizontal, 16) WrappingHStack(spacing: 8) { ForEach(suggestions, id: \.self) { suggestion in - Tag(suggestion, onPress: { + IconActionButton(title: suggestion, accessibilityId: "Tag-\(suggestion)") { onSelect(suggestion) dismiss() - }) + } } } .padding(.horizontal, 16) From 4c5c53f84a706d2ca3ce5ac158a15d4f4106ad9c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 15:11:58 -0300 Subject: [PATCH 07/18] fix: align contact screens with Figma - Copy icon on Contact Detail matches edit and share - No divider under the bio on Add Contact - Edit Contact labels the notes field "Notes" Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/ProfileEditFormView.swift | 3 ++- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 1 + Bitkit/Views/Contacts/AddContactView.swift | 3 ++- Bitkit/Views/Contacts/ContactDetailView.swift | 2 +- Bitkit/Views/Contacts/EditContactView.swift | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index 267268d3c..6c590c290 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -13,6 +13,7 @@ struct ProfileEditFormView: View { let publicKey: String let publicKeyLabel: String + var bioLabel: String = t("profile__create_bio_label") let bioPlaceholder: String let isSaving: Bool let footerNote: String? @@ -112,7 +113,7 @@ struct ProfileEditFormView: View { private var bioSection: some View { VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("profile__create_bio_label"), textColor: .white64) + CaptionMText(bioLabel, textColor: .white64) TextField( bioPlaceholder, diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index f17e2ee2d..3d61af8f9 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1132,6 +1132,7 @@ "contacts__delete_label" = "Delete Contact"; "contacts__saved_title" = "Contact Saved"; "contacts__edit_title" = "Edit Contact"; +"contacts__edit_notes_label" = "NOTES"; "contacts__edit_bio_placeholder" = "Short note about this contact."; "contacts__edit_public_note" = "Please note contact information is stored in public files. Changes you make to a contact in Bitkit will not update their profile."; "contacts__edit_saved" = "Contact updated"; diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 07eca48ee..0abe85ead 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -122,7 +122,8 @@ struct AddContactView: View { truncatedKey: profile.truncatedPublicKey, name: profile.name, bio: profile.bio, - imageUrl: profile.imageUrl + imageUrl: profile.imageUrl, + showDivider: false ) .padding(.top, 24) } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 5f29609a7..c22a6244c 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -133,7 +133,7 @@ struct ContactDetailView: View { } .accessibilityIdentifier("ContactActivity") - GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) { + GradientCircleButton(icon: "copy", iconSize: 18, accessibilityLabel: t("common__copy")) { UIPasteboard.general.string = publicKey app.toast(type: .success, title: t("common__copied")) } diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 1905547fa..de7ba80c0 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -31,6 +31,7 @@ struct EditContactView: View { tags: $tags, publicKey: publicKey, publicKeyLabel: t("profile__create_pubky_label"), + bioLabel: t("contacts__edit_notes_label"), bioPlaceholder: t("contacts__edit_bio_placeholder"), isSaving: isSaving, footerNote: t("contacts__edit_public_note"), From f0e00c056865368c947811ed611a3c01a397c8af Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:26:06 -0300 Subject: [PATCH 08/18] fix: match profile action icons and circle buttons to Figma Adds the 24pt Phosphor regular pencil, copy and share icons from Figma for the profile actions and replaces the circle button's full outline with the top highlight used by the other buttons. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../icons/copy-simple.imageset/Contents.json | 16 ++++++++++++++++ .../icons/copy-simple.imageset/copy-simple.svg | 7 +++++++ .../icons/pencil-regular.imageset/Contents.json | 16 ++++++++++++++++ .../pencil-regular.imageset/pencil-regular.svg | 8 ++++++++ .../icons/share-regular.imageset/Contents.json | 16 ++++++++++++++++ .../share-regular.imageset/share-regular.svg | 7 +++++++ .../Components/Button/GradientCircleButton.swift | 6 +----- Bitkit/Views/Profile/ProfileView.swift | 6 +++--- 8 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/copy-simple.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/copy-simple.imageset/copy-simple.svg create mode 100644 Bitkit/Assets.xcassets/icons/pencil-regular.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/pencil-regular.imageset/pencil-regular.svg create mode 100644 Bitkit/Assets.xcassets/icons/share-regular.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/share-regular.imageset/share-regular.svg diff --git a/Bitkit/Assets.xcassets/icons/copy-simple.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/copy-simple.imageset/Contents.json new file mode 100644 index 000000000..4395654f2 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/copy-simple.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "copy-simple.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/copy-simple.imageset/copy-simple.svg b/Bitkit/Assets.xcassets/icons/copy-simple.imageset/copy-simple.svg new file mode 100644 index 000000000..8306ec1c3 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/copy-simple.imageset/copy-simple.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/Contents.json new file mode 100644 index 000000000..27dce12d3 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "pencil-regular.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/pencil-regular.svg b/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/pencil-regular.svg new file mode 100644 index 000000000..1d891cb2d --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/pencil-regular.imageset/pencil-regular.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Bitkit/Assets.xcassets/icons/share-regular.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/share-regular.imageset/Contents.json new file mode 100644 index 000000000..3abcf2259 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/share-regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "share-regular.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/share-regular.imageset/share-regular.svg b/Bitkit/Assets.xcassets/icons/share-regular.imageset/share-regular.svg new file mode 100644 index 000000000..e12fd79af --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/share-regular.imageset/share-regular.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Bitkit/Components/Button/GradientCircleButton.swift b/Bitkit/Components/Button/GradientCircleButton.swift index 967fe0b92..56578537f 100644 --- a/Bitkit/Components/Button/GradientCircleButton.swift +++ b/Bitkit/Components/Button/GradientCircleButton.swift @@ -36,11 +36,7 @@ struct GradientCircleButton: View { endPoint: .bottom ) ) - .overlay( - Circle() - .stroke(Color.white10, lineWidth: 1) - .padding(0.5) - ) + .shadow(color: .white10, radius: 0, x: 0, y: -1) if let icon { Image(icon) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 0d8fe3afd..a1919b0e8 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -80,12 +80,12 @@ struct ProfileView: View { private var profileActions: some View { HStack(spacing: 16) { - GradientCircleButton(icon: "pencil", accessibilityLabel: t("profile__edit")) { + GradientCircleButton(icon: "pencil-regular", accessibilityLabel: t("profile__edit")) { navigation.navigate(.editProfile) } .accessibilityIdentifier("ProfileEdit") - GradientCircleButton(icon: "copy", iconSize: 18, accessibilityLabel: t("common__copy")) { + GradientCircleButton(icon: "copy-simple", accessibilityLabel: t("common__copy")) { if let pk = pubkyProfile.publicKey { UIPasteboard.general.string = pk app.toast(type: .success, title: t("common__copied"), accessibilityIdentifier: "ProfilePubkyCopiedToast") @@ -93,7 +93,7 @@ struct ProfileView: View { } .accessibilityIdentifier("ProfileCopy") - GradientCircleButton(icon: "share", accessibilityLabel: t("common__share")) { + GradientCircleButton(icon: "share-regular", accessibilityLabel: t("common__share")) { shareProfile() } .accessibilityIdentifier("ProfileShare") From 5dc5f45f77baa26f3c26758c3f9df33e3b08e873 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:38:16 -0300 Subject: [PATCH 09/18] fix: trim own profile to Figma and allow bio line breaks - Own profile shows only name, avatar, bio, QR and actions, per the Profile + QR design notes; links stay on contact details and Edit Profile - QR uses the Figma side padding instead of a fixed 279pt width - Multi-line text fields no longer set a submit label, so Return inserts a line break Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/TextField.swift | 19 ++++++++++++++----- Bitkit/Views/Profile/ProfileView.swift | 16 +--------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift index cee07afa7..60b2af999 100644 --- a/Bitkit/Components/TextField.swift +++ b/Bitkit/Components/TextField.swift @@ -38,16 +38,25 @@ struct TextField: View { .font(font) } - SwiftUI.TextField("", text: $text, axis: axis) - .accentColor(.brandAccent) - .font(font) - .submitLabel(submitLabel) - .accessibilityIdentifierIfPresent(testIdentifier) + if axis == .vertical { + // A submit label turns Return into submit, so multi-line fields skip it to keep line breaks + inputField + } else { + inputField + .submitLabel(submitLabel) + } } .padding(contentPadding) .background(backgroundColor) .cornerRadius(8) } + + private var inputField: some View { + SwiftUI.TextField("", text: $text, axis: axis) + .accentColor(.brandAccent) + .font(font) + .accessibilityIdentifierIfPresent(testIdentifier) + } } #Preview { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index a1919b0e8..c2c57f22b 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -62,15 +62,11 @@ struct ProfileView: View { .padding(.bottom, 16) profileQRCode(profile) - .frame(width: 279) + .padding(.horizontal, 32) .padding(.bottom, 16) profileActions .padding(.bottom, 24) - - if !profile.links.isEmpty { - profileLinks(profile) - } } .padding(.horizontal, 16) } @@ -129,16 +125,6 @@ struct ProfileView: View { .accessibilityIdentifier("ProfileQRCode") } - // MARK: - Links / Metadata - - private func profileLinks(_ profile: PubkyProfile) -> some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in - ProfileLinkRow(label: link.label, value: link.url, linkIndex: index) - } - } - } - // MARK: - Loading / Empty States private var loadingContent: some View { From 50be92735ec986baa7319fdefd6f2fb98b6cf19c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:51:03 -0300 Subject: [PATCH 10/18] fix: copy pubky when tapping the profile QR The QR view's own tap gesture swallowed the wrapping button's tap, so nothing was copied and no toast appeared. Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index c2c57f22b..860ac4258 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -98,14 +98,21 @@ struct ProfileView: View { // MARK: - QR Code + private func copyPublicKey(_ publicKey: String) { + UIPasteboard.general.string = publicKey + app.toast(type: .success, title: t("common__copied")) + } + private func profileQRCode(_ profile: PubkyProfile) -> some View { Button { - UIPasteboard.general.string = profile.publicKey - app.toast(type: .success, title: t("common__copied")) + copyPublicKey(profile.publicKey) } label: { VStack(spacing: 12) { ZStack { - QR(content: profile.publicKey) + // QR attaches its own tap gesture, which swallows the button tap unless it gets the action too + QR(content: profile.publicKey) { + copyPublicKey(profile.publicKey) + } if let imageUrl = profile.imageUrl { ZStack { From 02bda71d8fd77967ec8849b638da75e11d93ceab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 08:55:01 -0300 Subject: [PATCH 11/18] fix: use Figma action icons on contact details Adds 24pt coins, activity and trash icons alongside the pencil, copy and share ones so the contact actions match Figma, and drops the per-icon size override the old copy asset needed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../activity-regular.imageset/Contents.json | 16 ++++++++++++++++ .../activity-regular.svg | 5 +++++ .../icons/coins-regular.imageset/Contents.json | 16 ++++++++++++++++ .../coins-regular.imageset/coins-regular.svg | 1 + .../icons/trash-regular.imageset/Contents.json | 16 ++++++++++++++++ .../trash-regular.imageset/trash-regular.svg | 1 + .../Components/Button/GradientCircleButton.swift | 8 ++------ Bitkit/Views/Contacts/ContactDetailView.swift | 10 +++++----- 8 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/activity-regular.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/activity-regular.imageset/activity-regular.svg create mode 100644 Bitkit/Assets.xcassets/icons/coins-regular.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/coins-regular.imageset/coins-regular.svg create mode 100644 Bitkit/Assets.xcassets/icons/trash-regular.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/trash-regular.imageset/trash-regular.svg diff --git a/Bitkit/Assets.xcassets/icons/activity-regular.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/activity-regular.imageset/Contents.json new file mode 100644 index 000000000..f8f76d993 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/activity-regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "activity-regular.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/activity-regular.imageset/activity-regular.svg b/Bitkit/Assets.xcassets/icons/activity-regular.imageset/activity-regular.svg new file mode 100644 index 000000000..349ac4eff --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/activity-regular.imageset/activity-regular.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Bitkit/Assets.xcassets/icons/coins-regular.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/coins-regular.imageset/Contents.json new file mode 100644 index 000000000..536f75475 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/coins-regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "coins-regular.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/coins-regular.imageset/coins-regular.svg b/Bitkit/Assets.xcassets/icons/coins-regular.imageset/coins-regular.svg new file mode 100644 index 000000000..b22582779 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/coins-regular.imageset/coins-regular.svg @@ -0,0 +1 @@ + diff --git a/Bitkit/Assets.xcassets/icons/trash-regular.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/trash-regular.imageset/Contents.json new file mode 100644 index 000000000..740d72fa5 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/trash-regular.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "trash-regular.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/Bitkit/Assets.xcassets/icons/trash-regular.imageset/trash-regular.svg b/Bitkit/Assets.xcassets/icons/trash-regular.imageset/trash-regular.svg new file mode 100644 index 000000000..4fc5773cd --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/trash-regular.imageset/trash-regular.svg @@ -0,0 +1 @@ + diff --git a/Bitkit/Components/Button/GradientCircleButton.swift b/Bitkit/Components/Button/GradientCircleButton.swift index 56578537f..67180e31c 100644 --- a/Bitkit/Components/Button/GradientCircleButton.swift +++ b/Bitkit/Components/Button/GradientCircleButton.swift @@ -4,15 +4,12 @@ import SwiftUI struct GradientCircleButton: View { let icon: String? let systemIcon: String? - let iconSize: CGFloat let accessibilityLabel: String let action: () -> Void - /// `iconSize` compensates for assets exported without the standard icon padding. - init(icon: String, iconSize: CGFloat = 24, accessibilityLabel: String, action: @escaping () -> Void) { + init(icon: String, accessibilityLabel: String, action: @escaping () -> Void) { self.icon = icon systemIcon = nil - self.iconSize = iconSize self.accessibilityLabel = accessibilityLabel self.action = action } @@ -20,7 +17,6 @@ struct GradientCircleButton: View { init(systemIcon: String, accessibilityLabel: String, action: @escaping () -> Void) { icon = nil self.systemIcon = systemIcon - iconSize = 24 self.accessibilityLabel = accessibilityLabel self.action = action } @@ -43,7 +39,7 @@ struct GradientCircleButton: View { .resizable() .scaledToFit() .foregroundColor(.textPrimary) - .frame(width: iconSize, height: iconSize) + .frame(width: 24, height: 24) } else if let systemIcon { Image(systemName: systemIcon) .font(.system(size: 18, weight: .medium)) diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index c22a6244c..68341492f 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -114,7 +114,7 @@ struct ContactDetailView: View { private var contactActions: some View { HStack(spacing: 16) { - GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { + GradientCircleButton(icon: "coins-regular", accessibilityLabel: t("wallet__send")) { if canRequestPayment { sheets.showSheet( .receive, @@ -128,24 +128,24 @@ struct ContactDetailView: View { } .accessibilityIdentifier("ContactPay") - GradientCircleButton(icon: "activity", accessibilityLabel: t("wallet__activity")) { + GradientCircleButton(icon: "activity-regular", accessibilityLabel: t("wallet__activity")) { navigation.navigate(.contactActivity(publicKey: publicKey)) } .accessibilityIdentifier("ContactActivity") - GradientCircleButton(icon: "copy", iconSize: 18, accessibilityLabel: t("common__copy")) { + GradientCircleButton(icon: "copy-simple", accessibilityLabel: t("common__copy")) { UIPasteboard.general.string = publicKey app.toast(type: .success, title: t("common__copied")) } .accessibilityIdentifier("ContactCopy") - GradientCircleButton(icon: "share", accessibilityLabel: t("common__share")) { + GradientCircleButton(icon: "share-regular", accessibilityLabel: t("common__share")) { shareContact() } .accessibilityIdentifier("ContactShare") GradientCircleButton( - icon: showsDeleteAction ? "trash" : "pencil", + icon: showsDeleteAction ? "trash-regular" : "pencil-regular", accessibilityLabel: t(showsDeleteAction ? "common__delete" : "common__edit") ) { if showsDeleteAction { From 947c135909192c3fb17ad3bf57f19c7521020cc4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:12:04 -0300 Subject: [PATCH 12/18] fix: tappable contact links, tags layout and multi-line bio - Contact link rows open web addresses, emails and phone numbers; plain text values stay as text - Add Tag sits below the existing tags on contact details - Bio uses NoteTextEditor with line breaks enabled; a vertical TextField ended editing on Return instead of inserting a new line Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/NoteTextEditor.swift | 5 +- Bitkit/Components/ProfileEditFormView.swift | 25 +++++--- Bitkit/Components/TextField.swift | 21 ++----- .../TextEditor+DismissOnReturn.swift | 7 ++- Bitkit/Views/Contacts/ContactDetailView.swift | 16 +++--- Bitkit/Views/Profile/ProfileView.swift | 57 ++++++++++++++++--- BitkitTests/ProfileLinkRowTests.swift | 26 +++++++++ 7 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 BitkitTests/ProfileLinkRowTests.swift diff --git a/Bitkit/Components/NoteTextEditor.swift b/Bitkit/Components/NoteTextEditor.swift index c12dccda3..f03cfd85a 100644 --- a/Bitkit/Components/NoteTextEditor.swift +++ b/Bitkit/Components/NoteTextEditor.swift @@ -8,6 +8,7 @@ struct NoteTextEditor: View { var minHeight: CGFloat = 30 var maxHeight: CGFloat = 50 var backgroundColor: Color = .white06 + var allowsLineBreaks = false var body: some View { ZStack(alignment: .topLeading) { @@ -20,11 +21,11 @@ struct NoteTextEditor: View { .font(.custom(Fonts.semiBold, size: 15)) .foregroundColor(.textPrimary) .accentColor(.brandAccent) - .submitLabel(.done) + .submitLabel(allowsLineBreaks ? .return : .done) .scrollContentBackground(.hidden) .padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5)) .frame(minHeight: minHeight, maxHeight: maxHeight) - .dismissKeyboardOnReturn(text: $text, isFocused: isFocused) + .dismissKeyboardOnReturn(text: $text, isFocused: isFocused, isEnabled: !allowsLineBreaks) .accessibilityValue(text) .accessibilityIdentifier(testIdentifier) } diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index 6c590c290..7bd29cf91 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -27,6 +27,7 @@ struct ProfileEditFormView: View { @Environment(KeyboardManager.self) private var keyboard @State private var showAddLinkSheet = false + @FocusState private var isBioFocused: Bool @State private var showAddTagSheet = false var body: some View { @@ -74,11 +75,16 @@ struct ProfileEditFormView: View { } .padding(.horizontal, 16) .padding(.bottom, ScreenLayout.floatingFooterClearance + 16) + .background { + // On the background rather than the ScrollView, so taps on multi-line fields don't resign them + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + dismissKeyboard() + } + } } .scrollDismissesKeyboard(.interactively) - .onTapGesture { - dismissKeyboard() - } if !keyboard.isPresented { footerBar @@ -115,13 +121,16 @@ struct ProfileEditFormView: View { VStack(alignment: .leading, spacing: 8) { CaptionMText(bioLabel, textColor: .white64) - TextField( - bioPlaceholder, + NoteTextEditor( text: $bio, - axis: .vertical, - testIdentifier: "ProfileEditBio" + placeholder: bioPlaceholder, + testIdentifier: "ProfileEditBio", + isFocused: $isBioFocused, + minHeight: 60, + maxHeight: 120, + backgroundColor: .white10, + allowsLineBreaks: true ) - .lineLimit(3, reservesSpace: true) } } diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift index 60b2af999..20c1dc49f 100644 --- a/Bitkit/Components/TextField.swift +++ b/Bitkit/Components/TextField.swift @@ -31,32 +31,23 @@ struct TextField: View { } var body: some View { - ZStack(alignment: .topLeading) { + ZStack(alignment: .leading) { if text.isEmpty { Text(placeholder) .foregroundColor(.secondary) .font(font) } - if axis == .vertical { - // A submit label turns Return into submit, so multi-line fields skip it to keep line breaks - inputField - } else { - inputField - .submitLabel(submitLabel) - } + SwiftUI.TextField("", text: $text, axis: axis) + .accentColor(.brandAccent) + .font(font) + .submitLabel(submitLabel) + .accessibilityIdentifierIfPresent(testIdentifier) } .padding(contentPadding) .background(backgroundColor) .cornerRadius(8) } - - private var inputField: some View { - SwiftUI.TextField("", text: $text, axis: axis) - .accentColor(.brandAccent) - .font(font) - .accessibilityIdentifierIfPresent(testIdentifier) - } } #Preview { diff --git a/Bitkit/Extensions/TextEditor+DismissOnReturn.swift b/Bitkit/Extensions/TextEditor+DismissOnReturn.swift index 020bd9553..d01a3f102 100644 --- a/Bitkit/Extensions/TextEditor+DismissOnReturn.swift +++ b/Bitkit/Extensions/TextEditor+DismissOnReturn.swift @@ -3,11 +3,12 @@ import SwiftUI private struct DismissKeyboardOnReturnModifier: ViewModifier { @Binding var text: String var isFocused: FocusState.Binding + var isEnabled: Bool func body(content: Content) -> some View { content .onChange(of: text) { _, newValue in - guard isFocused.wrappedValue else { return } + guard isEnabled, isFocused.wrappedValue else { return } if newValue.last == "\n" { text = newValue.trimmingCharacters(in: .whitespacesAndNewlines) isFocused.wrappedValue = false @@ -17,7 +18,7 @@ private struct DismissKeyboardOnReturnModifier: ViewModifier { } extension View { - func dismissKeyboardOnReturn(text: Binding, isFocused: FocusState.Binding) -> some View { - modifier(DismissKeyboardOnReturnModifier(text: text, isFocused: isFocused)) + func dismissKeyboardOnReturn(text: Binding, isFocused: FocusState.Binding, isEnabled: Bool = true) -> some View { + modifier(DismissKeyboardOnReturnModifier(text: text, isFocused: isFocused, isEnabled: isEnabled)) } } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 68341492f..33aedcbe6 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -183,15 +183,17 @@ struct ContactDetailView: View { CaptionMText(t("profile__create_tags_label"), textColor: .white64) .accessibilityIdentifier("ContactViewTagsHeader") - WrappingHStack(spacing: 8) { - ForEach(profile.tags, id: \.self) { tag in - Tag(tag, icon: .close, onDelete: { - removeTag(tag) - }) + if !profile.tags.isEmpty { + WrappingHStack(spacing: 8) { + ForEach(profile.tags, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { + removeTag(tag) + }) + } } - - addTagButton } + + addTagButton } } diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 860ac4258..b95fe1db9 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -199,27 +199,66 @@ struct ProfileView: View { // MARK: - Profile Link Row struct ProfileLinkRow: View { + @Environment(\.openURL) private var openURL + let label: String let value: String let linkIndex: Int + /// Link values are free text, so only values that look like a web address, email or phone number open. + static func destination(for value: String) -> URL? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains(" ") else { return nil } + + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), + ["http", "https", "mailto", "tel"].contains(scheme) + { + return url + } + + if trimmed.contains("@"), trimmed.contains(".") { + return URL(string: "mailto:\(trimmed)") + } + + if trimmed.contains("."), let url = URL(string: "https://\(trimmed)"), url.host?.contains(".") == true { + return url + } + + return nil + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(label, textColor: .white64) - .accessibilityIdentifier("ProfileLinkLabel_\(linkIndex)") - - BodySSBText(value, textColor: .white) - .accessibilityIdentifier("ProfileLinkValue_\(linkIndex)") - .frame(maxWidth: .infinity, alignment: .leading) + if let destination = Self.destination(for: value) { + Button { + openURL(destination) + } label: { + rowContent + } + .buttonStyle(.plain) + .accessibilityAddTraits(.isLink) + } else { + rowContent } - .padding(.vertical, 16) - .accessibilityElement(children: .contain) CustomDivider() } .frame(maxWidth: .infinity, alignment: .leading) } + + private var rowContent: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(label, textColor: .white64) + .accessibilityIdentifier("ProfileLinkLabel_\(linkIndex)") + + BodySSBText(value, textColor: .white) + .accessibilityIdentifier("ProfileLinkValue_\(linkIndex)") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 16) + .contentShape(Rectangle()) + .accessibilityElement(children: .contain) + } } #Preview { diff --git a/BitkitTests/ProfileLinkRowTests.swift b/BitkitTests/ProfileLinkRowTests.swift new file mode 100644 index 000000000..443d75ee6 --- /dev/null +++ b/BitkitTests/ProfileLinkRowTests.swift @@ -0,0 +1,26 @@ +@testable import Bitkit +import XCTest + +final class ProfileLinkRowTests: XCTestCase { + func testBareDomainOpensOverHttps() { + XCTAssertEqual(ProfileLinkRow.destination(for: "github.com")?.absoluteString, "https://github.com") + XCTAssertEqual(ProfileLinkRow.destination(for: "x.com/satoshi")?.absoluteString, "https://x.com/satoshi") + } + + func testExplicitSchemesAreKept() { + XCTAssertEqual(ProfileLinkRow.destination(for: "https://bitcoin.org")?.absoluteString, "https://bitcoin.org") + XCTAssertEqual(ProfileLinkRow.destination(for: "mailto:satoshi@gmx.com")?.absoluteString, "mailto:satoshi@gmx.com") + XCTAssertEqual(ProfileLinkRow.destination(for: "tel:+15551234")?.absoluteString, "tel:+15551234") + } + + func testEmailOpensMail() { + XCTAssertEqual(ProfileLinkRow.destination(for: "satoshin@gmx.com")?.absoluteString, "mailto:satoshin@gmx.com") + } + + func testPlainTextIsNotALink() { + XCTAssertNil(ProfileLinkRow.destination(for: "@satoshinakamoto")) + XCTAssertNil(ProfileLinkRow.destination(for: "Ask me in person")) + XCTAssertNil(ProfileLinkRow.destination(for: "")) + XCTAssertNil(ProfileLinkRow.destination(for: "javascript:alert(1)")) + } +} From 1a55e4b017a0008d2015898626c7a7fc5a25949b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:42:12 -0300 Subject: [PATCH 13/18] fix: open bare phone numbers from contact links Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 18 +++++++++++++++++- BitkitTests/ProfileLinkRowTests.swift | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index b95fe1db9..495bc57ef 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -208,7 +208,13 @@ struct ProfileLinkRow: View { /// Link values are free text, so only values that look like a web address, email or phone number open. static func destination(for value: String) -> URL? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !trimmed.contains(" ") else { return nil } + guard !trimmed.isEmpty else { return nil } + + if let phoneURL = phoneDestination(for: trimmed) { + return phoneURL + } + + guard !trimmed.contains(" ") else { return nil } if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) @@ -227,6 +233,16 @@ struct ProfileLinkRow: View { return nil } + private static let phoneCharacters = CharacterSet(charactersIn: "+0123456789 -().") + + private static func phoneDestination(for value: String) -> URL? { + guard value.unicodeScalars.allSatisfy(phoneCharacters.contains) else { return nil } + let digits = value.filter(\.isNumber) + guard digits.count >= 7 else { return nil } + let number = value.hasPrefix("+") ? "+\(digits)" : digits + return URL(string: "tel:\(number)") + } + var body: some View { VStack(alignment: .leading, spacing: 0) { if let destination = Self.destination(for: value) { diff --git a/BitkitTests/ProfileLinkRowTests.swift b/BitkitTests/ProfileLinkRowTests.swift index 443d75ee6..01ea5b7a7 100644 --- a/BitkitTests/ProfileLinkRowTests.swift +++ b/BitkitTests/ProfileLinkRowTests.swift @@ -13,6 +13,16 @@ final class ProfileLinkRowTests: XCTestCase { XCTAssertEqual(ProfileLinkRow.destination(for: "tel:+15551234")?.absoluteString, "tel:+15551234") } + func testSchemeCaseIsIgnored() { + XCTAssertEqual(ProfileLinkRow.destination(for: "Https://example.com")?.absoluteString, "Https://example.com") + } + + func testBarePhoneNumberOpensDialer() { + XCTAssertEqual(ProfileLinkRow.destination(for: "+15551234")?.absoluteString, "tel:+15551234") + XCTAssertEqual(ProfileLinkRow.destination(for: "+1 (555) 123-4567")?.absoluteString, "tel:+15551234567") + XCTAssertNil(ProfileLinkRow.destination(for: "2024")) + } + func testEmailOpensMail() { XCTAssertEqual(ProfileLinkRow.destination(for: "satoshin@gmx.com")?.absoluteString, "mailto:satoshin@gmx.com") } From 37fa00a0fa065c1634b18d6535a374186d757a14 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 09:44:02 -0300 Subject: [PATCH 14/18] fix: open formatted tel: contact links Ports the bitkit-android#1332 review finding: tel: links with spaces or brackets were rejected by the whitespace check before the scheme check. Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 4 ++++ BitkitTests/ProfileLinkRowTests.swift | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 495bc57ef..3bec763b6 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -214,6 +214,10 @@ struct ProfileLinkRow: View { return phoneURL } + if trimmed.lowercased().hasPrefix("tel:") { + return phoneDestination(for: String(trimmed.dropFirst(4))) + } + guard !trimmed.contains(" ") else { return nil } if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), diff --git a/BitkitTests/ProfileLinkRowTests.swift b/BitkitTests/ProfileLinkRowTests.swift index 01ea5b7a7..bb844cc49 100644 --- a/BitkitTests/ProfileLinkRowTests.swift +++ b/BitkitTests/ProfileLinkRowTests.swift @@ -23,6 +23,11 @@ final class ProfileLinkRowTests: XCTestCase { XCTAssertNil(ProfileLinkRow.destination(for: "2024")) } + func testFormattedTelLinkOpensDialer() { + XCTAssertEqual(ProfileLinkRow.destination(for: "tel:+1 555 123 4567")?.absoluteString, "tel:+15551234567") + XCTAssertEqual(ProfileLinkRow.destination(for: "TEL:+1 (555) 123-4567")?.absoluteString, "tel:+15551234567") + } + func testEmailOpensMail() { XCTAssertEqual(ProfileLinkRow.destination(for: "satoshin@gmx.com")?.absoluteString, "mailto:satoshin@gmx.com") } From fc374016fbc4240e493c055e788fc29fe13684ef Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 10:16:30 -0300 Subject: [PATCH 15/18] fix: keep numeric contact text from opening the dialer Bare numbers open as tel: links only in international format or under a phone label, and web links need a top-level domain with letters, so IP addresses and dates stay plain text. Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Views/Profile/ProfileView.swift | 18 ++++++++++++++---- BitkitTests/ProfileLinkRowTests.swift | 13 ++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 3bec763b6..aa1bd6b37 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -206,11 +206,13 @@ struct ProfileLinkRow: View { let linkIndex: Int /// Link values are free text, so only values that look like a web address, email or phone number open. - static func destination(for value: String) -> URL? { + /// A bare number counts as a phone number only in international format or under a phone label, + /// so dates, IP addresses and other numeric text stay plain. + static func destination(for value: String, label: String = "") -> URL? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if let phoneURL = phoneDestination(for: trimmed) { + if trimmed.hasPrefix("+") || isPhoneLabel(label), let phoneURL = phoneDestination(for: trimmed) { return phoneURL } @@ -230,13 +232,21 @@ struct ProfileLinkRow: View { return URL(string: "mailto:\(trimmed)") } - if trimmed.contains("."), let url = URL(string: "https://\(trimmed)"), url.host?.contains(".") == true { + if let url = URL(string: "https://\(trimmed)"), let host = url.host, host.contains("."), + let topLevelDomain = host.split(separator: ".").last, topLevelDomain.contains(where: \.isLetter) + { return url } return nil } + private static let phoneLabels: Set = ["phone", "tel", "telephone", "mobile", "cell"] + + private static func isPhoneLabel(_ label: String) -> Bool { + phoneLabels.contains(label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + private static let phoneCharacters = CharacterSet(charactersIn: "+0123456789 -().") private static func phoneDestination(for value: String) -> URL? { @@ -249,7 +259,7 @@ struct ProfileLinkRow: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if let destination = Self.destination(for: value) { + if let destination = Self.destination(for: value, label: label) { Button { openURL(destination) } label: { diff --git a/BitkitTests/ProfileLinkRowTests.swift b/BitkitTests/ProfileLinkRowTests.swift index bb844cc49..7003a1b60 100644 --- a/BitkitTests/ProfileLinkRowTests.swift +++ b/BitkitTests/ProfileLinkRowTests.swift @@ -17,10 +17,21 @@ final class ProfileLinkRowTests: XCTestCase { XCTAssertEqual(ProfileLinkRow.destination(for: "Https://example.com")?.absoluteString, "Https://example.com") } - func testBarePhoneNumberOpensDialer() { + func testInternationalPhoneNumberOpensDialer() { XCTAssertEqual(ProfileLinkRow.destination(for: "+15551234")?.absoluteString, "tel:+15551234") XCTAssertEqual(ProfileLinkRow.destination(for: "+1 (555) 123-4567")?.absoluteString, "tel:+15551234567") + } + + func testLocalNumberOpensDialerUnderPhoneLabel() { + XCTAssertEqual(ProfileLinkRow.destination(for: "555 123 4567", label: "Phone")?.absoluteString, "tel:5551234567") + XCTAssertNil(ProfileLinkRow.destination(for: "555 123 4567", label: "Notes")) + } + + func testNumericTextStaysPlain() { XCTAssertNil(ProfileLinkRow.destination(for: "2024")) + XCTAssertNil(ProfileLinkRow.destination(for: "192.168.1.100")) + XCTAssertNil(ProfileLinkRow.destination(for: "2024-01-01")) + XCTAssertNil(ProfileLinkRow.destination(for: "2024.01.01")) } func testFormattedTelLinkOpensDialer() { From 6686718ded0a6f7c82d16fa444590d19ba843ada Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 13:35:26 -0300 Subject: [PATCH 16/18] fix: restore profile links and tags, add copy popup and transparent header Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/ClipboardCopyPopup.swift | 32 ++++ Bitkit/Components/ProfileEditFormView.swift | 117 ++++++++------ .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Views/Contacts/EditContactView.swift | 42 +++-- Bitkit/Views/Profile/EditProfileView.swift | 42 +++-- Bitkit/Views/Profile/ProfileView.swift | 145 ++++++++++++++++-- 6 files changed, 266 insertions(+), 113 deletions(-) create mode 100644 Bitkit/Components/ClipboardCopyPopup.swift diff --git a/Bitkit/Components/ClipboardCopyPopup.swift b/Bitkit/Components/ClipboardCopyPopup.swift new file mode 100644 index 000000000..daebbe7fd --- /dev/null +++ b/Bitkit/Components/ClipboardCopyPopup.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct ClipboardCopyPopup: View { + let title: String + let value: String + + var body: some View { + VStack(spacing: 16) { + BodyMSBText(title, textColor: .brandAccent) + + BodySText(value, textColor: .textPrimary) + } + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 247) + .padding(32) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.4), radius: 25, x: 0, y: 25) + .accessibilityElement(children: .combine) + } +} + +#Preview { + ClipboardCopyPopup( + title: "pubky copied to clipboard", + value: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.customBlack) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index 7bd29cf91..941c75046 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -6,6 +6,7 @@ struct ProfileEditFormView: View { case textOnly } + let navigationTitle: String @Binding var name: String @Binding var bio: String @Binding var links: [ProfileLinkInput] @@ -32,73 +33,83 @@ struct ProfileEditFormView: View { var body: some View { ZStack(alignment: .bottom) { - ScrollView { - VStack(spacing: 0) { - VStack(spacing: 32) { - avatar() + InsetHeaderScrollView( + header: { + NavigationBar(title: navigationTitle) + .padding(.horizontal, 16) + }, + content: { + formContent + }, + scrollModifier: DismissKeyboardOnScroll() + ) - ProfileNameField(name: $name, accessibilityId: "ProfileEditName") + if !keyboard.isPresented { + footerBar + } + } + .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { + AddLinkSheet { label, url in + links.append(ProfileLinkInput(label: label, url: url)) + } + } + .sheet(isPresented: $showAddTagSheet, onDismiss: dismissKeyboard) { + AddProfileTagSheet { tag in + tags.append(tag) + } + } + } - CustomDivider() + private var formContent: some View { + VStack(spacing: 0) { + VStack(spacing: 32) { + avatar() - pubkyKeySection - } - .padding(.horizontal, 16) - .padding(.vertical, 32) + ProfileNameField(name: $name, accessibilityId: "ProfileEditName") - VStack(alignment: .leading, spacing: 16) { - CustomDivider() + CustomDivider() - bioSection + pubkyKeySection + } + .padding(.horizontal, 16) + .padding(.vertical, 32) - CustomDivider() + VStack(alignment: .leading, spacing: 16) { + CustomDivider() - linksSection + bioSection - CustomDivider() + CustomDivider() - tagsSection + linksSection - if let footerNote { - CustomDivider() + CustomDivider() - footnoteSection(footerNote) - } + tagsSection - if let deleteLabel, let onDelete { - CustomDivider() + if let footerNote { + CustomDivider() - deleteSection(label: deleteLabel, action: onDelete) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 16) - .padding(.bottom, ScreenLayout.floatingFooterClearance + 16) - .background { - // On the background rather than the ScrollView, so taps on multi-line fields don't resign them - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - dismissKeyboard() - } + footnoteSection(footerNote) } - } - .scrollDismissesKeyboard(.interactively) - if !keyboard.isPresented { - footerBar - } - } - .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { - AddLinkSheet { label, url in - links.append(ProfileLinkInput(label: label, url: url)) + if let deleteLabel, let onDelete { + CustomDivider() + + deleteSection(label: deleteLabel, action: onDelete) + } } + .frame(maxWidth: .infinity, alignment: .leading) } - .sheet(isPresented: $showAddTagSheet, onDismiss: dismissKeyboard) { - AddProfileTagSheet { tag in - tags.append(tag) - } + .padding(.horizontal, 16) + .padding(.bottom, ScreenLayout.floatingFooterClearance + 16) + .background { + // On the background rather than the ScrollView, so taps on multi-line fields don't resign them + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + dismissKeyboard() + } } } @@ -296,3 +307,9 @@ struct ProfileEditFormView: View { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } + +private struct DismissKeyboardOnScroll: ViewModifier { + func body(content: Content) -> some View { + content.scrollDismissesKeyboard(.interactively) + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 3d61af8f9..71049490d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1217,6 +1217,7 @@ "profile__session_expired_description" = "Your profile session has expired. Please reconnect to restore your profile."; "profile__edit_nav_title" = "Edit Profile"; "profile__edit_saved" = "Profile updated"; +"profile__pubky_copied" = "pubky copied to clipboard"; "profile__edit_delete_section" = "DELETE"; "profile__edit_public_note" = "Please note profile information is stored in public files."; "profile__delete_title" = "Delete Profile?"; diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index de7ba80c0..09e4aa1c5 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -20,29 +20,25 @@ struct EditContactView: View { @State private var avatarImage: UIImage? var body: some View { - VStack(spacing: 0) { - NavigationBar(title: t("contacts__edit_title")) - .padding(.horizontal, 16) - - ProfileEditFormView( - name: $name, - bio: $bio, - links: $links, - tags: $tags, - publicKey: publicKey, - publicKeyLabel: t("profile__create_pubky_label"), - bioLabel: t("contacts__edit_notes_label"), - bioPlaceholder: t("contacts__edit_bio_placeholder"), - isSaving: isSaving, - footerNote: t("contacts__edit_public_note"), - deleteLabel: t("contacts__delete_label"), - deleteActionStyle: .buttonWithIcon, - onSave: { await saveContact() }, - onCancel: { navigation.navigateBack() }, - onDelete: { showDeleteConfirmation = true } - ) { - avatarSection - } + ProfileEditFormView( + navigationTitle: t("contacts__edit_title"), + name: $name, + bio: $bio, + links: $links, + tags: $tags, + publicKey: publicKey, + publicKeyLabel: t("profile__create_pubky_label"), + bioLabel: t("contacts__edit_notes_label"), + bioPlaceholder: t("contacts__edit_bio_placeholder"), + isSaving: isSaving, + footerNote: t("contacts__edit_public_note"), + deleteLabel: t("contacts__delete_label"), + deleteActionStyle: .buttonWithIcon, + onSave: { await saveContact() }, + onCancel: { navigation.navigateBack() }, + onDelete: { showDeleteConfirmation = true } + ) { + avatarSection } .frame(maxWidth: .infinity, maxHeight: .infinity) .bottomSafeAreaPadding() diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 1570321ae..0e476cf01 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -20,30 +20,24 @@ struct EditProfileView: View { var body: some View { ZStack { - VStack(spacing: 0) { - NavigationBar( - title: t("profile__edit_nav_title") - ) - .padding(.horizontal, 16) - - ProfileEditFormView( - name: $username, - bio: $bio, - links: $links, - tags: $tags, - publicKey: pubkyProfile.publicKey ?? "...", - publicKeyLabel: t("profile__create_pubky_display_label"), - bioPlaceholder: t("profile__create_bio_placeholder"), - isSaving: isSaving, - footerNote: t("profile__edit_public_note"), - deleteLabel: t("profile__delete_label"), - deleteActionStyle: .buttonWithIcon, - onSave: { await saveProfile() }, - onCancel: { navigation.navigateBack() }, - onDelete: { showDeleteConfirmation = true } - ) { - avatarPicker - } + ProfileEditFormView( + navigationTitle: t("profile__edit_nav_title"), + name: $username, + bio: $bio, + links: $links, + tags: $tags, + publicKey: pubkyProfile.publicKey ?? "...", + publicKeyLabel: t("profile__create_pubky_display_label"), + bioPlaceholder: t("profile__create_bio_placeholder"), + isSaving: isSaving, + footerNote: t("profile__edit_public_note"), + deleteLabel: t("profile__delete_label"), + deleteActionStyle: .buttonWithIcon, + onSave: { await saveProfile() }, + onCancel: { navigation.navigateBack() }, + onDelete: { showDeleteConfirmation = true } + ) { + avatarPicker } if isDeleting { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index aa1bd6b37..0bae602a2 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -6,24 +6,38 @@ struct ProfileView: View { @EnvironmentObject var pubkyProfile: PubkyProfileManager @State private var showSignOutConfirmation = false + @State private var showAddTagSheet = false + @State private var isUpdatingTags = false @State private var isSigningOut = false + @State private var copiedPublicKey: String? + @State private var hideCopiedPopupTask: Task? var body: some View { - VStack(spacing: 0) { - NavigationBar( - title: t("profile__nav_title") - ) - .padding(.horizontal, 16) - - if pubkyProfile.isLoadingProfile && pubkyProfile.profile == nil { - loadingContent - } else if let profile = pubkyProfile.profile { + Group { + if let profile = pubkyProfile.profile { profileContent(profile) } else { - emptyContent + VStack(spacing: 0) { + navigationBar + + if pubkyProfile.isLoadingProfile { + loadingContent + } else { + emptyContent + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + if let copiedPublicKey { + ClipboardCopyPopup(title: t("profile__pubky_copied"), value: copiedPublicKey) + .accessibilityIdentifier("ProfilePubkyCopiedToast") + .onTapGesture { hideCopiedPopup() } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + } + .animation(.easeInOut(duration: 0.2), value: copiedPublicKey) .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) @@ -46,8 +60,13 @@ struct ProfileView: View { // MARK: - Profile Content + private var navigationBar: some View { + NavigationBar(title: t("profile__nav_title")) + .padding(.horizontal, 16) + } + private func profileContent(_ profile: PubkyProfile) -> some View { - ScrollView { + InsetHeaderScrollView(header: { navigationBar }) { VStack(spacing: 0) { CenteredProfileHeader( truncatedKey: profile.truncatedPublicKey, @@ -58,7 +77,7 @@ struct ProfileView: View { nameAccessibilityIdentifier: "ProfileViewName", notesAccessibilityIdentifier: "ProfileViewNotes" ) - .padding(.top, 32) + .padding(.top, 16) .padding(.bottom, 16) profileQRCode(profile) @@ -66,10 +85,28 @@ struct ProfileView: View { .padding(.bottom, 16) profileActions - .padding(.bottom, 24) + .padding(.bottom, 16) + + CustomDivider() + + VStack(alignment: .leading, spacing: 0) { + if !profile.links.isEmpty { + profileLinks(profile) + } + + profileTags(profile) + .padding(.top, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 16) } .padding(.horizontal, 16) } + .sheet(isPresented: $showAddTagSheet) { + AddProfileTagSheet { tag in + addTag(tag, to: profile) + } + } } // MARK: - Actions (edit, copy, share) @@ -83,8 +120,7 @@ struct ProfileView: View { GradientCircleButton(icon: "copy-simple", accessibilityLabel: t("common__copy")) { if let pk = pubkyProfile.publicKey { - UIPasteboard.general.string = pk - app.toast(type: .success, title: t("common__copied"), accessibilityIdentifier: "ProfilePubkyCopiedToast") + copyPublicKey(pk) } } .accessibilityIdentifier("ProfileCopy") @@ -100,7 +136,20 @@ struct ProfileView: View { private func copyPublicKey(_ publicKey: String) { UIPasteboard.general.string = publicKey - app.toast(type: .success, title: t("common__copied")) + Haptics.notify(.success) + copiedPublicKey = publicKey + + hideCopiedPopupTask?.cancel() + hideCopiedPopupTask = Task { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + copiedPublicKey = nil + } + } + + private func hideCopiedPopup() { + hideCopiedPopupTask?.cancel() + copiedPublicKey = nil } private func profileQRCode(_ profile: PubkyProfile) -> some View { @@ -132,6 +181,70 @@ struct ProfileView: View { .accessibilityIdentifier("ProfileQRCode") } + // MARK: - Links + + private func profileLinks(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in + ProfileLinkRow(label: link.label, value: link.url, linkIndex: index) + } + } + } + + // MARK: - Tags + + private func profileTags(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + .accessibilityIdentifier("ProfileViewTagsHeader") + + if !profile.tags.isEmpty { + WrappingHStack(spacing: 8) { + ForEach(profile.tags, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { + updateTags(profile.tags.filter { $0 != tag }, profile: profile) + }) + } + } + } + + IconActionButton( + icon: "tag", + title: t("profile__create_add_tag"), + accessibilityId: "ProfileAddTag" + ) { + showAddTagSheet = true + } + } + .disabled(isUpdatingTags) + } + + private func addTag(_ tag: String, to profile: PubkyProfile) { + guard !profile.tags.contains(tag) else { return } + updateTags(profile.tags + [tag], profile: profile) + } + + private func updateTags(_ tags: [String], profile: PubkyProfile) { + guard !isUpdatingTags else { return } + isUpdatingTags = true + + Task { + defer { isUpdatingTags = false } + + do { + try await pubkyProfile.saveProfile( + name: profile.name, + bio: profile.bio, + links: profile.links, + tags: tags + ) + } catch { + Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") + app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + } + } + } + // MARK: - Loading / Empty States private var loadingContent: some View { From 5289db3223c7e87f54970297d8d4a7376023359e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 25 Sep 2026 07:14:33 -0300 Subject: [PATCH 17/18] fix: keep profile name casing when editing Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/ProfileNameField.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/ProfileNameField.swift b/Bitkit/Components/ProfileNameField.swift index 7463b70ab..17751f06d 100644 --- a/Bitkit/Components/ProfileNameField.swift +++ b/Bitkit/Components/ProfileNameField.swift @@ -1,7 +1,8 @@ import SwiftUI -/// Display-caps name input. `textCase` does not apply to typed `TextField` text, so the stored name keeps -/// its original casing and an uppercased copy is drawn over the field while it is not being edited. +/// Display-caps name input. The stored name keeps its original casing and an uppercased copy is drawn over +/// the field while it is not being edited. `textCase` must stay off the `TextField`: it uppercases the existing +/// text once editing starts and writes that back into the binding. struct ProfileNameField: View { @Binding var name: String let accessibilityId: String @@ -17,7 +18,6 @@ struct ProfileNameField: View { SwiftUI.TextField(t("profile__create_name_placeholder"), text: $name) .font(Fonts.black(size: 44)) .kerning(-1) - .textCase(.uppercase) .multilineTextAlignment(.center) .foregroundColor(showsUppercasedName ? .clear : .textPrimary) .autocorrectionDisabled() From be82404ffca3b9024cfbc36849616280f4c8113a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 25 Sep 2026 08:06:47 -0300 Subject: [PATCH 18/18] fix: capitalize the first letter of each word of the home profile name Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/Header.swift | 2 +- Bitkit/Extensions/String+Utilities.swift | 8 ++++++++ BitkitTests/StringUtilitiesTests.swift | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index e69b9a8d0..ddb374b9e 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -136,7 +136,7 @@ struct Header: View { profileAvatar if let name = pubkyProfile.displayName { - TitleText(name) + TitleText(name.capitalizingFirstLetterOfEachWord) } else { TitleText(t("slashtags__your_name_capital")) } diff --git a/Bitkit/Extensions/String+Utilities.swift b/Bitkit/Extensions/String+Utilities.swift index d0466ec92..7f94a9336 100644 --- a/Bitkit/Extensions/String+Utilities.swift +++ b/Bitkit/Extensions/String+Utilities.swift @@ -20,6 +20,14 @@ extension String { return value } + /// Uppercases the first letter of each word and leaves the rest as typed, so "satoshi nakamoto" reads + /// "Satoshi Nakamoto" while "McDonald" keeps its inner capital. + var capitalizingFirstLetterOfEachWord: String { + split(separator: " ", omittingEmptySubsequences: false) + .map { word in word.prefix(1).uppercased() + word.dropFirst() } + .joined(separator: " ") + } + enum EllipsisStyle { /// Ellipsis in the middle: "ab...de" case middle diff --git a/BitkitTests/StringUtilitiesTests.swift b/BitkitTests/StringUtilitiesTests.swift index db5a41c03..17ec516e0 100644 --- a/BitkitTests/StringUtilitiesTests.swift +++ b/BitkitTests/StringUtilitiesTests.swift @@ -21,4 +21,12 @@ final class StringUtilitiesTests: XCTestCase { func testRemovingLightningSchemesTrimsWhitespace() { XCTAssertEqual(" lnurlw:lnurl1example ".removingLightningSchemes(), "lnurl1example") } + + func testCapitalizingFirstLetterOfEachWord() { + XCTAssertEqual("satoshi nakamoto".capitalizingFirstLetterOfEachWord, "Satoshi Nakamoto") + XCTAssertEqual("Satoshi NAKAMOTO".capitalizingFirstLetterOfEachWord, "Satoshi NAKAMOTO") + XCTAssertEqual("ronald mcDonald".capitalizingFirstLetterOfEachWord, "Ronald McDonald") + XCTAssertEqual("élodie van".capitalizingFirstLetterOfEachWord, "Élodie Van") + XCTAssertEqual("".capitalizingFirstLetterOfEachWord, "") + } }