Commit 0af36f5b04

0af36f5b040bff9843f5ed50a098bc5d5c2b7a2e

parent: aace513052

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 00:47 UTC

refactor: extract ProfileView from SettingsView

Move profile-related UI into its own dedicated ProfileView, simplifying
SettingsView and improving navigation structure in MoreView.

Layout: unified · split

Hutch/Views/More/MoreView.swift +10 −2
@@ -11,15 +11,17 @@ struct MoreView: View {
11 11
12 var body: some View { 12 var body: some View {
13 List { 13 List {
14 Section { 14 Section("Search") {
15 NavigationLink(value: MoreRoute.lookup) { 15 NavigationLink(value: MoreRoute.lookup) {
16 Label("Look Up", systemImage: "magnifyingglass") 16 Label("Look Up", systemImage: "magnifyingglass")
17 } 17 }
18 }
18 19
20 Section("Other Services") {
19 NavigationLink(value: MoreRoute.lists) { 21 NavigationLink(value: MoreRoute.lists) {
20 Label("Mailing Lists", systemImage: "list.bullet.rectangle") 22 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
21 } 23 }
22 24
23 NavigationLink(value: MoreRoute.manPageBrowser) { 25 NavigationLink(value: MoreRoute.manPageBrowser) {
24 Label("Man Pages", systemImage: "book") 26 Label("Man Pages", systemImage: "book")
25 } 27 }
@@ -27,6 +29,12 @@ struct MoreView: View {
27 NavigationLink(value: MoreRoute.pastes) { 29 NavigationLink(value: MoreRoute.pastes) {
28 Label("Pastes", systemImage: "doc.on.clipboard") 30 Label("Pastes", systemImage: "doc.on.clipboard")
29 } 31 }
32 }
33
34 Section("Meta") {
35 NavigationLink(value: MoreRoute.profile) {
36 Label("Profile", systemImage: "person.text.rectangle")
37 }
30 38
31 NavigationLink(value: MoreRoute.settings) { 39 NavigationLink(value: MoreRoute.settings) {
32 Label("Settings", systemImage: "gear") 40 Label("Settings", systemImage: "gear")
Hutch/Views/More/ProfileView.swift added +641
@@ -0,0 +1,641 @@
1import PhotosUI
2import SwiftUI
3
4private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
5 interpretedSyntax: .inlineOnlyPreservingWhitespace
6)
7
8struct ProfileView: View {
9 @Environment(AppState.self) private var appState
10 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
11 @State private var viewModel: SettingsViewModel?
12 @State private var contributionViewModel: UserProfileViewModel?
13 @State private var pendingDestructiveAction: ProfileDestructiveAction?
14
15 var body: some View {
16 Group {
17 if let viewModel {
18 profileContent(viewModel)
19 } else {
20 SRHTLoadingStateView(message: "Loading profile…")
21 }
22 }
23 .navigationTitle("Profile")
24 .task {
25 if viewModel == nil {
26 let vm = SettingsViewModel(client: appState.client)
27 viewModel = vm
28 await vm.loadProfile()
29 }
30 }
31 .task(id: contributionGraphsEnabled ? appState.currentUser?.canonicalName ?? "" : "contributions-disabled") {
32 guard contributionGraphsEnabled, let currentUser = appState.currentUser else { return }
33
34 let owner = currentUser.canonicalName.hasPrefix("~")
35 ? String(currentUser.canonicalName.dropFirst())
36 : currentUser.canonicalName
37 let actor = currentUser.canonicalName.hasPrefix("~") ? currentUser.canonicalName : "~\(currentUser.canonicalName)"
38
39 let vm: UserProfileViewModel
40 if let existingViewModel = contributionViewModel,
41 existingViewModel.actor == actor,
42 existingViewModel.ownerUsername == owner {
43 vm = existingViewModel
44 } else {
45 let newViewModel = UserProfileViewModel(
46 ownerUsername: owner,
47 actor: actor,
48 client: appState.client,
49 statsService: HutchStatsService(configuration: appState.configuration)
50 )
51 contributionViewModel = newViewModel
52 vm = newViewModel
53 }
54
55 await vm.loadContributions()
56 }
57 }
58
59 @ViewBuilder
60 private func profileContent(_ viewModel: SettingsViewModel) -> some View {
61 @Bindable var vm = viewModel
62
63 Form {
64 if let profile = viewModel.profile {
65 profileSection(profile, viewModel: viewModel)
66
67 if contributionGraphsEnabled, let contributionViewModel {
68 Section {
69 ContributionProfileCard(
70 actor: contributionViewModel.actor,
71 weeks: contributionViewModel.contributionCalendar.map {
72 ContributionCalendarLayout.weekColumns(from: $0.days)
73 } ?? [],
74 stats: contributionViewModel.contributionStats,
75 isLoading: contributionViewModel.isLoadingContributions,
76 error: contributionViewModel.contributionsError ?? contributionViewModel.contributionStatusText,
77 isIndexedButEmpty: contributionViewModel.isContributionActivityIndexedButEmpty
78 )
79 }
80 }
81
82 sshKeysSection(viewModel)
83 pgpKeysSection(viewModel)
84 patSection(viewModel)
85 }
86 }
87 .overlay {
88 if viewModel.isLoading, viewModel.profile == nil {
89 SRHTLoadingStateView(message: "Loading profile…")
90 } else if let error = viewModel.error, viewModel.profile == nil {
91 SRHTErrorStateView(
92 title: "Couldn't Load Profile",
93 message: error,
94 retryAction: { await viewModel.loadProfile() }
95 )
96 }
97 }
98 .sheet(isPresented: $vm.isEditingProfile) {
99 if let profile = viewModel.profile {
100 EditProfileSheet(profile: profile, viewModel: viewModel)
101 }
102 }
103 .alert("Error", isPresented: Binding(
104 get: { viewModel.error != nil && viewModel.profile != nil },
105 set: { isPresented in
106 if !isPresented {
107 viewModel.error = nil
108 }
109 }
110 )) {
111 Button("OK") { viewModel.error = nil }
112 } message: {
113 if let error = viewModel.error {
114 Text(error)
115 }
116 }
117 .alert(
118 pendingDestructiveAction?.title ?? "",
119 isPresented: Binding(
120 get: { pendingDestructiveAction != nil },
121 set: { isPresented in
122 if !isPresented {
123 pendingDestructiveAction = nil
124 }
125 }
126 )
127 ) {
128 Button("Cancel", role: .cancel) {}
129 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
130 guard let action = pendingDestructiveAction else { return }
131 pendingDestructiveAction = nil
132 Task {
133 switch action {
134 case .deleteSSHKey(let key):
135 await viewModel.deleteSSHKey(key)
136 case .deletePGPKey(let key):
137 await viewModel.deletePGPKey(key)
138 }
139 }
140 }
141 } message: {
142 if let pendingDestructiveAction {
143 Text(pendingDestructiveAction.message)
144 }
145 }
146 .refreshable {
147 await viewModel.loadProfile()
148 }
149 }
150
151 @ViewBuilder
152 private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
153 Section("Profile") {
154 HStack(spacing: 12) {
155 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
156 switch phase {
157 case .success(let image):
158 image
159 .resizable()
160 .scaledToFill()
161 default:
162 Image(systemName: "person.crop.circle.fill")
163 .resizable()
164 .foregroundStyle(.secondary)
165 }
166 }
167 .frame(width: 56, height: 56)
168 .clipShape(Circle())
169
170 VStack(alignment: .leading, spacing: 2) {
171 Text(profile.canonicalName)
172 .font(.headline)
173 Text(profile.email)
174 .font(.subheadline)
175 .foregroundStyle(.secondary)
176 if let userType = profile.userType {
177 Text(userType.capitalized)
178 .font(.caption)
179 .foregroundStyle(.tertiary)
180 }
181 }
182 }
183 .padding(.vertical, 4)
184
185 if let bio = profile.bio, !bio.isEmpty {
186 VStack(alignment: .leading, spacing: 2) {
187 Text("Bio")
188 .font(.caption)
189 .foregroundStyle(.secondary)
190 ProfileBioView(markdown: bio)
191 }
192 }
193
194 if let location = profile.location, !location.isEmpty {
195 LabeledContent("Location", value: location)
196 }
197
198 if let url = profile.url, !url.isEmpty {
199 LabeledContent("URL", value: url)
200 }
201
202 if let status = profile.paymentStatus {
203 LabeledContent("Payment", value: status.capitalized)
204 }
205
206 if let sub = profile.subscription {
207 if let status = sub.status {
208 LabeledContent("Subscription", value: status.capitalized)
209 }
210 if let interval = sub.interval {
211 LabeledContent("Interval", value: interval.capitalized)
212 }
213 }
214
215 Button("Edit Profile") {
216 viewModel.isEditingProfile = true
217 }
218
219 SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
220 SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
221 }
222 }
223 }
224
225 @ViewBuilder
226 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
227 @Bindable var vm = viewModel
228
229 Section {
230 ForEach(viewModel.sshKeys) { key in
231 VStack(alignment: .leading, spacing: 2) {
232 Text(key.fingerprint)
233 .font(.caption.monospaced())
234 .lineLimit(1)
235 .truncationMode(.middle)
236
237 HStack {
238 if let comment = key.comment, !comment.isEmpty {
239 Text(comment)
240 .font(.caption2)
241 .foregroundStyle(.secondary)
242 }
243 Spacer()
244 Text(key.created.relativeDescription)
245 .font(.caption2)
246 .foregroundStyle(.tertiary)
247 }
248
249 if let lastUsed = key.lastUsed {
250 Text("Last used \(lastUsed.relativeDescription)")
251 .font(.caption2)
252 .foregroundStyle(.tertiary)
253 }
254 }
255 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
256 Button("Delete", role: .destructive) {
257 pendingDestructiveAction = .deleteSSHKey(key)
258 }
259 }
260 }
261
262 if viewModel.isAddingSSHKey {
263 TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
264 .font(.caption.monospaced())
265 .lineLimit(3...6)
266
267 HStack {
268 Button("Cancel") {
269 viewModel.isAddingSSHKey = false
270 viewModel.newSSHKey = ""
271 }
272 Spacer()
273 Button("Add") {
274 Task { await viewModel.addSSHKey() }
275 }
276 .buttonStyle(.borderedProminent)
277 .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
278 }
279 } else {
280 Button {
281 viewModel.isAddingSSHKey = true
282 } label: {
283 SwiftUI.Label("Add SSH Key", systemImage: "key")
284 }
285 }
286 } header: {
287 Text("SSH Keys")
288 } footer: {
289 Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
290 }
291 }
292
293 @ViewBuilder
294 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
295 @Bindable var vm = viewModel
296
297 Section {
298 ForEach(viewModel.pgpKeys) { key in
299 VStack(alignment: .leading, spacing: 2) {
300 Text(key.fingerprint)
301 .font(.caption.monospaced())
302 .lineLimit(1)
303 .truncationMode(.middle)
304
305 Text(key.created.relativeDescription)
306 .font(.caption2)
307 .foregroundStyle(.tertiary)
308 }
309 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
310 Button("Delete", role: .destructive) {
311 pendingDestructiveAction = .deletePGPKey(key)
312 }
313 }
314 }
315
316 if viewModel.isAddingPGPKey {
317 TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
318 .font(.caption.monospaced())
319 .lineLimit(3...6)
320
321 HStack {
322 Button("Cancel") {
323 viewModel.isAddingPGPKey = false
324 viewModel.newPGPKey = ""
325 }
326 Spacer()
327 Button("Add") {
328 Task { await viewModel.addPGPKey() }
329 }
330 .buttonStyle(.borderedProminent)
331 .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
332 }
333 } else {
334 Button {
335 viewModel.isAddingPGPKey = true
336 } label: {
337 SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
338 }
339 }
340 } header: {
341 Text("PGP Keys")
342 } footer: {
343 Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
344 }
345 }
346
347 @ViewBuilder
348 private func patSection(_ viewModel: SettingsViewModel) -> some View {
349 Section {
350 if viewModel.isLoadingPATs {
351 HStack {
352 Spacer()
353 ProgressView()
354 Spacer()
355 }
356 } else if viewModel.personalAccessTokens.isEmpty {
357 Button("Load Tokens") {
358 Task { await viewModel.loadPersonalAccessTokens() }
359 }
360 } else {
361 ForEach(viewModel.personalAccessTokens) { token in
362 VStack(alignment: .leading, spacing: 4) {
363 HStack {
364 Text(token.comment ?? "Token #\(token.id)")
365 .font(.subheadline)
366 Spacer()
367 }
368
369 HStack(spacing: 12) {
370 Text("Issued \(token.issued.relativeDescription)")
371 .font(.caption2)
372 .foregroundStyle(.secondary)
373
374 if let expires = token.expires {
375 Text("Expires \(expires.relativeDescription)")
376 .font(.caption2)
377 .foregroundStyle(expires < Date.now ? .red : .secondary)
378 }
379 }
380
381 if let grants = token.grants, !grants.isEmpty {
382 Text(grants)
383 .font(.caption2.monospaced())
384 .foregroundStyle(.tertiary)
385 .lineLimit(2)
386 }
387 }
388 }
389 }
390 } header: {
391 Text("Personal Access Tokens")
392 } footer: {
393 if !viewModel.personalAccessTokens.isEmpty {
394 Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
395 }
396 }
397 }
398}
399
400private struct ProfileBioView: View {
401 let markdown: String
402
403 var body: some View {
404 Text(profileBioAttributedString(markdown))
405 .frame(maxWidth: .infinity, alignment: .leading)
406 .tint(.accentColor)
407 .textSelection(.enabled)
408 }
409}
410
411func profileBioAttributedString(_ markdown: String) -> AttributedString {
412 guard let attributed = try? AttributedString(
413 markdown: markdown,
414 options: profileBioMarkdownOptions
415 ) else {
416 return AttributedString(markdown)
417 }
418 return attributed
419}
420
421private struct EditProfileSheet: View {
422 let profile: UserProfile
423 let viewModel: SettingsViewModel
424
425 @State private var email: String
426 @State private var url: String
427 @State private var location: String
428 @State private var bio: String
429 @State private var selectedPhoto: PhotosPickerItem?
430 @State private var avatarPreview: UIImage?
431 @State private var isShowingRemoveAvatarConfirmation = false
432
433 @Environment(\.dismiss) private var dismiss
434
435 init(profile: UserProfile, viewModel: SettingsViewModel) {
436 self.profile = profile
437 self.viewModel = viewModel
438 _email = State(initialValue: profile.email)
439 _url = State(initialValue: profile.url ?? "")
440 _location = State(initialValue: profile.location ?? "")
441 _bio = State(initialValue: profile.bio ?? "")
442 }
443
444 var body: some View {
445 NavigationStack {
446 Form {
447 Section {
448 HStack {
449 Spacer()
450 VStack(spacing: 8) {
451 PhotosPicker(selection: $selectedPhoto, matching: .images) {
452 Group {
453 if let avatarPreview {
454 Image(uiImage: avatarPreview)
455 .resizable()
456 .scaledToFill()
457 } else {
458 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
459 switch phase {
460 case .success(let image):
461 image
462 .resizable()
463 .scaledToFill()
464 default:
465 Image(systemName: "person.crop.circle.fill")
466 .resizable()
467 .foregroundStyle(.secondary)
468 }
469 }
470 }
471 }
472 .frame(width: 80, height: 80)
473 .clipShape(Circle())
474 .overlay(
475 Circle()
476 .stroke(.secondary.opacity(0.3), lineWidth: 1)
477 )
478 }
479
480 Text("Tap to change avatar")
481 .font(.caption)
482 .foregroundStyle(.secondary)
483
484 if viewModel.isUploadingAvatar {
485 ProgressView()
486 .controlSize(.small)
487 }
488 }
489 Spacer()
490 }
491 .listRowBackground(Color.clear)
492
493 if profile.avatar != nil || avatarPreview != nil {
494 HStack {
495 Spacer()
496 Button(role: .destructive) {
497 isShowingRemoveAvatarConfirmation = true
498 } label: {
499 Text("Remove Avatar")
500 }
501 .buttonStyle(.borderedProminent)
502 .disabled(viewModel.isUploadingAvatar)
503 Spacer()
504 }
505 .listRowBackground(Color.clear)
506 .listRowSeparator(.hidden)
507 }
508 }
509
510 Section("Edit Profile") {
511 VStack(alignment: .leading, spacing: 4) {
512 Text("Email")
513 .font(.caption)
514 .foregroundStyle(.secondary)
515 TextField("Enter email", text: $email)
516 .textContentType(.emailAddress)
517 .keyboardType(.emailAddress)
518 .autocorrectionDisabled()
519 .textInputAutocapitalization(.never)
520 }
521
522 VStack(alignment: .leading, spacing: 4) {
523 Text("URL")
524 .font(.caption)
525 .foregroundStyle(.secondary)
526 TextField("Enter URL", text: $url)
527 .textContentType(.URL)
528 .keyboardType(.URL)
529 .autocorrectionDisabled()
530 .textInputAutocapitalization(.never)
531 }
532
533 VStack(alignment: .leading, spacing: 4) {
534 Text("Location")
535 .font(.caption)
536 .foregroundStyle(.secondary)
537 TextField("Enter location", text: $location)
538 }
539
540 VStack(alignment: .leading, spacing: 4) {
541 Text("Bio")
542 .font(.caption)
543 .foregroundStyle(.secondary)
544 TextField("Enter bio", text: $bio, axis: .vertical)
545 .lineLimit(3...6)
546 }
547 }
548 }
549 .navigationTitle("Edit Profile")
550 .navigationBarTitleDisplayMode(.inline)
551 .onChange(of: selectedPhoto) { _, newItem in
552 guard let newItem else { return }
553 Task {
554 if let data = try? await newItem.loadTransferable(type: Data.self),
555 let image = UIImage(data: data) {
556 avatarPreview = image
557 if let jpegData = image.jpegData(compressionQuality: 0.85) {
558 await viewModel.uploadAvatar(jpegData: jpegData)
559 }
560 }
561 }
562 }
563 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
564 Button("Cancel", role: .cancel) {}
565 Button("Remove Avatar", role: .destructive) {
566 Task {
567 await viewModel.removeAvatar()
568 if viewModel.error == nil {
569 avatarPreview = nil
570 selectedPhoto = nil
571 }
572 }
573 }
574 } message: {
575 Text("Your profile avatar will be removed from SourceHut.")
576 }
577 .toolbar {
578 ToolbarItem(placement: .cancellationAction) {
579 Button("Cancel") {
580 dismiss()
581 }
582 }
583 ToolbarItem(placement: .confirmationAction) {
584 Button {
585 Task {
586 await viewModel.saveProfile(
587 email: email,
588 url: url,
589 location: location,
590 bio: bio
591 )
592 if viewModel.error == nil {
593 dismiss()
594 }
595 }
596 } label: {
597 if viewModel.isSavingProfile {
598 ProgressView()
599 .controlSize(.small)
600 } else {
601 Text("Save")
602 }
603 }
604 .disabled(viewModel.isSavingProfile)
605 }
606 }
607 }
608 }
609}
610
611private enum ProfileDestructiveAction {
612 case deleteSSHKey(SSHKey)
613 case deletePGPKey(PGPKey)
614
615 var title: String {
616 switch self {
617 case .deleteSSHKey:
618 "Remove SSH Key?"
619 case .deletePGPKey:
620 "Remove PGP Key?"
621 }
622 }
623
624 var confirmationLabel: String {
625 switch self {
626 case .deleteSSHKey:
627 "Remove SSH Key"
628 case .deletePGPKey:
629 "Remove PGP Key"
630 }
631 }
632
633 var message: String {
634 switch self {
635 case .deleteSSHKey(let key):
636 "Remove SSH key \(key.fingerprint) from your account?"
637 case .deletePGPKey(let key):
638 "Remove PGP key \(key.fingerprint) from your account?"
639 }
640 }
641}
Hutch/Views/Settings/SettingsView.swift +8 −576
@@ -1,94 +1,18 @@
1import PhotosUI
2import SwiftUI 1import SwiftUI
3 2
4private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
5 interpretedSyntax: .inlineOnlyPreservingWhitespace
6)
7
8struct SettingsView: View { 3struct SettingsView: View {
9 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
10 @Environment(\.colorScheme) private var colorScheme
11 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 5 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
12 @State private var viewModel: SettingsViewModel? 6 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
13 @State private var pendingDestructiveAction: SettingsDestructiveAction? 7 @State private var pendingDestructiveAction: SettingsDestructiveAction?
14 8
15 var body: some View { 9 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 { 10 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() 11 behaviorSection()
53 12 authenticationSection()
54 // Token / Sign Out
55 tokenSection()
56
57 aboutSection() 13 aboutSection()
58 } 14 }
59 .overlay { 15 .navigationTitle("Settings")
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( 16 .alert(
93 pendingDestructiveAction?.title ?? "", 17 pendingDestructiveAction?.title ?? "",
94 isPresented: Binding( 18 isPresented: Binding(
@@ -100,9 +24,7 @@ struct SettingsView: View {
100 } 24 }
101 ) 25 )
102 ) { 26 ) {
103 Button("Cancel", role: .cancel) { 27 Button("Cancel", role: .cancel) {}
104 // Alert dismissal is implicit; no additional action required.
105 }
106 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { 28 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
107 guard let action = pendingDestructiveAction else { return } 29 guard let action = pendingDestructiveAction else { return }
108 pendingDestructiveAction = nil 30 pendingDestructiveAction = nil
@@ -112,10 +34,6 @@ struct SettingsView: View {
112 await appState.resetAppData() 34 await appState.resetAppData()
113 case .signOut: 35 case .signOut:
114 await appState.signOut() 36 await appState.signOut()
115 case .deleteSSHKey(let key):
116 await viewModel.deleteSSHKey(key)
117 case .deletePGPKey(let key):
118 await viewModel.deletePGPKey(key)
119 } 37 }
120 } 38 }
121 } 39 }
@@ -124,282 +42,22 @@ struct SettingsView: View {
124 Text(pendingDestructiveAction.message) 42 Text(pendingDestructiveAction.message)
125 } 43 }
126 } 44 }
127 .refreshable {
128 await viewModel.loadProfile()
129 }
130 } 45 }
131 46
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 47 @ViewBuilder
391 private func behaviorSection() -> some View { 48 private func behaviorSection() -> some View {
392 Section { 49 Section {
393 Toggle("Swipe actions", isOn: $swipeActionsEnabled) 50 Toggle("Swipe actions", isOn: $swipeActionsEnabled)
51 Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
394 } header: { 52 } header: {
395 Text("Behavior") 53 Text("Behavior")
396 } footer: { 54 } footer: {
397 Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes.") 55 Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes. Contribution graphs controls whether SourceHut activity heatmaps appear in lookup profiles.")
398 } 56 }
399 } 57 }
400 58
401 @ViewBuilder 59 @ViewBuilder
402 private func tokenSection() -> some View { 60 private func authenticationSection() -> some View {
403 Section { 61 Section {
404 HStack { 62 HStack {
405 Image(systemName: "key.fill") 63 Image(systemName: "key.fill")
@@ -436,227 +94,13 @@ struct SettingsView: View {
436 } 94 }
437} 95}
438 96
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 { 97func settingsBioAttributedString(_ markdown: String) -> AttributedString {
451 guard let attributed = try? AttributedString( 98 profileBioAttributedString(markdown)
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} 99}
654 100
655private enum SettingsDestructiveAction { 101private enum SettingsDestructiveAction {
656 case resetAppData 102 case resetAppData
657 case signOut 103 case signOut
658 case deleteSSHKey(SSHKey)
659 case deletePGPKey(PGPKey)
660 104
661 var title: String { 105 var title: String {
662 switch self { 106 switch self {
@@ -664,10 +108,6 @@ private enum SettingsDestructiveAction {
664 "Reset App Data?" 108 "Reset App Data?"
665 case .signOut: 109 case .signOut:
666 "Sign Out?" 110 "Sign Out?"
667 case .deleteSSHKey:
668 "Remove SSH Key?"
669 case .deletePGPKey:
670 "Remove PGP Key?"
671 } 111 }
672 } 112 }
673 113
@@ -677,10 +117,6 @@ private enum SettingsDestructiveAction {
677 "Reset App Data" 117 "Reset App Data"
678 case .signOut: 118 case .signOut:
679 "Sign Out" 119 "Sign Out"
680 case .deleteSSHKey:
681 "Remove SSH Key"
682 case .deletePGPKey:
683 "Remove PGP Key"
684 } 120 }
685 } 121 }
686 122
@@ -690,10 +126,6 @@ private enum SettingsDestructiveAction {
690 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device." 126 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
691 case .signOut: 127 case .signOut:
692 "This signs you out of Hutch and clears saved authentication state on this device." 128 "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 } 129 }
698 } 130 }
699} 131}