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 250 251 252 .contentShape(Rectangle())253 }254 .buttonStyle(.plain)255 }256}257258struct FavoritesView: View {259 @ObservedObject var appState: AppState260 @ObservedObject var favoritesManager: FavoritesManager261262 init(appState: AppState, favoritesManager: FavoritesManager) {263 self.appState = appState264 self.favoritesManager = favoritesManager265 }266267 var body: some View {268 if favoritesManager.favorites.isEmpty {269 EmptyState(icon: "heart.slash", text: "No favorites yet")270 } else {271 WallpaperGrid(272 wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)273 }274 }275}276277struct HistoryView: View {278 @ObservedObject var appState: AppState279 @ObservedObject var historyManager: HistoryManager280281 init(appState: AppState, historyManager: HistoryManager) {282 self.appState = appState283 self.historyManager = historyManager284 }285286 var body: some View {287 VStack {288 if !historyManager.history.isEmpty {289 HStack {290 Spacer()291 Button("Clear History") { clearHistory() }292 .font(.caption)293 .buttonStyle(.link)294 }295 .padding(.horizontal)296 .padding(.top, 8)297 }298299 if historyManager.history.isEmpty {300 EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")301 } else {302 WallpaperGrid(303 wallpapers: historyManager.history, appState: appState, onEndReached: nil)304 }305 }306 }307308 func clearHistory() { historyManager.clearHistory() }309}310311struct SettingsView: View {312 @ObservedObject var appState: AppState313314 var body: some View {315 VStack(alignment: .leading, spacing: 24) {316 VStack(alignment: .leading, spacing: 12) {317 Text("Storage")318 .font(.headline)319320 Toggle("Launch at Login", isOn: $appState.launchAtLogin)321 Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)322323 if appState.saveToFolderEnabled {324 HStack {325 Text("Location")326 Spacer()327 Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)328 .foregroundColor(.secondary)329 .lineLimit(1)330 .truncationMode(.middle)331 Button("Change…") { selectFolder() }332 }333 }334 }335336 VStack(alignment: .leading, spacing: 12) {337 HStack {338 Text("Source folders")339 .font(.headline)340 Spacer()341 Button(action: addSourceFolders) {342 Label("Add folder…", systemImage: "plus")343 }344 .buttonStyle(.link)345 }346347 if appState.sourceFolderPaths.isEmpty {348 EmptyState(icon: "folder.badge.questionmark", text: "No source folders yet")349 .frame(height: 120)350 } else {351 VStack(spacing: 6) {352 ForEach(appState.sourceFolderPaths, id: \.self) { path in353 HStack {354 Image(systemName: "folder")355 .foregroundColor(.secondary)356 Text(URL(fileURLWithPath: path).lastPathComponent)357 .lineLimit(1)358 .truncationMode(.middle)359 Spacer()360 Text(path)361 .font(.caption2)362 .foregroundColor(.secondary)363 .lineLimit(1)364 .truncationMode(.middle)365 Button(action: { appState.removeSourceFolder(path) }) {366 Image(systemName: "minus.circle")367 }368 .buttonStyle(.plain)369 .foregroundColor(.secondary)370 .accessibilityLabel("Remove \(URL(fileURLWithPath: path).lastPathComponent)")371 }372 .padding(.horizontal, 8)373 .padding(.vertical, 4)374 .background(375 RoundedRectangle(cornerRadius: 6)376 .fill(Color.secondary.opacity(0.08))377 )378 }379 }380 }381 }382383 Spacer()384385 Button(role: .destructive, action: quit) {386 Text("Quit Haven")387 .frame(maxWidth: .infinity)388 }389 .controlSize(.large)390 }391 .padding()392 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)393 }394395 func selectFolder() { appState.selectSaveFolder() }396 func addSourceFolders() { appState.addSourceFolders() }397 func quit() { appState.quit() }398}399400struct EmptyState: View {401 let icon: String402 let text: String403404 var body: some View {405 VStack(spacing: 16) {406 Image(systemName: icon)407 .font(.system(size: 48))408 .foregroundColor(.gray.opacity(0.5))409 Text(text)410 .foregroundColor(.secondary)411 }412 .frame(maxWidth: .infinity, maxHeight: .infinity)413 }414}415416struct TabButton: View {417 let icon: String418 let tab: AppTab419 @Binding var activeTab: AppTab420421 var body: some View {422 Button(action: select) {423 Image(systemName: icon)424 .symbolVariant(activeTab == tab ? .fill : .none)425 .foregroundColor(activeTab == tab ? .accentColor : .secondary)426 .font(.system(size: 18))427 }428 .buttonStyle(.plain)429 }430431 func select() { activeTab = tab }432}433434435struct VisualEffectView: NSViewRepresentable {436 let material: NSVisualEffectView.Material437 let blendingMode: NSVisualEffectView.BlendingMode438439 func makeNSView(context: Context) -> NSVisualEffectView {440 let visualEffectView = NSVisualEffectView()441 visualEffectView.material = material442 visualEffectView.blendingMode = blendingMode443 visualEffectView.state = .active444 return visualEffectView445 }446447 func updateNSView(_ nsView: NSVisualEffectView, context: Context) {448 nsView.material = material449 nsView.blendingMode = blendingMode450 }451}