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/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/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 8426f7b18..67180e31c 100644 --- a/Bitkit/Components/Button/GradientCircleButton.swift +++ b/Bitkit/Components/Button/GradientCircleButton.swift @@ -32,11 +32,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/Components/Button/IconActionButton.swift b/Bitkit/Components/Button/IconActionButton.swift index b2c1f1ae2..3650ab54e 100644 --- a/Bitkit/Components/Button/IconActionButton.swift +++ b/Bitkit/Components/Button/IconActionButton.swift @@ -1,23 +1,26 @@ 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 let accessibilityId: String let action: () -> Void init( - icon: String, + icon: String? = nil, 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 } @@ -25,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(.white) - } else { - Image(icon) - .resizable() - .scaledToFit() - .foregroundColor(.white) - .frame(width: 14, height: 14) + if let icon { + iconImage(icon) + .frame(width: 16, height: 16) } - BodySSBText(title) + 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/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/Header.swift b/Bitkit/Components/Header.swift index d8106aa55..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")) } @@ -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/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 a5c99bf12..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] @@ -13,6 +14,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? @@ -23,87 +25,91 @@ struct ProfileEditFormView: View { let onDelete: (() -> Void)? @ViewBuilder let avatar: () -> Avatar + @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 { - ScrollView { - VStack(spacing: 0) { + ZStack(alignment: .bottom) { + InsetHeaderScrollView( + header: { + NavigationBar(title: navigationTitle) + .padding(.horizontal, 16) + }, + content: { + formContent + }, + scrollModifier: DismissKeyboardOnScroll() + ) + + 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) + } + } + } + + private var formContent: some View { + VStack(spacing: 0) { + VStack(spacing: 32) { 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") + ProfileNameField(name: $name, accessibilityId: "ProfileEditName") CustomDivider() - .padding(.bottom, 16) pubkyKeySection - .padding(.bottom, 24) + } + .padding(.horizontal, 16) + .padding(.vertical, 32) - VStack(alignment: .leading, spacing: 0) { - bioSection - .padding(.bottom, 16) + VStack(alignment: .leading, spacing: 16) { + CustomDivider() - linksSection - .padding(.bottom, 16) + bioSection - if !links.isEmpty { - CustomDivider(color: .white16) - .padding(.bottom, 16) - } + CustomDivider() - tagsSection - .padding(.bottom, 24) + linksSection - if let footerNote { - CustomDivider(color: .white16) - .padding(.bottom, 16) + CustomDivider() - footnoteSection(footerNote) - .padding(.bottom, 24) - } + tagsSection - if let deleteLabel, let onDelete { - CustomDivider(color: .white16) - .padding(.bottom, 16) + if let footerNote { + CustomDivider() - deleteSection(label: deleteLabel, action: onDelete) - .padding(.bottom, 24) - } + footnoteSection(footerNote) + } + + if let deleteLabel, let onDelete { + CustomDivider() + + deleteSection(label: deleteLabel, action: onDelete) } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 16) - .padding(.bottom, 24) - } - .scrollDismissesKeyboard(.interactively) - .onTapGesture { - dismissKeyboard() - } - .safeAreaInset(edge: .bottom, spacing: 0) { - footerBar - } - .sheet(isPresented: $showAddLinkSheet, onDismiss: dismissKeyboard) { - AddLinkSheet { label, url in - links.append(ProfileLinkInput(label: label, url: url)) } + .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() + } } } @@ -113,13 +119,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) } } @@ -127,15 +130,17 @@ 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, + NoteTextEditor( text: $bio, - backgroundColor: .gray6, - font: .custom(Fonts.regular, size: 17), - axis: .vertical, - testIdentifier: "ProfileEditBio" + placeholder: bioPlaceholder, + testIdentifier: "ProfileEditBio", + isFocused: $isBioFocused, + minHeight: 60, + maxHeight: 120, + backgroundColor: .white10, + allowsLineBreaks: true ) } } @@ -143,7 +148,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 +169,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 +177,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 +187,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 +203,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 +222,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 { @@ -269,9 +255,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: { @@ -293,26 +279,37 @@ 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() { 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/Components/ProfileNameField.swift b/Bitkit/Components/ProfileNameField.swift new file mode 100644 index 000000000..17751f06d --- /dev/null +++ b/Bitkit/Components/ProfileNameField.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// 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 + var focusesWhenEmpty = false + + @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) + .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) + .task { + if focusesWhenEmpty, name.isEmpty { + isFocused = true + } + } + } +} 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/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/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index da850afd4..71049490d 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"; @@ -1192,9 +1193,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"; @@ -1216,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/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..33aedcbe6 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", 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 { @@ -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/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 1a51795a5..09e4aa1c5 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -20,28 +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"), - 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() @@ -182,6 +179,7 @@ struct EditContactView: View { .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environment(KeyboardManager()) .environmentObject(PubkyProfileManager()) } .preferredColorScheme(.dark) 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..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 { @@ -42,21 +43,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 +93,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 +108,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", focusesWhenEmpty: true) } // MARK: - Pubky Key Section @@ -138,7 +124,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..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 { @@ -110,14 +104,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) } } } @@ -227,6 +221,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 c01a97f93..0bae602a2 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -9,23 +9,35 @@ struct ProfileView: View { @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) @@ -48,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, @@ -60,15 +77,15 @@ struct ProfileView: View { nameAccessibilityIdentifier: "ProfileViewName", notesAccessibilityIdentifier: "ProfileViewNotes" ) - .padding(.top, 24) - .padding(.bottom, 24) + .padding(.top, 16) + .padding(.bottom, 16) profileQRCode(profile) - .frame(width: 279) - .padding(.bottom, 24) + .padding(.horizontal, 32) + .padding(.bottom, 16) profileActions - .padding(.bottom, 24) + .padding(.bottom, 16) CustomDivider() @@ -81,6 +98,7 @@ struct ProfileView: View { .padding(.top, 16) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 16) } .padding(.horizontal, 16) } @@ -95,20 +113,19 @@ 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", 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") + copyPublicKey(pk) } } .accessibilityIdentifier("ProfileCopy") - GradientCircleButton(icon: "share", accessibilityLabel: t("common__share")) { + GradientCircleButton(icon: "share-regular", accessibilityLabel: t("common__share")) { shareProfile() } .accessibilityIdentifier("ProfileShare") @@ -117,14 +134,34 @@ struct ProfileView: View { // MARK: - QR Code + private func copyPublicKey(_ publicKey: String) { + UIPasteboard.general.string = publicKey + 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 { 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 { @@ -144,7 +181,7 @@ struct ProfileView: View { .accessibilityIdentifier("ProfileQRCode") } - // MARK: - Links / Metadata + // MARK: - Links private func profileLinks(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { @@ -161,23 +198,25 @@ struct ProfileView: View { 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) - }) + 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 - } + IconActionButton( + icon: "tag", + title: t("profile__create_add_tag"), + accessibilityId: "ProfileAddTag" + ) { + showAddTagSheet = true } - .disabled(isUpdatingTags) } + .disabled(isUpdatingTags) } private func addTag(_ tag: String, to profile: PubkyProfile) { @@ -273,27 +312,96 @@ 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. + /// 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 trimmed.hasPrefix("+") || isPhoneLabel(label), let phoneURL = phoneDestination(for: trimmed) { + 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(), + ["http", "https", "mailto", "tel"].contains(scheme) + { + return url + } + + if trimmed.contains("@"), trimmed.contains(".") { + return URL(string: "mailto:\(trimmed)") + } + + 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? { + 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) { - 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, label: label) { + 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/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) diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 54e422277..f05ae9ead 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,12 @@ 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 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 + nonItemSlots += (hwWalletManager.wallets.count + 1) / 2 if shouldShowBanner { nonItemSlots += 1 } diff --git a/BitkitTests/ProfileLinkRowTests.swift b/BitkitTests/ProfileLinkRowTests.swift new file mode 100644 index 000000000..7003a1b60 --- /dev/null +++ b/BitkitTests/ProfileLinkRowTests.swift @@ -0,0 +1,52 @@ +@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 testSchemeCaseIsIgnored() { + XCTAssertEqual(ProfileLinkRow.destination(for: "Https://example.com")?.absoluteString, "Https://example.com") + } + + 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() { + 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") + } + + 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)")) + } +} 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, "") + } } diff --git a/changelog.d/next/781.fixed.md b/changelog.d/next/781.fixed.md new file mode 100644 index 000000000..223b364b3 --- /dev/null +++ b/changelog.d/next/781.fixed.md @@ -0,0 +1 @@ +Aligned the profile screens and the home header spacing with the latest designs.