Browse Source

Add Phase 1 UI with mock data matching the Indeed AI Jobs design.

Replace the placeholder app with a three-column shell, design system, four feature areas, and protocol-shaped mocks so UI can be reviewed before backend work.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 22 giờ trước cách đây
mục cha
commit
5c6fdf34f8
35 tập tin đã thay đổi với 3792 bổ sung và 41 xóa
  1. 39 0
      indeed_ai_job_search/App/AppDependencies.swift
  2. 134 0
      indeed_ai_job_search/App/RootShellView.swift
  3. 21 0
      indeed_ai_job_search/App/indeed_ai_job_searchApp.swift
  4. 9 0
      indeed_ai_job_search/Assets.xcassets/AccentColor.colorset/Contents.json
  5. 0 24
      indeed_ai_job_search/ContentView.swift
  6. 60 0
      indeed_ai_job_search/DesignSystem/Components/ActiveFilterChipsView.swift
  7. 43 0
      indeed_ai_job_search/DesignSystem/Components/ButtonStyles.swift
  8. 134 0
      indeed_ai_job_search/DesignSystem/Components/EmptyStateView.swift
  9. 181 0
      indeed_ai_job_search/DesignSystem/Components/JobBadges.swift
  10. 135 0
      indeed_ai_job_search/DesignSystem/Components/JobRowView.swift
  11. 194 0
      indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift
  12. 44 0
      indeed_ai_job_search/DesignSystem/Theme/AppColors.swift
  13. 35 0
      indeed_ai_job_search/DesignSystem/Theme/AppSpacing.swift
  14. 24 0
      indeed_ai_job_search/DesignSystem/Theme/AppTypography.swift
  15. 172 0
      indeed_ai_job_search/Domain/Enums/JobEnums.swift
  16. 71 0
      indeed_ai_job_search/Domain/Models/JobAlert.swift
  17. 101 0
      indeed_ai_job_search/Domain/Models/JobListing.swift
  18. 17 0
      indeed_ai_job_search/Domain/Models/SavedJob.swift
  19. 109 0
      indeed_ai_job_search/Domain/Models/SearchCriteria.swift
  20. 34 0
      indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift
  21. 92 0
      indeed_ai_job_search/Features/JobAlerts/ViewModels/JobAlertsViewModel.swift
  22. 250 0
      indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift
  23. 176 0
      indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift
  24. 231 0
      indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift
  25. 246 0
      indeed_ai_job_search/Features/JobSearch/Views/FiltersPanelView.swift
  26. 114 0
      indeed_ai_job_search/Features/SavedJobs/ViewModels/SavedJobsViewModel.swift
  27. 120 0
      indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift
  28. 39 0
      indeed_ai_job_search/Features/Settings/ViewModels/SettingsViewModel.swift
  29. 131 0
      indeed_ai_job_search/Features/Settings/Views/SettingsView.swift
  30. 125 0
      indeed_ai_job_search/Navigation/AppSidebarView.swift
  31. 42 0
      indeed_ai_job_search/Navigation/SidebarItem.swift
  32. 400 0
      indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift
  33. 27 0
      indeed_ai_job_search/PreviewSupport/PreviewDependencies.swift
  34. 242 0
      indeed_ai_job_search/Services/Mock/MockServices.swift
  35. 0 17
      indeed_ai_job_search/indeed_ai_job_searchApp.swift

+ 39 - 0
indeed_ai_job_search/App/AppDependencies.swift

@@ -0,0 +1,39 @@
+//
+//  AppDependencies.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+@MainActor
+final class AppDependencies {
+    let jobDataSource: JobDataSource
+    let searchInterpreter: OpenAISearchService
+    let validationService: JobValidationService
+    let savedJobsStore: MockSavedJobsStore
+    let alertsStore: MockAlertsStore
+
+    init(
+        jobDataSource: JobDataSource,
+        searchInterpreter: OpenAISearchService,
+        validationService: JobValidationService,
+        savedJobsStore: MockSavedJobsStore,
+        alertsStore: MockAlertsStore
+    ) {
+        self.jobDataSource = jobDataSource
+        self.searchInterpreter = searchInterpreter
+        self.validationService = validationService
+        self.savedJobsStore = savedJobsStore
+        self.alertsStore = alertsStore
+    }
+
+    static var preview: AppDependencies {
+        AppDependencies(
+            jobDataSource: MockJobDataSource(),
+            searchInterpreter: MockSearchInterpreter(),
+            validationService: MockJobValidationService(),
+            savedJobsStore: MockSavedJobsStore(),
+            alertsStore: MockAlertsStore()
+        )
+    }
+}

+ 134 - 0
indeed_ai_job_search/App/RootShellView.swift

@@ -0,0 +1,134 @@
+//
+//  RootShellView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct RootShellView: View {
+    let dependencies: AppDependencies
+
+    @State private var selection: SidebarItem = .jobSearch
+    @State private var jobSearchVM: JobSearchViewModel
+    @State private var savedJobsVM: SavedJobsViewModel
+    @State private var alertsVM: JobAlertsViewModel
+    @State private var settingsVM: SettingsViewModel
+    @State private var showFiltersSheet = false
+
+    init(dependencies: AppDependencies) {
+        self.dependencies = dependencies
+        _jobSearchVM = State(initialValue: JobSearchViewModel(dependencies: dependencies))
+        _savedJobsVM = State(initialValue: SavedJobsViewModel(dependencies: dependencies))
+        _alertsVM = State(initialValue: JobAlertsViewModel(dependencies: dependencies))
+        _settingsVM = State(initialValue: SettingsViewModel())
+    }
+
+    var body: some View {
+        GeometryReader { proxy in
+            let width = proxy.size.width
+            let showInlineFilters = width >= AppSpacing.mediumBreakpoint && selection == .jobSearch
+
+            HStack(spacing: 0) {
+                AppSidebarView(
+                    selection: $selection,
+                    unreadAlertCount: alertsVM.unreadCount
+                )
+                .frame(width: AppSpacing.sidebarWidth)
+                .overlay(alignment: .trailing) {
+                    Rectangle()
+                        .fill(AppColors.border)
+                        .frame(width: 1)
+                }
+
+                detail(showInlineFilters: showInlineFilters)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            }
+            .background(AppColors.backgroundPrimary)
+        }
+        .preferredColorScheme(settingsVM.preferredColorScheme)
+        .toolbar {
+            ToolbarItemGroup(placement: .primaryAction) {
+                Button {
+                    alertsVM.openNotificationCenter()
+                } label: {
+                    Image(systemName: alertsVM.unreadCount > 0 ? "bell.badge" : "bell")
+                }
+                .help("Notification center")
+
+                Button {
+                    selection = .settings
+                } label: {
+                    ZStack {
+                        Circle()
+                            .fill(AppColors.brandSoft)
+                            .frame(width: 28, height: 28)
+                        Text("JD")
+                            .font(.system(size: 10, weight: .semibold))
+                            .foregroundStyle(AppColors.brand)
+                    }
+                }
+                .help("Settings")
+            }
+        }
+        .sheet(isPresented: $showFiltersSheet) {
+            NavigationStack {
+                FiltersPanelView(
+                    criteria: $jobSearchVM.criteria,
+                    onReset: { jobSearchVM.resetFilters() },
+                    onChange: { jobSearchVM.applyFiltersAndRefresh() }
+                )
+                .toolbar {
+                    ToolbarItem(placement: .cancellationAction) {
+                        Button("Done") { showFiltersSheet = false }
+                    }
+                }
+            }
+            .frame(minWidth: 360, minHeight: 560)
+        }
+        .sheet(isPresented: $alertsVM.showNotificationCenter) {
+            NotificationCenterSheet(events: alertsVM.events)
+        }
+        .onChange(of: selection) { _, newValue in
+            if newValue == .savedJobs {
+                savedJobsVM.refreshFromStore()
+            }
+        }
+        .background(keyboardShortcuts)
+    }
+
+    @ViewBuilder
+    private func detail(showInlineFilters: Bool) -> some View {
+        switch selection {
+        case .jobSearch:
+            AIJobSearchView(
+                viewModel: jobSearchVM,
+                showsInlineFilters: showInlineFilters,
+                onRequestFiltersSheet: { showFiltersSheet = true }
+            )
+        case .savedJobs:
+            SavedJobsView(viewModel: savedJobsVM) {
+                selection = .jobSearch
+            }
+        case .jobAlerts:
+            JobAlertsView(viewModel: alertsVM)
+        case .settings:
+            SettingsView(viewModel: settingsVM)
+        }
+    }
+
+    private var keyboardShortcuts: some View {
+        Button("") {
+            selection = .jobSearch
+            jobSearchVM.focusSearchField.toggle()
+        }
+        .keyboardShortcut("f", modifiers: .command)
+        .opacity(0)
+        .frame(width: 0, height: 0)
+        .accessibilityHidden(true)
+    }
+}
+
+#Preview("Root Shell") {
+    RootShellView(dependencies: .preview)
+        .frame(width: 1280, height: 820)
+}

+ 21 - 0
indeed_ai_job_search/App/indeed_ai_job_searchApp.swift

@@ -0,0 +1,21 @@
+//
+//  indeed_ai_job_searchApp.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+@main
+struct indeed_ai_job_searchApp: App {
+    @State private var dependencies = AppDependencies.preview
+
+    var body: some Scene {
+        WindowGroup {
+            RootShellView(dependencies: dependencies)
+        }
+        .defaultSize(width: 1280, height: 820)
+        .commands {
+            CommandGroup(replacing: .newItem) {}
+        }
+    }
+}

+ 9 - 0
indeed_ai_job_search/Assets.xcassets/AccentColor.colorset/Contents.json

@@ -1,6 +1,15 @@
 {
   "colors" : [
     {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : {
+          "alpha" : "1.000",
+          "blue" : "0.922",
+          "green" : "0.388",
+          "red" : "0.145"
+        }
+      },
       "idiom" : "universal"
     }
   ],

+ 0 - 24
indeed_ai_job_search/ContentView.swift

@@ -1,24 +0,0 @@
-//
-//  ContentView.swift
-//  indeed_ai_job_search
-//
-//  Created by Mqlmac4 on 12/10/2026.
-//
-
-import SwiftUI
-
-struct ContentView: View {
-    var body: some View {
-        VStack {
-            Image(systemName: "globe")
-                .imageScale(.large)
-                .foregroundStyle(.tint)
-            Text("Hello, world!")
-        }
-        .padding()
-    }
-}
-
-#Preview {
-    ContentView()
-}

+ 60 - 0
indeed_ai_job_search/DesignSystem/Components/ActiveFilterChipsView.swift

@@ -0,0 +1,60 @@
+//
+//  ActiveFilterChipsView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct ActiveFilterChipsView: View {
+    let chips: [FilterChip]
+    var onRemove: (FilterChip) -> Void
+    var onEditFilters: (() -> Void)? = nil
+
+    var body: some View {
+        HStack(spacing: AppSpacing.xs) {
+            ScrollView(.horizontal, showsIndicators: false) {
+                HStack(spacing: AppSpacing.xs) {
+                    ForEach(chips) { chip in
+                        chipView(chip)
+                    }
+                }
+            }
+
+            if let onEditFilters {
+                Button(action: onEditFilters) {
+                    Label("Edit Filters", systemImage: "slider.horizontal.3")
+                        .font(AppTypography.chip)
+                        .foregroundStyle(AppColors.brand)
+                }
+                .buttonStyle(.plain)
+                .fixedSize()
+            }
+        }
+    }
+
+    private func chipView(_ chip: FilterChip) -> some View {
+        HStack(spacing: 6) {
+            Text(chip.label)
+                .font(AppTypography.chip)
+                .foregroundStyle(AppColors.brandDark)
+
+            Button {
+                onRemove(chip)
+            } label: {
+                Image(systemName: "xmark")
+                    .font(.system(size: 9, weight: .bold))
+                    .foregroundStyle(AppColors.brand)
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("Remove \(chip.label) filter")
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 7)
+        .background(AppColors.brandSoft)
+        .clipShape(Capsule())
+        .overlay(
+            Capsule()
+                .stroke(AppColors.brandMuted, lineWidth: 1)
+        )
+    }
+}

+ 43 - 0
indeed_ai_job_search/DesignSystem/Components/ButtonStyles.swift

@@ -0,0 +1,43 @@
+//
+//  ButtonStyles.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct PrimaryButtonStyle: ButtonStyle {
+    @Environment(\.isEnabled) private var isEnabled
+
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .font(AppTypography.button)
+            .foregroundStyle(AppColors.textOnBrand)
+            .padding(.horizontal, AppSpacing.lg)
+            .padding(.vertical, 10)
+            .background(isEnabled ? AppColors.brand : AppColors.brand.opacity(0.45))
+            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
+            .opacity(configuration.isPressed ? 0.88 : 1)
+    }
+}
+
+struct SecondaryButtonStyle: ButtonStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .font(AppTypography.button)
+            .foregroundStyle(AppColors.brand)
+            .padding(.horizontal, AppSpacing.md)
+            .padding(.vertical, 8)
+            .background(AppColors.brandSoft)
+            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.buttonRadius, style: .continuous))
+            .opacity(configuration.isPressed ? 0.85 : 1)
+    }
+}
+
+struct GhostButtonStyle: ButtonStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .font(AppTypography.chip)
+            .foregroundStyle(AppColors.brand)
+            .opacity(configuration.isPressed ? 0.7 : 1)
+    }
+}

+ 134 - 0
indeed_ai_job_search/DesignSystem/Components/EmptyStateView.swift

