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}