WallpaperItem.swift

5.7 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(maxWidth: .infinity)32        .frame(height: 100)33        .aspectRatio(contentMode: .fill)34        .clipped()35        .cornerRadius(8)36        .overlay(37          RoundedRectangle(cornerRadius: 8)38            .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)39        )40        .shadow(radius: isHovering ? 4 : 0)41        .overlay(42          // Favorite Button43          Group {44            if isHovering || favoritesManager.isFavorite(wallpaper) {45              Button(action: toggleFavorite) {46                Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart")47                  .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white)48                  .shadow(radius: 2)49                  .font(.system(size: 16, weight: .bold))50                  .padding(6)51                  .background(Color.black.opacity(0.3))52                  .clipShape(Circle())53              }54              .buttonStyle(.plain)55              .padding(6)56              .transition(.opacity)57            }58          },59          alignment: .topTrailing60        )61        .overlay(alignment: .topLeading) { resolutionBadge }62        .scaleEffect(isHovering ? 1.02 : 1.0)63        .animation(.spring(response: 0.3), value: isHovering)64    }65    .buttonStyle(.plain)66    .onHover { isHovering = $0 }67    .help("\(wallpaper.resolution)\n\(wallpaper.purity)")68  }6970  /// 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  }8889  func thumbnail(for phase: AsyncImagePhase) -> AnyView {90    if case .success(let image) = phase {91      return AnyView(image.resizable().aspectRatio(contentMode: .fill))92    }93    if case .failure = phase {94      return AnyView(95        Rectangle().fill(Color.gray.opacity(0.2)).overlay(96          attempt < maxAttempts97            ? AnyView(ProgressView())98            : AnyView(Image(systemName: "photo").foregroundColor(.gray.opacity(0.6)))99        ).task { await retryAfterFailure() })100    }101    return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView()))102  }103104  /// Exponential-backoff retry. Each successive failure doubles the wait105  /// (`base * 2^attempt`) so a flaky host gets a chance to recover without106  /// hammering it, while still bounding the total wait under107  /// `maxAttempts * base` for the default parameters.108  ///109  /// Cancellation-safe: bails out immediately when the hosting task is110  /// already cancelled, and exits early if `Task.sleep` itself is111  /// interrupted mid-wait so a disappearing row never advances the112  /// attempt counter or rebinds `retryID`.113  /// Exposes the doubling backoff schedule for tests so callers can114  /// assert the wait times without having to actually sleep.115  func backoffDelay(forAttempt attempt: Int) -> UInt64 {116    let safe = max(0, attempt)117    let multiplier = UInt64(1) << safe118    return multiplier &* retryDelayNanoseconds119  }120121  func retryAfterFailure() async {122    guard attempt < maxAttempts else { return }123    if Task.isCancelled { return }124125    // Doubling schedule: 1x, 2x, 4x, 8x, ... `attempt` stays small126    // (≤ maxAttempts), so the shift never overflows UInt64.127    let multiplier = UInt64(1) << attempt128    let delay = multiplier &* retryDelayNanoseconds129    do {130      try await Task.sleep(nanoseconds: delay)131    } catch {132      return133    }134    guard !Task.isCancelled else { return }135    attempt += 1136    retryID = UUID()137  }138139  /// Test seam: read-only access to the internal retry counter.140  var exposedAttempt: Int { attempt }141142  /// 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  }158159  func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }160}