krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

27bfcbbed6acde158ec40c5bb46e45e76c81a319

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T00:47:34Z

refactor: extract ProfileView from SettingsView

Move profile-related UI into its own dedicated ProfileView, simplifying
SettingsView and improving navigation structure in MoreView.
 Hutch/Views/More/MoreView.swift         |  12 +-
 Hutch/Views/More/ProfileView.swift      | 641 ++++++++++++++++++++++++++++++++
 Hutch/Views/Settings/SettingsView.swift | 584 +----------------------------
 3 files changed, 659 insertions(+), 578 deletions(-)

diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index b03aa3a..09aa37a 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -11,15 +11,17 @@ struct MoreView: View {
 
     var body: some View {
         List {
-            Section {
+            Section("Search") {
                 NavigationLink(value: MoreRoute.lookup) {
                     Label("Look Up", systemImage: "magnifyingglass")
                 }
+            }
 
+            Section("Other Services") {
                 NavigationLink(value: MoreRoute.lists) {
                     Label("Mailing Lists", systemImage: "list.bullet.rectangle")
                 }
-                
+
                 NavigationLink(value: MoreRoute.manPageBrowser) {
                     Label("Man Pages", systemImage: "book")
                 }
@@ -27,6 +29,12 @@ struct MoreView: View {
                 NavigationLink(value: MoreRoute.pastes) {
                     Label("Pastes", systemImage: "doc.on.clipboard")
                 }
+            }
+
+            Section("Meta") {
+                NavigationLink(value: MoreRoute.profile) {
+                    Label("Profile", systemImage: "person.text.rectangle")
+                }
 
                 NavigationLink(value: MoreRoute.settings) {
                     Label("Settings", systemImage: "gear")
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
new file mode 100644
index 0000000..a7172e4
--- /dev/null
+++ b/Hutch/Views/More/ProfileView.swift
@@ -0,0 +1,641 @@
+import PhotosUI
+import SwiftUI
+
+private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
+    interpretedSyntax: .inlineOnlyPreservingWhitespace
+)
+
+struct ProfileView: View {
+    @Environment(AppState.self) private var appState
+    @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
+    @State private var viewModel: SettingsViewModel?
+    @State private var contributionViewModel: UserProfileViewModel?
+    @State private var pendingDestructiveAction: ProfileDestructiveAction?
+
+    var body: some View {
+        Group {
+            if let viewModel {
+                profileContent(viewModel)
+            } else {
+                SRHTLoadingStateView(message: "Loading profile…")
+            }
+        }
+        .navigationTitle("Profile")
+        .task {
+            if viewModel == nil {
+                let vm = SettingsViewModel(client: appState.client)
+                viewModel = vm
+                await vm.loadProfile()
+            }
+        }
+        .task(id: contributionGraphsEnabled ? appState.currentUser?.canonicalName ?? "" : "contributions-disabled") {
+            guard contributionGraphsEnabled, let currentUser = appState.currentUser else { return }
+
+            let owner = currentUser.canonicalName.hasPrefix("~")
+                ? String(currentUser.canonicalName.dropFirst())
+                : currentUser.canonicalName
+            let actor = currentUser.canonicalName.hasPrefix("~") ? currentUser.canonicalName : "~\(currentUser.canonicalName)"
+
+            let vm: UserProfileViewModel
+            if let existingViewModel = contributionViewModel,
+               existingViewModel.actor == actor,
+               existingViewModel.ownerUsername == owner {
+                vm = existingViewModel
+            } else {
+                let newViewModel = UserProfileViewModel(
+                    ownerUsername: owner,
+                    actor: actor,
+                    client: appState.client,
+                    statsService: HutchStatsService(configuration: appState.configuration)
+                )
+                contributionViewModel = newViewModel
+                vm = newViewModel
+            }
+
+            await vm.loadContributions()
+        }
+    }
+
+    @ViewBuilder
+    private func profileContent(_ viewModel: SettingsViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        Form {
+            if let profile = viewModel.profile {
+                profileSection(profile, viewModel: viewModel)
+
+                if contributionGraphsEnabled, let contributionViewModel {
+                    Section {
+                        ContributionProfileCard(
+                            actor: contributionViewModel.actor,
+                            weeks: contributionViewModel.contributionCalendar.map {
+                                ContributionCalendarLayout.weekColumns(from: $0.days)
+                            } ?? [],
+                            stats: contributionViewModel.contributionStats,
+                            isLoading: contributionViewModel.isLoadingContributions,
+                            error: contributionViewModel.contributionsError ?? contributionViewModel.contributionStatusText,
+                            isIndexedButEmpty: contributionViewModel.isContributionActivityIndexedButEmpty
+                        )
+                    }
+                }
+
+                sshKeysSection(viewModel)
+                pgpKeysSection(viewModel)
+                patSection(viewModel)
+            }
+        }
+        .overlay {
+            if viewModel.isLoading, viewModel.profile == nil {
+                SRHTLoadingStateView(message: "Loading profile…")
+            } else if let error = viewModel.error, viewModel.profile == nil {
+                SRHTErrorStateView(
+                    title: "Couldn't Load Profile",
+                    message: error,
+                    retryAction: { await viewModel.loadProfile() }
+                )
+            }
+        }
+        .sheet(isPresented: $vm.isEditingProfile) {
+            if let profile = viewModel.profile {
+                EditProfileSheet(profile: profile, viewModel: viewModel)
+            }
+        }
+        .alert("Error", isPresented: Binding(
+            get: { viewModel.error != nil && viewModel.profile != nil },
+            set: { isPresented in
+                if !isPresented {
+                    viewModel.error = nil
+                }
+            }
+        )) {
+            Button("OK") { viewModel.error = nil }
+        } message: {
+            if let error = viewModel.error {
+                Text(error)
+            }
+        }
+        .alert(
+            pendingDestructiveAction?.title ?? "",
+            isPresented: Binding(
+                get: { pendingDestructiveAction != nil },
+                set: { isPresented in
+                    if !isPresented {
+                        pendingDestructiveAction = nil
+                    }
+                }
+            )
+        ) {
+            Button("Cancel", role: .cancel) {}
+            Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
+                guard let action = pendingDestructiveAction else { return }
+                pendingDestructiveAction = nil
+                Task {
+                    switch action {
+                    case .deleteSSHKey(let key):
+                        await viewModel.deleteSSHKey(key)
+                    case .deletePGPKey(let key):
+                        await viewModel.deletePGPKey(key)
+                    }
+                }
+            }
+        } message: {
+            if let pendingDestructiveAction {
+                Text(pendingDestructiveAction.message)
+            }
+        }
+        .refreshable {
+            await viewModel.loadProfile()
+        }
+    }
+
+    @ViewBuilder
+    private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
+        Section("Profile") {
+            HStack(spacing: 12) {
+                AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+                    switch phase {
+                    case .success(let image):
+                        image
+                            .resizable()
+                            .scaledToFill()
+                    default:
+                        Image(systemName: "person.crop.circle.fill")
+                            .resizable()
+                            .foregroundStyle(.secondary)
+                    }
+                }
+                .frame(width: 56, height: 56)
+                .clipShape(Circle())
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(profile.canonicalName)
+                        .font(.headline)
+                    Text(profile.email)
+                        .font(.subheadline)
+                        .foregroundStyle(.secondary)
+                    if let userType = profile.userType {
+                        Text(userType.capitalized)
+                            .font(.caption)
+                            .foregroundStyle(.tertiary)
+                    }
+                }
+            }
+            .padding(.vertical, 4)
+
+            if let bio = profile.bio, !bio.isEmpty {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text("Bio")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                    ProfileBioView(markdown: bio)
+                }
+            }
+
+            if let location = profile.location, !location.isEmpty {
+                LabeledContent("Location", value: location)
+            }
+
+            if let url = profile.url, !url.isEmpty {
+                LabeledContent("URL", value: url)
+            }
+
+            if let status = profile.paymentStatus {
+                LabeledContent("Payment", value: status.capitalized)
+            }
+
+            if let sub = profile.subscription {
+                if let status = sub.status {
+                    LabeledContent("Subscription", value: status.capitalized)
+                }
+                if let interval = sub.interval {
+                    LabeledContent("Interval", value: interval.capitalized)
+                }
+            }
+
+            Button("Edit Profile") {
+                viewModel.isEditingProfile = true
+            }
+
+            SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
+                SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        Section {
+            ForEach(viewModel.sshKeys) { key in
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(key.fingerprint)
+                        .font(.caption.monospaced())
+                        .lineLimit(1)
+                        .truncationMode(.middle)
+
+                    HStack {
+                        if let comment = key.comment, !comment.isEmpty {
+                            Text(comment)
+                                .font(.caption2)
+                                .foregroundStyle(.secondary)
+                        }
+                        Spacer()
+                        Text(key.created.relativeDescription)
+                            .font(.caption2)
+                            .foregroundStyle(.tertiary)
+                    }
+
+                    if let lastUsed = key.lastUsed {
+                        Text("Last used \(lastUsed.relativeDescription)")
+                            .font(.caption2)
+                            .foregroundStyle(.tertiary)
+                    }
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    Button("Delete", role: .destructive) {
+                        pendingDestructiveAction = .deleteSSHKey(key)
+                    }
+                }
+            }
+
+            if viewModel.isAddingSSHKey {
+                TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+                    .font(.caption.monospaced())
+                    .lineLimit(3...6)
+
+                HStack {
+                    Button("Cancel") {
+                        viewModel.isAddingSSHKey = false
+                        viewModel.newSSHKey = ""
+                    }
+                    Spacer()
+                    Button("Add") {
+                        Task { await viewModel.addSSHKey() }
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            } else {
+                Button {
+                    viewModel.isAddingSSHKey = true
+                } label: {
+                    SwiftUI.Label("Add SSH Key", systemImage: "key")
+                }
+            }
+        } header: {
+            Text("SSH Keys")
+        } footer: {
+            Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
+        }
+    }
+
+    @ViewBuilder
+    private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
+        @Bindable var vm = viewModel
+
+        Section {
+            ForEach(viewModel.pgpKeys) { key in
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(key.fingerprint)
+                        .font(.caption.monospaced())
+                        .lineLimit(1)
+                        .truncationMode(.middle)
+
+                    Text(key.created.relativeDescription)
+                        .font(.caption2)
+                        .foregroundStyle(.tertiary)
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                    Button("Delete", role: .destructive) {
+                        pendingDestructiveAction = .deletePGPKey(key)
+                    }
+                }
+            }
+
+            if viewModel.isAddingPGPKey {
+                TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+                    .font(.caption.monospaced())
+                    .lineLimit(3...6)
+
+                HStack {
+                    Button("Cancel") {
+                        viewModel.isAddingPGPKey = false
+                        viewModel.newPGPKey = ""
+                    }
+                    Spacer()
+                    Button("Add") {
+                        Task { await viewModel.addPGPKey() }
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+                }
+            } else {
+                Button {
+                    viewModel.isAddingPGPKey = true
+                } label: {
+                    SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
+                }
+            }
+        } header: {
+            Text("PGP Keys")
+        } footer: {
+            Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
+        }
+    }
+
+    @ViewBuilder
+    private func patSection(_ viewModel: SettingsViewModel) -> some View {
+        Section {
+            if viewModel.isLoadingPATs {
+                HStack {
+                    Spacer()
+                    ProgressView()
+                    Spacer()
+                }
+            } else if viewModel.personalAccessTokens.isEmpty {
+                Button("Load Tokens") {
+                    Task { await viewModel.loadPersonalAccessTokens() }
+                }
+            } else {
+                ForEach(viewModel.personalAccessTokens) { token in
+                    VStack(alignment: .leading, spacing: 4) {
+                        HStack {
+                            Text(token.comment ?? "Token #\(token.id)")
+                                .font(.subheadline)
+                            Spacer()
+                        }
+
+                        HStack(spacing: 12) {
+                            Text("Issued \(token.issued.relativeDescription)")
+                                .font(.caption2)
+                                .foregroundStyle(.secondary)
+
+                            if let expires = token.expires {
+                                Text("Expires \(expires.relativeDescription)")
+                                    .font(.caption2)
+                                    .foregroundStyle(expires < Date.now ? .red : .secondary)
+                            }
+                        }
+
+                        if let grants = token.grants, !grants.isEmpty {
+                            Text(grants)
+                                .font(.caption2.monospaced())
+                                .foregroundStyle(.tertiary)
+                                .lineLimit(2)
+                        }
+                    }
+                }
+            }
+        } header: {
+            Text("Personal Access Tokens")
+        } footer: {
+            if !viewModel.personalAccessTokens.isEmpty {
+                Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
+            }
+        }
+    }
+}
+
+private struct ProfileBioView: View {
+    let markdown: String
+
+    var body: some View {
+        Text(profileBioAttributedString(markdown))
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .tint(.accentColor)
+            .textSelection(.enabled)
+    }
+}
+
+func profileBioAttributedString(_ markdown: String) -> AttributedString {
+    guard let attributed = try? AttributedString(
+        markdown: markdown,
+        options: profileBioMarkdownOptions
+    ) else {
+        return AttributedString(markdown)
+    }
+    return attributed
+}
+
+private struct EditProfileSheet: View {
+    let profile: UserProfile
+    let viewModel: SettingsViewModel
+
+    @State private var email: String
+    @State private var url: String
+    @State private var location: String
+    @State private var bio: String
+    @State private var selectedPhoto: PhotosPickerItem?
+    @State private var avatarPreview: UIImage?
+    @State private var isShowingRemoveAvatarConfirmation = false
+
+    @Environment(\.dismiss) private var dismiss
+
+    init(profile: UserProfile, viewModel: SettingsViewModel) {
+        self.profile = profile
+        self.viewModel = viewModel
+        _email = State(initialValue: profile.email)
+        _url = State(initialValue: profile.url ?? "")
+        _location = State(initialValue: profile.location ?? "")
+        _bio = State(initialValue: profile.bio ?? "")
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section {
+                    HStack {
+                        Spacer()
+                        VStack(spacing: 8) {
+                            PhotosPicker(selection: $selectedPhoto, matching: .images) {
+                                Group {
+                                    if let avatarPreview {
+                                        Image(uiImage: avatarPreview)
+                                            .resizable()
+                                            .scaledToFill()
+                                    } else {
+                                        AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+                                            switch phase {
+                                            case .success(let image):
+                                                image
+                                                    .resizable()
+                                                    .scaledToFill()
+                                            default:
+                                                Image(systemName: "person.crop.circle.fill")
+                                                    .resizable()
+                                                    .foregroundStyle(.secondary)
+                                            }
+                                        }
+                                    }
+                                }
+                                .frame(width: 80, height: 80)
+                                .clipShape(Circle())
+                                .overlay(
+                                    Circle()
+                                        .stroke(.secondary.opacity(0.3), lineWidth: 1)
+                                )
+                            }
+
+                            Text("Tap to change avatar")
+                                .font(.caption)
+                                .foregroundStyle(.secondary)
+
+                            if viewModel.isUploadingAvatar {
+                                ProgressView()
+                                    .controlSize(.small)
+                            }
+                        }
+                        Spacer()
+                    }
+                    .listRowBackground(Color.clear)
+
+                    if profile.avatar != nil || avatarPreview != nil {
+                        HStack {
+                            Spacer()
+                            Button(role: .destructive) {
+                                isShowingRemoveAvatarConfirmation = true
+                            } label: {
+                                Text("Remove Avatar")
+                            }
+                            .buttonStyle(.borderedProminent)
+                            .disabled(viewModel.isUploadingAvatar)
+                            Spacer()
+                        }
+                        .listRowBackground(Color.clear)
+                        .listRowSeparator(.hidden)
+                    }
+                }
+
+                Section("Edit Profile") {
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text("Email")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        TextField("Enter email", text: $email)
+                            .textContentType(.emailAddress)
+                            .keyboardType(.emailAddress)
+                            .autocorrectionDisabled()
+                            .textInputAutocapitalization(.never)
+                    }
+
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text("URL")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        TextField("Enter URL", text: $url)
+                            .textContentType(.URL)
+                            .keyboardType(.URL)
+                            .autocorrectionDisabled()
+                            .textInputAutocapitalization(.never)
+                    }
+
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text("Location")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        TextField("Enter location", text: $location)
+                    }
+
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text("Bio")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                        TextField("Enter bio", text: $bio, axis: .vertical)
+                            .lineLimit(3...6)
+                    }
+                }
+            }
+            .navigationTitle("Edit Profile")
+            .navigationBarTitleDisplayMode(.inline)
+            .onChange(of: selectedPhoto) { _, newItem in
+                guard let newItem else { return }
+                Task {
+                    if let data = try? await newItem.loadTransferable(type: Data.self),
+                       let image = UIImage(data: data) {
+                        avatarPreview = image
+                        if let jpegData = image.jpegData(compressionQuality: 0.85) {
+                            await viewModel.uploadAvatar(jpegData: jpegData)
+                        }
+                    }
+                }
+            }
+            .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
+                Button("Cancel", role: .cancel) {}
+                Button("Remove Avatar", role: .destructive) {
+                    Task {
+                        await viewModel.removeAvatar()
+                        if viewModel.error == nil {
+                            avatarPreview = nil
+                            selectedPhoto = nil
+                        }
+                    }
+                }
+            } message: {
+                Text("Your profile avatar will be removed from SourceHut.")
+            }
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") {
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button {
+                        Task {
+                            await viewModel.saveProfile(
+                                email: email,
+                                url: url,
+                                location: location,
+                                bio: bio
+                            )
+                            if viewModel.error == nil {
+                                dismiss()
+                            }
+                        }
+                    } label: {
+                        if viewModel.isSavingProfile {
+                            ProgressView()
+                                .controlSize(.small)
+                        } else {
+                            Text("Save")
+                        }
+                    }
+                    .disabled(viewModel.isSavingProfile)
+                }
+            }
+        }
+    }
+}
+
+private enum ProfileDestructiveAction {
+    case deleteSSHKey(SSHKey)
+    case deletePGPKey(PGPKey)
+
+    var title: String {
+        switch self {
+        case .deleteSSHKey:
+            "Remove SSH Key?"
+        case .deletePGPKey:
+            "Remove PGP Key?"
+        }
+    }
+
+    var confirmationLabel: String {
+        switch self {
+        case .deleteSSHKey:
+            "Remove SSH Key"
+        case .deletePGPKey:
+            "Remove PGP Key"
+        }
+    }
+
+    var message: String {
+        switch self {
+        case .deleteSSHKey(let key):
+            "Remove SSH key \(key.fingerprint) from your account?"
+        case .deletePGPKey(let key):
+            "Remove PGP key \(key.fingerprint) from your account?"
+        }
+    }
+}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 2c9e528..a249807 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -1,94 +1,18 @@
-import PhotosUI
 import SwiftUI
 
