#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