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}