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

View File

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

View File

@@ -11,11 +11,30 @@ extension Date {
static var zero: Date { static var zero: Date {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm" dateFormatter.dateFormat = "HH:mm"
if let date = dateFormatter.date(from:"00:00") { if let date = dateFormatter.date(from:"00:00") {
return date return date
} else { } else {
return Date() 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 formatter.unitsStyle = .positional
return formatter 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 { static func formatDate(duration: String) -> String {
return "\(minute)min" 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, case CATEGORIES,
RECIPE_LIST(categoryName: String), RECIPE_LIST(categoryName: String),
RECIPE_DETAIL(recipeId: Int), RECIPE_DETAIL(recipeId: Int),
NEW_RECIPE,
IMAGE(recipeId: Int, thumb: Bool) IMAGE(recipeId: Int, thumb: Bool)
case LOGINV2REQ, case LOGINV2REQ,
@@ -30,6 +31,7 @@ enum RequestPath {
case .RECIPE_LIST(categoryName: let name): return "category/\(name)" case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" 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 .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 .LOGINV2REQ: return "/index.php/login/v2"
case .CUSTOM(path: let path): return path case .CUSTOM(path: let path): return path

View File

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

View File

@@ -16,7 +16,7 @@ import SwiftUI
private var imageCache: [Int: RecipeImage] = [:] private var imageCache: [Int: RecipeImage] = [:]
let dataStore: DataStore let dataStore: DataStore
var apiInterface: APIController? = nil var apiController: APIController? = nil
/// The path of an image in storage /// The path of an image in storage
private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in
@@ -43,6 +43,7 @@ import SwiftUI
) { ) {
self.categories = categoryList self.categories = categoryList
} }
print(self.categories)
} }
/// Try to load the recipe list from store or the server. /// Try to load the recipe list from store or the server.
@@ -56,7 +57,9 @@ import SwiftUI
needsUpdate: needsUpdate needsUpdate: needsUpdate
) { ) {
recipes[categoryName] = recipeList recipes[categoryName] = recipeList
print(recipeList)
} }
} }
/// Try to load the recipe details from cache. If not found, try to load from store or the server. /// 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))") 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 the image needs an update, request it from the server and overwrite the stored image
if needsUpdate { if needsUpdate {
guard let apiInterface = apiInterface else { return nil } guard let apiController = apiController else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
guard let image = UIImage(data: data) else { guard let image = UIImage(data: data) else {
imageCache[recipeId] = RecipeImage(imageExists: false) imageCache[recipeId] = RecipeImage(imageExists: false)
return nil return nil
@@ -154,8 +157,8 @@ import SwiftUI
// Try to load from the server. Store if successfull. // Try to load from the server. Store if successfull.
print("Attempting to load image from server ...") print("Attempting to load image from server ...")
guard let apiInterface = apiInterface else { return nil } guard let apiController = apiController else { return nil }
if let data = await apiInterface.imageDataFromServer(recipeId: recipeId, thumb: thumb) { if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) {
print("Image data received.") 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. // 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 { guard let image = UIImage(data: data) else {
@@ -190,9 +193,9 @@ extension MainViewModel {
print("Data found locally.") print("Data found locally.")
return data return data
} else { } else {
guard let apiInterface = apiInterface else { return nil } guard let apiController = apiController else { return nil }
let request = RequestWrapper.jsonGetRequest(path: networkPath) 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) print(error as Any)
if let data = data { if let data = data {
await dataStore.save(data: data, toPath: localPath) 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 categoryName: String
@State var searchText: String = "" @State var searchText: String = ""
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@@ -19,10 +19,13 @@ struct RecipeBookView: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
LazyVStack { LazyVStack {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in 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) RecipeCardView(viewModel: viewModel, recipe: recipe)
} }
.buttonStyle(.plain) .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 Foundation
import SwiftUI import SwiftUI
struct Keyword: Identifiable {
let id = UUID()
let name: String
init(_ name: String) {
self.name = name
}
}
struct KeywordPickerView: View { struct KeywordPickerView: View {
@State var title: String @State var title: String
@State var searchSuggestions: [String] @State var searchSuggestions: [Keyword]
@Binding var selection: [String] @Binding var selection: [String]
@State var searchText: 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 { var body: some View {
VStack { VStack(alignment: .leading) {
TextField(title, text: $searchText) TextField(title, text: $searchText)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.padding() .padding()
LazyVGrid(columns: columns, spacing: 5) { ScrollView {
if searchText != "" { LazyVGrid(columns: columns, spacing: 5) {
HStack { if searchText != "" {
if selection.contains(searchText) { KeywordItemView(
Image(systemName: "checkmark.circle.fill") 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() ForEach(suggestionsFiltered(), id: \.id) { suggestion in
.background( KeywordItemView(
RoundedRectangle(cornerRadius: 15) keyword: suggestion,
.foregroundStyle(Color("backgroundHighlight")) isSelected: selection.contains(suggestion.name)
) ) { keyword in
.onTapGesture { if selection.contains(keyword.name) {
if selection.contains(searchText) { selection.removeAll(where: { s in
selection.removeAll(where: { s in s == keyword.name ? true : false
s == searchText ? true : false })
}) } else {
} else { selection.append(keyword.name)
selection.append(searchText) }
searchSuggestions.append(searchText)
} }
} }
} }
ForEach(suggestionsFiltered(), id: \.self) { suggestion in Divider().padding()
HStack { HStack {
if selection.contains(suggestion) { Text("Selected keywords:")
Image(systemName: "checkmark.circle.fill") .font(.headline)
} .padding()
Text(suggestion) Spacer()
} }
.padding() LazyVGrid(columns: columns, spacing: 5) {
.background( ForEach(selection, id: \.self) { suggestion in
RoundedRectangle(cornerRadius: 15) KeywordItemView(
.foregroundStyle(Color("backgroundHighlight")) keyword: Keyword(suggestion),
) isSelected: true
.onTapGesture { ) { keyword in
if selection.contains(suggestion) { if selection.contains(keyword.name) {
selection.removeAll(where: { s in selection.removeAll(where: { s in
s == suggestion ? true : false s == keyword.name ? true : false
}) })
} else { } else {
selection.append(suggestion) selection.append(keyword.name)
}
} }
} }
} }
Spacer()
} }
Spacer()
} }
.navigationTitle(title)
} }
func suggestionsFiltered() -> [String] { func suggestionsFiltered() -> [Keyword] {
guard searchText != "" else { return searchSuggestions } guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in 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 import SwiftUI
struct MainView: View { struct MainView: View {
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@ObservedObject var userSettings: UserSettings @ObservedObject var userSettings: UserSettings
@State private var selectedCategory: Category? = nil
@State private var showEditView: Bool = false @State private var showEditView: Bool = false
var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)]
var body: some View { var body: some View {
NavigationView { NavigationSplitView {
ScrollView(.vertical, showsIndicators: false) { List(viewModel.categories, selection: $selectedCategory) { category in
LazyVGrid(columns: columns, spacing: 0) { if category.recipe_count != 0 {
ForEach(viewModel.categories, id: \.name) { category in NavigationLink(value: category) {
if category.recipe_count != 0 { HStack(alignment: .center) {
NavigationLink( Image(systemName: "book.closed.fill")
destination: RecipeBookView( Text(category.name)
categoryName: category.name, .font(.system(size: 20, weight: .light, design: .serif))
viewModel: viewModel .italic()
) }.padding(7)
) {
CategoryCardView(category: category)
}
.buttonStyle(.plain)
}
} }
} }
} }
/*.navigationDestination(isPresented: $showEditView) {
RecipeEditView()
}*/
.navigationTitle("Cookbooks") .navigationTitle("Cookbooks")
.toolbar { .toolbar {
Menu { Menu {
@@ -68,9 +62,20 @@ struct MainView: View {
Image(systemName: "gearshape") Image(systemName: "gearshape")
} }
} }
.background( .navigationDestination(isPresented: $showEditView) {
NavigationLink(destination: RecipeEditView(), isActive: $showEditView) { EmptyView() } 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) .tint(.nextcloudBlue)

View File

@@ -16,6 +16,8 @@ struct RecipeDetailView: View {
@State var recipeImage: UIImage? @State var recipeImage: UIImage?
@State var showTitle: Bool = false @State var showTitle: Bool = false
@State var isDownloaded: Bool? = nil @State var isDownloaded: Bool? = nil
@State private var presentEditView: Bool = false
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -69,6 +71,18 @@ struct RecipeDetailView: View {
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationTitle(showTitle ? recipe.name : "") .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 { .task {
recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) 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) recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true)
recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, 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 { if let prepTime = recipeDetail.prepTime {
VStack { VStack {
SecondaryLabel(text: "Prep time") SecondaryLabel(text: "Prep time")
Text(formatDate(duration: prepTime)) Text(DateFormatter.formatDate(duration: prepTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
} }
@@ -99,7 +112,7 @@ struct RecipeDurationSection: View {
if let cookTime = recipeDetail.cookTime { if let cookTime = recipeDetail.cookTime {
VStack { VStack {
SecondaryLabel(text: "Cook time") SecondaryLabel(text: "Cook time")
Text(formatDate(duration: cookTime)) Text(DateFormatter.formatDate(duration: cookTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
} }
@@ -107,7 +120,7 @@ struct RecipeDurationSection: View {
if let totalTime = recipeDetail.totalTime { if let totalTime = recipeDetail.totalTime {
VStack { VStack {
SecondaryLabel(text: "Total time") SecondaryLabel(text: "Total time")
Text(formatDate(duration: totalTime)) Text(DateFormatter.formatDate(duration: totalTime))
.lineLimit(1) .lineLimit(1)
}.padding() }.padding()
} }

View File

@@ -11,33 +11,56 @@ import PhotosUI
struct RecipeEditView: View { struct RecipeEditView: View {
@ObservedObject var viewModel: MainViewModel
@State var recipe: RecipeDetail = RecipeDetail() @State var recipe: RecipeDetail = RecipeDetail()
@Binding var isPresented: Bool
@State var image: PhotosPickerItem? = nil @State var image: PhotosPickerItem? = nil
@State var times = [Date.zero, Date.zero, Date.zero] @State var times = [Date.zero, Date.zero, Date.zero]
@State var uploadNew: Bool = true
@State var searchText: String = "" @State var searchText: String = ""
@State var keywords: [String] = [] @State var keywords: [String] = []
init(recipe: RecipeDetail? = nil) { @State private var alertMessage: String = ""
self.recipe = recipe ?? RecipeDetail() @State private var presentAlert: Bool = false
}
var body: some View { var body: some View {
Form { Form {
TextField("Title", text: $recipe.name) 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()) { PhotosPicker(selection: $image, matching: .images, photoLibrary: .shared()) {
Image(systemName: "photo") Image(systemName: "photo")
.symbolRenderingMode(.multicolor) .symbolRenderingMode(.multicolor)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
*/
Section() { Section() {
NavigationLink(recipe.recipeCategory == "" ? "Category" : "Category: \(recipe.recipeCategory)") {
CategoryPickerView(title: "Category", searchSuggestions: [], selection: $recipe.recipeCategory)
}
NavigationLink("Keywords") { 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: { } header: {
Text("Keywords") Text("Discoverability")
} footer: { } footer: {
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack { HStack {
@@ -63,7 +86,131 @@ struct RecipeEditView: View {
EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient) EditableListSection(title: "Ingredients", items: $recipe.recipeIngredient)
EditableListSection(title: "Tools", items: $recipe.tool) EditableListSection(title: "Tools", items: $recipe.tool)
EditableListSection(title: "Instructions", items: $recipe.recipeInstructions) 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)
}
}
} }
} }