Hutch/Views/Settings/SettingsView.swift
743 lines · 27524 bytes
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 NavigationStack {
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
34 @ViewBuilder
35 private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
36 @Bindable var vm = viewModel
37
38 Form {
39 if let profile = viewModel.profile {
40 // Profile section
41 profileSection(profile, viewModel: viewModel)
42
43 // SSH Keys
44 sshKeysSection(viewModel)
45
46 // PGP Keys
47 pgpKeysSection(viewModel)
48
49 // Personal Access Tokens
50 patSection(viewModel)
51 }
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 tokenSection() -> some View {
389 Section {
390 HStack {
391 Image(systemName: "key.fill")
392 .foregroundStyle(.secondary)
393 Text("Personal access token in use")
394 .font(.subheadline)
395 .foregroundStyle(.secondary)
396 }
397 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
398
399 Button("Reset App Data", role: .destructive) {
400 pendingDestructiveAction = .resetAppData
401 }
402
403 Button("Sign Out", role: .destructive) {
404 pendingDestructiveAction = .signOut
405 }
406 } header: {
407 Text("Authentication")
408 } footer: {
409 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.")
410 }
411 }
412
413 @ViewBuilder
414 private func aboutSection() -> some View {
415 Section("App") {
416 NavigationLink {
417 AboutView()
418 } label: {
419 SwiftUI.Label("About Hutch", systemImage: "info.circle")
420 }
421 }
422 }
423}
424
425private struct SettingsBioView: View {
426 let markdown: String
427
428 var body: some View {
429 Text(settingsBioAttributedString(markdown))
430 .frame(maxWidth: .infinity, alignment: .leading)
431 .tint(.accentColor)
432 .textSelection(.enabled)
433 }
434}
435
436func settingsBioAttributedString(_ markdown: String) -> AttributedString {
437 guard let attributed = try? AttributedString(
438 markdown: markdown,
439 options: settingsBioMarkdownOptions
440 ) else {
441 return AttributedString(markdown)
442 }
443 return attributed
444}
445
446// MARK: - Edit Profile Sheet
447
448private struct EditProfileSheet: View {
449 let profile: UserProfile
450 let viewModel: SettingsViewModel
451
452 @State private var email: String
453 @State private var url: String
454 @State private var location: String
455 @State private var bio: String
456 @State private var selectedPhoto: PhotosPickerItem?
457 @State private var avatarPreview: UIImage?
458 @State private var isShowingRemoveAvatarConfirmation = false
459
460 @Environment(\.dismiss) private var dismiss
461
462 init(profile: UserProfile, viewModel: SettingsViewModel) {
463 self.profile = profile
464 self.viewModel = viewModel
465 _email = State(initialValue: profile.email)
466 _url = State(initialValue: profile.url ?? "")
467 _location = State(initialValue: profile.location ?? "")
468 _bio = State(initialValue: profile.bio ?? "")
469 }
470
471 var body: some View {
472 NavigationStack {
473 Form {
474 Section {
475 HStack {
476 Spacer()
477 VStack(spacing: 8) {
478 PhotosPicker(selection: $selectedPhoto, matching: .images) {
479 Group {
480 if let avatarPreview {
481 Image(uiImage: avatarPreview)
482 .resizable()
483 .scaledToFill()
484 } else {
485 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
486 switch phase {
487 case .success(let image):
488 image
489 .resizable()
490 .scaledToFill()
491 default:
492 Image(systemName: "person.crop.circle.fill")
493 .resizable()
494 .foregroundStyle(.secondary)
495 }
496 }
497 }
498 }
499 .frame(width: 80, height: 80)
500 .clipShape(Circle())
501 .overlay(
502 Circle()
503 .stroke(.secondary.opacity(0.3), lineWidth: 1)
504 )
505 }
506
507 Text("Tap to change avatar")
508 .font(.caption)
509 .foregroundStyle(.secondary)
510
511 if viewModel.isUploadingAvatar {
512 ProgressView()
513 .controlSize(.small)
514 }
515 }
516 Spacer()
517 }
518 .listRowBackground(Color.clear)
519
520 if profile.avatar != nil || avatarPreview != nil {
521 HStack {
522 Spacer()
523 Button(role: .destructive) {
524 isShowingRemoveAvatarConfirmation = true
525 } label: {
526 Text("Remove Avatar")
527 }
528 .buttonStyle(.borderedProminent)
529 .disabled(viewModel.isUploadingAvatar)
530 Spacer()
531 }
532 .listRowBackground(Color.clear)
533 .listRowSeparator(.hidden)
534 }
535 }
536
537 Section("Edit Profile") {
538 VStack(alignment: .leading, spacing: 4) {
539 Text("Email")
540 .font(.caption)
541 .foregroundStyle(.secondary)
542 TextField("Enter email", text: $email)
543 .textContentType(.emailAddress)
544 .keyboardType(.emailAddress)
545 .autocorrectionDisabled()
546 .textInputAutocapitalization(.never)
547 }
548
549 VStack(alignment: .leading, spacing: 4) {
550 Text("URL")
551 .font(.caption)
552 .foregroundStyle(.secondary)
553 TextField("Enter URL", text: $url)
554 .textContentType(.URL)
555 .keyboardType(.URL)
556 .autocorrectionDisabled()
557 .textInputAutocapitalization(.never)
558 }
559
560 VStack(alignment: .leading, spacing: 4) {
561 Text("Location")
562 .font(.caption)
563 .foregroundStyle(.secondary)
564 TextField("Enter location", text: $location)
565 }
566
567 VStack(alignment: .leading, spacing: 4) {
568 Text("Bio")
569 .font(.caption)
570 .foregroundStyle(.secondary)
571 TextField("Enter bio", text: $bio, axis: .vertical)
572 .lineLimit(3...6)
573 }
574 }
575 }
576 .navigationTitle("Edit Profile")
577 .navigationBarTitleDisplayMode(.inline)
578 .onChange(of: selectedPhoto) { _, newItem in
579 guard let newItem else { return }
580 Task {
581 if let data = try? await newItem.loadTransferable(type: Data.self),
582 let image = UIImage(data: data) {
583 avatarPreview = image
584 // Encode as JPEG and upload
585 if let jpegData = image.jpegData(compressionQuality: 0.85) {
586 await viewModel.uploadAvatar(jpegData: jpegData)
587 }
588 }
589 }
590 }
591 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
592 Button("Cancel", role: .cancel) {}
593 Button("Remove Avatar", role: .destructive) {
594 Task {
595 await viewModel.removeAvatar()
596 if viewModel.error == nil {
597 avatarPreview = nil
598 selectedPhoto = nil
599 }
600 }
601 }
602 } message: {
603 Text("Your profile avatar will be removed from SourceHut.")
604 }
605 .toolbar {
606 ToolbarItem(placement: .cancellationAction) {
607 Button("Cancel") {
608 dismiss()
609 }
610 }
611 ToolbarItem(placement: .confirmationAction) {
612 Button {
613 Task {
614 await viewModel.saveProfile(
615 email: email,
616 url: url,
617 location: location,
618 bio: bio
619 )
620 if viewModel.error == nil {
621 dismiss()
622 }
623 }
624 } label: {
625 if viewModel.isSavingProfile {
626 ProgressView()
627 .controlSize(.small)
628 } else {
629 Text("Save")
630 }
631 }
632 .disabled(viewModel.isSavingProfile)
633 }
634 }
635 }
636 }
637}
638
639private enum SettingsDestructiveAction {
640 case resetAppData
641 case signOut
642 case deleteSSHKey(SSHKey)
643 case deletePGPKey(PGPKey)
644
645 var title: String {
646 switch self {
647 case .resetAppData:
648 "Reset App Data?"
649 case .signOut:
650 "Sign Out?"
651 case .deleteSSHKey:
652 "Remove SSH Key?"
653 case .deletePGPKey:
654 "Remove PGP Key?"
655 }
656 }
657
658 var confirmationLabel: String {
659 switch self {
660 case .resetAppData:
661 "Reset App Data"
662 case .signOut:
663 "Sign Out"
664 case .deleteSSHKey:
665 "Remove SSH Key"
666 case .deletePGPKey:
667 "Remove PGP Key"
668 }
669 }
670
671 var message: String {
672 switch self {
673 case .resetAppData:
674 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
675 case .signOut:
676 "This signs you out of Hutch and clears saved authentication state on this device."
677 case .deleteSSHKey(let key):
678 "Remove SSH key \(key.fingerprint) from your account?"
679 case .deletePGPKey(let key):
680 "Remove PGP key \(key.fingerprint) from your account?"
681 }
682 }
683}
684
685private struct AboutView: View {
686 private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
687 ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
688 ?? "Hutch"
689 private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
690 ?? "Unknown"
691 private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
692 ?? "Unknown"
693
694 var body: some View {
695 Form {
696 Section {
697 VStack(alignment: .leading, spacing: 6) {
698 Text(appName)
699 .font(.title2.weight(.semibold))
700 Text("A native SourceHut client for iPhone.")
701 .font(.subheadline)
702 .foregroundStyle(.secondary)
703 }
704 .padding(.vertical, 4)
705
706 LabeledContent("Version", value: version)
707 LabeledContent("Build", value: build)
708 }
709
710 Section("Links") {
711 Link(destination: URL(string: "https://sr.ht")!) {
712 SwiftUI.Label("SourceHut", systemImage: "link")
713 }
714 Link(destination: URL(string: "https://man.sr.ht")!) {
715 SwiftUI.Label("SourceHut Manuals", systemImage: "book")
716 }
717 Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
718 SwiftUI.Label("Project Repository", systemImage: "folder")
719 }
720 }
721
722 Section("Support") {
723 Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
724 SwiftUI.Label("Email Support", systemImage: "envelope")
725 }
726 }
727
728 Section("Privacy") {
729 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
730 .font(.subheadline)
731 .foregroundStyle(.secondary)
732 }
733
734 Section("Acknowledgements") {
735 Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
736 .font(.subheadline)
737 .foregroundStyle(.secondary)
738 }
739 }
740 .navigationTitle("About")
741 .navigationBarTitleDisplayMode(.inline)
742 }
743}