import SwiftUI struct MainView: View { @StateObject private var appState: AppState init(appState: AppState? = nil) { let appState = appState ?? AppState() _appState = StateObject(wrappedValue: appState) } var body: some View { ZStack { VStack(spacing: 0) { // Header (Universal) HStack { Text("Haven") .font(.custom("Avenir Next Medium", size: 18)) Spacer() // Tab Bar HStack(spacing: 16) { TabButton(icon: "safari", tab: .explore, activeTab: $appState.activeTab) TabButton(icon: "folder", tab: .local, activeTab: $appState.activeTab) TabButton(icon: "heart", tab: .favorites, activeTab: $appState.activeTab) TabButton(icon: "clock", tab: .history, activeTab: $appState.activeTab) TabButton(icon: "gearshape", tab: .settings, activeTab: $appState.activeTab) } } .padding() .background(VisualEffectView(material: .headerView, blendingMode: .behindWindow)) Divider() ErrorBanner(appState: appState) // Content ZStack { switch appState.activeTab { case .explore: ExploreView(appState: appState) case .local: LocalView(appState: appState) case .favorites: FavoritesView(appState: appState, favoritesManager: appState.favoritesManager) case .history: HistoryView(appState: appState, historyManager: appState.historyManager) case .settings: SettingsView(appState: appState) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(width: 450, height: 640) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { Task { await appState.fetchWallpapers() } } } } /// Compact banner that surfaces the most recent error from `AppState` or /// the two nested persistence managers, in priority order: /// 1. `appState.errorMessage` (operational failures) /// 2. `historyManager.persistenceError` /// 3. `favoritesManager.persistenceError` /// Each tier clears only its own message so unrelated errors stay visible. struct ErrorBanner: View { @ObservedObject var appState: AppState @ObservedObject var favoritesManager: FavoritesManager @ObservedObject var historyManager: HistoryManager init( appState: AppState, favoritesManager: FavoritesManager = FavoritesManager.shared, historyManager: HistoryManager = HistoryManager.shared ) { self.appState = appState self.favoritesManager = favoritesManager self.historyManager = historyManager } var body: some View { if let entry = bannerEntry { HStack(alignment: .top, spacing: 8) { Image(systemName: entry.systemImage) .foregroundColor(.white) Text(entry.message) .font(.callout) .foregroundColor(.white) .frame(maxWidth: .infinity, alignment: .leading) .multilineTextAlignment(.leading) Button(action: entry.dismiss) { Image(systemName: "xmark.circle.fill") .foregroundColor(.white.opacity(0.85)) } .buttonStyle(.plain) .accessibilityLabel("Dismiss") } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color(red: 0.66, green: 0.20, blue: 0.20)) .transition(.opacity) } } private struct Entry { let message: String let systemImage: String let dismiss: () -> Void } private var bannerEntry: Entry? { if let message = appState.errorMessage { return Entry(message: message, systemImage: "exclamationmark.triangle.fill") { appState.errorMessage = nil } } if let message = historyManager.persistenceError { return Entry(message: message, systemImage: "clock.arrow.circlepath") { historyManager.persistenceError = nil } } if let message = favoritesManager.persistenceError { return Entry(message: message, systemImage: "heart.slash.fill") { favoritesManager.persistenceError = nil } } return nil } } // MARK: - Subviews struct ExploreView: View { @ObservedObject var appState: AppState var body: some View { VStack(spacing: 0) { // Controls HStack { HStack { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) TextField("Search...", text: $appState.searchText) } .padding(8) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(8) Menu { Section("Categories") { Toggle("General", isOn: $appState.categories.general) Toggle("Anime", isOn: $appState.categories.anime) Toggle("People", isOn: $appState.categories.people) } Section("Purity") { Toggle("SFW", isOn: $appState.purity.sfw) Toggle("Sketchy", isOn: $appState.purity.sketchy) } Divider() Picker("Resolution", selection: $appState.selectedResolution) { ForEach(appState.availableResolutions, id: \.self) { resolutionOption($0) } } Picker("Ratio", selection: $appState.selectedRatio) { ForEach(appState.availableRatios, id: \.self) { ratioOption($0) } } } label: { Image(systemName: "slider.horizontal.3") } .menuStyle(.borderlessButton) .frame(width: 30) Button(action: refreshAction) { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .frame(width: 30) } .padding() Divider() // Feed-mode selector: Latest, Toplist, Random. The active mode is // visually emphasised; taps update appState.feedMode and trigger // a reset fetch via the Combine observer in AppState. HStack(spacing: 10) { ForEach(FeedMode.allCases) { mode in FeedModePill(mode: mode, isActive: appState.feedMode == mode) { select(mode) } } } .padding(.horizontal) .padding(.vertical, 10) Divider() if let status = appState.wallhavenStatus, appState.wallpapers.isEmpty { WallhavenStatusPanel(status: status, retry: refreshAction) } else { WallpaperGrid( wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction) } } } func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) } func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) } func refresh() async { await appState.fetchWallpapers(reset: true) } func refreshAction() { Task { await refresh() } } func select(_ mode: FeedMode) { if appState.feedMode != mode { appState.feedMode = mode } } func loadMore() async { await appState.loadMore() } func loadMoreAction() { Task { await loadMore() } } } /// Shown in place of the grid while Wallhaven is down, so an empty Haven reads /// as Wallhaven's outage rather than as Haven having failed to load anything. struct WallhavenStatusPanel: View { let status: WallhavenStatus let retry: () -> Void var body: some View { VStack(spacing: 12) { Image(systemName: "icloud.slash") .font(.system(size: 40)) .foregroundColor(.orange) Text(status.title) .font(.headline) Text(status.detail) .font(.callout) .foregroundColor(.secondary) .multilineTextAlignment(.center) Text("Nothing is wrong with Haven — these wallpapers come from Wallhaven's servers.") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) HStack(spacing: 12) { Link("Check Wallhaven's status", destination: Configuration.statusURL) .font(.callout) Button("Try Again", action: retry) } .padding(.top, 4) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct FeedModePill: View { let mode: FeedMode let isActive: Bool let action: () -> Void // Neutral grey palette: lets the selected mode read as "active" without // // competing with the rest of the menu-bar app's muted UI. private var tint: Color { isActive ? Color.accentColor : Color.secondary } var body: some View { Button(action: action) { HStack(spacing: 6) { Image(systemName: mode.systemImage) Text(mode.displayName) .font(.system(size: 13, weight: .medium)) } .padding(.horizontal, 14) .padding(.vertical, 8) .frame(maxWidth: .infinity) .foregroundColor(isActive ? .white : .primary) .background( RoundedRectangle(cornerRadius: 10) .fill(isActive ? Color.accentColor : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1) ) // A `.plain` button hit-tests only its label's content (icon and text), // not the background or flexible frame around them, so without an // explicit shape the padded interior of the pill swallows clicks that // look like they land on it. .contentShape(Rectangle()) } .buttonStyle(.plain) } } struct FavoritesView: View { @ObservedObject var appState: AppState @ObservedObject var favoritesManager: FavoritesManager init(appState: AppState, favoritesManager: FavoritesManager) { self.appState = appState self.favoritesManager = favoritesManager } var body: some View { if favoritesManager.favorites.isEmpty { EmptyState(icon: "heart.slash", text: "No favorites yet") } else { WallpaperGrid( wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil) } } } struct HistoryView: View { @ObservedObject var appState: AppState @ObservedObject var historyManager: HistoryManager init(appState: AppState, historyManager: HistoryManager) { self.appState = appState self.historyManager = historyManager } var body: some View { VStack { if !historyManager.history.isEmpty { HStack { Spacer() Button("Clear History") { clearHistory() } .font(.caption) .buttonStyle(.link) } .padding(.horizontal) .padding(.top, 8) } if historyManager.history.isEmpty { EmptyState(icon: "clock.arrow.circlepath", text: "No history yet") } else { WallpaperGrid( wallpapers: historyManager.history, appState: appState, onEndReached: nil) } } } func clearHistory() { historyManager.clearHistory() } } struct SettingsView: View { @ObservedObject var appState: AppState var body: some View { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 12) { Text("Storage") .font(.headline) Toggle("Launch at Login", isOn: $appState.launchAtLogin) Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled) if appState.saveToFolderEnabled { HStack { Text("Location") Spacer() Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Button("Change…") { selectFolder() } } } } VStack(alignment: .leading, spacing: 12) { HStack { Text("Source folders") .font(.headline) Spacer() Button(action: addSourceFolders) { Label("Add folder…", systemImage: "plus") } .buttonStyle(.link) } if appState.sourceFolderPaths.isEmpty { EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet") .frame(height: 120) } else { VStack(spacing: 6) { ForEach(appState.sourceFolderPaths, id: \.self) { path in HStack { Image(systemName: "folder") .foregroundColor(.secondary) Text(URL(fileURLWithPath: path).lastPathComponent) .lineLimit(1) .truncationMode(.middle) Spacer() Text(path) .font(.caption2) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Button(action: { appState.removeSourceFolder(path) }) { Image(systemName: "minus.circle") } .buttonStyle(.plain) .foregroundColor(.secondary) .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)") } .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.secondary.opacity(0.08)) ) } } } } Spacer() Button(role: .destructive, action: quit) { Text("Quit Haven") .frame(maxWidth: .infinity) } .controlSize(.large) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } func selectFolder() { appState.selectSaveFolder() } func addSourceFolders() { appState.addSourceFolders() } func quit() { appState.quit() } } struct EmptyState: View { let icon: String let text: String var body: some View { VStack(spacing: 16) { Image(systemName: icon) .font(.system(size: 48)) .foregroundColor(.gray.opacity(0.5)) Text(text) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct TabButton: View { let icon: String let tab: AppTab @Binding var activeTab: AppTab var body: some View { Button(action: select) { Image(systemName: icon) .symbolVariant(activeTab == tab ? .fill : .none) .foregroundColor(activeTab == tab ? .accentColor : .secondary) .font(.system(size: 18)) } .buttonStyle(.plain) } func select() { activeTab = tab } } // Redeclaring this as it was in the original file, or we can move to a separate file struct VisualEffectView: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode func makeNSView(context: Context) -> NSVisualEffectView { let visualEffectView = NSVisualEffectView() visualEffectView.material = material visualEffectView.blendingMode = blendingMode visualEffectView.state = .active return visualEffectView } func updateNSView(_ nsView: NSVisualEffectView, context: Context) { nsView.material = material nsView.blendingMode = blendingMode } }