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 WallpaperGrid(203 wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)204 }205 }206207 func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }208 func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }209 func refresh() async { await appState.fetchWallpapers(reset: true) }210 func refreshAction() { Task { await refresh() } }211 func select(_ mode: FeedMode) {212 if appState.feedMode != mode { appState.feedMode = mode }213 }214 func loadMore() async { await appState.loadMore() }215 func loadMoreAction() { Task { await loadMore() } }216}217218struct FeedModePill: View {219 let mode: FeedMode220 let isActive: Bool221 let action: () -> Void222223 224 225 private var tint: Color {226 isActive ? Color.accentColor : Color.secondary227 }228229 var body: some View {230 Button(action: action) {231 HStack(spacing: 6) {232 Image(systemName: mode.systemImage)233 Text(mode.displayName)234 .font(.system(size: 13, weight: .medium))235 }236 .padding(.horizontal, 14)237 .padding(.vertical, 8)238 .frame(maxWidth: .infinity)239 .foregroundColor(isActive ? .white : .primary)240 .background(241 RoundedRectangle(cornerRadius: 10)242 .fill(isActive ? Color.accentColor : Color.clear)243 )244 .overlay(245 RoundedRectangle(cornerRadius: 10)246 .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)247 )248 }249 .buttonStyle(.plain)250 }251}252253struct FavoritesView: View {254 @ObservedObject var appState: AppState255 @ObservedObject var favoritesManager: FavoritesManager256257 init(appState: AppState, favoritesManager: FavoritesManager) {258 self.appState = appState259 self.favoritesManager = favoritesManager260 }261262 var body: some View {263 if favoritesManager.favorites.isEmpty {264 EmptyState(icon: "heart.slash", text: "No favorites yet")265 } else {266 WallpaperGrid(267 wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)268 }269 }270}271272struct HistoryView: View {273 @ObservedObject var appState: AppState274 @ObservedObject var historyManager: HistoryManager275276 init(appState: AppState, historyManager: HistoryManager) {277 self.appState = appState278 self.historyManager = historyManager279 }280281 var body: some View {282 VStack {283 if !historyManager.history.isEmpty {284 HStack {285 Spacer()286 Button("Clear History") { clearHistory() }287 .font(.caption)288 .buttonStyle(.link)289 }290 .padding(.horizontal)291 .padding(.top, 8)292 }293294 if historyManager.history.isEmpty {295 EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")296 } else {297 WallpaperGrid(298 wallpapers: historyManager.history, appState: appState, onEndReached: nil)299 }300 }301 }302303 func clearHistory() { historyManager.clearHistory() }304}305306struct SettingsView: View {307 @ObservedObject var appState: AppState308309 var body: some View {310 VStack(alignment: .leading, spacing: 24) {311 VStack(alignment: .leading, spacing: 12) {312 Text("Storage")313 .font(.headline)314315 Toggle("Launch at Login", isOn: $appState.launchAtLogin)316 Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)317318 if appState.saveToFolderEnabled {319 HStack {320 Text("Location")321 Spacer()322 Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)323 .foregroundColor(.secondary)324 .lineLimit(1)325 .truncationMode(.middle)326 Button("Change…") { selectFolder() }327 }328 }329 }330331 VStack(alignment: .leading, spacing: 12) {332 HStack {333 Text("Source folders")334 .font(.headline)335 Spacer()336 Button(action: addSourceFolders) {337 Label("Add folder…", systemImage: "plus")338 }339 .buttonStyle(.link)340 }341342 if appState.sourceFolderPaths.isEmpty {343 EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet")344 .frame(height: 120)345 } else {346 VStack(spacing: 6) {347 ForEach(appState.sourceFolderPaths, id: \.self) { path in348 HStack {349 Image(systemName: "folder")350 .foregroundColor(.secondary)351 Text(URL(fileURLWithPath: path).lastPathComponent)352 .lineLimit(1)353 .truncationMode(.middle)354 Spacer()355 Text(path)356 .font(.caption2)357 .foregroundColor(.secondary)358 .lineLimit(1)359 .truncationMode(.middle)360 Button(action: { appState.removeSourceFolder(path) }) {361 Image(systemName: "minus.circle")362 }363 .buttonStyle(.plain)364 .foregroundColor(.secondary)365 .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)")366 }367 .padding(.horizontal, 8)368 .padding(.vertical, 4)369 .background(370 RoundedRectangle(cornerRadius: 6)371 .fill(Color.secondary.opacity(0.08))372 )373 }374 }375 }376 }377378 Spacer()379380 Button(role: .destructive, action: quit) {381 Text("Quit Haven")382 .frame(maxWidth: .infinity)383 }384 .controlSize(.large)385 }386 .padding()387 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)388 }389390 func selectFolder() { appState.selectSaveFolder() }391 func addSourceFolders() { appState.addSourceFolders() }392 func quit() { appState.quit() }393}394395struct EmptyState: View {396 let icon: String397 let text: String398399 var body: some View {400 VStack(spacing: 16) {401 Image(systemName: icon)402 .font(.system(size: 48))403 .foregroundColor(.gray.opacity(0.5))404 Text(text)405 .foregroundColor(.secondary)406 }407 .frame(maxWidth: .infinity, maxHeight: .infinity)408 }409}410411struct TabButton: View {412 let icon: String413 let tab: AppTab414 @Binding var activeTab: AppTab415416 var body: some View {417 Button(action: select) {418 Image(systemName: icon)419 .symbolVariant(activeTab == tab ? .fill : .none)420 .foregroundColor(activeTab == tab ? .accentColor : .secondary)421 .font(.system(size: 18))422 }423 .buttonStyle(.plain)424 }425426 func select() { activeTab = tab }427}428429430struct VisualEffectView: NSViewRepresentable {431 let material: NSVisualEffectView.Material432 let blendingMode: NSVisualEffectView.BlendingMode433434 func makeNSView(context: Context) -> NSVisualEffectView {435 let visualEffectView = NSVisualEffectView()436 visualEffectView.material = material437 visualEffectView.blendingMode = blendingMode438 visualEffectView.state = .active439 return visualEffectView440 }441442 func updateNSView(_ nsView: NSVisualEffectView, context: Context) {443 nsView.material = material444 nsView.blendingMode = blendingMode445 }446}