SearchComposerView.swift 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. //
  2. // SearchComposerView.swift
  3. // indeed_ai_job_search
  4. //
  5. import SwiftUI
  6. struct SearchComposerView: View {
  7. enum Mode {
  8. case hero
  9. case compact
  10. }
  11. let mode: Mode
  12. @Binding var query: String
  13. var isSearching: Bool = false
  14. var suggestedPrompts: [String] = []
  15. var onSubmit: () -> Void
  16. var onSelectPrompt: ((String) -> Void)? = nil
  17. var focusTrigger: Bool = false
  18. @FocusState private var isFocused: Bool
  19. var body: some View {
  20. Group {
  21. switch mode {
  22. case .hero:
  23. heroContent
  24. case .compact:
  25. compactBar
  26. }
  27. }
  28. .onChange(of: focusTrigger) { _, _ in
  29. isFocused = true
  30. }
  31. }
  32. private var heroContent: some View {
  33. VStack(spacing: AppSpacing.xl) {
  34. VStack(spacing: AppSpacing.sm) {
  35. Image(systemName: "sparkles")
  36. .font(.system(size: 28, weight: .medium))
  37. .foregroundStyle(AppColors.brand)
  38. .frame(width: 56, height: 56)
  39. .background(AppColors.brandSoft)
  40. .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
  41. Text("Indeed AI Jobs")
  42. .font(AppTypography.heroTitle)
  43. .foregroundStyle(AppColors.textPrimary)
  44. Text("Describe the role you want. AI searches live Indeed listings for matching jobs.")
  45. .font(AppTypography.heroSubtitle)
  46. .foregroundStyle(AppColors.textSecondary)
  47. .multilineTextAlignment(.center)
  48. .frame(maxWidth: 480)
  49. }
  50. compactBar
  51. .frame(maxWidth: 640)
  52. if !suggestedPrompts.isEmpty {
  53. VStack(alignment: .leading, spacing: AppSpacing.sm) {
  54. Text("Try a suggestion")
  55. .font(AppTypography.caption)
  56. .foregroundStyle(AppColors.textTertiary)
  57. FlowLayout(spacing: AppSpacing.xs) {
  58. ForEach(suggestedPrompts, id: \.self) { prompt in
  59. Button {
  60. onSelectPrompt?(prompt)
  61. } label: {
  62. Text(prompt)
  63. .font(AppTypography.chip)
  64. .foregroundStyle(AppColors.textPrimary)
  65. .padding(.horizontal, 12)
  66. .padding(.vertical, 8)
  67. .background(AppColors.surface)
  68. .clipShape(Capsule())
  69. .overlay(
  70. Capsule()
  71. .stroke(AppColors.borderStrong, lineWidth: 1)
  72. )
  73. }
  74. .buttonStyle(.plain)
  75. }
  76. }
  77. }
  78. .frame(maxWidth: 640, alignment: .leading)
  79. }
  80. }
  81. .frame(maxWidth: .infinity, maxHeight: .infinity)
  82. .padding(AppSpacing.xxl)
  83. }
  84. private var compactBar: some View {
  85. let barHeight: CGFloat = 42
  86. return HStack(alignment: .center, spacing: AppSpacing.xs) {
  87. HStack(spacing: 8) {
  88. Image(systemName: "magnifyingglass")
  89. .font(.system(size: 14, weight: .medium))
  90. .foregroundStyle(AppColors.textTertiary)
  91. TextField(
  92. "",
  93. text: $query,
  94. prompt: Text("Describe a job, e.g. iOS developer or Swift engineer in Lahore")
  95. .foregroundStyle(AppColors.textTertiary)
  96. )
  97. .appPlainTextField()
  98. .focused($isFocused)
  99. .onSubmit(onSubmit)
  100. if !query.isEmpty {
  101. Button {
  102. query = ""
  103. } label: {
  104. Image(systemName: "xmark.circle.fill")
  105. .font(.system(size: 13))
  106. .foregroundStyle(AppColors.textTertiary)
  107. }
  108. .buttonStyle(.plain)
  109. .accessibilityLabel("Clear search")
  110. }
  111. }
  112. .padding(.horizontal, 12)
  113. .frame(maxHeight: .infinity)
  114. .background(AppColors.surface)
  115. .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
  116. .overlay(
  117. RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous)
  118. .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
  119. )
  120. Button {
  121. onSubmit()
  122. } label: {
  123. Group {
  124. if isSearching {
  125. ProgressView()
  126. .controlSize(.small)
  127. .tint(.white)
  128. } else {
  129. Text("Search")
  130. }
  131. }
  132. .frame(minWidth: 72)
  133. .frame(maxHeight: .infinity)
  134. }
  135. .buttonStyle(CompactSearchButtonStyle())
  136. // Keep enabled while searching so a new query can cancel and replace the in-flight one.
  137. .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  138. .keyboardShortcut(.defaultAction)
  139. .help(isSearching ? "Search again (cancels current search)" : "Search")
  140. }
  141. .frame(height: barHeight)
  142. }
  143. }
  144. /// Matches the single-line search field height so the button stays aligned.
  145. private struct CompactSearchButtonStyle: ButtonStyle {
  146. @Environment(\.isEnabled) private var isEnabled
  147. func makeBody(configuration: Configuration) -> some View {
  148. configuration.label
  149. .font(AppTypography.button)
  150. .foregroundStyle(AppColors.textOnBrand)
  151. .padding(.horizontal, AppSpacing.md)
  152. .frame(maxHeight: .infinity)
  153. .background(isEnabled ? AppColors.brand : AppColors.brand.opacity(0.45))
  154. .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
  155. .opacity(configuration.isPressed ? 0.88 : 1)
  156. }
  157. }
  158. /// Simple wrapping layout for suggestion chips.
  159. struct FlowLayout: Layout {
  160. var spacing: CGFloat = 8
  161. func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
  162. let result = layout(proposal: proposal, subviews: subviews)
  163. return result.size
  164. }
  165. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
  166. let result = layout(proposal: proposal, subviews: subviews)
  167. for (index, position) in result.positions.enumerated() {
  168. subviews[index].place(
  169. at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
  170. proposal: .unspecified
  171. )
  172. }
  173. }
  174. private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
  175. let maxWidth = proposal.width ?? .infinity
  176. var positions: [CGPoint] = []
  177. var x: CGFloat = 0
  178. var y: CGFloat = 0
  179. var rowHeight: CGFloat = 0
  180. var totalHeight: CGFloat = 0
  181. var totalWidth: CGFloat = 0
  182. for subview in subviews {
  183. let size = subview.sizeThatFits(.unspecified)
  184. if x + size.width > maxWidth, x > 0 {
  185. x = 0
  186. y += rowHeight + spacing
  187. rowHeight = 0
  188. }
  189. positions.append(CGPoint(x: x, y: y))
  190. rowHeight = max(rowHeight, size.height)
  191. x += size.width + spacing
  192. totalWidth = max(totalWidth, x - spacing)
  193. totalHeight = y + rowHeight
  194. }
  195. return (CGSize(width: totalWidth, height: totalHeight), positions)
  196. }
  197. }