@@ -0,0 +1,134 @@
+//
+//  EmptyStateView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct EmptyStateView: View {
+    let icon: String
+    let title: String
+    let message: String
+    var actionTitle: String? = nil
+    var action: (() -> Void)? = nil
+
+    var body: some View {
+        VStack(spacing: AppSpacing.md) {
+            Image(systemName: icon)
+                .font(.system(size: 36, weight: .light))
+                .foregroundStyle(AppColors.brand)
+                .frame(width: 64, height: 64)
+                .background(AppColors.brandSoft)
+                .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+
+            Text(title)
+                .font(AppTypography.sectionTitle)
+                .foregroundStyle(AppColors.textPrimary)
+
+            Text(message)
+                .font(AppTypography.body)
+                .foregroundStyle(AppColors.textSecondary)
+                .multilineTextAlignment(.center)
+                .frame(maxWidth: 360)
+
+            if let actionTitle, let action {
+                Button(actionTitle, action: action)
+                    .buttonStyle(PrimaryButtonStyle())
+                    .padding(.top, AppSpacing.xs)
+            }
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .padding(AppSpacing.xl)
+    }
+}
+
+struct ErrorStateView: View {
+    let message: String
+    var retry: (() -> Void)?
+
+    var body: some View {
+        EmptyStateView(
+            icon: "exclamationmark.triangle",
+            title: "Something went wrong",
+            message: message,
+            actionTitle: retry == nil ? nil : "Try again",
+            action: retry
+        )
+    }
+}
+
+struct LoadingSkeletonView: View {
+    var rowCount: Int = 5
+
+    var body: some View {
+        VStack(spacing: AppSpacing.sm) {
+            ForEach(0..<rowCount, id: \.self) { _ in
+                skeletonCard
+            }
+        }
+        .redacted(reason: .placeholder)
+        .shimmering()
+    }
+
+    private var skeletonCard: some View {
+        HStack(alignment: .top, spacing: AppSpacing.md) {
+            RoundedRectangle(cornerRadius: 10)
+                .fill(AppColors.border)
+                .frame(width: 44, height: 44)
+
+            VStack(alignment: .leading, spacing: AppSpacing.xs) {
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(AppColors.border)
+                    .frame(width: 220, height: 14)
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(AppColors.border)
+                    .frame(width: 140, height: 12)
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(AppColors.border)
+                    .frame(width: 180, height: 12)
+            }
+            Spacer()
+        }
+        .padding(AppSpacing.md)
+        .background(AppColors.surfaceCard)
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
+    }
+}
+
+private struct ShimmerModifier: ViewModifier {
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
+    @State private var phase: CGFloat = 0
+
+    func body(content: Content) -> some View {
+        if reduceMotion {
+            content
+        } else {
+            content
+                .overlay(
+                    LinearGradient(
+                        colors: [.clear, Color.white.opacity(0.35), .clear],
+                        startPoint: .leading,
+                        endPoint: .trailing
+                    )
+                    .rotationEffect(.degrees(12))
+                    .offset(x: phase)
+                )
+                .clipped()
+                .onAppear {
+                    withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
+                        phase = 280
+                    }
+                }
+        }
+    }
+}
+
+extension View {
+    func shimmering() -> some View {
+        modifier(ShimmerModifier())
+    }
+}

+ 181 - 0
indeed_ai_job_search/DesignSystem/Components/JobBadges.swift

@@ -0,0 +1,181 @@
+//
+//  JobBadges.swift
+//  indeed_ai_job_search
+//
+
+import AppKit
+import SwiftUI
+
+struct RemoteWorkBadge: View {
+    let type: RemoteType
+
+    var body: some View {
+        Label(type.displayName, systemImage: icon)
+            .font(AppTypography.meta)
+            .foregroundStyle(AppColors.textSecondary)
+            .labelStyle(.titleAndIcon)
+    }
+
+    private var icon: String {
+        switch type {
+        case .remote: return "house"
+        case .hybrid: return "building.2"
+        case .onsite: return "mappin.and.ellipse"
+        case .unknown: return "questionmark.circle"
+        }
+    }
+}
+
+struct SalaryLabel: View {
+    let salary: SalaryRange?
+
+    var body: some View {
+        if let text = salary?.formatted {
+            Text(text)
+                .font(AppTypography.salary)
+                .foregroundStyle(AppColors.salary)
+        }
+    }
+}
+
+struct RelativeDateLabel: View {
+    let date: Date
+
+    var body: some View {
+        Text(Self.relativeString(from: date))
+            .font(AppTypography.meta)
+            .foregroundStyle(AppColors.textTertiary)
+    }
+
+    static func relativeString(from date: Date) -> String {
+        let seconds = max(0, Int(Date().timeIntervalSince(date)))
+        let days = seconds / 86_400
+        let hours = seconds / 3_600
+
+        if days <= 0 {
+            if hours <= 0 { return "Just now" }
+            if hours == 1 { return "1 hour ago" }
+            return "\(hours) hours ago"
+        }
+        if days == 1 { return "1 day ago" }
+        if days < 7 { return "\(days) days ago" }
+        let weeks = days / 7
+        if weeks == 1 { return "1 week ago" }
+        return "\(weeks) weeks ago"
+    }
+}
+
+struct EasyApplyBadge: View {
+    var body: some View {
+        Label("Easy Apply", systemImage: "bolt.fill")
+            .font(AppTypography.caption)
+            .foregroundStyle(AppColors.easyApply)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(AppColors.brandSoft)
+            .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+    }
+}
+
+struct FeaturedBadge: View {
+    var body: some View {
+        Text("Featured")
+            .font(AppTypography.caption)
+            .foregroundStyle(AppColors.featuredText)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(AppColors.featured)
+            .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+    }
+}
+
+struct UrgentlyHiringBadge: View {
+    var body: some View {
+        Text("Urgently hiring")
+            .font(AppTypography.caption)
+            .foregroundStyle(AppColors.urgentText)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(AppColors.urgent)
+            .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+    }
+}
+
+struct UnavailableBadge: View {
+    var body: some View {
+        Text("Unavailable")
+            .font(AppTypography.caption)
+            .foregroundStyle(AppColors.unavailable)
+            .padding(.horizontal, 8)
+            .padding(.vertical, 4)
+            .background(AppColors.border)
+            .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+    }
+}
+
+struct CompanyLogoView: View {
+    let listing: JobListing
+
+    var body: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(logoBackground)
+            Image(systemName: listing.logoSymbol)
+                .font(.system(size: 18, weight: .semibold))
+                .foregroundStyle(logoForeground)
+        }
+        .frame(width: 44, height: 44)
+        .accessibilityHidden(true)
+    }
+
+    private var logoBackground: Color {
+        switch listing.company.lowercased() {
+        case "apple": return Color.black.opacity(0.08)
+        case "microsoft": return Color.blue.opacity(0.12)
+        default: return AppColors.brandSoft
+        }
+    }
+
+    private var logoForeground: Color {
+        switch listing.company.lowercased() {
+        case "apple": return AppColors.textPrimary
+        case "microsoft": return Color.blue
+        default: return AppColors.brand
+        }
+    }
+}
+
+struct SaveJobButton: View {
+    let isSaved: Bool
+    let action: () -> Void
+
+    var body: some View {
+        Button(action: action) {
+            Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
+                .font(.system(size: 14, weight: .medium))
+                .foregroundStyle(isSaved ? AppColors.brand : AppColors.textTertiary)
+                .frame(width: 28, height: 28)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .help(isSaved ? "Remove from saved jobs" : "Save job")
+        .accessibilityLabel(isSaved ? "Unsave job" : "Save job")
+    }
+}
+
+struct OpenIndeedLinkButton: View {
+    let url: URL
+
+    var body: some View {
+        Button {
+            NSWorkspace.shared.open(url)
+        } label: {
+            Label("Open on Indeed", systemImage: "arrow.up.right.square")
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.brand)
+        }
+        .buttonStyle(.plain)
+        .help("Open listing on Indeed")
+        .accessibilityLabel("Open on Indeed")
+    }
+}

+ 135 - 0
indeed_ai_job_search/DesignSystem/Components/JobRowView.swift

@@ -0,0 +1,135 @@
+//
+//  JobRowView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct JobRowView: View {
+    let listing: JobListing
+    let isSaved: Bool
+    var onToggleSave: () -> Void
+    var isCompact: Bool = false
+
+    private var isUnavailable: Bool {
+        listing.availability == .unavailable
+    }
+
+    var body: some View {
+        HStack(alignment: .top, spacing: AppSpacing.md) {
+            CompanyLogoView(listing: listing)
+                .opacity(isUnavailable ? 0.45 : 1)
+
+            VStack(alignment: .leading, spacing: AppSpacing.xs) {
+                HStack(alignment: .top) {
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(listing.title)
+                            .font(AppTypography.jobTitle)
+                            .foregroundStyle(isUnavailable ? AppColors.textTertiary : AppColors.textPrimary)
+                            .strikethrough(isUnavailable)
+                            .lineLimit(2)
+
+                        Text(listing.company)
+                            .font(AppTypography.company)
+                            .foregroundStyle(AppColors.textSecondary)
+                    }
+
+                    Spacer(minLength: AppSpacing.sm)
+
+                    SaveJobButton(isSaved: isSaved, action: onToggleSave)
+                }
+
+                metaRow
+
+                SalaryLabel(salary: listing.salary)
+
+                HStack(spacing: AppSpacing.xs) {
+                    if listing.isEasyApply { EasyApplyBadge() }
+                    if listing.isFeatured { FeaturedBadge() }
+                    if listing.isUrgentlyHiring { UrgentlyHiringBadge() }
+                    if isUnavailable { UnavailableBadge() }
+
+                    Spacer(minLength: 0)
+
+                    RelativeDateLabel(date: listing.postedAt)
+                }
+                .padding(.top, 2)
+            }
+        }
+        .padding(AppSpacing.md)
+        .background(AppColors.surfaceCard)
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
+        .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
+        .accessibilityElement(children: .contain)
+    }
+
+    private var metaRow: some View {
+        HStack(spacing: AppSpacing.sm) {
+            Label(listing.location, systemImage: "mappin")
+            Text("•")
+                .foregroundStyle(AppColors.textTertiary)
+            RemoteWorkBadge(type: listing.remoteType)
+            if let employment = listing.employmentType {
+                Text("•")
+                    .foregroundStyle(AppColors.textTertiary)
+                Label(employment.displayName, systemImage: "briefcase")
+            }
+        }
+        .font(AppTypography.meta)
+        .foregroundStyle(AppColors.textSecondary)
+        .lineLimit(1)
+    }
+}
+
+struct JobGridCardView: View {
+    let listing: JobListing
+    let isSaved: Bool
+    var onToggleSave: () -> Void
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            HStack {
+                CompanyLogoView(listing: listing)
+                Spacer()
+                SaveJobButton(isSaved: isSaved, action: onToggleSave)
+            }
+
+            Text(listing.title)
+                .font(AppTypography.jobTitle)
+                .foregroundStyle(AppColors.textPrimary)
+                .lineLimit(2)
+
+            Text(listing.company)
+                .font(AppTypography.company)
+                .foregroundStyle(AppColors.textSecondary)
+
+            Label(listing.location, systemImage: "mappin")
+                .font(AppTypography.meta)
+                .foregroundStyle(AppColors.textSecondary)
+                .lineLimit(1)
+
+            SalaryLabel(salary: listing.salary)
+
+            Spacer(minLength: 0)
+
+            HStack {
+                if listing.isEasyApply { EasyApplyBadge() }
+                Spacer()
+                RelativeDateLabel(date: listing.postedAt)
+            }
+        }
+        .padding(AppSpacing.md)
+        .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
+        .background(AppColors.surfaceCard)
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
+        .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
+    }
+}

+ 194 - 0
indeed_ai_job_search/DesignSystem/Components/SearchComposerView.swift

@@ -0,0 +1,194 @@
+//
+//  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)
+    }
+}

+ 44 - 0
indeed_ai_job_search/DesignSystem/Theme/AppColors.swift

@@ -0,0 +1,44 @@
+//
+//  AppColors.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+enum AppColors {
+    // Brand — Indeed-inspired blue from design
+    static let brand = Color(red: 0.145, green: 0.388, blue: 0.922) // #2557EB
+    static let brandDark = Color(red: 0.082, green: 0.255, blue: 0.651) // #1541A6
+    static let brandSoft = Color(red: 0.898, green: 0.933, blue: 0.988) // #E5EEFC
+    static let brandMuted = Color(red: 0.820, green: 0.886, blue: 0.976) // #D1E2F9
+
+    // Surfaces
+    static let backgroundPrimary = Color(red: 0.973, green: 0.976, blue: 0.980) // #F8F9FA
+    static let backgroundSidebar = Color(red: 0.953, green: 0.961, blue: 0.973) // #F3F5F8
+    static let surface = Color.white
+    static let surfaceElevated = Color.white
+    static let surfaceCard = Color.white
+    static let border = Color(red: 0.890, green: 0.906, blue: 0.925) // #E3E7EC
+    static let borderStrong = Color(red: 0.820, green: 0.843, blue: 0.875) // #D1D7DF
+
+    // Text
+    static let textPrimary = Color(red: 0.102, green: 0.122, blue: 0.153) // #1A1F27
+    static let textSecondary = Color(red: 0.420, green: 0.455, blue: 0.510) // #6B7482
+    static let textTertiary = Color(red: 0.580, green: 0.620, blue: 0.670) // #949EAB
+    static let textOnBrand = Color.white
+
+    // Semantic
+    static let salary = Color(red: 0.086, green: 0.580, blue: 0.373) // #16945F
+    static let featured = Color(red: 0.898, green: 0.933, blue: 0.988)
+    static let featuredText = Color(red: 0.145, green: 0.388, blue: 0.922)
+    static let urgent = Color(red: 1.0, green: 0.910, blue: 0.910) // #FFE8E8
+    static let urgentText = Color(red: 0.780, green: 0.220, blue: 0.250) // #C73840
+    static let easyApply = Color(red: 0.145, green: 0.388, blue: 0.922)
+    static let destructive = Color(red: 0.780, green: 0.220, blue: 0.250)
+    static let unavailable = Color(red: 0.580, green: 0.620, blue: 0.670)
+    static let success = Color(red: 0.086, green: 0.580, blue: 0.373)
+    static let warning = Color(red: 0.851, green: 0.549, blue: 0.118)
+
+    // Promo card
+    static let promoBackground = Color(red: 0.898, green: 0.933, blue: 0.988)
+}

