Эх сурвалжийг харах

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 цаг өмнө
parent
commit
d147555895

+ 31 - 26
indeed_ai_job_search/App/RootShellView.swift

@@ -40,36 +40,17 @@ struct RootShellView: View {
                         .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) {
             NavigationStack {
                 FiltersPanelView(
@@ -96,6 +77,30 @@ struct RootShellView: View {
         .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
     private func detail(showInlineFilters: Bool) -> some View {
         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 {
         WindowGroup {
             RootShellView(dependencies: dependencies)
+                .ignoresSafeArea()
         }
+        .windowStyle(.hiddenTitleBar)
         .defaultSize(width: 1280, height: 820)
         .commands {
             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 filtersWidth: CGFloat = 280
+    /// Clears traffic lights / top utility row (bell + profile).
+    static let windowChromeTop: CGFloat = 52
     static let compactBreakpoint: CGFloat = 900
     static let mediumBreakpoint: CGFloat = 1100
     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) {
             searchHeader
                 .padding(.horizontal, AppSpacing.xl)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.sm)
                 .padding(.bottom, AppSpacing.md)
 
             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> = [
         .jobTitle, .exclude, .location, .employment, .experience
     ]
+    @State private var salaryText: String = ""
 
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
             header
                 .padding(.horizontal, AppSpacing.md)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.sm)
                 .padding(.bottom, AppSpacing.md)
 
             Divider().foregroundStyle(AppColors.border)
@@ -37,6 +38,8 @@ struct FiltersPanelView: View {
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
         .background(AppColors.surface)
+        .onAppear { syncSalaryText() }
+        .onChange(of: criteria.salaryFloor) { _, _ in syncSalaryText() }
     }
 
     private var header: some View {
@@ -93,14 +96,13 @@ struct FiltersPanelView: View {
 
     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")
+            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) {
                 Text("Exact phrase")
-                    .font(AppTypography.body)
-                    .foregroundStyle(AppColors.textSecondary)
             }
-            .toggleStyle(.checkbox)
+            .toggleStyle(FilterCheckboxToggleStyle())
             .onChange(of: criteria.exactPhrase) { _, _ in onChange() }
         }
         .onChange(of: criteria.jobTitle) { _, _ in onChange() }
@@ -108,31 +110,31 @@ struct FiltersPanelView: 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 {
         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
                     Toggle(isOn: Binding(
                         get: { criteria.remoteTypes.contains(type) },
@@ -143,17 +145,16 @@ struct FiltersPanelView: View {
                         }
                     )) {
                         Text(type.filterLabel)
-                            .font(AppTypography.body)
-                            .foregroundStyle(AppColors.textSecondary)
                     }
-                    .toggleStyle(.checkbox)
+                    .toggleStyle(FilterCheckboxToggleStyle())
                 }
             }
+            .padding(.top, 4)
         }
     }
 
     private var employmentSection: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 10) {
             ForEach(EmploymentType.allCases) { type in
                 Toggle(isOn: Binding(
                     get: { criteria.employmentTypes.contains(type) },
@@ -164,16 +165,14 @@ struct FiltersPanelView: View {
                     }
                 )) {
                     Text(type.displayName)
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
                 }
-                .toggleStyle(.checkbox)
+                .toggleStyle(FilterCheckboxToggleStyle())
             }
         }
     }
 
     private var experienceSection: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 10) {
             ForEach(ExperienceLevel.allCases) { level in
                 Toggle(isOn: Binding(
                     get: { criteria.experienceLevels.contains(level) },
@@ -184,48 +183,34 @@ struct FiltersPanelView: View {
                     }
                 )) {
                     Text(level.displayName)
-                        .font(AppTypography.body)
-                        .foregroundStyle(AppColors.textSecondary)
                 }
-                .toggleStyle(.checkbox)
+                .toggleStyle(FilterCheckboxToggleStyle())
             }
         }
     }
 
     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() }
+            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) {
             brandHeader
                 .padding(.horizontal, AppSpacing.md)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.windowChromeTop)
                 .padding(.bottom, AppSpacing.xl)
 
             VStack(spacing: 4) {