diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index cc81935..a3722a3 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -41,12 +41,19 @@ A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; }; - A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; + A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; }; A7F3F8EA2ACC221C0076C227 /* CategoryPickerViewOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */; }; A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; }; A95364672B7E89F1001018B0 /* ReorderableForEach.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95364662B7E89F1001018B0 /* ReorderableForEach.swift */; }; + A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506122B920D9F00E86029 /* RecipeDurationSection.swift */; }; + A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506142B920DF200E86029 /* RecipeGenericViews.swift */; }; + A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506182B920EC200E86029 /* RecipeIngredientSection.swift */; }; + A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */; }; + A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */; }; + A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061E2B920FFC00E86029 /* RecipeToolSection.swift */; }; + A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506202B92104700E86029 /* RecipeMetadataSection.swift */; }; A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; }; A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; }; A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; @@ -117,12 +124,19 @@ A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = ""; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = ""; }; - A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = ""; }; + A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = ""; }; A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerViewOld.swift; sourceTree = ""; }; A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; A95364662B7E89F1001018B0 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = ""; }; + A97506122B920D9F00E86029 /* RecipeDurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDurationSection.swift; sourceTree = ""; }; + A97506142B920DF200E86029 /* RecipeGenericViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeGenericViews.swift; sourceTree = ""; }; + A97506182B920EC200E86029 /* RecipeIngredientSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeIngredientSection.swift; sourceTree = ""; }; + A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeNutritionSection.swift; sourceTree = ""; }; + A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeInstructionSection.swift; sourceTree = ""; }; + A975061E2B920FFC00E86029 /* RecipeToolSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeToolSection.swift; sourceTree = ""; }; + A97506202B92104700E86029 /* RecipeMetadataSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeMetadataSection.swift; sourceTree = ""; }; A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = ""; }; A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = ""; }; A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = ""; }; @@ -333,6 +347,21 @@ path = Onboarding; sourceTree = ""; }; + A97506112B920D8100E86029 /* RecipeViewSections */ = { + isa = PBXGroup; + children = ( + A97506122B920D9F00E86029 /* RecipeDurationSection.swift */, + A97506182B920EC200E86029 /* RecipeIngredientSection.swift */, + A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */, + A975061E2B920FFC00E86029 /* RecipeToolSection.swift */, + A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */, + A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */, + A97506142B920DF200E86029 /* RecipeGenericViews.swift */, + A97506202B92104700E86029 /* RecipeMetadataSection.swift */, + ); + path = RecipeViewSections; + sourceTree = ""; + }; A977D0DC2B6002DA009783A9 /* Tabs */ = { isa = PBXGroup; children = ( @@ -359,7 +388,7 @@ A70171BD2AB4987900064C43 /* RecipeListView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171BF2AB498A900064C43 /* RecipeView.swift */, - A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, + A97506112B920D8100E86029 /* RecipeViewSections */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */, ); @@ -548,10 +577,13 @@ buildActionMask = 2147483647; files = ( A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */, + A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, + A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, + A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, @@ -561,21 +593,24 @@ A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, + A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */, A95364672B7E89F1001018B0 /* ReorderableForEach.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */, + A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */, A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */, A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */, A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, - A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, + A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A70171C02AB498A900064C43 /* RecipeView.swift in Sources */, A7F3F8EA2ACC221C0076C227 /* CategoryPickerViewOld.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, + A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, @@ -584,6 +619,7 @@ A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, + A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */, 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 cad8a5f..18d9e0e 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/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index 48bb229..64bae72 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -46,7 +46,7 @@ class ObservableRecipeDetail: ObservableObject { init(_ recipeDetail: RecipeDetail) { id = recipeDetail.id name = recipeDetail.name - keywords = recipeDetail.keywords.components(separatedBy: ",") + keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",") imageUrl = recipeDetail.imageUrl prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "") cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "") diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 5098800..9f106c7 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -1360,6 +1360,9 @@ } } } + }, + "Hours" : { + }, "If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : { "localizations" : { @@ -1857,6 +1860,9 @@ } } } + }, + "Minutes" : { + }, "Missing Name" : { "extractionState" : "stale", diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 22e7789..48b8444 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -30,6 +30,8 @@ struct RecipeView: View { Image(uiImage: recipeImage) .resizable() .scaledToFill() + .frame(maxHeight: imageHeight + 200) + .clipped() } } @@ -49,10 +51,11 @@ struct RecipeView: View { if viewModel.observableRecipeDetail.description != "" || viewModel.editMode { EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical) - .padding([.bottom, .horizontal]) + .fontWeight(.medium) + .padding(.horizontal) + .padding(.top, 2) } - // Recipe Body Section RecipeDurationSection(viewModel: viewModel) @@ -65,18 +68,19 @@ struct RecipeView: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) { RecipeIngredientSection(viewModel: viewModel) - .background(RoundedRectangle(cornerRadius: 20).foregroundStyle(.ultraThinMaterial)) - .padding(5) } if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) { RecipeInstructionSection(viewModel: viewModel) - .background(RoundedRectangle(cornerRadius: 20).foregroundStyle(.ultraThinMaterial)) - .padding(5) } if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) { RecipeToolSection(viewModel: viewModel) } RecipeNutritionSection(viewModel: viewModel) + } + + Divider() + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { if !viewModel.editMode { RecipeKeywordSection(viewModel: viewModel) } @@ -234,513 +238,3 @@ struct RecipeView: View { } -// MARK: - Recipe Metadata Section - -struct RecipeMetadataSection: View { - @EnvironmentObject var appState: AppState - @ObservedObject var viewModel: RecipeView.ViewModel - - @State var categories: [String] = [] - @State var keywords: [RecipeKeyword] = [] - @State var presentKeywordPopover: Bool = false - - var body: some View { - VStack(alignment: .leading) { - CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category") - - SecondaryLabel(text: "Keywords") - .padding() - - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in - Text(keyword) - } - } - }.padding(.horizontal) - - Button { - presentKeywordPopover.toggle() - } label: { - Text("Edit keywords") - Image(systemName: "chevron.right") - } - .padding(.horizontal) - - } - .task { - categories = appState.categories.map({ category in category.name }) - } - .sheet(isPresented: $presentKeywordPopover) { - KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) - } - } -} - - - -struct CategoryPickerView: View { - @Binding var items: [String] - @Binding var input: String - @State private var pickerChoice: String = "" - - var titleKey: LocalizedStringKey - - var body: some View { - VStack(alignment: .leading) { - SecondaryLabel(text: "Category") - .padding([.top, .horizontal]) - HStack { - TextField(titleKey, text: $input) - .lineLimit(1) - .textFieldStyle(.roundedBorder) - .padding() - .onSubmit { - pickerChoice = "" - } - - Picker("Select Item", selection: $pickerChoice) { - Text("").tag("") - ForEach(items, id: \.self) { item in - Text(item) - } - } - .pickerStyle(.menu) - .padding() - .onChange(of: pickerChoice) { newValue in - if pickerChoice != "" { - input = newValue - } - } - } - } - .onAppear { - pickerChoice = input - } - } -} - - - -// MARK: - Duration Section - -fileprivate struct RecipeDurationSection: View { - @EnvironmentObject var appState: AppState - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { - DurationView(time: viewModel.observableRecipeDetail.prepTime.displayString, title: LocalizedStringKey("Preparation")) - DurationView(time: viewModel.observableRecipeDetail.cookTime.displayString, title: LocalizedStringKey("Cooking")) - DurationView(time: viewModel.observableRecipeDetail.totalTime.displayString, title: LocalizedStringKey("Total time")) - } - - } -} - -struct DurationView: View { - @State var time: String - @State var title: LocalizedStringKey - - var body: some View { - VStack(alignment: .leading) { - HStack { - SecondaryLabel(text: title) - Spacer() - } - HStack { - Image(systemName: "clock") - .foregroundStyle(.secondary) - Text(time) - .lineLimit(1) - } - } - .padding() - } -} - - - -// MARK: - Nutrition Section - -fileprivate struct RecipeNutritionSection: View { - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) { - VStack(alignment: .leading) { - if viewModel.editMode { - ForEach(Nutrition.allCases, id: \.self) { nutrition in - HStack { - Text(nutrition.localizedDescription) - TextField("", text: binding(for: nutrition.dictKey), axis: .horizontal) - .textFieldStyle(.roundedBorder) - .lineLimit(1) - } - } - } else if !nutritionEmpty() { - VStack(alignment: .leading) { - ForEach(Nutrition.allCases, id: \.self) { nutrition in - if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey { - HStack(alignment: .top) { - Text("\(nutrition.localizedDescription): \(value)") - .multilineTextAlignment(.leading) - } - .padding(4) - } - } - } - } else { - Text(LocalizedStringKey("No nutritional information.")) - } - } - } title: { - HStack { - if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] { - SecondaryLabel(text: "Nutrition (\(servingSize))") - } else { - SecondaryLabel(text: LocalizedStringKey("Nutrition")) - } - Spacer() - } - } - .padding() - } - - func binding(for key: String) -> Binding { - Binding( - get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] }, - set: { viewModel.observableRecipeDetail.nutrition[key] = $0 } - ) - } - - func nutritionEmpty() -> Bool { - for nutrition in Nutrition.allCases { - if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] { - return false - } - } - return true - } -} - - -// MARK: - Keyword Section - -fileprivate struct RecipeKeywordSection: View { - @ObservedObject var viewModel: RecipeView.ViewModel - let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ] - - var body: some View { - CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) { - Group { - if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode { - RecipeListSection(list: viewModel.observableRecipeDetail.keywords) - } else { - Text(LocalizedStringKey("No keywords.")) - } - } - } title: { - HStack { - SecondaryLabel(text: LocalizedStringKey("Keywords")) - Spacer() - } - } - .padding() - } -} - - -// MARK: - More Information Section - -fileprivate struct MoreInformationSection: View { - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) { - VStack(alignment: .leading) { - Text("Created: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateCreated) ?? "")") - Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateModified) ?? "")") - if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) { - HStack() { - Text("URL:") - Link(destination: url) { - Text(viewModel.observableRecipeDetail.url) - } - } - } - } - .font(.caption) - .foregroundStyle(Color.secondary) - } title: { - HStack { - SecondaryLabel(text: "More information") - Spacer() - } - } - .padding() - } -} - -fileprivate struct RecipeListSection: View { - @State var list: [String] - - var body: some View { - VStack(alignment: .leading) { - ForEach(list, id: \.self) { item in - HStack(alignment: .top) { - Text("\u{2022}") - Text("\(item)") - .multilineTextAlignment(.leading) - } - .padding(4) - } - } - } -} - -fileprivate struct SecondaryLabel: View { - let text: LocalizedStringKey - var body: some View { - Text(text) - .foregroundColor(.secondary) - .font(.headline) - .padding(.vertical, 5) - } -} - - - - - -// MARK: - Ingredients Section - -fileprivate struct RecipeIngredientSection: View { - @EnvironmentObject var groceryList: GroceryList - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - VStack(alignment: .leading) { - HStack { - if viewModel.observableRecipeDetail.recipeYield == 0 { - SecondaryLabel(text: LocalizedStringKey("Ingredients")) - } else if viewModel.observableRecipeDetail.recipeYield == 1 { - SecondaryLabel(text: LocalizedStringKey("Ingredients per serving")) - } else { - SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings")) - } - Spacer() - Button { - withAnimation { - if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { - groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id) - } else { - groceryList.addItems( - ReorderableItem.items(viewModel.observableRecipeDetail.recipeIngredient), - toRecipe: viewModel.observableRecipeDetail.id, - recipeName: viewModel.observableRecipeDetail.name - ) - } - } - } label: { - if #available(iOS 17.0, *) { - Image(systemName: "storefront") - } else { - Image(systemName: "heart.text.square") - } - } - } - - EditableStringList(items: $viewModel.observableRecipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) { - ForEach(0.. - @State var recipeId: String - let addToGroceryListAction: () -> Void - @State var isSelected: Bool = false - - // Drag animation - @State private var dragOffset: CGFloat = 0 - @State private var animationStartOffset: CGFloat = 0 - let maxDragDistance = 50.0 - - var body: some View { - HStack(alignment: .top) { - if groceryList.containsItem(at: recipeId, item: ingredient.item) { - if #available(iOS 17.0, *) { - Image(systemName: "storefront") - .foregroundStyle(Color.green) - } else { - Image(systemName: "heart.text.square") - .foregroundStyle(Color.green) - } - - } else if isSelected { - Image(systemName: "checkmark.circle") - } else { - Image(systemName: "circle") - } - - Text("\(ingredient.item)") - .multilineTextAlignment(.leading) - .lineLimit(5) - Spacer() - } - .foregroundStyle(isSelected ? Color.secondary : Color.primary) - .onTapGesture { - isSelected.toggle() - } - .offset(x: dragOffset, y: 0) - .animation(.easeInOut, value: isSelected) - - .gesture( - DragGesture() - .onChanged { gesture in - // Update drag offset as the user drags - if animationStartOffset == 0 { - animationStartOffset = gesture.translation.width - } - let dragAmount = gesture.translation.width - let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset - self.dragOffset = max(0, offset) - } - .onEnded { gesture in - withAnimation { - if dragOffset > maxDragDistance * 0.3 { // Swipe threshold - if groceryList.containsItem(at: recipeId, item: ingredient.item) { - groceryList.deleteItem(ingredient.item, fromRecipe: recipeId) - } else { - addToGroceryListAction() - } - - } - // Animate back to original position - - self.dragOffset = 0 - self.animationStartOffset = 0 - } - } - ) - } -} - - -// MARK: - Instructions Section - -fileprivate struct RecipeInstructionSection: View { - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - VStack(alignment: .leading) { - HStack { - SecondaryLabel(text: LocalizedStringKey("Instructions")) - Spacer() - } - EditableStringList(items: $viewModel.observableRecipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) { - ForEach(0.. - @State var index: Int - @State var isSelected: Bool = false - - var body: some View { - HStack(alignment: .top) { - Text("\(index)") - .monospaced() - Text(instruction.item) - }.padding(4) - .foregroundStyle(isSelected ? Color.secondary : Color.primary) - .onTapGesture { - isSelected.toggle() - } - .animation(.easeInOut, value: isSelected) - } -} - - -// MARK: - Tool Section - -fileprivate struct RecipeToolSection: View { - @ObservedObject var viewModel: RecipeView.ViewModel - - var body: some View { - VStack(alignment: .leading) { - HStack { - SecondaryLabel(text: "Tools") - Spacer() - } - EditableStringList(items: $viewModel.observableRecipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) { - RecipeListSection(list: ReorderableItem.items(viewModel.observableRecipeDetail.tool)) - } - }.padding() - } -} - - -// MARK: - Generic Editable View Elements - -fileprivate struct EditableText: View { - @Binding var text: String - @Binding var editMode: Bool - @State var titleKey: LocalizedStringKey = "" - @State var lineLimit: ClosedRange = 0...1 - @State var axis: Axis = .horizontal - - var body: some View { - if editMode { - TextField(titleKey, text: $text, axis: axis) - .textFieldStyle(.roundedBorder) - .lineLimit(lineLimit) - } else { - Text(text) - } - } -} - - -fileprivate struct EditableStringList: View { - @Binding var items: [ReorderableItem] - @Binding var editMode: Bool - @State var titleKey: LocalizedStringKey = "" - @State var lineLimit: ClosedRange = 0...50 - @State var axis: Axis = .vertical - - var content: () -> Content - - var body: some View { - if editMode { - VStack { - ReorderableForEach(items: $items, defaultItem: ReorderableItem(item: "")) { ix, item in - TextField("", text: $items[ix].item, axis: axis) - .textFieldStyle(.roundedBorder) - .lineLimit(lineLimit) - } - } - .transition(.slide) - } else { - content() - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift new file mode 100644 index 0000000..30275f2 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift @@ -0,0 +1,122 @@ +// +// RecipeDurationSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Duration Section + +struct RecipeDurationSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + if viewModel.editMode { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { + EditableDurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation")) + EditableDurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking")) + EditableDurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time")) + } + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { + DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation")) + DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking")) + DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time")) + } + } + } +} + +fileprivate struct DurationView: View { + @ObservedObject var time: DurationComponents + @State var title: LocalizedStringKey + + var body: some View { + VStack(alignment: .leading) { + HStack { + SecondaryLabel(text: title) + Spacer() + } + HStack { + Image(systemName: "clock") + .foregroundStyle(.secondary) + Text(time.displayString) + .lineLimit(1) + } + } + .padding() + } +} + +fileprivate struct EditableDurationView: View { + @ObservedObject var time: DurationComponents + @State var title: LocalizedStringKey + @State var presentPopoverView: Bool = false + @State var hour: Int = 0 + @State var minute: Int = 0 + + var body: some View { + VStack(alignment: .leading) { + HStack { + SecondaryLabel(text: title) + Spacer() + } + Button { + presentPopoverView.toggle() + } label: { + HStack { + Image(systemName: "clock") + .foregroundStyle(.secondary) + Text(time.displayString) + .lineLimit(1) + } + } + } + .padding() + .popover(isPresented: $presentPopoverView) { + TimePickerPopoverView(selectedHour: $hour, selectedMinute: $minute) + } + .onChange(of: presentPopoverView) { presentPopover in + if !presentPopover { + time.hourComponent = String(hour) + time.minuteComponent = String(minute) + } + } + .onAppear { + minute = Int(time.minuteComponent) ?? 0 + hour = Int(time.hourComponent) ?? 0 + } + } +} + + +fileprivate struct TimePickerPopoverView: View { + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + + var body: some View { + HStack { + Picker(selection: $selectedHour, label: Text("Hours")) { + ForEach(0..<99, id: \.self) { hour in + Text("\(hour) h").tag(hour) + } + } + .pickerStyle(WheelPickerStyle()) + .frame(width: 100, height: 150) + .clipped() + + Picker(selection: $selectedMinute, label: Text("Minutes")) { + ForEach(0..<60, id: \.self) { minute in + Text("\(minute) min").tag(minute) + } + } + .pickerStyle(WheelPickerStyle()) + .frame(width: 100, height: 150) + .clipped() + } + .padding() + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift new file mode 100644 index 0000000..e19793c --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift @@ -0,0 +1,85 @@ +// +// RecipeSectionStructureViews.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Generic Editable View Elements + + +struct RecipeListSection: View { + @State var list: [String] + + var body: some View { + VStack(alignment: .leading) { + ForEach(list, id: \.self) { item in + HStack(alignment: .top) { + Text("\u{2022}") + Text("\(item)") + .multilineTextAlignment(.leading) + } + .padding(4) + } + } + } +} + + +struct SecondaryLabel: View { + let text: LocalizedStringKey + var body: some View { + Text(text) + .foregroundColor(.secondary) + .font(.headline) + .padding(.vertical, 5) + } +} + + +struct EditableText: View { + @Binding var text: String + @Binding var editMode: Bool + @State var titleKey: LocalizedStringKey = "" + @State var lineLimit: ClosedRange = 0...1 + @State var axis: Axis = .horizontal + + var body: some View { + if editMode { + TextField(titleKey, text: $text, axis: axis) + .textFieldStyle(.roundedBorder) + .lineLimit(lineLimit) + } else { + Text(text) + } + } +} + + +struct EditableStringList: View { + @Binding var items: [ReorderableItem] + @Binding var editMode: Bool + @State var titleKey: LocalizedStringKey = "" + @State var lineLimit: ClosedRange = 0...50 + @State var axis: Axis = .vertical + + var content: () -> Content + + var body: some View { + if editMode { + VStack { + ReorderableForEach(items: $items, defaultItem: ReorderableItem(item: "")) { ix, item in + TextField("", text: $items[ix].item, axis: axis) + .textFieldStyle(.roundedBorder) + .lineLimit(lineLimit) + } + } + .transition(.slide) + } else { + content() + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift new file mode 100644 index 0000000..a68f3d9 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -0,0 +1,137 @@ +// +// RecipeIngredientSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Ingredients Section + +struct RecipeIngredientSection: View { + @EnvironmentObject var groceryList: GroceryList + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + VStack(alignment: .leading) { + HStack { + if viewModel.observableRecipeDetail.recipeYield == 0 { + SecondaryLabel(text: LocalizedStringKey("Ingredients")) + } else if viewModel.observableRecipeDetail.recipeYield == 1 { + SecondaryLabel(text: LocalizedStringKey("Ingredients per serving")) + } else { + SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings")) + } + Spacer() + Button { + withAnimation { + if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { + groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id) + } else { + groceryList.addItems( + ReorderableItem.items(viewModel.observableRecipeDetail.recipeIngredient), + toRecipe: viewModel.observableRecipeDetail.id, + recipeName: viewModel.observableRecipeDetail.name + ) + } + } + } label: { + if #available(iOS 17.0, *) { + Image(systemName: "storefront") + } else { + Image(systemName: "heart.text.square") + } + } + } + + EditableStringList(items: $viewModel.observableRecipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) { + ForEach(0.. + @State var recipeId: String + let addToGroceryListAction: () -> Void + @State var isSelected: Bool = false + + // Drag animation + @State private var dragOffset: CGFloat = 0 + @State private var animationStartOffset: CGFloat = 0 + let maxDragDistance = 50.0 + + var body: some View { + HStack(alignment: .top) { + if groceryList.containsItem(at: recipeId, item: ingredient.item) { + if #available(iOS 17.0, *) { + Image(systemName: "storefront") + .foregroundStyle(Color.green) + } else { + Image(systemName: "heart.text.square") + .foregroundStyle(Color.green) + } + + } else if isSelected { + Image(systemName: "checkmark.circle") + } else { + Image(systemName: "circle") + } + + Text("\(ingredient.item)") + .multilineTextAlignment(.leading) + .lineLimit(5) + Spacer() + } + .foregroundStyle(isSelected ? Color.secondary : Color.primary) + .onTapGesture { + isSelected.toggle() + } + .offset(x: dragOffset, y: 0) + .animation(.easeInOut, value: isSelected) + + .gesture( + DragGesture() + .onChanged { gesture in + // Update drag offset as the user drags + if animationStartOffset == 0 { + animationStartOffset = gesture.translation.width + } + let dragAmount = gesture.translation.width + let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset + self.dragOffset = max(0, offset) + } + .onEnded { gesture in + withAnimation { + if dragOffset > maxDragDistance * 0.3 { // Swipe threshold + if groceryList.containsItem(at: recipeId, item: ingredient.item) { + groceryList.deleteItem(ingredient.item, fromRecipe: recipeId) + } else { + addToGroceryListAction() + } + + } + // Animate back to original position + + self.dragOffset = 0 + self.animationStartOffset = 0 + } + } + ) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift new file mode 100644 index 0000000..72ba0fa --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift @@ -0,0 +1,51 @@ +// +// RecipeInstructionSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Instructions Section + +struct RecipeInstructionSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + VStack(alignment: .leading) { + HStack { + SecondaryLabel(text: LocalizedStringKey("Instructions")) + Spacer() + } + EditableStringList(items: $viewModel.observableRecipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) { + ForEach(0.. + @State var index: Int + @State var isSelected: Bool = false + + var body: some View { + HStack(alignment: .top) { + Text("\(index)") + .monospaced() + Text(instruction.item) + }.padding(4) + .foregroundStyle(isSelected ? Color.secondary : Color.primary) + .onTapGesture { + isSelected.toggle() + } + .animation(.easeInOut, value: isSelected) + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/KeywordPickerView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift similarity index 71% rename from Nextcloud Cookbook iOS Client/Views/Recipes/KeywordPickerView.swift rename to Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift index 1f708d0..5206eba 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/KeywordPickerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift @@ -1,5 +1,5 @@ // -// KeywordPickerView.swift +// RecipeKeywordSection.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 03.10.23. @@ -8,7 +8,32 @@ import Foundation import SwiftUI +// MARK: - RecipeView Keyword Section +struct RecipeKeywordSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ] + + var body: some View { + CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) { + Group { + if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode { + RecipeListSection(list: viewModel.observableRecipeDetail.keywords) + } else { + Text(LocalizedStringKey("No keywords.")) + } + } + } title: { + HStack { + SecondaryLabel(text: LocalizedStringKey("Keywords")) + Spacer() + } + } + .padding() + } +} + +// MARK: - RecipeView Keyword Sheet View struct KeywordPickerView: View { @Environment(\.presentationMode) var presentationMode @@ -31,7 +56,7 @@ struct KeywordPickerView: View { } TextField(title, text: $searchText) .textFieldStyle(.roundedBorder) - .padding() + ScrollView { LazyVGrid(columns: columns, spacing: 5) { if searchText != "" { @@ -94,7 +119,7 @@ struct KeywordPickerView: View { } } .navigationTitle(title) - .padding(5) + .padding() } @@ -131,10 +156,36 @@ struct KeywordItemView: View { .padding() .background( RoundedRectangle(cornerRadius: 15) - .foregroundStyle(Color("backgroundHighlight")) + .foregroundStyle(.tertiary) ) .onTapGesture { tapped(keyword) } } } + + +// MARK: - Previews + +struct KeywordPickerView_Previews: PreviewProvider { + // Sample keywords for preview + static var sampleKeywords = [ + RecipeKeyword(name: "Vegan", recipe_count: 10), + RecipeKeyword(name: "Meat", recipe_count: 5), + RecipeKeyword(name: "Gluten-Free", recipe_count: 8), + RecipeKeyword(name: "Difficult", recipe_count: 7), + RecipeKeyword(name: "Chinese", recipe_count: 3), + RecipeKeyword(name: "European", recipe_count: 5), + RecipeKeyword(name: "Easy", recipe_count: 1) + + ] + + // Sample selection for preview + @State static var selection: [String] = ["Vegan"] + + static var previews: some View { + KeywordPickerView(title: "Select Keywords", searchSuggestions: sampleKeywords, selection: $selection) + + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift new file mode 100644 index 0000000..423549f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -0,0 +1,127 @@ +// +// RecipeMetadataSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - Recipe Metadata Section + +struct RecipeMetadataSection: View { + @EnvironmentObject var appState: AppState + @ObservedObject var viewModel: RecipeView.ViewModel + + @State var categories: [String] = [] + @State var keywords: [RecipeKeyword] = [] + @State var presentKeywordPopover: Bool = false + + var body: some View { + VStack(alignment: .leading) { + CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category") + + SecondaryLabel(text: "Keywords") + .padding() + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in + Text(keyword) + } + } + }.padding(.horizontal) + + Button { + presentKeywordPopover.toggle() + } label: { + Text("Edit keywords") + Image(systemName: "chevron.right") + } + .padding(.horizontal) + + } + .task { + categories = appState.categories.map({ category in category.name }) + } + .sheet(isPresented: $presentKeywordPopover) { + KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) + } + } +} + + + +struct CategoryPickerView: View { + @Binding var items: [String] + @Binding var input: String + @State private var pickerChoice: String = "" + + var titleKey: LocalizedStringKey + + var body: some View { + VStack(alignment: .leading) { + SecondaryLabel(text: "Category") + .padding([.top, .horizontal]) + HStack { + TextField(titleKey, text: $input) + .lineLimit(1) + .textFieldStyle(.roundedBorder) + .padding() + .onSubmit { + pickerChoice = "" + } + + Picker("Select Item", selection: $pickerChoice) { + Text("").tag("") + ForEach(items, id: \.self) { item in + Text(item) + } + } + .pickerStyle(.menu) + .padding() + .onChange(of: pickerChoice) { newValue in + if pickerChoice != "" { + input = newValue + } + } + } + } + .onAppear { + pickerChoice = input + } + } +} + + +// MARK: - RecipeView More Information Section + +struct MoreInformationSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) { + VStack(alignment: .leading) { + Text("Created: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateCreated) ?? "")") + Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateModified) ?? "")") + if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) { + HStack(alignment: .top) { + Text("URL:") + Link(destination: url) { + Text(viewModel.observableRecipeDetail.url) + } + } + } + } + .font(.caption) + .foregroundStyle(Color.secondary) + } title: { + HStack { + SecondaryLabel(text: "More information") + Spacer() + } + } + .padding() + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift new file mode 100644 index 0000000..03e02d0 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift @@ -0,0 +1,72 @@ +// +// RecipeNutritionSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Nutrition Section + +struct RecipeNutritionSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) { + VStack(alignment: .leading) { + if viewModel.editMode { + ForEach(Nutrition.allCases, id: \.self) { nutrition in + HStack { + Text(nutrition.localizedDescription) + TextField("", text: binding(for: nutrition.dictKey), axis: .horizontal) + .textFieldStyle(.roundedBorder) + .lineLimit(1) + } + } + } else if !nutritionEmpty() { + VStack(alignment: .leading) { + ForEach(Nutrition.allCases, id: \.self) { nutrition in + if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey { + HStack(alignment: .top) { + Text("\(nutrition.localizedDescription): \(value)") + .multilineTextAlignment(.leading) + } + .padding(4) + } + } + } + } else { + Text(LocalizedStringKey("No nutritional information.")) + } + } + } title: { + HStack { + if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] { + SecondaryLabel(text: "Nutrition (\(servingSize))") + } else { + SecondaryLabel(text: LocalizedStringKey("Nutrition")) + } + Spacer() + } + } + .padding() + } + + func binding(for key: String) -> Binding { + Binding( + get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] }, + set: { viewModel.observableRecipeDetail.nutrition[key] = $0 } + ) + } + + func nutritionEmpty() -> Bool { + for nutrition in Nutrition.allCases { + if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] { + return false + } + } + return true + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift new file mode 100644 index 0000000..848f45d --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift @@ -0,0 +1,27 @@ +// +// RecipeToolSection.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 01.03.24. +// + +import Foundation +import SwiftUI + +// MARK: - RecipeView Tool Section + +struct RecipeToolSection: View { + @ObservedObject var viewModel: RecipeView.ViewModel + + var body: some View { + VStack(alignment: .leading) { + HStack { + SecondaryLabel(text: "Tools") + Spacer() + } + EditableStringList(items: $viewModel.observableRecipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) { + RecipeListSection(list: ReorderableItem.items(viewModel.observableRecipeDetail.tool)) + } + }.padding() + } +}