JobRowView.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. //
  2. // JobRowView.swift
  3. // indeed_ai_job_search
  4. //
  5. import SwiftUI
  6. struct JobRowView: View {
  7. let listing: JobListing
  8. let isSaved: Bool
  9. var onToggleSave: () -> Void
  10. var onOpen: (() -> Void)? = nil
  11. var isCompact: Bool = false
  12. var showsCardChrome: Bool = true
  13. private var isUnavailable: Bool {
  14. listing.availability == .unavailable
  15. }
  16. private func openListing() {
  17. if let onOpen {
  18. onOpen()
  19. } else {
  20. JobListingOpener.open(listing)
  21. }
  22. }
  23. var body: some View {
  24. HStack(alignment: .top, spacing: AppSpacing.md) {
  25. CompanyLogoView(listing: listing)
  26. .opacity(isUnavailable ? 0.45 : 1)
  27. VStack(alignment: .leading, spacing: AppSpacing.xs) {
  28. HStack(alignment: .top) {
  29. VStack(alignment: .leading, spacing: 2) {
  30. Text(listing.title)
  31. .font(AppTypography.jobTitle)
  32. .foregroundStyle(isUnavailable ? AppColors.textTertiary : AppColors.textPrimary)
  33. .strikethrough(isUnavailable)
  34. .lineLimit(2)
  35. Text(listing.company)
  36. .font(AppTypography.company)
  37. .foregroundStyle(AppColors.textSecondary)
  38. }
  39. Spacer(minLength: AppSpacing.sm)
  40. // Nested Button keeps save from triggering the card open.
  41. SaveJobButton(isSaved: isSaved, action: onToggleSave)
  42. }
  43. metaRow
  44. SalaryLabel(salary: listing.salary)
  45. HStack(spacing: AppSpacing.xs) {
  46. if listing.isEasyApply { EasyApplyBadge() }
  47. if listing.isFeatured { FeaturedBadge() }
  48. if listing.isUrgentlyHiring { UrgentlyHiringBadge() }
  49. if isUnavailable { UnavailableBadge() }
  50. Spacer(minLength: 0)
  51. RelativeDateLabel(date: listing.postedAt)
  52. }
  53. .padding(.top, 2)
  54. Text(openLinkTitle)
  55. .font(AppTypography.caption)
  56. .foregroundStyle(AppColors.brand)
  57. .padding(.top, 4)
  58. .accessibilityHidden(true)
  59. }
  60. }
  61. .padding(AppSpacing.md)
  62. .frame(maxWidth: .infinity, alignment: .leading)
  63. .contentShape(Rectangle())
  64. .onTapGesture(perform: openListing)
  65. .background(showsCardChrome ? AppColors.surfaceCard : Color.clear)
  66. .clipShape(RoundedRectangle(cornerRadius: showsCardChrome ? AppSpacing.cardRadius : 0, style: .continuous))
  67. .overlay {
  68. if showsCardChrome {
  69. RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
  70. .stroke(AppColors.border, lineWidth: 1)
  71. }
  72. }
  73. .shadow(
  74. color: showsCardChrome ? Color.black.opacity(AppShadow.cardOpacity) : .clear,
  75. radius: showsCardChrome ? AppShadow.cardRadius : 0,
  76. x: 0,
  77. y: showsCardChrome ? AppShadow.cardY : 0
  78. )
  79. .help("\(openLinkTitle): \(listing.title) at \(listing.company)")
  80. .accessibilityElement(children: .contain)
  81. .accessibilityAddTraits(.isButton)
  82. .accessibilityHint(openAccessibilityHint)
  83. .accessibilityAction(named: openLinkTitle, openListing)
  84. }
  85. /// Direct viewjob when we have a real Indeed `jk`; otherwise company-scoped search.
  86. private var openLinkTitle: String {
  87. IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
  88. ? "Open on Indeed"
  89. : "Search on Indeed"
  90. }
  91. private var openAccessibilityHint: String {
  92. IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
  93. ? "Opens this job on Indeed"
  94. : "Searches Indeed for this title at \(listing.company)"
  95. }
  96. private var metaRow: some View {
  97. HStack(spacing: AppSpacing.sm) {
  98. if showsLocationSeparately {
  99. Label(listing.location, systemImage: "mappin")
  100. Text("•")
  101. .foregroundStyle(AppColors.textTertiary)
  102. }
  103. RemoteWorkBadge(type: listing.remoteType)
  104. if let employment = listing.employmentType {
  105. Text("•")
  106. .foregroundStyle(AppColors.textTertiary)
  107. Label(employment.displayName, systemImage: "briefcase")
  108. }
  109. }
  110. .font(AppTypography.meta)
  111. .foregroundStyle(AppColors.textSecondary)
  112. .lineLimit(1)
  113. }
  114. /// Avoid “Remote • Remote” when location text is only the work mode.
  115. private var showsLocationSeparately: Bool {
  116. let loc = listing.location.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  117. guard !loc.isEmpty else { return false }
  118. if listing.remoteType == .remote,
  119. loc == "remote" || loc == "remote only" {
  120. return false
  121. }
  122. return true
  123. }
  124. }
  125. struct JobGridCardView: View {
  126. let listing: JobListing
  127. let isSaved: Bool
  128. var onToggleSave: () -> Void
  129. var onOpen: (() -> Void)? = nil
  130. private func openListing() {
  131. if let onOpen {
  132. onOpen()
  133. } else {
  134. JobListingOpener.open(listing)
  135. }
  136. }
  137. var body: some View {
  138. VStack(alignment: .leading, spacing: AppSpacing.sm) {
  139. HStack {
  140. CompanyLogoView(listing: listing)
  141. Spacer()
  142. SaveJobButton(isSaved: isSaved, action: onToggleSave)
  143. }
  144. Text(listing.title)
  145. .font(AppTypography.jobTitle)
  146. .foregroundStyle(AppColors.textPrimary)
  147. .lineLimit(2)
  148. Text(listing.company)
  149. .font(AppTypography.company)
  150. .foregroundStyle(AppColors.textSecondary)
  151. Label(listing.location, systemImage: "mappin")
  152. .font(AppTypography.meta)
  153. .foregroundStyle(AppColors.textSecondary)
  154. .lineLimit(1)
  155. SalaryLabel(salary: listing.salary)
  156. Spacer(minLength: 0)
  157. HStack {
  158. if listing.isEasyApply { EasyApplyBadge() }
  159. Spacer()
  160. RelativeDateLabel(date: listing.postedAt)
  161. }
  162. Text(openLinkTitle)
  163. .font(AppTypography.caption)
  164. .foregroundStyle(AppColors.brand)
  165. .accessibilityHidden(true)
  166. }
  167. .padding(AppSpacing.md)
  168. .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
  169. .contentShape(Rectangle())
  170. .onTapGesture(perform: openListing)
  171. .background(AppColors.surfaceCard)
  172. .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
  173. .overlay(
  174. RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
  175. .stroke(AppColors.border, lineWidth: 1)
  176. )
  177. .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
  178. .help("\(openLinkTitle): \(listing.title) at \(listing.company)")
  179. .accessibilityAddTraits(.isButton)
  180. .accessibilityHint(openAccessibilityHint)
  181. .accessibilityAction(named: openLinkTitle, openListing)
  182. }
  183. private var openLinkTitle: String {
  184. IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
  185. ? "Open on Indeed"
  186. : "Search on Indeed"
  187. }
  188. private var openAccessibilityHint: String {
  189. IndeedBrowseURLBuilder.isLikelyRealJobKey(listing.indeedJobKey)
  190. ? "Opens this job on Indeed"
  191. : "Searches Indeed for this title at \(listing.company)"
  192. }
  193. }