Recipe creation, editing and deletion are now supported

This commit is contained in:
Vicnet
2023-10-04 11:23:05 +02:00
parent 77c07bb0b1
commit 85a8e631d0
14 changed files with 453 additions and 146 deletions

View File

@@ -19,7 +19,6 @@
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; };
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; };
A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */; };
A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BB2AB4983500064C43 /* CategoryCardView.swift */; };
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; };
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; };
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
@@ -34,6 +33,7 @@
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; };
A70D7CA32AC74B3B00D53DBF /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */; };
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; };
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -70,7 +70,6 @@
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; };
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; };
A70171B82AB399FB00064C43 /* DateFormatterExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatterExtension.swift; sourceTree = "<group>"; };
A70171BB2AB4983500064C43 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; };
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = "<group>"; };
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
@@ -85,6 +84,7 @@
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = "<group>"; };
A70D7CA22AC74B3B00D53DBF /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -196,7 +196,6 @@
isa = PBXGroup;
children = (
A70171832AA8E71900064C43 /* MainView.swift */,
A70171BB2AB4983500064C43 /* CategoryCardView.swift */,
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
@@ -204,6 +203,7 @@
A70171C82AB4CBB400064C43 /* OnboardingView.swift */,
A70171CC2AB501B100064C43 /* SettingsView.swift */,
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -366,11 +366,11 @@
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
A70171B92AB399FB00064C43 /* DateFormatterExtension.swift in Sources */,
A70171BC2AB4983500064C43 /* CategoryCardView.swift in Sources */,
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */,
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
A70171842AA8E71900064C43 /* MainView.swift in Sources */,

View File

