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