Hutch/Views/Settings/SettingsView.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Settings/SettingsView.swift history · blame · raw

743 lines · 27524 bytes

  1import PhotosUI
  2import SwiftUI
  3
  4private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
  5    interpretedSyntax: .inlineOnlyPreservingWhitespace
  6)
  7
  8struct SettingsView: View {
  9    @Environment(AppState.self) private var appState
 10    @Environment(\.colorScheme) private var colorScheme
 11    @State private var viewModel: SettingsViewModel?
 12    @State private var pendingDestructiveAction: SettingsDestructiveAction?
 13
 14    var body: some View {
 15        NavigationStack {
 16            Group {
 17                if let viewModel {
 18                    settingsContent(viewModel)
 19                } else {
 20                    SRHTLoadingStateView(message: "Loading profile…")
 21                }
 22            }
 23            .navigationTitle("Settings")
 24            .task {
 25                if viewModel == nil {
 26                    let vm = SettingsViewModel(client: appState.client)
 27                    viewModel = vm
 28                    await vm.loadProfile()
 29                }
 30            }
 31        }
 32    }
 33
 34    @ViewBuilder
 35    private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
 36        @Bindable var vm = viewModel
 37
 38        Form {
 39            if let profile = viewModel.profile {
 40                // Profile section
 41                profileSection(profile, viewModel: viewModel)
 42
 43                // SSH Keys
 44                sshKeysSection(viewModel)
 45
 46                // PGP Keys
 47                pgpKeysSection(viewModel)
 48
 49                // Personal Access Tokens
 50                patSection(viewModel)
 51            }
 52
 53            // Token / Sign Out
 54            tokenSection()
 55
 56            aboutSection()
 57        }
 58        .overlay {
 59            if viewModel.isLoading, viewModel.profile == nil {
 60                SRHTLoadingStateView(message: "Loading profile…")
 61            } else if let error = viewModel.error, viewModel.profile == nil {
 62                SRHTErrorStateView(
 63                    title: "Couldn't Load Profile",
 64                    message: error,
 65                    retryAction: { await viewModel.loadProfile() }
 66                )
 67            }
 68        }
 69        .sheet(isPresented: $vm.isEditingProfile) {
 70            if let profile = viewModel.profile {
 71                EditProfileSheet(
 72                    profile: profile,
 73                    viewModel: viewModel
 74                )
 75            }
 76        }
 77        .alert("Error", isPresented: Binding(
 78            get: { viewModel.error != nil && viewModel.profile != nil },
 79            set: { isPresented in
 80                if !isPresented {
 81                    viewModel.error = nil
 82                }
 83            }
 84        )) {
 85            Button("OK") { viewModel.error = nil }
 86        } message: {
 87            if let error = viewModel.error {
 88                Text(error)
 89            }
 90        }
 91        .alert(
 92            pendingDestructiveAction?.title ?? "",
 93            isPresented: Binding(
 94                get: { pendingDestructiveAction != nil },
 95                set: { isPresented in
 96                    if !isPresented {
 97                        pendingDestructiveAction = nil
 98                    }
 99                }
100            )
101        ) {
102            Button("Cancel", role: .cancel) {}
103            Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
104                guard let action = pendingDestructiveAction else { return }
105                pendingDestructiveAction = nil
106                Task {
107                    switch action {
108                    case .resetAppData:
109                        await appState.resetAppData()
110                    case .signOut:
111                        await appState.signOut()
112                    case .deleteSSHKey(let key):
113                        await viewModel.deleteSSHKey(key)
114                    case .deletePGPKey(let key):
115                        await viewModel.deletePGPKey(key)
116                    }
117                }
118            }
119        } message: {
120            if let pendingDestructiveAction {
121                Text(pendingDestructiveAction.message)
122            }
123        }
124        .refreshable {
125            await viewModel.loadProfile()
126        }
127    }
128
129    // MARK: - Profile Section
130
131    @ViewBuilder
132    private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
133        Section("Profile") {
134            HStack(spacing: 12) {
135                AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
136                    switch phase {
137                    case .success(let image):
138                        image
139                            .resizable()
140                            .scaledToFill()
141                    default:
142                        Image(systemName: "person.crop.circle.fill")
143                            .resizable()
144                            .foregroundStyle(.secondary)
145                    }
146                }
147                .frame(width: 56, height: 56)
148                .clipShape(Circle())
149
150                VStack(alignment: .leading, spacing: 2) {
151                    Text(profile.canonicalName)
152                        .font(.headline)
153                    Text(profile.email)
154                        .font(.subheadline)
155                        .foregroundStyle(.secondary)
156                    if let userType = profile.userType {
157                        Text(userType.capitalized)
158                            .font(.caption)
159                            .foregroundStyle(.tertiary)
160                    }
161                }
162            }
163            .padding(.vertical, 4)
164
165            if let bio = profile.bio, !bio.isEmpty {
166                VStack(alignment: .leading, spacing: 2) {
167                    Text("Bio")
168                        .font(.caption)
169                        .foregroundStyle(.secondary)
170                    SettingsBioView(markdown: bio)
171                }
172            }
173
174            if let location = profile.location, !location.isEmpty {
175                LabeledContent("Location", value: location)
176            }
177
178            if let url = profile.url, !url.isEmpty {
179                LabeledContent("URL", value: url)
180            }
181
182            if let status = profile.paymentStatus {
183                LabeledContent("Payment", value: status.capitalized)
184            }
185
186            if let sub = profile.subscription {
187                if let status = sub.status {
188                    LabeledContent("Subscription", value: status.capitalized)
189                }
190                if let interval = sub.interval {
191                    LabeledContent("Interval", value: interval.capitalized)
192                }
193            }
194
195            Button("Edit Profile") {
196                viewModel.isEditingProfile = true
197            }
198
199            SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
200                SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
201            }
202        }
203    }
204
205    // MARK: - SSH Keys Section
206
207    @ViewBuilder
208    private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
209        @Bindable var vm = viewModel
210
211        Section {
212            ForEach(viewModel.sshKeys) { key in
213                VStack(alignment: .leading, spacing: 2) {
214                    Text(key.fingerprint)
215                        .font(.caption.monospaced())
216                        .lineLimit(1)
217                        .truncationMode(.middle)
218
219                    HStack {
220                        if let comment = key.comment, !comment.isEmpty {
221                            Text(comment)
222                                .font(.caption2)
223                                .foregroundStyle(.secondary)
224                        }
225                        Spacer()
226                        Text(key.created.relativeDescription)
227                            .font(.caption2)
228                            .foregroundStyle(.tertiary)
229                    }
230
231                    if let lastUsed = key.lastUsed {
232                        Text("Last used \(lastUsed.relativeDescription)")
233                            .font(.caption2)
234                            .foregroundStyle(.tertiary)
235                    }
236                }
237                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
238                    Button("Delete", role: .destructive) {
239                        pendingDestructiveAction = .deleteSSHKey(key)
240                    }
241                }
242            }
243
244            if viewModel.isAddingSSHKey {
245                TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
246                    .font(.caption.monospaced())
247                    .lineLimit(3...6)
248
249                HStack {
250                    Button("Cancel") {
251                        viewModel.isAddingSSHKey = false
252                        viewModel.newSSHKey = ""
253                    }
254                    Spacer()
255                    Button("Add") {
256                        Task { await viewModel.addSSHKey() }
257                    }
258                    .buttonStyle(.borderedProminent)
259                    .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
260                }
261            } else {
262                Button {
263                    viewModel.isAddingSSHKey = true
264                } label: {
265                    SwiftUI.Label("Add SSH Key", systemImage: "key")
266                }
267            }
268        } header: {
269            Text("SSH Keys")
270        } footer: {
271            Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
272        }
273    }
274
275    // MARK: - PGP Keys Section
276
277    @ViewBuilder
278    private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
279        @Bindable var vm = viewModel
280
281        Section {
282            ForEach(viewModel.pgpKeys) { key in
283                VStack(alignment: .leading, spacing: 2) {
284                    Text(key.fingerprint)
285                        .font(.caption.monospaced())
286                        .lineLimit(1)
287                        .truncationMode(.middle)
288
289                    Text(key.created.relativeDescription)
290                        .font(.caption2)
291                        .foregroundStyle(.tertiary)
292                }
293                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
294                    Button("Delete", role: .destructive) {
295                        pendingDestructiveAction = .deletePGPKey(key)
296                    }
297                }
298            }
299
300            if viewModel.isAddingPGPKey {
301                TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
302                    .font(.caption.monospaced())
303                    .lineLimit(3...6)
304
305                HStack {
306                    Button("Cancel") {
307                        viewModel.isAddingPGPKey = false
308                        viewModel.newPGPKey = ""
309                    }
310                    Spacer()
311                    Button("Add") {
312                        Task { await viewModel.addPGPKey() }
313                    }
314                    .buttonStyle(.borderedProminent)
315                    .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
316                }
317            } else {
318                Button {
319                    viewModel.isAddingPGPKey = true
320                } label: {
321                    SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
322                }
323            }
324        } header: {
325            Text("PGP Keys")
326        } footer: {
327            Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
328        }
329    }
330
331    // MARK: - Personal Access Tokens Section
332
333    @ViewBuilder
334    private func patSection(_ viewModel: SettingsViewModel) -> some View {
335        Section {
336            if viewModel.isLoadingPATs {
337                HStack {
338                    Spacer()
339                    ProgressView()
340                    Spacer()
341                }
342            } else if viewModel.personalAccessTokens.isEmpty {
343                Button("Load Tokens") {
344                    Task { await viewModel.loadPersonalAccessTokens() }
345                }
346            } else {
347                ForEach(viewModel.personalAccessTokens) { token in
348                    VStack(alignment: .leading, spacing: 4) {
349                        HStack {
350                            Text(token.comment ?? "Token #\(token.id)")
351                                .font(.subheadline)
352                            Spacer()
353                        }
354
355                        HStack(spacing: 12) {
356                            Text("Issued \(token.issued.relativeDescription)")
357                                .font(.caption2)
358                                .foregroundStyle(.secondary)
359
360                            if let expires = token.expires {
361                                Text("Expires \(expires.relativeDescription)")
362                                    .font(.caption2)
363                                    .foregroundStyle(expires < Date.now ? .red : .secondary)
364                            }
365                        }
366
367                        if let grants = token.grants, !grants.isEmpty {
368                            Text(grants)
369                                .font(.caption2.monospaced())
370                                .foregroundStyle(.tertiary)
371                                .lineLimit(2)
372                        }
373                    }
374                }
375            }
376        } header: {
377            Text("Personal Access Tokens")
378        } footer: {
379            if !viewModel.personalAccessTokens.isEmpty {
380                Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
381            }
382        }
383    }
384
385    // MARK: - Token / Sign Out Section
386
387    @ViewBuilder
388    private func tokenSection() -> some View {
389        Section {
390            HStack {
391                Image(systemName: "key.fill")
392                    .foregroundStyle(.secondary)
393                Text("Personal access token in use")
394                    .font(.subheadline)
395                    .foregroundStyle(.secondary)
396            }
397            .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
398
399            Button("Reset App Data", role: .destructive) {
400                pendingDestructiveAction = .resetAppData
401            }
402
403            Button("Sign Out", role: .destructive) {
404                pendingDestructiveAction = .signOut
405            }
406        } header: {
407            Text("Authentication")
408        } footer: {
409            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.")
410        }
411    }
412
413    @ViewBuilder
414    private func aboutSection() -> some View {
415        Section("App") {
416            NavigationLink {
417                AboutView()
418            } label: {
419                SwiftUI.Label("About Hutch", systemImage: "info.circle")
420            }
421        }
422    }
423}
424
425private struct SettingsBioView: View {
426    let markdown: String
427
428    var body: some View {
429        Text(settingsBioAttributedString(markdown))
430            .frame(maxWidth: .infinity, alignment: .leading)
431            .tint(.accentColor)
432            .textSelection(.enabled)
433    }
434}
435
436func settingsBioAttributedString(_ markdown: String) -> AttributedString {
437    guard let attributed = try? AttributedString(
438        markdown: markdown,
439        options: settingsBioMarkdownOptions
440    ) else {
441        return AttributedString(markdown)
442    }
443    return attributed
444}
445
446// MARK: - Edit Profile Sheet
447
448private struct EditProfileSheet: View {
449    let profile: UserProfile
450    let viewModel: SettingsViewModel
451
452    @State private var email: String
453    @State private var url: String
454    @State private var location: String
455    @State private var bio: String
456    @State private var selectedPhoto: PhotosPickerItem?
457    @State private var avatarPreview: UIImage?
458    @State private var isShowingRemoveAvatarConfirmation = false
459
460    @Environment(\.dismiss) private var dismiss
461
462    init(profile: UserProfile, viewModel: SettingsViewModel) {
463        self.profile = profile
464        self.viewModel = viewModel
465        _email = State(initialValue: profile.email)
466        _url = State(initialValue: profile.url ?? "")
467        _location = State(initialValue: profile.location ?? "")
468        _bio = State(initialValue: profile.bio ?? "")
469    }
470
471    var body: some View {
472        NavigationStack {
473            Form {
474                Section {
475                    HStack {
476                        Spacer()
477                        VStack(spacing: 8) {
478                            PhotosPicker(selection: $selectedPhoto, matching: .images) {
479                                Group {
480                                    if let avatarPreview {
481                                        Image(uiImage: avatarPreview)
482                                            .resizable()
483                                            .scaledToFill()
484                                    } else {
485                                        AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
486                                            switch phase {
487                                            case .success(let image):
488                                                image
489                                                    .resizable()
490                                                    .scaledToFill()
491                                            default:
492                                                Image(systemName: "person.crop.circle.fill")
493                                                    .resizable()
494                                                    .foregroundStyle(.secondary)
495                                            }
496                                        }
497                                    }
498                                }
499                                .frame(width: 80, height: 80)
500                                .clipShape(Circle())
501                                .overlay(
502                                    Circle()
503                                        .stroke(.secondary.opacity(0.3), lineWidth: 1)
504                                )
505                            }
506
507                            Text("Tap to change avatar")
508                                .font(.caption)
509                                .foregroundStyle(.secondary)
510
511                            if viewModel.isUploadingAvatar {
512                                ProgressView()
513                                    .controlSize(.small)
514                            }
515                        }
516                        Spacer()
517                    }
518                    .listRowBackground(Color.clear)
519
520                    if profile.avatar != nil || avatarPreview != nil {
521                        HStack {
522                            Spacer()
523                            Button(role: .destructive) {
524                                isShowingRemoveAvatarConfirmation = true
525                            } label: {
526                                Text("Remove Avatar")
527                            }
528                            .buttonStyle(.borderedProminent)
529                            .disabled(viewModel.isUploadingAvatar)
530                            Spacer()
531                        }
532                        .listRowBackground(Color.clear)
533                        .listRowSeparator(.hidden)
534                    }
535                }
536
537                Section("Edit Profile") {
538                    VStack(alignment: .leading, spacing: 4) {
539                        Text("Email")
540                            .font(.caption)
541                            .foregroundStyle(.secondary)
542                        TextField("Enter email", text: $email)
543                            .textContentType(.emailAddress)
544                            .keyboardType(.emailAddress)
545                            .autocorrectionDisabled()
546                            .textInputAutocapitalization(.never)
547                    }
548
549                    VStack(alignment: .leading, spacing: 4) {
550                        Text("URL")
551                            .font(.caption)
552                            .foregroundStyle(.secondary)
553                        TextField("Enter URL", text: $url)
554                            .textContentType(.URL)
555                            .keyboardType(.URL)
556                            .autocorrectionDisabled()
557                            .textInputAutocapitalization(.never)
558                    }
559
560                    VStack(alignment: .leading, spacing: 4) {
561                        Text("Location")
562                            .font(.caption)
563                            .foregroundStyle(.secondary)
564                        TextField("Enter location", text: $location)
565                    }
566
567                    VStack(alignment: .leading, spacing: 4) {
568                        Text("Bio")
569                            .font(.caption)
570                            .foregroundStyle(.secondary)
571                        TextField("Enter bio", text: $bio, axis: .vertical)
572                            .lineLimit(3...6)
573                    }
574                }
575            }
576            .navigationTitle("Edit Profile")
577            .navigationBarTitleDisplayMode(.inline)
578            .onChange(of: selectedPhoto) { _, newItem in
579                guard let newItem else { return }
580                Task {
581                    if let data = try? await newItem.loadTransferable(type: Data.self),
582                       let image = UIImage(data: data) {
583                        avatarPreview = image
584                        // Encode as JPEG and upload
585                        if let jpegData = image.jpegData(compressionQuality: 0.85) {
586                            await viewModel.uploadAvatar(jpegData: jpegData)
587                        }
588                    }
589                }
590            }
591            .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
592                Button("Cancel", role: .cancel) {}
593                Button("Remove Avatar", role: .destructive) {
594                    Task {
595                        await viewModel.removeAvatar()
596                        if viewModel.error == nil {
597                            avatarPreview = nil
598                            selectedPhoto = nil
599                        }
600                    }
601                }
602            } message: {
603                Text("Your profile avatar will be removed from SourceHut.")
604            }
605            .toolbar {
606                ToolbarItem(placement: .cancellationAction) {
607                    Button("Cancel") {
608                        dismiss()
609                    }
610                }
611                ToolbarItem(placement: .confirmationAction) {
612                    Button {
613                        Task {
614                            await viewModel.saveProfile(
615                                email: email,
616                                url: url,
617                                location: location,
618                                bio: bio
619                            )
620                            if viewModel.error == nil {
621                                dismiss()
622                            }
623                        }
624                    } label: {
625                        if viewModel.isSavingProfile {
626                            ProgressView()
627                                .controlSize(.small)
628                        } else {
629                            Text("Save")
630                        }
631                    }
632                    .disabled(viewModel.isSavingProfile)
633                }
634            }
635        }
636    }
637}
638
639private enum SettingsDestructiveAction {
640    case resetAppData
641    case signOut
642    case deleteSSHKey(SSHKey)
643    case deletePGPKey(PGPKey)
644
645    var title: String {
646        switch self {
647        case .resetAppData:
648            "Reset App Data?"
649        case .signOut:
650            "Sign Out?"
651        case .deleteSSHKey:
652            "Remove SSH Key?"
653        case .deletePGPKey:
654            "Remove PGP Key?"
655        }
656    }
657
658    var confirmationLabel: String {
659        switch self {
660        case .resetAppData:
661            "Reset App Data"
662        case .signOut:
663            "Sign Out"
664        case .deleteSSHKey:
665            "Remove SSH Key"
666        case .deletePGPKey:
667            "Remove PGP Key"
668        }
669    }
670
671    var message: String {
672        switch self {
673        case .resetAppData:
674            "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
675        case .signOut:
676            "This signs you out of Hutch and clears saved authentication state on this device."
677        case .deleteSSHKey(let key):
678            "Remove SSH key \(key.fingerprint) from your account?"
679        case .deletePGPKey(let key):
680            "Remove PGP key \(key.fingerprint) from your account?"
681        }
682    }
683}
684
685private struct AboutView: View {
686    private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
687        ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
688        ?? "Hutch"
689    private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
690        ?? "Unknown"
691    private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
692        ?? "Unknown"
693
694    var body: some View {
695        Form {
696            Section {
697                VStack(alignment: .leading, spacing: 6) {
698                    Text(appName)
699                        .font(.title2.weight(.semibold))
700                    Text("A native SourceHut client for iPhone.")
701                        .font(.subheadline)
702                        .foregroundStyle(.secondary)
703                }
704                .padding(.vertical, 4)
705
706                LabeledContent("Version", value: version)
707                LabeledContent("Build", value: build)
708            }
709
710            Section("Links") {
711                Link(destination: URL(string: "https://sr.ht")!) {
712                    SwiftUI.Label("SourceHut", systemImage: "link")
713                }
714                Link(destination: URL(string: "https://man.sr.ht")!) {
715                    SwiftUI.Label("SourceHut Manuals", systemImage: "book")
716                }
717                Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
718                    SwiftUI.Label("Project Repository", systemImage: "folder")
719                }
720            }
721
722            Section("Support") {
723                Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
724                    SwiftUI.Label("Email Support", systemImage: "envelope")
725                }
726            }
727
728            Section("Privacy") {
729                Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
730                    .font(.subheadline)
731                    .foregroundStyle(.secondary)
732            }
733
734            Section("Acknowledgements") {
735                Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
736                    .font(.subheadline)
737                    .foregroundStyle(.secondary)
738            }
739        }
740        .navigationTitle("About")
741        .navigationBarTitleDisplayMode(.inline)
742    }
743}