Zum Inhalt

apple/PhysarumApp/Sources/Library/LibraryView.swift

#if !os(tvOS)
import SwiftUI
import UniformTypeIdentifiers
import PhysarumEngine

/// The preset a per-card "Export…" is currently writing out (drives `.fileExporter`).
private struct ExportTarget: Identifiable { let id: String; let preset: Preset }

/// The preset library as a dark overlay: search + tag-filter pills + a card grid.
struct LibraryView: View {
    @Bindable var state: AppState
    @Binding var isPresented: Bool
    /// Sheet mode (compact) dismisses the library on pick; sidebar mode keeps it open
    /// so the picked preset swaps the living image in place without closing the column.
    var dismissOnSelect = true
    /// Opens the save flow (set by RootView). Surfaces a "Save current…" button in the header so the
    /// left-bar library is one of the two save entry points (topbar is the other).
    var onSave: (() -> Void)? = nil
    @State private var search = ""
    @State private var activeTags: Set<String> = []
    @State private var exportTarget: ExportTarget?
    @State private var showImporter = false
    @State private var showResetConfirm = false
    @State private var showKeepPinnedConfirm = false
    /// Import-decode/slot-full failures route through the app-wide alert (no inline text).
    @Environment(ErrorPresenter.self) private var errorPresenter

    #if !os(macOS)
    @Environment(\.horizontalSizeClass) private var hSizeClass
    #endif

    /// Compact (iPhone, iPad-portrait/split) → a single vertical column so cards
    /// stack and scroll; regular width keeps the adaptive multi-column grid.
    private var columns: [GridItem] {
        #if os(macOS)
        [GridItem(.adaptive(minimum: 120), spacing: Theme.Space.s3)]
        #else
        hSizeClass == .compact
            ? [GridItem(.flexible(), spacing: Theme.Space.s3)]
            : [GridItem(.adaptive(minimum: 120), spacing: Theme.Space.s3)]
        #endif
    }

    private var allTags: [String] {
        Array(Set(state.presets.flatMap { $0.tags })).sorted()
    }

    private var isFiltering: Bool { !search.isEmpty || !activeTags.isEmpty }