+ 35 - 0
indeed_ai_job_search/DesignSystem/Theme/AppSpacing.swift

@@ -0,0 +1,35 @@
+//
+//  AppSpacing.swift
+//  indeed_ai_job_search
+//
+
+import CoreGraphics
+
+enum AppSpacing {
+    static let xxs: CGFloat = 4
+    static let xs: CGFloat = 8
+    static let sm: CGFloat = 12
+    static let md: CGFloat = 16
+    static let lg: CGFloat = 20
+    static let xl: CGFloat = 24
+    static let xxl: CGFloat = 32
+    static let xxxl: CGFloat = 40
+
+    static let sidebarWidth: CGFloat = 220
+    static let filtersWidth: CGFloat = 280
+    static let compactBreakpoint: CGFloat = 900
+    static let mediumBreakpoint: CGFloat = 1100
+    static let minWindowWidth: CGFloat = 720
+
+    static let cardRadius: CGFloat = 12
+    static let inputRadius: CGFloat = 10
+    static let chipRadius: CGFloat = 20
+    static let buttonRadius: CGFloat = 8
+    static let sidebarItemRadius: CGFloat = 10
+}
+
+enum AppShadow {
+    static let cardRadius: CGFloat = 8
+    static let cardY: CGFloat = 2
+    static let cardOpacity: Double = 0.06
+}

+ 24 - 0
indeed_ai_job_search/DesignSystem/Theme/AppTypography.swift

@@ -0,0 +1,24 @@
+//
+//  AppTypography.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+enum AppTypography {
+    static let brandTitle = Font.system(size: 15, weight: .semibold, design: .default)
+    static let sidebarItem = Font.system(size: 13, weight: .medium, design: .default)
+    static let sectionTitle = Font.system(size: 15, weight: .semibold, design: .default)
+    static let jobTitle = Font.system(size: 15, weight: .semibold, design: .default)
+    static let company = Font.system(size: 13, weight: .regular, design: .default)
+    static let body = Font.system(size: 13, weight: .regular, design: .default)
+    static let meta = Font.system(size: 12, weight: .regular, design: .default)
+    static let caption = Font.system(size: 11, weight: .medium, design: .default)
+    static let salary = Font.system(size: 13, weight: .semibold, design: .default)
+    static let button = Font.system(size: 13, weight: .semibold, design: .default)
+    static let chip = Font.system(size: 12, weight: .medium, design: .default)
+    static let resultsCount = Font.system(size: 13, weight: .semibold, design: .default)
+    static let filterHeader = Font.system(size: 13, weight: .semibold, design: .default)
+    static let heroTitle = Font.system(size: 28, weight: .bold, design: .default)
+    static let heroSubtitle = Font.system(size: 15, weight: .regular, design: .default)
+}

+ 172 - 0
indeed_ai_job_search/Domain/Enums/JobEnums.swift

@@ -0,0 +1,172 @@
+//
+//  JobEnums.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum RemoteType: String, CaseIterable, Identifiable, Codable, Hashable {
+    case remote
+    case hybrid
+    case onsite
+    case unknown
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .remote: return "Remote"
+        case .hybrid: return "Hybrid"
+        case .onsite: return "On-site"
+        case .unknown: return "Location varies"
+        }
+    }
+
+    var filterLabel: String {
+        switch self {
+        case .remote: return "Remote only"
+        case .hybrid: return "Hybrid"
+        case .onsite: return "On-site only"
+        case .unknown: return "Any"
+        }
+    }
+}
+
+enum EmploymentType: String, CaseIterable, Identifiable, Codable, Hashable {
+    case fullTime
+    case partTime
+    case contract
+    case temporary
+    case internship
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .fullTime: return "Full-time"
+        case .partTime: return "Part-time"
+        case .contract: return "Contract"
+        case .temporary: return "Temporary"
+        case .internship: return "Internship"
+        }
+    }
+}
+
+enum ExperienceLevel: String, CaseIterable, Identifiable, Codable, Hashable {
+    case entry
+    case junior
+    case mid
+    case senior
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .entry: return "Entry-level"
+        case .junior: return "Junior (1–3 years)"
+        case .mid: return "Mid-level (3–5 years)"
+        case .senior: return "Senior (5+ years)"
+        }
+    }
+
+    var shortLabel: String {
+        switch self {
+        case .entry: return "Entry-level"
+        case .junior: return "1–3 years"
+        case .mid: return "3–5 years"
+        case .senior: return "2+ years experience"
+        }
+    }
+}
+
+enum JobAvailability: String, CaseIterable, Codable, Hashable {
+    case open
+    case unknown
+    case unavailable
+}
+
+enum SalaryPeriod: String, Codable, Hashable {
+    case year
+    case month
+    case hour
+    case week
+
+    var displaySuffix: String {
+        switch self {
+        case .year: return "a year"
+        case .month: return "a month"
+        case .hour: return "an hour"
+        case .week: return "a week"
+        }
+    }
+}
+
+enum JobSortOption: String, CaseIterable, Identifiable {
+    case relevance
+    case date
+    case salary
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .relevance: return "Relevance"
+        case .date: return "Date"
+        case .salary: return "Salary"
+        }
+    }
+}
+
+enum ResultsLayout: String, CaseIterable, Identifiable {
+    case list
+    case grid
+
+    var id: String { rawValue }
+
+    var iconName: String {
+        switch self {
+        case .list: return "list.bullet"
+        case .grid: return "square.grid.2x2"
+        }
+    }
+}
+
+enum AlertFrequency: String, CaseIterable, Identifiable, Codable, Hashable {
+    case instant
+    case daily
+    case weekly
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .instant: return "Instant"
+        case .daily: return "Daily"
+        case .weekly: return "Weekly"
+        }
+    }
+}
+
+enum AppearanceMode: String, CaseIterable, Identifiable {
+    case system
+    case light
+    case dark
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .system: return "System"
+        case .light: return "Light"
+        case .dark: return "Dark"
+        }
+    }
+}
+
+enum SearchScreenState: Equatable {
+    case idle
+    case loading
+    case success
+    case empty
+    case error(String)
+}

+ 71 - 0
indeed_ai_job_search/Domain/Models/JobAlert.swift

@@ -0,0 +1,71 @@
+//
+//  JobAlert.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct JobAlert: Identifiable, Hashable {
+    let id: UUID
+    var name: String
+    var criteria: SearchCriteria
+    var frequency: AlertFrequency
+    var isEnabled: Bool
+    var lastRunAt: Date?
+    var matchCount: Int
+    var notifyInApp: Bool
+    var notifySystem: Bool
+
+    init(
+        id: UUID = UUID(),
+        name: String,
+        criteria: SearchCriteria,
+        frequency: AlertFrequency = .daily,
+        isEnabled: Bool = true,
+        lastRunAt: Date? = nil,
+        matchCount: Int = 0,
+        notifyInApp: Bool = true,
+        notifySystem: Bool = true
+    ) {
+        self.id = id
+        self.name = name
+        self.criteria = criteria
+        self.frequency = frequency
+        self.isEnabled = isEnabled
+        self.lastRunAt = lastRunAt
+        self.matchCount = matchCount
+        self.notifyInApp = notifyInApp
+        self.notifySystem = notifySystem
+    }
+
+    var criteriaSummary: String {
+        var parts: [String] = []
+        if !criteria.jobTitle.isEmpty { parts.append(criteria.jobTitle) }
+        else if !criteria.keywords.isEmpty { parts.append(criteria.keywords) }
+        if !criteria.country.isEmpty { parts.append(criteria.country) }
+        if let first = criteria.remoteTypes.first { parts.append(first.displayName) }
+        return parts.isEmpty ? "All jobs" : parts.joined(separator: " · ")
+    }
+}
+
+struct AlertNotificationEvent: Identifiable, Hashable {
+    let id: UUID
+    var alertName: String
+    var message: String
+    var createdAt: Date
+    var isRead: Bool
+
+    init(
+        id: UUID = UUID(),
+        alertName: String,
+        message: String,
+        createdAt: Date = .now,
+        isRead: Bool = false
+    ) {
+        self.id = id
+        self.alertName = alertName
+        self.message = message
+        self.createdAt = createdAt
+        self.isRead = isRead
+    }
+}

+ 101 - 0
indeed_ai_job_search/Domain/Models/JobListing.swift

@@ -0,0 +1,101 @@
+//
+//  JobListing.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct SalaryRange: Hashable, Codable {
+    var min: Double?
+    var max: Double?
+    var currency: String
+    var period: SalaryPeriod
+
+    var formatted: String? {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .currency
+        formatter.currencyCode = currency
+        formatter.maximumFractionDigits = 0
+
+        let minText = min.flatMap { formatter.string(from: NSNumber(value: $0)) }
+        let maxText = max.flatMap { formatter.string(from: NSNumber(value: $0)) }
+
+        switch (minText, maxText) {
+        case let (min?, max?):
+            return "\(min) – \(max) \(period.displaySuffix)"
+        case let (min?, nil):
+            return "From \(min) \(period.displaySuffix)"
+        case let (nil, max?):
+            return "Up to \(max) \(period.displaySuffix)"
+        default:
+            return nil
+        }
+    }
+}
+
+struct JobListing: Identifiable, Hashable, Codable {
+    let id: UUID
+    let indeedJobKey: String
+    var title: String
+    var company: String
+    var companyInitials: String
+    var location: String
+    var remoteType: RemoteType
+    var employmentType: EmploymentType?
+    var experienceLevel: ExperienceLevel?
+    var salary: SalaryRange?
+    var postedAt: Date
+    var snippet: String
+    var indeedURL: URL
+    var availability: JobAvailability
+    var isEasyApply: Bool
+    var isFeatured: Bool
+    var isUrgentlyHiring: Bool
+    var logoSymbol: String
+
+    init(
+        id: UUID = UUID(),
+        indeedJobKey: String,
+        title: String,
+        company: String,
+        companyInitials: String? = nil,
+        location: String,
+        remoteType: RemoteType,
+        employmentType: EmploymentType? = nil,
+        experienceLevel: ExperienceLevel? = nil,
+        salary: SalaryRange? = nil,
+        postedAt: Date,
+        snippet: String,
+        indeedURL: URL,
+        availability: JobAvailability = .open,
+        isEasyApply: Bool = false,
+        isFeatured: Bool = false,
+        isUrgentlyHiring: Bool = false,
+        logoSymbol: String = "building.2.fill"
+    ) {
+        self.id = id
+        self.indeedJobKey = indeedJobKey
+        self.title = title
+        self.company = company
+        self.companyInitials = companyInitials ?? Self.initials(from: company)
+        self.location = location
+        self.remoteType = remoteType
+        self.employmentType = employmentType
+        self.experienceLevel = experienceLevel
+        self.salary = salary
+        self.postedAt = postedAt
+        self.snippet = snippet
+        self.indeedURL = indeedURL
+        self.availability = availability
+        self.isEasyApply = isEasyApply
+        self.isFeatured = isFeatured
+        self.isUrgentlyHiring = isUrgentlyHiring
+        self.logoSymbol = logoSymbol
+    }
+
+    private static func initials(from name: String) -> String {
+        let parts = name.split(separator: " ").prefix(2)
+        let letters = parts.compactMap { $0.first.map(String.init) }
+        return letters.joined().uppercased()
+    }
+}

+ 17 - 0
indeed_ai_job_search/Domain/Models/SavedJob.swift

@@ -0,0 +1,17 @@
+//
+//  SavedJob.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct SavedJob: Identifiable, Hashable {
+    var id: UUID { listing.id }
+    var listing: JobListing
+    var savedAt: Date
+    var lastVerifiedAt: Date?
+
+    var availability: JobAvailability {
+        listing.availability
+    }
+}

+ 109 - 0
indeed_ai_job_search/Domain/Models/SearchCriteria.swift

