Recipe edit UI polish

This commit is contained in:
VincentMeilinger
2024-03-05 08:39:06 +01:00
parent 597477544d
commit b5dbaad9aa
23 changed files with 337 additions and 341 deletions

View File

@@ -15,40 +15,28 @@ struct RecipeDurationSection: View {
@State var presentPopover: Bool = false
var body: some View {
if !viewModel.editMode {
VStack(alignment: .leading) {
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"))
}
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
Button {
presentPopover.toggle()
} label: {
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation"))
}
Button {
presentPopover.toggle()
} label: {
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
}
Button {
presentPopover.toggle()
} label: {
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
}
}
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
Button {
presentPopover.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
.padding(.top, 5)
}
.padding()
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
}
}
}
@@ -64,36 +52,64 @@ fileprivate struct DurationView: View {
}
HStack {
Image(systemName: "clock")
.bold()
.foregroundStyle(.secondary)
Text(time.displayString)
.lineLimit(1)
}
}
.padding()
}
}
fileprivate struct EditableDurationView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var prepTime: DurationComponents
@ObservedObject var cookTime: DurationComponents
@ObservedObject var totalTime: DurationComponents
var body: some View {
ScrollView {
VStack(alignment: .leading) {
VStack(alignment: .center) {
HStack {
SecondaryLabel(text: "Preparation")
Spacer()
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
TimePickerView(selectedHour: $prepTime.hourComponent, selectedMinute: $prepTime.minuteComponent)
SecondaryLabel(text: "Cooking")
HStack {
SecondaryLabel(text: "Cooking")
Spacer()
}
TimePickerView(selectedHour: $cookTime.hourComponent, selectedMinute: $cookTime.minuteComponent)
SecondaryLabel(text: "Total")
HStack {
SecondaryLabel(text: "Total time")
Spacer()
}
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
}
.padding()
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
}
}
private func updateTotalTime() {
var hourComponent = prepTime.hourComponent + cookTime.hourComponent
var minuteComponent = prepTime.minuteComponent + cookTime.minuteComponent
// Handle potential overflow from minutes to hours
if minuteComponent >= 60 {
hourComponent += minuteComponent / 60
minuteComponent %= 60
}
totalTime.hourComponent = hourComponent
totalTime.minuteComponent = minuteComponent
}
}

View File

@@ -59,27 +59,93 @@ struct EditableText: View {
}
struct EditableStringList<Content: View>: View {
@Binding var items: [ReorderableItem<String>]
@Binding var editMode: Bool
struct EditableListView: View {
@Binding var isPresented: Bool
@Binding var items: [String]
@State var title: LocalizedStringKey
@State var emptyListText: LocalizedStringKey
@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)
NavigationView {
ZStack {
List {
if items.isEmpty {
Text(emptyListText)
}
ForEach(items.indices, id: \.self) { ix in
TextField(titleKey, text: $items[ix], axis: axis)
.lineLimit(lineLimit)
}
.onDelete(perform: deleteItem)
.onMove(perform: moveItem)
}
VStack {
Spacer()
Button {
addItem()
} label: {
Image(systemName: "plus")
.foregroundStyle(.white)
.bold()
.padding()
.background(Circle().fill(Color.nextcloudBlue))
}
.padding()
}
}
.transition(.slide)
} else {
content()
.navigationBarTitle(title, displayMode: .inline)
.navigationBarItems(
trailing: Button(action: { isPresented = false }) {
Text("Done")
}
)
.environment(\.editMode, .constant(.active)) // Bind edit mode to your state variable
}
}
private func addItem() {
withAnimation {
items.append("")
}
}
private func deleteItem(at offsets: IndexSet) {
withAnimation {
items.remove(atOffsets: offsets)
}
}
private func moveItem(from source: IndexSet, to destination: Int) {
withAnimation {
items.move(fromOffsets: source, toOffset: destination)
}
}
}
// MARK: - Previews
struct EditableListView_Previews: PreviewProvider {
// Sample keywords for preview
@State static var sampleList: [String] = [
/*"3 Eggs",
"1 kg Potatos",
"3 g Sugar",
"1 ml Milk",
"Salt, Pepper"*/
]
static var previews: some View {
Color.white
.sheet(isPresented: .constant(true), content: {
EditableListView(isPresented: .constant(true), items: $sampleList, title: "Ingredient", emptyListText: "Add cooking steps for fellow chefs to follow.")
})
}
}

