Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
<key>CFBundleExecutable</key>
<string>Wallpaper</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<string>1.1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
Expand Down
13 changes: 12 additions & 1 deletion Sources/Wallpaper/BingAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ struct BingResponse: Codable {
}

/// A single Bing daily wallpaper entry
struct BingImage: Codable {
struct BingImage: Codable, Hashable, Identifiable {
let startdate: String // e.g. "20260218"
let urlbase: String // path to build UHD image URL
let copyright: String
let title: String

var id: String { startdate }

/// Formats "20260218" as "2026-02-18"
var formattedDate: String {
guard startdate.count == 8 else { return startdate }
let y = startdate.prefix(4)
let m = startdate.dropFirst(4).prefix(2)
let d = startdate.dropFirst(6).prefix(2)
return "\(y)-\(m)-\(d)"
}
}

enum WallpaperError: LocalizedError {
Expand Down
81 changes: 81 additions & 0 deletions Sources/Wallpaper/PreferencesStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

struct WallpaperPreferences: Codable {
var dislikedDates: Set<String> = [] // startdate strings, e.g. "20260218"
var favorites: [BingImage] = [] // full metadata for re-download
}

@MainActor @Observable
final class PreferencesStore {
static let shared = PreferencesStore()
private(set) var preferences = WallpaperPreferences()
let fileURL: URL

init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("BingWallpaper")
.appendingPathComponent("preferences.json")
}

func load() {
let fm = FileManager.default
guard fm.fileExists(atPath: fileURL.path) else { return }
do {
let data = try Data(contentsOf: fileURL)
preferences = try JSONDecoder().decode(WallpaperPreferences.self, from: data)
} catch {
// If corrupted, start fresh
preferences = WallpaperPreferences()
}
}

func save() {
let fm = FileManager.default
let dir = fileURL.deletingLastPathComponent()
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
do {
let data = try JSONEncoder().encode(preferences)
try data.write(to: fileURL, options: .atomic)
} catch {
// Silent failure — preferences are non-critical
}
}

// MARK: - Dislikes

func addDislike(_ date: String) {
preferences.dislikedDates.insert(date)
save()
}

func removeDislike(_ date: String) {
preferences.dislikedDates.remove(date)
save()
}

func isDisliked(_ date: String) -> Bool {
preferences.dislikedDates.contains(date)
}

// MARK: - Favorites

func addFavorite(_ image: BingImage) {
guard !preferences.favorites.contains(where: { $0.startdate == image.startdate }) else { return }
preferences.favorites.append(image)
save()
}

func removeFavorite(_ image: BingImage) {
preferences.favorites.removeAll { $0.startdate == image.startdate }
save()
}

func isFavorited(_ date: String) -> Bool {
preferences.favorites.contains { $0.startdate == date }
}

func favoriteDates() -> Set<String> {
Set(preferences.favorites.map(\.startdate))
}
}
169 changes: 163 additions & 6 deletions Sources/Wallpaper/WallpaperApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ struct WallpaperApp: App {
var body: some Scene {
MenuBarExtra("Wallpaper", systemImage: "photo.on.rectangle") {
VStack(spacing: 0) {
imageCard
if manager.showingFavorites {
favoritesPanel
} else {
imageCard
}
Divider()
toolbar
}
Expand Down Expand Up @@ -78,27 +82,75 @@ struct WallpaperApp: App {
LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .top, endPoint: .bottom)
)

// Action buttons at top corners (visible on hover)
VStack {
HStack {
actionButton(
icon: manager.isCurrentDisliked ? "hand.thumbsdown.fill" : "hand.thumbsdown",
tint: manager.isCurrentDisliked ? .orange : nil,
enabled: !manager.images.isEmpty
) {
if manager.isCurrentDisliked {
manager.undoDislike()
} else {
Task { await manager.dislike() }
}
}

Spacer()

actionButton(
icon: manager.isCurrentFavorited ? "hand.thumbsup.fill" : "hand.thumbsup",
tint: manager.isCurrentFavorited ? .blue : nil,
enabled: !manager.images.isEmpty
) { manager.toggleFavorite() }
}
.padding(.horizontal, 6)
.padding(.top, 6)

Spacer()
}
.opacity(isHoveringImage ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHoveringImage)

// Navigation arrows (visible on hover)
HStack {
navArrow("chevron.left", enabled: manager.hasPrevious) { Task { await manager.previous() } }
actionButton(
icon: "chevron.left",
enabled: manager.hasPrevious
) { Task { await manager.previous() } }

Spacer()
navArrow("chevron.right", enabled: manager.hasNext) { Task { await manager.next() } }

actionButton(
icon: "chevron.right",
enabled: manager.hasNext
) { Task { await manager.next() } }
}
.padding(.horizontal, 6)
.opacity(isHoveringImage ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHoveringImage)
}
.onHover { isHoveringImage = $0 }
.onTapGesture { Task { await manager.applyCurrentWallpaper() } }
.onTapGesture {
if !manager.isCurrentDisliked {
Task { await manager.applyCurrentWallpaper() }
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(8)
}

private func navArrow(_ icon: String, enabled: Bool, action: @escaping () -> Void) -> some View {
private func actionButton(
icon: String,
tint: Color? = nil,
enabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(tint ?? .white)
.frame(width: 28, height: 28)
.background(.ultraThinMaterial.opacity(0.8))
.clipShape(Circle())
Expand All @@ -109,6 +161,97 @@ struct WallpaperApp: App {
.disabled(!enabled)
}

// MARK: - Favorites Panel

private var favoritesPanel: some View {
ZStack(alignment: .bottom) {
if let fav = manager.currentFavorite {
if let image = manager.favoritePreviewImage {
Image(nsImage: image)
.resizable()
.aspectRatio(16 / 9, contentMode: .fill)
.frame(maxWidth: .infinity)
.clipped()
} else {
Rectangle()
.fill(.quaternary)
.aspectRatio(16 / 9, contentMode: .fit)
.overlay {
Image(systemName: "photo")
.font(.largeTitle)
.foregroundStyle(.secondary)
}
}

// Title overlay
VStack(alignment: .leading, spacing: 2) {
Spacer()
Text(fav.title)
.font(.callout.weight(.medium))
.lineLimit(2)
Text(fav.formattedDate)
.font(.caption2)
.opacity(0.8)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .top, endPoint: .bottom)
)

// Top-right actions (visible on hover)
VStack {
HStack {
Spacer()
actionButton(icon: "desktopcomputer", enabled: true) {
Task { await manager.applyFavorite(fav) }
}
actionButton(icon: "heart.slash.fill", tint: .pink, enabled: true) {
Task { await manager.removeCurrentFavorite() }
}
}
.padding(.horizontal, 6)
.padding(.top, 6)
Spacer()
}
.opacity(isHoveringImage ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHoveringImage)

// Navigation arrows (visible on hover)
HStack {
actionButton(icon: "chevron.left", enabled: manager.hasPreviousFavorite) {
manager.previousFavorite()
}
Spacer()
actionButton(icon: "chevron.right", enabled: manager.hasNextFavorite) {
manager.nextFavorite()
}
}
.padding(.horizontal, 6)
.opacity(isHoveringImage ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isHoveringImage)
} else {
Rectangle()
.fill(.quaternary)
.aspectRatio(16 / 9, contentMode: .fit)
.overlay {
VStack(spacing: 8) {
Image(systemName: "heart.slash")
.font(.title)
.foregroundStyle(.secondary)
Text("No favorites yet")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.onHover { isHoveringImage = $0 }
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(8)
}

// MARK: - Toolbar

private var toolbar: some View {
Expand Down Expand Up @@ -155,6 +298,20 @@ struct WallpaperApp: App {

Spacer()

Button {
if manager.showingFavorites {
Task { await manager.hideFavorites() }
} else {
manager.showFavorites()
}
} label: {
Image(systemName: manager.showingFavorites ? "heart.fill" : "heart")
.foregroundStyle(manager.showingFavorites ? .pink : .primary)
}
.help("Favorites")

Spacer()

Button {
refreshTrigger += 1
Task { await manager.refresh() }
Expand Down
Loading