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