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.