MainView.swift

14.7 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      if let status = appState.wallhavenStatus, appState.wallpapers.isEmpty {203        WallhavenStatusPanel(status: status, retry: refreshAction)204      } else {205        WallpaperGrid(206          wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)207      }208    }209  }210  func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }211  func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }212  func refresh() async { await appState.fetchWallpapers(reset: true) }213  func refreshAction() { Task { await refresh() } }214  func select(_ mode: FeedMode) {215    if appState.feedMode != mode { appState.feedMode = mode }216  }217  func loadMore() async { await appState.loadMore() }218  func loadMoreAction() { Task { await loadMore() } }219}220221/// Shown in place of the grid while Wallhaven is down, so an empty Haven reads222/// as Wallhaven's outage rather than as Haven having failed to load anything.223struct WallhavenStatusPanel: View {224  let status: WallhavenStatus225  let retry: () -> Void226227  var body: some View {228    VStack(spacing: 12) {229      Image(systemName: "icloud.slash")230        .font(.system(size: 40))231        .foregroundColor(.orange)232233      Text(status.title)234        .font(.headline)235236      Text(status.detail)237        .font(.callout)238        .foregroundColor(.secondary)239        .multilineTextAlignment(.center)240241      Text("Nothing is wrong with Haven — these wallpapers come from Wallhaven's servers.")242        .font(.caption)243        .foregroundColor(.secondary)244        .multilineTextAlignment(.center)245246      HStack(spacing: 12) {247        Link("Check Wallhaven's status", destination: Configuration.statusURL)248          .font(.callout)249        Button("Try Again", action: retry)250      }251      .padding(.top, 4)252    }253    .padding(24)254    .frame(maxWidth: .infinity, maxHeight: .infinity)255  }256}257258struct FeedModePill: View {259  let mode: FeedMode260  let isActive: Bool261  let action: () -> Void262263  // Neutral grey palette: lets the selected mode read as "active" without264  // // competing with the rest of the menu-bar app's muted UI.265  private var tint: Color {266    isActive ? Color.accentColor : Color.secondary267  }268269  var body: some View {270    Button(action: action) {271      HStack(spacing: 6) {272        Image(systemName: mode.systemImage)273        Text(mode.displayName)274          .font(.system(size: 13, weight: .medium))275      }276      .padding(.horizontal, 14)277      .padding(.vertical, 8)278      .frame(maxWidth: .infinity)279      .foregroundColor(isActive ? .white : .primary)280      .background(281        RoundedRectangle(cornerRadius: 10)282          .fill(isActive ? Color.accentColor : Color.clear)283      )284      .overlay(285        RoundedRectangle(cornerRadius: 10)286          .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)287      )288      // A `.plain` button hit-tests only its label's content (icon and text),289      // not the background or flexible frame around them, so without an290      // explicit shape the padded interior of the pill swallows clicks that291      // look like they land on it.292      .contentShape(Rectangle())293    }294    .buttonStyle(.plain)295  }296}297298struct FavoritesView: View {299  @ObservedObject var appState: AppState300  @ObservedObject var favoritesManager: FavoritesManager301302  init(appState: AppState, favoritesManager: FavoritesManager) {303    self.appState = appState304    self.favoritesManager = favoritesManager305  }306307  var body: some View {308    if favoritesManager.favorites.isEmpty {309      EmptyState(icon: "heart.slash", text: "No favorites yet")310    } else {311      WallpaperGrid(312        wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)313    }314  }315}316317struct HistoryView: View {318  @ObservedObject var appState: AppState319  @ObservedObject var historyManager: HistoryManager320321  init(appState: AppState, historyManager: HistoryManager) {322    self.appState = appState323    self.historyManager = historyManager324  }325326  var body: some View {327    VStack {328      if !historyManager.history.isEmpty {329        HStack {330          Spacer()331          Button("Clear History") { clearHistory() }332            .font(.caption)333            .buttonStyle(.link)334        }335        .padding(.horizontal)336        .padding(.top, 8)337      }338339      if historyManager.history.isEmpty {340        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")341      } else {342        WallpaperGrid(343          wallpapers: historyManager.history, appState: appState, onEndReached: nil)344      }345    }346  }347348  func clearHistory() { historyManager.clearHistory() }349}350351struct SettingsView: View {352  @ObservedObject var appState: AppState353354  var body: some View {355    VStack(alignment: .leading, spacing: 24) {356      VStack(alignment: .leading, spacing: 12) {357        Text("Storage")358          .font(.headline)359360        Toggle("Launch at Login", isOn: $appState.launchAtLogin)361        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)362363        if appState.saveToFolderEnabled {364          HStack {365            Text("Location")366            Spacer()367            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)368              .foregroundColor(.secondary)369              .lineLimit(1)370              .truncationMode(.middle)371            Button("Change…") { selectFolder() }372          }373        }374      }375376      VStack(alignment: .leading, spacing: 12) {377        HStack {378          Text("Source folders")379            .font(.headline)380          Spacer()381          Button(action: addSourceFolders) {382            Label("Add folder…", systemImage: "plus")383          }384          .buttonStyle(.link)385        }386387        if appState.sourceFolderPaths.isEmpty {388          EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet")389            .frame(height: 120)390        } else {391          VStack(spacing: 6) {392            ForEach(appState.sourceFolderPaths, id: \.self) { path in393              HStack {394                Image(systemName: "folder")395                  .foregroundColor(.secondary)396                Text(URL(fileURLWithPath: path).lastPathComponent)397                  .lineLimit(1)398                  .truncationMode(.middle)399                Spacer()400                Text(path)401                  .font(.caption2)402                  .foregroundColor(.secondary)403                  .lineLimit(1)404                  .truncationMode(.middle)405                Button(action: { appState.removeSourceFolder(path) }) {406                  Image(systemName: "minus.circle")407                }408                .buttonStyle(.plain)409                .foregroundColor(.secondary)410                .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)")411              }412              .padding(.horizontal, 8)413              .padding(.vertical, 4)414              .background(415                RoundedRectangle(cornerRadius: 6)416                  .fill(Color.secondary.opacity(0.08))417              )418            }419          }420        }421      }422423      Spacer()424425      Button(role: .destructive, action: quit) {426        Text("Quit Haven")427          .frame(maxWidth: .infinity)428      }429      .controlSize(.large)430    }431    .padding()432    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)433  }434435  func selectFolder() { appState.selectSaveFolder() }436  func addSourceFolders() { appState.addSourceFolders() }437  func quit() { appState.quit() }438}439440struct EmptyState: View {441  let icon: String442  let text: String443444  var body: some View {445    VStack(spacing: 16) {446      Image(systemName: icon)447        .font(.system(size: 48))448        .foregroundColor(.gray.opacity(0.5))449      Text(text)450        .foregroundColor(.secondary)451    }452    .frame(maxWidth: .infinity, maxHeight: .infinity)453  }454}455456struct TabButton: View {457  let icon: String458  let tab: AppTab459  @Binding var activeTab: AppTab460461  var body: some View {462    Button(action: select) {463      Image(systemName: icon)464        .symbolVariant(activeTab == tab ? .fill : .none)465        .foregroundColor(activeTab == tab ? .accentColor : .secondary)466        .font(.system(size: 18))467    }468    .buttonStyle(.plain)469  }470471  func select() { activeTab = tab }472}473474// Redeclaring this as it was in the original file, or we can move to a separate file475struct VisualEffectView: NSViewRepresentable {476  let material: NSVisualEffectView.Material477  let blendingMode: NSVisualEffectView.BlendingMode478479  func makeNSView(context: Context) -> NSVisualEffectView {480    let visualEffectView = NSVisualEffectView()481    visualEffectView.material = material482    visualEffectView.blendingMode = blendingMode483    visualEffectView.state = .active484    return visualEffectView485  }486487  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {488    nsView.material = material489    nsView.blendingMode = blendingMode490  }491}