krz/hutch

an ios client for sourcehut

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

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