RootShellView.swift 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. //
  2. // RootShellView.swift
  3. // indeed_ai_job_search
  4. //
  5. import SwiftUI
  6. struct RootShellView: View {
  7. let dependencies: AppDependencies
  8. @State private var selection: SidebarItem = .jobSearch
  9. @State private var jobSearchVM: JobSearchViewModel
  10. @State private var savedJobsVM: SavedJobsViewModel
  11. @State private var alertsVM: JobAlertsViewModel
  12. @State private var settingsVM: SettingsViewModel
  13. @State private var showFiltersSheet = false
  14. init(dependencies: AppDependencies) {
  15. self.dependencies = dependencies
  16. _jobSearchVM = State(initialValue: JobSearchViewModel(dependencies: dependencies))
  17. _savedJobsVM = State(initialValue: SavedJobsViewModel(dependencies: dependencies))
  18. _alertsVM = State(initialValue: JobAlertsViewModel(dependencies: dependencies))
  19. _settingsVM = State(initialValue: SettingsViewModel())
  20. }
  21. var body: some View {
  22. GeometryReader { proxy in
  23. let width = proxy.size.width
  24. let showInlineFilters = width >= AppSpacing.mediumBreakpoint && selection == .jobSearch
  25. HStack(spacing: 0) {
  26. AppSidebarView(
  27. selection: $selection,
  28. unreadAlertCount: alertsVM.unreadCount
  29. )
  30. .frame(width: AppSpacing.sidebarWidth)
  31. .overlay(alignment: .trailing) {
  32. Rectangle()
  33. .fill(AppColors.border)
  34. .frame(width: 1)
  35. }
  36. VStack(spacing: 0) {
  37. topUtilityBar
  38. detail(showInlineFilters: showInlineFilters)
  39. .frame(maxWidth: .infinity, maxHeight: .infinity)
  40. }
  41. .frame(maxWidth: .infinity, maxHeight: .infinity)
  42. }
  43. .background(AppColors.backgroundPrimary)
  44. }
  45. .preferredColorScheme(settingsVM.preferredColorScheme)
  46. .sheet(isPresented: $showFiltersSheet) {
  47. NavigationStack {
  48. FiltersPanelView(
  49. criteria: $jobSearchVM.criteria,
  50. onReset: { jobSearchVM.resetFilters() },
  51. onChange: { jobSearchVM.applyFiltersAndRefresh() }
  52. )
  53. .toolbar {
  54. ToolbarItem(placement: .cancellationAction) {
  55. Button("Done") { showFiltersSheet = false }
  56. }
  57. }
  58. }
  59. .frame(minWidth: 360, minHeight: 560)
  60. }
  61. .sheet(isPresented: $alertsVM.showNotificationCenter) {
  62. NotificationCenterSheet(events: alertsVM.events)
  63. }
  64. .onChange(of: selection) { _, newValue in
  65. if newValue == .savedJobs {
  66. savedJobsVM.refreshFromStore()
  67. }
  68. }
  69. .background(keyboardShortcuts)
  70. }
  71. /// Notifications sit top-right above mid content and filters.
  72. private var topUtilityBar: some View {
  73. HStack(spacing: AppSpacing.sm) {
  74. Spacer(minLength: 0)
  75. Button {
  76. alertsVM.openNotificationCenter()
  77. } label: {
  78. Image(systemName: alertsVM.unreadCount > 0 ? "bell.badge" : "bell")
  79. .font(.system(size: 15, weight: .medium))
  80. .foregroundStyle(AppColors.textSecondary)
  81. .frame(width: 32, height: 32)
  82. .contentShape(Rectangle())
  83. }
  84. .buttonStyle(.plain)
  85. .help("Notification center")
  86. .accessibilityLabel("Notifications")
  87. }
  88. .padding(.horizontal, AppSpacing.xl)
  89. .frame(height: AppSpacing.windowChromeTop)
  90. .frame(maxWidth: .infinity)
  91. .background(AppColors.backgroundPrimary)
  92. }
  93. @ViewBuilder
  94. private func detail(showInlineFilters: Bool) -> some View {
  95. switch selection {
  96. case .jobSearch:
  97. AIJobSearchView(
  98. viewModel: jobSearchVM,
  99. showsInlineFilters: showInlineFilters,
  100. onRequestFiltersSheet: { showFiltersSheet = true }
  101. )
  102. case .savedJobs:
  103. SavedJobsView(viewModel: savedJobsVM) {
  104. selection = .jobSearch
  105. }
  106. case .jobAlerts:
  107. JobAlertsView(viewModel: alertsVM)
  108. case .settings:
  109. SettingsView(viewModel: settingsVM)
  110. }
  111. }
  112. private var keyboardShortcuts: some View {
  113. Button("") {
  114. selection = .jobSearch
  115. jobSearchVM.focusSearchField.toggle()
  116. }
  117. .keyboardShortcut("f", modifiers: .command)
  118. .opacity(0)
  119. .frame(width: 0, height: 0)
  120. .accessibilityHidden(true)
  121. }
  122. }
  123. #Preview("Root Shell") {
  124. RootShellView(dependencies: .preview)
  125. .frame(width: 1280, height: 820)
  126. }