MainView.swift

8.8 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()113114      Divider()115116      // Feed-mode selector: Latest, Toplist, Random. The active mode is117      // visually emphasised; taps update appState.feedMode and trigger118      // a reset fetch via the Combine observer in AppState.119      HStack(spacing: 10) {120        ForEach(FeedMode.allCases) { mode in121          FeedModePill(mode: mode, isActive: appState.feedMode == mode) { select(mode) }122        }123      }124      .padding(.horizontal)125      .padding(.vertical, 10)126127      Divider()128129      WallpaperGrid(130        wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)131    }132  }133134  func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }135  func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }136  func refresh() async { await appState.fetchWallpapers(reset: true) }137  func refreshAction() { Task { await refresh() } }138  func select(_ mode: FeedMode) {139    if appState.feedMode != mode { appState.feedMode = mode }140  }141  func loadMore() async { await appState.loadMore() }142  func loadMoreAction() { Task { await loadMore() } }143}144145struct FeedModePill: View {146  let mode: FeedMode147  let isActive: Bool148  let action: () -> Void149150  private var tint: Color {151    switch mode {152    case .latest: return Color(red: 0.45, green: 0.85, blue: 0.30)  // green153    case .toplist: return Color(red: 0.85, green: 0.40, blue: 0.85)  // magenta154    case .random: return Color(red: 0.98, green: 0.55, blue: 0.20)  // orange155    }156  }157158  var body: some View {159    Button(action: action) {160      HStack(spacing: 6) {161        Image(systemName: mode.systemImage)162        Text(mode.displayName)163          .font(.system(size: 13, weight: .medium))164      }165      .padding(.horizontal, 14)166      .padding(.vertical, 8)167      .frame(maxWidth: .infinity)168      .foregroundColor(isActive ? .white : tint)169      .background(170        RoundedRectangle(cornerRadius: 10)171          .fill(isActive ? tint : Color.clear)172      )173      .overlay(174        RoundedRectangle(cornerRadius: 10)175          .stroke(tint.opacity(isActive ? 0 : 0.6), lineWidth: 1.5)176      )177    }178    .buttonStyle(.plain)179  }180}181182struct FavoritesView: View {183  @ObservedObject var appState: AppState184185  var body: some View {186    if appState.favoritesManager.favorites.isEmpty {187      EmptyState(icon: "heart.slash", text: "No favorites yet")188    } else {189      WallpaperGrid(190        wallpapers: appState.favoritesManager.favorites, appState: appState, onEndReached: nil)191    }192  }193}194195struct HistoryView: View {196  @ObservedObject var appState: AppState197198  var body: some View {199    VStack {200      if !appState.historyManager.history.isEmpty {201        HStack {202          Spacer()203          Button("Clear History") { clearHistory() }204            .font(.caption)205            .buttonStyle(.link)206        }207        .padding(.horizontal)208        .padding(.top, 8)209      }210211      if appState.historyManager.history.isEmpty {212        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")213      } else {214        WallpaperGrid(215          wallpapers: appState.historyManager.history, appState: appState, onEndReached: nil)216      }217    }218  }219220  func clearHistory() { appState.historyManager.clearHistory() }221}222223struct SettingsView: View {224  @ObservedObject var appState: AppState225226  var body: some View {227    VStack(alignment: .leading, spacing: 24) {228      VStack(alignment: .leading, spacing: 12) {229        Text("Storage")230          .font(.headline)231232        Toggle("Launch at Login", isOn: $appState.launchAtLogin)233        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)234235        if appState.saveToFolderEnabled {236          HStack {237            Text("Location")238            Spacer()239            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)240              .foregroundColor(.secondary)241              .lineLimit(1)242              .truncationMode(.middle)243            Button("Change…") { selectFolder() }244          }245        }246      }247248      Spacer()249250      Button(role: .destructive, action: quit) {251        Text("Quit Haven")252          .frame(maxWidth: .infinity)253      }254      .controlSize(.large)255    }256    .padding()257    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)258  }259260  func selectFolder() { appState.selectSaveFolder() }261  func quit() { appState.quit() }262}263264struct EmptyState: View {265  let icon: String266  let text: String267268  var body: some View {269    VStack(spacing: 16) {270      Image(systemName: icon)271        .font(.system(size: 48))272        .foregroundColor(.gray.opacity(0.5))273      Text(text)274        .foregroundColor(.secondary)275    }276    .frame(maxWidth: .infinity, maxHeight: .infinity)277  }278}279280struct TabButton: View {281  let icon: String282  let tab: AppTab283  @Binding var activeTab: AppTab284285  var body: some View {286    Button(action: select) {287      Image(systemName: icon)288        .symbolVariant(activeTab == tab ? .fill : .none)289        .foregroundColor(activeTab == tab ? .accentColor : .secondary)290        .font(.system(size: 18))291    }292    .buttonStyle(.plain)293  }294295  func select() { activeTab = tab }296}297298// Redeclaring this as it was in the original file, or we can move to a separate file299struct VisualEffectView: NSViewRepresentable {300  let material: NSVisualEffectView.Material301  let blendingMode: NSVisualEffectView.BlendingMode302303  func makeNSView(context: Context) -> NSVisualEffectView {304    let visualEffectView = NSVisualEffectView()305    visualEffectView.material = material306    visualEffectView.blendingMode = blendingMode307    visualEffectView.state = .active308    return visualEffectView309  }310311  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {312    nsView.material = material313    nsView.blendingMode = blendingMode314  }315}