-private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
-    interpretedSyntax: .inlineOnlyPreservingWhitespace
-)
-
 struct SettingsView: View {
     @Environment(AppState.self) private var appState
-    @Environment(\.colorScheme) private var colorScheme
     @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
-    @State private var viewModel: SettingsViewModel?
+    @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
     @State private var pendingDestructiveAction: SettingsDestructiveAction?
 
     var body: some View {
-        Group {
-            if let viewModel {
-                settingsContent(viewModel)
-            } else {
-                SRHTLoadingStateView(message: "Loading profile…")
-            }
-        }
-        .navigationTitle("Settings")
-        .task {
-            if viewModel == nil {
-                let vm = SettingsViewModel(client: appState.client)
-                viewModel = vm
-                await vm.loadProfile()
-            }
-        }
-    }
-
-    @ViewBuilder
-    private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         Form {
-            if let profile = viewModel.profile {
-                // Profile section
-                profileSection(profile, viewModel: viewModel)
-
-                // SSH Keys
-                sshKeysSection(viewModel)
-
-                // PGP Keys
-                pgpKeysSection(viewModel)
-
-                // Personal Access Tokens
-                patSection(viewModel)
-            }
-
             behaviorSection()
-
-            // Token / Sign Out
-            tokenSection()
-
+            authenticationSection()
             aboutSection()
         }
-        .overlay {
-            if viewModel.isLoading, viewModel.profile == nil {
-                SRHTLoadingStateView(message: "Loading profile…")
-            } else if let error = viewModel.error, viewModel.profile == nil {
-                SRHTErrorStateView(
-                    title: "Couldn't Load Profile",
-                    message: error,
-                    retryAction: { await viewModel.loadProfile() }
-                )
-            }
-        }
-        .sheet(isPresented: $vm.isEditingProfile) {
-            if let profile = viewModel.profile {
-                EditProfileSheet(
-                    profile: profile,
-                    viewModel: viewModel
-                )
-            }
-        }
-        .alert("Error", isPresented: Binding(
-            get: { viewModel.error != nil && viewModel.profile != nil },
-            set: { isPresented in
-                if !isPresented {
-                    viewModel.error = nil
-                }
-            }
-        )) {
-            Button("OK") { viewModel.error = nil }
-        } message: {
-            if let error = viewModel.error {
-                Text(error)
-            }
-        }
+        .navigationTitle("Settings")
         .alert(
             pendingDestructiveAction?.title ?? "",
             isPresented: Binding(
@@ -100,9 +24,7 @@ struct SettingsView: View {
                 }
             )
         ) {
-            Button("Cancel", role: .cancel) {
-                // Alert dismissal is implicit; no additional action required.
-            }
+            Button("Cancel", role: .cancel) {}
             Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
                 guard let action = pendingDestructiveAction else { return }
                 pendingDestructiveAction = nil
@@ -112,10 +34,6 @@ struct SettingsView: View {
                         await appState.resetAppData()
                     case .signOut:
                         await appState.signOut()
-                    case .deleteSSHKey(let key):
-                        await viewModel.deleteSSHKey(key)
-                    case .deletePGPKey(let key):
-                        await viewModel.deletePGPKey(key)
                     }
                 }
             }
