krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Tickets/TrackerManagementView.swift · raw
1import SwiftUI
2
3private struct TrackerQueryResponse: Decodable, Sendable {
4 let tracker: TrackerSummary?
5}
6
7private struct TrackerACLQueryResponse: Decodable, Sendable {
8 let tracker: TrackerACLQueryPayload?
9}
10
11private struct TrackerACLQueryPayload: Decodable, Sendable {
12 let defaultACL: DefaultTrackerACL
13 let acls: TrackerACLPage
14}
15
16private struct TrackerACLPage: Decodable, Sendable {
17 let results: [TrackerACL]
18 let cursor: String?
19}
20
21private struct TrackerLabelQueryResponse: Decodable, Sendable {
22 let tracker: TrackerLabelQueryPayload?
23}
24
25private struct TrackerLabelQueryPayload: Decodable, Sendable {
26 let labels: TrackerLabelPage
27}
28
29private struct TrackerLabelPage: Decodable, Sendable {
30 let results: [TicketLabel]
31 let cursor: String?
32}
33
34private struct UpdateTrackerResponse: Decodable, Sendable {
35 let updateTracker: TrackerSummary
36}
37
38private struct DeleteTrackerResponse: Decodable, Sendable {
39 let deleteTracker: DeletedTracker
40}
41
42private struct DeletedTracker: Decodable, Sendable {
43 let id: Int
44}
45
46private struct UpdateUserACLResponse: Decodable, Sendable {
47 let updateUserACL: TrackerACL
48}
49
50private struct UpdateTrackerACLResponse: Decodable, Sendable {
51 let updateTrackerACL: DefaultTrackerACL
52}
53
54private struct DeleteTrackerACLResponse: Decodable, Sendable {
55 let deleteACL: TrackerACL
56}
57
58private struct CreateTrackerLabelResponse: Decodable, Sendable {
59 let createLabel: TicketLabel
60}
61
62private struct UpdateTrackerLabelResponse: Decodable, Sendable {
63 let updateLabel: TicketLabel
64}
65
66private struct DeleteTrackerLabelResponse: Decodable, Sendable {
67 let deleteLabel: TicketLabel
68}
69
70private struct TrackerUserLookupResponse: Decodable, Sendable {
71 let user: UserIdPayload?
72}
73
74private struct UserIdPayload: Decodable, Sendable {
75 let id: Int
76}
77
78@Observable
79@MainActor
80final class TrackerManagementViewModel {
81 private(set) var tracker: TrackerSummary
82 private(set) var acls: [TrackerACL] = []
83 private(set) var defaultACL = DefaultTrackerACL(
84 browse: true,
85 submit: true,
86 comment: true,
87 edit: false,
88 triage: false
89 )
90 private(set) var labels: [TicketLabel] = []
91
92 private(set) var isSavingTracker = false
93 private(set) var isDeletingTracker = false
94 private(set) var isLoadingACLs = false
95 private(set) var isSavingACL = false
96 private(set) var isDeletingACL = false
97 private(set) var isLoadingLabels = false
98 private(set) var isSavingLabel = false
99 private(set) var isDeletingLabel = false
100
101 var error: String?
102 var didDeleteTracker = false
103
104 private let client: SRHTClient
105
106 init(tracker: TrackerSummary, client: SRHTClient) {
107 self.tracker = tracker
108 self.client = client
109 }
110
111 private static let trackerQuery = """
112 query tracker($rid: ID!) {
113 tracker(rid: $rid) {
114 id
115 rid
116 name
117 description
118 visibility
119 updated
120 owner { canonicalName }
121 }
122 }
123 """
124
125 private static let trackerACLsQuery = """
126 query trackerACLs($rid: ID!, $cursor: Cursor) {
127 tracker(rid: $rid) {
128 defaultACL {
129 browse
130 submit
131 comment
132 edit
133 triage
134 }
135 acls(cursor: $cursor) {
136 results {
137 id
138 created
139 entity { canonicalName }
140 browse
141 submit
142 comment
143 edit
144 triage
145 }
146 cursor
147 }
148 }
149 }
150 """
151
152 private static let trackerLabelsQuery = """
153 query trackerLabels($rid: ID!, $cursor: Cursor) {
154 tracker(rid: $rid) {
155 labels(cursor: $cursor) {
156 results {
157 id
158 name
159 backgroundColor
160 foregroundColor
161 }
162 cursor
163 }
164 }
165 }
166 """
167
168 private static let updateTrackerMutation = """
169 mutation updateTracker($id: Int!, $input: TrackerInput!) {
170 updateTracker(id: $id, input: $input) {
171 id
172 rid
173 name
174 description
175 visibility
176 updated
177 owner { canonicalName }
178 }
179 }
180 """
181
182 private static let deleteTrackerMutation = """
183 mutation deleteTracker($id: Int!) {
184 deleteTracker(id: $id) {
185 id
186 }
187 }
188 """
189
190 private static let updateUserACLMutation = """
191 mutation updateUserACL($trackerId: Int!, $userId: Int!, $input: ACLInput!) {
192 updateUserACL(trackerId: $trackerId, userId: $userId, input: $input) {
193 id
194 created
195 entity { canonicalName }
196 browse
197 submit
198 comment
199 edit
200 triage
201 }
202 }
203 """
204
205 private static let updateTrackerACLMutation = """
206 mutation updateTrackerACL($trackerId: Int!, $input: ACLInput!) {
207 updateTrackerACL(trackerId: $trackerId, input: $input) {
208 browse
209 submit
210 comment
211 edit
212 triage
213 }
214 }
215 """
216
217 private static let deleteACLMutation = """
218 mutation deleteACL($id: Int!) {
219 deleteACL(id: $id) {
220 id
221 created
222 entity { canonicalName }
223 browse
224 submit
225 comment
226 edit
227 triage
228 }
229 }
230 """
231
232 private static let createLabelMutation = """
233 mutation createLabel($trackerId: Int!, $name: String!, $foregroundColor: String!, $backgroundColor: String!) {
234 createLabel(trackerId: $trackerId, name: $name, foregroundColor: $foregroundColor, backgroundColor: $backgroundColor) {
235 id
236 name
237 backgroundColor
238 foregroundColor
239 }
240 }
241 """
242
243 private static let updateLabelMutation = """
244 mutation updateLabel($id: Int!, $input: UpdateLabelInput!) {
245 updateLabel(id: $id, input: $input) {
246 id
247 name
248 backgroundColor
249 foregroundColor
250 }
251 }
252 """
253
254 private static let deleteLabelMutation = """
255 mutation deleteLabel($id: Int!) {
256 deleteLabel(id: $id) {
257 id
258 name
259 backgroundColor
260 foregroundColor
261 }
262 }
263 """
264
265 private static let userLookupQuery = """
266 query userLookup($username: String!) {
267 user(username: $username) {
268 id
269 }
270 }
271 """
272
273 func refreshTracker() async {
274 do {
275 let result = try await client.execute(
276 service: .todo,
277 query: Self.trackerQuery,
278 variables: ["rid": tracker.rid],
279 responseType: TrackerQueryResponse.self
280 )
281 if let tracker = result.tracker {
282 self.tracker = tracker
283 }
284 } catch {
285 self.error = error.userFacingMessage
286 }
287 }
288
289 func updateTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
290 guard !isSavingTracker else { return nil }
291 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
292 guard !trimmedName.isEmpty else {
293 error = "Enter a tracker name."
294 return nil
295 }
296
297 isSavingTracker = true
298 error = nil
299 defer { isSavingTracker = false }
300
301 var input: [String: any Sendable] = [
302 "name": trimmedName,
303 "visibility": visibility.rawValue
304 ]
305 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
306 input["description"] = trimmedDescription.isEmpty ? "" : trimmedDescription
307
308 do {
309 let result = try await client.execute(
310 service: .todo,
311 query: Self.updateTrackerMutation,
312 variables: [
313 "id": tracker.id,
314 "input": input
315 ],
316 responseType: UpdateTrackerResponse.self
317 )
318 tracker = result.updateTracker
319 return result.updateTracker
320 } catch {
321 self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
322 return nil
323 }
324 }
325
326 func deleteTracker() async -> Bool {
327 guard !isDeletingTracker else { return false }
328 isDeletingTracker = true
329 error = nil
330 defer { isDeletingTracker = false }
331
332 do {
333 _ = try await client.execute(
334 service: .todo,
335 query: Self.deleteTrackerMutation,
336 variables: ["id": tracker.id],
337 responseType: DeleteTrackerResponse.self
338 )
339 didDeleteTracker = true
340 return true
341 } catch {
342 self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
343 return false
344 }
345 }
346
347 func loadACLs() async {
348 guard !isLoadingACLs else { return }
349 isLoadingACLs = true
350 error = nil
351 defer { isLoadingACLs = false }
352
353 do {
354 let result = try await client.execute(
355 service: .todo,
356 query: Self.trackerACLsQuery,
357 variables: ["rid": tracker.rid],
358 responseType: TrackerACLQueryResponse.self
359 )
360 defaultACL = result.tracker?.defaultACL ?? defaultACL
361 acls = result.tracker?.acls.results ?? []
362 } catch {
363 self.error = error.userFacingMessage
364 }
365 }
366
367 func updateDefaultACL(_ permissions: TrackerACLPermissions) async -> Bool {
368 guard !isSavingACL else { return false }
369 isSavingACL = true
370 error = nil
371 defer { isSavingACL = false }
372
373 do {
374 let result = try await client.execute(
375 service: .todo,
376 query: Self.updateTrackerACLMutation,
377 variables: [
378 "trackerId": tracker.id,
379 "input": permissions.graphQLInput
380 ],
381 responseType: UpdateTrackerACLResponse.self
382 )
383 defaultACL = result.updateTrackerACL
384 await loadACLs()
385 return true
386 } catch {
387 self.error = error.userFacingMessage
388 return false
389 }
390 }
391
392 func addOrUpdateACL(username: String, permissions: TrackerACLPermissions) async -> Bool {
393 guard !isSavingACL else { return false }
394 let normalizedUsername = Self.normalizedUsername(username)
395 guard !normalizedUsername.isEmpty else {
396 error = "Enter a SourceHut username."
397 return false
398 }
399
400 isSavingACL = true
401 error = nil
402 defer { isSavingACL = false }
403
404 do {
405 let userResult = try await client.execute(
406 service: .todo,
407 query: Self.userLookupQuery,
408 variables: ["username": normalizedUsername],
409 responseType: TrackerUserLookupResponse.self
410 )
411 guard let userId = userResult.user?.id else {
412 error = "That user couldn’t be found."
413 return false
414 }
415
416 let result = try await client.execute(
417 service: .todo,
418 query: Self.updateUserACLMutation,
419 variables: [
420 "trackerId": tracker.id,
421 "userId": userId,
422 "input": permissions.graphQLInput
423 ],
424 responseType: UpdateUserACLResponse.self
425 )
426 if let index = acls.firstIndex(where: { $0.id == result.updateUserACL.id }) {
427 acls[index] = result.updateUserACL
428 } else {
429 acls.append(result.updateUserACL)
430 acls.sort { $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending }
431 }
432 await loadACLs()
433 return true
434 } catch {
435 self.error = error.userFacingMessage
436 return false
437 }
438 }
439
440 func deleteACL(_ entry: TrackerACL) async {
441 guard !isDeletingACL else { return }
442 isDeletingACL = true
443 error = nil
444 defer { isDeletingACL = false }
445
446 do {
447 _ = try await client.execute(
448 service: .todo,
449 query: Self.deleteACLMutation,
450 variables: ["id": entry.id],
451 responseType: DeleteTrackerACLResponse.self
452 )
453 acls.removeAll { $0.id == entry.id }
454 await loadACLs()
455 } catch {
456 self.error = error.userFacingMessage
457 }
458 }
459
460 func loadLabels() async {
461 guard !isLoadingLabels else { return }
462 isLoadingLabels = true
463 error = nil
464 defer { isLoadingLabels = false }
465
466 do {
467 let result = try await client.execute(
468 service: .todo,
469 query: Self.trackerLabelsQuery,
470 variables: ["rid": tracker.rid],
471 responseType: TrackerLabelQueryResponse.self
472 )
473 labels = result.tracker?.labels.results ?? []
474 } catch {
475 self.error = error.userFacingMessage
476 }
477 }
478
479 func createLabel(name: String, foregroundColor: String, backgroundColor: String) async -> Bool {
480 guard !isSavingLabel else { return false }
481 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
482 guard !trimmedName.isEmpty else {
483 error = "Enter a label name."
484 return false
485 }
486 guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
487 error = "Label colors must use #RRGGBB format."
488 return false
489 }
490
491 isSavingLabel = true
492 error = nil
493 defer { isSavingLabel = false }
494
495 do {
496 _ = try await client.execute(
497 service: .todo,
498 query: Self.createLabelMutation,
499 variables: [
500 "trackerId": tracker.id,
501 "name": trimmedName,
502 "foregroundColor": foregroundColor,
503 "backgroundColor": backgroundColor
504 ],
505 responseType: CreateTrackerLabelResponse.self
506 )
507 await loadLabels()
508 return true
509 } catch {
510 self.error = error.userFacingMessage
511 return false
512 }
513 }
514
515 func updateLabel(
516 _ label: TicketLabel,
517 name: String,
518 foregroundColor: String,
519 backgroundColor: String
520 ) async -> Bool {
521 guard !isSavingLabel else { return false }
522 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
523 guard !trimmedName.isEmpty else {
524 error = "Enter a label name."
525 return false
526 }
527 guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
528 error = "Label colors must use #RRGGBB format."
529 return false
530 }
531
532 isSavingLabel = true
533 error = nil
534 defer { isSavingLabel = false }
535
536 var input: [String: any Sendable] = [:]
537 if trimmedName != label.name {
538 input["name"] = trimmedName
539 }
540 if foregroundColor.caseInsensitiveCompare(label.foregroundColor) != .orderedSame {
541 input["foregroundColor"] = foregroundColor
542 }
543 if backgroundColor.caseInsensitiveCompare(label.backgroundColor) != .orderedSame {
544 input["backgroundColor"] = backgroundColor
545 }
546
547 guard !input.isEmpty else { return true }
548
549 do {
550 _ = try await client.execute(
551 service: .todo,
552 query: Self.updateLabelMutation,
553 variables: [
554 "id": label.id,
555 "input": input
556 ],
557 responseType: UpdateTrackerLabelResponse.self
558 )
559 await loadLabels()
560 return true
561 } catch {
562 self.error = error.userFacingMessage
563 return false
564 }
565 }
566
567 func deleteLabel(_ label: TicketLabel) async {
568 guard !isDeletingLabel else { return }
569 isDeletingLabel = true
570 error = nil
571 defer { isDeletingLabel = false }
572
573 do {
574 _ = try await client.execute(
575 service: .todo,
576 query: Self.deleteLabelMutation,
577 variables: ["id": label.id],
578 responseType: DeleteTrackerLabelResponse.self
579 )
580 labels.removeAll { $0.id == label.id }
581 await loadLabels()
582 } catch {
583 self.error = error.userFacingMessage
584 }
585 }
586
587 static func normalizedUsername(_ value: String) -> String {
588 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
589 guard !trimmed.isEmpty else { return "" }
590 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
591 }
592
593 static func isValidHexColor(_ value: String) -> Bool {
594 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
595 guard trimmed.count == 7, trimmed.first == "#" else { return false }
596 return trimmed.dropFirst().allSatisfy { $0.isHexDigit }
597 }
598}
599
600private extension TrackerACLPermissions {
601 var graphQLInput: [String: any Sendable] {
602 [
603 "browse": browse,
604 "submit": submit,
605 "comment": comment,
606 "edit": edit,
607 "triage": triage
608 ]
609 }
610}
611
612struct TrackerEditorSheet: View {
613 let title: String
614 let confirmationTitle: String
615 let isSaving: Bool
616 let error: String?
617 let initialName: String
618 let initialDescription: String
619 let initialVisibility: Visibility
620 let onSave: (String, String, Visibility) async -> Bool
621
622 @Environment(\.dismiss) private var dismiss
623 @State private var name: String
624 @State private var description: String
625 @State private var visibility: Visibility
626
627 init(
628 title: String,
629 confirmationTitle: String,
630 isSaving: Bool,
631 error: String?,
632 initialName: String,
633 initialDescription: String,
634 initialVisibility: Visibility,
635 onSave: @escaping (String, String, Visibility) async -> Bool
636 ) {
637 self.title = title
638 self.confirmationTitle = confirmationTitle
639 self.isSaving = isSaving
640 self.error = error
641 self.initialName = initialName
642 self.initialDescription = initialDescription
643 self.initialVisibility = initialVisibility
644 self.onSave = onSave
645 _name = State(initialValue: initialName)
646 _description = State(initialValue: initialDescription)
647 _visibility = State(initialValue: initialVisibility)
648 }
649
650 var body: some View {
651 NavigationStack {
652 Form {
653 Section("Tracker Details") {
654 TextField("Tracker name", text: $name)
655 .textInputAutocapitalization(.never)
656 .autocorrectionDisabled()
657 .themedRow()
658 TextField("Short description (optional)", text: $description, axis: .vertical)
659 .lineLimit(2...4)
660 .themedRow()
661 Picker("Visibility", selection: $visibility) {
662 Text("Public").tag(Visibility.publicVisibility)
663 Text("Unlisted").tag(Visibility.unlisted)
664 Text("Private").tag(Visibility.privateVisibility)
665 }
666 .themedRow()
667 }
668
669 if let error, !error.isEmpty {
670 Section {
671 Text(error)
672 .foregroundStyle(.red)
673 .themedRow()
674 }
675 }
676 }
677 .themedList()
678 .navigationTitle(title)
679 .navigationBarTitleDisplayMode(.inline)
680 .toolbar {
681 ToolbarItem(placement: .cancellationAction) {
682 Button("Cancel") { dismiss() }
683 }
684 ToolbarItem(placement: .confirmationAction) {
685 Button {
686 Task {
687 let didSave = await onSave(name, description, visibility)
688 if didSave {
689 dismiss()
690 }
691 }
692 } label: {
693 if isSaving {
694 ProgressView()
695 .controlSize(.small)
696 } else {
697 Text(confirmationTitle)
698 }
699 }
700 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
701 }
702 }
703 }
704 }
705}
706
707struct TrackerACLManagementSheet: View {
708 let viewModel: TrackerManagementViewModel
709
710 @Bindable private var bindableViewModel: TrackerManagementViewModel
711 @State private var editingACL: TrackerACL?
712 @State private var editingDefaultACL = false
713 @State private var pendingDeletion: TrackerACL?
714 @State private var showCreateACL = false
715
716 init(viewModel: TrackerManagementViewModel) {
717 self.viewModel = viewModel
718 self._bindableViewModel = Bindable(viewModel)
719 }
720
721 var body: some View {
722 NavigationStack {
723 List {
724 Section("Default Access") {
725 TrackerPermissionSummary(permissions: viewModel.defaultACL.permissions)
726 .themedRow()
727 Button("Update Default ACL") {
728 editingDefaultACL = true
729 }
730 .disabled(viewModel.isSavingACL)
731 .themedRow()
732 }
733
734 Section {
735 if viewModel.isLoadingACLs {
736 HStack {
737 Spacer()
738 ProgressView()
739 Spacer()
740 }
741 .themedRow()
742 } else if viewModel.acls.isEmpty {
743 Text("No tracker-specific ACLs yet.")
744 .foregroundStyle(.secondary)
745 .themedRow()
746 } else {
747 ForEach(viewModel.acls) { entry in
748 VStack(alignment: .leading, spacing: 6) {
749 Text(entry.entity.canonicalName)
750 .font(.subheadline.weight(.medium))
751 TrackerPermissionSummary(permissions: entry.permissions)
752 }
753 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
754 Button {
755 pendingDeletion = entry
756 } label: {
757 Label("Delete", systemImage: "trash")
758 }
759 .tint(.red)
760
761 Button {
762 editingACL = entry
763 } label: {
764 Label("Edit", systemImage: "pencil")
765 }
766 .tint(.blue)
767 }
768 }
769 .themedRow()
770 }
771 } header: {
772 Text("User ACLs")
773 } footer: {
774 Text("Each ACL must include all five permission flags.")
775 }
776 }
777 .themedList()
778 .navigationTitle("ACLs")
779 .navigationBarTitleDisplayMode(.inline)
780 .toolbar {
781 ToolbarItem(placement: .topBarTrailing) {
782 Button {
783 showCreateACL = true
784 } label: {
785 Image(systemName: "plus")
786 }
787 .disabled(viewModel.isSavingACL)
788 .accessibilityLabel("Add ACL")
789 }
790 }
791 .task {
792 await viewModel.loadACLs()
793 }
794 .srhtErrorBanner(error: $bindableViewModel.error)
795 .sheet(isPresented: $showCreateACL) {
796 TrackerACLEditorSheet(
797 title: "Add ACL",
798 submitTitle: "Save",
799 isSaving: viewModel.isSavingACL,
800 error: viewModel.error,
801 initialUsername: "",
802 initialPermissions: viewModel.defaultACL.permissions
803 ) { username, permissions in
804 await viewModel.addOrUpdateACL(username: username, permissions: permissions)
805 }
806 }
807 .sheet(item: $editingACL) { entry in
808 TrackerACLEditorSheet(
809 title: "Update ACL",
810 submitTitle: "Save",
811 isSaving: viewModel.isSavingACL,
812 error: viewModel.error,
813 initialUsername: entry.entity.canonicalName,
814 initialPermissions: entry.permissions
815 ) { username, permissions in
816 await viewModel.addOrUpdateACL(username: username, permissions: permissions)
817 }
818 }
819 .sheet(isPresented: $editingDefaultACL) {
820 TrackerDefaultACLEditorSheet(
821 isSaving: viewModel.isSavingACL,
822 error: viewModel.error,
823 initialPermissions: viewModel.defaultACL.permissions
824 ) { permissions in
825 await viewModel.updateDefaultACL(permissions)
826 }
827 }
828 .alert("Remove Access?", isPresented: Binding(
829 get: { pendingDeletion != nil },
830 set: { isPresented in
831 if !isPresented {
832 pendingDeletion = nil
833 }
834 }
835 )) {
836 Button("Cancel", role: .cancel) {
837 // no-op: .cancel role handles alert dismissal
838 }
839 Button("Delete", role: .destructive) {
840 guard let pendingDeletion else { return }
841 Task {
842 await viewModel.deleteACL(pendingDeletion)
843 self.pendingDeletion = nil
844 }
845 }
846 } message: {
847 if let pendingDeletion {
848 Text("\(pendingDeletion.entity.canonicalName) will fall back to the tracker default ACL.")
849 }
850 }
851 }
852 }
853}
854
855private struct TrackerPermissionSummary: View {
856 let permissions: TrackerACLPermissions
857
858 var body: some View {
859 Text(summary)
860 .font(.caption)
861 .foregroundStyle(.secondary)
862 }
863
864 private var summary: String {
865 let items = [
866 permissions.browse ? "browse" : nil,
867 permissions.submit ? "submit" : nil,
868 permissions.comment ? "comment" : nil,
869 permissions.edit ? "edit" : nil,
870 permissions.triage ? "triage" : nil
871 ].compactMap { $0 }
872 return items.isEmpty ? "No permissions" : items.joined(separator: ", ")
873 }
874}
875
876private struct TrackerACLEditorSheet: View {
877 let title: String
878 let submitTitle: String
879 let isSaving: Bool
880 let error: String?
881 let initialUsername: String
882 let initialPermissions: TrackerACLPermissions
883 let onSave: (String, TrackerACLPermissions) async -> Bool
884
885 @Environment(\.dismiss) private var dismiss
886 @State private var username: String
887 @State private var browse: Bool
888 @State private var submit: Bool
889 @State private var comment: Bool
890 @State private var edit: Bool
891 @State private var triage: Bool
892
893 init(
894 title: String,
895 submitTitle: String,
896 isSaving: Bool,
897 error: String?,
898 initialUsername: String,
899 initialPermissions: TrackerACLPermissions,
900 onSave: @escaping (String, TrackerACLPermissions) async -> Bool
901 ) {
902 self.title = title
903 self.submitTitle = submitTitle
904 self.isSaving = isSaving
905 self.error = error
906 self.initialUsername = initialUsername
907 self.initialPermissions = initialPermissions
908 self.onSave = onSave
909 _username = State(initialValue: initialUsername)
910 _browse = State(initialValue: initialPermissions.browse)
911 _submit = State(initialValue: initialPermissions.submit)
912 _comment = State(initialValue: initialPermissions.comment)
913 _edit = State(initialValue: initialPermissions.edit)
914 _triage = State(initialValue: initialPermissions.triage)
915 }
916
917 var body: some View {
918 NavigationStack {
919 Form {
920 Section("User") {
921 TextField("Username or ~username", text: $username)
922 .autocorrectionDisabled()
923 .textInputAutocapitalization(.never)
924 .themedRow()
925 }
926
927 permissionSection
928
929 if let error, !error.isEmpty {
930 Section {
931 Text(error)
932 .foregroundStyle(.red)
933 .themedRow()
934 }
935 }
936 }
937 .themedList()
938 .navigationTitle(title)
939 .navigationBarTitleDisplayMode(.inline)
940 .toolbar {
941 ToolbarItem(placement: .cancellationAction) {
942 Button("Cancel") { dismiss() }
943 }
944 ToolbarItem(placement: .confirmationAction) {
945 Button {
946 Task {
947 let didSave = await onSave(username, permissions)
948 if didSave {
949 dismiss()
950 }
951 }
952 } label: {
953 if isSaving {
954 ProgressView()
955 .controlSize(.small)
956 } else {
957 Text(submitTitle)
958 }
959 }
960 .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
961 }
962 }
963 }
964 }
965
966 private var permissionSection: some View {
967 Section("Permissions") {
968 Toggle("Browse", isOn: $browse)
969 .themedRow()
970 Toggle("Submit", isOn: $submit)
971 .themedRow()
972 Toggle("Comment", isOn: $comment)
973 .themedRow()
974 Toggle("Edit", isOn: $edit)
975 .themedRow()
976 Toggle("Triage", isOn: $triage)
977 .themedRow()
978 }
979 }
980
981 private var permissions: TrackerACLPermissions {
982 TrackerACLPermissions(
983 browse: browse,
984 submit: submit,
985 comment: comment,
986 edit: edit,
987 triage: triage
988 )
989 }
990}
991
992private struct TrackerDefaultACLEditorSheet: View {
993 let isSaving: Bool
994 let error: String?
995 let initialPermissions: TrackerACLPermissions
996 let onSave: (TrackerACLPermissions) async -> Bool
997
998 @Environment(\.dismiss) private var dismiss
999 @State private var browse: Bool
1000 @State private var submit: Bool
1001 @State private var comment: Bool
1002 @State private var edit: Bool
1003 @State private var triage: Bool
1004
1005 init(
1006 isSaving: Bool,
1007 error: String?,
1008 initialPermissions: TrackerACLPermissions,
1009 onSave: @escaping (TrackerACLPermissions) async -> Bool
1010 ) {
1011 self.isSaving = isSaving
1012 self.error = error
1013 self.initialPermissions = initialPermissions
1014 self.onSave = onSave
1015 _browse = State(initialValue: initialPermissions.browse)
1016 _submit = State(initialValue: initialPermissions.submit)
1017 _comment = State(initialValue: initialPermissions.comment)
1018 _edit = State(initialValue: initialPermissions.edit)
1019 _triage = State(initialValue: initialPermissions.triage)
1020 }
1021
1022 var body: some View {
1023 NavigationStack {
1024 Form {
1025 Section("Permissions") {
1026 Toggle("Browse", isOn: $browse)
1027 .themedRow()
1028 Toggle("Submit", isOn: $submit)
1029 .themedRow()
1030 Toggle("Comment", isOn: $comment)
1031 .themedRow()
1032 Toggle("Edit", isOn: $edit)
1033 .themedRow()
1034 Toggle("Triage", isOn: $triage)
1035 .themedRow()
1036 }
1037
1038 if let error, !error.isEmpty {
1039 Section {
1040 Text(error)
1041 .foregroundStyle(.red)
1042 .themedRow()
1043 }
1044 }
1045 }
1046 .themedList()
1047 .navigationTitle("Default ACL")
1048 .navigationBarTitleDisplayMode(.inline)
1049 .toolbar {
1050 ToolbarItem(placement: .cancellationAction) {
1051 Button("Cancel") { dismiss() }
1052 }
1053 ToolbarItem(placement: .confirmationAction) {
1054 Button {
1055 Task {
1056 let didSave = await onSave(
1057 TrackerACLPermissions(
1058 browse: browse,
1059 submit: submit,
1060 comment: comment,
1061 edit: edit,
1062 triage: triage
1063 )
1064 )
1065 if didSave {
1066 dismiss()
1067 }
1068 }
1069 } label: {
1070 if isSaving {
1071 ProgressView()
1072 .controlSize(.small)
1073 } else {
1074 Text("Save")
1075 }
1076 }
1077 .disabled(isSaving)
1078 }
1079 }
1080 }
1081 }
1082}
1083
1084struct TrackerLabelManagementSheet: View {
1085 let viewModel: TrackerManagementViewModel
1086
1087 @Bindable private var bindableViewModel: TrackerManagementViewModel
1088 @State private var showCreateLabel = false
1089 @State private var editingLabel: TicketLabel?
1090 @State private var pendingDeletion: TicketLabel?
1091
1092 init(viewModel: TrackerManagementViewModel) {
1093 self.viewModel = viewModel
1094 self._bindableViewModel = Bindable(viewModel)
1095 }
1096
1097 var body: some View {
1098 NavigationStack {
1099 List {
1100 Section {
1101 Text("Labels are managed here and reused throughout the tracker.")
1102 .font(.footnote)
1103 .foregroundStyle(.secondary)
1104 .themedRow()
1105 }
1106
1107 if viewModel.isLoadingLabels {
1108 HStack {
1109 Spacer()
1110 ProgressView()
1111 Spacer()
1112 }
1113 .themedRow()
1114 } else if viewModel.labels.isEmpty {
1115 ContentUnavailableView(
1116 "No Labels",
1117 systemImage: "tag",
1118 description: Text("Create labels for triage and organization.")
1119 )
1120 .themedRow()
1121 } else {
1122 ForEach(viewModel.labels) { label in
1123 Button {
1124 editingLabel = label
1125 } label: {
1126 TrackerLabelManagementRow(label: label)
1127 }
1128 .buttonStyle(.plain)
1129 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
1130 Button {
1131 editingLabel = label
1132 } label: {
1133 Label("Edit", systemImage: "pencil")
1134 }
1135 .tint(.blue)
1136
1137 Button {
1138 pendingDeletion = label
1139 } label: {
1140 Label("Delete", systemImage: "trash")
1141 }
1142 .tint(.red)
1143 }
1144 }
1145 .themedRow()
1146 }
1147 }
1148 .themedList()
1149 .navigationTitle("Labels")
1150 .navigationBarTitleDisplayMode(.inline)
1151 .toolbar {
1152 ToolbarItem(placement: .topBarTrailing) {
1153 Button {
1154 showCreateLabel = true
1155 } label: {
1156 Image(systemName: "plus")
1157 }
1158 .disabled(viewModel.isSavingLabel)
1159 .accessibilityLabel("Create label")
1160 }
1161 }
1162 .task {
1163 await viewModel.loadLabels()
1164 }
1165 .srhtErrorBanner(error: $bindableViewModel.error)
1166 .sheet(isPresented: $showCreateLabel) {
1167 TrackerLabelEditorSheet(
1168 title: "New Label",
1169 submitTitle: "Create",
1170 isSaving: viewModel.isSavingLabel,
1171 error: viewModel.error,
1172 initialLabel: nil
1173 ) { name, foreground, background in
1174 await viewModel.createLabel(
1175 name: name,
1176 foregroundColor: foreground,
1177 backgroundColor: background
1178 )
1179 }
1180 }
1181 .sheet(item: $editingLabel) { label in
1182 TrackerLabelEditorSheet(
1183 title: "Update Label",
1184 submitTitle: "Save",
1185 isSaving: viewModel.isSavingLabel,
1186 error: viewModel.error,
1187 initialLabel: label
1188 ) { name, foreground, background in
1189 await viewModel.updateLabel(
1190 label,
1191 name: name,
1192 foregroundColor: foreground,
1193 backgroundColor: background
1194 )
1195 }
1196 }
1197 .alert("Delete Label?", isPresented: Binding(
1198 get: { pendingDeletion != nil },
1199 set: { isPresented in
1200 if !isPresented {
1201 pendingDeletion = nil
1202 }
1203 }
1204 )) {
1205 Button("Cancel", role: .cancel) {
1206 // no-op: .cancel role handles alert dismissal
1207 }
1208 Button("Delete", role: .destructive) {
1209 guard let pendingDeletion else { return }
1210 Task {
1211 await viewModel.deleteLabel(pendingDeletion)
1212 self.pendingDeletion = nil
1213 }
1214 }
1215 } message: {
1216 if let pendingDeletion {
1217 Text("“\(pendingDeletion.name)” will be removed from this tracker and from any tickets using it.")
1218 }
1219 }
1220 }
1221 }
1222}
1223
1224private struct TrackerLabelManagementRow: View {
1225 let label: TicketLabel
1226
1227 var body: some View {
1228 VStack(alignment: .leading, spacing: 8) {
1229 HStack(alignment: .top, spacing: 12) {
1230 LabelPill(label: label)
1231 Spacer()
1232 Image(systemName: "chevron.right")
1233 .font(.caption.weight(.semibold))
1234 .foregroundStyle(.tertiary)
1235 }
1236
1237 HStack(spacing: 12) {
1238 colorSwatch(hex: label.backgroundColor, title: "Background")
1239 colorSwatch(hex: label.foregroundColor, title: "Text")
1240 }
1241 }
1242 .padding(.vertical, 4)
1243 }
1244
1245 private func colorSwatch(hex: String, title: String) -> some View {
1246 HStack(spacing: 6) {
1247 Circle()
1248 .fill(Color(hex: hex) ?? .clear)
1249 .frame(width: 10, height: 10)
1250 .overlay {
1251 Circle()
1252 .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
1253 }
1254
1255 Text("\(title): \(hex.uppercased())")
1256 .font(.caption)
1257 .foregroundStyle(.secondary)
1258 }
1259 }
1260}
1261
1262private struct TrackerLabelEditorSheet: View {
1263 let title: String
1264 let submitTitle: String
1265 let isSaving: Bool
1266 let error: String?
1267 let initialLabel: TicketLabel?
1268 let onSave: (String, String, String) async -> Bool
1269
1270 @Environment(\.dismiss) private var dismiss
1271 @State private var name: String
1272 @State private var foregroundColor: Color
1273 @State private var backgroundColor: Color
1274
1275 init(
1276 title: String,
1277 submitTitle: String,
1278 isSaving: Bool,
1279 error: String?,
1280 initialLabel: TicketLabel?,
1281 onSave: @escaping (String, String, String) async -> Bool
1282 ) {
1283 self.title = title
1284 self.submitTitle = submitTitle
1285 self.isSaving = isSaving
1286 self.error = error
1287 self.initialLabel = initialLabel
1288 self.onSave = onSave
1289 _name = State(initialValue: initialLabel?.name ?? "")
1290 _foregroundColor = State(initialValue: Color(hex: initialLabel?.foregroundColor ?? "#ffffff") ?? .white)
1291 _backgroundColor = State(initialValue: Color(hex: initialLabel?.backgroundColor ?? "#000000") ?? .black)
1292 }
1293
1294 var body: some View {
1295 NavigationStack {
1296 Form {
1297 Section("Details") {
1298 TextField("Label name", text: $name)
1299 .themedRow()
1300 ColorPicker("Foreground", selection: $foregroundColor, supportsOpacity: false)
1301 .themedRow()
1302 ColorPicker("Background", selection: $backgroundColor, supportsOpacity: false)
1303 .themedRow()
1304 }
1305
1306 Section("Preview") {
1307 LabelPill(
1308 label: TicketLabel(
1309 id: initialLabel?.id ?? -1,
1310 name: name.isEmpty ? "Preview" : name,
1311 backgroundColor: backgroundColor.hexString,
1312 foregroundColor: foregroundColor.hexString
1313 )
1314 )
1315 .themedRow()
1316 }
1317
1318 if let error, !error.isEmpty {
1319 Section {
1320 Text(error)
1321 .foregroundStyle(.red)
1322 .themedRow()
1323 }
1324 }
1325 }
1326 .themedList()
1327 .navigationTitle(title)
1328 .navigationBarTitleDisplayMode(.inline)
1329 .toolbar {
1330 ToolbarItem(placement: .cancellationAction) {
1331 Button("Cancel") { dismiss() }
1332 }
1333 ToolbarItem(placement: .confirmationAction) {
1334 Button {
1335 Task {
1336 let didSave = await onSave(
1337 name,
1338 foregroundColor.hexString,
1339 backgroundColor.hexString
1340 )
1341 if didSave {
1342 dismiss()
1343 }
1344 }
1345 } label: {
1346 if isSaving {
1347 ProgressView()
1348 .controlSize(.small)
1349 } else {
1350 Text(submitTitle)
1351 }
1352 }
1353 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
1354 }
1355 }
1356 }
1357 }
1358}