krz/hutch

an ios client for sourcehut

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

v2.3.1: Hutch/Views/Settings/SettingsView.swift · raw

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