summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Views/Tickets
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Views/Tickets')
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift828
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift551
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift389
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift215
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift214
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift166
6 files changed, 2363 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
new file mode 100644
index 0000000..6b2be4f
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -0,0 +1,828 @@
+import SwiftUI
+import WebKit
+
+struct TicketDetailView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: TicketDetailViewModel?
+
+ // Sheet state
+ @State private var showResolveSheet = false
+ @State private var showAssignSheet = false
+ @State private var showLabelsSheet = false
+
+ // Comment composer mode
+ @State private var commentMode: CommentMode = .write
+
+ private enum CommentMode: String, CaseIterable {
+ case write = "Write"
+ case preview = "Preview"
+ }
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ }
+ }
+ .navigationTitle("#\(ticketId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if let viewModel, viewModel.ticket != nil {
+ actionsMenu(viewModel)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketDetailViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ trackerRid: trackerRid,
+ ticketId: ticketId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Actions Menu
+
+ @ViewBuilder
+ private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
+ Menu {
+ if let ticket = viewModel.ticket {
+ if ticket.status == .resolved {
+ Button {
+ Task {
+ await viewModel.updateStatus(
+ status: .reported,
+ resolution: .unresolved
+ )
+ }
+ } label: {
+ SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
+ }
+ } else {
+ Button {
+ showResolveSheet = true
+ } label: {
+ SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
+ }
+ }
+ }
+
+ Button {
+ showAssignSheet = true
+ } label: {
+ SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
+ }
+
+ Button {
+ showLabelsSheet = true
+ Task { await viewModel.loadTrackerLabels() }
+ } label: {
+ SwiftUI.Label("Manage Labels", systemImage: "tag")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .sheet(isPresented: $showResolveSheet) {
+ ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showAssignSheet) {
+ AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showLabelsSheet) {
+ LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
+ .presentationDetents([.medium])
+ }
+ }
+
+ // MARK: - Detail Content
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.ticket == nil {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ } else if let error = viewModel.error, viewModel.ticket == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Ticket",
+ message: error,
+ retryAction: { await viewModel.loadTicket() }
+ )
+ } else if let ticket = viewModel.ticket {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header
+ ticketHeader(ticket)
+
+ Divider()
+ .padding(.vertical, 12)
+
+ // Description
+ if let description = ticket.description, !description.isEmpty {
+ MarkdownContentView(markdown: description)
+ .padding(.horizontal)
+ .padding(.bottom, 16)
+
+ Divider()
+ .padding(.bottom, 12)
+ }
+
+ // Event timeline
+ if !viewModel.events.isEmpty {
+ Text("Activity")
+ .font(.headline)
+ .padding(.horizontal)
+ .padding(.bottom, 8)
+
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(viewModel.events) { event in
+ EventRow(
+ event: event,
+ ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
+ ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
+ )
+ if event.id != viewModel.events.last?.id {
+ Divider()
+ .padding(.leading, 40)
+ }
+ }
+ }
+
+ Divider()
+ .padding(.vertical, 12)
+ }
+
+ // Comment input
+ commentInput(viewModel)
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func ticketHeader(_ ticket: TicketDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(ticket.title)
+ .font(.title3.weight(.semibold))
+
+ HStack(spacing: 8) {
+ TicketStatusIcon(status: ticket.status)
+ Text(ticket.status.displayName)
+ .font(.subheadline.weight(.medium))
+
+ if ticket.status == .resolved, let resolution = ticket.resolution {
+ Text("(\(resolution.displayName))")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ HStack(spacing: 4) {
+ Text("Opened by")
+ .foregroundStyle(.secondary)
+ Text(ticket.submitter.canonicalName)
+ .fontWeight(.medium)
+ Text(ticket.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption)
+
+ 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)
+ }
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Comment Input
+
+ @ViewBuilder
+ private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("New Comment")
+ .font(.headline)
+
+ Picker("Mode", selection: $commentMode) {
+ ForEach(CommentMode.allCases, id: \.self) { mode in
+ Text(mode.rawValue).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+
+ if commentMode == .write {
+ TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(3...8)
+ } else {
+ // Markdown preview
+ if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Text("Nothing to preview")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
+ .background(Color(.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ } else {
+ MarkdownContentView(markdown: viewModel.commentText)
+ .frame(minHeight: 80, maxHeight: 200)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button {
+ Task { await viewModel.submitComment() }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Post Comment")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ .padding()
+ }
+}
+
+// MARK: - Self-Sizing Markdown Web View
+
+/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
+/// fit the rendered content. Reuses the same `markdownToHTML` converter and
+/// styling as the README renderer.
+private struct MarkdownContentView: View {
+ let markdown: String
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var renderedHTML: String?
+
+ var body: some View {
+ Group {
+ if let renderedHTML {
+ HTMLWebView(
+ html: renderedHTML,
+ colorScheme: colorScheme,
+ style: .commentPreview
+ )
+ } else {
+ SRHTLoadingStateView(message: "Preparing content…")
+ .frame(minHeight: 80)
+ }
+ }
+ .task(id: markdown) {
+ if renderedHTML != nil {
+ try? await Task.sleep(for: .milliseconds(150))
+ guard !Task.isCancelled else { return }
+ }
+
+ let html = await Task.detached(priority: .userInitiated) {
+ markdownToHTML(markdown)
+ }.value
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ }
+ }
+}
+
+// MARK: - Event Row
+
+private struct EventRow: View {
+ let event: TicketEvent
+ let ticketSubmitter: String?
+ let ticketAssignees: [String]?
+ @State private var isShowingSystemStatusInfo = false
+
+ var body: some View {
+ ForEach(event.changes) { change in
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: icon(for: change))
+ .foregroundStyle(color(for: change))
+ .frame(width: 24)
+ .padding(.top, 2)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ if kind(for: change) == .comment {
+ Text(change.author?.canonicalName ?? "")
+ .font(.subheadline.weight(.medium))
+ } else {
+ let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
+ if descriptionText.hasPrefix("System") {
+ HStack(spacing: 4) {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Button {
+ isShowingSystemStatusInfo = true
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+ } else {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Text(event.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ if kind(for: change) == .comment, let text = change.text {
+ MarkdownContentView(markdown: text)
+ }
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("This status change was recorded automatically or without a named user attached to the event.")
+ }
+ }
+ }
+
+ private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
+ let eventKind = kind(for: change)
+ let authorName: String
+
+ if let commentAuthor = event.changes.first(where: {
+ kind(for: $0) == .comment && $0.author != nil
+ })?.author?.canonicalName {
+ authorName = commentAuthor
+ } else {
+ switch eventKind {
+ case .created:
+ authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
+ case .statusChange:
+ authorName = "System"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ authorName = change.labeler?.canonicalName ?? "Someone"
+ case .assigned, .unassigned:
+ authorName = change.assigner?.canonicalName ?? "Someone"
+ case .comment:
+ authorName = change.author?.canonicalName ?? "Someone"
+ case .ticketMention, .userMention:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? "Someone"
+ case .unknown:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? change.assignee?.canonicalName
+ ?? change.mentioned?.canonicalName
+ ?? ticketAssignees?.first
+ ?? "Someone"
+ }
+ }
+
+ switch eventKind {
+ case .statusChange:
+ let oldStatus = change.oldStatus?.displayName ?? "unknown"
+ let newStatus = change.newStatus?.displayName ?? "unknown"
+ return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
+ case .labelUpdated, .labelAdded:
+ let labelName = change.label?.name ?? "a label"
+ let verb = eventKind == .labelAdded ? "added" : "updated"
+ return "\(authorName) \(verb) label \"\(labelName)\""
+ case .labelRemoved:
+ let labelName = change.label?.name ?? "a label"
+ return "\(authorName) removed label \"\(labelName)\""
+ case .assigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) assigned \(assigneeName)"
+ case .unassigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) unassigned \(assigneeName)"
+ case .ticketMention:
+ if let ticketId = change.mentioned?.id {
+ return "\(authorName) mentioned ticket #\(ticketId)"
+ }
+ return "\(authorName) mentioned another ticket"
+ case .userMention:
+ let user = change.mentioned?.canonicalName ?? "someone"
+ return "\(authorName) mentioned \(user)"
+ case .created:
+ return "\(authorName) opened this ticket"
+ case .comment:
+ return "\(authorName) commented"
+ case .unknown:
+ return "\(authorName) updated this ticket"
+ }
+ }
+
+ private func icon(for change: EventChange) -> String {
+ switch kind(for: change) {
+ case .comment:
+ "text.bubble"
+ case .statusChange:
+ "arrow.triangle.2.circlepath"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ "tag"
+ case .assigned:
+ "person.badge.plus"
+ case .unassigned:
+ "person.badge.minus"
+ case .ticketMention, .userMention:
+ "at"
+ case .created:
+ "plus.circle"
+ case .unknown:
+ "circle.fill"
+ }
+ }
+
+ private func color(for change: EventChange) -> Color {
+ switch kind(for: change) {
+ case .comment:
+ .blue
+ case .statusChange:
+ change.newStatus == .resolved ? .green : .orange
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ .purple
+ case .assigned, .unassigned:
+ .cyan
+ case .ticketMention, .userMention:
+ .indigo
+ case .created:
+ .green
+ case .unknown:
+ .gray
+ }
+ }
+
+ private func kind(for change: EventChange) -> EventKind {
+ switch change.eventType {
+ case "COMMENT", "Comment":
+ .comment
+ case "STATUS_CHANGE", "StatusChange":
+ .statusChange
+ case "LABEL_UPDATE", "LabelUpdate":
+ .labelUpdated
+ case "LABEL_ADDED", "LabelAdded":
+ .labelAdded
+ case "LABEL_REMOVED", "LabelRemoved":
+ .labelRemoved
+ case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
+ .assigned
+ case "UNASSIGNED_USER", "UnassignedUser":
+ .unassigned
+ case "TICKET_MENTION", "TicketMention":
+ .ticketMention
+ case "USER_MENTION", "UserMention":
+ .userMention
+ case "CREATED", "Created":
+ .created
+ default:
+ .unknown
+ }
+ }
+
+ private enum EventKind: Equatable {
+ case comment
+ case statusChange
+ case labelUpdated
+ case labelAdded
+ case labelRemoved
+ case assigned
+ case unassigned
+ case ticketMention
+ case userMention
+ case created
+ case unknown
+ }
+}
+
+// MARK: - Resolve Sheet
+
+private struct ResolveSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var selectedResolution: TicketResolution = .fixed
+
+ private static let resolutionOptions: [TicketResolution] = [
+ .closed, .fixed, .implemented, .wontFix,
+ .byDesign, .invalid, .duplicate, .notOurBug
+ ]
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Resolution") {
+ Picker("Resolution", selection: $selectedResolution) {
+ ForEach(Self.resolutionOptions, id: \.self) { resolution in
+ Text(resolution.displayName).tag(resolution)
+ }
+ }
+ .pickerStyle(.inline)
+ .labelsHidden()
+ }
+ }
+ .navigationTitle("Resolve Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Mark Resolved") {
+ Task {
+ await viewModel.updateStatus(
+ status: .resolved,
+ resolution: selectedResolution
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Assign Sheet
+
+private struct AssignSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var username = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ // Current assignees with remove buttons
+ if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
+ Section("Current Assignees") {
+ ForEach(ticket.assignees, id: \.canonicalName) { assignee in
+ HStack {
+ Text(assignee.canonicalName)
+ Spacer()
+ Button(role: .destructive) {
+ Task {
+ await viewModel.unassignUser(
+ username: assignee.canonicalName
+ )
+ }
+ } label: {
+ Image(systemName: "minus.circle.fill")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ Section("Add Assignee") {
+ TextField("Username or ~username", text: $username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Button("Add Assignee") {
+ let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
+ Task {
+ await viewModel.assignUser(username: name)
+ username = ""
+ }
+ }
+ .disabled(
+ username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .navigationTitle("Assignees")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { isPresented = false }
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Labels Sheet
+
+private struct LabelsSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var showCreateLabel = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.trackerLabels.isEmpty {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ } else {
+ ContentUnavailableView(
+ "No Labels",
+ systemImage: "tag",
+ description: Text("This tracker has no labels defined.")
+ )
+ }
+ } else {
+ List {
+ ForEach(viewModel.trackerLabels) { label in
+ LabelToggleRow(
+ label: label,
+ isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
+ isLoading: viewModel.isPerformingAction
+ ) { shouldApply in
+ Task {
+ if shouldApply {
+ await viewModel.labelTicket(labelId: label.id)
+ } else {
+ await viewModel.unlabelTicket(labelId: label.id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Labels")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { isPresented = false }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ showCreateLabel = true
+ } label: {
+ SwiftUI.Label("New Label", systemImage: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateLabel) {
+ CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
+ .presentationDetents([.medium])
+ }
+ }
+ }
+}
+
+// MARK: - Create Label Sheet
+
+private struct CreateLabelSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var labelName = ""
+ @State private var backgroundColor = Color.blue
+ @State private var foregroundColor = Color.white
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Label Details") {
+ TextField("Label name", text: $labelName)
+ .autocorrectionDisabled()
+ }
+
+ Section("Colors") {
+ ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
+ ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
+ }
+
+ Section("Preview") {
+ HStack {
+ Spacer()
+ Text(labelName.isEmpty ? "Label" : labelName)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ Spacer()
+ }
+ }
+ }
+ .navigationTitle("New Label")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Create Label") {
+ Task {
+ await viewModel.createLabel(
+ name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ backgroundColor: backgroundColor.hexString,
+ foregroundColor: foregroundColor.hexString
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(
+ labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+private struct LabelToggleRow: View {
+ let label: TicketLabel
+ let isApplied: Bool
+ let isLoading: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ Button {
+ onToggle(!isApplied)
+ } label: {
+ HStack {
+ LabelPill(label: label)
+ Spacer()
+ if isApplied {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ }
+ .disabled(isLoading)
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
new file mode 100644
index 0000000..70eaf0a
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -0,0 +1,551 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TicketDetailResponse: Decodable, Sendable {
+ let user: UserTrackerTicketWrapper
+}
+
+private struct UserTrackerTicketWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketWrapper
+}
+
+private struct TrackerTicketWrapper: Decodable, Sendable {
+ let ticket: TicketDetailPayload
+}
+
+private struct TicketDetailPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+ let events: EventsPage
+}
+
+private struct EventsPage: Decodable, Sendable {
+ let results: [TicketEvent]
+ let cursor: String?
+}
+
+private struct SubmitCommentResponse: Decodable, Sendable {
+ let submitComment: SubmittedEvent
+}
+
+private struct SubmittedEvent: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let changes: [EventChange]
+}
+
+private struct MutationEventResponse: Decodable, Sendable {
+ let id: Int
+}
+
+private struct UpdateStatusResponse: Decodable, Sendable {
+ let updateTicketStatus: UpdatedStatusEvent
+}
+
+private struct UpdatedStatusEvent: Decodable, Sendable {
+ let eventType: String
+}
+
+private struct AssignUserResponse: Decodable, Sendable {
+ let assignUser: MutationEventResponse
+}
+
+private struct UnassignUserResponse: Decodable, Sendable {
+ let unassignUser: MutationEventResponse
+}
+
+private struct LabelTicketResponse: Decodable, Sendable {
+ let labelTicket: MutationEventResponse
+}
+
+private struct UnlabelTicketResponse: Decodable, Sendable {
+ let unlabelTicket: MutationEventResponse
+}
+
+private struct UserLookupResponse: Decodable, Sendable {
+ let user: UserIdPayload
+}
+
+private struct UserIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
+private struct CreateLabelResponse: Decodable, Sendable {
+ let createLabel: TicketLabel
+}
+
+private struct TrackerLabelsResponse: Decodable, Sendable {
+ let user: UserTrackerLabelsWrapper
+}
+
+private struct UserTrackerLabelsWrapper: Decodable, Sendable {
+ let tracker: TrackerLabelsWrapper
+}
+
+private struct TrackerLabelsWrapper: Decodable, Sendable {
+ let labels: LabelsPage
+}
+
+private struct LabelsPage: Decodable, Sendable {
+ let results: [TicketLabel]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketDetailViewModel {
+
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ private(set) var ticket: TicketDetail?
+ private(set) var events: [TicketEvent] = []
+ private(set) var isLoading = false
+ private(set) var isSubmitting = false
+ private(set) var isPerformingAction = false
+ private(set) var trackerLabels: [TicketLabel] = []
+ var commentText = ""
+ var error: String?
+
+ private let client: SRHTClient
+
+ private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
+ if lhs.created == rhs.created {
+ return lhs.id < rhs.id
+ }
+ return lhs.created < rhs.created
+ }
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.trackerRid = trackerRid
+ self.ticketId = ticketId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ ticket(id: $ticketId) {
+ id
+ created
+ updated
+ title: subject
+ description: body
+ status
+ resolution
+ authenticity
+ submitter { canonicalName }
+ assignees { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ events {
+ results {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ ... on StatusChange {
+ oldStatus
+ newStatus
+ }
+ ... on LabelUpdate {
+ labeler { canonicalName }
+ label { name }
+ }
+ ... on Assignment {
+ assigner { canonicalName }
+ assignee { canonicalName }
+ }
+ ... on TicketMention {
+ mentioned { id }
+ }
+ ... on UserMention {
+ mentioned { canonicalName }
+ }
+ ... on Created {
+ author { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitCommentMutation = """
+ mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
+ submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ }
+ }
+ }
+ """
+
+ private static let updateStatusMutation = """
+ mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
+ updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ eventType: __typename
+ }
+ }
+ """
+
+ private static let assignUserMutation = """
+ mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let unassignUserMutation = """
+ mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let labelTicketMutation = """
+ mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let unlabelTicketMutation = """
+ mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user(username: $username) { id }
+ }
+ """
+
+ private static let trackerLabelsQuery = """
+ query trackerLabels($owner: String!, $tracker: String!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ labels {
+ results { id name backgroundColor foregroundColor }
+ }
+ }
+ }
+ }
+ """
+
+ private static let createLabelMutation = """
+ mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
+ createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
+ id
+ name
+ backgroundColor
+ foregroundColor
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTicket() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.detailQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName,
+ "ticketId": ticketId
+ ],
+ responseType: TicketDetailResponse.self
+ )
+ let payload = result.user.tracker.ticket
+ ticket = TicketDetail(
+ id: payload.id,
+ created: payload.created,
+ updated: payload.updated,
+ title: payload.title,
+ description: payload.description,
+ status: payload.status,
+ resolution: payload.resolution,
+ authenticity: payload.authenticity,
+ submitter: payload.submitter,
+ assignees: payload.assignees,
+ labels: payload.labels
+ )
+ events = payload.events.results.sorted(by: Self.timelineOrder)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func submitComment() async {
+ let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, !isSubmitting else { return }
+ isSubmitting = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["text": text]
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitCommentMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: SubmitCommentResponse.self
+ )
+ // Append the returned event so the comment shows immediately.
+ let submitted = result.submitComment
+ let event = TicketEvent(
+ id: submitted.id,
+ created: submitted.created,
+ changes: submitted.changes
+ )
+ events.append(event)
+ events.sort(by: Self.timelineOrder)
+ commentText = ""
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSubmitting = false
+ }
+
+ func updateComment(commentId: Int, text: String) async {
+ _ = commentId
+ _ = text
+ error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
+ }
+
+ // MARK: - Ticket Actions
+
+ func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "status": status.rawValue,
+ "resolution": resolution.rawValue
+ ]
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.updateStatusMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: UpdateStatusResponse.self
+ )
+ // Re-fetch the ticket to get updated status/resolution
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func assignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": username],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.assignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: AssignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unassignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": stripped],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unassignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: UnassignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func labelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.labelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: LabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unlabelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unlabelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: UnlabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func loadTrackerLabels() async {
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackerLabelsQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ],
+ responseType: TrackerLabelsResponse.self
+ )
+ trackerLabels = result.user.tracker.labels.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createLabelMutation,
+ variables: [
+ "trackerId": trackerId,
+ "name": name,
+ "backgroundColor": backgroundColor,
+ "foregroundColor": foregroundColor
+ ],
+ responseType: CreateLabelResponse.self
+ )
+ trackerLabels.append(result.createLabel)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+}
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)
+ )
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
new file mode 100644
index 0000000..916037e
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -0,0 +1,215 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackerTicketsResponse: Decodable, Sendable {
+ let user: UserTrackerWrapper
+}
+
+private struct UserTrackerWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketsWrapper
+}
+
+private struct TrackerTicketsWrapper: Decodable, Sendable {
+ let tickets: TicketsPage
+}
+
+private struct TicketsPage: Decodable, Sendable {
+ let results: [TicketSummary]
+ let cursor: String?
+}
+
+// MARK: - Filter
+
+enum TicketFilter: String, CaseIterable, Sendable {
+ case open = "Open"
+ case resolved = "Resolved"
+ case all = "All"
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketListViewModel {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+
+ private(set) var tickets: [TicketSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTicket = false
+ var error: String?
+ var filter: TicketFilter = .open
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets(cursor: $cursor) {
+ results {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitTicketMutation = """
+ mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
+ submitTicket(trackerId: $trackerId, input: $input) {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Computed
+
+ /// Tickets filtered by the selected status filter.
+ var filteredTickets: [TicketSummary] {
+ switch filter {
+ case .open:
+ tickets.filter { $0.status.isOpen }
+ case .resolved:
+ tickets.filter { !$0.status.isOpen }
+ case .all:
+ tickets
+ }
+ }
+
+ // MARK: - Public API
+
+ func loadTickets() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ tickets = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TicketSummary) async {
+ guard let last = tickets.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ tickets.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTicket(subject: String, body: String) async -> TicketSummary? {
+ guard !isCreatingTicket else { return nil }
+
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSubject.isEmpty else {
+ error = "Enter a ticket title."
+ return nil
+ }
+
+ isCreatingTicket = true
+ error = nil
+ defer { isCreatingTicket = false }
+
+ var input: [String: any Sendable] = [
+ "subject": trimmedSubject
+ ]
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedBody.isEmpty {
+ input["body"] = trimmedBody
+ }
+ let variables: [String: any Sendable] = [
+ "trackerId": trackerId,
+ "input": input
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitTicketMutation,
+ variables: variables,
+ responseType: SubmitTicketResponse.self
+ )
+ let ticket = result.submitTicket
+ tickets.insert(ticket, at: 0)
+ return ticket
+ } catch {
+ self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TicketsPage {
+ var variables: [String: any Sendable] = [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables,
+ responseType: TrackerTicketsResponse.self
+ )
+ return result.user.tracker.tickets
+ }
+
+ private struct SubmitTicketResponse: Decodable, Sendable {
+ let submitTicket: TicketSummary
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
new file mode 100644
index 0000000..d16246b
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -0,0 +1,214 @@
+import SwiftUI
+
+struct TrackerListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TrackerListViewModel?
+ @State private var showCreateTrackerSheet = false
+ @State private var createdTracker: TrackerSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ }
+ }
+ .navigationTitle("Trackers")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateTrackerSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTrackerSheet) {
+ if let viewModel {
+ CreateTrackerSheet(viewModel: viewModel) { tracker in
+ showCreateTrackerSheet = false
+ createdTracker = tracker
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTracker != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTracker = nil
+ }
+ }
+ )) {
+ if let createdTracker {
+ TicketListView(
+ ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
+ trackerName: createdTracker.name,
+ trackerId: createdTracker.id,
+ trackerRid: createdTracker.rid
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TrackerListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadTrackers()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TrackerListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.trackers) { tracker in
+ NavigationLink(value: tracker) {
+ TrackerRowView(tracker: tracker)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: tracker)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.trackers.isEmpty {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ } else if let error = viewModel.error, viewModel.trackers.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Trackers",
+ message: error,
+ retryAction: { await viewModel.loadTrackers() }
+ )
+ } else if viewModel.trackers.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Trackers",
+ systemImage: "checklist",
+ description: Text("Your bug trackers will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
+ await viewModel.loadTrackers()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTrackers()
+ }
+ .navigationDestination(for: TrackerSummary.self) { tracker in
+ TicketListView(
+ ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+ trackerName: tracker.name,
+ trackerId: tracker.id,
+ trackerRid: tracker.rid
+ )
+ }
+ }
+}
+
+private struct CreateTrackerSheet: View {
+ let viewModel: TrackerListViewModel
+ let onCreated: (TrackerSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var description = ""
+ @State private var visibility: Visibility = .public
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Tracker Details") {
+ TextField("Tracker name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Short description (optional)", text: $description, axis: .vertical)
+ .lineLimit(2...4)
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+ }
+ .navigationTitle("New Tracker")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let tracker = await viewModel.createTracker(
+ name: name,
+ description: description,
+ visibility: visibility
+ ) {
+ onCreated(tracker)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTracker {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Tracker")
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Tracker Row
+
+private struct TrackerRowView: View {
+ let tracker: TrackerSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(tracker.name)
+ .font(.subheadline.weight(.medium))
+
+ Spacer()
+
+ VisibilityBadge(visibility: tracker.visibility)
+ }
+
+ if let owner = tracker.owner.canonicalName.split(separator: "~").last {
+ Text("~\(owner)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let description = tracker.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Text(tracker.updated.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
new file mode 100644
index 0000000..9704071
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackersResponse: Decodable, Sendable {
+ let trackers: TrackersPage
+}
+
+private struct TrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TrackerListViewModel {
+
+ private(set) var trackers: [TrackerSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTracker = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createTrackerMutation = """
+ mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
+ createTracker(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTrackers() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ trackers = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TrackerSummary) async {
+ guard let last = trackers.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ trackers.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
+ guard !isCreatingTracker else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a tracker name."
+ return nil
+ }
+
+ isCreatingTracker = true
+ error = nil
+ defer { isCreatingTracker = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createTrackerMutation,
+ variables: variables,
+ responseType: CreateTrackerResponse.self
+ )
+ let tracker = result.createTracker
+ trackers.insert(tracker, at: 0)
+ return tracker
+ } catch {
+ self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TrackersPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: TrackersResponse.self
+ )
+ return result.trackers
+ }
+
+ private struct CreateTrackerResponse: Decodable, Sendable {
+ let createTracker: TrackerSummary
+ }
+}