    private var filtered: [Preset] {
        state.presets.filter { p in
            let matchesSearch = search.isEmpty
                || p.name.localizedCaseInsensitiveContains(search)
                || p.tags.contains { $0.localizedCaseInsensitiveContains(search) }
            let matchesTags = activeTags.isEmpty || activeTags.isSubset(of: Set(p.tags))
            return matchesSearch && matchesTags
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s3) {
            HStack {
                Text("PICK A PRESET")
                    .font(Theme.Fonts.eyebrow).tracking(1.6)
                    .foregroundStyle(Theme.Colors.fgTertiary)
                Spacer()
                TextField("Search…", text: $search)
                    .textFieldStyle(.roundedBorder)
                    .frame(maxWidth: 220)
                    .accessibilityLabel("Search presets")
                    .accessibilityHint("Filter by name or tag")
                if let onSave {
                    Button { onSave() } label: {
                        Image(systemName: "tray.and.arrow.down").font(.system(size: 13))
                    }
                    .buttonStyle(.plain)
                    .foregroundStyle(Theme.Colors.fgSecondary)
                    .help("Save current preset…")
                    .accessibilityLabel("Save current")
                    .accessibilityHint("Open the save dialog")
                }
                Button { showImporter = true } label: {
                    Image(systemName: "square.and.arrow.down").font(.system(size: 13))
                }
                .buttonStyle(.plain)
                .foregroundStyle(Theme.Colors.fgSecondary)
                .help("Import a preset…")
                .accessibilityLabel("Import preset")
                .accessibilityHint("Load a preset file")
                Menu {
                    Button("Keep only pinned") { showKeepPinnedConfirm = true }
                        .disabled(state.pinned.isEmpty)
                        .accessibilityLabel("Keep only pinned")
                        .accessibilityHint("Clear unpinned slots")
                        .accessibilityValue(state.pinned.isEmpty ? "No pinned presets" : "")
                    Button("Reset all Presets", role: .destructive) { showResetConfirm = true }
                        .accessibilityLabel("Reset all presets")
                        .accessibilityHint("Restore factory defaults")
                } label: {
                    Image(systemName: "ellipsis.circle").font(.system(size: 13))
                }
                .buttonStyle(.plain)
                .foregroundStyle(Theme.Colors.fgSecondary)
                .help("Library actions")
                // Sidebar mode (dismissOnSelect == false) is presented with a `.constant(true)`
                // binding, so writing `isPresented = false` here would be a silent no-op — the
                // column is collapsed via the topbar preset pill (columnVisibility) instead.
                // Only the sheet (compact) instance owns a real binding, so show the close glyph there.
                if dismissOnSelect {
                    Button { isPresented = false } label: {
                        Image(systemName: "xmark").font(.system(size: 12))
                    }
                    .buttonStyle(.plain)
                    .foregroundStyle(Theme.Colors.fgSecondary)
                    .accessibilityLabel("Close")
                    .accessibilityHint("Dismiss the library")
                }
            }

            if !allTags.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: Theme.Space.s2) {
                        ForEach(allTags, id: \.self) { tag in
                            TagPill(label: tag, active: activeTags.contains(tag)) {
                                if activeTags.contains(tag) { activeTags.remove(tag) } else { activeTags.insert(tag) }
                            }
                        }
                    }
                }
            }

            ScrollView {
                LazyVGrid(columns: columns, spacing: Theme.Space.s3) {
                    ForEach(filtered, id: \.id) { preset in
                        PresetCard(
                            preset: preset,
                            selected: preset.id == state.selectedID,
                            thumbURL: PresetCatalog.thumbnailURL(for: preset, store: state.presetStore),
                            reloadToken: state.thumbnailRevision
                        ) {
                            state.select(id: preset.id)
                            if dismissOnSelect { isPresented = false }
                        }
                        // A tappable pin star (top-right): pinned slots form the ATV lean-back rotation.
                        .overlay(alignment: .topTrailing) {
                            Button { state.togglePin(preset.id) } label: {
                                Image(systemName: state.isPinned(preset.id) ? "star.fill" : "star")
                                    .font(.system(size: 13))
                                    .foregroundStyle(state.isPinned(preset.id) ? Theme.Colors.accent : Theme.Colors.fgSecondary)
                                    .frame(width: 30, height: 30)
                                    .background(.black.opacity(0.35), in: Circle())
                                    .contentShape(Circle())   // whole circle is tappable, not just the glyph
                            }
                            .buttonStyle(.plain)
                            .padding(5)
                            .help(state.isPinned(preset.id) ? "Unpin (remove from ATV rotation)" : "Pin to the ATV rotation")
                            .accessibilityLabel("Pin")
                            .accessibilityHint("Add or remove from Apple TV rotation")
                            .accessibilityValue(A11y.pinned(state.isPinned(preset.id)))
                        }
                        .contextMenu {
                            Button("Export…") { exportTarget = ExportTarget(id: preset.id, preset: preset) }
                                .accessibilityLabel("Export preset")
                                .accessibilityHint("Save preset to a file")
                                .accessibilityValue(preset.name)
                            Button(state.isPinned(preset.id) ? "Unpin" : "Pin") { state.togglePin(preset.id) }
                            Divider()
                            Button("Clear", role: .destructive) { state.clearSlot(slotID: preset.id) }
                                .accessibilityLabel("Clear slot")
                                .accessibilityHint("Empty this library slot")
                                .accessibilityValue(preset.name)
                        }
                    }
                }
            }
        }
        .padding(Theme.Space.s5)
        // No macOS minWidth: the library is docked as a fixed-width (panelWidth) overlay
        // column in RootView, so forcing a 640pt min here centred the wider content inside
        // the narrow panel and clipped it at both edges. It lays out to the panel width.
        .background(Theme.Colors.surface)
        .foregroundStyle(Theme.Colors.fgPrimary)
        .tint(Theme.Colors.accent)
        // Export + import on ALL platforms (Files app handles iOS; no macOS-only gate).
        .fileExporter(
            isPresented: Binding(get: { exportTarget != nil }, set: { if !$0 { exportTarget = nil } }),
            document: exportTarget.map { PresetDocument(preset: $0.preset) },
            contentType: .json,
            defaultFilename: exportTarget.map { slug($0.preset.name) }
        ) { _ in exportTarget = nil }
        .fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in
            guard case .success(let url) = result else { return }   // user cancelled → silent
            let scoped = url.startAccessingSecurityScopedResource()
            defer { if scoped { url.stopAccessingSecurityScopedResource() } }
            guard let data = try? Data(contentsOf: url),
                  let preset = try? JSONDecoder().decode(Preset.self, from: data) else {
                errorPresenter.report(title: "Couldn’t Import Preset",
                                      message: "That file isn’t a valid Physarum preset.")
                return
            }
            if !state.importIntoSlot(preset) {
                errorPresenter.report(title: "No Free Slot",
                                      message: "All 24 slots are full. Clear one, then import again.")
            }
        }
        .alert("Reset all Presets", isPresented: $showResetConfirm) {
            Button("Reset", role: .destructive) { state.resetAllToFactory() }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("Are you sure? This will reset all your presets to factory settings.")
        }
        .alert("Keep only pinned", isPresented: $showKeepPinnedConfirm) {
            Button("Keep pinned", role: .destructive) { state.keepOnlyPinned() }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("This clears every slot you haven't pinned, keeping only your pinned presets.")
        }
    }

    /// Filesystem-safe slug for an export filename (mirrors AppState's id slug).
    private func slug(_ s: String) -> String {
        let mapped = s.lowercased().map { ch -> Character in ch.isLetter || ch.isNumber ? ch : "-" }
        let collapsed = String(mapped).split(separator: "-").joined(separator: "-")
        return collapsed.isEmpty ? "preset" : collapsed
    }
}
#endif