LocalView.swift

8.4 KB · raw

1import SwiftUI23struct LocalView: View {4  @ObservedObject var appState: AppState5  @ObservedObject var localManager: LocalWallpapersManager6  @State private var selectedWallpaper: LocalWallpaper?7  @State private var showingDeleteConfirmation = false8  @State private var wallpaperToDelete: LocalWallpaper?910  init(appState: AppState, localManager: LocalWallpapersManager? = nil) {11    self.appState = appState12    self.localManager = localManager ?? .shared13  }1415  var body: some View {16    VStack(spacing: 0) {17      // Header18      HStack {19        Text("Local Wallpapers")20          .font(.headline)2122        Spacer()2324        if !localManager.wallpapers.isEmpty {25          Text("\(localManager.wallpapers.count) items")26            .font(.caption)27            .foregroundColor(.secondary)28        }2930        Button(action: scan) {31          Image(systemName: "arrow.clockwise")32        }33        .buttonStyle(.plain)34        .disabled(localManager.isLoading)35      }36      .padding()3738      Divider()3940      // Content41      if localManager.isLoading && localManager.wallpapers.isEmpty {42        LoadingState()43      } else if !appState.saveToFolderEnabled {44        EmptyState(45          icon: "folder.badge.questionmark",46          text: "Enable 'Save wallpapers locally' in Settings to view local wallpapers"47        )48      } else if localManager.wallpapers.isEmpty {49        EmptyState(50          icon: "photo.on.rectangle.angled",51          text: "No wallpapers found in your save folder"52        )53      } else {54        LocalWallpaperGrid(55          wallpapers: localManager.wallpapers,56          onSetWallpaper: { wallpaper in Task { await set(wallpaper) } },57          onDeleteWallpaper: requestDelete58        )59      }60    }61    .alert(62      "Delete Wallpaper?", isPresented: $showingDeleteConfirmation, presenting: wallpaperToDelete,63      actions: deleteActions, message: deleteMessage)64    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)65    .onAppear {66      if appState.saveToFolderEnabled {67        localManager.scanFolder(path: appState.saveFolderPath)68      }69    }70  }7172  func scan() { localManager.scanFolder(path: appState.saveFolderPath) }73  func set(_ wallpaper: LocalWallpaper) async { await appState.setLocalWallpaper(wallpaper) }74  func requestDelete(_ wallpaper: LocalWallpaper) {75    wallpaperToDelete = wallpaper76    showingDeleteConfirmation = true77  }78  func delete(_ wallpaper: LocalWallpaper) { localManager.deleteWallpaper(wallpaper) }79  func cancelDelete() { wallpaperToDelete = nil }80  func deleteMessage(_ wallpaper: LocalWallpaper) -> Text {81    Text("This will permanently delete '\(wallpaper.filename)' from your save folder.")82  }83  @ViewBuilder func deleteActions(_ wallpaper: LocalWallpaper) -> some View {84    Button("Cancel", role: .cancel, action: cancelDelete)85    Button("Delete", role: .destructive) { delete(wallpaper) }86  }87}8889// MARK: - Local Wallpaper Grid9091struct LocalWallpaperGrid: View {92  let wallpapers: [LocalWallpaper]93  let onSetWallpaper: (LocalWallpaper) -> Void94  let onDeleteWallpaper: (LocalWallpaper) -> Void9596  var body: some View {97    ScrollView {98      LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {99        ForEach(wallpapers) { wallpaper in100          LocalWallpaperItem(101            wallpaper: wallpaper,102            onSet: { onSetWallpaper(wallpaper) },103            onDelete: { onDeleteWallpaper(wallpaper) }104          )105        }106      }107      .padding(12)108    }109  }110}111112// MARK: - Local Wallpaper Item113114struct LocalWallpaperItem: View {115  let wallpaper: LocalWallpaper116  let onSet: () -> Void117  let onDelete: () -> Void118119  @State private var isHovering = false120  @State private var image: NSImage?121  @State private var isLoadingImage = true122123  init(124    wallpaper: LocalWallpaper, onSet: @escaping () -> Void, onDelete: @escaping () -> Void,125    initialImage: NSImage? = nil, isLoadingImage: Bool = true126  ) {127    self.wallpaper = wallpaper128    self.onSet = onSet129    self.onDelete = onDelete130    _image = State(initialValue: initialImage)131    _isLoadingImage = State(initialValue: isLoadingImage)132  }133134  var body: some View {135    Button(action: onSet) {136      ZStack {137        if let image = image {138          Image(nsImage: image)139            .resizable()140            .aspectRatio(contentMode: .fill)141            .clipped()142        } else if isLoadingImage {143          Rectangle()144            .fill(Color.gray.opacity(0.2))145            .overlay(ProgressView())146        } else {147          Rectangle()148            .fill(Color.gray.opacity(0.1))149            .overlay(150              VStack {151                Image(systemName: "photo")152                  .foregroundColor(.gray)153                Text(wallpaper.filename)154                  .font(.caption2)155                  .foregroundColor(.secondary)156                  .lineLimit(1)157              }158            )159        }160      }161      .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 100)162      .cornerRadius(8)163      .overlay(164        RoundedRectangle(cornerRadius: 8)165          .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)166      )167      .shadow(radius: isHovering ? 4 : 0)168      .overlay(169        // Action Buttons170        HStack {171          // Delete button172          Button(action: onDelete) {173            Image(systemName: "trash")174              .foregroundColor(.white)175              .shadow(radius: 2)176              .font(.system(size: 14, weight: .bold))177              .padding(6)178              .background(Color.red.opacity(0.7))179              .clipShape(Circle())180          }181          .buttonStyle(.plain)182          .padding(6)183184          Spacer()185186          // Resolution badge187          Text(wallpaper.resolution)188            .font(.caption2)189            .padding(.horizontal, 6)190            .padding(.vertical, 2)191            .background(Color.black.opacity(0.6))192            .foregroundColor(.white)193            .cornerRadius(4)194            .padding(6)195        }196        .opacity(isHovering ? 1 : 0)197        .animation(.easeInOut(duration: 0.15), value: isHovering),198        alignment: .top199      )200      .scaleEffect(isHovering ? 1.02 : 1.0)201      .animation(.spring(response: 0.3), value: isHovering)202    }203    .buttonStyle(.plain)204    .onHover { isHovering = $0 }205    .help("\(wallpaper.resolution) • \(formatFileSize(wallpaper.fileSize))")206    .onAppear {207      loadImage()208    }209  }210211  /// Maximum thumbnail pixel dimension. ImageIO uses this as the larger side212  /// of the downsampled output — the full-resolution JPEG/PNG is decoded213  /// through `CGImageSourceCreateThumbnailAtIndex` which is dramatically214  /// cheaper than `NSImage(contentsOf:)` for multi-megapixel wallpapers.215  private static let thumbnailMaxPixel: Int = 320216217  private func loadImage() {218    isLoadingImage = true219    let url = self.wallpaper.url220    let maxPixel = Self.thumbnailMaxPixel221222    DispatchQueue.global(qos: .userInitiated).async {223      let loadedImage = LocalWallpaperItem.makeThumbnail(url: url, maxPixel: maxPixel)224      DispatchQueue.main.async {225        self.image = loadedImage226        self.isLoadingImage = false227      }228    }229  }230231  /// Builds an `NSImage` thumbnail via ImageIO. Public/static so it can be232  /// exercised by tests without spinning up a SwiftUI view, and so the233  /// decoding path never falls back to the full-resolution `NSImage`234  /// initializer used previously.235  static func makeThumbnail(url: URL, maxPixel: Int) -> NSImage? {236    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }237    let options: [CFString: Any] = [238      kCGImageSourceCreateThumbnailFromImageAlways: true,239      kCGImageSourceCreateThumbnailWithTransform: true,240      kCGImageSourceShouldCacheImmediately: true,241      kCGImageSourceThumbnailMaxPixelSize: maxPixel,242    ]243    guard244      let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)245    else { return nil }246    let size = NSSize(width: cgImage.width, height: cgImage.height)247    return NSImage(cgImage: cgImage, size: size)248  }249250  private func formatFileSize(_ bytes: Int64) -> String {251    let formatter = ByteCountFormatter()252    formatter.allowedUnits = [.useKB, .useMB, .useGB]253    formatter.countStyle = .file254    return formatter.string(fromByteCount: bytes)255  }256}257258// MARK: - Loading State259260struct LoadingState: View {261  var body: some View {262    VStack(spacing: 16) {263      ProgressView()264      Text("Loading wallpapers...")265        .foregroundColor(.secondary)266    }267    .frame(maxWidth: .infinity, maxHeight: .infinity)268  }269}