Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078c01808d | ||
|
|
31dd6c6926 | ||
|
|
d7272026bb | ||
|
|
6cecdcf1fd |
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user