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: deleteMessage64    )65    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)66    .onAppear {67      if appState.saveToFolderEnabled {68        localManager.scanFolder(path: appState.saveFolderPath)69      }70    }71  }7273  func scan() { localManager.scanFolder(path: appState.saveFolderPath) }74  func set(_ wallpaper: LocalWallpaper) async { await appState.setLocalWallpaper(wallpaper) }75  func requestDelete(_ wallpaper: LocalWallpaper) {76    wallpaperToDelete = wallpaper77    showingDeleteConfirmation = true78  }79  func delete(_ wallpaper: LocalWallpaper) { localManager.deleteWallpaper(wallpaper) }80  func cancelDelete() { wallpaperToDelete = nil }81  func deleteMessage(_ wallpaper: LocalWallpaper) -> Text {82    Text("This will permanently delete '\(wallpaper.filename)' from your save folder.")83  }84  @ViewBuilder func deleteActions(_ wallpaper: LocalWallpaper) -> some View {85    Button("Cancel", role: .cancel, action: cancelDelete)86    Button("Delete", role: .destructive) { delete(wallpaper) }87  }88}8990// MARK: - Local Wallpaper Grid9192struct LocalWallpaperGrid: View {93  let wallpapers: [LocalWallpaper]94  let onSetWallpaper: (LocalWallpaper) -> Void95  let onDeleteWallpaper: (LocalWallpaper) -> Void9697  var body: some View {98    ScrollView {99      LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {100        ForEach(wallpapers) { wallpaper in101          LocalWallpaperItem(102            wallpaper: wallpaper,103            onSet: { onSetWallpaper(wallpaper) },104            onDelete: { onDeleteWallpaper(wallpaper) }105          )106        }107      }108      .padding(12)109    }110  }111}112113// MARK: - Local Wallpaper Item114115struct LocalWallpaperItem: View {116  let wallpaper: LocalWallpaper117  let onSet: () -> Void118  let onDelete: () -> Void119120  @State private var isHovering = false121  @State private var image: NSImage?122  @State private var isLoadingImage = true123124  init(125    wallpaper: LocalWallpaper, onSet: @escaping () -> Void, onDelete: @escaping () -> Void,126    initialImage: NSImage? = nil, isLoadingImage: Bool = true127  ) {128    self.wallpaper = wallpaper129    self.onSet = onSet130    self.onDelete = onDelete131    _image = State(initialValue: initialImage)132    _isLoadingImage = State(initialValue: isLoadingImage)133  }134135  var body: some View {136    Button(action: onSet) {137      ZStack {138        if let image = image {139          Image(nsImage: image)140            .resizable()141            .aspectRatio(contentMode: .fill)142            .clipped()143        } else if isLoadingImage {144          Rectangle()145            .fill(Color.gray.opacity(0.2))146            .overlay(ProgressView())147        } else {148          Rectangle()149            .fill(Color.gray.opacity(0.1))150            .overlay(151              VStack {152                Image(systemName: "photo")153                  .foregroundColor(.gray)154                Text(wallpaper.filename)155                  .font(.caption2)156                  .foregroundColor(.secondary)157                  .lineLimit(1)158              }159            )160        }161      }162      .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 100)163      .cornerRadius(8)164      .overlay(165        RoundedRectangle(cornerRadius: 8)166          .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)167      )168      .shadow(radius: isHovering ? 4 : 0)169      .overlay(170        // Action Buttons171        HStack {172          // Delete button173          Button(action: onDelete) {174            Image(systemName: "trash")175              .foregroundColor(.white)176              .shadow(radius: 2)177              .font(.system(size: 14, weight: .bold))178              .padding(6)179              .background(Color.red.opacity(0.7))180              .clipShape(Circle())181          }182          .buttonStyle(.plain)183          .padding(6)184185          Spacer()186187          // Resolution badge188          Text(wallpaper.resolution)189            .font(.caption2)190            .padding(.horizontal, 6)191            .padding(.vertical, 2)192            .background(Color.black.opacity(0.6))193            .foregroundColor(.white)194            .cornerRadius(4)195            .padding(6)196        }197        .opacity(isHovering ? 1 : 0)198        .animation(.easeInOut(duration: 0.15), value: isHovering),199        alignment: .top200      )201      .scaleEffect(isHovering ? 1.02 : 1.0)202      .animation(.spring(response: 0.3), value: isHovering)203    }204    .buttonStyle(.plain)205    .onHover { isHovering = $0 }206    .help("\(wallpaper.resolution) • \(formatFileSize(wallpaper.fileSize))")207    .onAppear {208      loadImage()209    }210  }211212  /// Maximum thumbnail pixel dimension. ImageIO uses this as the larger side213  /// of the downsampled output — the full-resolution JPEG/PNG is decoded214  /// through `CGImageSourceCreateThumbnailAtIndex` which is dramatically215  /// cheaper than `NSImage(contentsOf:)` for multi-megapixel wallpapers.216  private static let thumbnailMaxPixel: Int = 320217218  private func loadImage() {219    isLoadingImage = true220    let url = self.wallpaper.url221    let maxPixel = Self.thumbnailMaxPixel222223    DispatchQueue.global(qos: .userInitiated).async {224      let loadedImage = LocalWallpaperItem.makeThumbnail(url: url, maxPixel: maxPixel)225      DispatchQueue.main.async {226        self.image = loadedImage227        self.isLoadingImage = false228      }229    }230  }231232  /// Builds an `NSImage` thumbnail via ImageIO. Public/static so it can be233  /// exercised by tests without spinning up a SwiftUI view, and so the234  /// decoding path never falls back to the full-resolution `NSImage`235  /// initializer used previously.236  static func makeThumbnail(url: URL, maxPixel: Int) -> NSImage? {237    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }238    let options: [CFString: Any] = [239      kCGImageSourceCreateThumbnailFromImageAlways: true,240      kCGImageSourceCreateThumbnailWithTransform: true,241      kCGImageSourceShouldCacheImmediately: true,242      kCGImageSourceThumbnailMaxPixelSize: maxPixel,243    ]244    guard245      let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)246    else { return nil }247    let size = NSSize(width: cgImage.width, height: cgImage.height)248    return NSImage(cgImage: cgImage, size: size)249  }250251  private func formatFileSize(_ bytes: Int64) -> String {252    let formatter = ByteCountFormatter()253    formatter.allowedUnits = [.useKB, .useMB, .useGB]254    formatter.countStyle = .file255    return formatter.string(fromByteCount: bytes)256  }257}258259// MARK: - Loading State260261struct LoadingState: View {262  var body: some View {263    VStack(spacing: 16) {264      ProgressView()265      Text("Loading wallpapers...")266        .foregroundColor(.secondary)267    }268    .frame(maxWidth: .infinity, maxHeight: .infinity)269  }270}