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}