Просмотр исходного кода

Polish window chrome and filter panel UX for Phase 1.

Hide the title bar, reserve top space for notifications, drop the unused profile control, and fix filter text alignment, checkboxes, and free-text country entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali 21 часов назад
Родитель
Сommit
d147555895

+ 31 - 26
indeed_ai_job_search/App/RootShellView.swift

@@ -40,36 +40,17 @@ struct RootShellView: View {
                         .frame(width: 1)
                         .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")
+                VStack(spacing: 0) {
+                    topUtilityBar
 
 
-                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)
-                    }
+                    detail(showInlineFilters: showInlineFilters)
+                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                 }
                 }
-                .help("Settings")
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
             }
             }
+            .background(AppColors.backgroundPrimary)
         }
         }
+        .preferredColorScheme(settingsVM.preferredColorScheme)
         .sheet(isPresented: $showFiltersSheet) {
         .sheet(isPresented: $showFiltersSheet) {
             NavigationStack {
             NavigationStack {
                 FiltersPanelView(
                 FiltersPanelView(
@@ -96,6 +77,30 @@ struct RootShellView: View {
         .background(keyboardShortcuts)
         .background(keyboardShortcuts)
     }
     }
 
 
+    /// Notifications sit top-right above mid content and filters.
+    private var topUtilityBar: some View {
+        HStack(spacing: AppSpacing.sm) {
+            Spacer(minLength: 0)
+
+            Button {
+                alertsVM.openNotificationCenter()
+            } label: {
+                Image(systemName: alertsVM.unreadCount > 0 ? "bell.badge" : "bell")
+                    .font(.system(size: 15, weight: .medium))
+                    .foregroundStyle(AppColors.textSecondary)
+                    .frame(width: 32, height: 32)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .help("Notification center")
+            .accessibilityLabel("Notifications")
+        }
+        .padding(.horizontal, AppSpacing.xl)
+        .frame(height: AppSpacing.windowChromeTop)
+        .frame(maxWidth: .infinity)
+        .background(AppColors.backgroundPrimary)
+    }
+
     @ViewBuilder
     @ViewBuilder
     private func detail(showInlineFilters: Bool) -> some View {
     private func detail(showInlineFilters: Bool) -> some View {
         switch selection {
         switch selection {

+ 2 - 0
indeed_ai_job_search/App/indeed_ai_job_searchApp.swift

@@ -12,7 +12,9 @@ struct indeed_ai_job_searchApp: App {
     var body: some Scene {
     var body: some Scene {
         WindowGroup {
         WindowGroup {
             RootShellView(dependencies: dependencies)
             RootShellView(dependencies: dependencies)
+                .ignoresSafeArea()
         }
         }
+        .windowStyle(.hiddenTitleBar)
         .defaultSize(width: 1280, height: 820)
         .defaultSize(width: 1280, height: 820)
         .commands {
         .commands {
             CommandGroup(replacing: .newItem) {}
             CommandGroup(replacing: .newItem) {}

+ 77 - 0
indeed_ai_job_search/DesignSystem/Components/FilterControls.swift

@@ -0,0 +1,77 @@
+//
+//  FilterControls.swift
+//  indeed_ai_job_search
+//
+
+import SwiftUI
+
+/// Left-aligned single-line filter text field (avoids macOS plain TextField centering).
+struct FilterTextField: View {
+    let title: String?
+    let prompt: String
+    @Binding var text: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            if let title {
+                Text(title)
+                    .font(AppTypography.caption)
+                    .foregroundStyle(AppColors.textTertiary)
+            }
+
+            TextField("", text: $text, prompt: Text(prompt).foregroundStyle(AppColors.textTertiary))
+                .textFieldStyle(.plain)
+                .font(AppTypography.body)
+                .foregroundStyle(AppColors.textPrimary)
+                .multilineTextAlignment(.leading)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .frame(height: 18, alignment: .leading)
+                .padding(.horizontal, 12)
+                .padding(.vertical, 10)
+                .background(AppColors.surface)
+                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .stroke(AppColors.borderStrong, lineWidth: 1)
+                )
+        }
+    }
+}
+
+struct FilterCheckboxToggleStyle: ToggleStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        Button {
+            configuration.isOn.toggle()
+        } label: {
+            HStack(alignment: .center, spacing: 10) {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 4, style: .continuous)
+                        .fill(Color.white)
+                        .frame(width: 16, height: 16)
+                        .overlay(
+                            RoundedRectangle(cornerRadius: 4, style: .continuous)
+                                .stroke(configuration.isOn ? AppColors.brand : AppColors.borderStrong, lineWidth: 1.5)
+                        )
+                        .shadow(color: Color.black.opacity(0.04), radius: 1, x: 0, y: 0.5)
+
+                    if configuration.isOn {
+                        RoundedRectangle(cornerRadius: 4, style: .continuous)
+                            .fill(AppColors.brand)
+                            .frame(width: 16, height: 16)
+                        Image(systemName: "checkmark")
+                            .font(.system(size: 9, weight: .bold))
+                            .foregroundStyle(.white)
+                    }
+                }
+                .frame(width: 16, height: 16)
+
+                configuration.label
+                    .font(AppTypography.body)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+            }
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+    }
+}

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

@@ -17,6 +17,8 @@ enum AppSpacing {
 
 
     static let sidebarWidth: CGFloat = 220
     static let sidebarWidth: CGFloat = 220
     static let filtersWidth: CGFloat = 280
     static let filtersWidth: CGFloat = 280
+    /// Clears traffic lights / top utility row (bell + profile).
+    static let windowChromeTop: CGFloat = 52
     static let compactBreakpoint: CGFloat = 900
     static let compactBreakpoint: CGFloat = 900
     static let mediumBreakpoint: CGFloat = 1100
     static let mediumBreakpoint: CGFloat = 1100
     static let minWindowWidth: CGFloat = 720
     static let minWindowWidth: CGFloat = 720

+ 1 - 1
indeed_ai_job_search/Features/JobSearch/Views/AIJobSearchView.swift

@@ -55,7 +55,7 @@ struct AIJobSearchView: View {
         VStack(alignment: .leading, spacing: 0) {
         VStack(alignment: .leading, spacing: 0) {
             searchHeader
             searchHeader
                 .padding(.horizontal, AppSpacing.xl)
                 .padding(.horizontal, AppSpacing.xl)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.sm)
                 .padding(.bottom, AppSpacing.md)
                 .padding(.bottom, AppSpacing.md)
 
 
             if !viewModel.activeChips.isEmpty {
             if !viewModel.activeChips.isEmpty {

+ 52 - 67
indeed_ai_job_search/Features/JobSearch/Views/FiltersPanelView.swift

@@ -13,12 +13,13 @@ struct FiltersPanelView: View {
     @State private var expanded: Set<FilterSection> = [
     @State private var expanded: Set<FilterSection> = [
         .jobTitle, .exclude, .location, .employment, .experience
         .jobTitle, .exclude, .location, .employment, .experience
     ]
     ]
+    @State private var salaryText: String = ""
 
 
     var body: some View {
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
         VStack(alignment: .leading, spacing: 0) {
             header
             header
                 .padding(.horizontal, AppSpacing.md)
                 .padding(.horizontal, AppSpacing.md)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.sm)
                 .padding(.bottom, AppSpacing.md)
                 .padding(.bottom, AppSpacing.md)
 
 
             Divider().foregroundStyle(AppColors.border)
             Divider().foregroundStyle(AppColors.border)
@@ -37,6 +38,8 @@ struct FiltersPanelView: View {
         }
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
         .background(AppColors.surface)
         .background(AppColors.surface)
+        .onAppear { syncSalaryText() }
+        .onChange(of: criteria.salaryFloor) { _, _ in syncSalaryText() }
     }
     }
 
 
     private var header: some View {
     private var header: some View {
@@ -93,14 +96,13 @@ struct FiltersPanelView: View {
 
 
     private var jobTitleSection: some View {
     private var jobTitleSection: some View {
         VStack(alignment: .leading, spacing: AppSpacing.sm) {
         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")
+            FilterTextField(title: "Job title", prompt: "e.g. iOS Developer", text: $criteria.jobTitle)
+            FilterTextField(title: "Keywords", prompt: "Skills or keywords", text: $criteria.keywords)
+
             Toggle(isOn: $criteria.exactPhrase) {
             Toggle(isOn: $criteria.exactPhrase) {
                 Text("Exact phrase")
                 Text("Exact phrase")
-                    .font(AppTypography.body)
-                    .foregroundStyle(AppColors.textSecondary)
             }
             }
-            .toggleStyle(.checkbox)
+            .toggleStyle(FilterCheckboxToggleStyle())
             .onChange(of: criteria.exactPhrase) { _, _ in onChange() }
             .onChange(of: criteria.exactPhrase) { _, _ in onChange() }
         }
         }
         .onChange(of: criteria.jobTitle) { _, _ in onChange() }
         .onChange(of: criteria.jobTitle) { _, _ in onChange() }
@@ -108,31 +110,31 @@ struct FiltersPanelView: View {
     }
     }
 
 
     private var excludeSection: some View {
     private var excludeSection: some View {
-        filterField("Exclude keywords", text: $criteria.excludedKeywords, prompt: "Words to exclude")
-            .onChange(of: criteria.excludedKeywords) { _, _ in onChange() }
+        FilterTextField(
+            title: "Exclude keywords",
+            prompt: "Words to exclude",
+            text: $criteria.excludedKeywords
+        )
+        .onChange(of: criteria.excludedKeywords) { _, _ in onChange() }
     }
     }
 
 
     private var locationSection: some View {
     private var locationSection: some View {
         VStack(alignment: .leading, spacing: AppSpacing.sm) {
         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) {
+            FilterTextField(
+                title: "Country",
+                prompt: "e.g. Pakistan, United States",
+                text: $criteria.country
+            )
+            .onChange(of: criteria.country) { _, _ in onChange() }
+
+            FilterTextField(
+                title: "City / Region",
+                prompt: "e.g. Lahore",
+                text: $criteria.city
+            )
+            .onChange(of: criteria.city) { _, _ in onChange() }
+
+            VStack(alignment: .leading, spacing: 10) {
                 ForEach([RemoteType.remote, .hybrid, .onsite], id: \.self) { type in
                 ForEach([RemoteType.remote, .hybrid, .onsite], id: \.self) { type in
                     Toggle(isOn: Binding(
                     Toggle(isOn: Binding(
                         get: { criteria.remoteTypes.contains(type) },
                         get: { criteria.remoteTypes.contains(type) },
@@ -143,17 +145,16 @@ struct FiltersPanelView: View {
                         }
                         }
                     )) {
                     )) {
                         Text(type.filterLabel)
                         Text(type.filterLabel)
-                            .font(AppTypography.body)
-                            .foregroundStyle(AppColors.textSecondary)
                     }
                     }
-                    .toggleStyle(.checkbox)
+                    .toggleStyle(FilterCheckboxToggleStyle())
                 }
                 }
             }
             }
+            .padding(.top, 4)
         }
         }
     }
     }
 
 
     private var employmentSection: some View {
     private var employmentSection: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 10) {
             ForEach(EmploymentType.allCases) { type in
             ForEach(EmploymentType.allCases) { type in
                 Toggle(isOn: Binding(
                 Toggle(isOn: Binding(
                     get: { criteria.employmentTypes.contains(type) },
                     get: { criteria.employmentTypes.contains(type) },
@@ -164,16 +165,14 @@ struct FiltersPanelView: View {
                     }
                     }
                 )) {
                 )) {
                     Text(type.displayName)
                     Text(type.displayName)
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
                 }
                 }
-                .toggleStyle(.checkbox)
+                .toggleStyle(FilterCheckboxToggleStyle())
             }
             }
         }
         }
     }
     }
 
 
     private var experienceSection: some View {
     private var experienceSection: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 10) {
             ForEach(ExperienceLevel.allCases) { level in
             ForEach(ExperienceLevel.allCases) { level in
                 Toggle(isOn: Binding(
                 Toggle(isOn: Binding(
                     get: { criteria.experienceLevels.contains(level) },
                     get: { criteria.experienceLevels.contains(level) },
@@ -184,48 +183,34 @@ struct FiltersPanelView: View {
                     }
                     }
                 )) {
                 )) {
                     Text(level.displayName)
                     Text(level.displayName)
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
                 }
                 }
-                .toggleStyle(.checkbox)
+                .toggleStyle(FilterCheckboxToggleStyle())
             }
             }
         }
         }
     }
     }
 
 
     private var salarySection: some View {
     private var salarySection: some View {
         VStack(alignment: .leading, spacing: AppSpacing.sm) {
         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() }
+            FilterTextField(
+                title: "Minimum salary (USD / year)",
+                prompt: "e.g. 40000",
+                text: $salaryText
+            )
+            .onChange(of: salaryText) { _, newValue in
+                let digits = newValue.filter(\.isNumber)
+                if digits != newValue {
+                    salaryText = digits
+                }
+                criteria.salaryFloor = Double(digits)
+                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 func syncSalaryText() {
+        let next = criteria.salaryFloor.map { String(Int($0)) } ?? ""
+        if salaryText != next {
+            salaryText = next
         }
         }
     }
     }
 }
 }

+ 1 - 1
indeed_ai_job_search/Navigation/AppSidebarView.swift

@@ -13,7 +13,7 @@ struct AppSidebarView: View {
         VStack(alignment: .leading, spacing: 0) {
         VStack(alignment: .leading, spacing: 0) {
             brandHeader
             brandHeader
                 .padding(.horizontal, AppSpacing.md)
                 .padding(.horizontal, AppSpacing.md)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.windowChromeTop)
                 .padding(.bottom, AppSpacing.xl)
                 .padding(.bottom, AppSpacing.xl)
 
 
             VStack(spacing: 4) {
             VStack(spacing: 4) {