@@ -124,282 +42,22 @@ struct SettingsView: View {
                 Text(pendingDestructiveAction.message)
             }
         }
-        .refreshable {
-            await viewModel.loadProfile()
-        }
     }
 
-    // MARK: - Profile Section
-
-    @ViewBuilder
-    private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
-        Section("Profile") {
-            HStack(spacing: 12) {
-                AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
-                    switch phase {
-                    case .success(let image):
-                        image
-                            .resizable()
-                            .scaledToFill()
-                    default:
-                        Image(systemName: "person.crop.circle.fill")
-                            .resizable()
-                            .foregroundStyle(.secondary)
-                    }
-                }
-                .frame(width: 56, height: 56)
-                .clipShape(Circle())
-
-                VStack(alignment: .leading, spacing: 2) {
-                    Text(profile.canonicalName)
-                        .font(.headline)
-                    Text(profile.email)
-                        .font(.subheadline)
-                        .foregroundStyle(.secondary)
-                    if let userType = profile.userType {
-                        Text(userType.capitalized)
-                            .font(.caption)
-                            .foregroundStyle(.tertiary)
-                    }
-                }
-            }
-            .padding(.vertical, 4)
-
-            if let bio = profile.bio, !bio.isEmpty {
-                VStack(alignment: .leading, spacing: 2) {
-                    Text("Bio")
-                        .font(.caption)
-                        .foregroundStyle(.secondary)
-                    SettingsBioView(markdown: bio)
-                }
-            }
-
-            if let location = profile.location, !location.isEmpty {
-                LabeledContent("Location", value: location)
-            }
-
-            if let url = profile.url, !url.isEmpty {
-                LabeledContent("URL", value: url)
-            }
-
-            if let status = profile.paymentStatus {
-                LabeledContent("Payment", value: status.capitalized)
-            }
-
-            if let sub = profile.subscription {
-                if let status = sub.status {
-                    LabeledContent("Subscription", value: status.capitalized)
-                }
-                if let interval = sub.interval {
-                    LabeledContent("Interval", value: interval.capitalized)
-                }
-            }
-
-            Button("Edit Profile") {
-                viewModel.isEditingProfile = true
-            }
-
-            SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
-                SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
-            }
-        }
-    }
-
-    // MARK: - SSH Keys Section
-
-    @ViewBuilder
-    private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
-        Section {
-            ForEach(viewModel.sshKeys) { key in
-                VStack(alignment: .leading, spacing: 2) {
-                    Text(key.fingerprint)
-                        .font(.caption.monospaced())
-                        .lineLimit(1)
-                        .truncationMode(.middle)
-
-                    HStack {
-                        if let comment = key.comment, !comment.isEmpty {
-                            Text(comment)
-                                .font(.caption2)
-                                .foregroundStyle(.secondary)
-                        }
-                        Spacer()
-                        Text(key.created.relativeDescription)
-                            .font(.caption2)
-                            .foregroundStyle(.tertiary)
-                    }
-
-                    if let lastUsed = key.lastUsed {
-                        Text("Last used \(lastUsed.relativeDescription)")
-                            .font(.caption2)
-                            .foregroundStyle(.tertiary)
-                    }
-                }
-                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                    Button("Delete", role: .destructive) {
-                        pendingDestructiveAction = .deleteSSHKey(key)
-                    }
-                }
-            }
-
-            if viewModel.isAddingSSHKey {
-                TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
-                    .font(.caption.monospaced())
-                    .lineLimit(3...6)
-
-                HStack {
-                    Button("Cancel") {
-                        viewModel.isAddingSSHKey = false
-                        viewModel.newSSHKey = ""
-                    }
-                    Spacer()
-                    Button("Add") {
-                        Task { await viewModel.addSSHKey() }
-                    }
-                    .buttonStyle(.borderedProminent)
-                    .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
-                }
-            } else {
-                Button {
-                    viewModel.isAddingSSHKey = true
-                } label: {
-                    SwiftUI.Label("Add SSH Key", systemImage: "key")
-                }
-            }
-        } header: {
-            Text("SSH Keys")
-        } footer: {
-            Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
-        }
-    }
-
-    // MARK: - PGP Keys Section
-
-    @ViewBuilder
-    private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
-        Section {
-            ForEach(viewModel.pgpKeys) { key in
-                VStack(alignment: .leading, spacing: 2) {
-                    Text(key.fingerprint)
-                        .font(.caption.monospaced())
-                        .lineLimit(1)
-                        .truncationMode(.middle)
-
-                    Text(key.created.relativeDescription)
-                        .font(.caption2)
-                        .foregroundStyle(.tertiary)
-                }
-                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                    Button("Delete", role: .destructive) {
-                        pendingDestructiveAction = .deletePGPKey(key)
-                    }
-                }
-            }
-
-            if viewModel.isAddingPGPKey {
-                TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
-                    .font(.caption.monospaced())
-                    .lineLimit(3...6)
-
-                HStack {
-                    Button("Cancel") {
-                        viewModel.isAddingPGPKey = false
-                        viewModel.newPGPKey = ""
-                    }
-                    Spacer()
-                    Button("Add") {
-                        Task { await viewModel.addPGPKey() }
-                    }
-                    .buttonStyle(.borderedProminent)
-                    .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
-                }
-            } else {
-                Button {
-                    viewModel.isAddingPGPKey = true
-                } label: {
-                    SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
-                }
-            }
-        } header: {
-            Text("PGP Keys")
-        } footer: {
-            Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
-        }
-    }
-
-    // MARK: - Personal Access Tokens Section
-
-    @ViewBuilder
-    private func patSection(_ viewModel: SettingsViewModel) -> some View {
-        Section {
-            if viewModel.isLoadingPATs {
-                HStack {
-                    Spacer()
-                    ProgressView()
-                    Spacer()
-                }
-            } else if viewModel.personalAccessTokens.isEmpty {
-                Button("Load Tokens") {
-                    Task { await viewModel.loadPersonalAccessTokens() }
-                }
-            } else {
-                ForEach(viewModel.personalAccessTokens) { token in
-                    VStack(alignment: .leading, spacing: 4) {
-                        HStack {
-                            Text(token.comment ?? "Token #\(token.id)")
-                                .font(.subheadline)
-                            Spacer()
-                        }
-
-                        HStack(spacing: 12) {
-                            Text("Issued \(token.issued.relativeDescription)")
-                                .font(.caption2)
-                                .foregroundStyle(.secondary)
-
-                            if let expires = token.expires {
-                                Text("Expires \(expires.relativeDescription)")
-                                    .font(.caption2)
-                                    .foregroundStyle(expires < Date.now ? .red : .secondary)
-                            }
-                        }
-
-                        if let grants = token.grants, !grants.isEmpty {
-                            Text(grants)
-                                .font(.caption2.monospaced())
-                                .foregroundStyle(.tertiary)
-                                .lineLimit(2)
-                        }
-                    }
-                }
-            }
-        } header: {
-            Text("Personal Access Tokens")
-        } footer: {
-            if !viewModel.personalAccessTokens.isEmpty {
-                Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
-            }
-        }
-    }
-
-    // MARK: - Token / Sign Out Section
-
     @ViewBuilder
     private func behaviorSection() -> some View {
         Section {
             Toggle("Swipe actions", isOn: $swipeActionsEnabled)
+            Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
         } header: {
             Text("Behavior")
         } footer: {
-            Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes.")
+            Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes. Contribution graphs controls whether SourceHut activity heatmaps appear in lookup profiles.")
         }
     }
 
     @ViewBuilder
