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}