WallpaperItem.swift

4.3 KB · raw

1import SwiftUI23struct WallpaperItem: View {4  let wallpaper: Wallpaper5  let action: () -> Void6  @ObservedObject var favoritesManager = FavoritesManager.shared7  @State private var retryID = UUID()8  @State private var attempt = 09  @State private var isHovering = false1011  // Transient thumbnail failures (flaky network) are common; retry a few12  // times with growing backoff before giving up quietly.13  let maxAttempts: Int14  let retryDelayNanoseconds: UInt641516  init(17    wallpaper: Wallpaper, action: @escaping () -> Void, attempt: Int = 0,18    maxAttempts: Int = 4, retryDelayNanoseconds: UInt64 = 800_000_00019  ) {20    self.wallpaper = wallpaper21    self.action = action22    self.maxAttempts = maxAttempts23    self.retryDelayNanoseconds = retryDelayNanoseconds24    _attempt = State(initialValue: attempt)25  }2627  var body: some View {28    Button(action: action) {29      AsyncImage(url: URL(string: wallpaper.thumbs.small)) { thumbnail(for: $0) }30        .id(retryID)31        .frame(height: 100)32        .clipped()33        .cornerRadius(8)34        .overlay(35          RoundedRectangle(cornerRadius: 8)36            .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)37        )38        .shadow(radius: isHovering ? 4 : 0)39        .overlay(40          // Favorite Button41          Group {42            if isHovering || favoritesManager.isFavorite(wallpaper) {43              Button(action: toggleFavorite) {44                Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart")45                  .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white)46                  .shadow(radius: 2)47                  .font(.system(size: 16, weight: .bold))48                  .padding(6)49                  .background(Color.black.opacity(0.3))50                  .clipShape(Circle())51              }52              .buttonStyle(.plain)53              .padding(6)54              .transition(.opacity)55            }56          },57          alignment: .topTrailing58        )59        .scaleEffect(isHovering ? 1.02 : 1.0)60        .animation(.spring(response: 0.3), value: isHovering)61    }62    .buttonStyle(.plain)63    .onHover { isHovering = $0 }64    .help("\(wallpaper.resolution)\n\(wallpaper.purity)")65  }6667  func thumbnail(for phase: AsyncImagePhase) -> AnyView {68    if case .success(let image) = phase {69      return AnyView(image.resizable().aspectRatio(contentMode: .fill))70    }71    if case .failure = phase {72      return AnyView(73        Rectangle().fill(Color.gray.opacity(0.2)).overlay(74          attempt < maxAttempts75            ? AnyView(ProgressView())76            : AnyView(Image(systemName: "photo").foregroundColor(.gray.opacity(0.6)))77        ).task { await retryAfterFailure() })78    }79    return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView()))80  }8182  /// Exponential-backoff retry. Each successive failure doubles the wait83  /// (`base * 2^attempt`) so a flaky host gets a chance to recover without84  /// hammering it, while still bounding the total wait under85  /// `maxAttempts * base` for the default parameters.86  ///87  /// Cancellation-safe: bails out immediately when the hosting task is88  /// already cancelled, and exits early if `Task.sleep` itself is89  /// interrupted mid-wait so a disappearing row never advances the90  /// attempt counter or rebinds `retryID`.91  /// Exposes the doubling backoff schedule for tests so callers can92  /// assert the wait times without having to actually sleep.93  func backoffDelay(forAttempt attempt: Int) -> UInt64 {94    let safe = max(0, attempt)95    let multiplier = UInt64(1) << safe96    return multiplier &* retryDelayNanoseconds97  }9899  func retryAfterFailure() async {100    guard attempt < maxAttempts else { return }101    if Task.isCancelled { return }102103    // Doubling schedule: 1x, 2x, 4x, 8x, ... `attempt` stays small104    // (≤ maxAttempts), so the shift never overflows UInt64.105    let multiplier = UInt64(1) << attempt106    let delay = multiplier &* retryDelayNanoseconds107    do {108      try await Task.sleep(nanoseconds: delay)109    } catch {110      return111    }112    guard !Task.isCancelled else { return }113    attempt += 1114    retryID = UUID()115  }116117  /// Test seam: read-only access to the internal retry counter.118  var exposedAttempt: Int { attempt }119120  func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }121}