-    private func tokenSection() -> some View {
+    private func authenticationSection() -> some View {
         Section {
             HStack {
                 Image(systemName: "key.fill")
@@ -436,227 +94,13 @@ struct SettingsView: View {
     }
 }
 
-private struct SettingsBioView: View {
-    let markdown: String
-
-    var body: some View {
-        Text(settingsBioAttributedString(markdown))
-            .frame(maxWidth: .infinity, alignment: .leading)
-            .tint(.accentColor)
-            .textSelection(.enabled)
-    }
-}
-
 func settingsBioAttributedString(_ markdown: String) -> AttributedString {
-    guard let attributed = try? AttributedString(
-        markdown: markdown,
-        options: settingsBioMarkdownOptions
-    ) else {
-        return AttributedString(markdown)
-    }
-    return attributed
-}
-
-// MARK: - Edit Profile Sheet
-
-private struct EditProfileSheet: View {
-    let profile: UserProfile
-    let viewModel: SettingsViewModel
-
-    @State private var email: String
-    @State private var url: String
-    @State private var location: String
-    @State private var bio: String
-    @State private var selectedPhoto: PhotosPickerItem?
-    @State private var avatarPreview: UIImage?
-    @State private var isShowingRemoveAvatarConfirmation = false
-
-    @Environment(\.dismiss) private var dismiss
-
-    init(profile: UserProfile, viewModel: SettingsViewModel) {
-        self.profile = profile
-        self.viewModel = viewModel
-        _email = State(initialValue: profile.email)
-        _url = State(initialValue: profile.url ?? "")
-        _location = State(initialValue: profile.location ?? "")
-        _bio = State(initialValue: profile.bio ?? "")
-    }
-
-    var body: some View {
-        NavigationStack {
-            Form {
-                Section {
-                    HStack {
-                        Spacer()
-                        VStack(spacing: 8) {
-                            PhotosPicker(selection: $selectedPhoto, matching: .images) {
-                                Group {
-                                    if let avatarPreview {
-                                        Image(uiImage: avatarPreview)
-                                            .resizable()
-                                            .scaledToFill()
-                                    } else {
-                                        AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
-                                            switch phase {
-                                            case .success(let image):
-                                                image
-                                                    .resizable()
-                                                    .scaledToFill()
-                                            default:
-                                                Image(systemName: "person.crop.circle.fill")
-                                                    .resizable()
-                                                    .foregroundStyle(.secondary)
-                                            }
-                                        }
-                                    }
-                                }
-                                .frame(width: 80, height: 80)
-                                .clipShape(Circle())
-                                .overlay(
-                                    Circle()
-                                        .stroke(.secondary.opacity(0.3), lineWidth: 1)
-                                )
-                            }
-
-                            Text("Tap to change avatar")
-                                .font(.caption)
-                                .foregroundStyle(.secondary)
-
-                            if viewModel.isUploadingAvatar {
-                                ProgressView()
-                                    .controlSize(.small)
-                            }
-                        }
-                        Spacer()
-                    }
-                    .listRowBackground(Color.clear)
-
-                    if profile.avatar != nil || avatarPreview != nil {
-                        HStack {
-                            Spacer()
-                            Button(role: .destructive) {
-                                isShowingRemoveAvatarConfirmation = true
-                            } label: {
-                                Text("Remove Avatar")
-                            }
-                            .buttonStyle(.borderedProminent)
-                            .disabled(viewModel.isUploadingAvatar)
-                            Spacer()
-                        }
-                        .listRowBackground(Color.clear)
-                        .listRowSeparator(.hidden)
-                    }
-                }
-
-                Section("Edit Profile") {
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("Email")
-                            .font(.caption)
-                            .foregroundStyle(.secondary)
-                        TextField("Enter email", text: $email)
-                            .textContentType(.emailAddress)
-                            .keyboardType(.emailAddress)
-                            .autocorrectionDisabled()
-                            .textInputAutocapitalization(.never)
-                    }
-
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("URL")
-                            .font(.caption)
-                            .foregroundStyle(.secondary)
-                        TextField("Enter URL", text: $url)
-                            .textContentType(.URL)
-                            .keyboardType(.URL)
-                            .autocorrectionDisabled()
-                            .textInputAutocapitalization(.never)
-                    }
-
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("Location")
-                            .font(.caption)
-                            .foregroundStyle(.secondary)
-                        TextField("Enter location", text: $location)
-                    }
-
-                    VStack(alignment: .leading, spacing: 4) {
-                        Text("Bio")
-                            .font(.caption)
-                            .foregroundStyle(.secondary)
-                        TextField("Enter bio", text: $bio, axis: .vertical)
-                            .lineLimit(3...6)
-                    }
-                }
-            }
-            .navigationTitle("Edit Profile")
-            .navigationBarTitleDisplayMode(.inline)
-            .onChange(of: selectedPhoto) { _, newItem in
-                guard let newItem else { return }
-                Task {
-                    if let data = try? await newItem.loadTransferable(type: Data.self),
-                       let image = UIImage(data: data) {
-                        avatarPreview = image
-                        // Encode as JPEG and upload
-                        if let jpegData = image.jpegData(compressionQuality: 0.85) {
-                            await viewModel.uploadAvatar(jpegData: jpegData)
-                        }
-                    }
-                }
-            }
-            .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
-                Button("Cancel", role: .cancel) {
-                    // Alert dismissal is implicit; no additional action required.
-                }
-                Button("Remove Avatar", role: .destructive) {
-                    Task {
-                        await viewModel.removeAvatar()
-                        if viewModel.error == nil {
-                            avatarPreview = nil
-                            selectedPhoto = nil
-                        }
-                    }
-                }
-            } message: {
-                Text("Your profile avatar will be removed from SourceHut.")
-            }
-            .toolbar {
-                ToolbarItem(placement: .cancellationAction) {
-                    Button("Cancel") {
-                        dismiss()
-                    }
-                }
-                ToolbarItem(placement: .confirmationAction) {
-                    Button {
-                        Task {
-                            await viewModel.saveProfile(
-                                email: email,
-                                url: url,
-                                location: location,
-                                bio: bio
-                            )
-                            if viewModel.error == nil {
-                                dismiss()
-                            }
-                        }
-                    } label: {
-                        if viewModel.isSavingProfile {
-                            ProgressView()
-                                .controlSize(.small)
-                        } else {
-                            Text("Save")
-                        }
-                    }
-                    .disabled(viewModel.isSavingProfile)
-                }
-            }
-        }
-    }
+    profileBioAttributedString(markdown)
 }
 
 private enum SettingsDestructiveAction {
     case resetAppData
     case signOut
-    case deleteSSHKey(SSHKey)
-    case deletePGPKey(PGPKey)
 
     var title: String {
         switch self {
@@ -664,10 +108,6 @@ private enum SettingsDestructiveAction {
             "Reset App Data?"
         case .signOut:
             "Sign Out?"
-        case .deleteSSHKey:
-            "Remove SSH Key?"
-        case .deletePGPKey:
-            "Remove PGP Key?"
         }
     }
 
@@ -677,10 +117,6 @@ private enum SettingsDestructiveAction {
             "Reset App Data"
         case .signOut:
             "Sign Out"
-        case .deleteSSHKey:
-            "Remove SSH Key"
-        case .deletePGPKey:
-            "Remove PGP Key"
         }
     }
 
@@ -690,10 +126,6 @@ private enum SettingsDestructiveAction {
             "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
         case .signOut:
             "This signs you out of Hutch and clears saved authentication state on this device."
-        case .deleteSSHKey(let key):
-            "Remove SSH key \(key.fingerprint) from your account?"
-        case .deletePGPKey(let key):
-            "Remove PGP key \(key.fingerprint) from your account?"
         }
     }
 }