SearchComposerView.swift 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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 turns it into filters and Indeed results.")
  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. HStack(spacing: AppSpacing.sm) {
  86. HStack(spacing: AppSpacing.xs) {
  87. Image(systemName: "magnifyingglass")
  88. .foregroundStyle(AppColors.textTertiary)
  89. TextField("Describe a job, e.g. Remote iOS developer jobs in Pakistan", text: $query, axis: .vertical)
  90. .textFieldStyle(.plain)
  91. .font(AppTypography.body)
  92. .lineLimit(mode == .hero ? 2...4 : 1...2)
  93. .focused($isFocused)
  94. .onSubmit(onSubmit)
  95. if !query.isEmpty {
  96. Button {
  97. query = ""
  98. } label: {
  99. Image(systemName: "xmark.circle.fill")
  100. .foregroundStyle(AppColors.textTertiary)
  101. }
  102. .buttonStyle(.plain)
  103. .accessibilityLabel("Clear search")
  104. }
  105. }
  106. .padding(.horizontal, AppSpacing.md)
  107. .padding(.vertical, 12)
  108. .background(AppColors.surface)
  109. .clipShape(RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous))
  110. .overlay(
  111. RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous)
  112. .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
  113. )
  114. Button {
  115. onSubmit()
  116. } label: {
  117. Group {
  118. if isSearching {
  119. ProgressView()
  120. .controlSize(.small)
  121. .tint(.white)
  122. } else {
  123. Text("Search")
  124. }
  125. }
  126. .frame(minWidth: 72)
  127. }
  128. .buttonStyle(PrimaryButtonStyle())
  129. .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
  130. .keyboardShortcut(.return, modifiers: .command)
  131. }
  132. }
  133. }
  134. /// Simple wrapping layout for suggestion chips.
  135. struct FlowLayout: Layout {
  136. var spacing: CGFloat = 8
  137. func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
  138. let result = layout(proposal: proposal, subviews: subviews)
  139. return result.size
  140. }
  141. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
  142. let result = layout(proposal: proposal, subviews: subviews)
  143. for (index, position) in result.positions.enumerated() {
  144. subviews[index].place(
  145. at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
  146. proposal: .unspecified
  147. )
  148. }
  149. }
  150. private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
  151. let maxWidth = proposal.width ?? .infinity
  152. var positions: [CGPoint] = []
  153. var x: CGFloat = 0
  154. var y: CGFloat = 0
  155. var rowHeight: CGFloat = 0
  156. var totalHeight: CGFloat = 0
  157. var totalWidth: CGFloat = 0
  158. for subview in subviews {
  159. let size = subview.sizeThatFits(.unspecified)
  160. if x + size.width > maxWidth, x > 0 {
  161. x = 0
  162. y += rowHeight + spacing
  163. rowHeight = 0
  164. }
  165. positions.append(CGPoint(x: x, y: y))
  166. rowHeight = max(rowHeight, size.height)
  167. x += size.width + spacing
  168. totalWidth = max(totalWidth, x - spacing)
  169. totalHeight = y + rowHeight
  170. }
  171. return (CGSize(width: totalWidth, height: totalHeight), positions)
  172. }
  173. }