|
@@ -10,14 +10,17 @@ struct SavedJobsView: View {
|
|
|
@Bindable var viewModel: SavedJobsViewModel
|
|
@Bindable var viewModel: SavedJobsViewModel
|
|
|
var onGoToSearch: () -> Void
|
|
var onGoToSearch: () -> Void
|
|
|
|
|
|
|
|
|
|
+ @State private var jobPendingUnsave: JobListing?
|
|
|
|
|
+ @State private var showUnsaveConfirmation = false
|
|
|
|
|
+
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
header
|
|
header
|
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
|
- .padding(.top, AppSpacing.lg)
|
|
|
|
|
|
|
+ .padding(.top, AppSpacing.sm)
|
|
|
.padding(.bottom, AppSpacing.md)
|
|
.padding(.bottom, AppSpacing.md)
|
|
|
|
|
|
|
|
- toolbar
|
|
|
|
|
|
|
+ filterBar
|
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
.padding(.horizontal, AppSpacing.xl)
|
|
|
.padding(.bottom, AppSpacing.md)
|
|
.padding(.bottom, AppSpacing.md)
|
|
|
|
|
|
|
@@ -27,56 +30,189 @@ struct SavedJobsView: View {
|
|
|
}
|
|
}
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
.background(AppColors.backgroundPrimary)
|
|
.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 {
|
|
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)
|
|
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
|
|
@ViewBuilder
|
|
@@ -93,28 +229,87 @@ struct SavedJobsView: View {
|
|
|
EmptyStateView(
|
|
EmptyStateView(
|
|
|
icon: "line.3.horizontal.decrease.circle",
|
|
icon: "line.3.horizontal.decrease.circle",
|
|
|
title: "No matches",
|
|
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 {
|
|
} 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
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|