summaryrefslogtreecommitdiff
path: root/Hutch/Views/Settings
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Views/Settings
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Views/Settings')
-rw-r--r--Hutch/Views/Settings/SettingsView.swift724
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift394
2 files changed, 1118 insertions, 0 deletions
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..5bbded8
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -0,0 +1,724 @@
+import PhotosUI
+import SwiftUI
+
+struct SettingsView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: SettingsViewModel?
+ @State private var pendingDestructiveAction: SettingsDestructiveAction?
+
+ var body: some View {
+ NavigationStack {
+ 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)
+ }
+
+ // Token / Sign Out
+ tokenSection()
+
+ 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)
+ }
+ }
+ .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 .resetAppData:
+ await appState.resetAppData()
+ case .signOut:
+ await appState.signOut()
+ 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()
+ }
+ }
+
+ // 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)
+ RenderedMarkupContentView(
+ content: .markdown(bio),
+ readmePath: nil,
+ colorScheme: colorScheme,
+ ownerCanonicalName: "",
+ repositoryName: ""
+ )
+ }
+ }
+
+ 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 tokenSection() -> some View {
+ Section {
+ HStack {
+ Image(systemName: "key.fill")
+ .foregroundStyle(.secondary)
+ Text("Personal access token in use")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+
+ Button("Reset App Data", role: .destructive) {
+ pendingDestructiveAction = .resetAppData
+ }
+
+ Button("Sign Out", role: .destructive) {
+ pendingDestructiveAction = .signOut
+ }
+ } header: {
+ Text("Authentication")
+ } footer: {
+ Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.")
+ }
+ }
+
+ @ViewBuilder
+ private func aboutSection() -> some View {
+ Section("App") {
+ NavigationLink {
+ AboutView()
+ } label: {
+ SwiftUI.Label("About Hutch", systemImage: "info.circle")
+ }
+ }
+ }
+}
+
+// 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) {}
+ 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 SettingsDestructiveAction {
+ case resetAppData
+ case signOut
+ case deleteSSHKey(SSHKey)
+ case deletePGPKey(PGPKey)
+
+ var title: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data?"
+ case .signOut:
+ "Sign Out?"
+ case .deleteSSHKey:
+ "Remove SSH Key?"
+ case .deletePGPKey:
+ "Remove PGP Key?"
+ }
+ }
+
+ var confirmationLabel: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data"
+ case .signOut:
+ "Sign Out"
+ case .deleteSSHKey:
+ "Remove SSH Key"
+ case .deletePGPKey:
+ "Remove PGP Key"
+ }
+ }
+
+ var message: String {
+ switch self {
+ case .resetAppData:
+ "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?"
+ }
+ }
+}
+
+private struct AboutView: View {
+ private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
+ ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
+ ?? "Hutch"
+ private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "Unknown"
+ private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+ ?? "Unknown"
+
+ var body: some View {
+ Form {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(appName)
+ .font(.title2.weight(.semibold))
+ Text("A native SourceHut client for iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+
+ LabeledContent("Version", value: version)
+ LabeledContent("Build", value: build)
+ }
+
+ Section("Links") {
+ Link(destination: URL(string: "https://sr.ht")!) {
+ SwiftUI.Label("SourceHut", systemImage: "link")
+ }
+ Link(destination: URL(string: "https://man.sr.ht")!) {
+ SwiftUI.Label("SourceHut Manuals", systemImage: "book")
+ }
+ Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) {
+ SwiftUI.Label("Project Repository", systemImage: "folder")
+ }
+ }
+
+ Section("Support") {
+ Link(destination: URL(string: "mailto:[email protected]")!) {
+ SwiftUI.Label("Email Support", systemImage: "envelope")
+ }
+ }
+
+ Section("Privacy") {
+ Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Acknowledgements") {
+ Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("About")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
new file mode 100644
index 0000000..204afbf
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -0,0 +1,394 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct MeProfileResponse: Decodable, Sendable {
+ let me: UserProfile
+}
+
+private struct UpdateUserResponse: Decodable, Sendable {
+ let updateUser: UpdatedUser
+}
+
+private struct UpdatedUser: Decodable, Sendable {
+ let username: String
+ let email: String
+ let url: String?
+ let location: String?
+ let bio: String?
+ let avatar: String?
+}
+
+private struct CreateSSHKeyResponse: Decodable, Sendable {
+ let createSSHKey: SSHKey
+}
+
+private struct DeleteSSHKeyResponse: Decodable, Sendable {
+ let deleteSSHKey: DeleteResult
+}
+
+private struct CreatePGPKeyResponse: Decodable, Sendable {
+ let createPGPKey: PGPKey
+}
+
+private struct DeletePGPKeyResponse: Decodable, Sendable {
+ let deletePGPKey: DeleteResult
+}
+
+private struct DeleteResult: Decodable, Sendable {
+ let id: Int?
+}
+
+private struct PATListResponse: Decodable, Sendable {
+ let personalAccessTokens: [PersonalAccessToken]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class SettingsViewModel {
+
+ private(set) var profile: UserProfile?
+ private(set) var sshKeys: [SSHKey] = []
+ private(set) var pgpKeys: [PGPKey] = []
+ private(set) var personalAccessTokens: [PersonalAccessToken] = []
+
+ private(set) var isLoading = false
+ private(set) var isLoadingPATs = false
+ private(set) var isSavingProfile = false
+ private(set) var isUploadingAvatar = false
+ var error: String?
+
+ var isEditingProfile = false
+
+ // Add SSH key fields
+ var newSSHKey = ""
+ var isAddingSSHKey = false
+
+ // Add PGP key fields
+ var newPGPKey = ""
+ var isAddingPGPKey = false
+
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let profileQuery = """
+ query me {
+ me {
+ username
+ canonicalName
+ email
+ url
+ location
+ bio
+ avatar
+ userType
+ sshKeys {
+ results { id fingerprint comment created lastUsed }
+ cursor
+ }
+ pgpKeys {
+ results { id fingerprint created }
+ cursor
+ }
+ paymentStatus
+ subscription { status autorenew interval }
+ }
+ }
+ """
+
+ private static let updateUserMutation = """
+ mutation updateUser($input: UserInput!) {
+ updateUser(input: $input) {
+ username email url location bio avatar
+ }
+ }
+ """
+
+ private static let createSSHKeyMutation = """
+ mutation createSSHKey($key: String!) {
+ createSSHKey(key: $key) {
+ id fingerprint comment created lastUsed
+ }
+ }
+ """
+
+ private static let deleteSSHKeyMutation = """
+ mutation deleteSSHKey($id: Int!) {
+ deleteSSHKey(id: $id) { id }
+ }
+ """
+
+ private static let createPGPKeyMutation = """
+ mutation createPGPKey($key: String!) {
+ createPGPKey(key: $key) {
+ id fingerprint created
+ }
+ }
+ """
+
+ private static let deletePGPKeyMutation = """
+ mutation deletePGPKey($id: Int!) {
+ deletePGPKey(id: $id) { id }
+ }
+ """
+
+ private static let personalAccessTokensQuery = """
+ query personalAccessTokens {
+ personalAccessTokens { id issued expires comment grants }
+ }
+ """
+
+ // MARK: - Load Profile
+
+ func loadProfile() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.profileQuery,
+ responseType: MeProfileResponse.self
+ )
+ profile = result.me
+ sshKeys = result.me.sshKeys.results
+ pgpKeys = result.me.pgpKeys.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ // MARK: - Update Profile
+
+ func saveProfile(email: String, url: String, location: String, bio: String) async {
+ guard !isSavingProfile else { return }
+ isSavingProfile = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "email": email,
+ "url": url.isEmpty ? nil as String? as Any : url,
+ "location": location.isEmpty ? nil as String? as Any : location,
+ "bio": bio.isEmpty ? nil as String? as Any : bio
+ ]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ isEditingProfile = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSavingProfile = false
+ }
+
+ // MARK: - Avatar
+
+ func uploadAvatar(jpegData: Data) async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ // The input variable has avatar set to null; the actual file
+ // is sent as a separate multipart part per graphql-multipart-request-spec.
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.executeMultipart(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ fileVariablePath: "input.avatar",
+ fileData: jpegData,
+ fileName: "avatar.jpg",
+ mimeType: "image/jpeg",
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ func removeAvatar() async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: nil,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ // MARK: - SSH Keys
+
+ func addSSHKey() async {
+ let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createSSHKeyMutation,
+ variables: ["key": key],
+ responseType: CreateSSHKeyResponse.self
+ )
+ sshKeys.append(result.createSSHKey)
+ newSSHKey = ""
+ isAddingSSHKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteSSHKey(_ key: SSHKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deleteSSHKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeleteSSHKeyResponse.self
+ )
+ sshKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - PGP Keys
+
+ func addPGPKey() async {
+ let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createPGPKeyMutation,
+ variables: ["key": key],
+ responseType: CreatePGPKeyResponse.self
+ )
+ pgpKeys.append(result.createPGPKey)
+ newPGPKey = ""
+ isAddingPGPKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deletePGPKey(_ key: PGPKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deletePGPKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeletePGPKeyResponse.self
+ )
+ pgpKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Personal Access Tokens
+
+ func loadPersonalAccessTokens() async {
+ guard !isLoadingPATs else { return }
+ isLoadingPATs = true
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.personalAccessTokensQuery,
+ responseType: PATListResponse.self
+ )
+ personalAccessTokens = result.personalAccessTokens
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingPATs = false
+ }
+
+}