View File

@@ -31,7 +31,7 @@ struct RecipeIngredientSection: View {
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
} else {
groceryList.addItems(
ReorderableItem.items(viewModel.observableRecipeDetail.recipeIngredient),
viewModel.observableRecipeDetail.recipeIngredient,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
@@ -46,17 +46,23 @@ struct RecipeIngredientSection: View {
}
}
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)
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],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4)
}
if viewModel.editMode {
Button {
viewModel.presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding()
}
@@ -66,7 +72,7 @@ struct RecipeIngredientSection: View {
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: ReorderableItem<String>
@Binding var ingredient: String
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false
@@ -78,7 +84,7 @@ fileprivate struct IngredientListItem: View {
var body: some View {
HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient.item) {
if groceryList.containsItem(at: recipeId, item: ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
@@ -93,7 +99,7 @@ fileprivate struct IngredientListItem: View {
Image(systemName: "circle")
}
Text("\(ingredient.item)")
Text("\(ingredient)")
.multilineTextAlignment(.leading)
.lineLimit(5)
Spacer()
@@ -119,15 +125,13 @@ fileprivate struct IngredientListItem: View {
.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)
if groceryList.containsItem(at: recipeId, item: ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
}
}
// Animate back to original position
self.dragOffset = 0
self.animationStartOffset = 0
}

View File

@@ -19,19 +19,27 @@ struct RecipeInstructionSection: View {
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)
}
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
}.padding()
if viewModel.editMode {
Button {
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
fileprivate struct RecipeInstructionListItem: View {
@State var instruction: ReorderableItem<String>
@Binding var instruction: String
@State var index: Int
@State var isSelected: Bool = false
@@ -39,7 +47,7 @@ fileprivate struct RecipeInstructionListItem: View {
HStack(alignment: .top) {
Text("\(index)")
.monospaced()
Text(instruction.item)
Text(instruction)
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {

View File

@@ -19,7 +19,6 @@ struct RecipeMetadataSection: View {
appState.categories.map({ category in category.name })
}
@State var presentKeywordSheet: Bool = false
@State var presentServingsPopover: Bool = false
@State var presentCategoryPopover: Bool = false
@@ -27,7 +26,6 @@ struct RecipeMetadataSection: View {
var body: some View {
VStack(alignment: .leading) {
// Category
//CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category")
SecondaryLabel(text: "Category")
HStack {
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
@@ -42,6 +40,7 @@ struct RecipeMetadataSection: View {
}
.pickerStyle(.menu)
}
.padding(.bottom)
// Keywords
SecondaryLabel(text: "Keywords")
@@ -51,6 +50,8 @@ struct RecipeMetadataSection: View {
HStack {
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
Text(keyword)
.padding(5)
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
}
}
}
@@ -61,6 +62,7 @@ struct RecipeMetadataSection: View {
Text("Select Keywords")
Image(systemName: "chevron.right")
}
.padding(.bottom)
// Servings / Yield
VStack(alignment: .leading) {
@@ -77,7 +79,8 @@ struct RecipeMetadataSection: View {
}
}
.padding()
.background(Rectangle().foregroundStyle(Color.white.opacity(0.1)))
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.white.opacity(0.1)))
.padding()
.sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
@@ -104,47 +107,6 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
}
}
fileprivate 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

View File

@@ -19,9 +19,19 @@ struct RecipeToolSection: View {
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))
RecipeListSection(list: viewModel.observableRecipeDetail.tool)
if viewModel.editMode {
Button {
viewModel.presentToolEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding()
}
}