diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index 18d9e0e..deea4fc 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 9f106c7..bfd241a 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -224,6 +224,9 @@ } } } + }, + "%lld serving(s)" : { + }, "%lld." : { "localizations" : { @@ -652,6 +655,9 @@ }, "Cholesterol content" : { "comment" : "Cholesterol content" + }, + "Choose" : { + }, "Configure what is stored on your device." : { "localizations" : { @@ -1172,9 +1178,6 @@ } } } - }, - "Edit keywords" : { - }, "Enable deletion" : { @@ -2562,6 +2565,9 @@ }, "Select Item" : { + }, + "Select Keywords" : { + }, "Selected keywords:" : { "localizations" : { @@ -2587,6 +2593,9 @@ }, "Serving size" : { "comment" : "Serving size" + }, + "Servings" : { + }, "Servings:" : { "localizations" : { @@ -3242,6 +3251,12 @@ } } } + }, + "Upload Changes" : { + + }, + "Upload Recipe" : { + }, "URL (e.g. example.com/recipe)" : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 48b8444..6225e86 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -15,6 +15,8 @@ struct RecipeView: View { @StateObject var viewModel: ViewModel @State var imageHeight: CGFloat = 350 + + private enum CoordinateSpaces { case scrollView } @@ -36,6 +38,9 @@ struct RecipeView: View { } VStack(alignment: .leading) { + if viewModel.editMode { + RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe) + } HStack { EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") .font(.title) @@ -102,14 +107,50 @@ struct RecipeView: View { viewModel.editMode = false } } + ToolbarItem(placement: .topBarTrailing) { - Button("Done") { + Button { // TODO: POST edited recipe if viewModel.newRecipe { presentationMode.wrappedValue.dismiss() } else { viewModel.editMode = false } + } label: { + if viewModel.newRecipe { + Text("Upload Recipe") + } else { + Text("Upload Changes") + } + } + } + if !viewModel.newRecipe { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + print("Delete recipe.") + viewModel.alertType = RecipeAlert.CONFIRM_DELETE + viewModel.alertAction = { + if let res = await deleteRecipe() { + viewModel.alertType = res + viewModel.alertAction = { } + viewModel.presentAlert = true + } else { + presentationMode.wrappedValue.dismiss() + } + } + viewModel.presentAlert = true + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + Text("Delete recipe") + .foregroundStyle(.red) + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.title3) + .padding() + } } } } else { @@ -177,6 +218,27 @@ struct RecipeView: View { viewModel.isDownloaded = false } } + .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { + ForEach(viewModel.alertType.alertButtons) { buttonType in + if buttonType == .OK { + Button(AlertButton.OK.rawValue, role: .cancel) { + Task { + await viewModel.alertAction() + } + } + } else if buttonType == .CANCEL { + Button(AlertButton.CANCEL.rawValue, role: .cancel) { } + } else if buttonType == .DELETE { + Button(AlertButton.DELETE.rawValue, role: .destructive) { + Task { + await viewModel.alertAction() + } + } + } + } + } message: { + Text(viewModel.alertType.localizedDescription) + } .onAppear { if UserSettings.shared.keepScreenAwake { UIApplication.shared.isIdleTimerDisabled = true @@ -207,12 +269,17 @@ struct RecipeView: View { @Published var presentShareSheet: Bool = false @Published var showTitle: Bool = false @Published var isDownloaded: Bool? = nil - var newRecipe: Bool = false - + @Published var importUrl: String = "" var recipe: Recipe var sharedURL: URL? = nil + var newRecipe: Bool = false + // Alerts + @Published var presentAlert = false + var alertType: UserAlert = RecipeAlert.GENERIC + var alertAction: () async -> () = { } + // Initializers init(recipe: Recipe) { self.recipe = recipe } @@ -229,12 +296,122 @@ struct RecipeView: View { recipe_id: 0) } + // View setup func setupView(recipeDetail: RecipeDetail) { self.recipeDetail = recipeDetail self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail) } - } } +extension RecipeView { + func importRecipe(from url: String) async -> UserAlert? { + let (scrapedRecipe, error) = await appState.importRecipe(url: url) + if let scrapedRecipe = scrapedRecipe { + viewModel.setupView(recipeDetail: scrapedRecipe) + return nil + } + + do { + let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url) + if let scrapedRecipe = scrapedRecipe { + viewModel.setupView(recipeDetail: scrapedRecipe) + } + if let error = error { + return error + } + } catch { + print("Error") + + } + return nil + } + + func uploadNewRecipe() async -> UserAlert? { + print("Uploading new recipe.") + if let recipeValidationError = recipeValid() { + return recipeValidationError + } + + return await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) + } + + func uploadEditedRecipe() async -> UserAlert? { + print("Uploading changed recipe.") + + guard let recipeId = Int(viewModel.observableRecipeDetail.id) else { return RequestAlert.REQUEST_DROPPED } + + return await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) + } + + func deleteRecipe() async -> RequestAlert? { + guard let id = Int(viewModel.observableRecipeDetail.id) else { + return .REQUEST_DROPPED + } + return await appState.deleteRecipe(withId: id, categoryName: viewModel.observableRecipeDetail.recipeCategory) + } + + func recipeValid() -> RecipeAlert? { + // Check if the recipe has a name + if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" { + return RecipeAlert.NO_TITLE + } + + // Check if the recipe has a unique name + for recipeList in appState.recipes.values { + for r in recipeList { + if r.name + .replacingOccurrences(of: " ", with: "") + .lowercased() == + viewModel.observableRecipeDetail.name + .replacingOccurrences(of: " ", with: "") + .lowercased() + { + return RecipeAlert.DUPLICATE + } + } + } + + return nil + } +} + + +// MARK: - Recipe Import Section + +fileprivate struct RecipeImportSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + var importRecipe: (String) async -> UserAlert? + + var body: some View { + VStack(alignment: .leading) { + SecondaryLabel(text: "Import Recipe") + + Text(LocalizedStringKey("Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings.")) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importUrl) + .textFieldStyle(.roundedBorder) + Button { + Task { + if let res = await importRecipe(viewModel.importUrl) { + viewModel.alertType = RecipeAlert.CUSTOM( + title: res.localizedTitle, + description: res.localizedDescription + ) + viewModel.alertAction = { } + viewModel.presentAlert = true + } + } + } label: { + Text(LocalizedStringKey("Import")) + } + }.padding(.top, 5) + } + .padding() + .background(Rectangle().foregroundStyle(Color.white.opacity(0.1))) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift index 423549f..ac883a7 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -16,44 +16,93 @@ struct RecipeMetadataSection: View { @State var categories: [String] = [] @State var keywords: [RecipeKeyword] = [] - @State var presentKeywordPopover: Bool = false + @State var presentKeywordSheet: Bool = false + @State var presentServingsPopover: Bool = false + @State var presentCategoryPopover: Bool = false var body: some View { VStack(alignment: .leading) { - CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category") + //CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category") + SecondaryLabel(text: "Category") + HStack { + TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory) + .lineLimit(1) + .textFieldStyle(.roundedBorder) + + Button { + presentCategoryPopover.toggle() + } label: { + Text("Choose") + } + } SecondaryLabel(text: "Keywords") - .padding() - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in - Text(keyword) + if !viewModel.observableRecipeDetail.keywords.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in + Text(keyword) + } } } - }.padding(.horizontal) - + } Button { - presentKeywordPopover.toggle() + presentKeywordSheet.toggle() } label: { - Text("Edit keywords") + Text("Select Keywords") Image(systemName: "chevron.right") } - .padding(.horizontal) + + VStack(alignment: .leading) { + SecondaryLabel(text: "Servings") + Button { + presentServingsPopover.toggle() + } label: { + Text("\(viewModel.observableRecipeDetail.recipeYield) serving(s)") + .lineLimit(1) + } + } } + .padding() + .background(Rectangle().foregroundStyle(Color.white.opacity(0.1))) .task { categories = appState.categories.map({ category in category.name }) } - .sheet(isPresented: $presentKeywordPopover) { + .sheet(isPresented: $presentKeywordSheet) { KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) } + .popover(isPresented: $presentServingsPopover) { + PickerPopoverView(value: $viewModel.observableRecipeDetail.recipeYield, items: 0..<99, titleKey: "Servings") + } + .popover(isPresented: $presentCategoryPopover) { + PickerPopoverView(value: $viewModel.observableRecipeDetail.recipeCategory, items: categories, titleKey: "Category") + } } } +fileprivate struct PickerPopoverView: View where Collection.Element == Item { + @Binding var value: Item + @State var items: Collection + var titleKey: LocalizedStringKey = "" + + var body: some View { + HStack { + Picker(selection: $value, label: Text(titleKey)) { + ForEach(Array(items), id: \.self) { item in + Text(item.description).tag(item) + } + } + .pickerStyle(WheelPickerStyle()) + .frame(width: 100, height: 150) + .clipped() + } + .padding() + } +} - -struct CategoryPickerView: View { +fileprivate struct CategoryPickerView: View { @Binding var items: [String] @Binding var input: String @State private var pickerChoice: String = ""