| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- //
- // SearchComposerView.swift
- // indeed_ai_job_search
- //
- import SwiftUI
- struct SearchComposerView: View {
- enum Mode {
- case hero
- case compact
- }
- let mode: Mode
- @Binding var query: String
- var isSearching: Bool = false
- var suggestedPrompts: [String] = []
- var onSubmit: () -> Void
- var onSelectPrompt: ((String) -> Void)? = nil
- var focusTrigger: Bool = false
- @FocusState private var isFocused: Bool
- var body: some View {
- Group {
- switch mode {
- case .hero:
- heroContent
- case .compact:
- compactBar
- }
- }
- .onChange(of: focusTrigger) { _, _ in
- isFocused = true
- }
- }
- private var heroContent: some View {
- VStack(spacing: AppSpacing.xl) {
- VStack(spacing: AppSpacing.sm) {
- Image(systemName: "sparkles")
- .font(.system(size: 28, weight: .medium))
- .foregroundStyle(AppColors.brand)
- .frame(width: 56, height: 56)
- .background(AppColors.brandSoft)
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- Text("Indeed AI Jobs")
- .font(AppTypography.heroTitle)
- .foregroundStyle(AppColors.textPrimary)
- Text("Describe the role you want. AI turns it into filters and Indeed results.")
- .font(AppTypography.heroSubtitle)
- .foregroundStyle(AppColors.textSecondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 480)
- }
- compactBar
- .frame(maxWidth: 640)
- if !suggestedPrompts.isEmpty {
- VStack(alignment: .leading, spacing: AppSpacing.sm) {
- Text("Try a suggestion")
- .font(AppTypography.caption)
- .foregroundStyle(AppColors.textTertiary)
- FlowLayout(spacing: AppSpacing.xs) {
- ForEach(suggestedPrompts, id: \.self) { prompt in
- Button {
- onSelectPrompt?(prompt)
- } label: {
- Text(prompt)
- .font(AppTypography.chip)
- .foregroundStyle(AppColors.textPrimary)
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(AppColors.surface)
- .clipShape(Capsule())
- .overlay(
- Capsule()
- .stroke(AppColors.borderStrong, lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- }
- }
- }
- .frame(maxWidth: 640, alignment: .leading)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(AppSpacing.xxl)
- }
- private var compactBar: some View {
- HStack(spacing: AppSpacing.sm) {
- HStack(spacing: AppSpacing.xs) {
- Image(systemName: "magnifyingglass")
- .foregroundStyle(AppColors.textTertiary)
- TextField("Describe a job, e.g. Remote iOS developer jobs in Pakistan", text: $query, axis: .vertical)
- .textFieldStyle(.plain)
- .font(AppTypography.body)
- .lineLimit(mode == .hero ? 2...4 : 1...2)
- .focused($isFocused)
- .onSubmit(onSubmit)
- if !query.isEmpty {
- Button {
- query = ""
- } label: {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(AppColors.textTertiary)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Clear search")
- }
- }
- .padding(.horizontal, AppSpacing.md)
- .padding(.vertical, 12)
- .background(AppColors.surface)
- .clipShape(RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: AppSpacing.inputRadius, style: .continuous)
- .stroke(isFocused ? AppColors.brand : AppColors.borderStrong, lineWidth: isFocused ? 1.5 : 1)
- )
- Button {
- onSubmit()
- } label: {
- Group {
- if isSearching {
- ProgressView()
- .controlSize(.small)
- .tint(.white)
- } else {
- Text("Search")
- }
- }
- .frame(minWidth: 72)
- }
- .buttonStyle(PrimaryButtonStyle())
- .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
- .keyboardShortcut(.return, modifiers: .command)
- }
- }
- }
- /// Simple wrapping layout for suggestion chips.
- struct FlowLayout: Layout {
- var spacing: CGFloat = 8
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
- let result = layout(proposal: proposal, subviews: subviews)
- return result.size
- }
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
- let result = layout(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: .unspecified
- )
- }
- }
- private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
- let maxWidth = proposal.width ?? .infinity
- var positions: [CGPoint] = []
- var x: CGFloat = 0
- var y: CGFloat = 0
- var rowHeight: CGFloat = 0
- var totalHeight: CGFloat = 0
- var totalWidth: CGFloat = 0
- for subview in subviews {
- let size = subview.sizeThatFits(.unspecified)
- if x + size.width > maxWidth, x > 0 {
- x = 0
- y += rowHeight + spacing
- rowHeight = 0
- }
- positions.append(CGPoint(x: x, y: y))
- rowHeight = max(rowHeight, size.height)
- x += size.width + spacing
- totalWidth = max(totalWidth, x - spacing)
- totalHeight = y + rowHeight
- }
- return (CGSize(width: totalWidth, height: totalHeight), positions)
- }
- }
|