Improved Recipe Editing
This commit is contained in:
Binary file not shown.
@@ -224,6 +224,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld serving(s)" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
"localizations" : {
|
||||
@@ -652,6 +655,9 @@
|
||||
},
|
||||
"Cholesterol content" : {
|
||||
"comment" : "Cholesterol content"
|
||||
},
|
||||
"Choose" : {
|
||||
|
||||
},
|
||||
"Configure what is stored on your device." : {
|
||||
"localizations" : {
|
||||
@@ -1172,9 +1178,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit keywords" : {
|
||||
|
||||
},
|
||||
"Enable deletion" : {
|
||||
|
||||
@@ -2562,6 +2565,9 @@
|
||||
},
|
||||
"Select Item" : {
|
||||
|
||||
},
|
||||
"Select Keywords" : {
|
||||
|
||||
},
|
||||
"Selected keywords:" : {
|
||||
"localizations" : {
|
||||
@@ -2587,6 +2593,9 @@
|
||||
},
|
||||
"Serving size" : {
|
||||
"comment" : "Serving size"
|
||||
},
|
||||
"Servings" : {
|
||||
|
||||
},
|
||||
"Servings:" : {
|
||||
"localizations" : {
|
||||
@@ -3242,6 +3251,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Upload Changes" : {
|
||||
|
||||
},
|
||||
"Upload Recipe" : {
|
||||
|
||||
},
|
||||
"URL (e.g. example.com/recipe)" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -15,6 +15,8 @@ struct RecipeView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
@State var imageHeight: CGFloat = 350
|
||||
|
||||
|
||||
|
||||
private enum CoordinateSpaces {
|
||||
case scrollView
|
||||
}
|
||||
@@ -36,6 +38,9 @@ struct RecipeView: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if viewModel.editMode {
|
||||
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
|
||||
}
|
||||
HStack {
|
||||
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
|
||||
.font(.title)
|
||||
@@ -102,14 +107,50 @@ struct RecipeView: View {
|
||||
viewModel.editMode = false
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
Button {
|
||||
// TODO: POST edited recipe
|
||||
if viewModel.newRecipe {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} else {
|
||||
viewModel.editMode = false
|
||||
}
|
||||
} label: {
|
||||
if viewModel.newRecipe {
|
||||
Text("Upload Recipe")
|
||||
} else {
|
||||
Text("Upload Changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !viewModel.newRecipe {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
print("Delete recipe.")
|
||||
viewModel.alertType = RecipeAlert.CONFIRM_DELETE
|
||||
viewModel.alertAction = {
|
||||
if let res = await deleteRecipe() {
|
||||
viewModel.alertType = res
|
||||
viewModel.alertAction = { }
|
||||
viewModel.presentAlert = true
|
||||
} else {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
viewModel.presentAlert = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
Text("Delete recipe")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.title3)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -177,6 +218,27 @@ struct RecipeView: View {
|
||||
viewModel.isDownloaded = false
|
||||
}
|
||||
}
|
||||
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
|
||||
ForEach(viewModel.alertType.alertButtons) { buttonType in
|
||||
if buttonType == .OK {
|
||||
Button(AlertButton.OK.rawValue, role: .cancel) {
|
||||
Task {
|
||||
await viewModel.alertAction()
|
||||
}
|
||||
}
|
||||
} else if buttonType == .CANCEL {
|
||||
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
|
||||
} else if buttonType == .DELETE {
|
||||
Button(AlertButton.DELETE.rawValue, role: .destructive) {
|
||||
Task {
|
||||
await viewModel.alertAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.alertType.localizedDescription)
|
||||
}
|
||||
.onAppear {
|
||||
if UserSettings.shared.keepScreenAwake {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
@@ -207,12 +269,17 @@ struct RecipeView: View {
|
||||
@Published var presentShareSheet: Bool = false
|
||||
@Published var showTitle: Bool = false
|
||||
@Published var isDownloaded: Bool? = nil
|
||||
var newRecipe: Bool = false
|
||||
|
||||
@Published var importUrl: String = ""
|
||||
var recipe: Recipe
|
||||
var sharedURL: URL? = nil
|
||||
var newRecipe: Bool = false
|
||||
|
||||
// Alerts
|
||||
@Published var presentAlert = false
|
||||
var alertType: UserAlert = RecipeAlert.GENERIC
|
||||
var alertAction: () async -> () = { }
|
||||
|
||||
// Initializers
|
||||
init(recipe: Recipe) {
|
||||
self.recipe = recipe
|
||||
}
|
||||
@@ -229,12 +296,122 @@ struct RecipeView: View {
|
||||
recipe_id: 0)
|
||||
}
|
||||
|
||||
// View setup
|
||||
func setupView(recipeDetail: RecipeDetail) {
|
||||
self.recipeDetail = recipeDetail
|
||||
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension RecipeView {
|
||||
func importRecipe(from url: String) async -> UserAlert? {
|
||||
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
||||
if let scrapedRecipe = scrapedRecipe {
|
||||
viewModel.setupView(recipeDetail: scrapedRecipe)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
|
||||
if let scrapedRecipe = scrapedRecipe {
|
||||
viewModel.setupView(recipeDetail: scrapedRecipe)
|
||||
}
|
||||
if let error = error {
|
||||
return error
|
||||
}
|
||||
} catch {
|
||||
print("Error")
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadNewRecipe() async -> UserAlert? {
|
||||
print("Uploading new recipe.")
|
||||
if let recipeValidationError = recipeValid() {
|
||||
return recipeValidationError
|
||||
}
|
||||
|
||||
return await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
|
||||
}
|
||||
|
||||
func uploadEditedRecipe() async -> UserAlert? {
|
||||
print("Uploading changed recipe.")
|
||||
|
||||
guard let recipeId = Int(viewModel.observableRecipeDetail.id) else { return RequestAlert.REQUEST_DROPPED }
|
||||
|
||||
return await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
|
||||
}
|
||||
|
||||
func deleteRecipe() async -> RequestAlert? {
|
||||
guard let id = Int(viewModel.observableRecipeDetail.id) else {
|
||||
return .REQUEST_DROPPED
|
||||
}
|
||||
return await appState.deleteRecipe(withId: id, categoryName: viewModel.observableRecipeDetail.recipeCategory)
|
||||
}
|
||||
|
||||
func recipeValid() -> RecipeAlert? {
|
||||
// Check if the recipe has a name
|
||||
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" {
|
||||
return RecipeAlert.NO_TITLE
|
||||
}
|
||||
|
||||
// Check if the recipe has a unique name
|
||||
for recipeList in appState.recipes.values {
|
||||
for r in recipeList {
|
||||
if r.name
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.lowercased() ==
|
||||
viewModel.observableRecipeDetail.name
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.lowercased()
|
||||
{
|
||||
return RecipeAlert.DUPLICATE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recipe Import Section
|
||||
|
||||
fileprivate struct RecipeImportSection: View {
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
var importRecipe: (String) async -> UserAlert?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
SecondaryLabel(text: "Import Recipe")
|
||||
|
||||
Text(LocalizedStringKey("Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings."))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
Task {
|
||||
if let res = await importRecipe(viewModel.importUrl) {
|
||||
viewModel.alertType = RecipeAlert.CUSTOM(
|
||||
title: res.localizedTitle,
|
||||
description: res.localizedDescription
|
||||
)
|
||||
viewModel.alertAction = { }
|
||||
viewModel.presentAlert = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(LocalizedStringKey("Import"))
|
||||
}
|
||||
}.padding(.top, 5)
|
||||
}
|
||||
.padding()
|
||||
.background(Rectangle().foregroundStyle(Color.white.opacity(0.1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,44 +16,93 @@ struct RecipeMetadataSection: View {
|
||||
|
||||
@State var categories: [String] = []
|
||||
@State var keywords: [RecipeKeyword] = []
|
||||
@State var presentKeywordPopover: Bool = false
|
||||
@State var presentKeywordSheet: Bool = false
|
||||
@State var presentServingsPopover: Bool = false
|
||||
@State var presentCategoryPopover: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category")
|
||||
//CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category")
|
||||
SecondaryLabel(text: "Category")
|
||||
HStack {
|
||||
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button {
|
||||
presentCategoryPopover.toggle()
|
||||
} label: {
|
||||
Text("Choose")
|
||||
}
|
||||
}
|
||||
|
||||
SecondaryLabel(text: "Keywords")
|
||||
.padding()
|
||||
|
||||
if !viewModel.observableRecipeDetail.keywords.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
}
|
||||
}
|
||||
}.padding(.horizontal)
|
||||
|
||||
}
|
||||
}
|
||||
Button {
|
||||
presentKeywordPopover.toggle()
|
||||
presentKeywordSheet.toggle()
|
||||
} label: {
|
||||
Text("Edit keywords")
|
||||
Text("Select Keywords")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
SecondaryLabel(text: "Servings")
|
||||
Button {
|
||||
presentServingsPopover.toggle()
|
||||
} label: {
|
||||
Text("\(viewModel.observableRecipeDetail.recipeYield) serving(s)")
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Rectangle().foregroundStyle(Color.white.opacity(0.1)))
|
||||
.task {
|
||||
categories = appState.categories.map({ category in category.name })
|
||||
}
|
||||
.sheet(isPresented: $presentKeywordPopover) {
|
||||
.sheet(isPresented: $presentKeywordSheet) {
|
||||
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
||||
}
|
||||
.popover(isPresented: $presentServingsPopover) {
|
||||
PickerPopoverView(value: $viewModel.observableRecipeDetail.recipeYield, items: 0..<99, titleKey: "Servings")
|
||||
}
|
||||
.popover(isPresented: $presentCategoryPopover) {
|
||||
PickerPopoverView(value: $viewModel.observableRecipeDetail.recipeCategory, items: categories, titleKey: "Category")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
|
||||
@Binding var value: Item
|
||||
@State var items: Collection
|
||||
var titleKey: LocalizedStringKey = ""
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Picker(selection: $value, label: Text(titleKey)) {
|
||||
ForEach(Array(items), id: \.self) { item in
|
||||
Text(item.description).tag(item)
|
||||
}
|
||||
}
|
||||
.pickerStyle(WheelPickerStyle())
|
||||
.frame(width: 100, height: 150)
|
||||
.clipped()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryPickerView: View {
|
||||
fileprivate struct CategoryPickerView: View {
|
||||
@Binding var items: [String]
|
||||
@Binding var input: String
|
||||
@State private var pickerChoice: String = ""
|
||||
|
||||
Reference in New Issue
Block a user