diff options
Diffstat (limited to 'Hutch/Views/Tickets')
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 828 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 551 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 389 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 215 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListView.swift | 214 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListViewModel.swift | 166 |
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 + } +} |
