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