krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.9.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}