@@ -0,0 +1,109 @@
+//
+//  SearchCriteria.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+struct SearchCriteria: Hashable, Codable {
+    var keywords: String = ""
+    var jobTitle: String = ""
+    var exactPhrase: Bool = false
+    var excludedKeywords: String = ""
+    var country: String = "Pakistan"
+    var city: String = ""
+    var remoteTypes: Set<RemoteType> = []
+    var employmentTypes: Set<EmploymentType> = []
+    var experienceLevels: Set<ExperienceLevel> = []
+    var salaryFloor: Double?
+    var dateFreshnessDays: Int?
+
+    var activeChips: [FilterChip] {
+        var chips: [FilterChip] = []
+
+        if !jobTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            chips.append(FilterChip(id: "title", label: jobTitle, kind: .title))
+        } else if !keywords.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            chips.append(FilterChip(id: "keywords", label: keywords, kind: .keywords))
+        }
+
+        for type in remoteTypes.sorted(by: { $0.rawValue < $1.rawValue }) where type != .unknown {
+            chips.append(FilterChip(id: "remote-\(type.rawValue)", label: type.displayName, kind: .remote(type)))
+        }
+
+        if !country.isEmpty {
+            chips.append(FilterChip(id: "country", label: country, kind: .location))
+        }
+
+        if !city.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            chips.append(FilterChip(id: "city", label: city, kind: .location))
+        }
+
+        for type in employmentTypes.sorted(by: { $0.rawValue < $1.rawValue }) {
+            chips.append(FilterChip(id: "emp-\(type.rawValue)", label: type.displayName, kind: .employment(type)))
+        }
+
+        for level in experienceLevels.sorted(by: { $0.rawValue < $1.rawValue }) {
+            chips.append(FilterChip(id: "exp-\(level.rawValue)", label: level.shortLabel, kind: .experience(level)))
+        }
+
+        if let salaryFloor {
+            let formatted = NumberFormatter.localizedString(from: NSNumber(value: salaryFloor), number: .currency)
+            chips.append(FilterChip(id: "salary", label: "\(formatted)+", kind: .salary))
+        }
+
+        return chips
+    }
+
+    mutating func removeChip(_ chip: FilterChip) {
+        switch chip.kind {
+        case .title:
+            jobTitle = ""
+        case .keywords:
+            keywords = ""
+        case .remote(let type):
+            remoteTypes.remove(type)
+        case .location:
+            if chip.id == "city" {
+                city = ""
+            } else if chip.id == "country" {
+                country = ""
+            }
+        case .employment(let type):
+            employmentTypes.remove(type)
+        case .experience(let level):
+            experienceLevels.remove(level)
+        case .salary:
+            salaryFloor = nil
+        case .exclude:
+            excludedKeywords = ""
+        }
+    }
+
+    mutating func reset() {
+        self = SearchCriteria()
+    }
+}
+
+struct FilterChip: Identifiable, Hashable {
+    enum Kind: Hashable {
+        case title
+        case keywords
+        case remote(RemoteType)
+        case location
+        case employment(EmploymentType)
+        case experience(ExperienceLevel)
+        case salary
+        case exclude
+    }
+
+    let id: String
+    let label: String
+    let kind: Kind
+}
+
+struct InterpretedSearch: Hashable {
+    var naturalLanguageQuery: String
+    var criteria: SearchCriteria
+    var confidenceNote: String
+}

+ 34 - 0
indeed_ai_job_search/Domain/Protocols/JobSearchProtocols.swift

@@ -0,0 +1,34 @@
+//
+//  JobSearchProtocols.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+protocol JobDataSource: Sendable {
+    func search(criteria: SearchCriteria, query: String) async throws -> [JobListing]
+}
+
+protocol OpenAISearchService: Sendable {
+    func interpret(query: String) async throws -> InterpretedSearch
+}
+
+protocol JobValidationService: Sendable {
+    func validate(listing: JobListing) async -> JobAvailability
+}
+
+protocol SavedJobsStore: AnyObject {
+    var savedJobs: [SavedJob] { get }
+    func isSaved(_ listing: JobListing) -> Bool
+    func toggleSave(_ listing: JobListing)
+    func remove(_ listing: JobListing)
+}
+
+protocol AlertsStore: AnyObject {
+    var alerts: [JobAlert] { get }
+    var events: [AlertNotificationEvent] { get }
+    func upsert(_ alert: JobAlert)
+    func delete(_ alert: JobAlert)
+    func setEnabled(_ alert: JobAlert, enabled: Bool)
+    func markEventsRead()
+}

+ 92 - 0
indeed_ai_job_search/Features/JobAlerts/ViewModels/JobAlertsViewModel.swift

@@ -0,0 +1,92 @@
+//
+//  JobAlertsViewModel.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+import Observation
+
+@Observable
+@MainActor
+final class JobAlertsViewModel {
+    var showEditor = false
+    var showNotificationCenter = false
+    var editingAlert: JobAlert?
+    var draftName = ""
+    var draftCriteria = SearchCriteria()
+    var draftFrequency: AlertFrequency = .daily
+    var draftNotifyInApp = true
+    var draftNotifySystem = true
+
+    private let store: MockAlertsStore
+
+    init(dependencies: AppDependencies) {
+        self.store = dependencies.alertsStore
+    }
+
+    var alerts: [JobAlert] { store.alerts }
+    var events: [AlertNotificationEvent] { store.events }
+
+    var unreadCount: Int {
+        events.filter { !$0.isRead }.count
+    }
+
+    func createNew() {
+        editingAlert = nil
+        draftName = ""
+        draftCriteria = SearchCriteria()
+        draftCriteria.country = "Pakistan"
+        draftFrequency = .daily
+        draftNotifyInApp = true
+        draftNotifySystem = true
+        showEditor = true
+    }
+
+    func edit(_ alert: JobAlert) {
+        editingAlert = alert
+        draftName = alert.name
+        draftCriteria = alert.criteria
+        draftFrequency = alert.frequency
+        draftNotifyInApp = alert.notifyInApp
+        draftNotifySystem = alert.notifySystem
+        showEditor = true
+    }
+
+    func saveDraft() {
+        let name = draftName.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !name.isEmpty else { return }
+
+        if var existing = editingAlert {
+            existing.name = name
+            existing.criteria = draftCriteria
+            existing.frequency = draftFrequency
+            existing.notifyInApp = draftNotifyInApp
+            existing.notifySystem = draftNotifySystem
+            store.upsert(existing)
+        } else {
+            store.upsert(
+                JobAlert(
+                    name: name,
+                    criteria: draftCriteria,
+                    frequency: draftFrequency,
+                    notifyInApp: draftNotifyInApp,
+                    notifySystem: draftNotifySystem
+                )
+            )
+        }
+        showEditor = false
+    }
+
+    func delete(_ alert: JobAlert) {
+        store.delete(alert)
+    }
+
+    func toggleEnabled(_ alert: JobAlert) {
+        store.setEnabled(alert, enabled: !alert.isEnabled)
+    }
+
+    func openNotificationCenter() {
+        showNotificationCenter = true
+        store.markEventsRead()
+    }
+}

+ 250 - 0
indeed_ai_job_search/Features/JobAlerts/Views/JobAlertsView.swift

@@ -0,0 +1,250 @@
+//
+//  JobAlertsView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct JobAlertsView: View {
+    @Bindable var viewModel: JobAlertsViewModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            header
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.top, AppSpacing.lg)
+                .padding(.bottom, AppSpacing.md)
+
+            content
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.lg)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.backgroundPrimary)
+        .sheet(isPresented: $viewModel.showEditor) {
+            AlertEditorSheet(viewModel: viewModel)
+        }
+        .sheet(isPresented: $viewModel.showNotificationCenter) {
+            NotificationCenterSheet(events: viewModel.events)
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 4) {
+                Text("Job Alerts")
+                    .font(AppTypography.sectionTitle)
+                    .foregroundStyle(AppColors.textPrimary)
+                Text("Get notified when new Indeed jobs match your criteria.")
+                    .font(AppTypography.body)
+                    .foregroundStyle(AppColors.textSecondary)
+            }
+
+            Spacer()
+
+            HStack(spacing: AppSpacing.sm) {
+                Button {
+                    viewModel.openNotificationCenter()
+                } label: {
+                    Label {
+                        Text("Notifications")
+                    } icon: {
+                        ZStack(alignment: .topTrailing) {
+                            Image(systemName: "bell")
+                            if viewModel.unreadCount > 0 {
+                                Circle()
+                                    .fill(AppColors.brand)
+                                    .frame(width: 8, height: 8)
+                                    .offset(x: 3, y: -2)
+                            }
+                        }
+                    }
+                }
+                .buttonStyle(SecondaryButtonStyle())
+
+                Button("Create alert") {
+                    viewModel.createNew()
+                }
+                .buttonStyle(PrimaryButtonStyle())
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var content: some View {
+        if viewModel.alerts.isEmpty {
+            EmptyStateView(
+                icon: "bell.badge",
+                title: "No alerts yet",
+                message: "Create an alert to track new Indeed listings that match your search.",
+                actionTitle: "Create alert",
+                action: { viewModel.createNew() }
+            )
+        } else {
+            ScrollView {
+                LazyVStack(spacing: AppSpacing.sm) {
+                    ForEach(viewModel.alerts) { alert in
+                        alertCard(alert)
+                    }
+                }
+            }
+        }
+    }
+
+    private func alertCard(_ alert: JobAlert) -> some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            HStack {
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(alert.name)
+                        .font(AppTypography.jobTitle)
+                        .foregroundStyle(AppColors.textPrimary)
+                    Text(alert.criteriaSummary)
+                        .font(AppTypography.meta)
+                        .foregroundStyle(AppColors.textSecondary)
+                        .lineLimit(2)
+                }
+
+                Spacer()
+
+                Text(alert.isEnabled ? "Active" : "Paused")
+                    .font(AppTypography.caption)
+                    .foregroundStyle(alert.isEnabled ? AppColors.success : AppColors.textTertiary)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(alert.isEnabled ? AppColors.success.opacity(0.12) : AppColors.border)
+                    .clipShape(Capsule())
+            }
+
+            HStack(spacing: AppSpacing.md) {
+                Label(alert.frequency.displayName, systemImage: "clock")
+                if let lastRun = alert.lastRunAt {
+                    Label("Last run \(RelativeDateLabel.relativeString(from: lastRun))", systemImage: "arrow.clockwise")
+                }
+                Label("\(alert.matchCount) matches", systemImage: "briefcase")
+                Spacer()
+            }
+            .font(AppTypography.meta)
+            .foregroundStyle(AppColors.textSecondary)
+
+            HStack {
+                Button(alert.isEnabled ? "Pause" : "Resume") {
+                    viewModel.toggleEnabled(alert)
+                }
+                .buttonStyle(SecondaryButtonStyle())
+
+                Button("Edit") {
+                    viewModel.edit(alert)
+                }
+                .buttonStyle(GhostButtonStyle())
+
+                Button("Delete", role: .destructive) {
+                    viewModel.delete(alert)
+                }
+                .buttonStyle(GhostButtonStyle())
+                .foregroundStyle(AppColors.destructive)
+            }
+        }
+        .padding(AppSpacing.md)
+        .background(AppColors.surfaceCard)
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
+    }
+}
+
+struct AlertEditorSheet: View {
+    @Bindable var viewModel: JobAlertsViewModel
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack {
+                Text(viewModel.editingAlert == nil ? "Create alert" : "Edit alert")
+                    .font(AppTypography.sectionTitle)
+                Spacer()
+                Button("Cancel") {
+                    dismiss()
+                }
+                .buttonStyle(GhostButtonStyle())
+            }
+            .padding(AppSpacing.lg)
+
+            Divider()
+
+            Form {
+                TextField("Alert name", text: $viewModel.draftName)
+                TextField("Job title", text: $viewModel.draftCriteria.jobTitle)
+                TextField("Keywords", text: $viewModel.draftCriteria.keywords)
+                TextField("Country", text: $viewModel.draftCriteria.country)
+                TextField("City", text: $viewModel.draftCriteria.city)
+
+                Picker("Frequency", selection: $viewModel.draftFrequency) {
+                    ForEach(AlertFrequency.allCases) { frequency in
+                        Text(frequency.displayName).tag(frequency)
+                    }
+                }
+
+                Toggle("In-app notifications", isOn: $viewModel.draftNotifyInApp)
+                Toggle("System notifications", isOn: $viewModel.draftNotifySystem)
+            }
+            .formStyle(.grouped)
+            .padding(.horizontal, AppSpacing.sm)
+
+            HStack {
+                Spacer()
+                Button("Save alert") {
+                    viewModel.saveDraft()
+                    dismiss()
+                }
+                .buttonStyle(PrimaryButtonStyle())
+                .disabled(viewModel.draftName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+            }
+            .padding(AppSpacing.lg)
+        }
+        .frame(minWidth: 480, minHeight: 520)
+    }
+}
+
+struct NotificationCenterSheet: View {
+    let events: [AlertNotificationEvent]
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            HStack {
+                Text("Notification center")
+                    .font(AppTypography.sectionTitle)
+                Spacer()
+                Button("Done") { dismiss() }
+                    .buttonStyle(GhostButtonStyle())
+            }
+            .padding(AppSpacing.lg)
+
+            Divider()
+
+            if events.isEmpty {
+                EmptyStateView(
+                    icon: "bell.slash",
+                    title: "No notifications",
+                    message: "Alert activity will appear here."
+                )
+            } else {
+                List(events) { event in
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text(event.alertName)
+                            .font(AppTypography.filterHeader)
+                        Text(event.message)
+                            .font(AppTypography.body)
+                            .foregroundStyle(AppColors.textSecondary)
+                        RelativeDateLabel(date: event.createdAt)
+                    }
+                    .padding(.vertical, 4)
+                    .listRowBackground(event.isRead ? Color.clear : AppColors.brandSoft.opacity(0.45))
+                }
+            }
+        }
+        .frame(minWidth: 420, minHeight: 460)
+    }
+}

+ 176 - 0
indeed_ai_job_search/Features/JobSearch/ViewModels/JobSearchViewModel.swift

