Phase 8: Ablage, AirDrop und Calendarr

Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 10:16:19 +02:00
parent 28b1b4270e
commit 8308536227
25 changed files with 2917 additions and 868 deletions

View File

@@ -0,0 +1,77 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "AirDrop")
/// Der Weg zur Empfängerauswahl.
public enum AirDrop {
/// Öffnet AirDrop mit den übergebenen Dateien.
///
/// - Returns: ob AirDrop überhaupt zur Verfügung stand.
@MainActor
@discardableResult
public static func send(_ urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
guard let service = NSSharingService(named: .sendViaAirDrop) else {
log.error("AirDrop steht nicht zur Verfügung")
return false
}
guard service.canPerform(withItems: urls) else {
log.error("AirDrop kann diese Auswahl nicht senden")
return false
}
service.perform(withItems: urls)
return true
}
}
/// Eine Fläche, auf die man Dateien fallen lässt, um sie zu verschicken.
public struct AirDropView: View {
@State private var isTargeted = false
@State private var failed = false
public init() {}
public var body: some View {
VStack(spacing: 6) {
Image(systemName: "shareplay")
.font(.system(size: 20, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent : Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(failed ? Onyx.Color.warning : Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
// Versprechen an, nicht als fertige Datei.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
.animation(Onyx.Motion.value, value: isTargeted)
}
}
// MARK: - Widget
public struct AirDropWidget: OnyxWidget {
public let id = "airdrop"
public var displayName: String { String(localized: "airdrop.name", bundle: .module) }
public let symbolName = "shareplay"
public let supportedSizes: [WidgetSize] = [.small, .medium]
public init() {}
public func makeView(size: WidgetSize) -> AnyView {
AnyView(AirDropView())
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Nimmt gezogene Dateien an auch die, die es noch gar nicht gibt.
///
/// Mail, WhatsApp und die meisten Chat-Programme ziehen Anhänge nicht als
/// fertige Datei heraus, sondern als **Versprechen**: die Datei entsteht erst,
/// wenn das Ziel sagt, wohin. Genau diese Anhänge sind der Hauptgrund für eine
/// Ablage, also darf die Fläche nicht nur `public.file-url` annehmen.
///
/// `loadFileRepresentation` ist der Weg, der beides abdeckt: bei einer echten
/// Datei reicht es sie durch, bei einem Versprechen löst es dieses ein. Deshalb
/// `UTType.item` als angenommener Typ die Wurzel, der alles entspricht.
/// Was sich nicht als Datei materialisieren lässt, fällt hinten heraus.
public struct FileDropModifier: ViewModifier {
@Binding private var isTargeted: Bool
private let onFiles: ([URL]) -> Void
public init(isTargeted: Binding<Bool>, onFiles: @escaping ([URL]) -> Void) {
self._isTargeted = isTargeted
self.onFiles = onFiles
}
public func body(content: Content) -> some View {
content.onDrop(of: [.item], isTargeted: $isTargeted) { providers in
for provider in providers { receive(provider) }
return !providers.isEmpty
}
}
private func receive(_ provider: NSItemProvider) {
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
if let error {
log.error("Ablage: Datei nicht erhalten: \(error.localizedDescription, privacy: .public)")
return
}
guard let url else { return }
// Die übergebene Datei existiert **nur innerhalb dieses Blocks**;
// danach räumt das System sie weg. Wer sie erst auf dem MainActor
// anfassen will, greift ins Leere also hier und sofort sichern.
guard let staged = Self.stage(url) else { return }
Task { @MainActor in onFiles([staged]) }
}
}
/// Rettet die Datei aus dem flüchtigen Verzeichnis in einen eigenen
/// Zwischenordner.
private static func stage(_ url: URL) -> URL? {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("Onyx-Drop-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
let target = directory.appendingPathComponent(url.lastPathComponent)
// Verschieben, wenn möglich: bei einem Video von zwei Gigabyte ist
// der Unterschied zum Kopieren spürbar. Über Datenträgergrenzen
// hinweg scheitert es, dann wird kopiert.
do { try FileManager.default.moveItem(at: url, to: target) }
catch { try FileManager.default.copyItem(at: url, to: target) }
return target
} catch {
log.error("Ablage: Zwischenspeichern gescheitert: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
public extension View {
/// Macht die ganze Fläche zum Ziel für gezogene Dateien.
func onFileDrop(isTargeted: Binding<Bool>,
perform: @escaping ([URL]) -> Void) -> some View {
modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform))
}
}

View File

@@ -0,0 +1,166 @@
{
"sourceLanguage": "en",
"strings": {
"airdrop.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dateien zum Senden ablegen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop files to send"
}
}
}
},
"airdrop.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
}
}
}
},
"airdrop.unavailable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop kann das nicht senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop cannot send this"
}
}
}
},
"shelf.airdrop": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Per AirDrop senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Send via AirDrop"
}
}
}
},
"shelf.clear": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Alles leeren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Clear all"
}
}
}
},
"shelf.delete": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In den Papierkorb"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Move to Trash"
}
}
}
},
"shelf.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dateien hierhin ziehen, um sie zu parken."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop files here to park them."
}
}
}
},
"shelf.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablage"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shelf"
}
}
}
},
"shelf.open": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffnen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open"
}
}
}
},
"shelf.reveal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Finder zeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show in Finder"
}
}
}
}
},
"version": "1.1"
}

