krz/hutch

an ios client for sourcehut

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

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