Updated Duration Picker during Recipe Editing

This commit is contained in:
VincentMeilinger
2024-03-01 15:32:04 +01:00
parent 744ea76a34
commit 650df2b67e
13 changed files with 733 additions and 525 deletions

View File

@@ -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 = "<group>"; };
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerViewOld.swift; sourceTree = "<group>"; };
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
A95364662B7E89F1001018B0 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = "<group>"; };
A97506122B920D9F00E86029 /* RecipeDurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDurationSection.swift; sourceTree = "<group>"; };
A97506142B920DF200E86029 /* RecipeGenericViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeGenericViews.swift; sourceTree = "<group>"; };
A97506182B920EC200E86029 /* RecipeIngredientSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeIngredientSection.swift; sourceTree = "<group>"; };
A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeNutritionSection.swift; sourceTree = "<group>"; };
A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeInstructionSection.swift; sourceTree = "<group>"; };
A975061E2B920FFC00E86029 /* RecipeToolSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeToolSection.swift; sourceTree = "<group>"; };
A97506202B92104700E86029 /* RecipeMetadataSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeMetadataSection.swift; sourceTree = "<group>"; };
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
@@ -333,6 +347,21 @@
path = Onboarding;
sourceTree = "<group>";
};
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 = "<group>";
};
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 */,

View File

@@ -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 ?? "")

View File

@@ -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",

View File

@@ -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<String> {
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..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(ingredient: viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) {
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix].item,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4)
}
}
}.padding()
}
}
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: ReorderableItem<String>
@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..<viewModel.observableRecipeDetail.recipeInstructions.count, id: \.self) { ix in
RecipeInstructionListItem(instruction: viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
}
}.padding()
}
}
fileprivate struct RecipeInstructionListItem: View {
@State var instruction: ReorderableItem<String>
@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<Int> = 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<Content: View>: View {
@Binding var items: [ReorderableItem<String>]
@Binding var editMode: Bool
@State var titleKey: LocalizedStringKey = ""
@State var lineLimit: ClosedRange<Int> = 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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Int> = 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<Content: View>: View {
@Binding var items: [ReorderableItem<String>]
@Binding var editMode: Bool
@State var titleKey: LocalizedStringKey = ""
@State var lineLimit: ClosedRange<Int> = 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()
}
}
}

View File

@@ -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..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(ingredient: viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) {
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix].item,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4)
}
}
}.padding()
}
}
// MARK: - RecipeIngredientSection List Item
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: ReorderableItem<String>
@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
}
}
)
}
}

View File

@@ -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..<viewModel.observableRecipeDetail.recipeInstructions.count, id: \.self) { ix in
RecipeInstructionListItem(instruction: viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
}
}.padding()
}
}
fileprivate struct RecipeInstructionListItem: View {
@State var instruction: ReorderableItem<String>
@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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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<String> {
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
}
}

View File

@@ -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()
}
}