Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 673 additions & 1 deletion App/Sources/Localizable.xcstrings

Large diffs are not rendered by default.

139 changes: 138 additions & 1 deletion App/Sources/SaveEditorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,147 @@ final class SaveEditorModel {
func inventoryRows() -> [InventoryRow] {
guard let document, let ref = resolvedReferenceDB() else { return [] }
return document.inventoryItems().map { slot in
InventoryRow(id: slot.itemID, name: ref.itemName(id: slot.itemID) ?? "#\(slot.itemID)", count: slot.count)
InventoryRow(id: slot.itemID, name: ref.itemName(id: slot.itemID) ?? String(localized: "item"), count: slot.count)
}
}

// MARK: - Ingredients / Food

/// A named ingredient row for the restaurant browse view.
struct IngredientRow: Identifiable, Equatable {
let id: Int // ingredientsID (ItemDataID)
let name: String
let count: Int
let branchCount: Int
let maxRecommended: Int
}

/// The save's ingredients, name-resolved. When `includeAll` is true, blends in
/// unowned reference ingredients with count 0.
func ingredientRows(includeAll: Bool = false) -> [IngredientRow] {
guard let document, let ref = resolvedReferenceDB() else { return [] }
let owned = document.ingredientItems()
var ownedMap: [Int: (count: Int, branchCount: Int)] = [:]
for slot in owned {
ownedMap[slot.id] = (count: slot.count, branchCount: slot.branchCount)
}

if !includeAll {
return owned.map { slot in
let name = ref.itemName(id: slot.id) ?? String(localized: "ingredient")
let maxRec = ref.tierTarget(forIngredientID: slot.id)
return IngredientRow(id: slot.id, name: name, count: slot.count, branchCount: slot.branchCount, maxRecommended: maxRec)
}
}

let allRef = ref.allIngredients()
var seen = Set<Int>()
var out: [IngredientRow] = []

for item in allRef {
guard !seen.contains(item.id) else { continue }
seen.insert(item.id)
let counts = ownedMap[item.id] ?? (count: 0, branchCount: 0)
let name = ref.itemName(id: item.id) ?? String(localized: "ingredient")
let maxRec = ref.tierTarget(forIngredientID: item.id)
out.append(IngredientRow(id: item.id, name: name, count: counts.count, branchCount: counts.branchCount, maxRecommended: maxRec))
}

for slot in owned where !seen.contains(slot.id) {
seen.insert(slot.id)
let name = ref.itemName(id: slot.id) ?? String(localized: "ingredient")
let maxRec = ref.tierTarget(forIngredientID: slot.id)
out.append(IngredientRow(id: slot.id, name: name, count: slot.count, branchCount: slot.branchCount, maxRecommended: maxRec))
}

return out.sorted { $0.id < $1.id }
}

/// Set an ingredient's count (and optional branch count), pushing an undo step.
func setIngredientCount(id: Int, count: Int, branchCount: Int? = nil) {
guard let original = document, let before = currentEditState else { return }
var doc = original
let ref = resolvedReferenceDB()
let pID = ref?.ingredient(for: id)?.parentID
guard doc.setIngredient(id: id, count: count, branchCount: branchCount, parentID: pID) else {
ingredientStatus = String(localized: "Couldn't set ingredient \(String(id)).")
return
}
redoStack.removeAll()
let name = ref?.itemName(id: id) ?? String(localized: "ingredient")
undoStack.append(UndoStep(before: before,
label: name,
coalesceKey: "ing_\(id)", at: Date()))
if undoStack.count > Self.undoLimit { undoStack.removeFirst() }
document = doc
recordBulk(String(localized: "Set \(name) (\(String(id))) → \(count)."))
}

/// Adjust an ingredient's count by delta (clamped >= 0).
func adjustIngredient(id: Int, by delta: Int) {
let current = document?.ingredientItems().first(where: { $0.id == id })?.count ?? 0
let target = max(0, current + delta)
setIngredientCount(id: id, count: target)
}

/// Maximize an ingredient to its recommended tier cap.
func maximizeIngredient(id: Int) {
let maxCount = resolvedReferenceDB()?.tierTarget(forIngredientID: id) ?? 999
setIngredientCount(id: id, count: maxCount)
}

/// Batch adjust multiple ingredients by delta.
func batchAdjustIngredients(ids: Set<Int>, delta: Int) {
guard !ids.isEmpty, let original = document, let before = currentEditState else { return }
var doc = original
let ref = resolvedReferenceDB()
var changedCount = 0
for id in ids {
let current = doc.ingredientItems().first(where: { $0.id == id })?.count ?? 0
let target = max(0, current + delta)
let pID = ref?.ingredient(for: id)?.parentID
if doc.setIngredient(id: id, count: target, parentID: pID) {
changedCount += 1
}
}
guard changedCount > 0 else { return }
redoStack.removeAll()
undoStack.append(UndoStep(before: before,
label: String(localized: "Adjust ingredients"),
coalesceKey: nil, at: Date()))
if undoStack.count > Self.undoLimit { undoStack.removeFirst() }
document = doc
recordBulk(String(localized: "Adjusted \(changedCount) ingredients by \(delta)."))
}

/// Batch maximize multiple ingredients to their tier caps.
func batchMaximizeIngredients(ids: Set<Int>) {
guard !ids.isEmpty, let original = document, let before = currentEditState else { return }
var doc = original
let ref = resolvedReferenceDB()
var changedCount = 0
for id in ids {
let maxCount = ref?.tierTarget(forIngredientID: id) ?? 999
let pID = ref?.ingredient(for: id)?.parentID
if doc.setIngredient(id: id, count: maxCount, parentID: pID) {
changedCount += 1
}
}
guard changedCount > 0 else { return }
redoStack.removeAll()
undoStack.append(UndoStep(before: before,
label: String(localized: "Max ingredients"),
coalesceKey: nil, at: Date()))
if undoStack.count > Self.undoLimit { undoStack.removeFirst() }
document = doc
recordBulk(String(localized: "Maxed \(changedCount) ingredients."))
}

/// Search reference ingredients by name or numeric ID.
func searchIngredients(_ query: String) -> [ItemMatch] {
resolvedReferenceDB()?.searchIngredients(query) ?? []
}

/// Search the reference item table by name (for the Advanced item picker).
func searchItems(_ query: String) -> [ItemMatch] {
resolvedReferenceDB()?.searchItems(query) ?? []
Expand Down
115 changes: 112 additions & 3 deletions App/Sources/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,27 @@ struct ContentView: View {
@State private var showingBackups = false
@State private var confirmLoad: (() -> Void)? = nil
@State private var itemQuery = ""
@State private var ingredientQuery = ""
@State private var showAllIngredients = false
@State private var isIngredientSelectionMode = false
@State private var selectedIngredientIDs: Set<Int> = []

private enum FocusTarget: Hashable { case itemSearch }
private enum FocusTarget: Hashable { case itemSearch, ingredientSearch }
@FocusState private var focus: FocusTarget?


private var filteredItems: [SaveEditorModel.InventoryRow] {
let all = model.inventoryRows()
let q = itemQuery.trimmingCharacters(in: .whitespaces).lowercased()
guard !q.isEmpty else { return all }
return all.filter { $0.name.lowercased().contains(q) || String($0.id).contains(q) }
}

private var filteredIngredients: [SaveEditorModel.IngredientRow] {
let all = model.ingredientRows(includeAll: showAllIngredients)
let q = ingredientQuery.trimmingCharacters(in: .whitespaces).lowercased()
guard !q.isEmpty else { return all }
return all.filter { $0.name.lowercased().contains(q) || String($0.id).contains(q) }
}

var body: some View {
mainBody
Expand Down Expand Up @@ -57,9 +66,109 @@ struct ContentView: View {
SectionHeader(category: .economy).plainRow()
}

// Restaurant (bulk fills)
// Restaurant (bulk fills + per-ingredient browse)
Section {
bulkRows(.restaurant)

VStack(spacing: Theme.Spacing.xs) {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "magnifyingglass").foregroundStyle(Theme.Color.textSecondary)
TextField("Search ingredients by name…", text: $ingredientQuery).textFieldStyle(.plain)
.focused($focus, equals: .ingredientSearch)

if !ingredientQuery.isEmpty {
Button {
ingredientQuery = ""
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(Theme.Color.textSecondary)
}
.buttonStyle(.plain)
}

Spacer(minLength: Theme.Spacing.sm)

Picker("Scope", selection: $showAllIngredients) {
Text("In Stock (\(model.ingredientRows(includeAll: false).count))").tag(false)
Text("All Ingredients (\(model.ingredientRows(includeAll: true).count))").tag(true)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)

Button(isIngredientSelectionMode ? "Done" : "Select…") {
isIngredientSelectionMode.toggle()
if !isIngredientSelectionMode {
selectedIngredientIDs.removeAll()
}
}
.buttonStyle(.bordered).controlSize(.small)
}

if isIngredientSelectionMode {
HStack(spacing: Theme.Spacing.sm) {
Text("\(selectedIngredientIDs.count) selected")
.font(.callout.monospacedDigit())
.foregroundStyle(Theme.Color.textSecondary)

Spacer()

Button("+10 Selected") {
model.batchAdjustIngredients(ids: selectedIngredientIDs, delta: 10)
}
.buttonStyle(.bordered).controlSize(.small)
.disabled(selectedIngredientIDs.isEmpty)

Button("+50 Selected") {
model.batchAdjustIngredients(ids: selectedIngredientIDs, delta: 50)
}
.buttonStyle(.bordered).controlSize(.small)
.disabled(selectedIngredientIDs.isEmpty)

Button("Max Selected") {
model.batchMaximizeIngredients(ids: selectedIngredientIDs)
}
.buttonStyle(.bordered).controlSize(.small).tint(Theme.Color.coral)
.disabled(selectedIngredientIDs.isEmpty)

Button("Select All") {
selectedIngredientIDs = Set(filteredIngredients.map(\.id))
}
.buttonStyle(.borderless).controlSize(.small)

Button("Clear") {
selectedIngredientIDs.removeAll()
}
.buttonStyle(.borderless).controlSize(.small)
.disabled(selectedIngredientIDs.isEmpty)
}
.padding(.top, Theme.Spacing.xs)
}
}
.padding(.horizontal, Theme.Spacing.xl).padding(.vertical, Theme.Spacing.sm)
.plainRow()

let ingredients = filteredIngredients
if ingredients.isEmpty {
Text(ingredientQuery.isEmpty ? "No ingredients in stock." : "No ingredients match “\(ingredientQuery)”.")
.font(.callout).foregroundStyle(Theme.Color.textSecondary)
.padding(.horizontal, Theme.Spacing.xl).padding(.vertical, Theme.Spacing.sm)
.plainRow()
} else {
ForEach(ingredients) { row in
IngredientItemRow(
model: model,
row: row,
isSelected: selectedIngredientIDs.contains(row.id),
isSelectionMode: isIngredientSelectionMode,
onToggleSelect: {
if selectedIngredientIDs.contains(row.id) {
selectedIngredientIDs.remove(row.id)
} else {
selectedIngredientIDs.insert(row.id)
}
}
).plainRow()
}
}
} header: {
SectionHeader(category: .restaurant).plainRow()
}
Expand Down
92 changes: 92 additions & 0 deletions App/Sources/Views/TableRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,98 @@ struct InventoryItemRow: View {
}
}

/// One restaurant ingredient row: name · id · current count · quick +10/+50/Max · inline exact entry.
struct IngredientItemRow: View {
let model: SaveEditorModel
let row: SaveEditorModel.IngredientRow
let isSelected: Bool
let isSelectionMode: Bool
let onToggleSelect: () -> Void

@State private var setText = ""

var body: some View {
HStack(spacing: Theme.Spacing.md) {
if isSelectionMode {
Button(action: onToggleSelect) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Theme.Color.coral : Theme.Color.textSecondary)
}
.buttonStyle(.plain)
.frame(width: Theme.Spacing.iconGutter)
} else {
Image(systemName: "fork.knife")
.foregroundStyle(Theme.Color.coral.opacity(0.8))
.frame(width: Theme.Spacing.iconGutter)
.accessibilityHidden(true)
}

Text(row.name).foregroundStyle(Theme.Color.textPrimary).lineLimit(1)
Text(verbatim: "#\(row.id)")
.denseCaption().monospaced()
.foregroundStyle(Theme.Color.textSecondary)

if row.branchCount > 0 {
Text(String(localized: "Branch: \(row.branchCount)"))
.denseCaption().monospaced()
.foregroundStyle(Theme.Color.ocean)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Theme.Color.ocean.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
}

Spacer(minLength: Theme.Spacing.md)

Text(row.count, format: .number)
.monospacedDigit().foregroundStyle(Theme.Color.textSecondary)
.frame(minWidth: 48, alignment: .trailing)

if !isSelectionMode {
Button("+10") {
model.adjustIngredient(id: row.id, by: 10)
}
.buttonStyle(.bordered).controlSize(.small)
.help("Increase by 10")

Button("+50") {
model.adjustIngredient(id: row.id, by: 50)
}
.buttonStyle(.bordered).controlSize(.small)
.help("Increase by 50")

Button("Max") {
model.maximizeIngredient(id: row.id)
}
.buttonStyle(.bordered).controlSize(.small).tint(Theme.Color.coral)
.help("Set to tier maximum (\(row.maxRecommended))")

TextField(text: $setText) { Text("Value") }
.textFieldStyle(.roundedBorder).monospacedDigit().frame(width: 68)
.onSubmit(apply)
.onExitCommand { setText = "" }
.accessibilityLabel("Set \(row.name) count")

if CountInput.parse(setText) != nil {
Button(action: apply) { Image(systemName: "return") }
.buttonStyle(.bordered).controlSize(.small)
.help("Set \(row.name) count")
.accessibilityLabel("Set \(row.name) count")
}
}
}
.padding(.horizontal, Theme.Spacing.xl)
.padding(.vertical, Theme.Spacing.sm)
.accessibilityElement(children: .contain)
.accessibilityLabel(row.name)
}

private func apply() {
guard let count = CountInput.parse(setText) else { return }
model.setIngredientCount(id: row.id, count: Int(count))
setText = ""
}
}

extension View {
/// A caption that stays legible in Han and Hangul. Those glyphs carry several times the
/// stroke count of Latin at the same point size, so 11pt `.caption` — fine for
Expand Down
Loading