@@ -0,0 +1,176 @@
+//
+//  JobSearchViewModel.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+import Observation
+
+@Observable
+@MainActor
+final class JobSearchViewModel {
+    var query: String = ""
+    var criteria = SearchCriteria()
+    var results: [JobListing] = []
+    var state: SearchScreenState = .idle
+    var sortOption: JobSortOption = .relevance
+    var layout: ResultsLayout = .list
+    var hasSearched = false
+    var showFiltersInspector = true
+    var focusSearchField = false
+    var totalResultCount: Int = 0
+    /// Bumps when save state changes so SwiftUI refreshes bookmark icons.
+    private(set) var saveVersion = 0
+
+    let suggestedPrompts = [
+        "Remote iOS developer jobs in Pakistan",
+        "Senior SwiftUI engineer, hybrid Lahore",
+        "Full-time junior iOS roles in Karachi",
+        "Contract mobile developer remote",
+        "Entry-level iOS internship Pakistan"
+    ]
+
+    private let dataSource: JobDataSource
+    private let interpreter: OpenAISearchService
+    private let savedStore: MockSavedJobsStore
+    private var searchTask: Task<Void, Never>?
+    private var filterDebounceTask: Task<Void, Never>?
+
+    init(dependencies: AppDependencies) {
+        self.dataSource = dependencies.jobDataSource
+        self.interpreter = dependencies.searchInterpreter
+        self.savedStore = dependencies.savedJobsStore
+    }
+
+    func isSaved(_ listing: JobListing) -> Bool {
+        _ = saveVersion
+        return savedStore.isSaved(listing)
+    }
+
+    var displayedResults: [JobListing] {
+        switch sortOption {
+        case .relevance:
+            return results
+        case .date:
+            return results.sorted { $0.postedAt > $1.postedAt }
+        case .salary:
+            return results.sorted {
+                ($0.salary?.max ?? $0.salary?.min ?? 0) > ($1.salary?.max ?? $1.salary?.min ?? 0)
+            }
+        }
+    }
+
+    var activeChips: [FilterChip] {
+        criteria.activeChips
+    }
+
+    func selectPrompt(_ prompt: String) {
+        query = prompt
+        submitSearch()
+    }
+
+    func submitSearch() {
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return }
+
+        searchTask?.cancel()
+        searchTask = Task {
+            state = .loading
+            hasSearched = true
+
+            do {
+                let interpreted = try await interpreter.interpret(query: trimmed)
+                if Task.isCancelled { return }
+
+                // Merge AI interpretation with any user-edited filters already set.
+                var merged = interpreted.criteria
+                if !criteria.excludedKeywords.isEmpty {
+                    merged.excludedKeywords = criteria.excludedKeywords
+                }
+                if criteria.salaryFloor != nil {
+                    merged.salaryFloor = criteria.salaryFloor
+                }
+                // Prefer explicit user filter edits when present.
+                if !criteria.employmentTypes.isEmpty {
+                    merged.employmentTypes = criteria.employmentTypes
+                }
+                if !criteria.experienceLevels.isEmpty {
+                    merged.experienceLevels = criteria.experienceLevels
+                }
+                if !criteria.remoteTypes.isEmpty {
+                    merged.remoteTypes = criteria.remoteTypes
+                }
+                if !criteria.city.isEmpty {
+                    merged.city = criteria.city
+                }
+                if !criteria.country.isEmpty {
+                    merged.country = criteria.country
+                }
+                if !criteria.jobTitle.isEmpty {
+                    merged.jobTitle = criteria.jobTitle
+                }
+
+                criteria = merged
+                let listings = try await dataSource.search(criteria: criteria, query: trimmed)
+                if Task.isCancelled { return }
+
+                results = listings
+                totalResultCount = max(listings.count, listings.isEmpty ? 0 : 1_248)
+                state = listings.isEmpty ? .empty : .success
+            } catch is CancellationError {
+                return
+            } catch {
+                state = .error(error.localizedDescription)
+            }
+        }
+    }
+
+    func applyFiltersAndRefresh() {
+        guard hasSearched else { return }
+        filterDebounceTask?.cancel()
+        filterDebounceTask = Task {
+            try? await Task.sleep(nanoseconds: 350_000_000)
+            if Task.isCancelled { return }
+            refreshResultsOnly()
+        }
+    }
+
+    func removeChip(_ chip: FilterChip) {
+        criteria.removeChip(chip)
+        applyFiltersAndRefresh()
+    }
+
+    func resetFilters() {
+        criteria.reset()
+        applyFiltersAndRefresh()
+    }
+
+    /// Re-runs the mock Indeed search with current criteria without re-interpreting the query.
+    private func refreshResultsOnly() {
+        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        searchTask?.cancel()
+        searchTask = Task {
+            state = .loading
+            do {
+                let listings = try await dataSource.search(criteria: criteria, query: trimmed)
+                if Task.isCancelled { return }
+                results = listings
+                totalResultCount = listings.isEmpty ? 0 : max(listings.count, 1_248)
+                state = listings.isEmpty ? .empty : .success
+            } catch is CancellationError {
+                return
+            } catch {
+                state = .error(error.localizedDescription)
+            }
+        }
+    }
+
+    func toggleSave(_ listing: JobListing) {
+        savedStore.toggleSave(listing)
+        saveVersion += 1
+    }
+
+    func retry() {
+        submitSearch()
+    }
+}

+ 231 - 0
indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift

@@ -0,0 +1,231 @@
+//
+//  AIJobSearchView.swift
+//  indeed_ai_job_search
+//
+
+import AppKit
+import SwiftUI
+
+struct AIJobSearchView: View {
+    @Bindable var viewModel: JobSearchViewModel
+    var showsInlineFilters: Bool
+    var onRequestFiltersSheet: (() -> Void)? = nil
+
+    @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+    var body: some View {
+        HStack(spacing: 0) {
+            mainColumn
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+            if showsInlineFilters, viewModel.hasSearched {
+                Divider().foregroundStyle(AppColors.border)
+                FiltersPanelView(
+                    criteria: $viewModel.criteria,
+                    onReset: { viewModel.resetFilters() },
+                    onChange: { viewModel.applyFiltersAndRefresh() }
+                )
+                .frame(width: AppSpacing.filtersWidth)
+                .transition(reduceMotion ? .opacity : .move(edge: .trailing).combined(with: .opacity))
+            }
+        }
+        .background(AppColors.backgroundPrimary)
+        .animation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(response: 0.35, dampingFraction: 0.86), value: viewModel.hasSearched)
+    }
+
+    @ViewBuilder
+    private var mainColumn: some View {
+        if !viewModel.hasSearched {
+            SearchComposerView(
+                mode: .hero,
+                query: $viewModel.query,
+                isSearching: viewModel.state == .loading,
+                suggestedPrompts: viewModel.suggestedPrompts,
+                onSubmit: { viewModel.submitSearch() },
+                onSelectPrompt: { viewModel.selectPrompt($0) },
+                focusTrigger: viewModel.focusSearchField
+            )
+            .background(AppColors.backgroundPrimary)
+        } else {
+            resultsContent
+        }
+    }
+
+    private var resultsContent: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            searchHeader
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.top, AppSpacing.lg)
+                .padding(.bottom, AppSpacing.md)
+
+            if !viewModel.activeChips.isEmpty {
+                ActiveFilterChipsView(
+                    chips: viewModel.activeChips,
+                    onRemove: { viewModel.removeChip($0) },
+                    onEditFilters: showsInlineFilters ? nil : onRequestFiltersSheet
+                )
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.md)
+            } else if !showsInlineFilters {
+                HStack {
+                    Spacer()
+                    Button {
+                        onRequestFiltersSheet?()
+                    } label: {
+                        Label("Edit Filters", systemImage: "slider.horizontal.3")
+                            .font(AppTypography.chip)
+                            .foregroundStyle(AppColors.brand)
+                    }
+                    .buttonStyle(.plain)
+                }
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.md)
+            }
+
+            resultsToolbar
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.sm)
+
+            resultsBody
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.lg)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.backgroundPrimary)
+    }
+
+    private var searchHeader: some View {
+        SearchComposerView(
+            mode: .compact,
+            query: $viewModel.query,
+            isSearching: viewModel.state == .loading,
+            onSubmit: { viewModel.submitSearch() },
+            focusTrigger: viewModel.focusSearchField
+        )
+    }
+
+    private var resultsToolbar: some View {
+        HStack {
+            Group {
+                switch viewModel.state {
+                case .loading:
+                    Text("Searching Indeed…")
+                case .success, .empty:
+                    Text("\(viewModel.totalResultCount.formatted()) jobs found")
+                case .error:
+                    Text("Search unavailable")
+                case .idle:
+                    Text("Ready")
+                }
+            }
+            .font(AppTypography.resultsCount)
+            .foregroundStyle(AppColors.textPrimary)
+
+            Spacer()
+
+            HStack(spacing: AppSpacing.sm) {
+                Menu {
+                    ForEach(JobSortOption.allCases) { option in
+                        Button(option.displayName) {
+                            viewModel.sortOption = option
+                        }
+                    }
+                } label: {
+                    HStack(spacing: 4) {
+                        Text("Sort by:")
+                            .foregroundStyle(AppColors.textSecondary)
+                        Text(viewModel.sortOption.displayName)
+                            .foregroundStyle(AppColors.textPrimary)
+                        Image(systemName: "chevron.down")
+                            .font(.system(size: 9, weight: .semibold))
+                            .foregroundStyle(AppColors.textTertiary)
+                    }
+                    .font(AppTypography.meta)
+                }
+                .menuStyle(.borderlessButton)
+
+                HStack(spacing: 2) {
+                    ForEach(ResultsLayout.allCases) { layout in
+                        Button {
+                            viewModel.layout = layout
+                        } label: {
+                            Image(systemName: layout.iconName)
+                                .font(.system(size: 12, weight: .medium))
+                                .foregroundStyle(viewModel.layout == layout ? AppColors.brand : AppColors.textTertiary)
+                                .frame(width: 28, height: 28)
+                                .background(viewModel.layout == layout ? AppColors.brandSoft : Color.clear)
+                                .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+                        }
+                        .buttonStyle(.plain)
+                        .help(layout == .list ? "List view" : "Grid view")
+                    }
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var resultsBody: some View {
+        switch viewModel.state {
+        case .idle:
+            EmptyView()
+        case .loading:
+            ScrollView {
+                LoadingSkeletonView()
+            }
+        case .empty:
+            EmptyStateView(
+                icon: "magnifyingglass",
+                title: "No jobs found",
+                message: "Try adjusting filters or rewriting your search to find more Indeed listings.",
+                actionTitle: "Edit filters",
+                action: {
+                    if showsInlineFilters {
+                        viewModel.showFiltersInspector = true
+                    } else {
+                        onRequestFiltersSheet?()
+                    }
+                }
+            )
+        case .error(let message):
+            ErrorStateView(message: message) {
+                viewModel.retry()
+            }
+        case .success:
+            ScrollView {
+                if viewModel.layout == .list {
+                    LazyVStack(spacing: AppSpacing.sm) {
+                        ForEach(viewModel.displayedResults) { listing in
+                            JobRowView(
+                                listing: listing,
+                                isSaved: viewModel.isSaved(listing),
+                                onToggleSave: { viewModel.toggleSave(listing) }
+                            )
+                            .contextMenu {
+                                Button("Open on Indeed") {
+                                    NSWorkspace.shared.open(listing.indeedURL)
+                                }
+                                Button(viewModel.isSaved(listing) ? "Unsave" : "Save") {
+                                    viewModel.toggleSave(listing)
+                                }
+                            }
+                        }
+                    }
+                } else {
+                    LazyVGrid(
+                        columns: [GridItem(.adaptive(minimum: 240), spacing: AppSpacing.sm)],
+                        spacing: AppSpacing.sm
+                    ) {
+                        ForEach(viewModel.displayedResults) { listing in
+                            JobGridCardView(
+                                listing: listing,
+                                isSaved: viewModel.isSaved(listing),
+                                onToggleSave: { viewModel.toggleSave(listing) }
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 246 - 0
indeed_ai_job_search/Features/JobSearch/Views/FiltersPanelView.swift

@@ -0,0 +1,246 @@
+//
+//  FiltersPanelView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct FiltersPanelView: View {
+    @Binding var criteria: SearchCriteria
+    var onReset: () -> Void
+    var onChange: () -> Void
+
+    @State private var expanded: Set<FilterSection> = [
+        .jobTitle, .exclude, .location, .employment, .experience
+    ]
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            header
+                .padding(.horizontal, AppSpacing.md)
+                .padding(.top, AppSpacing.lg)
+                .padding(.bottom, AppSpacing.md)
+
+            Divider().foregroundStyle(AppColors.border)
+
+            ScrollView {
+                VStack(spacing: 0) {
+                    accordion(.jobTitle) { jobTitleSection }
+                    accordion(.exclude) { excludeSection }
+                    accordion(.location) { locationSection }
+                    accordion(.employment) { employmentSection }
+                    accordion(.experience) { experienceSection }
+                    accordion(.salary) { salarySection }
+                }
+                .padding(.bottom, AppSpacing.xl)
+            }
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.surface)
+    }
+
+    private var header: some View {
+        HStack {
+            Text("Filters")
+                .font(AppTypography.sectionTitle)
+                .foregroundStyle(AppColors.textPrimary)
+            Spacer()
+            Button("Reset all", action: onReset)
+                .buttonStyle(GhostButtonStyle())
+        }
+    }
+
+    private func accordion<Content: View>(
+        _ section: FilterSection,
+        @ViewBuilder content: () -> Content
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 0) {
+            Button {
+                withAnimation(.easeInOut(duration: 0.2)) {
+                    if expanded.contains(section) {
+                        expanded.remove(section)
+                    } else {
+                        expanded.insert(section)
+                    }
+                }
+            } label: {
+                HStack {
+                    Text(section.title)
+                        .font(AppTypography.filterHeader)
+                        .foregroundStyle(AppColors.textPrimary)
+                    Spacer()
+                    Image(systemName: "chevron.down")
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(AppColors.textTertiary)
+                        .rotationEffect(.degrees(expanded.contains(section) ? 0 : -90))
+                }
+                .padding(.horizontal, AppSpacing.md)
+                .padding(.vertical, 14)
+                .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+
+            if expanded.contains(section) {
+                content()
+                    .padding(.horizontal, AppSpacing.md)
+                    .padding(.bottom, AppSpacing.md)
+                    .transition(.opacity.combined(with: .move(edge: .top)))
+            }
+
+            Divider().foregroundStyle(AppColors.border)
+        }
+    }
+
+    private var jobTitleSection: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            filterField("Job title", text: $criteria.jobTitle, prompt: "e.g. iOS Developer")
+            filterField("Keywords", text: $criteria.keywords, prompt: "Skills or keywords")
+            Toggle(isOn: $criteria.exactPhrase) {
+                Text("Exact phrase")
+                    .font(AppTypography.body)
+                    .foregroundStyle(AppColors.textSecondary)
+            }
+            .toggleStyle(.checkbox)
+            .onChange(of: criteria.exactPhrase) { _, _ in onChange() }
+        }
+        .onChange(of: criteria.jobTitle) { _, _ in onChange() }
+        .onChange(of: criteria.keywords) { _, _ in onChange() }
+    }
+
+    private var excludeSection: some View {
+        filterField("Exclude keywords", text: $criteria.excludedKeywords, prompt: "Words to exclude")
+            .onChange(of: criteria.excludedKeywords) { _, _ in onChange() }
+    }
+
+    private var locationSection: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            VStack(alignment: .leading, spacing: 4) {
+                Text("Country")
+                    .font(AppTypography.caption)
+                    .foregroundStyle(AppColors.textTertiary)
+                Picker("Country", selection: $criteria.country) {
+                    Text("Pakistan").tag("Pakistan")
+                    Text("United States").tag("United States")
+                    Text("United Kingdom").tag("United Kingdom")
+                    Text("Canada").tag("Canada")
+                    Text("Remote / Any").tag("")
+                }
+                .labelsHidden()
+                .onChange(of: criteria.country) { _, _ in onChange() }
+            }
+
+            filterField("City / Region", text: $criteria.city, prompt: "e.g. Lahore")
+                .onChange(of: criteria.city) { _, _ in onChange() }
+
+            VStack(alignment: .leading, spacing: 8) {
+                ForEach([RemoteType.remote, .hybrid, .onsite], id: \.self) { type in
+                    Toggle(isOn: Binding(
+                        get: { criteria.remoteTypes.contains(type) },
+                        set: { enabled in
+                            if enabled { criteria.remoteTypes.insert(type) }
+                            else { criteria.remoteTypes.remove(type) }
+                            onChange()
+                        }
+                    )) {
+                        Text(type.filterLabel)
+                            .font(AppTypography.body)
+                            .foregroundStyle(AppColors.textSecondary)
+                    }
+                    .toggleStyle(.checkbox)
+                }
+            }
+        }
+    }
+
+    private var employmentSection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            ForEach(EmploymentType.allCases) { type in
+                Toggle(isOn: Binding(
+                    get: { criteria.employmentTypes.contains(type) },
+                    set: { enabled in
+                        if enabled { criteria.employmentTypes.insert(type) }
+                        else { criteria.employmentTypes.remove(type) }
+                        onChange()
+                    }
+                )) {
+                    Text(type.displayName)
+                        .font(AppTypography.body)
+                        .foregroundStyle(AppColors.textSecondary)
+                }
+                .toggleStyle(.checkbox)
+            }
+        }
+    }
+
+    private var experienceSection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            ForEach(ExperienceLevel.allCases) { level in
+                Toggle(isOn: Binding(
+                    get: { criteria.experienceLevels.contains(level) },
+                    set: { enabled in
+                        if enabled { criteria.experienceLevels.insert(level) }
+                        else { criteria.experienceLevels.remove(level) }
+                        onChange()
+                    }
+                )) {
+                    Text(level.displayName)
+                        .font(AppTypography.body)
+                        .foregroundStyle(AppColors.textSecondary)
+                }
+                .toggleStyle(.checkbox)
+            }
+        }
+    }
+
+    private var salarySection: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            Text("Minimum salary (USD / year)")
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.textTertiary)
+
+            TextField("e.g. 40000", value: $criteria.salaryFloor, format: .number)
+                .textFieldStyle(.plain)
+                .padding(10)
+                .background(AppColors.backgroundPrimary)
+                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .stroke(AppColors.border, lineWidth: 1)
+                )
+                .onChange(of: criteria.salaryFloor) { _, _ in onChange() }
+        }
+    }
+
+    private func filterField(_ title: String, text: Binding<String>, prompt: String) -> some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(title)
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.textTertiary)
+            TextField(prompt, text: text)
+                .textFieldStyle(.plain)
+                .font(AppTypography.body)
+                .padding(10)
+                .background(AppColors.backgroundPrimary)
+                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .stroke(AppColors.border, lineWidth: 1)
+                )
+        }
+    }
+}
+
+private enum FilterSection: Hashable {
+    case jobTitle, exclude, location, employment, experience, salary
+
+    var title: String {
+        switch self {
+        case .jobTitle: return "Job Title & Keywords"
+        case .exclude: return "Exclude Keywords"
+        case .location: return "Location"
+        case .employment: return "Employment Type"
+        case .experience: return "Experience Level"
+        case .salary: return "Salary"
+        }
+    }
+}

