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(height: 100) .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 ) .scaleEffect(isHovering ? 1.02 : 1.0) .animation(.spring(response: 0.3), value: isHovering) } .buttonStyle(.plain) .onHover { isHovering = $0 } .help("\(wallpaper.resolution)\n\(wallpaper.purity)") } 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 } func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) } }