MainView.swift

13.5 KB · raw

1import SwiftUI23struct MainView: View {4  @StateObject private var appState: AppState56  init(appState: AppState? = nil) {7    let appState = appState ?? AppState()8    _appState = StateObject(wrappedValue: appState)9  }1011  var body: some View {12    ZStack {13      VStack(spacing: 0) {14        // Header (Universal)15        HStack {16          Text("Haven")17            .font(.custom("Avenir Next Medium", size: 18))1819          Spacer()2021          // Tab Bar22          HStack(spacing: 16) {23            TabButton(icon: "safari", tab: .explore, activeTab: $appState.activeTab)24            TabButton(icon: "folder", tab: .local, activeTab: $appState.activeTab)25            TabButton(icon: "heart", tab: .favorites, activeTab: $appState.activeTab)26            TabButton(icon: "clock", tab: .history, activeTab: $appState.activeTab)27            TabButton(icon: "gearshape", tab: .settings, activeTab: $appState.activeTab)28          }29        }30        .padding()31        .background(VisualEffectView(material: .headerView, blendingMode: .behindWindow))3233        Divider()3435        ErrorBanner(appState: appState)3637        // Content38        ZStack {39          switch appState.activeTab {40          case .explore:41            ExploreView(appState: appState)42          case .local:43            LocalView(appState: appState)44          case .favorites:45            FavoritesView(appState: appState, favoritesManager: appState.favoritesManager)46          case .history:47            HistoryView(appState: appState, historyManager: appState.historyManager)48          case .settings:49            SettingsView(appState: appState)50          }51        }52        .frame(maxWidth: .infinity, maxHeight: .infinity)53      }54    }55    .frame(width: 450, height: 640)56    .background(Color(nsColor: .windowBackgroundColor))57    .onAppear {58      Task { await appState.fetchWallpapers() }59    }60  }61}6263/// Compact banner that surfaces the most recent error from `AppState` or64/// the two nested persistence managers, in priority order:65/// 1. `appState.errorMessage` (operational failures)66/// 2. `historyManager.persistenceError`67/// 3. `favoritesManager.persistenceError`68/// Each tier clears only its own message so unrelated errors stay visible.69struct ErrorBanner: View {70  @ObservedObject var appState: AppState71  @ObservedObject var favoritesManager: FavoritesManager72  @ObservedObject var historyManager: HistoryManager7374  init(75    appState: AppState,76    favoritesManager: FavoritesManager = FavoritesManager.shared,77    historyManager: HistoryManager = HistoryManager.shared78  ) {79    self.appState = appState80    self.favoritesManager = favoritesManager81    self.historyManager = historyManager82  }8384  var body: some View {85    if let entry = bannerEntry {86      HStack(alignment: .top, spacing: 8) {87        Image(systemName: entry.systemImage)88          .foregroundColor(.white)89        Text(entry.message)90          .font(.callout)91          .foregroundColor(.white)92          .frame(maxWidth: .infinity, alignment: .leading)93          .multilineTextAlignment(.leading)94        Button(action: entry.dismiss) {95          Image(systemName: "xmark.circle.fill")96            .foregroundColor(.white.opacity(0.85))97        }98        .buttonStyle(.plain)99        .accessibilityLabel("Dismiss")100      }101      .padding(.horizontal, 12)102      .padding(.vertical, 8)103      .background(Color(red: 0.66, green: 0.20, blue: 0.20))104      .transition(.opacity)105    }106  }107108  private struct Entry {109    let message: String110    let systemImage: String111    let dismiss: () -> Void112  }113114  private var bannerEntry: Entry? {115    if let message = appState.errorMessage {116      return Entry(message: message, systemImage: "exclamationmark.triangle.fill") {117        appState.errorMessage = nil118      }119    }120    if let message = historyManager.persistenceError {121      return Entry(message: message, systemImage: "clock.arrow.circlepath") {122        historyManager.persistenceError = nil123      }124    }125    if let message = favoritesManager.persistenceError {126      return Entry(message: message, systemImage: "heart.slash.fill") {127        favoritesManager.persistenceError = nil128      }129    }130    return nil131  }132}133134// MARK: - Subviews135136struct ExploreView: View {137  @ObservedObject var appState: AppState138139  var body: some View {140    VStack(spacing: 0) {141      // Controls142      HStack {143        HStack {144          Image(systemName: "magnifyingglass")145            .foregroundColor(.secondary)146          TextField("Search...", text: $appState.searchText)147        }148        .padding(8)149        .background(Color(nsColor: .controlBackgroundColor))150        .cornerRadius(8)151152        Menu {153          Section("Categories") {154            Toggle("General", isOn: $appState.categories.general)155            Toggle("Anime", isOn: $appState.categories.anime)156            Toggle("People", isOn: $appState.categories.people)157          }158159          Section("Purity") {160            Toggle("SFW", isOn: $appState.purity.sfw)161            Toggle("Sketchy", isOn: $appState.purity.sketchy)162          }163164          Divider()165166          Picker("Resolution", selection: $appState.selectedResolution) {167            ForEach(appState.availableResolutions, id: \.self) { resolutionOption($0) }168          }169          Picker("Ratio", selection: $appState.selectedRatio) {170            ForEach(appState.availableRatios, id: \.self) { ratioOption($0) }171          }172        } label: {173          Image(systemName: "slider.horizontal.3")174        }175176        .menuStyle(.borderlessButton)177        .frame(width: 30)178179        Button(action: refreshAction) {180          Image(systemName: "arrow.clockwise")181        }182        .buttonStyle(.plain)183        .frame(width: 30)184      }185      .padding()186187      Divider()188189      // Feed-mode selector: Latest, Toplist, Random. The active mode is190      // visually emphasised; taps update appState.feedMode and trigger191      // a reset fetch via the Combine observer in AppState.192      HStack(spacing: 10) {193        ForEach(FeedMode.allCases) { mode in194          FeedModePill(mode: mode, isActive: appState.feedMode == mode) { select(mode) }195        }196      }197      .padding(.horizontal)198      .padding(.vertical, 10)199200      Divider()201202      WallpaperGrid(203        wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)204    }205  }206207  func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }208  func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }209  func refresh() async { await appState.fetchWallpapers(reset: true) }210  func refreshAction() { Task { await refresh() } }211  func select(_ mode: FeedMode) {212    if appState.feedMode != mode { appState.feedMode = mode }213  }214  func loadMore() async { await appState.loadMore() }215  func loadMoreAction() { Task { await loadMore() } }216}217218struct FeedModePill: View {219  let mode: FeedMode220  let isActive: Bool221  let action: () -> Void222223  // Neutral grey palette: lets the selected mode read as "active" without224  // // competing with the rest of the menu-bar app's muted UI.225  private var tint: Color {226    isActive ? Color.accentColor : Color.secondary227  }228229  var body: some View {230    Button(action: action) {231      HStack(spacing: 6) {232        Image(systemName: mode.systemImage)233        Text(mode.displayName)234          .font(.system(size: 13, weight: .medium))235      }236      .padding(.horizontal, 14)237      .padding(.vertical, 8)238      .frame(maxWidth: .infinity)239      .foregroundColor(isActive ? .white : .primary)240      .background(241        RoundedRectangle(cornerRadius: 10)242          .fill(isActive ? Color.accentColor : Color.clear)243      )244      .overlay(245        RoundedRectangle(cornerRadius: 10)246          .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)247      )248      // A `.plain` button hit-tests only its label's content (icon and text),249      // not the background or flexible frame around them, so without an250      // explicit shape the padded interior of the pill swallows clicks that251      // look like they land on it.252      .contentShape(Rectangle())253    }254    .buttonStyle(.plain)255  }256}257258struct FavoritesView: View {259  @ObservedObject var appState: AppState260  @ObservedObject var favoritesManager: FavoritesManager261262  init(appState: AppState, favoritesManager: FavoritesManager) {263    self.appState = appState264    self.favoritesManager = favoritesManager265  }266267  var body: some View {268    if favoritesManager.favorites.isEmpty {269      EmptyState(icon: "heart.slash", text: "No favorites yet")270    } else {271      WallpaperGrid(272        wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)273    }274  }275}276277struct HistoryView: View {278  @ObservedObject var appState: AppState279  @ObservedObject var historyManager: HistoryManager280281  init(appState: AppState, historyManager: HistoryManager) {282    self.appState = appState283    self.historyManager = historyManager284  }285286  var body: some View {287    VStack {288      if !historyManager.history.isEmpty {289        HStack {290          Spacer()291          Button("Clear History") { clearHistory() }292            .font(.caption)293            .buttonStyle(.link)294        }295        .padding(.horizontal)296        .padding(.top, 8)297      }298299      if historyManager.history.isEmpty {300        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")301      } else {302        WallpaperGrid(303          wallpapers: historyManager.history, appState: appState, onEndReached: nil)304      }305    }306  }307308  func clearHistory() { historyManager.clearHistory() }309}310311struct SettingsView: View {312  @ObservedObject var appState: AppState313314  var body: some View {315    VStack(alignment: .leading, spacing: 24) {316      VStack(alignment: .leading, spacing: 12) {317        Text("Storage")318          .font(.headline)319320        Toggle("Launch at Login", isOn: $appState.launchAtLogin)321        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)322323        if appState.saveToFolderEnabled {324          HStack {325            Text("Location")326            Spacer()327            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)328              .foregroundColor(.secondary)329              .lineLimit(1)330              .truncationMode(.middle)331            Button("Change…") { selectFolder() }332          }333        }334      }335336      VStack(alignment: .leading, spacing: 12) {337        HStack {338          Text("Source folders")339            .font(.headline)340          Spacer()341          Button(action: addSourceFolders) {342            Label("Add folder…", systemImage: "plus")343          }344          .buttonStyle(.link)345        }346347        if appState.sourceFolderPaths.isEmpty {348          EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet")349            .frame(height: 120)350        } else {351          VStack(spacing: 6) {352            ForEach(appState.sourceFolderPaths, id: \.self) { path in353              HStack {354                Image(systemName: "folder")355                  .foregroundColor(.secondary)356                Text(URL(fileURLWithPath: path).lastPathComponent)357                  .lineLimit(1)358                  .truncationMode(.middle)359                Spacer()360                Text(path)361                  .font(.caption2)362                  .foregroundColor(.secondary)363                  .lineLimit(1)364                  .truncationMode(.middle)365                Button(action: { appState.removeSourceFolder(path) }) {366                  Image(systemName: "minus.circle")367                }368                .buttonStyle(.plain)369                .foregroundColor(.secondary)370                .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)")371              }372              .padding(.horizontal, 8)373              .padding(.vertical, 4)374              .background(375                RoundedRectangle(cornerRadius: 6)376                  .fill(Color.secondary.opacity(0.08))377              )378            }379          }380        }381      }382383      Spacer()384385      Button(role: .destructive, action: quit) {386        Text("Quit Haven")387          .frame(maxWidth: .infinity)388      }389      .controlSize(.large)390    }391    .padding()392    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)393  }394395  func selectFolder() { appState.selectSaveFolder() }396  func addSourceFolders() { appState.addSourceFolders() }397  func quit() { appState.quit() }398}399400struct EmptyState: View {401  let icon: String402  let text: String403404  var body: some View {405    VStack(spacing: 16) {406      Image(systemName: icon)407        .font(.system(size: 48))408        .foregroundColor(.gray.opacity(0.5))409      Text(text)410        .foregroundColor(.secondary)411    }412    .frame(maxWidth: .infinity, maxHeight: .infinity)413  }414}415416struct TabButton: View {417  let icon: String418  let tab: AppTab419  @Binding var activeTab: AppTab420421  var body: some View {422    Button(action: select) {423      Image(systemName: icon)424        .symbolVariant(activeTab == tab ? .fill : .none)425        .foregroundColor(activeTab == tab ? .accentColor : .secondary)426        .font(.system(size: 18))427    }428    .buttonStyle(.plain)429  }430431  func select() { activeTab = tab }432}433434// Redeclaring this as it was in the original file, or we can move to a separate file435struct VisualEffectView: NSViewRepresentable {436  let material: NSVisualEffectView.Material437  let blendingMode: NSVisualEffectView.BlendingMode438439  func makeNSView(context: Context) -> NSVisualEffectView {440    let visualEffectView = NSVisualEffectView()441    visualEffectView.material = material442    visualEffectView.blendingMode = blendingMode443    visualEffectView.state = .active444    return visualEffectView445  }446447  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {448    nsView.material = material449    nsView.blendingMode = blendingMode450  }451}