+ 114 - 0
indeed_ai_job_search/Features/SavedJobs/ViewModels/SavedJobsViewModel.swift

@@ -0,0 +1,114 @@
+//
+//  SavedJobsViewModel.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+import Observation
+
+enum SavedSortOption: String, CaseIterable, Identifiable {
+    case dateSaved
+    case postedDate
+    case title
+    case company
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .dateSaved: return "Date saved"
+        case .postedDate: return "Posted date"
+        case .title: return "Title"
+        case .company: return "Company"
+        }
+    }
+}
+
+enum SavedAvailabilityFilter: String, CaseIterable, Identifiable {
+    case all
+    case open
+    case unavailable
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .all: return "All"
+        case .open: return "Open"
+        case .unavailable: return "Unavailable"
+        }
+    }
+}
+
+@Observable
+@MainActor
+final class SavedJobsViewModel {
+    var searchText = ""
+    var sortOption: SavedSortOption = .dateSaved
+    var availabilityFilter: SavedAvailabilityFilter = .all
+    var remoteOnly = false
+    private(set) var listVersion = 0
+
+    let store: MockSavedJobsStore
+
+    init(dependencies: AppDependencies) {
+        self.store = dependencies.savedJobsStore
+    }
+
+    var savedJobs: [SavedJob] {
+        _ = listVersion
+        return store.savedJobs
+    }
+
+    var filteredJobs: [SavedJob] {
+        var items = savedJobs
+
+        let needle = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        if !needle.isEmpty {
+            items = items.filter {
+                "\($0.listing.title) \($0.listing.company) \($0.listing.location)"
+                    .lowercased()
+                    .contains(needle)
+            }
+        }
+
+        switch availabilityFilter {
+        case .all: break
+        case .open:
+            items = items.filter { $0.availability != .unavailable }
+        case .unavailable:
+            items = items.filter { $0.availability == .unavailable }
+        }
+
+        if remoteOnly {
+            items = items.filter { $0.listing.remoteType == .remote }
+        }
+
+        switch sortOption {
+        case .dateSaved:
+            items.sort { $0.savedAt > $1.savedAt }
+        case .postedDate:
+            items.sort { $0.listing.postedAt > $1.listing.postedAt }
+        case .title:
+            items.sort { $0.listing.title.localizedCaseInsensitiveCompare($1.listing.title) == .orderedAscending }
+        case .company:
+            items.sort { $0.listing.company.localizedCaseInsensitiveCompare($1.listing.company) == .orderedAscending }
+        }
+
+        return items
+    }
+
+    func toggleSave(_ listing: JobListing) {
+        store.toggleSave(listing)
+        listVersion += 1
+    }
+
+    func isSaved(_ listing: JobListing) -> Bool {
+        _ = listVersion
+        return store.isSaved(listing)
+    }
+
+    func refreshFromStore() {
+        listVersion += 1
+    }
+}

+ 120 - 0
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -0,0 +1,120 @@
+//
+//  SavedJobsView.swift
+//  indeed_ai_job_search
+//
+
+import AppKit
+import SwiftUI
+
+struct SavedJobsView: View {
+    @Bindable var viewModel: SavedJobsViewModel
+    var onGoToSearch: () -> Void
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            header
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.top, AppSpacing.lg)
+                .padding(.bottom, AppSpacing.md)
+
+            toolbar
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.md)
+
+            content
+                .padding(.horizontal, AppSpacing.xl)
+                .padding(.bottom, AppSpacing.lg)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.backgroundPrimary)
+    }
+
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text("Saved Jobs")
+                .font(AppTypography.sectionTitle)
+                .foregroundStyle(AppColors.textPrimary)
+            Text("Jobs you’ve bookmarked from Indeed search results.")
+                .font(AppTypography.body)
+                .foregroundStyle(AppColors.textSecondary)
+        }
+    }
+
+    private var toolbar: some View {
+        HStack(spacing: AppSpacing.sm) {
+            HStack {
+                Image(systemName: "magnifyingglass")
+                    .foregroundStyle(AppColors.textTertiary)
+                TextField("Search saved jobs", text: $viewModel.searchText)
+                    .textFieldStyle(.plain)
+            }
+            .padding(10)
+            .background(AppColors.surface)
+            .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+            .overlay(
+                RoundedRectangle(cornerRadius: 8, style: .continuous)
+                    .stroke(AppColors.border, lineWidth: 1)
+            )
+            .frame(maxWidth: 280)
+
+            Picker("Sort", selection: $viewModel.sortOption) {
+                ForEach(SavedSortOption.allCases) { option in
+                    Text(option.displayName).tag(option)
+                }
+            }
+            .frame(width: 150)
+
+            Picker("Availability", selection: $viewModel.availabilityFilter) {
+                ForEach(SavedAvailabilityFilter.allCases) { option in
+                    Text(option.displayName).tag(option)
+                }
+            }
+            .frame(width: 140)
+
+            Toggle("Remote only", isOn: $viewModel.remoteOnly)
+                .toggleStyle(.checkbox)
+                .font(AppTypography.meta)
+
+            Spacer()
+        }
+    }
+
+    @ViewBuilder
+    private var content: some View {
+        if viewModel.savedJobs.isEmpty {
+            EmptyStateView(
+                icon: "bookmark",
+                title: "No saved jobs yet",
+                message: "Save listings from AI Job Search to revisit them here.",
+                actionTitle: "Go to AI Job Search",
+                action: onGoToSearch
+            )
+        } else if viewModel.filteredJobs.isEmpty {
+            EmptyStateView(
+                icon: "line.3.horizontal.decrease.circle",
+                title: "No matches",
+                message: "Try clearing search or filters to see your saved jobs."
+            )
+        } else {
+            ScrollView {
+                LazyVStack(spacing: AppSpacing.sm) {
+                    ForEach(viewModel.filteredJobs) { saved in
+                        JobRowView(
+                            listing: saved.listing,
+                            isSaved: true,
+                            onToggleSave: { viewModel.toggleSave(saved.listing) }
+                        )
+                        .contextMenu {
+                            Button("Open on Indeed") {
+                                NSWorkspace.shared.open(saved.listing.indeedURL)
+                            }
+                            Button("Remove from saved", role: .destructive) {
+                                viewModel.toggleSave(saved.listing)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 39 - 0
indeed_ai_job_search/Features/Settings/ViewModels/SettingsViewModel.swift

@@ -0,0 +1,39 @@
+//
+//  SettingsViewModel.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+import Observation
+import SwiftUI
+
+@Observable
+@MainActor
+final class SettingsViewModel {
+    var defaultCountry = "Pakistan"
+    var defaultCity = ""
+    var preferRemote = true
+    var defaultFreshnessDays = 14
+    var quietHoursEnabled = false
+    var openAIConfigured = false
+    var notificationPermissionLabel = "Not requested"
+
+    var appearanceMode: AppearanceMode {
+        didSet {
+            UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode")
+        }
+    }
+
+    init() {
+        let raw = UserDefaults.standard.string(forKey: "appearanceMode") ?? AppearanceMode.system.rawValue
+        appearanceMode = AppearanceMode(rawValue: raw) ?? .system
+    }
+
+    var preferredColorScheme: ColorScheme? {
+        switch appearanceMode {
+        case .system: return nil
+        case .light: return .light
+        case .dark: return .dark
+        }
+    }
+}

+ 131 - 0
indeed_ai_job_search/Features/Settings/Views/SettingsView.swift

@@ -0,0 +1,131 @@
+//
+//  SettingsView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct SettingsView: View {
+    @Bindable var viewModel: SettingsViewModel
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: AppSpacing.lg) {
+                header
+
+                settingsCard("Search defaults") {
+                    labeledField("Default country") {
+                        TextField("Country", text: $viewModel.defaultCountry)
+                            .textFieldStyle(.roundedBorder)
+                    }
+                    labeledField("Default city") {
+                        TextField("City (optional)", text: $viewModel.defaultCity)
+                            .textFieldStyle(.roundedBorder)
+                    }
+                    Toggle("Prefer remote roles", isOn: $viewModel.preferRemote)
+                    labeledField("Default freshness (days)") {
+                        Stepper("\(viewModel.defaultFreshnessDays) days", value: $viewModel.defaultFreshnessDays, in: 1...60)
+                    }
+                }
+
+                settingsCard("Notifications") {
+                    HStack {
+                        Text("macOS permission")
+                        Spacer()
+                        Text(viewModel.notificationPermissionLabel)
+                            .foregroundStyle(AppColors.textSecondary)
+                    }
+                    Toggle("Quiet hours (UI only)", isOn: $viewModel.quietHoursEnabled)
+                    Text("Alert delivery is mocked in Phase 1. System notifications arrive in a later phase.")
+                        .font(AppTypography.meta)
+                        .foregroundStyle(AppColors.textTertiary)
+                }
+
+                settingsCard("Appearance") {
+                    Picker("Theme", selection: Binding(
+                        get: { viewModel.appearanceMode },
+                        set: { viewModel.appearanceMode = $0 }
+                    )) {
+                        ForEach(AppearanceMode.allCases) { mode in
+                            Text(mode.displayName).tag(mode)
+                        }
+                    }
+                    .pickerStyle(.segmented)
+                }
+
+                settingsCard("OpenAI") {
+                    HStack {
+                        Text("API key status")
+                        Spacer()
+                        Text(viewModel.openAIConfigured ? "Configured" : "Not configured")
+                            .foregroundStyle(viewModel.openAIConfigured ? AppColors.success : AppColors.textSecondary)
+                            .font(AppTypography.filterHeader)
+                    }
+                    Text("Keys are injected at build time in a later phase. Phase 1 uses mock interpretation only.")
+                        .font(AppTypography.meta)
+                        .foregroundStyle(AppColors.textTertiary)
+                }
+
+                settingsCard("Privacy & data") {
+                    Text("Search queries may be processed by OpenAI in later phases. Indeed listings open on indeed.com. Local saved jobs and alerts stay on this Mac.")
+                        .font(AppTypography.body)
+                        .foregroundStyle(AppColors.textSecondary)
+
+                    Button("Clear local data") {}
+                        .disabled(true)
+                        .help("Available after persistence ships in Phase 5")
+
+                    Button("Export saved jobs") {}
+                        .disabled(true)
+                }
+
+                settingsCard("Subscription") {
+                    Text("Coming soon")
+                        .font(AppTypography.body)
+                        .foregroundStyle(AppColors.textSecondary)
+                }
+            }
+            .padding(AppSpacing.xl)
+            .frame(maxWidth: 760, alignment: .leading)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.backgroundPrimary)
+    }
+
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text("Settings")
+                .font(AppTypography.sectionTitle)
+                .foregroundStyle(AppColors.textPrimary)
+            Text("Defaults, appearance, and privacy for Indeed AI Jobs.")
+                .font(AppTypography.body)
+                .foregroundStyle(AppColors.textSecondary)
+        }
+    }
+
+    private func settingsCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            Text(title)
+                .font(AppTypography.filterHeader)
+                .foregroundStyle(AppColors.textPrimary)
+            content()
+        }
+        .padding(AppSpacing.md)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(AppColors.surfaceCard)
+        .clipShape(RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                .stroke(AppColors.border, lineWidth: 1)
+        )
+    }
+
+    private func labeledField<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(title)
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.textTertiary)
+            content()
+        }
+    }
+}

