5 Commits
1.10.1 ... main

Author SHA1 Message Date
VincentM
512d534edf Update README.md 2024-10-29 13:44:16 +01:00
VincentM
c4be0e98b9 Merge pull request #25 from VincentMeilinger/1.10.1
Recipe decoding fixes
2024-05-05 10:35:13 +02:00
VincentM
b4b6afb45a Update README.md 2024-04-23 17:10:26 +02:00
VincentM
d6cfa6b01d Update README.md 2024-03-26 18:21:28 +01:00
VincentM
498ed0d8ff Merge pull request #21 from VincentMeilinger/1.10
1.10
2024-03-26 18:18:35 +01:00
11 changed files with 33 additions and 166 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.2;
MARKETING_VERSION = 1.10.1;
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.2;
MARKETING_VERSION = 1.10.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;

View File

@@ -170,9 +170,6 @@ 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,7 +13,6 @@ 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
@@ -36,7 +35,6 @@ class ObservableRecipeDetail: ObservableObject {
id = ""
name = String(localized: "New Recipe")
keywords = []
image = ""
imageUrl = ""
prepTime = DurationComponents()
cookTime = DurationComponents()
@@ -57,7 +55,6 @@ 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 ?? "")
@@ -80,7 +77,6 @@ class ObservableRecipeDetail: ObservableObject {
keywords: self.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
image: self.image,
imageUrl: self.imageUrl,
id: self.id,
prepTime: self.prepTime.toPTString(),
@@ -97,59 +93,21 @@ class ObservableRecipeDetail: ObservableObject {
)
}
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 {
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
if factor == 0 {
return ingredient
return AttributedString(ingredient)
}
// Match mixed fractions first
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
.mixedFraction,
in: String(ingredient.characters),
in: ingredient,
multFactor: factor
)
// Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.fraction,
in: String(ingredient.characters),
in: ingredient,
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
@@ -158,7 +116,7 @@ class ObservableRecipeDetail: ObservableObject {
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.number,
in: String(ingredient.characters),
in: ingredient,
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
@@ -166,8 +124,7 @@ 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 = ingredient
var attributedString = AttributedString(ingredient)
for (newSubstring, matchRange) in matches {
guard let range = Range(matchRange, in: attributedString) else { continue }
var attributedSubString = AttributedString(newSubstring)

View File

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

View File

@@ -485,6 +485,7 @@
}
},
"Add" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3831,28 +3832,6 @@
}
}
},
"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(ObservableRecipeDetail.applyMarkdownStyling(item))
Text("\(item)")
.multilineTextAlignment(.leading)
}
.padding(4)
@@ -53,7 +53,7 @@ struct EditableText: View {
.textFieldStyle(.roundedBorder)
.lineLimit(lineLimit)
} else {
Text(ObservableRecipeDetail.applyMarkdownStyling(text))
Text(text)
}
}
}

View File

@@ -129,7 +129,7 @@ fileprivate struct IngredientListItem: View {
.foregroundStyle(.red)
}
if unmodified {
Text(ObservableRecipeDetail.applyMarkdownStyling(ingredient))
Text(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(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
} else {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings/recipeYield)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(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(ObservableRecipeDetail.applyMarkdownStyling(instruction))
Text(instruction)
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {

View File

@@ -11,46 +11,23 @@ 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(newGroceries: $newGroceries)
EmptyGroceryListView()
} 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: {
withAnimation {
groceryList.deleteItem(item.name, fromRecipe: key)
withAnimation {
groceryList.objectWillChange.send()
}
})
}
@@ -80,9 +57,6 @@ struct GroceryListTabView: View {
.foregroundStyle(Color.nextcloudBlue)
}
}
.onTapGesture {
isFocused = false
}
}
}
}
@@ -123,10 +97,6 @@ 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 🍓")
@@ -135,35 +105,8 @@ 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
}
}
}
@@ -209,8 +152,6 @@ 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 {
@@ -219,18 +160,17 @@ class GroceryRecipeItem: Identifiable, Codable {
}
if saveGroceryDict {
self.save()
}
self.objectWillChange.send()
}
}
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
for item in items {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
self.save()
self.objectWillChange.send()
save()
objectWillChange.send()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
@@ -242,28 +182,26 @@ class GroceryRecipeItem: Identifiable, Codable {
groceryDict.removeValue(forKey: recipeId)
}
save()
self.objectWillChange.send()
objectWillChange.send()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
self.objectWillChange.send()
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,15 +32,17 @@ 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.
- [ ] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [x] **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.
- [ ] **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.12 and beyond** (Ideas for the future; integration not guaranteed!):
- Allow adding custom items to the grocery list.
- Fuzzy search for recipe names and keywords.
- In-app timer for the cook time specified in a recipe.
- An in-app timer for the cook time specified in a recipe.
- Search for recipes based on left-over ingredients.