MainView.swift

13.2 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    }249    .buttonStyle(.plain)250  }251}252253struct FavoritesView: View {254  @ObservedObject var appState: AppState255  @ObservedObject var favoritesManager: FavoritesManager256257  init(appState: AppState, favoritesManager: FavoritesManager) {258    self.appState = appState259    self.favoritesManager = favoritesManager260  }261262  var body: some View {263    if favoritesManager.favorites.isEmpty {264      EmptyState(icon: "heart.slash", text: "No favorites yet")265    } else {266      WallpaperGrid(267        wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)268    }269  }270}271272struct HistoryView: View {273  @ObservedObject var appState: AppState274  @ObservedObject var historyManager: HistoryManager275276  init(appState: AppState, historyManager: HistoryManager) {277    self.appState = appState278    self.historyManager = historyManager279  }280281  var body: some View {282    VStack {283      if !historyManager.history.isEmpty {284        HStack {285          Spacer()286          Button("Clear History") { clearHistory() }287            .font(.caption)288            .buttonStyle(.link)289        }290        .padding(.horizontal)291        .padding(.top, 8)292      }293294      if historyManager.history.isEmpty {295        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")296      } else {297        WallpaperGrid(298          wallpapers: historyManager.history, appState: appState, onEndReached: nil)299      }300    }301  }302303  func clearHistory() { historyManager.clearHistory() }304}305306struct SettingsView: View {307  @ObservedObject var appState: AppState308309  var body: some View {310    VStack(alignment: .leading, spacing: 24) {311      VStack(alignment: .leading, spacing: 12) {312        Text("Storage")313          .font(.headline)314315        Toggle("Launch at Login", isOn: $appState.launchAtLogin)316        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)317318        if appState.saveToFolderEnabled {319          HStack {320            Text("Location")321            Spacer()322            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)323              .foregroundColor(.secondary)324              .lineLimit(1)325              .truncationMode(.middle)326            Button("Change…") { selectFolder() }327          }328        }329      }330331      VStack(alignment: .leading, spacing: 12) {332        HStack {333          Text("Source folders")334            .font(.headline)335          Spacer()336          Button(action: addSourceFolders) {337            Label("Add folder…", systemImage: "plus")338          }339          .buttonStyle(.link)340        }341342        if appState.sourceFolderPaths.isEmpty {343          EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet")344            .frame(height: 120)345        } else {346          VStack(spacing: 6) {347            ForEach(appState.sourceFolderPaths, id: \.self) { path in348              HStack {349                Image(systemName: "folder")350                  .foregroundColor(.secondary)351                Text(URL(fileURLWithPath: path).lastPathComponent)352                  .lineLimit(1)353                  .truncationMode(.middle)354                Spacer()355                Text(path)356                  .font(.caption2)357                  .foregroundColor(.secondary)358                  .lineLimit(1)359                  .truncationMode(.middle)360                Button(action: { appState.removeSourceFolder(path) }) {361                  Image(systemName: "minus.circle")362                }363                .buttonStyle(.plain)364                .foregroundColor(.secondary)365                .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)")366              }367              .padding(.horizontal, 8)368              .padding(.vertical, 4)369              .background(370                RoundedRectangle(cornerRadius: 6)371                  .fill(Color.secondary.opacity(0.08))372              )373            }374          }375        }376      }377378      Spacer()379380      Button(role: .destructive, action: quit) {381        Text("Quit Haven")382          .frame(maxWidth: .infinity)383      }384      .controlSize(.large)385    }386    .padding()387    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)388  }389390  func selectFolder() { appState.selectSaveFolder() }391  func addSourceFolders() { appState.addSourceFolders() }392  func quit() { appState.quit() }393}394395struct EmptyState: View {396  let icon: String397  let text: String398399  var body: some View {400    VStack(spacing: 16) {401      Image(systemName: icon)402        .font(.system(size: 48))403        .foregroundColor(.gray.opacity(0.5))404      Text(text)405        .foregroundColor(.secondary)406    }407    .frame(maxWidth: .infinity, maxHeight: .infinity)408  }409}410411struct TabButton: View {412  let icon: String413  let tab: AppTab414  @Binding var activeTab: AppTab415416  var body: some View {417    Button(action: select) {418      Image(systemName: icon)419        .symbolVariant(activeTab == tab ? .fill : .none)420        .foregroundColor(activeTab == tab ? .accentColor : .secondary)421        .font(.system(size: 18))422    }423    .buttonStyle(.plain)424  }425426  func select() { activeTab = tab }427}428429// Redeclaring this as it was in the original file, or we can move to a separate file430struct VisualEffectView: NSViewRepresentable {431  let material: NSVisualEffectView.Material432  let blendingMode: NSVisualEffectView.BlendingMode433434  func makeNSView(context: Context) -> NSVisualEffectView {435    let visualEffectView = NSVisualEffectView()436    visualEffectView.material = material437    visualEffectView.blendingMode = blendingMode438    visualEffectView.state = .active439    return visualEffectView440  }441442  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {443    nsView.material = material444    nsView.blendingMode = blendingMode445  }446}