krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.0: Hutch/Views/Tickets/TicketListView.swift · raw
1import SwiftUI
2
3struct TicketListView: View {
4 let onTrackerUpdated: (TrackerSummary) -> Void
5 let onTrackerDeleted: (TrackerSummary) -> Void
6
7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
8 @Environment(AppState.self) private var appState
9 @Environment(\.isAMOLEDTheme) private var isAMOLED
10 @Environment(\.dismiss) private var dismiss
11 @Environment(\.openURL) private var openURL
12 @State private var tracker: TrackerSummary
13 @State private var viewModel: TicketListViewModel?
14 @State private var trackerManagementViewModel: TrackerManagementViewModel?
15 @State private var showCreateTicketSheet = false
16 @State private var createdTicket: TicketSummary?
17 @State private var labelEditorTicket: LabelEditorTicket?
18 @State private var showLabelFilterSheet = false
19 @State private var showSaveFilterSheet = false
20 @State private var showTrackerEditor = false
21 @State private var showTrackerACLs = false
22 @State private var showTrackerLabels = false
23 @State private var showDeleteTrackerConfirmation = false
24 @State private var showBulkCloseSheet = false
25 @State private var showBulkAssignSheet = false
26 @State private var bulkActionResult: TicketBulkActionResult?
27 @State private var pinChangeCount = 0
28
29 private var isOwnedByCurrentUser: Bool {
30 guard let currentUser = appState.currentUser else { return false }
31 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
32 }
33
34 private var currentUserKey: String? {
35 appState.currentUser?.canonicalName
36 }
37
38 private var isPinnedToHome: Bool {
39 _ = pinChangeCount
40 guard let currentUserKey else { return false }
41 return HomePinStore.isPinned(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
42 }
43
44 private var navigationTitle: String {
45 guard let viewModel, viewModel.isSelectionMode else { return tracker.name }
46 let count = viewModel.selectedTicketCount
47 return count == 0 ? "Select Tickets" : "\(count) Selected"
48 }
49
50 init(
51 tracker: TrackerSummary,
52 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ },
53 onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }
54 ) {
55 self._tracker = State(initialValue: tracker)
56 self.onTrackerUpdated = onTrackerUpdated
57 self.onTrackerDeleted = onTrackerDeleted
58 }
59
60 var body: some View {
61 Group {
62 if let viewModel {
63 listContent(viewModel)
64 } else {
65 SRHTLoadingStateView(message: "Loading tickets…")
66 }
67 }
68 .navigationTitle(navigationTitle)
69 .navigationBarTitleDisplayMode(.inline)
70 .toolbar {
71 ToolbarItem(placement: .topBarLeading) {
72 if let viewModel, viewModel.isSelectionMode {
73 Button("Cancel") {
74 viewModel.setSelectionMode(false)
75 }
76 }
77 }
78
79 ToolbarItemGroup(placement: .topBarTrailing) {
80 if viewModel != nil {
81 if let viewModel, viewModel.isSelectionMode {
82 Button("All") {
83 viewModel.selectVisibleTickets(viewModel.filteredTickets)
84 }
85 .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
86 } else {
87 Button {
88 showCreateTicketSheet = true
89 } label: {
90 Image(systemName: "plus")
91 }
92 .accessibilityLabel("Create ticket")
93
94 trackerActionsMenu
95 }
96 }
97 }
98 }
99 .sheet(isPresented: $showCreateTicketSheet) {
100 if let viewModel {
101 CreateTicketSheet(viewModel: viewModel) { ticket in
102 showCreateTicketSheet = false
103 createdTicket = ticket
104 }
105 }
106 }
107 .sheet(item: $labelEditorTicket) { item in
108 if let viewModel {
109 TicketLabelsSheet(
110 ticketId: item.id,
111 viewModel: viewModel
112 )
113 .presentationDetents([.medium])
114 }
115 }
116 .sheet(isPresented: $showLabelFilterSheet) {
117 if let viewModel {
118 TicketFilterLabelsSheet(viewModel: viewModel)
119 .presentationDetents([.medium, .large])
120 }
121 }
122 .sheet(isPresented: $showSaveFilterSheet) {
123 if let viewModel {
124 SaveTicketFilterSheet(viewModel: viewModel)
125 .presentationDetents([.height(220)])
126 }
127 }
128 .sheet(isPresented: $showTrackerEditor) {
129 if let trackerManagementViewModel {
130 TrackerEditorSheet(
131 title: "Update Tracker",
132 confirmationTitle: "Save",
133 isSaving: trackerManagementViewModel.isSavingTracker,
134 error: trackerManagementViewModel.error,
135 initialName: tracker.name,
136 initialDescription: tracker.description ?? "",
137 initialVisibility: tracker.visibility
138 ) { name, description, visibility in
139 if let updatedTracker = await trackerManagementViewModel.updateTracker(
140 name: name,
141 description: description,
142 visibility: visibility
143 ) {
144 tracker = updatedTracker
145 onTrackerUpdated(updatedTracker)
146 return true
147 }
148 return false
149 }
150 }
151 }
152 .sheet(isPresented: $showTrackerACLs) {
153 if let trackerManagementViewModel {
154 TrackerACLManagementSheet(viewModel: trackerManagementViewModel)
155 .presentationDetents([.large])
156 }
157 }
158 .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) {
159 if let trackerManagementViewModel {
160 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
161 .presentationDetents([.large])
162 }
163 }
164 .sheet(isPresented: $showBulkCloseSheet) {
165 if let viewModel {
166 BulkResolveSheet(
167 viewModel: viewModel,
168 isPresented: $showBulkCloseSheet,
169 onComplete: { result in
170 bulkActionResult = result
171 }
172 )
173 .presentationDetents([.medium])
174 }
175 }
176 .sheet(isPresented: $showBulkAssignSheet) {
177 if let viewModel {
178 BulkAssignSheet(
179 viewModel: viewModel,
180 currentUser: appState.currentUser,
181 isPresented: $showBulkAssignSheet,
182 onComplete: { result in
183 bulkActionResult = result
184 }
185 )
186 .presentationDetents([.medium])
187 }
188 }
189 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
190 Button("Cancel", role: .cancel) {
191 // no-op: .cancel role handles alert dismissal
192 }
193 Button("Delete", role: .destructive) {
194 guard let trackerManagementViewModel else { return }
195 Task {
196 let didDelete = await trackerManagementViewModel.deleteTracker()
197 if didDelete {
198 onTrackerDeleted(tracker)
199 dismiss()
200 }
201 }
202 }
203 } message: {
204 Text("“\(tracker.name)” will be permanently deleted.")
205 }
206 .alert(item: $bulkActionResult) { result in
207 Alert(
208 title: Text(result.title),
209 message: Text(result.message),
210 dismissButton: .default(Text("OK"))
211 )
212 }
213 .navigationDestination(isPresented: Binding(
214 get: { createdTicket != nil },
215 set: { isPresented in
216 if !isPresented {
217 createdTicket = nil
218 }
219 }
220 )) {
221 if let createdTicket {
222 TicketDetailView(
223 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
224 trackerName: tracker.name,
225 trackerId: tracker.id,
226 trackerRid: tracker.rid,
227 ticketId: createdTicket.id
228 )
229 }
230 }
231 .task {
232 if viewModel == nil {
233 let vm = TicketListViewModel(
234 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
235 trackerName: tracker.name,
236 trackerId: tracker.id,
237 trackerRid: tracker.rid,
238 client: appState.client,
239 defaults: appState.accountDefaults
240 )
241 viewModel = vm
242 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
243 await vm.loadTickets()
244 await vm.loadTrackerLabels()
245 await vm.loadSubscriptionState()
246 }
247 }
248 }
249
250 private func togglePinnedState() {
251 guard let currentUserKey else { return }
252 HomePinStore.togglePin(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
253 pinChangeCount += 1
254 }
255
256 @ViewBuilder
257 private func listContent(_ viewModel: TicketListViewModel) -> some View {
258 @Bindable var vm = viewModel
259
260 List {
261 Section {
262 TicketListFilterHeader(
263 selectedLabels: viewModel.selectedLabels,
264 savedFilters: viewModel.savedFilters,
265 activeSavedFilterID: viewModel.activeSavedFilterID,
266 canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
267 isSelectionMode: viewModel.isSelectionMode,
268 selectedTicketCount: viewModel.selectedTicketCount,
269 filter: $vm.filter
270 ) {
271 showLabelFilterSheet = true
272 } onSaveFilter: {
273 showSaveFilterSheet = true
274 } onResetFilters: {
275 vm.resetFilters()
276 } onApplySavedFilter: { savedFilter in
277 vm.applySavedFilter(savedFilter)
278 } onDeleteSavedFilter: { savedFilter in
279 vm.deleteSavedFilter(savedFilter)
280 }
281 .listRowInsets(EdgeInsets())
282 .listRowBackground(isAMOLED ? Color.black : Color.clear)
283 .listRowSeparator(.hidden)
284
285 ForEach(viewModel.filteredTickets) { ticket in
286 Group {
287 if viewModel.isSelectionMode {
288 Button {
289 viewModel.toggleTicketSelection(ticket)
290 } label: {
291 SelectableTicketRow(
292 ticket: ticket,
293 isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
294 )
295 .equatable()
296 }
297 .buttonStyle(.plain)
298 } else {
299 NavigationLink {
300 TicketDetailView(
301 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
302 trackerName: tracker.name,
303 trackerId: tracker.id,
304 trackerRid: tracker.rid,
305 ticketId: ticket.id
306 )
307 } label: {
308 TicketRowView(ticket: ticket)
309 .equatable()
310 }
311 .contextMenu {
312 if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) {
313 Button {
314 openURL(url)
315 } label: {
316 Label("Open in Browser", systemImage: "safari")
317 }
318
319 Button {
320 appState.copyToPasteboard(url.absoluteString, label: "ticket URL")
321 } label: {
322 Label("Copy URL", systemImage: "doc.on.doc")
323 }
324 }
325
326 Button {
327 appState.copyToPasteboard(String(ticket.id), label: "ticket ID")
328 } label: {
329 Label("Copy Ticket ID", systemImage: "number")
330 }
331 }
332 .swipeActions(edge: .leading, allowsFullSwipe: true) {
333 if swipeActionsEnabled {
334 ticketAssignSwipeAction(ticket, viewModel: viewModel)
335 }
336 }
337 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
338 if swipeActionsEnabled {
339 ticketStatusSwipeAction(ticket, viewModel: viewModel)
340 ticketLabelSwipeAction(ticket, viewModel: viewModel)
341 }
342 }
343 }
344 }
345 .task {
346 await viewModel.loadMoreIfNeeded(currentItem: ticket)
347 }
348 }
349 .themedRow()
350
351 if viewModel.isLoadingMore {
352 HStack {
353 Spacer()
354 ProgressView()
355 Spacer()
356 }
357 .listRowSeparator(.hidden)
358 .themedRow()
359 }
360 }
361 }
362 .themedList()
363 .listStyle(.plain)
364 .listSectionSpacing(.compact)
365 .searchable(
366 text: $vm.searchText,
367 placement: .navigationBarDrawer(displayMode: .always),
368 prompt: "Search tickets in \(tracker.name)"
369 )
370 .searchSuggestions {
371 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
372 RecentSearchSuggestions(
373 title: "Recent Ticket Searches",
374 entries: viewModel.recentSearches
375 ) { query in
376 vm.searchText = query
377 } onClear: {
378 viewModel.clearRecentSearches()
379 }
380 }
381 }
382 .onSubmit(of: .search) {
383 let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
384 guard !query.isEmpty else { return }
385 viewModel.recordRecentSearch(query)
386 }
387 .overlay {
388 if viewModel.isLoading, viewModel.tickets.isEmpty {
389 SRHTLoadingStateView(message: "Loading tickets…")
390 } else if let error = viewModel.error, viewModel.tickets.isEmpty {
391 SRHTErrorStateView(
392 title: "Couldn't Load Tickets",
393 message: error,
394 retryAction: { await viewModel.loadTickets() }
395 )
396 } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
397 viewModel.filteredTickets.isEmpty {
398 ContentUnavailableView(
399 "No Ticket Matches",
400 systemImage: "magnifyingglass",
401 description: Text("No tickets in \(tracker.name) matched “\(viewModel.searchText)”.")
402 )
403 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
404 ContentUnavailableView(
405 "No Tickets",
406 systemImage: "ticket",
407 description: Text(emptyStateDescription(for: viewModel))
408 )
409 }
410 }
411 .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
412 await viewModel.loadTickets()
413 }
414 .srhtErrorBanner(error: $vm.error)
415 .refreshable {
416 await viewModel.loadTickets()
417 }
418 .safeAreaInset(edge: .bottom) {
419 if viewModel.isSelectionMode {
420 TicketBulkActionBar(
421 selectedCount: viewModel.selectedTicketCount,
422 isPerformingAction: viewModel.isPerformingAction,
423 onClose: {
424 showBulkCloseSheet = true
425 },
426 onAssign: {
427 showBulkAssignSheet = true
428 }
429 )
430 }
431 }
432 }
433
434 private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
435 if !viewModel.selectedLabelIDs.isEmpty {
436 return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels."
437 }
438 return "No \(viewModel.filter.rawValue.lowercased()) tickets found."
439 }
440
441 private func syncTrackerLabelsIntoTicketList() {
442 guard let trackerManagementViewModel, let viewModel else { return }
443 viewModel.syncTrackerLabels(trackerManagementViewModel.labels)
444 }
445
446 private var trackerActionsMenu: some View {
447 Menu {
448 Button {
449 viewModel?.setSelectionMode(true)
450 } label: {
451 Label("Select", systemImage: "checkmark.circle")
452 }
453
454 if currentUserKey != nil {
455 Button {
456 togglePinnedState()
457 } label: {
458 Label(
459 isPinnedToHome ? "Unpin from Home" : "Pin to Home",
460 systemImage: isPinnedToHome ? "pin.slash" : "pin"
461 )
462 }
463 }
464
465 if let shareURL = SRHTWebURL.tracker(tracker) {
466 ShareLink(item: shareURL) {
467 Label("Share", systemImage: "square.and.arrow.up")
468 }
469 }
470
471 Divider()
472
473 if let viewModel {
474 Button {
475 Task { await viewModel.toggleSubscription() }
476 } label: {
477 Label(
478 viewModel.isSubscribed ? "Unsubscribe" : "Subscribe",
479 systemImage: viewModel.isSubscribed ? "bell.slash" : "bell"
480 )
481 }
482 .disabled(viewModel.isPerformingAction)
483 }
484
485 Divider()
486
487 if let trackerURL = SRHTWebURL.tracker(tracker) {
488 Button {
489 openURL(trackerURL)
490 } label: {
491 Label("Open in Browser", systemImage: "safari")
492 }
493
494 Button {
495 appState.copyToPasteboard(trackerURL.absoluteString, label: "tracker URL")
496 } label: {
497 Label("Copy URL", systemImage: "doc.on.doc")
498 }
499 }
500
501 Button {
502 appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
503 } label: {
504 Label("Copy Tracker ID", systemImage: "number")
505 }
506
507 Button {
508 appState.copyToPasteboard(tracker.rid, label: "tracker RID")
509 } label: {
510 Label("Copy RID", systemImage: "number")
511 }
512
513 if isOwnedByCurrentUser {
514 Divider()
515
516 Button {
517 showTrackerEditor = true
518 } label: {
519 Label("Update Tracker", systemImage: "pencil")
520 }
521
522 Button {
523 showTrackerACLs = true
524 } label: {
525 Label("Manage ACLs", systemImage: "person.2")
526 }
527
528 Button {
529 showTrackerLabels = true
530 } label: {
531 Label("Manage Labels", systemImage: "tag")
532 }
533
534 Button(role: .destructive) {
535 showDeleteTrackerConfirmation = true
536 } label: {
537 Label("Delete Tracker", systemImage: "trash")
538 }
539 }
540 } label: {
541 Image(systemName: "ellipsis.circle")
542 }
543 .accessibilityLabel("Tracker actions")
544 }
545
546 private func ownerUsername(for tracker: TrackerSummary) -> String {
547 tracker.owner.canonicalName.srhtUsername
548 }
549
550 @ViewBuilder
551 private func ticketAssignSwipeAction(
552 _ ticket: TicketSummary,
553 viewModel: TicketListViewModel
554 ) -> some View {
555 if let currentUser = appState.currentUser {
556 let isAssigned = ticket.assignees.contains { assignee in
557 matchesAssignee(assignee, user: currentUser)
558 }
559
560 if isAssigned {
561 Button {
562 Task {
563 await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
564 }
565 } label: {
566 Label("Unassign Me", systemImage: "person.badge.minus")
567 }
568 .tint(.orange)
569 } else {
570 Button {
571 Task {
572 await viewModel.assignToMe(ticket: ticket, user: currentUser)
573 }
574 } label: {
575 Label("Assign Me", systemImage: "person.badge.plus")
576 }
577 .tint(.cyan)
578 }
579 }
580 }
581
582 @ViewBuilder
583 private func ticketStatusSwipeAction(
584 _ ticket: TicketSummary,
585 viewModel: TicketListViewModel
586 ) -> some View {
587 if ticket.status.isOpen {
588 Button {
589 Task { await viewModel.resolveTicket(ticket) }
590 } label: {
591 Label("Close", systemImage: "xmark.circle")
592 }
593 .tint(.red)
594 } else {
595 Button {
596 Task { await viewModel.reopenTicket(ticket) }
597 } label: {
598 Label("Reopen", systemImage: "arrow.uturn.backward")
599 }
600 .tint(.blue)
601 }
602 }
603
604 @ViewBuilder
605 private func ticketLabelSwipeAction(
606 _ ticket: TicketSummary,
607 viewModel: TicketListViewModel
608 ) -> some View {
609 Button {
610 labelEditorTicket = LabelEditorTicket(id: ticket.id)
611 Task { await viewModel.loadTrackerLabels() }
612 } label: {
613 Label("Labels", systemImage: "tag")
614 }
615 .tint(.purple)
616 }
617
618 private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
619 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
620 let userCanonical = normalizedCanonicalName(user.canonicalName)
621 if assigneeCanonical == userCanonical {
622 return true
623 }
624 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
625 }
626
627 private func normalizedCanonicalName(_ value: String) -> String {
628 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
629 if trimmed.hasPrefix("~") {
630 return trimmed
631 }
632 return "~\(trimmed)"
633 }
634
635 private func normalizedUsername(_ value: String) -> String {
636 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
637 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
638 }
639}
640
641private struct LabelEditorTicket: Identifiable {
642 let id: Int
643}
644
645private struct CreateTicketSheet: View {
646 let viewModel: TicketListViewModel
647 let onCreated: (TicketSummary) -> Void
648
649 @Environment(\.dismiss) private var dismiss
650 @State private var subject = ""
651 @State private var descriptionText = ""
652
653 var body: some View {
654 NavigationStack {
655 Form {
656 Section("Ticket Details") {
657 TextField("Title", text: $subject)
658 .themedRow()
659 TextField("Description (optional)", text: $descriptionText, axis: .vertical)
660 .lineLimit(6...12)
661 .themedRow()
662 }
663 }
664 .themedList()
665 .navigationTitle("New Ticket")
666 .navigationBarTitleDisplayMode(.inline)
667 .toolbar {
668 ToolbarItem(placement: .cancellationAction) {
669 Button("Cancel") { dismiss() }
670 }
671 ToolbarItem(placement: .confirmationAction) {
672 Button {
673 Task {
674 if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
675 onCreated(ticket)
676 }
677 }
678 } label: {
679 if viewModel.isCreatingTicket {
680 ProgressView()
681 .controlSize(.small)
682 } else {
683 Text("Create Ticket")
684 }
685 }
686 .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
687 }
688 }
689 }
690 }
691}
692
693private struct TicketLabelsSheet: View {
694 let ticketId: Int
695 let viewModel: TicketListViewModel
696
697 @Environment(\.dismiss) private var dismiss
698
699 var body: some View {
700 NavigationStack {
701 Group {
702 if let ticket = viewModel.ticket(withId: ticketId) {
703 if viewModel.trackerLabels.isEmpty {
704 if viewModel.isPerformingAction {
705 ProgressView()
706 } else {
707 ContentUnavailableView(
708 "No Labels",
709 systemImage: "tag",
710 description: Text("This tracker has no labels defined.")
711 )
712 }
713 } else {
714 List {
715 ForEach(viewModel.trackerLabels) { label in
716 TicketListLabelToggleRow(
717 label: label,
718 isApplied: ticket.labels.contains(where: { $0.id == label.id }),
719 isLoading: viewModel.isPerformingAction
720 ) { shouldApply in
721 Task {
722 if shouldApply {
723 await viewModel.labelTicket(ticket, label: label)
724 } else {
725 await viewModel.unlabelTicket(ticket, label: label)
726 }
727 }
728 }
729 }
730 .themedRow()
731 }
732 .themedList()
733 }
734 } else {
735 ContentUnavailableView(
736 "Ticket Unavailable",
737 systemImage: "ticket",
738 description: Text("This ticket is no longer in the current list.")
739 )
740 }
741 }
742 .navigationTitle("Labels")
743 .navigationBarTitleDisplayMode(.inline)
744 .toolbar {
745 ToolbarItem(placement: .cancellationAction) {
746 Button("Done") { dismiss() }
747 }
748 }
749 .task {
750 if viewModel.trackerLabels.isEmpty {
751 await viewModel.loadTrackerLabels()
752 }
753 }
754 }
755 }
756}
757
758private struct TicketListFilterHeader: View {
759 let selectedLabels: [TicketLabel]
760 let savedFilters: [SavedTicketFilter]
761 let activeSavedFilterID: SavedTicketFilter.ID?
762 let canSaveCurrentFilter: Bool
763 let isSelectionMode: Bool
764 let selectedTicketCount: Int
765 @Binding var filter: TicketFilter
766 let onShowLabels: () -> Void
767 let onSaveFilter: () -> Void
768 let onResetFilters: () -> Void
769 let onApplySavedFilter: (SavedTicketFilter) -> Void
770 let onDeleteSavedFilter: (SavedTicketFilter) -> Void
771
772 var body: some View {
773 VStack(alignment: .leading, spacing: 10) {
774 if isSelectionMode {
775 Text(selectionSummary)
776 .font(.subheadline.weight(.medium))
777 .foregroundStyle(.secondary)
778 }
779
780 Picker("Filter", selection: $filter) {
781 ForEach(TicketFilter.allCases, id: \.self) { filter in
782 Text(filter.rawValue).tag(filter)
783 }
784 }
785 .pickerStyle(.segmented)
786 .disabled(isSelectionMode)
787
788 TicketQuickFilterBar(
789 selectedLabels: selectedLabels,
790 savedFilters: savedFilters,
791 activeSavedFilterID: activeSavedFilterID,
792 canSaveCurrentFilter: canSaveCurrentFilter,
793 onShowLabels: onShowLabels,
794 onSaveFilter: onSaveFilter,
795 onResetFilters: onResetFilters,
796 onApplySavedFilter: onApplySavedFilter,
797 onDeleteSavedFilter: onDeleteSavedFilter
798 )
799 .disabled(isSelectionMode)
800 }
801 .padding(.horizontal, 16)
802 .padding(.top, 6)
803 .padding(.bottom, 10)
804 }
805
806 private var selectionSummary: String {
807 selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected"
808 }
809}
810
811private struct TicketQuickFilterBar: View {
812 let selectedLabels: [TicketLabel]
813 let savedFilters: [SavedTicketFilter]
814 let activeSavedFilterID: SavedTicketFilter.ID?
815 let canSaveCurrentFilter: Bool
816 let onShowLabels: () -> Void
817 let onSaveFilter: () -> Void
818 let onResetFilters: () -> Void
819 let onApplySavedFilter: (SavedTicketFilter) -> Void
820 let onDeleteSavedFilter: (SavedTicketFilter) -> Void
821
822 var body: some View {
823 VStack(alignment: .leading, spacing: 8) {
824 actionButtons
825
826 if !selectedLabels.isEmpty {
827 ScrollView(.horizontal, showsIndicators: false) {
828 HStack(spacing: 8) {
829 ForEach(selectedLabels) { label in
830 LabelPill(label: label)
831 }
832 }
833 }
834 }
835
836 if !savedFilters.isEmpty {
837 ScrollView(.horizontal, showsIndicators: false) {
838 HStack(spacing: 8) {
839 ForEach(savedFilters) { savedFilter in
840 Button {
841 onApplySavedFilter(savedFilter)
842 } label: {
843 HStack(spacing: 6) {
844 Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle")
845 .imageScale(.small)
846 Text(savedFilter.name)
847 .lineLimit(1)
848 }
849 .font(.caption.weight(.medium))
850 .padding(.horizontal, 10)
851 .padding(.vertical, 7)
852 .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary)
853 .background(
854 activeSavedFilterID == savedFilter.id ?
855 Color.accentColor.opacity(0.14) :
856 Color(.secondarySystemFill),
857 in: Capsule()
858 )
859 }
860 .contextMenu {
861 Button(role: .destructive) {
862 onDeleteSavedFilter(savedFilter)
863 } label: {
864 Label("Delete Filter", systemImage: "trash")
865 }
866 }
867 }
868 }
869 }
870 }
871 }
872 }
873
874 private var labelButtonTitle: String {
875 selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
876 }
877
878 @ViewBuilder
879 private var actionButtons: some View {
880 ViewThatFits(in: .horizontal) {
881 HStack(spacing: 8) {
882 labelsButton
883 saveFilterButton
884 if canSaveCurrentFilter {
885 resetButton
886 }
887 Spacer(minLength: 0)
888 }
889
890 FlowLayout(spacing: 8) {
891 labelsButton
892 saveFilterButton
893 if canSaveCurrentFilter {
894 resetButton
895 }
896 }
897 }
898 }
899
900 private var labelsButton: some View {
901 Button(action: onShowLabels) {
902 Label(labelButtonTitle, systemImage: "tag")
903 .font(.caption.weight(.medium))
904 }
905 .buttonStyle(.bordered)
906 }
907
908 private var saveFilterButton: some View {
909 Button(action: onSaveFilter) {
910 Label("Save Filter", systemImage: "square.and.arrow.down")
911 .font(.caption.weight(.medium))
912 }
913 .buttonStyle(.bordered)
914 .disabled(!canSaveCurrentFilter)
915 }
916
917 private var resetButton: some View {
918 Button("Reset", action: onResetFilters)
919 .font(.caption.weight(.medium))
920 .buttonStyle(.bordered)
921 }
922}
923
924private struct TicketFilterLabelsSheet: View {
925 let viewModel: TicketListViewModel
926
927 @Environment(\.dismiss) private var dismiss
928
929 var body: some View {
930 NavigationStack {
931 Group {
932 if viewModel.availableLabels.isEmpty {
933 ContentUnavailableView(
934 "No Labels",
935 systemImage: "tag",
936 description: Text("This tracker has no labels available for filtering yet.")
937 )
938 } else {
939 List {
940 if !viewModel.selectedLabels.isEmpty {
941 Section("Selected") {
942 FlowLayout(spacing: 6) {
943 ForEach(viewModel.selectedLabels) { label in
944 LabelPill(label: label)
945 }
946 }
947 .padding(.vertical, 4)
948 .themedRow()
949 }
950 }
951
952 Section("Labels") {
953 ForEach(viewModel.availableLabels) { label in
954 Button {
955 viewModel.toggleLabelSelection(label)
956 } label: {
957 HStack {
958 LabelPill(label: label)
959 Spacer()
960 if viewModel.selectedLabelIDs.contains(label.id) {
961 Image(systemName: "checkmark")
962 .foregroundStyle(.blue)
963 }
964 }
965 }
966 .buttonStyle(.plain)
967 }
968 .themedRow()
969 }
970 }
971 .themedList()
972 }
973 }
974 .navigationTitle("Filter Labels")
975 .navigationBarTitleDisplayMode(.inline)
976 .toolbar {
977 ToolbarItem(placement: .cancellationAction) {
978 Button("Done") { dismiss() }
979 }
980 if !viewModel.selectedLabelIDs.isEmpty {
981 ToolbarItem(placement: .confirmationAction) {
982 Button("Clear") { viewModel.clearLabelSelection() }
983 }
984 }
985 }
986 .task {
987 if viewModel.availableLabels.isEmpty {
988 await viewModel.loadTrackerLabels()
989 }
990 }
991 }
992 }
993}
994
995private struct SaveTicketFilterSheet: View {
996 let viewModel: TicketListViewModel
997
998 @Environment(\.dismiss) private var dismiss
999 @State private var name: String
1000
1001 init(viewModel: TicketListViewModel) {
1002 self.viewModel = viewModel
1003 self._name = State(initialValue: viewModel.suggestedSavedFilterName)
1004 }
1005
1006 var body: some View {
1007 NavigationStack {
1008 Form {
1009 Section("Name") {
1010 TextField("Filter name", text: $name)
1011 .textInputAutocapitalization(.words)
1012 .themedRow()
1013 }
1014 }
1015 .themedList()
1016 .navigationTitle("Save Filter")
1017 .navigationBarTitleDisplayMode(.inline)
1018 .toolbar {
1019 ToolbarItem(placement: .cancellationAction) {
1020 Button("Cancel") { dismiss() }
1021 }
1022 ToolbarItem(placement: .confirmationAction) {
1023 Button("Save") {
1024 viewModel.saveCurrentFilter(named: name)
1025 dismiss()
1026 }
1027 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
1028 }
1029 }
1030 }
1031 }
1032}
1033
1034private struct TicketListLabelToggleRow: View {
1035 let label: TicketLabel
1036 let isApplied: Bool
1037 let isLoading: Bool
1038 let onToggle: (Bool) -> Void
1039
1040 var body: some View {
1041 Button {
1042 onToggle(!isApplied)
1043 } label: {
1044 HStack {
1045 LabelPill(label: label)
1046 Spacer()
1047 if isApplied {
1048 Image(systemName: "checkmark")
1049 .foregroundStyle(.blue)
1050 }
1051 }
1052 }
1053 .disabled(isLoading)
1054 }
1055}
1056
1057private struct TicketBulkActionBar: View {
1058 let selectedCount: Int
1059 let isPerformingAction: Bool
1060 let onClose: () -> Void
1061 let onAssign: () -> Void
1062
1063 var body: some View {
1064 VStack(spacing: 12) {
1065 HStack {
1066 Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected")
1067 .font(.subheadline.weight(.medium))
1068 .foregroundStyle(.secondary)
1069 Spacer()
1070 }
1071
1072 HStack(spacing: 12) {
1073 Button("Close") {
1074 onClose()
1075 }
1076 .buttonStyle(.borderedProminent)
1077 .tint(.red)
1078 .disabled(selectedCount == 0 || isPerformingAction)
1079
1080 Button("Assign") {
1081 onAssign()
1082 }
1083 .buttonStyle(.bordered)
1084 .disabled(selectedCount == 0 || isPerformingAction)
1085
1086 if isPerformingAction {
1087 ProgressView()
1088 .controlSize(.small)
1089 }
1090 }
1091 }
1092 .padding(.horizontal, 16)
1093 .padding(.top, 12)
1094 .padding(.bottom, 16)
1095 .background(.ultraThinMaterial)
1096 }
1097}
1098
1099private struct BulkResolveSheet: View {
1100 let viewModel: TicketListViewModel
1101 @Binding var isPresented: Bool
1102 let onComplete: (TicketBulkActionResult) -> Void
1103 @State private var selectedResolution: TicketResolution = .fixed
1104
1105 private static let resolutionOptions: [TicketResolution] = [
1106 .closed, .fixed, .implemented, .wontFix,
1107 .byDesign, .invalid, .duplicate, .notOurBug
1108 ]
1109
1110 var body: some View {
1111 NavigationStack {
1112 Form {
1113 Section {
1114 Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1115 .font(.subheadline)
1116 .foregroundStyle(.secondary)
1117 .themedRow()
1118 }
1119
1120 Section("Resolution") {
1121 Picker("Resolution", selection: $selectedResolution) {
1122 ForEach(Self.resolutionOptions, id: \.self) { resolution in
1123 Text(resolution.displayName).tag(resolution)
1124 }
1125 }
1126 .pickerStyle(.inline)
1127 .labelsHidden()
1128 .themedRow()
1129 }
1130 }
1131 .themedList()
1132 .navigationTitle("Close Tickets")
1133 .navigationBarTitleDisplayMode(.inline)
1134 .toolbar {
1135 ToolbarItem(placement: .cancellationAction) {
1136 Button("Cancel") { isPresented = false }
1137 }
1138 ToolbarItem(placement: .confirmationAction) {
1139 Button("Close") {
1140 Task {
1141 if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) {
1142 onComplete(result)
1143 isPresented = false
1144 }
1145 }
1146 }
1147 .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1148 }
1149 }
1150 }
1151 }
1152}
1153
1154private struct BulkAssignSheet: View {
1155 let viewModel: TicketListViewModel
1156 let currentUser: User?
1157 @Binding var isPresented: Bool
1158 let onComplete: (TicketBulkActionResult) -> Void
1159 @State private var username = ""
1160
1161 var body: some View {
1162 NavigationStack {
1163 Form {
1164 Section {
1165 Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected")
1166 .font(.subheadline)
1167 .foregroundStyle(.secondary)
1168 .themedRow()
1169 }
1170
1171 if let currentUser {
1172 Section {
1173 Button("Assign to Me") {
1174 Task {
1175 if let result = await viewModel.assignSelectedTickets(username: currentUser.username) {
1176 onComplete(result)
1177 isPresented = false
1178 }
1179 }
1180 }
1181 .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction)
1182 .themedRow()
1183 }
1184 }
1185
1186 Section("Assign User") {
1187 TextField("Username or ~username", text: $username)
1188 .textContentType(.username)
1189 .autocorrectionDisabled()
1190 .textInputAutocapitalization(.never)
1191 .themedRow()
1192 }
1193 }
1194 .themedList()
1195 .navigationTitle("Assign Tickets")
1196 .navigationBarTitleDisplayMode(.inline)
1197 .toolbar {
1198 ToolbarItem(placement: .cancellationAction) {
1199 Button("Cancel") { isPresented = false }
1200 }
1201 ToolbarItem(placement: .confirmationAction) {
1202 Button("Assign") {
1203 Task {
1204 if let result = await viewModel.assignSelectedTickets(username: username) {
1205 onComplete(result)
1206 isPresented = false
1207 }
1208 }
1209 }
1210 .disabled(
1211 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1212 || viewModel.selectedTicketCount == 0
1213 || viewModel.isPerformingAction
1214 )
1215 }
1216 }
1217 }
1218 }
1219}
1220
1221private struct SelectableTicketRow: View, Equatable {
1222 let ticket: TicketSummary
1223 let isSelected: Bool
1224
1225 var body: some View {
1226 HStack(alignment: .top, spacing: 12) {
1227 Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
1228 .font(.title3)
1229 .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
1230 .padding(.top, 4)
1231
1232 TicketRowView(ticket: ticket)
1233 }
1234 .padding(.vertical, 2)
1235 .contentShape(Rectangle())
1236 }
1237}
1238
1239// MARK: - Ticket Row
1240
1241private struct TicketRowView: View, Equatable {
1242 let ticket: TicketSummary
1243
1244 var body: some View {
1245 VStack(alignment: .leading, spacing: 6) {
1246 HStack(alignment: .top) {
1247 TicketStatusIcon(status: ticket.status)
1248 .frame(width: 20)
1249
1250 VStack(alignment: .leading, spacing: 2) {
1251 Text("#\(ticket.id)")
1252 .font(.caption.monospaced())
1253 .foregroundStyle(.secondary)
1254 + Text(" ")
1255 + Text(ticket.title)
1256 .font(.subheadline)
1257 }
1258
1259 Spacer()
1260
1261 TicketStatusBadge(status: ticket.status)
1262 }
1263
1264 HStack(spacing: 8) {
1265 Text(ticket.submitter.canonicalName)
1266 .font(.caption)
1267 .foregroundStyle(.secondary)
1268
1269 Spacer()
1270
1271 Text(ticket.created.relativeDescription)
1272 .font(.caption)
1273 .foregroundStyle(.tertiary)
1274 }
1275
1276 if !ticket.labels.isEmpty {
1277 FlowLayout(spacing: 4) {
1278 ForEach(ticket.labels) { label in
1279 LabelPill(label: label)
1280 }
1281 }
1282 }
1283
1284 if !ticket.assignees.isEmpty {
1285 HStack(spacing: 4) {
1286 Image(systemName: "person.fill")
1287 .font(.caption2)
1288 .foregroundStyle(.secondary)
1289 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
1290 .font(.caption)
1291 .foregroundStyle(.secondary)
1292 .lineLimit(1)
1293 }
1294 }
1295 }
1296 .padding(.vertical, 2)
1297 }
1298}
1299
1300// MARK: - Ticket Status Badge
1301
1302private struct TicketStatusBadge: View {
1303 let status: TicketStatus
1304
1305 var body: some View {
1306 Text(status.displayName)
1307 .font(.caption2.weight(.semibold))
1308 .padding(.horizontal, 6)
1309 .padding(.vertical, 2)
1310 .foregroundStyle(color)
1311 .background(color.opacity(0.12))
1312 .clipShape(Capsule())
1313 }
1314
1315 private var color: Color {
1316 switch status {
1317 case .reported: .gray
1318 case .confirmed: .blue
1319 case .inProgress: .yellow
1320 case .pending: .orange
1321 case .resolved: .green
1322 }
1323 }
1324}
1325
1326// MARK: - Ticket Status Icon
1327
1328struct TicketStatusIcon: View {
1329 let status: TicketStatus
1330
1331 var body: some View {
1332 Image(systemName: iconName)
1333 .foregroundStyle(color)
1334 }
1335
1336 private var iconName: String {
1337 switch status {
1338 case .reported: "circle"
1339 case .confirmed: "circle.inset.filled"
1340 case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
1341 case .pending: "clock.fill"
1342 case .resolved: "checkmark.circle.fill"
1343 }
1344 }
1345
1346 private var color: Color {
1347 switch status {
1348 case .reported: .gray
1349 case .confirmed: .blue
1350 case .inProgress: .yellow
1351 case .pending: .orange
1352 case .resolved: .green
1353 }
1354 }
1355}
1356
1357// MARK: - Label Pill
1358
1359struct LabelPill: View {
1360 let label: TicketLabel
1361
1362 var body: some View {
1363 Text(label.name)
1364 .font(.caption.weight(.medium))
1365 .padding(.horizontal, 8)
1366 .padding(.vertical, 3)
1367 .background(backgroundColor)
1368 .foregroundStyle(foregroundColor)
1369 .clipShape(Capsule())
1370 }
1371
1372 private var backgroundColor: Color {
1373 Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
1374 }
1375
1376 private var foregroundColor: Color {
1377 Color(hex: label.foregroundColor) ?? .primary
1378 }
1379}
1380
1381// MARK: - Color from hex string
1382
1383extension Color {
1384 init?(hex: String) {
1385 var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
1386 if hexString.hasPrefix("#") {
1387 hexString.removeFirst()
1388 }
1389
1390 guard hexString.count == 6,
1391 let hexNumber = UInt64(hexString, radix: 16) else {
1392 return nil
1393 }
1394
1395 let r = Double((hexNumber & 0xFF0000) >> 16) / 255
1396 let g = Double((hexNumber & 0x00FF00) >> 8) / 255
1397 let b = Double(hexNumber & 0x0000FF) / 255
1398
1399 self.init(red: r, green: g, blue: b)
1400 }
1401
1402 /// Returns a `#rrggbb` hex string for this color.
1403 var hexString: String {
1404 let resolved = resolve(in: .init())
1405 let r = Int(max(0, min(1, resolved.red)) * 255)
1406 let g = Int(max(0, min(1, resolved.green)) * 255)
1407 let b = Int(max(0, min(1, resolved.blue)) * 255)
1408 return String(format: "#%02x%02x%02x", r, g, b)
1409 }
1410}
1411
1412// MARK: - Flow Layout (for label pills)
1413
1414struct FlowLayout: Layout {
1415 var spacing: CGFloat = 4
1416
1417 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
1418 _ = cache
1419 let result = layoutSubviews(proposal: proposal, subviews: subviews)
1420 return result.size
1421 }
1422
1423 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
1424 _ = cache
1425 let result = layoutSubviews(proposal: proposal, subviews: subviews)
1426 for (index, position) in result.positions.enumerated() {
1427 subviews[index].place(
1428 at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
1429 proposal: ProposedViewSize(result.sizes[index])
1430 )
1431 }
1432 }
1433
1434 private struct LayoutResult {
1435 var positions: [CGPoint]
1436 var sizes: [CGSize]
1437 var size: CGSize
1438 }
1439
1440 private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
1441 let maxWidth = proposal.width ?? .infinity
1442 var positions: [CGPoint] = []
1443 var sizes: [CGSize] = []
1444 var currentX: CGFloat = 0
1445 var currentY: CGFloat = 0
1446 var lineHeight: CGFloat = 0
1447 var totalHeight: CGFloat = 0
1448 var totalWidth: CGFloat = 0
1449
1450 for subview in subviews {
1451 let size = subview.sizeThatFits(.unspecified)
1452 sizes.append(size)
1453
1454 if currentX + size.width > maxWidth, currentX > 0 {
1455 currentX = 0
1456 currentY += lineHeight + spacing
1457 lineHeight = 0
1458 }
1459
1460 positions.append(CGPoint(x: currentX, y: currentY))
1461 lineHeight = max(lineHeight, size.height)
1462 currentX += size.width + spacing
1463 totalWidth = max(totalWidth, currentX - spacing)
1464 totalHeight = currentY + lineHeight
1465 }
1466
1467 return LayoutResult(
1468 positions: positions,
1469 sizes: sizes,
1470 size: CGSize(width: totalWidth, height: totalHeight)
1471 )
1472 }
1473}