Updated recipe editing user interface

This commit is contained in:
Vicnet
2023-10-05 22:59:53 +02:00
parent 85a8e631d0
commit c3a068a1c0
7 changed files with 277 additions and 155 deletions

View File

@@ -19,7 +19,7 @@ extension Category: Identifiable, Hashable {
struct Recipe: Codable { struct Recipe: Codable {
let name: String let name: String
let keywords: String let keywords: String?
let dateCreated: String let dateCreated: String
let dateModified: String let dateModified: String
let imageUrl: String let imageUrl: String

View File

@@ -34,10 +34,17 @@ class UserSettings: ObservableObject {
} }
} }
@Published var defaultCategory: String {
didSet {
UserDefaults.standard.set(defaultCategory, forKey: "defaultCategory")
}
}
init() { init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? "" self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? ""
self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true
self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? ""
} }
} }

View File

@@ -47,13 +47,15 @@ import SwiftUI
} }
/// Try to load the recipe list from store or the server. /// Try to load the recipe list from store or the server.
/// - Warning: The category named '\*' is translated into '\_' for network calls and storage requests in this function. This is necessary for the nextcloud cookbook api.
/// - Parameters /// - Parameters
/// - categoryName: The name of the category containing the requested list of recipes. /// - categoryName: The name of the category containing the requested list of recipes.
/// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first.
func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async {
let categoryString = categoryName == "*" ? "_" : categoryName
if let recipeList: [Recipe] = await loadObject( if let recipeList: [Recipe] = await loadObject(
localPath: "category_\(categoryName).data", localPath: "category_\(categoryString).data",
networkPath: .RECIPE_LIST(categoryName: categoryName), networkPath: .RECIPE_LIST(categoryName: categoryString),
needsUpdate: needsUpdate needsUpdate: needsUpdate
) { ) {
recipes[categoryName] = recipeList recipes[categoryName] = recipeList

View File

@@ -31,19 +31,21 @@ struct CategoryDetailView: View {
} }
.navigationTitle(categoryName == "*" ? "Other" : categoryName) .navigationTitle(categoryName == "*" ? "Other" : categoryName)
.toolbar { .toolbar {
Menu { ToolbarItem(placement: .topBarLeading) {
Button { Menu {
print("Downloading all recipes in category \(categoryName) ...") Button {
downloadRecipes() print("Downloading all recipes in category \(categoryName) ...")
} label: { downloadRecipes()
HStack { } label: {
Text("Download recipes") HStack {
Image(systemName: "icloud.and.arrow.down") Text("Download recipes")
Image(systemName: "icloud.and.arrow.down")
}
} }
}
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
}
} }
} }
.searchable(text: $searchText, prompt: "Search recipes") .searchable(text: $searchText, prompt: "Search recipes")

View File

@@ -23,7 +23,7 @@ struct MainView: View {
NavigationLink(value: category) { NavigationLink(value: category) {
HStack(alignment: .center) { HStack(alignment: .center) {
Image(systemName: "book.closed.fill") Image(systemName: "book.closed.fill")
Text(category.name) Text(category.name == "*" ? "Other" : category.name)
.font(.system(size: 20, weight: .light, design: .serif)) .font(.system(size: 20, weight: .light, design: .serif))
.italic() .italic()
}.padding(7) }.padding(7)
@@ -32,39 +32,40 @@ struct MainView: View {
} }
.navigationTitle("Cookbooks") .navigationTitle("Cookbooks")
.toolbar { .toolbar {
Menu { ToolbarItem(placement: .topBarLeading) {
Button { Menu {
print("Downloading all recipes ...") Button {
Task { print("Downloading all recipes ...")
await viewModel.downloadAllRecipes() Task {
await viewModel.downloadAllRecipes()
}
} label: {
HStack {
Text("Download all recipes")
Image(systemName: "icloud.and.arrow.down")
}
}
Button {
print("Create recipe")
showEditView = true
} label: {
HStack {
Text("Create new recipe")
Image(systemName: "plus.circle")
}
} }
} label: { } label: {
HStack { Image(systemName: "ellipsis.circle")
Text("Download all recipes")
Image(systemName: "icloud.and.arrow.down")
}
} }
Button {
print("Create recipe")
showEditView = true
} label: {
HStack {
Text("Create new recipe")
Image(systemName: "plus.circle")
}
}
} label: {
Image(systemName: "ellipsis.circle")
} }
ToolbarItem(placement: .topBarTrailing) {
NavigationLink( destination: SettingsView(userSettings: userSettings, viewModel: viewModel)) { NavigationLink( destination: SettingsView(userSettings: userSettings, viewModel: viewModel)) {
Image(systemName: "gearshape") Image(systemName: "gearshape")
}
} }
} }
.navigationDestination(isPresented: $showEditView) {
RecipeEditView(viewModel: viewModel, isPresented: $showEditView)
}
} detail: { } detail: {
NavigationStack { NavigationStack {
@@ -79,12 +80,17 @@ struct MainView: View {
} }
.tint(.nextcloudBlue) .tint(.nextcloudBlue)
.sheet(isPresented: $showEditView) {
RecipeEditView(viewModel: viewModel, isPresented: $showEditView)
}
.task { .task {
await viewModel.loadCategoryList() await viewModel.loadCategoryList()
} }
.refreshable { .refreshable {
await viewModel.loadCategoryList(needsUpdate: true) await viewModel.loadCategoryList(needsUpdate: true)
} }
// TODO: SET DEFAULT CATEGORY
} }
} }

View File

@@ -73,16 +73,20 @@ struct RecipeDetailView: View {
.navigationTitle(showTitle ? recipe.name : "") .navigationTitle(showTitle ? recipe.name : "")
.toolbar { .toolbar {
if let recipeDetail = recipeDetail { if let recipeDetail = recipeDetail {
NavigationLink { Button {
RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false).tag("RecipeEditView") presentEditView = true
} label: { } label: {
HStack { HStack {
Image(systemName: "pencil")
Text("Edit") Text("Edit")
} }
} }
} }
} }
.sheet(isPresented: $presentEditView) {
if let recipeDetail = recipeDetail {
RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false)
}
}
.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)

