407 lines
14 KiB
Swift
407 lines
14 KiB
Swift
//
|
|
// RecipeEditView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 29.09.23.
|
|
//
|
|
|
|
import Foundation
|
|
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 uploadNew: Bool = true
|
|
|
|
@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 keywordSuggestions: [String] = []
|
|
|
|
@State private var alertMessage: ErrorMessages = .GENERIC
|
|
@State private var presentAlert: Bool = false
|
|
@State private var waitingForUpload: Bool = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack {
|
|
HStack {
|
|
Button() {
|
|
isPresented = false
|
|
} label: {
|
|
Text("Cancel")
|
|
.bold()
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
Spacer()
|
|
Button() {
|
|
if uploadNew {
|
|
uploadNewRecipe()
|
|
} else {
|
|
uploadEditedRecipe()
|
|
}
|
|
} label: {
|
|
Text("Upload")
|
|
.bold()
|
|
}
|
|
}.padding()
|
|
HStack {
|
|
Text(recipe.name == "" ? String(localized: "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: viewModel.categories.map({ category in
|
|
category.name == "*" ? "Other" : category.name
|
|
}),
|
|
selection: $recipe.recipeCategory)
|
|
}
|
|
NavigationLink("Keywords") {
|
|
KeywordPickerView(
|
|
title: "Keywords",
|
|
searchSuggestions: keywordSuggestions,
|
|
selection: $keywords
|
|
)
|
|
}
|
|
} header: {
|
|
Text("Discoverability")
|
|
} footer: {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
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)
|
|
}
|
|
}
|
|
.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: String(localized: "Ingredients"), items: $recipe.recipeIngredient)
|
|
EditableListSection(title: String(localized: "Tools"), items: $recipe.tool)
|
|
EditableListSection(title: String(localized: "Instructions"), items: $recipe.recipeInstructions)
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
self.keywordSuggestions = await viewModel.getKeywords()
|
|
}
|
|
.onAppear {
|
|
if uploadNew { return }
|
|
if let prepTime = recipe.prepTime {
|
|
self.times[0] = Date.fromPTRepresentation(prepTime)
|
|
}
|
|
if let cookTime = recipe.cookTime {
|
|
self.times[1] = Date.fromPTRepresentation(cookTime)
|
|
}
|
|
if let totalTime = recipe.totalTime {
|
|
self.times[2] = Date.fromPTRepresentation(totalTime)
|
|
}
|
|
|
|
for keyword in recipe.keywords.components(separatedBy: ",") {
|
|
keywords.append(keyword)
|
|
}
|
|
}
|
|
.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() {
|
|
if let date = Date.toPTRepresentation(date: times[0]) {
|
|
self.recipe.prepTime = date
|
|
}
|
|
if let date = Date.toPTRepresentation(date: times[1]) {
|
|
self.recipe.cookTime = date
|
|
}
|
|
if let date = Date.toPTRepresentation(date: times[2]) {
|
|
self.recipe.totalTime = date
|
|
}
|
|
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,
|
|
headerFields: [
|
|
HeaderField.accept(value: .JSON),
|
|
HeaderField.ocsRequest(value: true),
|
|
HeaderField.contentType(value: .JSON)
|
|
],
|
|
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()
|
|
let request = RequestWrapper.customRequest(
|
|
method: .PUT,
|
|
path: .RECIPE_DETAIL(recipeId: recipeId),
|
|
headerFields: [
|
|
HeaderField.accept(value: .JSON),
|
|
HeaderField.ocsRequest(value: true),
|
|
HeaderField.contentType(value: .JSON)
|
|
],
|
|
body: JSONEncoder.safeEncode(self.recipe)
|
|
)
|
|
sendRequest(request)
|
|
dismissEditView()
|
|
}
|
|
|
|
func deleteRecipe() {
|
|
guard let recipeId = Int(recipe.id) else { return }
|
|
let request = RequestWrapper.customRequest(
|
|
method: .DELETE,
|
|
path: .RECIPE_DETAIL(recipeId: recipeId),
|
|
headerFields: [
|
|
HeaderField.accept(value: .JSON),
|
|
HeaderField.ocsRequest(value: true)
|
|
]
|
|
)
|
|
sendRequest(request)
|
|
if let recipeIdInt = Int(recipe.id) {
|
|
viewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory)
|
|
}
|
|
dismissEditView()
|
|
}
|
|
|
|
func sendRequest(_ request: RequestWrapper) {
|
|
Task {
|
|
guard let apiController = viewModel.apiController else { return }
|
|
let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request)
|
|
guard let data = data else { return }
|
|
do {
|
|
let error = try JSONDecoder().decode(ServerMessage.self, from: data)
|
|
DispatchQueue.main.sync {
|
|
alertMessage = .CUSTOM(title: "Error.", description: LocalizedStringKey(stringLiteral: error.msg))
|
|
presentAlert = true
|
|
return
|
|
}
|
|
} catch {
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
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]
|
|
|
|
var body: some View {
|
|
Section() {
|
|
List {
|
|
ForEach(items.indices, id: \.self) { ix in
|
|
HStack(alignment: .top) {
|
|
Text("\(ix+1).")
|
|
.padding(.vertical, 10)
|
|
TextEditor(text: $items[ix])
|
|
.multilineTextAlignment(.leading)
|
|
.textFieldStyle(.plain)
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
.onMove { indexSet, offset in
|
|
items.move(fromOffsets: indexSet, toOffset: offset)
|
|
}
|
|
.onDelete { indexSet in
|
|
items.remove(atOffsets: indexSet)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Text("Add")
|
|
Button() {
|
|
items.append("")
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text(title)
|
|
Spacer()
|
|
EditButton()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
struct TimePicker: View {
|
|
@Binding var hours: Int
|
|
@Binding var minutes: Int
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Picker("", selection: $hours) {
|
|
ForEach(0..<99, id: \.self) { i in
|
|
Text("\(i) hours").tag(i)
|
|
}
|
|
}.pickerStyle(.wheel)
|
|
Picker("", selection: $minutes) {
|
|
ForEach(0..<60, id: \.self) { i in
|
|
Text("\(i) min").tag(i)
|
|
}
|
|
}.pickerStyle(.wheel)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|