summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets/TicketListView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets/TicketListView.swift')
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift389
1 files changed, 389 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
new file mode 100644
index 0000000..7e16276
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -0,0 +1,389 @@
+import SwiftUI
+
+struct TicketListView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TicketListViewModel?
+ @State private var showCreateTicketSheet = false
+ @State private var createdTicket: TicketSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ }
+ }
+ .navigationTitle(trackerName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Button {
+ showCreateTicketSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTicketSheet) {
+ if let viewModel {
+ CreateTicketSheet(viewModel: viewModel) { ticket in
+ showCreateTicketSheet = false
+ createdTicket = ticket
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTicket != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTicket = nil
+ }
+ }
+ )) {
+ if let createdTicket {
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketListViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTickets()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TicketListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ // Filter picker
+ Section {
+ Picker("Filter", selection: $vm.filter) {
+ ForEach(TicketFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
+ // Tickets
+ ForEach(viewModel.filteredTickets) { ticket in
+ NavigationLink(value: ticket) {
+ TicketRowView(ticket: ticket)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: ticket)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.tickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ } else if let error = viewModel.error, viewModel.tickets.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Tickets",
+ message: error,
+ retryAction: { await viewModel.loadTickets() }
+ )
+ } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Tickets",
+ systemImage: "ticket",
+ description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
+ await viewModel.loadTickets()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTickets()
+ }
+ .navigationDestination(for: TicketSummary.self) { ticket in
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
+ }
+ }
+}
+
+private struct CreateTicketSheet: View {
+ let viewModel: TicketListViewModel
+ let onCreated: (TicketSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var subject = ""
+ @State private var descriptionText = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Ticket Details") {
+ TextField("Title", text: $subject)
+ TextField("Description (optional)", text: $descriptionText, axis: .vertical)
+ .lineLimit(6...12)
+ }
+ }
+ .navigationTitle("New Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
+ onCreated(ticket)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTicket {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Ticket")
+ }
+ }
+ .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Ticket Row
+
+private struct TicketRowView: View {
+ let ticket: TicketSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top) {
+ TicketStatusIcon(status: ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("#\(ticket.id)")
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ + Text(" ")
+ + Text(ticket.title)
+ .font(.subheadline)
+ }
+
+ Spacer()
+ }
+
+ HStack(spacing: 8) {
+ Text(ticket.submitter.canonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(ticket.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+
+ if !ticket.assignees.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "person.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Ticket Status Icon
+
+struct TicketStatusIcon: View {
+ let status: TicketStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .reported: "circle"
+ case .confirmed: "circle.inset.filled"
+ case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "clock.fill"
+ case .resolved: "checkmark.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .reported: .gray
+ case .confirmed: .blue
+ case .inProgress: .yellow
+ case .pending: .orange
+ case .resolved: .green
+ }
+ }
+}
+
+// MARK: - Label Pill
+
+struct LabelPill: View {
+ let label: TicketLabel
+
+ var body: some View {
+ Text(label.name)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ }
+
+ private var backgroundColor: Color {
+ Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
+ }
+
+ private var foregroundColor: Color {
+ Color(hex: label.foregroundColor) ?? .primary
+ }
+}
+
+// MARK: - Color from hex string
+
+extension Color {
+ init?(hex: String) {
+ var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if hexString.hasPrefix("#") {
+ hexString.removeFirst()
+ }
+
+ guard hexString.count == 6,
+ let hexNumber = UInt64(hexString, radix: 16) else {
+ return nil
+ }
+
+ let r = Double((hexNumber & 0xFF0000) >> 16) / 255
+ let g = Double((hexNumber & 0x00FF00) >> 8) / 255
+ let b = Double(hexNumber & 0x0000FF) / 255
+
+ self.init(red: r, green: g, blue: b)
+ }
+
+ /// Returns a `#rrggbb` hex string for this color.
+ var hexString: String {
+ let resolved = resolve(in: .init())
+ let r = Int(max(0, min(1, resolved.red)) * 255)
+ let g = Int(max(0, min(1, resolved.green)) * 255)
+ let b = Int(max(0, min(1, resolved.blue)) * 255)
+ return String(format: "#%02x%02x%02x", r, g, b)
+ }
+}
+
+// MARK: - Flow Layout (for label pills)
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 4
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(
+ at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
+ proposal: ProposedViewSize(result.sizes[index])
+ )
+ }
+ }
+
+ private struct LayoutResult {
+ var positions: [CGPoint]
+ var sizes: [CGSize]
+ var size: CGSize
+ }
+
+ private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var sizes: [CGSize] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var totalHeight: CGFloat = 0
+ var totalWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ sizes.append(size)
+
+ if currentX + size.width > maxWidth, currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ totalWidth = max(totalWidth, currentX - spacing)
+ totalHeight = currentY + lineHeight
+ }
+
+ return LayoutResult(
+ positions: positions,
+ sizes: sizes,
+ size: CGSize(width: totalWidth, height: totalHeight)
+ )
+ }
+}