Преглед изворни кода

Redesign Saved Jobs with clearer filters and unsave confirmation.

Polish the saved list layout, status chips, and work-type filter, and require confirmation before removing a bookmarked job.

Co-authored-by: Cursor <cursoragent@cursor.com>
faiz ali пре 21 часа
родитељ
комит
faa54282b6

+ 14 - 6
indeed_ai_job_search/DesignSystem/Components/JobRowView.swift

@@ -10,6 +10,7 @@ struct JobRowView: View {
     let isSaved: Bool
     var onToggleSave: () -> Void
     var isCompact: Bool = false
+    var showsCardChrome: Bool = true
 
     private var isUnavailable: Bool {
         listing.availability == .unavailable
@@ -57,13 +58,20 @@ struct JobRowView: View {
             }
         }
         .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)
+        .background(showsCardChrome ? AppColors.surfaceCard : Color.clear)
+        .clipShape(RoundedRectangle(cornerRadius: showsCardChrome ? AppSpacing.cardRadius : 0, style: .continuous))
+        .overlay {
+            if showsCardChrome {
+                RoundedRectangle(cornerRadius: AppSpacing.cardRadius, style: .continuous)
+                    .stroke(AppColors.border, lineWidth: 1)
+            }
+        }
+        .shadow(
+            color: showsCardChrome ? Color.black.opacity(AppShadow.cardOpacity) : .clear,
+            radius: showsCardChrome ? AppShadow.cardRadius : 0,
+            x: 0,
+            y: showsCardChrome ? AppShadow.cardY : 0
         )
-        .shadow(color: Color.black.opacity(AppShadow.cardOpacity), radius: AppShadow.cardRadius, x: 0, y: AppShadow.cardY)
         .accessibilityElement(children: .contain)
     }
 

+ 43 - 28
indeed_ai_job_search/Features/SavedJobs/ViewModels/SavedJobsViewModel.swift

@@ -6,36 +6,44 @@
 import Foundation
 import Observation
 
-enum SavedSortOption: String, CaseIterable, Identifiable {
-    case dateSaved
-    case postedDate
-    case title
-    case company
+enum SavedAvailabilityFilter: String, CaseIterable, Identifiable {
+    case all
+    case open
+    case unavailable
 
     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"
+        case .all: return "All"
+        case .open: return "Open"
+        case .unavailable: return "Unavailable"
+        }
+    }
+
+    var iconName: String {
+        switch self {
+        case .all: return "square.stack.3d.up"
+        case .open: return "checkmark.circle"
+        case .unavailable: return "xmark.circle"
         }
     }
 }
 