+ 125 - 0
indeed_ai_job_search/Navigation/AppSidebarView.swift

@@ -0,0 +1,125 @@
+//
+//  AppSidebarView.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+struct AppSidebarView: View {
+    @Binding var selection: SidebarItem
+    var unreadAlertCount: Int = 0
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            brandHeader
+                .padding(.horizontal, AppSpacing.md)
+                .padding(.top, AppSpacing.lg)
+                .padding(.bottom, AppSpacing.xl)
+
+            VStack(spacing: 4) {
+                ForEach(SidebarItem.allCases) { item in
+                    sidebarButton(item)
+                }
+            }
+            .padding(.horizontal, AppSpacing.sm)
+
+            Spacer(minLength: AppSpacing.lg)
+
+            promoCard
+                .padding(.horizontal, AppSpacing.md)
+                .padding(.bottom, AppSpacing.lg)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(AppColors.backgroundSidebar)
+    }
+
+    private var brandHeader: some View {
+        HStack(spacing: 10) {
+            ZStack {
+                RoundedRectangle(cornerRadius: 8, style: .continuous)
+                    .fill(AppColors.brand)
+                    .frame(width: 28, height: 28)
+                Image(systemName: "checkmark")
+                    .font(.system(size: 12, weight: .bold))
+                    .foregroundStyle(.white)
+            }
+
+            Text("Indeed AI Jobs")
+                .font(AppTypography.brandTitle)
+                .foregroundStyle(AppColors.textPrimary)
+                .lineLimit(1)
+        }
+        .accessibilityElement(children: .combine)
+        .accessibilityAddTraits(.isHeader)
+    }
+
+    private func sidebarButton(_ item: SidebarItem) -> some View {
+        let isSelected = selection == item
+
+        return Button {
+            selection = item
+        } label: {
+            HStack(spacing: 10) {
+                Image(systemName: item.icon)
+                    .font(.system(size: 14, weight: .medium))
+                    .frame(width: 18)
+
+                Text(item.title)
+                    .font(AppTypography.sidebarItem)
+
+                Spacer(minLength: 0)
+
+                if item == .jobAlerts, unreadAlertCount > 0 {
+                    Text("\(unreadAlertCount)")
+                        .font(AppTypography.caption)
+                        .foregroundStyle(.white)
+                        .padding(.horizontal, 6)
+                        .padding(.vertical, 2)
+                        .background(AppColors.brand)
+                        .clipShape(Capsule())
+                }
+            }
+            .foregroundStyle(isSelected ? AppColors.brand : AppColors.textSecondary)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 10)
+            .background(
+                RoundedRectangle(cornerRadius: AppSpacing.sidebarItemRadius, style: .continuous)
+                    .fill(isSelected ? AppColors.brandSoft : Color.clear)
+            )
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .keyboardShortcut(item.keyboardShortcut, modifiers: .command)
+        .accessibilityAddTraits(isSelected ? .isSelected : [])
+    }
+
+    private var promoCard: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.sm) {
+            Image(systemName: "sparkles")
+                .font(.system(size: 16, weight: .semibold))
+                .foregroundStyle(AppColors.brand)
+
+            Text("Smarter job search with AI")
+                .font(AppTypography.filterHeader)
+                .foregroundStyle(AppColors.textPrimary)
+                .fixedSize(horizontal: false, vertical: true)
+
+            Button {
+                selection = .jobSearch
+            } label: {
+                Image(systemName: "arrow.right")
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white)
+                    .frame(width: 28, height: 28)
+                    .background(AppColors.brand)
+                    .clipShape(Circle())
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("Learn more about AI job search")
+        }
+        .padding(AppSpacing.md)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(AppColors.promoBackground)
+        .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+    }
+}

+ 42 - 0
indeed_ai_job_search/Navigation/SidebarItem.swift

@@ -0,0 +1,42 @@
+//
+//  SidebarItem.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+enum SidebarItem: String, CaseIterable, Identifiable, Hashable {
+    case jobSearch
+    case savedJobs
+    case jobAlerts
+    case settings
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .jobSearch: return "AI Job Search"
+        case .savedJobs: return "Saved Jobs"
+        case .jobAlerts: return "Job Alerts"
+        case .settings: return "Settings"
+        }
+    }
+
+    var icon: String {
+        switch self {
+        case .jobSearch: return "sparkles"
+        case .savedJobs: return "bookmark"
+        case .jobAlerts: return "bell"
+        case .settings: return "gearshape"
+        }
+    }
+
+    var keyboardShortcut: KeyEquivalent {
+        switch self {
+        case .jobSearch: return "1"
+        case .savedJobs: return "2"
+        case .jobAlerts: return "3"
+        case .settings: return "4"
+        }
+    }
+}

+ 400 - 0
indeed_ai_job_search/PreviewSupport/MockData/MockJobListings.swift

@@ -0,0 +1,400 @@
+//
+//  MockJobListings.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+
+enum MockJobListings {
+    static let all: [JobListing] = {
+        let now = Date()
+        let day: TimeInterval = 86_400
+
+        func url(_ key: String) -> URL {
+            URL(string: "https://www.indeed.com/viewjob?jk=\(key)")!
+        }
+
+        return [
+            JobListing(
+                indeedJobKey: "ios001",
+                title: "Senior iOS Developer",
+                company: "Apple",
+                location: "Lahore, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 50_000, max: 80_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-day),
+                snippet: "Build delightful iOS experiences with SwiftUI and UIKit for millions of users.",
+                indeedURL: url("ios001"),
+                isEasyApply: true,
+                isFeatured: true,
+                logoSymbol: "apple.logo"
+            ),
+            JobListing(
+                indeedJobKey: "ios002",
+                title: "iOS Developer",
+                company: "Microsoft",
+                location: "Islamabad, Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 45_000, max: 70_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-2 * day),
+                snippet: "Join a distributed team shipping mobile productivity apps on iOS.",
+                indeedURL: url("ios002"),
+                isEasyApply: true,
+                isUrgentlyHiring: true,
+                logoSymbol: "square.grid.2x2.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios003",
+                title: "Mobile Software Engineer (iOS)",
+                company: "Alorica",
+                location: "Karachi, Pakistan",
+                remoteType: .onsite,
+                employmentType: .fullTime,
+                experienceLevel: .junior,
+                salary: SalaryRange(min: 30_000, max: 48_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-3 * day),
+                snippet: "Develop and maintain customer-facing iOS applications.",
+                indeedURL: url("ios003"),
+                isEasyApply: true,
+                logoSymbol: "phone.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios004",
+                title: "iOS Engineer",
+                company: "Careem",
+                location: "Lahore, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 40_000, max: 65_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-4 * day),
+                snippet: "Own features end-to-end for the Careem rider and captain apps.",
+                indeedURL: url("ios004"),
+                isFeatured: true,
+                logoSymbol: "car.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios005",
+                title: "Senior Mobile Developer",
+                company: "Bykea",
+                location: "Karachi, Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 55_000, max: 85_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-5 * day),
+                snippet: "Lead iOS architecture and mentor a growing mobile team.",
+                indeedURL: url("ios005"),
+                isEasyApply: true,
+                isUrgentlyHiring: true,
+                logoSymbol: "bicycle"
+            ),
+            JobListing(
+                indeedJobKey: "ios006",
+                title: "SwiftUI Developer",
+                company: "Afiniti",
+                location: "Islamabad, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 48_000, max: 75_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-6 * day),
+                snippet: "Ship modern SwiftUI interfaces for enterprise AI products.",
+                indeedURL: url("ios006"),
+                isEasyApply: true,
+                logoSymbol: "sparkles"
+            ),
+            JobListing(
+                indeedJobKey: "ios007",
+                title: "Junior iOS Developer",
+                company: "Systems Limited",
+                location: "Lahore, Pakistan",
+                remoteType: .onsite,
+                employmentType: .fullTime,
+                experienceLevel: .entry,
+                salary: SalaryRange(min: 18_000, max: 28_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-7 * day),
+                snippet: "Learn production iOS development with senior mentors.",
+                indeedURL: url("ios007"),
+                isEasyApply: true,
+                logoSymbol: "desktopcomputer"
+            ),
+            JobListing(
+                indeedJobKey: "ios008",
+                title: "iOS Contractor",
+                company: "Remote Team Co",
+                location: "Pakistan",
+                remoteType: .remote,
+                employmentType: .contract,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 35, max: 55, currency: "USD", period: .hour),
+                postedAt: now.addingTimeInterval(-8 * day),
+                snippet: "6-month contract building a consumer fintech iOS app.",
+                indeedURL: url("ios008"),
+                isFeatured: true,
+                logoSymbol: "globe"
+            ),
+            JobListing(
+                indeedJobKey: "ios009",
+                title: "Lead iOS Engineer",
+                company: "Daraz",
+                location: "Karachi, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 70_000, max: 95_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-9 * day),
+                snippet: "Drive mobile platform strategy for Pakistan’s top marketplace.",
+                indeedURL: url("ios009"),
+                isEasyApply: true,
+                isFeatured: true,
+                logoSymbol: "cart.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios010",
+                title: "iOS Developer (Part-time)",
+                company: "EduTech PK",
+                location: "Remote, Pakistan",
+                remoteType: .remote,
+                employmentType: .partTime,
+                experienceLevel: .junior,
+                salary: SalaryRange(min: 12_000, max: 20_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-10 * day),
+                snippet: "Part-time role improving an education app for students.",
+                indeedURL: url("ios010"),
+                logoSymbol: "book.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios011",
+                title: "macOS / iOS Engineer",
+                company: "Cursor Labs",
+                location: "Remote",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 90_000, max: 130_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-1.5 * day),
+                snippet: "Build native Apple platform tools with Swift concurrency.",
+                indeedURL: url("ios011"),
+                isEasyApply: true,
+                isFeatured: true,
+                logoSymbol: "laptopcomputer"
+            ),
+            JobListing(
+                indeedJobKey: "ios012",
+                title: "iOS Developer",
+                company: "JazzCash",
+                location: "Islamabad, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 42_000, max: 60_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-11 * day),
+                snippet: "Secure mobile payments experience for millions of users.",
+                indeedURL: url("ios012"),
+                isUrgentlyHiring: true,
+                logoSymbol: "creditcard.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios013",
+                title: "Swift Developer",
+                company: "10Pearls",
+                location: "Karachi, Pakistan",
+                remoteType: .onsite,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: nil,
+                postedAt: now.addingTimeInterval(-12 * day),
+                snippet: "Client projects spanning healthcare and fintech iOS apps.",
+                indeedURL: url("ios013"),
+                isEasyApply: true,
+                logoSymbol: "hammer.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios014",
+                title: "iOS Intern",
+                company: "Arbisoft",
+                location: "Lahore, Pakistan",
+                remoteType: .onsite,
+                employmentType: .internship,
+                experienceLevel: .entry,
+                salary: SalaryRange(min: 400, max: 600, currency: "USD", period: .month),
+                postedAt: now.addingTimeInterval(-2 * day),
+                snippet: "Paid internship focused on Swift and SwiftUI fundamentals.",
+                indeedURL: url("ios014"),
+                logoSymbol: "graduationcap.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios015",
+                title: "Senior iOS Developer",
+                company: "Emumba",
+                location: "Islamabad, Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 60_000, max: 90_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-0.5 * day),
+                snippet: "Remote-first team building consumer apps for global clients.",
+                indeedURL: url("ios015"),
+                isEasyApply: true,
+                isFeatured: true,
+                logoSymbol: "antenna.radiowaves.left.and.right"
+            ),
+            JobListing(
+                indeedJobKey: "ios016",
+                title: "Mobile Engineer",
+                company: "Foodpanda",
+                location: "Lahore, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 38_000, max: 58_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-14 * day),
+                snippet: "Improve ordering flows and performance on iOS.",
+                indeedURL: url("ios016"),
+                logoSymbol: "fork.knife"
+            ),
+            JobListing(
+                indeedJobKey: "ios017",
+                title: "iOS Developer (Unavailable)",
+                company: "Closed Co",
+                location: "Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 40_000, max: 55_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-30 * day),
+                snippet: "This listing is no longer accepting applications.",
+                indeedURL: url("ios017"),
+                availability: .unavailable,
+                logoSymbol: "xmark.circle.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios018",
+                title: "Temporary iOS Support Engineer",
+                company: "Helpdesk Inc",
+                location: "Remote, Pakistan",
+                remoteType: .remote,
+                employmentType: .temporary,
+                experienceLevel: .junior,
+                salary: SalaryRange(min: 25, max: 35, currency: "USD", period: .hour),
+                postedAt: now.addingTimeInterval(-3.5 * day),
+                snippet: "Short-term engagement supporting crash triage and hotfixes.",
+                indeedURL: url("ios018"),
+                isEasyApply: true,
+                logoSymbol: "wrench.and.screwdriver.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios019",
+                title: "Principal iOS Architect",
+                company: "Netsol",
+                location: "Lahore, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 100_000, max: 140_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-15 * day),
+                snippet: "Define multi-app mobile architecture and CI/CD standards.",
+                indeedURL: url("ios019"),
+                isFeatured: true,
+                logoSymbol: "building.columns.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios020",
+                title: "iOS Developer",
+                company: "VentureDive",
+                location: "Karachi, Pakistan",
+                remoteType: .onsite,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 35_000, max: 52_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-4.5 * day),
+                snippet: "Collaborate with product and design on high-quality apps.",
+                indeedURL: url("ios020"),
+                isEasyApply: true,
+                isUrgentlyHiring: true,
+                logoSymbol: "person.3.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios021",
+                title: "React Native / iOS Engineer",
+                company: "Confiz",
+                location: "Lahore, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 40_000, max: 62_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-6.5 * day),
+                snippet: "Hybrid role with native iOS modules and React Native.",
+                indeedURL: url("ios021"),
+                logoSymbol: "arrow.triangle.2.circlepath"
+            ),
+            JobListing(
+                indeedJobKey: "ios022",
+                title: "iOS QA Automation Engineer",
+                company: "Folio3",
+                location: "Karachi, Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 32_000, max: 48_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-7.5 * day),
+                snippet: "Automate XCUITest suites for mobile release quality.",
+                indeedURL: url("ios022"),
+                isEasyApply: true,
+                logoSymbol: "checkmark.seal.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios023",
+                title: "Staff iOS Engineer",
+                company: "KeepTruckin",
+                location: "Islamabad, Pakistan",
+                remoteType: .hybrid,
+                employmentType: .fullTime,
+                experienceLevel: .senior,
+                salary: SalaryRange(min: 85_000, max: 120_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-0.8 * day),
+                snippet: "Own fleet-management mobile experiences at scale.",
+                indeedURL: url("ios023"),
+                isEasyApply: true,
+                isFeatured: true,
+                logoSymbol: "truck.box.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios024",
+                title: "iOS Developer",
+                company: "Tintash",
+                location: "Lahore, Pakistan",
+                remoteType: .onsite,
+                employmentType: .fullTime,
+                experienceLevel: .junior,
+                salary: SalaryRange(min: 22_000, max: 35_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-9.5 * day),
+                snippet: "Work on game-adjacent and consumer product iOS apps.",
+                indeedURL: url("ios024"),
+                logoSymbol: "gamecontroller.fill"
+            ),
+            JobListing(
+                indeedJobKey: "ios025",
+                title: "Remote iOS Developer",
+                company: "Global Soft",
+                location: "Remote, Pakistan",
+                remoteType: .remote,
+                employmentType: .fullTime,
+                experienceLevel: .mid,
+                salary: SalaryRange(min: 50_000, max: 78_000, currency: "USD", period: .year),
+                postedAt: now.addingTimeInterval(-1.2 * day),
+                snippet: "Fully remote role building banking apps with Swift.",
+                indeedURL: url("ios025"),
+                isEasyApply: true,
+                isFeatured: true,
+                isUrgentlyHiring: true,
+                logoSymbol: "building.2.fill"
+            )
+        ]
+    }()
+}

