4 Commits
main ... 1.10.1

Author SHA1 Message Date
VincentMeilinger
078c01808d 1.10.2 2025-05-28 06:29:37 +02:00
VincentMeilinger
31dd6c6926 Fixed disappearing images when updating recipes 2025-05-26 23:56:52 +02:00
VincentMeilinger
d7272026bb Bugfixes, manual grocery list entries 2025-05-26 23:04:14 +02:00
VincentMeilinger
6cecdcf1fd Markdown support in recipe ingredients, instructions and tools 2025-05-26 17:30:36 +02:00
11 changed files with 166 additions and 33 deletions

View File

@@ -797,7 +797,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
MARKETING_VERSION = 1.10.2;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -841,7 +841,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
MARKETING_VERSION = 1.10.2;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;

View File

@@ -170,6 +170,9 @@ import UIKit
}
var allRecipes: [Recipe] = []
for category in categories {
if self.recipes[category.name] == nil {
await getCategory(named: category.name, fetchMode: .preferLocal)
}
if let recipeArray = self.recipes[category.name] {
allRecipes.append(contentsOf: recipeArray)
}

View File

@@ -13,6 +13,7 @@ class ObservableRecipeDetail: ObservableObject {
var id: String
@Published var name: String
@Published var keywords: [String]
@Published var image: String
@Published var imageUrl: String
@Published var prepTime: DurationComponents
@Published var cookTime: DurationComponents
@@ -35,6 +36,7 @@ class ObservableRecipeDetail: ObservableObject {
id = ""
name = String(localized: "New Recipe")
keywords = []
image = ""
imageUrl = ""
prepTime = DurationComponents()
cookTime = DurationComponents()
@@ -55,6 +57,7 @@ class ObservableRecipeDetail: ObservableObject {
id = recipeDetail.id
name = recipeDetail.name
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
image = recipeDetail.image ?? ""
imageUrl = recipeDetail.imageUrl ?? ""
prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "")
cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "")
@@ -77,6 +80,7 @@ class ObservableRecipeDetail: ObservableObject {
keywords: self.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
image: self.image,
imageUrl: self.imageUrl,
id: self.id,
prepTime: self.prepTime.toPTString(),
@@ -93,21 +97,59 @@ class ObservableRecipeDetail: ObservableObject {
)
}
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
static func applyMarkdownStyling(_ text: String) -> AttributedString {
if var markdownString = try? AttributedString(
markdown: text,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
) {
for (intentBlock, intentRange) in markdownString.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
markdownString[intentRange].font = .system(.title).bold()
case 2:
markdownString[intentRange].font = .system(.title2).bold()
case 3:
markdownString[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != markdownString.startIndex {
markdownString.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
}
}
return markdownString
}
return AttributedString(text)
}
static func adjustIngredient(_ ingredient: AttributedString, by factor: Double) -> AttributedString {
if factor == 0 {
return AttributedString(ingredient)
return ingredient
}
// Match mixed fractions first
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
.mixedFraction,
in: ingredient,
in: String(ingredient.characters),
multFactor: factor
)
// Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.fraction,
in: ingredient,
in: String(ingredient.characters),
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
@@ -116,7 +158,7 @@ class ObservableRecipeDetail: ObservableObject {
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.number,
in: ingredient,
in: String(ingredient.characters),
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
@@ -124,7 +166,8 @@ class ObservableRecipeDetail: ObservableObject {
// Sort matches by match range lower bound, descending.
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
var attributedString = AttributedString(ingredient)
var attributedString = ingredient
for (newSubstring, matchRange) in matches {
guard let range = Range(matchRange, in: attributedString) else { continue }
var attributedSubString = AttributedString(newSubstring)

View File

@@ -38,6 +38,7 @@ struct RecipeDetail: Codable {
var dateCreated: String?
var dateModified: String?
var imageUrl: String?
var image: String?
var id: String
var prepTime: String?
var cookTime: String?
@@ -51,11 +52,12 @@ struct RecipeDetail: Codable {
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
init(name: String, keywords: String, dateCreated: String, dateModified: String, image: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.image = image
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
@@ -76,6 +78,7 @@ struct RecipeDetail: Codable {
keywords = ""
dateCreated = ""
dateModified = ""
image = ""
imageUrl = ""
id = ""
prepTime = ""
@@ -93,7 +96,7 @@ struct RecipeDetail: Codable {
// Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
}
init(from decoder: Decoder) throws {
@@ -102,6 +105,7 @@ struct RecipeDetail: Codable {
keywords = try container.decode(String.self, forKey: .keywords)
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
image = try container.decodeIfPresent(String.self, forKey: .image)
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
id = try container.decode(String.self, forKey: .id)
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
@@ -109,13 +113,14 @@ struct RecipeDetail: Codable {
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
description = try container.decode(String.self, forKey: .description)
url = try container.decode(String.self, forKey: .url)
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
recipeYield = try container.decode(Int?.self, forKey: .recipeYield) ?? 1
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
}
}
@@ -127,6 +132,7 @@ extension RecipeDetail {
keywords: "",
dateCreated: "",
dateModified: "",
image: "",
imageUrl: "",
id: "",
prepTime: "",

View File

@@ -485,7 +485,6 @@
}
},
"Add" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3832,6 +3831,28 @@
}
}
},
"To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Über das Textfeld können Einkäufe manuell hinzugefügt werden. Durch Zeilenumbrüche können mehrere Artikel auf einmal hinzugefügt werden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Para añadir comestibles manualmente, escríbelos en el cuadro de abajo y pulsa el botón.\nPara añadir varios artículos a la vez, sepáralos con una nueva línea."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pour ajouter des courses manuellement, tape-les dans la case ci-dessous et appuie sur le bouton.\nPour ajouter plusieurs articles à la fois, sépare-les par un saut de ligne."
}
}
}
},
"Tool" : {
"localizations" : {
"de" : {

View File

@@ -19,7 +19,7 @@ struct RecipeListSection: View {
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
Text("\(item)")
Text(ObservableRecipeDetail.applyMarkdownStyling(item))
.multilineTextAlignment(.leading)
}
.padding(4)
@@ -53,7 +53,7 @@ struct EditableText: View {
.textFieldStyle(.roundedBorder)
.lineLimit(lineLimit)
} else {
Text(text)
Text(ObservableRecipeDetail.applyMarkdownStyling(text))
}
}
}

View File

@@ -129,7 +129,7 @@ fileprivate struct IngredientListItem: View {
.foregroundStyle(.red)
}
if unmodified {
Text(ingredient)
Text(ObservableRecipeDetail.applyMarkdownStyling(ingredient))
.multilineTextAlignment(.leading)
.lineLimit(5)
} else {
@@ -142,9 +142,9 @@ fileprivate struct IngredientListItem: View {
}
.onChange(of: servings) { newServings in
if recipeYield == 0 {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings)
} else {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings/recipeYield)
}
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)

View File

@@ -47,7 +47,7 @@ fileprivate struct RecipeInstructionListItem: View {
HStack(alignment: .top) {
Text("\(index)")
.monospaced()
Text(instruction)
Text(ObservableRecipeDetail.applyMarkdownStyling(instruction))
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {

View File

@@ -11,23 +11,46 @@ import SwiftUI
struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList
@State var newGroceries: String = ""
@FocusState private var isFocused: Bool
var body: some View {
NavigationStack {
if groceryList.groceryDict.isEmpty {
EmptyGroceryListView()
EmptyGroceryListView(newGroceries: $newGroceries)
} else {
List {
HStack(alignment: .top) {
TextEditor(text: $newGroceries)
.padding(4)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary).opacity(0.5))
.focused($isFocused)
Button {
if !newGroceries.isEmpty {
let items = newGroceries
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
groceryList.addItems(items, toRecipe: "Other", recipeName: String(localized: "Other"))
}
newGroceries = ""
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
Section {
ForEach(groceryList.groceryDict[key]!.items) { item in
GroceryListItemView(item: item, toggleAction: {
groceryList.toggleItemChecked(item)
groceryList.objectWillChange.send()
}, deleteAction: {
groceryList.deleteItem(item.name, fromRecipe: key)
withAnimation {
groceryList.objectWillChange.send()
groceryList.deleteItem(item.name, fromRecipe: key)
}
})
}
@@ -57,6 +80,9 @@ struct GroceryListTabView: View {
.foregroundStyle(Color.nextcloudBlue)
}
}
.onTapGesture {
isFocused = false
}
}
}
}
@@ -97,6 +123,10 @@ fileprivate struct GroceryListItemView: View {
fileprivate struct EmptyGroceryListView: View {
@EnvironmentObject var groceryList: GroceryList
@Binding var newGroceries: String
@FocusState private var isFocused: Bool
var body: some View {
List {
Text("You're all set for cooking 🍓")
@@ -105,8 +135,35 @@ fileprivate struct EmptyGroceryListView: View {
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
.foregroundStyle(.secondary)
HStack(alignment: .top) {
TextEditor(text: $newGroceries)
.padding(4)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary).opacity(0.5))
.focused($isFocused)
Button {
if !newGroceries.isEmpty {
let items = newGroceries
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
groceryList.addItems(items, toRecipe: "Other", recipeName: String(localized: "Other"))
}
newGroceries = ""
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
.padding(.bottom, 4)
}
.navigationTitle("Grocery List")
.onTapGesture {
isFocused = false
}
}
}
@@ -152,6 +209,8 @@ class GroceryRecipeItem: Identifiable, Codable {
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))")
DispatchQueue.main.async {
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
@@ -160,8 +219,9 @@ class GroceryRecipeItem: Identifiable, Codable {
}
if saveGroceryDict {
self.save()
self.objectWillChange.send()
}
self.objectWillChange.send()
}
}
@@ -169,8 +229,8 @@ class GroceryRecipeItem: Identifiable, Codable {
for item in items {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
objectWillChange.send()
self.save()
self.objectWillChange.send()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
@@ -182,26 +242,28 @@ class GroceryRecipeItem: Identifiable, Codable {
groceryDict.removeValue(forKey: recipeId)
}
save()
objectWillChange.send()
self.objectWillChange.send()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
objectWillChange.send()
self.objectWillChange.send()
}
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
self.objectWillChange.send()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
self.objectWillChange.send()
}
func containsItem(at recipeId: String, item: String) -> Bool {

View File

@@ -32,17 +32,15 @@ You can download the app from the AppStore:
- [x] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code. Update: I will continue to work on this update in January 2024.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images.
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
- Allow adding custom items to the grocery list.
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
- Fuzzy search for recipe names and keywords.
- An in-app timer for the cook time specified in a recipe.
- In-app timer for the cook time specified in a recipe.
- Search for recipes based on left-over ingredients.