-enum SavedAvailabilityFilter: String, CaseIterable, Identifiable {
+enum SavedWorkModeFilter: String, CaseIterable, Identifiable {
     case all
-    case open
-    case unavailable
+    case remote
+    case hybrid
+    case onsite
 
     var id: String { rawValue }
 
     var displayName: String {
         switch self {
         case .all: return "All"
-        case .open: return "Open"
-        case .unavailable: return "Unavailable"
+        case .remote: return "Remote"
+        case .hybrid: return "Hybrid"
+        case .onsite: return "On-site"
         }
     }
 }
@@ -44,9 +52,8 @@ enum SavedAvailabilityFilter: String, CaseIterable, Identifiable {
 @MainActor
 final class SavedJobsViewModel {
     var searchText = ""
-    var sortOption: SavedSortOption = .dateSaved
     var availabilityFilter: SavedAvailabilityFilter = .all
-    var remoteOnly = false
+    var workModeFilter: SavedWorkModeFilter = .all
     private(set) var listVersion = 0
 
     let store: MockSavedJobsStore
@@ -60,6 +67,12 @@ final class SavedJobsViewModel {
         return store.savedJobs
     }
 
+    var hasActiveFilters: Bool {
+        !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            || availabilityFilter != .all
+            || workModeFilter != .all
+    }
+
     var filteredJobs: [SavedJob] {
         var items = savedJobs
 
@@ -80,21 +93,17 @@ final class SavedJobsViewModel {
             items = items.filter { $0.availability == .unavailable }
         }
 
-        if remoteOnly {
+        switch workModeFilter {
+        case .all: break
+        case .remote:
             items = items.filter { $0.listing.remoteType == .remote }
+        case .hybrid:
+            items = items.filter { $0.listing.remoteType == .hybrid }
+        case .onsite:
+            items = items.filter { $0.listing.remoteType == .onsite }
         }
 
-        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 }
-        }
-
+        items.sort { $0.savedAt > $1.savedAt }
         return items
     }
 
@@ -108,6 +117,12 @@ final class SavedJobsViewModel {
         return store.isSaved(listing)
     }
 
+    func clearFilters() {
+        searchText = ""
+        availabilityFilter = .all
+        workModeFilter = .all
+    }
+
     func refreshFromStore() {
         listVersion += 1
     }

+ 246 - 51
indeed_ai_job_search/Features/SavedJobs/Views/SavedJobsView.swift

@@ -10,14 +10,17 @@ struct SavedJobsView: View {
     @Bindable var viewModel: SavedJobsViewModel
     var onGoToSearch: () -> Void
 
+    @State private var jobPendingUnsave: JobListing?
+    @State private var showUnsaveConfirmation = false
+
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
             header
                 .padding(.horizontal, AppSpacing.xl)
-                .padding(.top, AppSpacing.lg)
+                .padding(.top, AppSpacing.sm)
                 .padding(.bottom, AppSpacing.md)
 
-            toolbar
+            filterBar
                 .padding(.horizontal, AppSpacing.xl)
                 .padding(.bottom, AppSpacing.md)
 
@@ -27,56 +30,189 @@ struct SavedJobsView: View {
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
         .background(AppColors.backgroundPrimary)
+        .confirmationDialog(
+            "Remove saved job?",
+            isPresented: $showUnsaveConfirmation,
+            titleVisibility: .visible,
+            presenting: jobPendingUnsave
+        ) { listing in
+            Button("Remove", role: .destructive) {
+                viewModel.toggleSave(listing)
+                jobPendingUnsave = nil
+            }
+            Button("Cancel", role: .cancel) {
+                jobPendingUnsave = nil
+            }
+        } message: { listing in
+            Text("“\(listing.title)” at \(listing.company) will be removed from your saved jobs.")
+        }
     }
 
     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)
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 6) {
+                HStack(spacing: AppSpacing.sm) {
+                    Text("Saved Jobs")
+                        .font(AppTypography.sectionTitle)
+                        .foregroundStyle(AppColors.textPrimary)
+
+                    Text("\(viewModel.savedJobs.count)")
+                        .font(AppTypography.caption)
+                        .foregroundStyle(AppColors.brand)
+                        .padding(.horizontal, 8)
+                        .padding(.vertical, 3)
+                        .background(AppColors.brandSoft)
+                        .clipShape(Capsule())
+                }
+
+                Text("Bookmarked Indeed listings you want to revisit.")
+                    .font(AppTypography.body)
+                    .foregroundStyle(AppColors.textSecondary)
+            }
+
+            Spacer(minLength: AppSpacing.md)
+
+            if !viewModel.savedJobs.isEmpty {
+                Button {
+                    onGoToSearch()
+                } label: {
+                    Label("Find more jobs", systemImage: "sparkles")
+                }
+                .buttonStyle(SecondaryButtonStyle())
+            }
         }
     }
 
