summaryrefslogtreecommitdiff
path: root/Hutch/Views/Settings
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Settings')
-rw-r--r--Hutch/Views/Settings/SettingsView.swift584
1 files changed, 8 insertions, 576 deletions
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?"
}
}
}