MainView.swift

9.0 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        // Content36        ZStack {37          switch appState.activeTab {38          case .explore:39            ExploreView(appState: appState)40          case .local:41            LocalView(appState: appState)42          case .favorites:43            FavoritesView(appState: appState)44          case .history:45            HistoryView(appState: appState)46          case .settings:47            SettingsView(appState: appState)48          }49        }50        .frame(maxWidth: .infinity, maxHeight: .infinity)51      }52    }53    .frame(width: 450, height: 640)54    .background(Color(nsColor: .windowBackgroundColor))55    .onAppear {56      Task { await appState.fetchWallpapers() }57    }58  }59}6061// MARK: - Subviews6263struct ExploreView: View {64  @ObservedObject var appState: AppState6566  var body: some View {67    VStack(spacing: 0) {68      // Controls69      HStack {70        HStack {71          Image(systemName: "magnifyingglass")72            .foregroundColor(.secondary)73          TextField("Search...", text: $appState.searchText)74        }75        .padding(8)76        .background(Color(nsColor: .controlBackgroundColor))77        .cornerRadius(8)7879        Menu {80          Section("Categories") {81            Toggle("General", isOn: $appState.categories.general)82            Toggle("Anime", isOn: $appState.categories.anime)83            Toggle("People", isOn: $appState.categories.people)84          }8586          Section("Purity") {87            Toggle("SFW", isOn: $appState.purity.sfw)88            Toggle("Sketchy", isOn: $appState.purity.sketchy)89          }9091          Divider()9293          Picker("Resolution", selection: $appState.selectedResolution) {94            ForEach(appState.availableResolutions, id: \.self) { resolutionOption($0) }95          }96          Picker("Ratio", selection: $appState.selectedRatio) {97            ForEach(appState.availableRatios, id: \.self) { ratioOption($0) }98          }99        } label: {100          Image(systemName: "slider.horizontal.3")101        }102103        .menuStyle(.borderlessButton)104        .frame(width: 30)105106        Button(action: refreshAction) {107          Image(systemName: "arrow.clockwise")108        }109        .buttonStyle(.plain)110        .frame(width: 30)111      }112      .padding()113      .onChange(of: appState.selectedResolution) { _ in Task { await appState.fetchWallpapers() } }114      .onChange(of: appState.selectedRatio) { _ in Task { await appState.fetchWallpapers() } }115116      Divider()117118      // Feed-mode selector: Latest, Toplist, Random. The active mode is119      // visually emphasised; taps update appState.feedMode and trigger120      // a reset fetch via the Combine observer in AppState.121      HStack(spacing: 10) {122        ForEach(FeedMode.allCases) { mode in123          FeedModePill(mode: mode, isActive: appState.feedMode == mode) { select(mode) }124        }125      }126      .padding(.horizontal)127      .padding(.vertical, 10)128129      Divider()130131      WallpaperGrid(132        wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)133    }134  }135136  func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }137  func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }138  func refresh() async { await appState.fetchWallpapers(reset: true) }139  func refreshAction() { Task { await refresh() } }140  func select(_ mode: FeedMode) {141    if appState.feedMode != mode { appState.feedMode = mode }142  }143  func loadMore() async { await appState.loadMore() }144  func loadMoreAction() { Task { await loadMore() } }145}146147struct FeedModePill: View {148  let mode: FeedMode149  let isActive: Bool150  let action: () -> Void151152  private var tint: Color {153    switch mode {154    case .latest: return Color(red: 0.45, green: 0.85, blue: 0.30)  // green155    case .toplist: return Color(red: 0.85, green: 0.40, blue: 0.85)  // magenta156    case .random: return Color(red: 0.98, green: 0.55, blue: 0.20)  // orange157    }158  }159160  var body: some View {161    Button(action: action) {162      HStack(spacing: 6) {163        Image(systemName: mode.systemImage)164        Text(mode.displayName)165          .font(.system(size: 13, weight: .medium))166      }167      .padding(.horizontal, 14)168      .padding(.vertical, 8)169      .frame(maxWidth: .infinity)170      .foregroundColor(isActive ? .white : tint)171      .background(172        RoundedRectangle(cornerRadius: 10)173          .fill(isActive ? tint : Color.clear)174      )175      .overlay(176        RoundedRectangle(cornerRadius: 10)177          .stroke(tint.opacity(isActive ? 0 : 0.6), lineWidth: 1.5)178      )179    }180    .buttonStyle(.plain)181  }182}183184struct FavoritesView: View {185  @ObservedObject var appState: AppState186187  var body: some View {188    if appState.favoritesManager.favorites.isEmpty {189      EmptyState(icon: "heart.slash", text: "No favorites yet")190    } else {191      WallpaperGrid(192        wallpapers: appState.favoritesManager.favorites, appState: appState, onEndReached: nil)193    }194  }195}196197struct HistoryView: View {198  @ObservedObject var appState: AppState199200  var body: some View {201    VStack {202      if !appState.historyManager.history.isEmpty {203        HStack {204          Spacer()205          Button("Clear History") { clearHistory() }206            .font(.caption)207            .buttonStyle(.link)208        }209        .padding(.horizontal)210        .padding(.top, 8)211      }212213      if appState.historyManager.history.isEmpty {214        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")215      } else {216        WallpaperGrid(217          wallpapers: appState.historyManager.history, appState: appState, onEndReached: nil)218      }219    }220  }221222  func clearHistory() { appState.historyManager.clearHistory() }223}224225struct SettingsView: View {226  @ObservedObject var appState: AppState227228  var body: some View {229    VStack(alignment: .leading, spacing: 24) {230      VStack(alignment: .leading, spacing: 12) {231        Text("Storage")232          .font(.headline)233234        Toggle("Launch at Login", isOn: $appState.launchAtLogin)235        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)236237        if appState.saveToFolderEnabled {238          HStack {239            Text("Location")240            Spacer()241            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)242              .foregroundColor(.secondary)243              .lineLimit(1)244              .truncationMode(.middle)245            Button("Change…") { selectFolder() }246          }247        }248      }249250      Spacer()251252      Button(role: .destructive, action: quit) {253        Text("Quit Haven")254          .frame(maxWidth: .infinity)255      }256      .controlSize(.large)257    }258    .padding()259    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)260  }261262  func selectFolder() { appState.selectSaveFolder() }263  func quit() { appState.quit() }264}265266struct EmptyState: View {267  let icon: String268  let text: String269270  var body: some View {271    VStack(spacing: 16) {272      Image(systemName: icon)273        .font(.system(size: 48))274        .foregroundColor(.gray.opacity(0.5))275      Text(text)276        .foregroundColor(.secondary)277    }278    .frame(maxWidth: .infinity, maxHeight: .infinity)279  }280}281282struct TabButton: View {283  let icon: String284  let tab: AppTab285  @Binding var activeTab: AppTab286287  var body: some View {288    Button(action: select) {289      Image(systemName: icon)290        .symbolVariant(activeTab == tab ? .fill : .none)291        .foregroundColor(activeTab == tab ? .accentColor : .secondary)292        .font(.system(size: 18))293    }294    .buttonStyle(.plain)295  }296297  func select() { activeTab = tab }298}299300// Redeclaring this as it was in the original file, or we can move to a separate file301struct VisualEffectView: NSViewRepresentable {302  let material: NSVisualEffectView.Material303  let blendingMode: NSVisualEffectView.BlendingMode304305  func makeNSView(context: Context) -> NSVisualEffectView {306    let visualEffectView = NSVisualEffectView()307    visualEffectView.material = material308    visualEffectView.blendingMode = blendingMode309    visualEffectView.state = .active310    return visualEffectView311  }312313  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {314    nsView.material = material315    nsView.blendingMode = blendingMode316  }317}