View File

@@ -0,0 +1,83 @@
import Foundation
/// Eine abgelegte Datei.
public struct ShelfItem: Identifiable, Equatable, Sendable {
public let url: URL
public let name: String
public let size: Int64
public let addedAt: Date
public var id: String { url.path }
public init(url: URL, name: String, size: Int64, addedAt: Date) {
self.url = url
self.name = name
self.size = size
self.addedAt = addedAt
}
/// Neuestes zuerst.
///
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
/// Dateien bei jeder Aktualisierung durcheinander.
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
items.sorted {
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
}
}
}
/// Findet freie Namen, ohne je etwas zu überschreiben.
public enum ShelfNaming {
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`,
///
/// Gleichnamige Dateien sind der Normalfall `IMG_0001.jpg` aus zwei
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
public static func uniqueName(for name: String, taken: Set<String>) -> String {
let name = name.isEmpty ? "Datei" : name
guard taken.contains(name) else { return name }
let (base, ext) = split(name)
var counter = 2
while true {
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
if !taken.contains(candidate) { return candidate }
counter += 1
}
}
/// Zerlegt in Namensteil und Endung.
///
/// Ein führender Punkt zählt nicht als Endung sonst würde aus
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
/// unbekannten Doppelendungen nichts kaputt macht.
private static func split(_ name: String) -> (base: String, ext: String) {
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
return (name, "")
}
return (String(name[name.startIndex..<dot]),
String(name[name.index(after: dot)...]))
}
}
/// Räumt alte Dateien weg wenn man es ausdrücklich möchte.
public enum ShelfCleanup {
/// Was über die Frist hinaus liegt.
///
/// `nil` und `0` heißen beide gar nicht". Eine Ablage, die ungefragt
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
/// nicht der Verlust aller Dateien sein.
public static func expired(_ items: [ShelfItem], now: Date,
afterDays: Int?) -> [ShelfItem] {
guard let days = afterDays, days > 0 else { return [] }
let limit = now.addingTimeInterval(-Double(days) * 86_400)
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
// unwiderruflich, einen Tag länger warten ist es nicht.
return items.filter { $0.addedAt < limit }
}
}

View File

@@ -0,0 +1,137 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Die Ablage: ein Ordner, in dem Dateien zwischenlagern.
///
/// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in
/// temporären Verzeichnissen, die das System aufräumt ein Verweis darauf
/// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie
/// braucht.
@MainActor
@Observable
public final class ShelfStore {
public private(set) var items: [ShelfItem] = []
public private(set) var lastError: String?
/// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard.
public var autoCleanupDays: Int? {
didSet {
defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey)
cleanUp()
}
}
private static let cleanupKey = "shelf.cleanupDays"
public let directory: URL
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard, directory: URL? = nil) {
self.defaults = defaults
self.directory = directory ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Shelf", isDirectory: true)
let stored = defaults.integer(forKey: Self.cleanupKey)
self.autoCleanupDays = stored > 0 ? stored : nil
try? FileManager.default.createDirectory(at: self.directory,
withIntermediateDirectories: true)
reload()
cleanUp()
}
// MARK: - Lesen
public func reload() {
let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey,
.contentModificationDateKey, .isDirectoryKey]
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]) else {
items = []
return
}
items = ShelfItem.sorted(urls.compactMap { url in
let values = try? url.resourceValues(forKeys: Set(keys))
return ShelfItem(
url: url,
name: url.lastPathComponent,
size: Int64(values?.fileSize ?? 0),
// Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde.
// Ein zehn Jahre altes Foto von gestern soll nicht sofort
// ablaufen, nur weil die Kamera ein altes Datum trägt.
addedAt: values?.addedToDirectoryDate
?? values?.contentModificationDate ?? Date())
})
}
// MARK: - Hinzufügen
/// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam.
@discardableResult
public func add(_ urls: [URL]) -> [ShelfItem] {
var added: [ShelfItem] = []
var taken = Set(items.map(\.name))
for source in urls {
// Dateien aus fremden Apps kommen als sicherheitsbereichte URLs.
// Ohne das Paar aus Start und Stop scheitert das Kopieren mit
// Berechtigung verweigert" und zwar erst bei manchen Quellen,
// was den Fehler besonders unangenehm macht.
let scoped = source.startAccessingSecurityScopedResource()
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken)
let target = directory.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: source, to: target)
taken.insert(name)
let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
added.append(ShelfItem(url: target, name: name,
size: Int64(size), addedAt: Date()))
} catch {
log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)")
lastError = error.localizedDescription
}
}
if !added.isEmpty { reload() }
return added
}
// MARK: - Entfernen
public func remove(_ item: ShelfItem) {
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
// und ein Fehlklick darf keine Datei vernichten.
NSWorkspace.shared.recycle([item.url]) { [weak self] _, error in
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
Task { @MainActor in self?.reload() }
}
}
public func removeAll() {
guard !items.isEmpty else { return }
NSWorkspace.shared.recycle(items.map(\.url)) { [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Räumt ab, was über die eingestellte Frist hinaus liegt.
public func cleanUp(now: Date = Date()) {
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
guard !expired.isEmpty else { return }
log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen")
NSWorkspace.shared.recycle(expired.map(\.url)) { [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Öffnet den Ablageordner im Finder der Notausgang, wenn die Oberfläche
/// nicht das kann, was man gerade braucht.
public func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting([directory])
}
}

View File

@@ -0,0 +1,203 @@
import SwiftUI
import AppKit
import QuickLookThumbnailing
import OnyxDesign
import OnyxWidgetKit
/// Vorschaubilder, einmal erzeugt und behalten.
///
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
/// jedem Tastendruck ein neuer Auftrag laufen.
@MainActor
@Observable
final class ThumbnailCache {
private var images: [String: NSImage] = [:]
private var pending: Set<String> = []
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
if let cached = images[item.id] { return cached }
guard !pending.contains(item.id) else { return nil }
pending.insert(item.id)
let request = QLThumbnailGenerator.Request(
fileAt: item.url, size: CGSize(width: size, height: size),
scale: NSScreen.main?.backingScaleFactor ?? 2,
representationTypes: .all)
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in
guard let rep else { return }
let image = NSImage(cgImage: rep.cgImage,
size: CGSize(width: size, height: size))
Task { @MainActor [weak self] in
self?.images[item.id] = image
self?.pending.remove(item.id)
}
}
return nil
}
}
/// Die Ablage.
public struct ShelfView: View {
private let store: ShelfStore
private let compact: Bool
@State private var isTargeted = false
@State private var thumbnails = ThumbnailCache()
public init(store: ShelfStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
if store.items.isEmpty {
EmptyShelf()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails)
}
}
}
.scrollIndicators(.never)
HStack(spacing: 10) {
Button { store.removeAll() } label: {
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
Button { store.revealInFinder() } label: {
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
.overlay {
if isTargeted {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08),
in: .rect(cornerRadius: 10, style: .continuous))
.allowsHitTesting(false)
}
}
.animation(Onyx.Motion.value, value: isTargeted)
}
}
private struct EmptyShelf: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: "tray")
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text("shelf.empty", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
private struct ShelfRow: View {
let item: ShelfItem
let store: ShelfStore
let thumbnails: ThumbnailCache
@State private var isHovered = false
var body: some View {
HStack(spacing: 8) {
thumbnail
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Text(item.size.formatted(.byteCount(style: .file)))
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 0)
if isHovered {
Button { AirDrop.send([item.url]) } label: {
Image(systemName: "shareplay").font(.system(size: 10))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.padding(.vertical, 2)
.contentShape(.rect)
.onHover { isHovered = $0 }
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
// reicht die URL als Text weiter, und im Finder landet eine
// Internetverknüpfung statt der Datei.
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
.contextMenu {
Button { NSWorkspace.shared.open(item.url) } label: {
Text("shelf.open", bundle: .module)
}
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
Text("shelf.reveal", bundle: .module)
}
Button { AirDrop.send([item.url]) } label: {
Text("shelf.airdrop", bundle: .module)
}
Divider()
Button(role: .destructive) { store.remove(item) } label: {
Text("shelf.delete", bundle: .module)
}
}
}
@ViewBuilder
private var thumbnail: some View {
if let image = thumbnails.image(for: item, size: 22) {
Image(nsImage: image)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.clipShape(.rect(cornerRadius: 3, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Onyx.Color.elevated)
.frame(width: 22, height: 22)
}
}
}
// MARK: - Widget
public struct ShelfWidget: OnyxWidget {
public let id = "shelf"
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
public let symbolName = "tray.full"
public let supportedSizes: [WidgetSize] = [.medium, .large]
private let store: ShelfStore
public init(store: ShelfStore) { self.store = store }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(ShelfView(store: store, compact: size == .medium))
}
}