krz/hutch

an ios client for sourcehut

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

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