import SwiftUI struct WallpaperItem: View { let wallpaper: Wallpaper let action: () -> Void @ObservedObject var favoritesManager = FavoritesManager.shared @State private var retryID = UUID() @State private var attempt = 0 @State private var isHovering = false // Transient thumbnail failures (flaky network) are common; retry a few // times with growing backoff before giving up quietly. let maxAttempts: Int let retryDelayNanoseconds: UInt64 init( wallpaper: Wallpaper, action: @escaping () -> Void, attempt: Int = 0, maxAttempts: Int = 4, retryDelayNanoseconds: UInt64 = 800_000_000 ) { self.wallpaper = wallpaper self.action = action self.maxAttempts = maxAttempts self.retryDelayNanoseconds = retryDelayNanoseconds _attempt = State(initialValue: attempt) } var body: some View { Button(action: action) { AsyncImage(url: URL(string: wallpaper.thumbs.small)) { thumbnail(for: $0) } .id(retryID) .frame(maxWidth: .infinity) .frame(height: 100) .aspectRatio(contentMode: .fill) .clipped() .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2) ) .shadow(radius: isHovering ? 4 : 0) .overlay( // Favorite Button Group { if isHovering || favoritesManager.isFavorite(wallpaper) { Button(action: toggleFavorite) { Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart") .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white) .shadow(radius: 2) .font(.system(size: 16, weight: .bold)) .padding(6) .background(Color.black.opacity(0.3)) .clipShape(Circle()) } .buttonStyle(.plain) .padding(6) .transition(.opacity) } }, alignment: .topTrailing ) .overlay(alignment: .topLeading) { resolutionBadge } .scaleEffect(isHovering ? 1.02 : 1.0) .animation(.spring(response: 0.3), value: isHovering) } .buttonStyle(.plain) .onHover { isHovering = $0 } .help("\(wallpaper.resolution)\n\(wallpaper.purity)") } /// Top-left overlay badge that maps the wallpaper's `WxH` string to a /// compact "1080"/"2K"/"4K"/"8K" label, falling back to the raw string /// when the height is non-standard. @ViewBuilder private var resolutionBadge: some View { let label = WallpaperItem.resolutionBadge(for: wallpaper.resolution) if !label.isEmpty { Text(label) .font(.caption2.weight(.semibold)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.black.opacity(0.55)) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .padding(6) .accessibilityLabel("Resolution \(label)") } } func thumbnail(for phase: AsyncImagePhase) -> AnyView { if case .success(let image) = phase { return AnyView(image.resizable().aspectRatio(contentMode: .fill)) } if case .failure = phase { return AnyView( Rectangle().fill(Color.gray.opacity(0.2)).overlay( attempt < maxAttempts ? AnyView(ProgressView()) : AnyView(Image(systemName: "photo").foregroundColor(.gray.opacity(0.6))) ).task { await retryAfterFailure() }) } return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView())) } /// Exponential-backoff retry. Each successive failure doubles the wait /// (`base * 2^attempt`) so a flaky host gets a chance to recover without /// hammering it, while still bounding the total wait under /// `maxAttempts * base` for the default parameters. /// /// Cancellation-safe: bails out immediately when the hosting task is /// already cancelled, and exits early if `Task.sleep` itself is /// interrupted mid-wait so a disappearing row never advances the /// attempt counter or rebinds `retryID`. /// Exposes the doubling backoff schedule for tests so callers can /// assert the wait times without having to actually sleep. func backoffDelay(forAttempt attempt: Int) -> UInt64 { let safe = max(0, attempt) let multiplier = UInt64(1) << safe return multiplier &* retryDelayNanoseconds } func retryAfterFailure() async { guard attempt < maxAttempts else { return } if Task.isCancelled { return } // Doubling schedule: 1x, 2x, 4x, 8x, ... `attempt` stays small // (≤ maxAttempts), so the shift never overflows UInt64. let multiplier = UInt64(1) << attempt let delay = multiplier &* retryDelayNanoseconds do { try await Task.sleep(nanoseconds: delay) } catch { return } guard !Task.isCancelled else { return } attempt += 1 retryID = UUID() } /// Test seam: read-only access to the internal retry counter. var exposedAttempt: Int { attempt } /// Map `WxH` resolutions (e.g. `3840x2160`) to compact badge labels. /// Returns the empty string when no height is found so the badge view /// can opt out entirely without conditional caller code. static func resolutionBadge(for resolution: String) -> String { let height = resolution.split(separator: "x").last.flatMap { Int($0) } ?? 0 switch height { case 4320...: return "8K" case 3000...: return "4K+" case 2160..<3000: return "4K" case 1440..<2160: return "2K" case 1080..<1440: return "1080" case 720..<1080: return "720" case 1...: return "\(height)" default: return resolution } } func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) } }