Zum Inhalt

apple/PhysarumApp/Sources/Library/SaveDialog.swift

#if !os(tvOS)
import SwiftUI
import PhysarumEngine

/// Save the current edits into a slot: name/tags/signal, then pick one of the 24 slots (filled =
/// name + thumbnail and confirms before overwriting, empty = + fills directly).
struct SaveDialog: View {
    @Bindable var state: AppState
    @Binding var isPresented: Bool
    @State private var name = ""
    @State private var tagsText = ""
    @State private var signal: SignalName = .pearl
    /// The slot the user has selected to save into (highlighted; not yet committed).
    @State private var pickedIndex: Int?
    /// Set when committing into a FILLED slot — drives the overwrite confirmation.
    @State private var overwriteIndex: Int?

    private let columns = [GridItem(.adaptive(minimum: 96), spacing: Theme.Space.s2)]

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s4) {
            Text("SAVE PRESET")
                .font(Theme.Fonts.eyebrow).tracking(1.6)
                .foregroundStyle(Theme.Colors.fgTertiary)

            field("Name") { TextField("Name", text: $name).textFieldStyle(.roundedBorder) }
            field("Tags (comma-separated)") {
                TextField("soft, flow, cool…", text: $tagsText).textFieldStyle(.roundedBorder)
            }
            field("Signal colour") {
                Picker("", selection: $signal) {
                    ForEach(SignalName.allCases, id: \.self) { Text($0.rawValue).tag($0) }
                }
                .labelsHidden()
                .accessibilityLabel("Signal colour")
                .accessibilityValue(signal.rawValue)
            }

            Text("PICK A SLOT")
                .font(Theme.Fonts.eyebrow).tracking(1.6)
                .foregroundStyle(Theme.Colors.fgTertiary)
            ScrollView {
                LazyVGrid(columns: columns, spacing: Theme.Space.s2) {
                    ForEach(0..<PresetSlots.count, id: \.self) { i in
                        let preset = state.slotStore.slots.preset(at: i)
                        SlotCell(
                            preset: preset,
                            isSelected: i == pickedIndex,
                            thumbURL: preset.flatMap { PresetCatalog.thumbnailURL(for: $0, store: state.presetStore) }
                        ) {
                            pickedIndex = i   // select the target; commit happens via the Save button
                        }
                        // "Save to slot" / "Which slot to write into": each slot cell is the
                        // picker's element — it announces its slot, preset, and picked state.
                        .accessibilityLabel("Save to slot \(i + 1), \(preset?.name ?? "empty")")
                        .accessibilityHint("Which slot to write into")
                        .accessibilityValue(A11y.selected(i == pickedIndex))
                    }
                }
            }
            .frame(maxHeight: 260)

            // Save (into the picked slot) sits above Cancel, as the primary action.
            VStack(spacing: Theme.Space.s2) {
                PhysButton(title: saveTitle, kind: .ember) { commitSave() }
                    .accessibilityLabel("Save preset")
                PhysButton(title: "Cancel", kind: .ghost) { isPresented = false }
                    .accessibilityLabel("Cancel")
            }
        }
        .padding(Theme.Space.s5)
        .frame(width: 440)
        .background(Theme.Colors.surface)
        .foregroundStyle(Theme.Colors.fgPrimary)
        .tint(Theme.Colors.accent)
        .onAppear {
            name = state.selectedPreset.name
            tagsText = state.selectedPreset.tags.joined(separator: ", ")
            signal = state.selectedPreset.tagSignal
            pickedIndex = state.selectedSlotIndex ?? state.firstEmptySlotIndex ?? 0   // default: the current slot
        }
        .alert("Overwrite slot?", isPresented: Binding(
            get: { overwriteIndex != nil }, set: { if !$0 { overwriteIndex = nil } }
        )) {
            Button("Overwrite", role: .destructive) { if let i = overwriteIndex { save(into: i) } }
                .accessibilityLabel("Overwrite")
                .accessibilityHint("Replace the existing preset")
            Button("Cancel", role: .cancel) { overwriteIndex = nil }
                .accessibilityLabel("Cancel")
        } message: {
            Text("This replaces the preset currently in that slot.")
        }
    }

    /// Label for the primary button — reflects whether the picked slot is filled (overwrite) or empty.
    private var saveTitle: String {
        guard let i = pickedIndex else { return "Save" }
        if let p = state.slotStore.slots.preset(at: i) { return "Overwrite \u{201C}\(p.name)\u{201D}" }
        return "Save to empty slot"
    }

    /// Commit into the picked slot — straight to save for an empty slot, via confirmation for a filled one.
    private func commitSave() {
        guard let i = pickedIndex else { return }
        if state.slotStore.slots.preset(at: i) != nil { overwriteIndex = i } else { save(into: i) }
    }

    private func save(into index: Int) {
        state.saveToSlot(index: index, name: name, tags: parseTags(), tagSignal: signal)
        isPresented = false
    }

    @ViewBuilder private func field<C: View>(_ label: String, @ViewBuilder _ control: () -> C) -> some View {
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            Text(label).physLabel()
            control()
        }
    }

    private func parseTags() -> [String] {
        tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
    }
}

/// One slot in the save picker. Loads its thumbnail in a `.task` (keyed by the URL) so typing in
/// the dialog's text fields never re-decodes 24 PNGs synchronously on the main thread.
private struct SlotCell: View {
    let preset: Preset?
    let isSelected: Bool
    let thumbURL: URL?
    let action: () -> Void
    @State private var image: Image?

    var body: some View {
        Button(action: action) {
            VStack(spacing: 4) {
                content
                Text(preset?.name ?? "Empty")
                    .font(Theme.Fonts.eyebrow).lineLimit(1)
                    .foregroundStyle(preset == nil ? Theme.Colors.fgTertiary : Theme.Colors.fgPrimary)
            }
            .padding(Theme.Space.s1)
            .background(Theme.Colors.raised, in: RoundedRectangle(cornerRadius: Theme.Radius.md))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.md)
                    .strokeBorder(isSelected ? Theme.Colors.accent : Theme.Colors.borderSubtle,
                                  lineWidth: isSelected ? 2 : 1)
            )
            .contentShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
        }
        .buttonStyle(.plain)
        .task(id: thumbURL?.path ?? "empty") { image = thumbURL.flatMap { loadPlatformImage($0) } }
    }

    @ViewBuilder private var content: some View {
        Group {
            if let image {
                image.resizable().aspectRatio(contentMode: .fill)
            } else if preset == nil {
                RoundedRectangle(cornerRadius: Theme.Radius.sm).fill(Theme.Colors.overlay)
                    .overlay(Image(systemName: "plus").foregroundStyle(Theme.Colors.fgTertiary))
            } else {
                RoundedRectangle(cornerRadius: Theme.Radius.sm).fill(Theme.Colors.overlay)
            }
        }
        .frame(height: 44).frame(maxWidth: .infinity).clipped()
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.sm))
    }
}
#endif