diff options
Diffstat (limited to 'Hutch/Views/Tickets/TicketListView.swift')
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 389 |
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) + ) + } +} |