+ 27 - 0
indeed_ai_job_search/PreviewSupport/PreviewDependencies.swift

@@ -0,0 +1,27 @@
+//
+//  PreviewDependencies.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+#Preview("AI Job Search — Hero") {
+    RootShellView(dependencies: .preview)
+        .frame(width: 1280, height: 820)
+}
+
+#Preview("AI Job Search — Narrow") {
+    RootShellView(dependencies: .preview)
+        .frame(width: 900, height: 700)
+}
+
+#Preview("Job Row") {
+    JobRowView(
+        listing: MockJobListings.all[0],
+        isSaved: false,
+        onToggleSave: {}
+    )
+    .padding()
+    .frame(width: 640)
+    .background(AppColors.backgroundPrimary)
+}

+ 242 - 0
indeed_ai_job_search/Services/Mock/MockServices.swift

@@ -0,0 +1,242 @@
+//
+//  MockServices.swift
+//  indeed_ai_job_search
+//
+
+import Foundation
+import Observation
+
+struct MockJobDataSource: JobDataSource {
+    var delayNanoseconds: UInt64 = 700_000_000
+    var shouldFail: Bool = false
+
+    func search(criteria: SearchCriteria, query: String) async throws -> [JobListing] {
+        try await Task.sleep(nanoseconds: delayNanoseconds)
+        if shouldFail {
+            throw MockSearchError.network
+        }
+
+        let needle = (query + " " + criteria.jobTitle + " " + criteria.keywords)
+            .lowercased()
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        var results = MockJobListings.all.filter { $0.availability != .unavailable }
+
+        if !needle.isEmpty {
+            results = results.filter { job in
+                let haystack = "\(job.title) \(job.company) \(job.location) \(job.snippet)".lowercased()
+                let tokens = needle.split(separator: " ").map(String.init)
+                return tokens.contains { haystack.contains($0) }
+            }
+        }
+
+        if !criteria.excludedKeywords.isEmpty {
+            let excluded = criteria.excludedKeywords.lowercased().split(separator: " ").map(String.init)
+            results = results.filter { job in
+                let haystack = "\(job.title) \(job.company) \(job.snippet)".lowercased()
+                return !excluded.contains { haystack.contains($0) }
+            }
+        }
+
+        if !criteria.remoteTypes.isEmpty {
+            results = results.filter { criteria.remoteTypes.contains($0.remoteType) }
+        }
+
+        if !criteria.employmentTypes.isEmpty {
+            results = results.filter { job in
+                guard let type = job.employmentType else { return false }
+                return criteria.employmentTypes.contains(type)
+            }
+        }
+
+        if !criteria.experienceLevels.isEmpty {
+            results = results.filter { job in
+                guard let level = job.experienceLevel else { return false }
+                return criteria.experienceLevels.contains(level)
+            }
+        }
+
+        if !criteria.country.isEmpty {
+            results = results.filter {
+                $0.location.localizedCaseInsensitiveContains(criteria.country)
+                    || $0.remoteType == .remote
+            }
+        }
+
+        if !criteria.city.isEmpty {
+            results = results.filter {
+                $0.location.localizedCaseInsensitiveContains(criteria.city)
+                    || $0.remoteType == .remote
+            }
+        }
+
+        if let floor = criteria.salaryFloor {
+            results = results.filter { job in
+                guard let max = job.salary?.max ?? job.salary?.min else { return true }
+                return max >= floor
+            }
+        }
+
+        return results
+    }
+}
+
+enum MockSearchError: LocalizedError {
+    case network
+
+    var errorDescription: String? {
+        switch self {
+        case .network:
+            return "We couldn’t reach job results. Check your connection and try again."
+        }
+    }
+}
+
+struct MockSearchInterpreter: OpenAISearchService {
+    func interpret(query: String) async throws -> InterpretedSearch {
+        try await Task.sleep(nanoseconds: 350_000_000)
+
+        var criteria = SearchCriteria()
+        let lower = query.lowercased()
+
+        if lower.contains("ios") {
+            criteria.jobTitle = "iOS Developer"
+        } else if lower.contains("android") {
+            criteria.jobTitle = "Android Developer"
+        } else if lower.contains("swift") {
+            criteria.jobTitle = "Swift Developer"
+        } else {
+            criteria.keywords = query
+        }
+
+        if lower.contains("remote") {
+            criteria.remoteTypes.insert(.remote)
+        }
+        if lower.contains("hybrid") {
+            criteria.remoteTypes.insert(.hybrid)
+        }
+        if lower.contains("pakistan") {
+            criteria.country = "Pakistan"
+        }
+        if lower.contains("lahore") {
+            criteria.city = "Lahore"
+            criteria.country = "Pakistan"
+        }
+        if lower.contains("full-time") || lower.contains("full time") {
+            criteria.employmentTypes.insert(.fullTime)
+        }
+        if lower.contains("senior") || lower.contains("2+") || lower.contains("experience") {
+            criteria.experienceLevels.insert(.senior)
+        }
+
+        return InterpretedSearch(
+            naturalLanguageQuery: query,
+            criteria: criteria,
+            confidenceNote: "Mock AI interpretation for Phase 1 preview."
+        )
+    }
+}
+
+struct MockJobValidationService: JobValidationService {
+    func validate(listing: JobListing) async -> JobAvailability {
+        listing.availability
+    }
+}
+
+@Observable
+@MainActor
+final class MockSavedJobsStore: SavedJobsStore {
+    private(set) var savedJobs: [SavedJob] = []
+
+    func isSaved(_ listing: JobListing) -> Bool {
+        savedJobs.contains { $0.listing.indeedJobKey == listing.indeedJobKey }
+    }
+
+    func toggleSave(_ listing: JobListing) {
+        if let index = savedJobs.firstIndex(where: { $0.listing.indeedJobKey == listing.indeedJobKey }) {
+            savedJobs.remove(at: index)
+        } else {
+            savedJobs.insert(SavedJob(listing: listing, savedAt: .now), at: 0)
+        }
+    }
+
+    func remove(_ listing: JobListing) {
+        savedJobs.removeAll { $0.listing.indeedJobKey == listing.indeedJobKey }
+    }
+}
+
+@Observable
+@MainActor
+final class MockAlertsStore: AlertsStore {
+    private(set) var alerts: [JobAlert]
+    private(set) var events: [AlertNotificationEvent]
+
+    init() {
+        var remoteIOS = SearchCriteria()
+        remoteIOS.jobTitle = "iOS Developer"
+        remoteIOS.country = "Pakistan"
+        remoteIOS.remoteTypes = [.remote]
+        remoteIOS.employmentTypes = [.fullTime]
+
+        var senior = SearchCriteria()
+        senior.jobTitle = "Senior iOS Developer"
+        senior.country = "Pakistan"
+        senior.experienceLevels = [.senior]
+
+        self.alerts = [
+            JobAlert(
+                name: "Remote iOS in Pakistan",
+                criteria: remoteIOS,
+                frequency: .daily,
+                isEnabled: true,
+                lastRunAt: Date().addingTimeInterval(-3_600),
+                matchCount: 12
+            ),
+            JobAlert(
+                name: "Senior iOS roles",
+                criteria: senior,
+                frequency: .weekly,
+                isEnabled: false,
+                lastRunAt: Date().addingTimeInterval(-86_400 * 3),
+                matchCount: 4
+            )
+        ]
+
+        self.events = [
+            AlertNotificationEvent(
+                alertName: "Remote iOS in Pakistan",
+                message: "12 new matching jobs since yesterday.",
+                createdAt: Date().addingTimeInterval(-2_000)
+            ),
+            AlertNotificationEvent(
+                alertName: "Senior iOS roles",
+                message: "4 new senior roles this week.",
+                createdAt: Date().addingTimeInterval(-86_400),
+                isRead: true
+            )
+        ]
+    }
+
+    func upsert(_ alert: JobAlert) {
+        if let index = alerts.firstIndex(where: { $0.id == alert.id }) {
+            alerts[index] = alert
+        } else {
+            alerts.insert(alert, at: 0)
+        }
+    }
+
+    func delete(_ alert: JobAlert) {
+        alerts.removeAll { $0.id == alert.id }
+    }
+
+    func setEnabled(_ alert: JobAlert, enabled: Bool) {
+        guard let index = alerts.firstIndex(where: { $0.id == alert.id }) else { return }
+        alerts[index].isEnabled = enabled
+    }
+
+    func markEventsRead() {
+        for index in events.indices {
+            events[index].isRead = true
+        }
+    }
+}

+ 0 - 17
indeed_ai_job_search/indeed_ai_job_searchApp.swift

@@ -1,17 +0,0 @@
-//
-//  indeed_ai_job_searchApp.swift
-//  indeed_ai_job_search
-//
-//  Created by Mqlmac4 on 12/10/2026.
-//
-
-import SwiftUI
-
-@main
-struct indeed_ai_job_searchApp: App {
-    var body: some Scene {
-        WindowGroup {
-            ContentView()
-        }
-    }
-}