Improved Recipe Editing

This commit is contained in:
VincentMeilinger
2024-03-01 17:35:12 +01:00
parent 650df2b67e
commit aa45bbdbd8
4 changed files with 263 additions and 22 deletions

View File

@@ -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" : {

View File

@@ -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)))
}
}

View File

@@ -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 = ""