krz/hutch

an ios client for sourcehut

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

v2.3.0: 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            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 behaviorSection() -> some View {
389        Section {
390            Toggle(
391                "Swipe actions",
392                isOn: Binding(
393                    get: {
394                        UserDefaults.standard.object(forKey: AppStorageKeys.swipeActionsEnabled) as? Bool ?? true
395                    },
396                    set: { UserDefaults.standard.set($0, forKey: AppStorageKeys.swipeActionsEnabled) }
397                )
398            )
399        } header: {
400            Text("Behavior")
401        } footer: {
402            Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes.")
403        }
404    }
405
406    @ViewBuilder
407    private func tokenSection() -> some View {
408        Section {
409            HStack {
410                Image(systemName: "key.fill")
411                    .foregroundStyle(.secondary)
412                Text("Personal access token in use")
413                    .font(.subheadline)
414                    .foregroundStyle(.secondary)
415            }
416            .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
417
418            Button("Reset App Data", role: .destructive) {
419                pendingDestructiveAction = .resetAppData
420            }
421
422            Button("Sign Out", role: .destructive) {
423                pendingDestructiveAction = .signOut
424            }
425        } header: {
426            Text("Authentication")
427        } footer: {
428            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.")
429        }
430    }
431
432    @ViewBuilder
433    private func aboutSection() -> some View {
434        Section("App") {
435            NavigationLink {
436                AboutView()
437            } label: {
438                SwiftUI.Label("About Hutch", systemImage: "info.circle")
439            }
440        }
441    }
442}
443
444private struct SettingsBioView: View {
445    let markdown: String
446
447    var body: some View {
448        Text(settingsBioAttributedString(markdown))
449            .frame(maxWidth: .infinity, alignment: .leading)
450            .tint(.accentColor)
451            .textSelection(.enabled)
452    }
453}
454
455func settingsBioAttributedString(_ markdown: String) -> AttributedString {
456    guard let attributed = try? AttributedString(
457        markdown: markdown,
458        options: settingsBioMarkdownOptions
459    ) else {
460        return AttributedString(markdown)
461    }
462    return attributed
463}
464
465// MARK: - Edit Profile Sheet
466
467private struct EditProfileSheet: View {
468    let profile: UserProfile
469    let viewModel: SettingsViewModel
470
471    @State private var email: String
472    @State private var url: String
473    @State private var location: String
474    @State private var bio: String
475    @State private var selectedPhoto: PhotosPickerItem?
476    @State private var avatarPreview: UIImage?
477    @State private var isShowingRemoveAvatarConfirmation = false
478
479    @Environment(\.dismiss) private var dismiss
480
481    init(profile: UserProfile, viewModel: SettingsViewModel) {
482        self.profile = profile
483        self.viewModel = viewModel
484        _email = State(initialValue: profile.email)
485        _url = State(initialValue: profile.url ?? "")
486        _location = State(initialValue: profile.location ?? "")
487        _bio = State(initialValue: profile.bio ?? "")
488    }
489
490    var body: some View {
491        NavigationStack {
492            Form {
493                Section {
494                    HStack {
495                        Spacer()
496                        VStack(spacing: 8) {
497                            PhotosPicker(selection: $selectedPhoto, matching: .images) {
498                                Group {
499                                    if let avatarPreview {
500                                        Image(uiImage: avatarPreview)
501                                            .resizable()
502                                            .scaledToFill()
503                                    } else {
504                                        AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
505                                            switch phase {
506                                            case .success(let image):
507                                                image
508                                                    .resizable()
509                                                    .scaledToFill()
510                                            default:
511                                                Image(systemName: "person.crop.circle.fill")
512                                                    .resizable()
513                                                    .foregroundStyle(.secondary)
514                                            }
515                                        }
516                                    }
517                                }
518                                .frame(width: 80, height: 80)
519                                .clipShape(Circle())
520                                .overlay(
521                                    Circle()
522                                        .stroke(.secondary.opacity(0.3), lineWidth: 1)
523                                )
524                            }
525
526                            Text("Tap to change avatar")
527                                .font(.caption)
528                                .foregroundStyle(.secondary)
529
530                            if viewModel.isUploadingAvatar {
531                                ProgressView()
532                                    .controlSize(.small)
533                            }
534                        }
535                        Spacer()
536                    }
537                    .listRowBackground(Color.clear)
538
539                    if profile.avatar != nil || avatarPreview != nil {
540                        HStack {
541                            Spacer()
542                            Button(role: .destructive) {
543                                isShowingRemoveAvatarConfirmation = true
544                            } label: {
545                                Text("Remove Avatar")
546                            }
547                            .buttonStyle(.borderedProminent)
548                            .disabled(viewModel.isUploadingAvatar)
549                            Spacer()
550                        }
551                        .listRowBackground(Color.clear)
552                        .listRowSeparator(.hidden)
553                    }
554                }
555
556                Section("Edit Profile") {
557                    VStack(alignment: .leading, spacing: 4) {
558                        Text("Email")
559                            .font(.caption)
560                            .foregroundStyle(.secondary)
561                        TextField("Enter email", text: $email)
562                            .textContentType(.emailAddress)
563                            .keyboardType(.emailAddress)
564                            .autocorrectionDisabled()
565                            .textInputAutocapitalization(.never)
566                    }
567
568                    VStack(alignment: .leading, spacing: 4) {
569                        Text("URL")
570                            .font(.caption)
571                            .foregroundStyle(.secondary)
572                        TextField("Enter URL", text: $url)
573                            .textContentType(.URL)
574                            .keyboardType(.URL)
575                            .autocorrectionDisabled()
576                            .textInputAutocapitalization(.never)
577                    }
578
579                    VStack(alignment: .leading, spacing: 4) {
580                        Text("Location")
581                            .font(.caption)
582                            .foregroundStyle(.secondary)
583                        TextField("Enter location", text: $location)
584                    }
585
586                    VStack(alignment: .leading, spacing: 4) {
587                        Text("Bio")
588                            .font(.caption)
589                            .foregroundStyle(.secondary)
590                        TextField("Enter bio", text: $bio, axis: .vertical)
591                            .lineLimit(3...6)
592                    }
593                }
594            }
595            .navigationTitle("Edit Profile")
596            .navigationBarTitleDisplayMode(.inline)
597            .onChange(of: selectedPhoto) { _, newItem in
598                guard let newItem else { return }
599                Task {
600                    if let data = try? await newItem.loadTransferable(type: Data.self),
601                       let image = UIImage(data: data) {
602                        avatarPreview = image
603                        // Encode as JPEG and upload
604                        if let jpegData = image.jpegData(compressionQuality: 0.85) {
605                            await viewModel.uploadAvatar(jpegData: jpegData)
606                        }
607                    }
608                }
609            }
610            .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
611                Button("Cancel", role: .cancel) {}
612                Button("Remove Avatar", role: .destructive) {
613                    Task {
614                        await viewModel.removeAvatar()
615                        if viewModel.error == nil {
616                            avatarPreview = nil
617                            selectedPhoto = nil
618                        }
619                    }
620                }
621            } message: {
622                Text("Your profile avatar will be removed from SourceHut.")
623            }
624            .toolbar {
625                ToolbarItem(placement: .cancellationAction) {
626                    Button("Cancel") {
627                        dismiss()
628                    }
629                }
630                ToolbarItem(placement: .confirmationAction) {
631                    Button {
632                        Task {
633                            await viewModel.saveProfile(
634                                email: email,
635                                url: url,
636                                location: location,
637                                bio: bio
638                            )
639                            if viewModel.error == nil {
640                                dismiss()
641                            }
642                        }
643                    } label: {
644                        if viewModel.isSavingProfile {
645                            ProgressView()
646                                .controlSize(.small)
647                        } else {
648                            Text("Save")
649                        }
650                    }
651                    .disabled(viewModel.isSavingProfile)
652                }
653            }
654        }
655    }
656}
657
658private enum SettingsDestructiveAction {
659    case resetAppData
660    case signOut
661    case deleteSSHKey(SSHKey)
662    case deletePGPKey(PGPKey)
663
664    var title: String {
665        switch self {
666        case .resetAppData:
667            "Reset App Data?"
668        case .signOut:
669            "Sign Out?"
670        case .deleteSSHKey:
671            "Remove SSH Key?"
672        case .deletePGPKey:
673            "Remove PGP Key?"
674        }
675    }
676
677    var confirmationLabel: String {
678        switch self {
679        case .resetAppData:
680            "Reset App Data"
681        case .signOut:
682            "Sign Out"
683        case .deleteSSHKey:
684            "Remove SSH Key"
685        case .deletePGPKey:
686            "Remove PGP Key"
687        }
688    }
689
690    var message: String {
691        switch self {
692        case .resetAppData:
693            "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
694        case .signOut:
695            "This signs you out of Hutch and clears saved authentication state on this device."
696        case .deleteSSHKey(let key):
697            "Remove SSH key \(key.fingerprint) from your account?"
698        case .deletePGPKey(let key):
699            "Remove PGP key \(key.fingerprint) from your account?"
700        }
701    }
702}
703
704private struct AboutView: View {
705    private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
706        ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
707        ?? "Hutch"
708    private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
709        ?? "Unknown"
710    private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
711        ?? "Unknown"
712
713    var body: some View {
714        Form {
715            Section {
716                VStack(alignment: .leading, spacing: 6) {
717                    Text(appName)
718                        .font(.title2.weight(.semibold))
719                    Text("A native SourceHut client for iPhone.")
720                        .font(.subheadline)
721                        .foregroundStyle(.secondary)
722                }
723                .padding(.vertical, 4)
724
725                LabeledContent("Version", value: version)
726                LabeledContent("Build", value: build)
727            }
728
729            Section("Links") {
730                Link(destination: URL(string: "https://sr.ht")!) {
731                    SwiftUI.Label("SourceHut", systemImage: "link")
732                }
733                Link(destination: URL(string: "https://man.sr.ht")!) {
734                    SwiftUI.Label("SourceHut Manuals", systemImage: "book")
735                }
736                Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
737                    SwiftUI.Label("Project Repository", systemImage: "folder")
738                }
739            }
740
741            Section("Support") {
742                Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
743                    SwiftUI.Label("Email Support", systemImage: "envelope")
744                }
745            }
746
747            Section("Privacy") {
748                Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
749                    .font(.subheadline)
750                    .foregroundStyle(.secondary)
751
752                Link(destination: URL(string: "https://hutch.cleberg.net/privacy.html")!) {
753                    SwiftUI.Label("Privacy Policy", systemImage: "hand.raised")
754                }
755            }
756
757            Section("Acknowledgements") {
758                Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
759                    .font(.subheadline)
760                    .foregroundStyle(.secondary)
761            }
762        }
763        .navigationTitle("About")
764        .navigationBarTitleDisplayMode(.inline)
765    }
766}