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
3 files Sources/Haven/UI/Views/Components/WallpaperItem.swift+39 −0Sources/Haven/UI/Views/MainView.swift+147 −16Tests/HavenTests/ViewCoverageTests.swift+4 −4
Sources/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 }

view file

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 

view file

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]

view file