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}