Updated recipe editing user interface
This commit is contained in:
@@ -10,108 +10,173 @@ import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
|
||||
fileprivate enum ErrorMessages: Error {
|
||||
|
||||
case NO_TITLE,
|
||||
DUPLICATE,
|
||||
UPLOAD_ERROR,
|
||||
CONFIRM_DELETE,
|
||||
GENERIC,
|
||||
CUSTOM(title: LocalizedStringKey, description: LocalizedStringKey)
|
||||
|
||||
var localizedDescription: LocalizedStringKey {
|
||||
switch self {
|
||||
case .NO_TITLE:
|
||||
return "Please enter a recipe name."
|
||||
case .DUPLICATE:
|
||||
return "A recipe with that name already exists."
|
||||
case .UPLOAD_ERROR:
|
||||
return "Unable to upload your recipe. Please check your internet connection."
|
||||
case .CONFIRM_DELETE:
|
||||
return "This action is not reversible!"
|
||||
case .CUSTOM(title: _, description: let description):
|
||||
return description
|
||||
default:
|
||||
return "An unknown error occured."
|
||||
}
|
||||
}
|
||||
|
||||
var localizedTitle: LocalizedStringKey {
|
||||
switch self {
|
||||
case .NO_TITLE:
|
||||
return "Missing recipe name."
|
||||
case .DUPLICATE:
|
||||
return "Duplicate recipe."
|
||||
case .UPLOAD_ERROR:
|
||||
return "Network error."
|
||||
case .CONFIRM_DELETE:
|
||||
return "Delete recipe?"
|
||||
case .CUSTOM(title: let title, description: _):
|
||||
return title
|
||||
default:
|
||||
return "Error."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct RecipeEditView: View {
|
||||
@ObservedObject var viewModel: MainViewModel
|
||||
@State var recipe: RecipeDetail = RecipeDetail()
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@State var image: PhotosPickerItem? = nil
|
||||
@State var times = [Date.zero, Date.zero, Date.zero]
|
||||
@State var uploadNew: Bool = true
|
||||
@State var searchText: String = ""
|
||||
@State var keywords: [String] = []
|
||||
|
||||
@State private var image: PhotosPickerItem? = nil
|
||||
@State private var times = [Date.zero, Date.zero, Date.zero]
|
||||
@State private var searchText: String = ""
|
||||
@State private var keywords: [String] = []
|
||||
|
||||
@State private var alertMessage: String = ""
|
||||
@State private var alertMessage: ErrorMessages = .GENERIC
|
||||
@State private var presentAlert: Bool = false
|
||||
@State private var waitingForUpload: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Title", text: $recipe.name)
|
||||
Section {
|
||||
TextEditor(text: $recipe.description)
|
||||
} header: {
|
||||
Text("Description")
|
||||
}
|
||||
/*
|
||||
PhotosPicker(selection: $image, matching: .images, photoLibrary: .shared()) {
|
||||
Image(systemName: "photo")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
*/
|
||||
Section() {
|
||||
NavigationLink(recipe.recipeCategory == "" ? "Category" : "Category: \(recipe.recipeCategory)") {
|
||||
CategoryPickerView(title: "Category", searchSuggestions: [], selection: $recipe.recipeCategory)
|
||||
VStack {
|
||||
HStack {
|
||||
Button() {
|
||||
isPresented = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.bold()
|
||||
}
|
||||
NavigationLink("Keywords") {
|
||||
KeywordPickerView(
|
||||
title: "Keywords",
|
||||
searchSuggestions: [
|
||||
Keyword("Hauptspeisen"),
|
||||
Keyword("Lecker"),
|
||||
Keyword("Trinken"),
|
||||
Keyword("Essen"),
|
||||
Keyword("Nachspeisen"),
|
||||
Keyword("Futter"),
|
||||
Keyword("Apfel"),
|
||||
Keyword("test")
|
||||
],
|
||||
selection: $keywords
|
||||
)
|
||||
if !uploadNew {
|
||||
Menu {
|
||||
Button {
|
||||
print("Delete recipe.")
|
||||
alertMessage = .CONFIRM_DELETE
|
||||
presentAlert = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
Text("Delete recipe")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.title3)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Discoverability")
|
||||
} footer: {
|
||||
ScrollView(.horizontal) {
|
||||
HStack {
|
||||
ForEach(keywords, id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
Spacer()
|
||||
Button() {
|
||||
if uploadNew {
|
||||
uploadNewRecipe()
|
||||
} else {
|
||||
uploadEditedRecipe()
|
||||
}
|
||||
} label: {
|
||||
Text("Upload")
|
||||
.bold()
|
||||
}
|
||||
}.padding()
|
||||
HStack {
|
||||
Text(recipe.name == "" ? "New recipe" : recipe.name)
|
||||
.font(.title)
|
||||
.bold()
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
Form {
|
||||
TextField("Title", text: $recipe.name)
|
||||
Section {
|
||||
TextEditor(text: $recipe.description)
|
||||
} header: {
|
||||
Text("Description")
|
||||
}
|
||||
/*
|
||||
PhotosPicker(selection: $image, matching: .images, photoLibrary: .shared()) {
|
||||
Image(systemName: "photo")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
*/
|
||||
Section() {
|
||||
NavigationLink(recipe.recipeCategory == "" ? "Category" : "Category: \(recipe.recipeCategory)") {
|
||||
CategoryPickerView(title: "Category", searchSuggestions: [], selection: $recipe.recipeCategory)
|
||||
}
|
||||
NavigationLink("Keywords") {
|
||||
KeywordPickerView(
|
||||
title: "Keywords",
|
||||
searchSuggestions: [
|
||||
Keyword("Hauptspeisen"),
|
||||
Keyword("Lecker"),
|
||||
Keyword("Trinken"),
|
||||
Keyword("Essen"),
|
||||
Keyword("Nachspeisen"),
|
||||
Keyword("Futter"),
|
||||
Keyword("Apfel"),
|
||||
Keyword("test")
|
||||
],
|
||||
selection: $keywords
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Discoverability")
|
||||
} footer: {
|
||||
ScrollView(.horizontal) {
|
||||
HStack {
|
||||
ForEach(keywords, id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section() {
|
||||
Picker("Yield/Portions:", selection: $recipe.recipeYield) {
|
||||
ForEach(0..<99, id: \.self) { i in
|
||||
Text("\(i)").tag(i)
|
||||
|
||||
Section() {
|
||||
Picker("Yield/Portions:", selection: $recipe.recipeYield) {
|
||||
ForEach(0..<99, id: \.self) { i in
|
||||
Text("\(i)").tag(i)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
DatePicker("Prep time:", selection: $times[0], displayedComponents: .hourAndMinute)
|
||||
DatePicker("Cook time:", selection: $times[1], displayedComponents: .hourAndMinute)
|
||||
DatePicker("Total time:", selection: $times[2], displayedComponents: .hourAndMinute)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
DatePicker("Prep time:", selection: $times[0], displayedComponents: .hourAndMinute)
|
||||
DatePicker("Cook time:", selection: $times[1], displayedComponents: .hourAndMinute)
|
||||
DatePicker("Total time:", selection: $times[2], displayedComponents: .hourAndMinute)
|
||||
}
|
||||
|
||||
EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient)
|
||||
EditableListSection(title: "Tools", items: $recipe.tool)
|
||||
EditableListSection(title: "Instructions", items: $recipe.recipeInstructions)
|
||||
}.navigationTitle("Edit your recipe")
|
||||
.toolbar {
|
||||
Menu {
|
||||
Button {
|
||||
print("Delete recipe.")
|
||||
deleteRecipe()
|
||||
self.isPresented = false
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
Text("Delete recipe")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
Button() {
|
||||
if uploadNew {
|
||||
uploadNewRecipe()
|
||||
} else {
|
||||
uploadEditedRecipe()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
Text(uploadNew ? "Upload" : "Update")
|
||||
.bold()
|
||||
|
||||
EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient)
|
||||
EditableListSection(title: "Tools", items: $recipe.tool)
|
||||
EditableListSection(title: "Instructions", items: $recipe.recipeInstructions)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -130,15 +195,22 @@ struct RecipeEditView: View {
|
||||
keywords.append(keyword)
|
||||
}
|
||||
}
|
||||
.alert(alertMessage, isPresented: $presentAlert) {
|
||||
Button("Ok", role: .cancel) {
|
||||
self.isPresented = false
|
||||
.alert(alertMessage.localizedTitle, isPresented: $presentAlert) {
|
||||
switch alertMessage {
|
||||
case .CONFIRM_DELETE:
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteRecipe()
|
||||
}
|
||||
default:
|
||||
Button("Ok", role: .cancel) { }
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessage.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func createRecipe() {
|
||||
print(self.recipe.name)
|
||||
if let date = Date.toPTRepresentation(date: times[0]) {
|
||||
self.recipe.prepTime = date
|
||||
}
|
||||
@@ -148,12 +220,43 @@ struct RecipeEditView: View {
|
||||
if let date = Date.toPTRepresentation(date: times[2]) {
|
||||
self.recipe.totalTime = date
|
||||
}
|
||||
self.recipe.keywords = self.keywords.joined(separator: ",")
|
||||
if !self.keywords.isEmpty {
|
||||
self.recipe.keywords = self.keywords.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
func recipeValid() -> Bool {
|
||||
// Check if the recipe has a name
|
||||
if recipe.name == "" {
|
||||
self.alertMessage = .NO_TITLE
|
||||
self.presentAlert = true
|
||||
return false
|
||||
}
|
||||
// Check if the recipe has a unique name
|
||||
for recipeList in viewModel.recipes.values {
|
||||
for r in recipeList {
|
||||
if r.name
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.lowercased() ==
|
||||
recipe.name
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.lowercased()
|
||||
{
|
||||
self.alertMessage = .DUPLICATE
|
||||
self.presentAlert = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func uploadNewRecipe() {
|
||||
print("Uploading new recipe.")
|
||||
waitingForUpload = true
|
||||
createRecipe()
|
||||
guard recipeValid() else { return }
|
||||
let request = RequestWrapper.customRequest(
|
||||
method: .POST,
|
||||
path: .NEW_RECIPE,
|
||||
@@ -165,9 +268,11 @@ struct RecipeEditView: View {
|
||||
body: JSONEncoder.safeEncode(self.recipe)
|
||||
)
|
||||
sendRequest(request)
|
||||
dismissEditView()
|
||||
}
|
||||
|
||||
func uploadEditedRecipe() {
|
||||
waitingForUpload = true
|
||||
print("Uploading changed recipe.")
|
||||
guard let recipeId = Int(recipe.id) else { return }
|
||||
createRecipe()
|
||||
@@ -182,6 +287,7 @@ struct RecipeEditView: View {
|
||||
body: JSONEncoder.safeEncode(self.recipe)
|
||||
)
|
||||
sendRequest(request)
|
||||
dismissEditView()
|
||||
}
|
||||
|
||||
func deleteRecipe() {
|
||||
@@ -195,6 +301,7 @@ struct RecipeEditView: View {
|
||||
]
|
||||
)
|
||||
sendRequest(request)
|
||||
dismissEditView()
|
||||
}
|
||||
|
||||
func sendRequest(_ request: RequestWrapper) {
|
||||
@@ -204,34 +311,28 @@ struct RecipeEditView: View {
|
||||
guard let data = data else { return }
|
||||
do {
|
||||
let error = try JSONDecoder().decode(ServerMessage.self, from: data)
|
||||
alertMessage = error.msg
|
||||
presentAlert = true
|
||||
DispatchQueue.main.sync {
|
||||
alertMessage = .CUSTOM(title: "Error.", description: LocalizedStringKey(stringLiteral: error.msg))
|
||||
presentAlert = true
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.isPresented = false
|
||||
await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct SearchField: View {
|
||||
@State var title: String
|
||||
@State var text: String
|
||||
@State var searchSuggestions: [String]
|
||||
|
||||
var body: some View {
|
||||
TextField(title, text: $text)
|
||||
.searchSuggestions {
|
||||
ForEach(searchSuggestions, id: \.self) { suggestion in
|
||||
Text(suggestion).searchCompletion(suggestion)
|
||||
}
|
||||
}
|
||||
func dismissEditView() {
|
||||
Task {
|
||||
await self.viewModel.loadCategoryList(needsUpdate: true)
|
||||
await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true)
|
||||
}
|
||||
self.isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct EditableListSection: View {
|
||||
@State var title: String
|
||||
@Binding var items: [String]
|
||||
|
||||
Reference in New Issue
Block a user