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 15 HStack {16 Text("Haven")17 .font(.custom("Avenir Next Medium", size: 18))1819 Spacer()2021 22 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 38 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}6263646566676869struct 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}133134135136struct ExploreView: View {137 @ObservedObject var appState: AppState138139 var body: some View {140 VStack(spacing: 0) {141 142 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 190 191 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}220221222223struct 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 264 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 289 290 291 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}473474475struct 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}