Schritt 4 der Umsetzungsreihenfolge. - AppSettings: Quell-/Zielordner über Security-Scoped Bookmarks, überlebt einen Neustart. - DropZoneView: Drag & Drop plus Dateiauswahl-Dialog, öffnet im konfigurierten Quellordner. - ConversionCoordinator treibt JobQueue an, sortiert neue Dateien nach zuletzt geändert, hält Fortschritt/Status pro Datei nach. - FileListView zeigt Fortschritt und hebt Problemfälle (übersprungen/ abgelehnt/fehlgeschlagen) sichtbar hervor — keine stillen Abbrüche. - CoreErrors+LocalizedError: deutsche Klartexte für die Core-Fehler- Enums leben bewusst im App-Target, nicht im UI-freien Core. - AppIcon aus assets/icon.svg gebaut (alle 10 macOS-Icongrößen via rsvg-convert, ImageMagicks eingebauter SVG-Renderer hat den Rahmen-Stroke verschluckt). Bewusst ausgeklammert (spätere Schritte): Review-Ansicht, Menu-Bar- Betrieb, Signierung/Notarisierung/Sparkle, Overwrite-Rückfrage-Dialog.main
@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_16x16.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_16x16@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32x32.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_32x32@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_128x128.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_128x128@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256x256.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_256x256@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512x512.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon_512x512@2x.png",
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct DropTwoApp: App {
|
||||||
|
@StateObject private var settings = AppSettings()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>drop.two</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>drop.two</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>(c) Samuel Zielke</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import DropTwoCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Treibt die Verarbeitung der Dateiliste über `JobQueue` an und hält den
|
||||||
|
/// UI-Status pro Datei nach. Kennt keine Views — nur `@Published`-Zustand,
|
||||||
|
/// den SwiftUI beobachtet.
|
||||||
|
@MainActor
|
||||||
|
final class ConversionCoordinator: ObservableObject {
|
||||||
|
@Published private(set) var items: [DropItem] = []
|
||||||
|
@Published private(set) var isRunning = false
|
||||||
|
|
||||||
|
private var runningTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var overallProgress: Double {
|
||||||
|
guard !items.isEmpty else { return 0 }
|
||||||
|
let sum = items.reduce(0.0) { partial, item in
|
||||||
|
switch item.status {
|
||||||
|
case .waiting:
|
||||||
|
return partial
|
||||||
|
case .running(let progress):
|
||||||
|
return partial + progress
|
||||||
|
case .skipped, .rejected, .succeeded, .failed:
|
||||||
|
return partial + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum / Double(items.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var problemItems: [DropItem] {
|
||||||
|
items.filter { $0.status.needsAttention }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst.
|
||||||
|
func addFiles(_ urls: [URL]) {
|
||||||
|
let existing = Set(items.map(\.sourceURL))
|
||||||
|
let newURLs = urls
|
||||||
|
.filter { !existing.contains($0) }
|
||||||
|
.sorted { modificationDate(for: $0) > modificationDate(for: $1) }
|
||||||
|
items.append(contentsOf: newURLs.map(DropItem.init))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
items.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(targetFolder: URL) {
|
||||||
|
guard !isRunning, !items.isEmpty else { return }
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
for index in items.indices {
|
||||||
|
items[index].status = .waiting
|
||||||
|
}
|
||||||
|
let jobs = items.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) }
|
||||||
|
|
||||||
|
runningTask = Task {
|
||||||
|
let stream = JobQueue.run(
|
||||||
|
jobs: jobs,
|
||||||
|
outputRootDirectory: targetFolder,
|
||||||
|
progress: { [weak self] job, value in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.updateProgress(jobID: job.id, value: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for await result in stream {
|
||||||
|
apply(result)
|
||||||
|
}
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress(jobID: UUID, value: Double) {
|
||||||
|
guard let index = items.firstIndex(where: { $0.id == jobID }) else { return }
|
||||||
|
items[index].status = .running(progress: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ result: JobQueue.JobResult) {
|
||||||
|
guard let index = items.firstIndex(where: { $0.id == result.job.id }) else { return }
|
||||||
|
switch result.outcome {
|
||||||
|
case .skipped(_, let reason):
|
||||||
|
items[index].status = .skipped(reason)
|
||||||
|
case .rejected(_, let reason):
|
||||||
|
items[index].status = .rejected(reason)
|
||||||
|
case .succeeded(let success):
|
||||||
|
items[index].status = .succeeded(croppedDestination: success.croppedDestination)
|
||||||
|
case .failed(_, let error):
|
||||||
|
items[index].status = .failed(message: Self.message(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func message(for error: Error) -> String {
|
||||||
|
if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modificationDate(for url: URL) -> Date {
|
||||||
|
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import DropTwoCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ein Element in der Dateiliste der Oberfläche, mit seinem aktuellen
|
||||||
|
/// Verarbeitungsstatus. Rein UI-seitig — die eigentliche Logik steckt in
|
||||||
|
/// `ConversionPipeline`/`JobQueue` in DropTwoCore.
|
||||||
|
struct DropItem: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let sourceURL: URL
|
||||||
|
var status: Status
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
case waiting
|
||||||
|
case running(progress: Double)
|
||||||
|
case skipped(SkipReason)
|
||||||
|
case rejected(RejectionReason)
|
||||||
|
case succeeded(croppedDestination: URL)
|
||||||
|
case failed(message: String)
|
||||||
|
|
||||||
|
/// Fehlerliste statt stiller Abbrüche: alles außer "läuft"/"fertig"
|
||||||
|
/// braucht sichtbare Aufmerksamkeit.
|
||||||
|
var needsAttention: Bool {
|
||||||
|
switch self {
|
||||||
|
case .skipped, .rejected, .failed:
|
||||||
|
return true
|
||||||
|
case .waiting, .running, .succeeded:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(sourceURL: URL) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
self.status = .waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName: String { sourceURL.lastPathComponent }
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Quell- und Zielordner, persistiert über Security-Scoped Bookmarks statt
|
||||||
|
/// roher Pfade — überlebt damit auch einen späteren Wechsel in den App
|
||||||
|
/// Sandbox (README: "@AppStorage + Security-Scoped Bookmarks").
|
||||||
|
@MainActor
|
||||||
|
final class AppSettings: ObservableObject {
|
||||||
|
@Published private(set) var sourceFolder: URL?
|
||||||
|
@Published private(set) var targetFolder: URL?
|
||||||
|
|
||||||
|
private enum DefaultsKey {
|
||||||
|
static let sourceBookmark = "dev.zielke.droptwo.sourceFolderBookmark"
|
||||||
|
static let targetBookmark = "dev.zielke.droptwo.targetFolderBookmark"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
sourceFolder = Self.resolveBookmark(forKey: DefaultsKey.sourceBookmark)
|
||||||
|
targetFolder = Self.resolveBookmark(forKey: DefaultsKey.targetBookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSourceFolder(_ url: URL) {
|
||||||
|
sourceFolder = url
|
||||||
|
Self.storeBookmark(for: url, key: DefaultsKey.sourceBookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTargetFolder(_ url: URL) {
|
||||||
|
targetFolder = url
|
||||||
|
Self.storeBookmark(for: url, key: DefaultsKey.targetBookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storeBookmark(for url: URL, key: String) {
|
||||||
|
do {
|
||||||
|
let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
} catch {
|
||||||
|
// Best effort: der Ordner bleibt für die laufende Sitzung trotzdem
|
||||||
|
// gesetzt, nur der Neustart-Fall geht dann verloren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveBookmark(forKey key: String) -> URL? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||||
|
var isStale = false
|
||||||
|
guard let url = try? URL(resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = url.startAccessingSecurityScopedResource()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@StateObject private var coordinator = ConversionCoordinator()
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
DropZoneView()
|
||||||
|
|
||||||
|
if !coordinator.items.isEmpty {
|
||||||
|
FileListView()
|
||||||
|
|
||||||
|
if !coordinator.problemItems.isEmpty {
|
||||||
|
Text("\(coordinator.problemItems.count) Datei(en) brauchen Aufmerksamkeit.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
ProgressView(value: coordinator.overallProgress)
|
||||||
|
Button("Leeren") { coordinator.removeAll() }
|
||||||
|
.disabled(coordinator.isRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Einstellungen…") { showSettings = true }
|
||||||
|
Spacer()
|
||||||
|
Button("Start") {
|
||||||
|
guard let target = settings.targetFolder else { return }
|
||||||
|
coordinator.start(targetFolder: target)
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(coordinator.items.isEmpty || coordinator.isRunning || settings.targetFolder == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(minWidth: 520, minHeight: 460)
|
||||||
|
.environmentObject(coordinator)
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct DropZoneView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@EnvironmentObject private var coordinator: ConversionCoordinator
|
||||||
|
@State private var isTargeted = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "arrow.down.doc")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("9:16-Dateien hierher ziehen")
|
||||||
|
.font(.headline)
|
||||||
|
Button("Datei(en) wählen…", action: chooseFiles)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 140)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6]))
|
||||||
|
.foregroundStyle(isTargeted ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||||
|
)
|
||||||
|
.onDrop(of: [.fileURL], isTargeted: $isTargeted, perform: handleDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chooseFiles() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = true
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie]
|
||||||
|
if let sourceFolder = settings.sourceFolder {
|
||||||
|
panel.directoryURL = sourceFolder
|
||||||
|
}
|
||||||
|
guard panel.runModal() == .OK else { return }
|
||||||
|
coordinator.addFiles(panel.urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDrop(providers: [NSItemProvider]) -> Bool {
|
||||||
|
guard !providers.isEmpty else { return false }
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var urls: [URL] = []
|
||||||
|
for provider in providers {
|
||||||
|
group.enter()
|
||||||
|
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||||
|
if let url { urls.append(url) }
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) {
|
||||||
|
coordinator.addFiles(urls)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FileListView: View {
|
||||||
|
@EnvironmentObject private var coordinator: ConversionCoordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(coordinator.items) { item in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.fileName)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
statusText(for: item.status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(item.status.needsAttention ? Color.orange : .secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
statusIndicator(for: item.status)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statusText(for status: DropItem.Status) -> some View {
|
||||||
|
switch status {
|
||||||
|
case .waiting:
|
||||||
|
Text("Wartet …")
|
||||||
|
case .running(let progress):
|
||||||
|
Text("\(Int(progress * 100)) %")
|
||||||
|
case .skipped(let reason):
|
||||||
|
Text(reason.errorDescription ?? "Übersprungen")
|
||||||
|
case .rejected(let reason):
|
||||||
|
Text(reason.errorDescription ?? "Abgelehnt")
|
||||||
|
case .succeeded(let destination):
|
||||||
|
Text("Fertig — \(destination.lastPathComponent)")
|
||||||
|
case .failed(let message):
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statusIndicator(for status: DropItem.Status) -> some View {
|
||||||
|
switch status {
|
||||||
|
case .waiting:
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .running(let progress):
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.frame(width: 60)
|
||||||
|
case .skipped:
|
||||||
|
Image(systemName: "arrow.uturn.forward.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .rejected, .failed:
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
case .succeeded:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Einstellungen")
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Quellordner").font(.headline)
|
||||||
|
Text("Der Dateiauswahl-Dialog öffnet sich hier.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
folderRow(current: settings.sourceFolder) { settings.setSourceFolder($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Zielordner").font(.headline)
|
||||||
|
Text("Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
folderRow(current: settings.targetFolder) { settings.setTargetFolder($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Fertig") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(minWidth: 440, minHeight: 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func folderRow(current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(current?.path ?? "Nicht gesetzt")
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.foregroundStyle(current == nil ? .secondary : .primary)
|
||||||
|
Spacer()
|
||||||
|
Button("Wählen…") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
if let current { panel.directoryURL = current }
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
onChoose(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||