diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index aeaeb85..e26b948 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -19,7 +19,7 @@ extension Category: Identifiable, Hashable { struct Recipe: Codable { let name: String - let keywords: String + let keywords: String? let dateCreated: String let dateModified: String let imageUrl: String diff --git a/Nextcloud Cookbook iOS Client/Data/UserDefaults.swift b/Nextcloud Cookbook iOS Client/Data/UserDefaults.swift index 3a8b8cf..8fdc291 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserDefaults.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserDefaults.swift @@ -34,10 +34,17 @@ class UserSettings: ObservableObject { } } + @Published var defaultCategory: String { + didSet { + UserDefaults.standard.set(defaultCategory, forKey: "defaultCategory") + } + } + init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? "" self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true + self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" } } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index d3a79bb..e10e26f 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -47,13 +47,15 @@ import SwiftUI } /// 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 /// - 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. func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { + let categoryString = categoryName == "*" ? "_" : categoryName if let recipeList: [Recipe] = await loadObject( - localPath: "category_\(categoryName).data", - networkPath: .RECIPE_LIST(categoryName: categoryName), + localPath: "category_\(categoryString).data", + networkPath: .RECIPE_LIST(categoryName: categoryString), needsUpdate: needsUpdate ) { recipes[categoryName] = recipeList diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index 871c203..da82228 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -31,19 +31,21 @@ struct CategoryDetailView: View { } .navigationTitle(categoryName == "*" ? "Other" : categoryName) .toolbar { - Menu { - Button { - print("Downloading all recipes in category \(categoryName) ...") - downloadRecipes() - } label: { - HStack { - Text("Download recipes") - Image(systemName: "icloud.and.arrow.down") + ToolbarItem(placement: .topBarLeading) { + Menu { + Button { + print("Downloading all recipes in category \(categoryName) ...") + downloadRecipes() + } label: { + HStack { + Text("Download recipes") + Image(systemName: "icloud.and.arrow.down") + } } + + } label: { + Image(systemName: "ellipsis.circle") } - - } label: { - Image(systemName: "ellipsis.circle") } } .searchable(text: $searchText, prompt: "Search recipes") diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index e1b132b..626719f 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -23,7 +23,7 @@ struct MainView: View { NavigationLink(value: category) { HStack(alignment: .center) { Image(systemName: "book.closed.fill") - Text(category.name) + Text(category.name == "*" ? "Other" : category.name) .font(.system(size: 20, weight: .light, design: .serif)) .italic() }.padding(7) @@ -32,39 +32,40 @@ struct MainView: View { } .navigationTitle("Cookbooks") .toolbar { - Menu { - Button { - print("Downloading all recipes ...") - Task { - await viewModel.downloadAllRecipes() + ToolbarItem(placement: .topBarLeading) { + Menu { + Button { + print("Downloading all recipes ...") + 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: { - HStack { - Text("Download all recipes") - Image(systemName: "icloud.and.arrow.down") - } + Image(systemName: "ellipsis.circle") } - - Button { - print("Create recipe") - showEditView = true - } label: { - HStack { - Text("Create new recipe") - Image(systemName: "plus.circle") - } - } - } label: { - Image(systemName: "ellipsis.circle") } - - NavigationLink( destination: SettingsView(userSettings: userSettings, viewModel: viewModel)) { - Image(systemName: "gearshape") + ToolbarItem(placement: .topBarTrailing) { + NavigationLink( destination: SettingsView(userSettings: userSettings, viewModel: viewModel)) { + Image(systemName: "gearshape") + } } } - .navigationDestination(isPresented: $showEditView) { - RecipeEditView(viewModel: viewModel, isPresented: $showEditView) - } + } detail: { NavigationStack { @@ -79,12 +80,17 @@ struct MainView: View { } .tint(.nextcloudBlue) + .sheet(isPresented: $showEditView) { + RecipeEditView(viewModel: viewModel, isPresented: $showEditView) + } .task { await viewModel.loadCategoryList() } .refreshable { await viewModel.loadCategoryList(needsUpdate: true) } + + // TODO: SET DEFAULT CATEGORY } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 9d5abc8..ccff280 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -73,16 +73,20 @@ struct RecipeDetailView: View { .navigationTitle(showTitle ? recipe.name : "") .toolbar { if let recipeDetail = recipeDetail { - NavigationLink { - RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false).tag("RecipeEditView") + Button { + presentEditView = true } label: { HStack { - Image(systemName: "pencil") Text("Edit") } } } } + .sheet(isPresented: $presentEditView) { + if let recipeDetail = recipeDetail { + RecipeEditView(viewModel: viewModel, recipe: recipeDetail, isPresented: $presentEditView, uploadNew: false) + } + } .task { recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index fcbf4d0..fcb720d 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -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]