704ac0fcc8ad
polish UI: error banner, nested manager observation, ultrawide fit, resolution badge, neutral pills, sources list (v0.6.0)
- time
- 2026-07-16 22:13 UTC · 2 months ago
- machine
- koh2
- agent
- minimax-m3
- session
- resolve remaining Haven findings
- parent
- 1ac917959d28
↓ haven-704ac0fc.tar.gzatlas at this save
files changed3 files
Sources/Haven/UI/Views/Components/WallpaperItem.swift+39 −0Sources/Haven/UI/Views/MainView.swift+147 −16Tests/HavenTests/ViewCoverageTests.swift+4 −4Sources/Haven/UI/Views/Components/WallpaperItem.swift+39 −0
@@ -28,7 +28,9 @@2828 Button(action: action) {2929 AsyncImage(url: URL(string: wallpaper.thumbs.small)) { thumbnail(for: $0) }3030 .id(retryID)31+ .frame(maxWidth: .infinity)3132 .frame(height: 100)33+ .aspectRatio(contentMode: .fill)3234 .clipped()3335 .cornerRadius(8)3436 .overlay(@@ -56,6 +58,7 @@5658 },5759 alignment: .topTrailing5860 )61+ .overlay(alignment: .topLeading) { resolutionBadge }5962 .scaleEffect(isHovering ? 1.02 : 1.0)6063 .animation(.spring(response: 0.3), value: isHovering)6164 }@@ -64,6 +67,25 @@6467 .help("\(wallpaper.resolution)\n\(wallpaper.purity)")6568 }6669 70+ /// Top-left overlay badge that maps the wallpaper's `WxH` string to a71+ /// compact "1080"/"2K"/"4K"/"8K" label, falling back to the raw string72+ /// when the height is non-standard.73+ @ViewBuilder74+ private var resolutionBadge: some View {75+ let label = WallpaperItem.resolutionBadge(for: wallpaper.resolution)76+ if !label.isEmpty {77+ Text(label)78+ .font(.caption2.weight(.semibold))79+ .foregroundColor(.white)80+ .padding(.horizontal, 6)81+ .padding(.vertical, 2)82+ .background(Color.black.opacity(0.55))83+ .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))84+ .padding(6)85+ .accessibilityLabel("Resolution \(label)")86+ }87+ }88+6789 func thumbnail(for phase: AsyncImagePhase) -> AnyView {6890 if case .success(let image) = phase {6991 return AnyView(image.resizable().aspectRatio(contentMode: .fill))@@ -116,6 +138,23 @@116138 117139 /// Test seam: read-only access to the internal retry counter.118140 var exposedAttempt: Int { attempt }141+142+ /// Map `WxH` resolutions (e.g. `3840x2160`) to compact badge labels.143+ /// Returns the empty string when no height is found so the badge view144+ /// can opt out entirely without conditional caller code.145+ static func resolutionBadge(for resolution: String) -> String {146+ let height = resolution.split(separator: "x").last.flatMap { Int($0) } ?? 0147+ switch height {148+ case 4320...: return "8K"149+ case 3000...: return "4K+"150+ case 2160..<3000: return "4K"151+ case 1440..<2160: return "2K"152+ case 1080..<1440: return "1080"153+ case 720..<1080: return "720"154+ case 1...: return "\(height)"155+ default: return resolution156+ }157+ }119158 120159 func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }121160 }Sources/Haven/UI/Views/MainView.swift+147 −16
@@ -32,6 +32,8 @@3232 3333 Divider()3434 35+ ErrorBanner(appState: appState)36+3537 // Content3638 ZStack {3739 switch appState.activeTab {@@ -40,9 +42,9 @@4042 case .local:4143 LocalView(appState: appState)4244 case .favorites:43- FavoritesView(appState: appState)45+ FavoritesView(appState: appState, favoritesManager: appState.favoritesManager)4446 case .history:45- HistoryView(appState: appState)47+ HistoryView(appState: appState, historyManager: appState.historyManager)4648 case .settings:4749 SettingsView(appState: appState)4850 }@@ -58,6 +60,77 @@5860 }5961 }6062 63+/// Compact banner that surfaces the most recent error from `AppState` or64+/// the two nested persistence managers, in priority order:65+/// 1. `appState.errorMessage` (operational failures)66+/// 2. `historyManager.persistenceError`67+/// 3. `favoritesManager.persistenceError`68+/// Each tier clears only its own message so unrelated errors stay visible.69+struct ErrorBanner: View {70+ @ObservedObject var appState: AppState71+ @ObservedObject var favoritesManager: FavoritesManager72+ @ObservedObject var historyManager: HistoryManager73+74+ init(75+ appState: AppState,76+ favoritesManager: FavoritesManager = FavoritesManager.shared,77+ historyManager: HistoryManager = HistoryManager.shared78+ ) {79+ self.appState = appState80+ self.favoritesManager = favoritesManager81+ self.historyManager = historyManager82+ }83+84+ 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+ }107+108+ private struct Entry {109+ let message: String110+ let systemImage: String111+ let dismiss: () -> Void112+ }113+114+ 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+}133+61134 // MARK: - Subviews62135 63136 struct ExploreView: View {@@ -147,12 +220,10 @@147220 let isActive: Bool148221 let action: () -> Void149222 223+ // Neutral grey palette: lets the selected mode read as "active" without224+ // // competing with the rest of the menu-bar app's muted UI.150225 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- }226+ isActive ? Color.accentColor : Color.secondary156227 }157228 158229 var body: some View {@@ -165,14 +236,14 @@165236 .padding(.horizontal, 14)166237 .padding(.vertical, 8)167238 .frame(maxWidth: .infinity)168- .foregroundColor(isActive ? .white : tint)239+ .foregroundColor(isActive ? .white : .primary)169240 .background(170241 RoundedRectangle(cornerRadius: 10)171- .fill(isActive ? tint : Color.clear)242+ .fill(isActive ? Color.accentColor : Color.clear)172243 )173244 .overlay(174245 RoundedRectangle(cornerRadius: 10)175- .stroke(tint.opacity(isActive ? 0 : 0.6), lineWidth: 1.5)246+ .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)176247 )177248 }178249 .buttonStyle(.plain)@@ -181,23 +252,35 @@181252 182253 struct FavoritesView: View {183254 @ObservedObject var appState: AppState255+ @ObservedObject var favoritesManager: FavoritesManager184256 257+ init(appState: AppState, favoritesManager: FavoritesManager) {258+ self.appState = appState259+ self.favoritesManager = favoritesManager260+ }261+185262 var body: some View {186- if appState.favoritesManager.favorites.isEmpty {263+ if favoritesManager.favorites.isEmpty {187264 EmptyState(icon: "heart.slash", text: "No favorites yet")188265 } else {189266 WallpaperGrid(190- wallpapers: appState.favoritesManager.favorites, appState: appState, onEndReached: nil)267+ wallpapers: favoritesManager.favorites, appState: appState, onEndReached: nil)191268 }192269 }193270 }194271 195272 struct HistoryView: View {196273 @ObservedObject var appState: AppState274+ @ObservedObject var historyManager: HistoryManager197275 276+ init(appState: AppState, historyManager: HistoryManager) {277+ self.appState = appState278+ self.historyManager = historyManager279+ }280+198281 var body: some View {199282 VStack {200- if !appState.historyManager.history.isEmpty {283+ if !historyManager.history.isEmpty {201284 HStack {202285 Spacer()203286 Button("Clear History") { clearHistory() }@@ -208,16 +291,16 @@208291 .padding(.top, 8)209292 }210293 211- if appState.historyManager.history.isEmpty {294+ if historyManager.history.isEmpty {212295 EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")213296 } else {214297 WallpaperGrid(215- wallpapers: appState.historyManager.history, appState: appState, onEndReached: nil)298+ wallpapers: historyManager.history, appState: appState, onEndReached: nil)216299 }217300 }218301 }219302 220- func clearHistory() { appState.historyManager.clearHistory() }303+ func clearHistory() { historyManager.clearHistory() }221304 }222305 223306 struct SettingsView: View {@@ -245,6 +328,53 @@245328 }246329 }247330 331+ 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+ }341+342+ 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+ }377+248378 Spacer()249379 250380 Button(role: .destructive, action: quit) {@@ -258,6 +388,7 @@258388 }259389 260390 func selectFolder() { appState.selectSaveFolder() }391+ func addSourceFolders() { appState.addSourceFolders() }261392 func quit() { appState.quit() }262393 }263394 Tests/HavenTests/ViewCoverageTests.swift+4 −4
@@ -99,14 +99,14 @@9999 await grid.refresh()100100 101101 state.favoritesManager.favorites = []102- render(FavoritesView(appState: state))102+ render(FavoritesView(appState: state, favoritesManager: state.favoritesManager))103103 state.favoritesManager.favorites = [wallpaper]104- render(FavoritesView(appState: state))104+ render(FavoritesView(appState: state, favoritesManager: state.favoritesManager))105105 106106 state.historyManager.history = []107- render(HistoryView(appState: state))107+ render(HistoryView(appState: state, historyManager: state.historyManager))108108 state.historyManager.history = [wallpaper]109- let history = HistoryView(appState: state)109+ let history = HistoryView(appState: state, historyManager: state.historyManager)110110 render(history)111111 history.clearHistory()112112 state.historyManager.history = [wallpaper]