View File

@@ -10,108 +10,173 @@ import SwiftUI
import PhotosUI 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 { struct RecipeEditView: View {
@ObservedObject var viewModel: MainViewModel @ObservedObject var viewModel: MainViewModel
@State var recipe: RecipeDetail = RecipeDetail() @State var recipe: RecipeDetail = RecipeDetail()
@Binding var isPresented: Bool @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 uploadNew: Bool = true
@State var searchText: String = ""
@State var keywords: [String] = []
@State private var alertMessage: 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: ErrorMessages = .GENERIC
@State private var presentAlert: Bool = false @State private var presentAlert: Bool = false
@State private var waitingForUpload: Bool = false
var body: some View { var body: some View {
Form { VStack {
TextField("Title", text: $recipe.name) HStack {
Section { Button() {
TextEditor(text: $recipe.description) isPresented = false
} header: { } label: {
Text("Description") Text("Cancel")
} .bold()
/*
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") { if !uploadNew {
KeywordPickerView( Menu {
title: "Keywords", Button {
searchSuggestions: [ print("Delete recipe.")
Keyword("Hauptspeisen"), alertMessage = .CONFIRM_DELETE
Keyword("Lecker"), presentAlert = true
Keyword("Trinken"), } label: {
Keyword("Essen"), Image(systemName: "trash")
Keyword("Nachspeisen"), .foregroundStyle(.red)
Keyword("Futter"), Text("Delete recipe")
Keyword("Apfel"), .foregroundStyle(.red)
Keyword("test") }
], } label: {
selection: $keywords Image(systemName: "ellipsis.circle")
) .font(.title3)
.padding()
}
} }
} header: { Spacer()
Text("Discoverability") Button() {
} footer: { if uploadNew {
ScrollView(.horizontal) { uploadNewRecipe()
HStack { } else {
ForEach(keywords, id: \.self) { keyword in uploadEditedRecipe()
Text(keyword) }
} 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() { Section() {
Picker("Yield/Portions:", selection: $recipe.recipeYield) { Picker("Yield/Portions:", selection: $recipe.recipeYield) {
ForEach(0..<99, id: \.self) { i in ForEach(0..<99, id: \.self) { i in
Text("\(i)").tag(i) 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: "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("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 { .onAppear {
@@ -130,15 +195,22 @@ struct RecipeEditView: View {
keywords.append(keyword) keywords.append(keyword)
} }
} }
.alert(alertMessage, isPresented: $presentAlert) { .alert(alertMessage.localizedTitle, isPresented: $presentAlert) {
Button("Ok", role: .cancel) { switch alertMessage {
self.isPresented = false case .CONFIRM_DELETE:
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
deleteRecipe()
}
default:
Button("Ok", role: .cancel) { }
} }
} message: {
Text(alertMessage.localizedDescription)
} }
} }
func createRecipe() { func createRecipe() {
print(self.recipe.name)
if let date = Date.toPTRepresentation(date: times[0]) { if let date = Date.toPTRepresentation(date: times[0]) {
self.recipe.prepTime = date self.recipe.prepTime = date
} }
@@ -148,12 +220,43 @@ struct RecipeEditView: View {
if let date = Date.toPTRepresentation(date: times[2]) { if let date = Date.toPTRepresentation(date: times[2]) {
self.recipe.totalTime = date 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() { func uploadNewRecipe() {
print("Uploading new recipe.") print("Uploading new recipe.")
waitingForUpload = true
createRecipe() createRecipe()
guard recipeValid() else { return }
let request = RequestWrapper.customRequest( let request = RequestWrapper.customRequest(
method: .POST, method: .POST,
path: .NEW_RECIPE, path: .NEW_RECIPE,
@@ -165,9 +268,11 @@ struct RecipeEditView: View {
body: JSONEncoder.safeEncode(self.recipe) body: JSONEncoder.safeEncode(self.recipe)
) )
sendRequest(request) sendRequest(request)
dismissEditView()
} }
func uploadEditedRecipe() { func uploadEditedRecipe() {
waitingForUpload = true
print("Uploading changed recipe.") print("Uploading changed recipe.")
guard let recipeId = Int(recipe.id) else { return } guard let recipeId = Int(recipe.id) else { return }
createRecipe() createRecipe()
@@ -182,6 +287,7 @@ struct RecipeEditView: View {
body: JSONEncoder.safeEncode(self.recipe) body: JSONEncoder.safeEncode(self.recipe)
) )
sendRequest(request) sendRequest(request)
dismissEditView()
} }
func deleteRecipe() { func deleteRecipe() {
@@ -195,6 +301,7 @@ struct RecipeEditView: View {
] ]
) )
sendRequest(request) sendRequest(request)
dismissEditView()
} }
func sendRequest(_ request: RequestWrapper) { func sendRequest(_ request: RequestWrapper) {
@@ -204,34 +311,28 @@ struct RecipeEditView: View {
guard let data = data else { return } guard let data = data else { return }
do { do {
let error = try JSONDecoder().decode(ServerMessage.self, from: data) let error = try JSONDecoder().decode(ServerMessage.self, from: data)
alertMessage = error.msg DispatchQueue.main.sync {
presentAlert = true alertMessage = .CUSTOM(title: "Error.", description: LocalizedStringKey(stringLiteral: error.msg))
presentAlert = true
return
}
} catch { } catch {
self.isPresented = false
await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true)
} }
} }
} }
}
func dismissEditView() {
Task {
struct SearchField: View { await self.viewModel.loadCategoryList(needsUpdate: true)
@State var title: String await self.viewModel.loadRecipeList(categoryName: self.recipe.recipeCategory, needsUpdate: true)
@State var text: String }
@State var searchSuggestions: [String] self.isPresented = false
var body: some View {
TextField(title, text: $text)
.searchSuggestions {
ForEach(searchSuggestions, id: \.self) { suggestion in
Text(suggestion).searchCompletion(suggestion)
}
}
} }
} }
struct EditableListSection: View { struct EditableListSection: View {
@State var title: String @State var title: String
@Binding var items: [String] @Binding var items: [String]