summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets/TicketDetailView.swift
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/TicketDetailView.swift
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
Diffstat (limited to 'Hutch/Views/Tickets/TicketDetailView.swift')
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift828
1 files changed, 828 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)
+ }
+}