@@ -13,6 +13,10 @@ struct Category: Codable {
let recipe_count: Int
}
extension Category: Identifiable, Hashable {
var id: String { name }
}
struct Recipe: Codable {
let name: String
let keywords: String
@@ -23,6 +27,10 @@ struct Recipe: Codable {
let recipe_id: Int
}
extension Recipe: Identifiable, Hashable {
var id: String { name }
}
struct RecipeDetail: Codable {
var name: String
var keywords: String
@@ -140,3 +148,9 @@ struct MetaData: Codable {
let status: String
let statuscode: Int
}
// Networking
struct ServerMessage: Decodable {
let msg: String
}

View File

@@ -11,11 +11,30 @@ extension Date {
static var zero: Date {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
if let date = dateFormatter.date(from:"00:00") {
return date
} else {
return Date()
}
}
static func toPTRepresentation(date: Date) -> String? {
// PT0H18M0S
let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date)
if let hour = dateComponents.hour, let minute = dateComponents.minute {
return "PT\(hour)H\(minute)M0S"
}
return nil
}
static func fromPTRepresentation(_ representation: String) -> Date {
let (hour, minute) = DateFormatter.stringToComponents(duration: representation)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
if let date = dateFormatter.date(from:"\(hour):\(minute)") {
return date
} else {
return Date()
}
}
}

View File

@@ -13,29 +13,45 @@ extension Formatter {
formatter.unitsStyle = .positional
return formatter
}()
}
func formatDate(duration: String) -> String {
var duration = duration
if duration.hasPrefix("PT") { duration.removeFirst(2) }
var hour: Int = 0, minute: Int = 0
if let index = duration.firstIndex(of: "H") {
hour = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
if let index = duration.firstIndex(of: "M") {
minute = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
if hour == 0 && minute != 0 {
return "\(minute)min"
static func formatDate(duration: String) -> String {
var duration = duration
if duration.hasPrefix("PT") { duration.removeFirst(2) }
var hour: Int = 0, minute: Int = 0
if let index = duration.firstIndex(of: "H") {
hour = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
if let index = duration.firstIndex(of: "M") {
minute = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
if hour == 0 && minute != 0 {
return "\(minute)min"
}
if hour != 0 && minute == 0 {
return "\(hour)h"
}
if hour != 0 && minute != 0 {
return "\(hour)h \(minute)"
}
return "--"
}
if hour != 0 && minute == 0 {
return "\(hour)h"
static func stringToComponents(duration: String) -> (Int, Int) {
var duration = duration
if duration.hasPrefix("PT") { duration.removeFirst(2) }
var hour: Int = 0, minute: Int = 0
if let index = duration.firstIndex(of: "H") {
hour = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
if let index = duration.firstIndex(of: "M") {
minute = Int(duration[..<index]) ?? 0
duration.removeSubrange(...index)
}
return (hour, minute)
}
if hour != 0 && minute != 0 {
return "\(hour)h \(minute)"
}
return "--"
}

View File

@@ -18,6 +18,7 @@ enum RequestPath {
case CATEGORIES,
RECIPE_LIST(categoryName: String),
RECIPE_DETAIL(recipeId: Int),
NEW_RECIPE,
IMAGE(recipeId: Int, thumb: Bool)
case LOGINV2REQ,
@@ -30,6 +31,7 @@ enum RequestPath {
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
case .NEW_RECIPE: return "recipes"
case .LOGINV2REQ: return "/index.php/login/v2"
case .CUSTOM(path: let path): return path

View File

@@ -19,7 +19,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
} else {
MainView(viewModel: mainViewModel, userSettings: userSettings)
.onAppear {
mainViewModel.apiInterface = APIController(userSettings: userSettings)
mainViewModel.apiController = APIController(userSettings: userSettings)
}
}
}.transition(.slide)

View File

@@ -16,7 +16,7 @@ import SwiftUI
private var imageCache: [Int: RecipeImage] = [:]
let dataStore: DataStore
var apiInterface: APIController? = nil
var apiController: APIController? = nil
/// The path of an image in storage
private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in
@@ -43,6 +43,7 @@ import SwiftUI
) {
self.categories = categoryList
}
print(self.categories)
}
/// Try to load the recipe list from store or the server.
@@ -56,7 +57,9 @@ import SwiftUI
needsUpdate: needsUpdate
) {
recipes[categoryName] = recipeList
print(recipeList)
}
}
/// Try to load the recipe details from cache. If not found, try to load from store or the server.
@@ -116,8 +119,8 @@ import SwiftUI
print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))")
// If the image needs an update, request it from the server and overwrite the stored image
if needsUpdate {
guard let apiInterface = apiInterface else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
guard let apiController = apiController else { return nil }
if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
guard let image = UIImage(data: data) else {
imageCache[recipeId] = RecipeImage(imageExists: false)
return nil
@@ -154,8 +157,8 @@ import SwiftUI
// Try to load from the server. Store if successfull.
print("Attempting to load image from server ...")
guard let apiInterface = apiInterface else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
guard let apiController = apiController else { return nil }
if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
print("Image data received.")
// Create empty RecipeImage for each recipe even if no image found, so that further server requests are only sent if explicitly requested.
guard let image = UIImage(data: data) else {
@@ -190,9 +193,9 @@ extension MainViewModel {
print("Data found locally.")
return data
} else {
guard let apiInterface = apiInterface else { return nil }
guard let apiController = apiController else { return nil }
let request = RequestWrapper.jsonGetRequest(path: networkPath)
let (data, error): (T?, Error?) = await apiInterface.sendDataRequest(request)
let (data, error): (T?, Error?) = await apiController.sendDataRequest(request)
print(error as Any)
if let data = data {
await dataStore.save(data: data, toPath: localPath)

View File

@@ -1,32 +0,0 @@
//
// CategoryCardView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct CategoryCardView: View {
@State var category: Category
var body: some View {
ZStack {
Image("cookbook-category")
.resizable()
.scaledToFit()
.overlay(
VStack {
Spacer()
Text(category.name == "*" ? "Other" : category.name)
.font(.headline)
.lineLimit(2)
.foregroundStyle(.white)
.padding()
}
)
.padding()
}
}
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct RecipeBookView: View {
struct CategoryDetailView: View {
@State var categoryName: String
@State var searchText: String = ""
@ObservedObject var viewModel: MainViewModel
@@ -19,10 +19,13 @@ struct RecipeBookView: View {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(destination: RecipeDetailView(viewModel: viewModel, recipe: recipe)) {
NavigationLink() {
RecipeDetailView(viewModel: viewModel, recipe: recipe).id(recipe.recipe_id)
} label: {
RecipeCardView(viewModel: viewModel, recipe: recipe)
}
.buttonStyle(.plain)
}
}
}

View File

@@ -0,0 +1,71 @@
//
// CategoryPickerView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 03.10.23.
//
import Foundation
import SwiftUI
struct CategoryPickerView: View {
@State var title: String
@State var searchSuggestions: [String]
@Binding var selection: String
@State var searchText: String = ""
var body: some View {
VStack {
TextField(title, text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
List {
if searchText != "" {
HStack {
if selection.contains(searchText) {
Image(systemName: "checkmark.circle.fill")
}
Text(searchText)
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight"))
)
.onTapGesture {
selection = searchText
}
}
ForEach(suggestionsFiltered(), id: \.self) { suggestion in
HStack {
if selection.contains(suggestion) {
Image(systemName: "checkmark.circle.fill")
}
Text(suggestion)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight"))
)
.onTapGesture {
selection = suggestion
}
}
}
Spacer()
}
.navigationTitle(title)
}
func suggestionsFiltered() -> [String] {
guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in
suggestion.lowercased().contains(searchText.lowercased())
}
}
}

View File

@@ -8,74 +8,120 @@
import Foundation
import SwiftUI
struct Keyword: Identifiable {
let id = UUID()
let name: String
init(_ name: String) {
self.name = name
}
}
struct KeywordPickerView: View {
@State var title: String
@State var searchSuggestions: [String]
@State var searchSuggestions: [Keyword]
@Binding var selection: [String]
@State var searchText: String = ""
var columns: [GridItem] = [GridItem(.adaptive(minimum: 120), spacing: 0)]
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 5)]
var body: some View {
VStack {
VStack(alignment: .leading) {
TextField(title, text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
LazyVGrid(columns: columns, spacing: 5) {
if searchText != "" {
HStack {
if selection.contains(searchText) {
Image(systemName: "checkmark.circle.fill")
ScrollView {
LazyVGrid(columns: columns, spacing: 5) {
if searchText != "" {
KeywordItemView(
keyword: Keyword(searchText),
isSelected: selection.contains(searchText)
) { keyword in
if selection.contains(keyword.name) {
selection.removeAll(where: { s in
s == keyword.name ? true : false
})
searchSuggestions.removeAll(where: { s in
s.name == keyword.name ? true : false
})
} else {
selection.append(keyword.name)
}
}
Text(searchText)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight"))
)
.onTapGesture {
if selection.contains(searchText) {
selection.removeAll(where: { s in
s == searchText ? true : false
})
} else {
selection.append(searchText)
searchSuggestions.append(searchText)
ForEach(suggestionsFiltered(), id: \.id) { suggestion in
KeywordItemView(
keyword: suggestion,
isSelected: selection.contains(suggestion.name)
) { keyword in
if selection.contains(keyword.name) {
selection.removeAll(where: { s in
s == keyword.name ? true : false
})
} else {
selection.append(keyword.name)
}
}
}
}
ForEach(suggestionsFiltered(), id: \.self) { suggestion in
HStack {
if selection.contains(suggestion) {
Image(systemName: "checkmark.circle.fill")
}
Text(suggestion)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight"))
)
.onTapGesture {
if selection.contains(suggestion) {
selection.removeAll(where: { s in
s == suggestion ? true : false
})
} else {
selection.append(suggestion)
Divider().padding()
HStack {
Text("Selected keywords:")
.font(.headline)
.padding()
Spacer()
}
LazyVGrid(columns: columns, spacing: 5) {
ForEach(selection, id: \.self) { suggestion in
KeywordItemView(
keyword: Keyword(suggestion),
isSelected: true
) { keyword in
if selection.contains(keyword.name) {
selection.removeAll(where: { s in
s == keyword.name ? true : false
})
} else {
selection.append(keyword.name)
}
}
}
}
Spacer()
}
Spacer()
}
.navigationTitle(title)
}
func suggestionsFiltered() -> [String] {
func suggestionsFiltered() -> [Keyword] {
guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in
suggestion.lowercased().contains(searchText.lowercased())
suggestion.name.lowercased().contains(searchText.lowercased())
}
}
}
struct KeywordItemView: View {
var keyword: Keyword
var isSelected: Bool
var tapped: (Keyword) -> ()
var body: some View {
HStack {
if isSelected {
Image(systemName: "checkmark.circle.fill")
}
Text(keyword.name)
.lineLimit(2)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight"))
)
.onTapGesture {
tapped(keyword)
}
}
}

View File

@@ -7,35 +7,29 @@
import SwiftUI
struct MainView: View {
@ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings: UserSettings
@State private var selectedCategory: Category? = nil
@State private var showEditView: Bool = false
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
var body: some View {
NavigationView {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 0) {
ForEach(viewModel.categories, id: \.name) { category in
if category.recipe_count != 0 {
NavigationLink(
destination: RecipeBookView(
categoryName: category.name,
viewModel: viewModel
)
) {
CategoryCardView(category: category)
}
.buttonStyle(.plain)
}
NavigationSplitView {
List(viewModel.categories, selection: $selectedCategory) { category in
if category.recipe_count != 0 {
NavigationLink(value: category) {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
Text(category.name)
.font(.system(size: 20, weight: .light, design: .serif))
.italic()
}.padding(7)
}
}
}
/*.navigationDestination(isPresented: $showEditView) {
RecipeEditView()
}*/
.navigationTitle("Cookbooks")
.toolbar {
Menu {
@@ -68,9 +62,20 @@ struct MainView: View {
Image(systemName: "gearshape")
}
}
.background(
NavigationLink(destination: RecipeEditView(), isActive: $showEditView) { EmptyView() }
)
.navigationDestination(isPresented: $showEditView) {
RecipeEditView(viewModel: viewModel, isPresented: $showEditView)
}
} detail: {
NavigationStack {
if let category = selectedCategory {
CategoryDetailView(
categoryName: category.name,
viewModel: viewModel
)
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
}
}
.tint(.nextcloudBlue)

View File

@@ -16,6 +16,8 @@ struct RecipeDetailView: View {
@State var recipeImage: UIImage?
@State var showTitle: Bool = false
@State var isDownloaded: Bool? = nil
@State private var presentEditView: Bool = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
@@ -69,6 +71,18 @@ struct RecipeDetailView: View {
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(showTitle ? recipe.name : "")
.toolbar {
if let recipeDetail = recipeDetail {
NavigationLink {
RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false).tag("RecipeEditView")
} label: {
HStack {
Image(systemName: "pencil")
Text("Edit")
}
}
}
}
.task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false)
@@ -78,7 +92,6 @@ struct RecipeDetailView: View {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true)
}
}
}
@@ -91,7 +104,7 @@ struct RecipeDurationSection: View {
if let prepTime = recipeDetail.prepTime {
VStack {
SecondaryLabel(text: "Prep time")
Text(formatDate(duration: prepTime))
Text(DateFormatter.formatDate(duration: prepTime))
.lineLimit(1)
}.padding()
}
@@ -99,7 +112,7 @@ struct RecipeDurationSection: View {
if let cookTime = recipeDetail.cookTime {
VStack {
SecondaryLabel(text: "Cook time")
Text(formatDate(duration: cookTime))
Text(DateFormatter.formatDate(duration: cookTime))
.lineLimit(1)
}.padding()
}
@@ -107,7 +120,7 @@ struct RecipeDurationSection: View {
if let totalTime = recipeDetail.totalTime {
VStack {
SecondaryLabel(text: "Total time")
Text(formatDate(duration: totalTime))
Text(DateFormatter.formatDate(duration: totalTime))
.lineLimit(1)
}.padding()
}

View File

@@ -11,33 +11,56 @@ import PhotosUI
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] = []
init(recipe: RecipeDetail? = nil) {
self.recipe = recipe ?? RecipeDetail()
}
@State private var alertMessage: String = ""
@State private var presentAlert: Bool = false
var body: some View {
Form {
TextField("Title", text: $recipe.name)
TextField("Description", text: $recipe.description)
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: "Keyword", searchSuggestions: [], selection: $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("Keywords")
Text("Discoverability")
} footer: {
ScrollView(.horizontal) {
HStack {
@@ -63,7 +86,131 @@ struct RecipeEditView: View {
EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient)
EditableListSection(title: "Tools", items: $recipe.tool)
EditableListSection(title: "Instructions", items: $recipe.recipeInstructions)
}.navigationTitle("New Recipe")
}.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()
}
}
.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, isPresented: $presentAlert) {
Button("Ok", role: .cancel) {
self.isPresented = false
}
}
}
func createRecipe() {
print(self.recipe.name)
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
}
self.recipe.keywords = self.keywords.joined(separator: ",")
}
func uploadNewRecipe() {
print("Uploading new recipe.")
createRecipe()
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)
}
func uploadEditedRecipe() {
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)
}
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)
}
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)
alertMessage = error.msg
presentAlert = true
} catch {
self.isPresented = false
await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true)
}
}
}
}