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." : { "%lld." : {
"localizations" : { "localizations" : {
@@ -652,6 +655,9 @@
}, },
"Cholesterol content" : { "Cholesterol content" : {
"comment" : "Cholesterol content" "comment" : "Cholesterol content"
},
"Choose" : {
}, },
"Configure what is stored on your device." : { "Configure what is stored on your device." : {
"localizations" : { "localizations" : {
@@ -1172,9 +1178,6 @@
} }
} }
} }
},
"Edit keywords" : {
}, },
"Enable deletion" : { "Enable deletion" : {
@@ -2562,6 +2565,9 @@
}, },
"Select Item" : { "Select Item" : {
},
"Select Keywords" : {
}, },
"Selected keywords:" : { "Selected keywords:" : {
"localizations" : { "localizations" : {
@@ -2587,6 +2593,9 @@
}, },
"Serving size" : { "Serving size" : {
"comment" : "Serving size" "comment" : "Serving size"
},
"Servings" : {
}, },
"Servings:" : { "Servings:" : {
"localizations" : { "localizations" : {
@@ -3242,6 +3251,12 @@
} }
} }
} }
},
"Upload Changes" : {
},
"Upload Recipe" : {
}, },
"URL (e.g. example.com/recipe)" : { "URL (e.g. example.com/recipe)" : {
"localizations" : { "localizations" : {

View File

@@ -15,6 +15,8 @@ struct RecipeView: View {
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@State var imageHeight: CGFloat = 350 @State var imageHeight: CGFloat = 350
private enum CoordinateSpaces { private enum CoordinateSpaces {
case scrollView case scrollView
} }
@@ -36,6 +38,9 @@ struct RecipeView: View {
} }
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
HStack { HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title) .font(.title)
@@ -102,14 +107,50 @@ struct RecipeView: View {
viewModel.editMode = false viewModel.editMode = false
} }
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { Button {
// TODO: POST edited recipe // TODO: POST edited recipe
if viewModel.newRecipe { if viewModel.newRecipe {
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
} else { } else {
viewModel.editMode = false 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 { } else {
@@ -177,6 +218,27 @@ struct RecipeView: View {
viewModel.isDownloaded = false 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 { .onAppear {
if UserSettings.shared.keepScreenAwake { if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.isIdleTimerDisabled = true
@@ -207,12 +269,17 @@ struct RecipeView: View {
@Published var presentShareSheet: Bool = false @Published var presentShareSheet: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
var newRecipe: Bool = false @Published var importUrl: String = ""
var recipe: Recipe var recipe: Recipe
var sharedURL: URL? = nil 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) { init(recipe: Recipe) {
self.recipe = recipe self.recipe = recipe
} }
@@ -229,12 +296,122 @@ struct RecipeView: View {
recipe_id: 0) recipe_id: 0)
} }
// View setup
func setupView(recipeDetail: RecipeDetail) { func setupView(recipeDetail: RecipeDetail) {
self.recipeDetail = recipeDetail self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(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 categories: [String] = []
@State var keywords: [RecipeKeyword] = [] @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 { var body: some View {
VStack(alignment: .leading) { 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") SecondaryLabel(text: "Keywords")
.padding()
ScrollView(.horizontal, showsIndicators: false) { if !viewModel.observableRecipeDetail.keywords.isEmpty {
HStack { ScrollView(.horizontal, showsIndicators: false) {
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in HStack {
Text(keyword) ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
Text(keyword)
}
} }
} }
}.padding(.horizontal) }
Button { Button {
presentKeywordPopover.toggle() presentKeywordSheet.toggle()
} label: { } label: {
Text("Edit keywords") Text("Select Keywords")
Image(systemName: "chevron.right") 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 { .task {
categories = appState.categories.map({ category in category.name }) categories = appState.categories.map({ category in category.name })
} }
.sheet(isPresented: $presentKeywordPopover) { .sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) 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 items: [String]
@Binding var input: String @Binding var input: String
@State private var pickerChoice: String = "" @State private var pickerChoice: String = ""