Fix category handling, recipe management, and dark mode toggle tint

- Map uncategorized category between * (internal) and empty string
  (API) so selecting Sonstige/Other correctly persists to the server
- Default new recipes to Other (*) category and remove None option
- Add "New Category" option to category picker in recipe edit view
- Include newly created/imported recipes in recently viewed list and
  pre-fetch thumbnails so images display immediately
- Remove deleted recipes from recently viewed list
- Remove broad .tint(.primary) from RecipeTabView that caused white
  toggles in Settings during dark mode
- Rename German "Other" translation from Andere to Sonstige
- Add missing translations for Servings stepper and new category strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 07:13:01 +01:00
parent 02118e3d7a
commit fb6b16c1fc
7 changed files with 131 additions and 14 deletions

View File

@@ -374,6 +374,8 @@ import UIKit
}) })
recipeDetails.removeValue(forKey: id) recipeDetails.removeValue(forKey: id)
} }
recentRecipes.removeAll { $0.recipe_id == id }
await saveLocal(recentRecipes, path: "recent_recipes.data")
return nil return nil
} }

View File

@@ -45,7 +45,7 @@ class ObservableRecipeDetail: ObservableObject {
description = "" description = ""
url = "" url = ""
recipeYield = 1 recipeYield = 1
recipeCategory = "" recipeCategory = "*"
tool = [] tool = []
recipeIngredient = [] recipeIngredient = []
recipeInstructions = [] recipeInstructions = []
@@ -67,7 +67,7 @@ class ObservableRecipeDetail: ObservableObject {
description = recipeDetail.description description = recipeDetail.description
url = recipeDetail.url ?? "" url = recipeDetail.url ?? ""
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
recipeCategory = recipeDetail.recipeCategory recipeCategory = recipeDetail.recipeCategory.isEmpty ? "*" : recipeDetail.recipeCategory
tool = recipeDetail.tool tool = recipeDetail.tool
recipeIngredient = recipeDetail.recipeIngredient recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = recipeDetail.recipeInstructions recipeInstructions = recipeDetail.recipeInstructions
@@ -92,7 +92,7 @@ class ObservableRecipeDetail: ObservableObject {
description: self.description, description: self.description,
url: self.url, url: self.url,
recipeYield: self.recipeYield, recipeYield: self.recipeYield,
recipeCategory: self.recipeCategory, recipeCategory: self.recipeCategory == "*" ? "" : self.recipeCategory,
tool: self.tool, tool: self.tool,
recipeIngredient: self.recipeIngredient, recipeIngredient: self.recipeIngredient,
recipeInstructions: self.recipeInstructions, recipeInstructions: self.recipeInstructions,

View File

@@ -494,7 +494,6 @@
} }
}, },
"Add" : { "Add" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -950,6 +949,28 @@
} }
} }
}, },
"Category name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kategoriename"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nombre de categoría"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nom de catégorie"
}
}
}
},
"Category: %@" : { "Category: %@" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -2958,6 +2979,50 @@
} }
} }
}, },
"New Category" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Neue Kategorie"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nueva categoría"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nouvelle catégorie"
}
}
}
},
"New Category…" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Neue Kategorie …"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nueva categoría…"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nouvelle catégorie…"
}
}
}
},
"New recipe" : { "New recipe" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3341,7 +3406,7 @@
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Andere" "value" : "Sonstige"
} }
}, },
"es" : { "es" : {
@@ -4229,8 +4294,26 @@
} }
}, },
"Servings: %lld" : { "Servings: %lld" : {
"comment" : "A stepper that allows the user to select the number of servings for a recipe. The first argument is a label describing the number of servings. The second argument is a binding to the number of servings, which is", "localizations" : {
"isCommentAutoGenerated" : true "de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Portionen: %lld"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Porciones: %lld"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Portions : %lld"
}
}
}
}, },
"Settings" : { "Settings" : {
"localizations" : { "localizations" : {

View File

@@ -487,6 +487,8 @@ struct RecipeViewToolBar: ToolbarContent {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true) let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
// Fetch the image after upload so it displays in view mode // Fetch the image after upload so it displays in view mode
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer) viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
// Pre-fetch thumbnail so it's cached for recents and category lists
let _ = await appState.getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
// Update recipe reference so the view tracks the server-assigned id // Update recipe reference so the view tracks the server-assigned id
viewModel.recipe = Recipe( viewModel.recipe = Recipe(
name: viewModel.observableRecipeDetail.name, name: viewModel.observableRecipeDetail.name,
@@ -497,6 +499,7 @@ struct RecipeViewToolBar: ToolbarContent {
imagePlaceholderUrl: "", imagePlaceholderUrl: "",
recipe_id: id recipe_id: id
) )
appState.addToRecentRecipes(viewModel.recipe)
} }
viewModel.newRecipe = false viewModel.newRecipe = false
viewModel.editMode = false viewModel.editMode = false

View File

@@ -33,9 +33,8 @@ struct RecipeMetadataSection: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) { Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("").tag("")
ForEach(categories, id: \.self) { item in ForEach(categories, id: \.self) { item in
Text(item) Text(item == "*" ? String(localized: "Other") : item).tag(item)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
@@ -93,19 +92,50 @@ struct RecipeEditMetadataSection: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
@State private var showNewCategoryAlert = false
@State private var newCategoryName = ""
private let newCategoryTag = "\0_new_category_"
var categories: [String] { var categories: [String] {
appState.categories.map { $0.name } var list = appState.categories.map { $0.name }
let current = viewModel.observableRecipeDetail.recipeCategory
if !current.isEmpty && current != newCategoryTag && !list.contains(current) {
list.append(current)
}
return list
} }
var body: some View { var body: some View {
Section("Details") { Section("Details") {
Picker("Category", selection: $viewModel.observableRecipeDetail.recipeCategory) { Picker("Category", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("None").tag("")
ForEach(categories, id: \.self) { item in ForEach(categories, id: \.self) { item in
Text(item).tag(item) Text(item == "*" ? String(localized: "Other") : item).tag(item)
} }
Divider()
Text("New Category…").tag(newCategoryTag)
} }
.pickerStyle(.menu) .pickerStyle(.menu)
.onChange(of: viewModel.observableRecipeDetail.recipeCategory) { _, newValue in
if newValue == newCategoryTag {
newCategoryName = ""
showNewCategoryAlert = true
}
}
.alert("New Category", isPresented: $showNewCategoryAlert) {
TextField("Category name", text: $newCategoryName)
Button("Cancel", role: .cancel) {
viewModel.observableRecipeDetail.recipeCategory = "*"
}
Button("Add") {
let trimmed = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
viewModel.observableRecipeDetail.recipeCategory = trimmed
} else {
viewModel.observableRecipeDetail.recipeCategory = "*"
}
}
}
Stepper("Servings: \(viewModel.observableRecipeDetail.recipeYield)", value: $viewModel.observableRecipeDetail.recipeYield, in: 1...99) Stepper("Servings: \(viewModel.observableRecipeDetail.recipeYield)", value: $viewModel.observableRecipeDetail.recipeYield, in: 1...99)

View File

@@ -26,7 +26,7 @@ struct SettingsView: View {
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) { Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
Text("None").tag("None") Text("None").tag("None")
ForEach(appState.categories, id: \.name) { category in ForEach(appState.categories, id: \.name) { category in
Text(category.name == "*" ? "Other" : category.name).tag(category) Text(category.name == "*" ? String(localized: "Other") : category.name).tag(category)
} }
} }
} header: { } header: {

View File

@@ -163,7 +163,6 @@ struct RecipeTabView: View {
} }
} }
} }
.tint(.primary)
.sheet(isPresented: $viewModel.showImportURLSheet) { .sheet(isPresented: $viewModel.showImportURLSheet) {
ImportURLSheet { recipeDetail in ImportURLSheet { recipeDetail in
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail) viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)