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