krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.1: Hutch/Views/Tickets/TicketListView.swift · raw

  1import SwiftUI
  2
  3struct TicketListView: View {
  4    let ownerUsername: String
  5    let trackerName: String
  6    let trackerId: Int
  7    let trackerRid: String
  8
  9    @Environment(AppState.self) private var appState
 10    @State private var viewModel: TicketListViewModel?
 11    @State private var showCreateTicketSheet = false
 12    @State private var createdTicket: TicketSummary?
 13
 14    var body: some View {
 15        Group {
 16            if let viewModel {
 17                listContent(viewModel)
 18            } else {
 19                SRHTLoadingStateView(message: "Loading tickets…")
 20            }
 21        }
 22        .navigationTitle(trackerName)
 23        .navigationBarTitleDisplayMode(.inline)
 24        .toolbar {
 25            ToolbarItemGroup(placement: .topBarTrailing) {
 26                SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
 27                    Image(systemName: "square.and.arrow.up")
 28                }
 29
 30                if viewModel != nil {
 31                    Button {
 32                        showCreateTicketSheet = true
 33                    } label: {
 34                        Image(systemName: "plus")
 35                    }
 36                    .accessibilityLabel("Create ticket")
 37                }
 38            }
 39        }
 40        .sheet(isPresented: $showCreateTicketSheet) {
 41            if let viewModel {
 42                CreateTicketSheet(viewModel: viewModel) { ticket in
 43                    showCreateTicketSheet = false
 44                    createdTicket = ticket
 45                }
 46            }
 47        }
 48        .navigationDestination(isPresented: Binding(
 49            get: { createdTicket != nil },
 50            set: { isPresented in
 51                if !isPresented {
 52                    createdTicket = nil
 53                }
 54            }
 55        )) {
 56            if let createdTicket {
 57                TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
 58            }
 59        }
 60        .task {
 61            if viewModel == nil {
 62                let vm = TicketListViewModel(
 63                    ownerUsername: ownerUsername,
 64                    trackerName: trackerName,
 65                    trackerId: trackerId,
 66                    client: appState.client
 67                )
 68                viewModel = vm
 69                await vm.loadTickets()
 70            }
 71        }
 72    }
 73
 74    @ViewBuilder
 75    private func listContent(_ viewModel: TicketListViewModel) -> some View {
 76        @Bindable var vm = viewModel
 77
 78        List {
 79            // Filter picker
 80            Section {
 81                Picker("Filter", selection: $vm.filter) {
 82                    ForEach(TicketFilter.allCases, id: \.self) { filter in
 83                        Text(filter.rawValue).tag(filter)
 84                    }
 85                }
 86                .pickerStyle(.segmented)
 87                .listRowBackground(Color.clear)
 88                .listRowInsets(EdgeInsets())
 89            }
 90
 91            // Tickets
 92            ForEach(viewModel.filteredTickets) { ticket in
 93                NavigationLink(value: ticket) {
 94                    TicketRowView(ticket: ticket)
 95                }
 96                .task {
 97                    await viewModel.loadMoreIfNeeded(currentItem: ticket)
 98                }
 99            }
100
101            if viewModel.isLoadingMore {
102                HStack {
103                    Spacer()
104                    ProgressView()
105                    Spacer()
106                }
107                .listRowSeparator(.hidden)
108            }
109        }
110        .listStyle(.plain)
111        .overlay {
112            if viewModel.isLoading, viewModel.tickets.isEmpty {
113                SRHTLoadingStateView(message: "Loading tickets…")
114            } else if let error = viewModel.error, viewModel.tickets.isEmpty {
115                SRHTErrorStateView(
116                    title: "Couldn't Load Tickets",
117                    message: error,
118                    retryAction: { await viewModel.loadTickets() }
119                )
120            } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
121                ContentUnavailableView(
122                    "No Tickets",
123                    systemImage: "ticket",
124                    description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
125                )
126            }
127        }
128        .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
129            await viewModel.loadTickets()
130        }
131        .srhtErrorBanner(error: $vm.error)
132        .refreshable {
133            await viewModel.loadTickets()
134        }
135        .navigationDestination(for: TicketSummary.self) { ticket in
136            TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
137        }
138    }
139}
140
141private struct CreateTicketSheet: View {
142    let viewModel: TicketListViewModel
143    let onCreated: (TicketSummary) -> Void
144
145    @Environment(\.dismiss) private var dismiss
146    @State private var subject = ""
147    @State private var descriptionText = ""
148
149    var body: some View {
150        NavigationStack {
151            Form {
152                Section("Ticket Details") {
153                    TextField("Title", text: $subject)
154                    TextField("Description (optional)", text: $descriptionText, axis: .vertical)
155                        .lineLimit(6...12)
156                }
157            }
158            .navigationTitle("New Ticket")
159            .navigationBarTitleDisplayMode(.inline)
160            .toolbar {
161                ToolbarItem(placement: .cancellationAction) {
162                    Button("Cancel") { dismiss() }
163                }
164                ToolbarItem(placement: .confirmationAction) {
165                    Button {
166                        Task {
167                            if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
168                                onCreated(ticket)
169                            }
170                        }
171                    } label: {
172                        if viewModel.isCreatingTicket {
173                            ProgressView()
174                                .controlSize(.small)
175                        } else {
176                            Text("Create Ticket")
177                        }
178                    }
179                    .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
180                }
181            }
182        }
183    }
184}
185
186// MARK: - Ticket Row
187
188private struct TicketRowView: View {
189    let ticket: TicketSummary
190
191    var body: some View {
192        VStack(alignment: .leading, spacing: 6) {
193            HStack(alignment: .top) {
194                TicketStatusIcon(status: ticket.status)
195                    .frame(width: 20)
196
197                VStack(alignment: .leading, spacing: 2) {
198                    Text("#\(ticket.id)")
199                        .font(.caption.monospaced())
200                        .foregroundStyle(.secondary)
201                    + Text(" ")
202                    + Text(ticket.title)
203                        .font(.subheadline)
204                }
205
206                Spacer()
207            }
208
209            HStack(spacing: 8) {
210                Text(ticket.submitter.canonicalName)
211                    .font(.caption)
212                    .foregroundStyle(.secondary)
213
214                Spacer()
215
216                Text(ticket.created.relativeDescription)
217                    .font(.caption)
218                    .foregroundStyle(.tertiary)
219            }
220
221            if !ticket.labels.isEmpty {
222                FlowLayout(spacing: 4) {
223                    ForEach(ticket.labels) { label in
224                        LabelPill(label: label)
225                    }
226                }
227            }
228
229            if !ticket.assignees.isEmpty {
230                HStack(spacing: 4) {
231                    Image(systemName: "person.fill")
232                        .font(.caption2)
233                        .foregroundStyle(.secondary)
234                    Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
235                        .font(.caption)
236                        .foregroundStyle(.secondary)
237                        .lineLimit(1)
238                }
239            }
240        }
241        .padding(.vertical, 2)
242    }
243}
244
245// MARK: - Ticket Status Icon
246
247struct TicketStatusIcon: View {
248    let status: TicketStatus
249
250    var body: some View {
251        Image(systemName: iconName)
252            .foregroundStyle(color)
253    }
254
255    private var iconName: String {
256        switch status {
257        case .reported:   "circle"
258        case .confirmed:  "circle.inset.filled"
259        case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
260        case .pending:    "clock.fill"
261        case .resolved:   "checkmark.circle.fill"
262        }
263    }
264
265    private var color: Color {
266        switch status {
267        case .reported:   .gray
268        case .confirmed:  .blue
269        case .inProgress: .yellow
270        case .pending:    .orange
271        case .resolved:   .green
272        }
273    }
274}
275
276// MARK: - Label Pill
277
278struct LabelPill: View {
279    let label: TicketLabel
280
281    var body: some View {
282        Text(label.name)
283            .font(.caption2.weight(.medium))
284            .padding(.horizontal, 6)
285            .padding(.vertical, 2)
286            .background(backgroundColor)
287            .foregroundStyle(foregroundColor)
288            .clipShape(Capsule())
289    }
290
291    private var backgroundColor: Color {
292        Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
293    }
294
295    private var foregroundColor: Color {
296        Color(hex: label.foregroundColor) ?? .primary
297    }
298}
299
300// MARK: - Color from hex string
301
302extension Color {
303    init?(hex: String) {
304        var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
305        if hexString.hasPrefix("#") {
306            hexString.removeFirst()
307        }
308
309        guard hexString.count == 6,
310              let hexNumber = UInt64(hexString, radix: 16) else {
311            return nil
312        }
313
314        let r = Double((hexNumber & 0xFF0000) >> 16) / 255
315        let g = Double((hexNumber & 0x00FF00) >> 8) / 255
316        let b = Double(hexNumber & 0x0000FF) / 255
317
318        self.init(red: r, green: g, blue: b)
319    }
320
321    /// Returns a `#rrggbb` hex string for this color.
322    var hexString: String {
323        let resolved = resolve(in: .init())
324        let r = Int(max(0, min(1, resolved.red)) * 255)
325        let g = Int(max(0, min(1, resolved.green)) * 255)
326        let b = Int(max(0, min(1, resolved.blue)) * 255)
327        return String(format: "#%02x%02x%02x", r, g, b)
328    }
329}
330
331// MARK: - Flow Layout (for label pills)
332
333struct FlowLayout: Layout {
334    var spacing: CGFloat = 4
335
336    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
337        let result = layoutSubviews(proposal: proposal, subviews: subviews)
338        return result.size
339    }
340
341    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
342        let result = layoutSubviews(proposal: proposal, subviews: subviews)
343        for (index, position) in result.positions.enumerated() {
344            subviews[index].place(
345                at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
346                proposal: ProposedViewSize(result.sizes[index])
347            )
348        }
349    }
350
351    private struct LayoutResult {
352        var positions: [CGPoint]
353        var sizes: [CGSize]
354        var size: CGSize
355    }
356
357    private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
358        let maxWidth = proposal.width ?? .infinity
359        var positions: [CGPoint] = []
360        var sizes: [CGSize] = []
361        var currentX: CGFloat = 0
362        var currentY: CGFloat = 0
363        var lineHeight: CGFloat = 0
364        var totalHeight: CGFloat = 0
365        var totalWidth: CGFloat = 0
366
367        for subview in subviews {
368            let size = subview.sizeThatFits(.unspecified)
369            sizes.append(size)
370
371            if currentX + size.width > maxWidth, currentX > 0 {
372                currentX = 0
373                currentY += lineHeight + spacing
374                lineHeight = 0
375            }
376
377            positions.append(CGPoint(x: currentX, y: currentY))
378            lineHeight = max(lineHeight, size.height)
379            currentX += size.width + spacing
380            totalWidth = max(totalWidth, currentX - spacing)
381            totalHeight = currentY + lineHeight
382        }
383
384        return LayoutResult(
385            positions: positions,
386            sizes: sizes,
387            size: CGSize(width: totalWidth, height: totalHeight)
388        )
389    }
390}