-    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)
+    private var filterBar: some View {
+        VStack(alignment: .leading, spacing: AppSpacing.md) {
+            HStack(spacing: AppSpacing.sm) {
+                searchField
+                    .frame(maxWidth: 360)
+
+                Spacer(minLength: 0)
+
+                if viewModel.hasActiveFilters {
+                    Button("Clear filters") {
+                        viewModel.clearFilters()
+                    }
+                    .buttonStyle(GhostButtonStyle())
+                }
             }
-            .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)
+            HStack(alignment: .center, spacing: AppSpacing.xl) {
+                HStack(spacing: AppSpacing.sm) {
+                    Text("Status")
+                        .font(AppTypography.caption)
+                        .foregroundStyle(AppColors.textTertiary)
+
+                    HStack(spacing: 6) {
+                        ForEach(SavedAvailabilityFilter.allCases) { filter in
+                            availabilityChip(filter)
+                        }
+                    }
                 }
+
+                workTypeDropdown
+
+                Spacer(minLength: 0)
             }
-            .frame(width: 150)
+        }
+        .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)
+    }
+
+    private var searchField: some View {
+        HStack(spacing: 8) {
+            Image(systemName: "magnifyingglass")
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(AppColors.textTertiary)
 
-            Picker("Availability", selection: $viewModel.availabilityFilter) {
-                ForEach(SavedAvailabilityFilter.allCases) { option in
+            TextField("", text: $viewModel.searchText, prompt: Text("Search title, company, location").foregroundStyle(AppColors.textTertiary))
+                .textFieldStyle(.plain)
+                .font(AppTypography.body)
+                .foregroundStyle(AppColors.textPrimary)
+                .multilineTextAlignment(.leading)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .frame(height: 18, alignment: .leading)
+
+            if !viewModel.searchText.isEmpty {
+                Button {
+                    viewModel.searchText = ""
+                } label: {
+                    Image(systemName: "xmark.circle.fill")
+                        .foregroundStyle(AppColors.textTertiary)
+                }
+                .buttonStyle(.plain)
+                .accessibilityLabel("Clear search")
+            }
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 10)
+        .background(AppColors.backgroundPrimary)
+        .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+        .overlay(
+            RoundedRectangle(cornerRadius: 8, style: .continuous)
+                .stroke(AppColors.borderStrong, lineWidth: 1)
+        )
+    }
+
+    private var workTypeDropdown: some View {
+        HStack(spacing: 8) {
+            Text("Work type")
+                .font(AppTypography.caption)
+                .foregroundStyle(AppColors.textTertiary)
+
+            Picker("Work type", selection: $viewModel.workModeFilter) {
+                ForEach(SavedWorkModeFilter.allCases) { option in
                     Text(option.displayName).tag(option)
                 }
             }
-            .frame(width: 140)
+            .labelsHidden()
+            .pickerStyle(.menu)
+            .tint(AppColors.textPrimary)
+            .font(AppTypography.chip)
+            .frame(minWidth: 100, alignment: .leading)
+        }
+    }
 
-            Toggle("Remote only", isOn: $viewModel.remoteOnly)
-                .toggleStyle(.checkbox)
-                .font(AppTypography.meta)
+    private func availabilityChip(_ filter: SavedAvailabilityFilter) -> some View {
+        let isSelected = viewModel.availabilityFilter == filter
 
-            Spacer()
+        return Button {
+            viewModel.availabilityFilter = filter
+        } label: {
+            HStack(spacing: 6) {
+                Image(systemName: filter.iconName)
+                    .font(.system(size: 11, weight: .semibold))
+                Text(filter.displayName)
+                    .font(AppTypography.chip)
+            }
+            .foregroundStyle(isSelected ? AppColors.brand : AppColors.textSecondary)
+            .padding(.horizontal, 14)
+            .padding(.vertical, 8)
+            .background(
+                Capsule()
+                    .fill(isSelected ? AppColors.brandSoft : Color.white)
+            )
+            .overlay(
+                Capsule()
+                    .stroke(isSelected ? AppColors.brand.opacity(0.35) : AppColors.borderStrong, lineWidth: isSelected ? 1.5 : 1)
+            )
+            .shadow(
+                color: isSelected ? AppColors.brand.opacity(0.12) : Color.black.opacity(0.04),
+                radius: isSelected ? 4 : 2,
+                x: 0,
+                y: 1
+            )
         }
+        .buttonStyle(.plain)
+        .accessibilityAddTraits(isSelected ? .isSelected : [])
     }
 
     @ViewBuilder
@@ -93,28 +229,87 @@ struct SavedJobsView: View {
             EmptyStateView(
                 icon: "line.3.horizontal.decrease.circle",
                 title: "No matches",
-                message: "Try clearing search or filters to see your saved jobs."
+                message: "Try clearing search or filters to see your saved jobs.",
+                actionTitle: "Clear filters",
+                action: { viewModel.clearFilters() }
             )
         } 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)
-                            }
+            VStack(alignment: .leading, spacing: AppSpacing.sm) {
+                Text(resultsSummary)
+                    .font(AppTypography.meta)
+                    .foregroundStyle(AppColors.textSecondary)
+                    .padding(.leading, 2)
+
+                ScrollView {
+                    LazyVStack(spacing: AppSpacing.sm) {
+                        ForEach(viewModel.filteredJobs) { saved in
+                            savedJobCard(saved)
                         }
                     }
                 }
             }
         }
     }
+
+    private var resultsSummary: String {
+        let shown = viewModel.filteredJobs.count
+        let total = viewModel.savedJobs.count
+        if shown == total {
+            return "\(shown) saved \(shown == 1 ? "job" : "jobs")"
+        }
+        return "Showing \(shown) of \(total) saved jobs"
+    }
+
+    private func savedJobCard(_ saved: SavedJob) -> some View {
+        VStack(alignment: .leading, spacing: 0) {
+            JobRowView(
+                listing: saved.listing,
+                isSaved: true,
+                onToggleSave: { requestUnsave(saved.listing) },
+                showsCardChrome: false
+            )
+
+            HStack(spacing: AppSpacing.md) {
+                Label("Saved \(RelativeDateLabel.relativeString(from: saved.savedAt))", systemImage: "bookmark.fill")
+                    .font(AppTypography.caption)
+                    .foregroundStyle(AppColors.brand)
+
+                Spacer(minLength: 0)
+
+                OpenIndeedLinkButton(url: saved.listing.indeedURL)
+            }
+            .padding(.horizontal, AppSpacing.md)
+            .padding(.vertical, 10)
+            .background(AppColors.brandSoft.opacity(0.45))
+            .clipShape(
+                UnevenRoundedRectangle(
+                    topLeadingRadius: 0,
+                    bottomLeadingRadius: AppSpacing.cardRadius,
+                    bottomTrailingRadius: AppSpacing.cardRadius,
+                    topTrailingRadius: 0,
+                    style: .continuous
+                )
+            )
+        }
+        .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)
+        .contextMenu {
+            Button("Open on Indeed") {
+                NSWorkspace.shared.open(saved.listing.indeedURL)
+            }
+            Button("Remove from saved", role: .destructive) {
+                requestUnsave(saved.listing)
+            }
+        }
+    }
+
+    private func requestUnsave(_ listing: JobListing) {
+        jobPendingUnsave = listing
+        showUnsaveConfirmation = true
+    }
 }