Compare commits
5 Commits
1.10.1
...
512d534edf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512d534edf | ||
|
|
c4be0e98b9 | ||
|
|
b4b6afb45a | ||
|
|
d6cfa6b01d | ||
|
|
498ed0d8ff |
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
groceryList.deleteItem(item.name, fromRecipe: key)
|
||||
withAnimation {
|
||||
groceryList.deleteItem(item.name, fromRecipe: key)
|
||||
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,9 +160,8 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
}
|
||||
if saveGroceryDict {
|
||||
self.save()
|
||||
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +169,8 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
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 {
|
||||
|
||||
10
README.md
10
README.md
@@ -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!):
|
||||
- [ ] **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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user