summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 19:47:34 -0500
committerChristian Cleberg <[email protected]>2026-04-11 19:47:34 -0500
commit27bfcbbed6acde158ec40c5bb46e45e76c81a319 (patch)
tree0acee79279f8544124b1bc0f0b056265513a6d92 /Hutch
parent2e6b653f4d0b54d892a448f908799b8ac4d726a9 (diff)
downloadhutch-2.10.3.tar.gz
hutch-2.10.3.tar.bz2
hutch-2.10.3.zip
refactor: extract ProfileView from SettingsViewv2.10.3
Move profile-related UI into its own dedicated ProfileView, simplifying SettingsView and improving navigation structure in MoreView.
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Views/More/MoreView.swift12
-rw-r--r--Hutch/Views/More/ProfileView.swift641
-rw-r--r--Hutch/Views/Settings/SettingsView.swift584
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?"
}
}
}