Redesign recipe creation and edit view with Form-based layout and URL import

Replace the single "+" button with a 2-option menu (Create New Recipe / Import
from URL) across RecipeTabView, RecipeListView, and AllRecipesListView. Add
ImportURLSheet for server-side recipe import with loading and error states.

Completely redesign edit mode to use a native Form layout with inline editing
for all sections (metadata, duration, ingredients, instructions, tools,
nutrition) instead of the previous sheet-based EditableListView approach. Move
delete action from edit toolbar to view mode context menu. Add recipe image
display to the edit form.

Refactor RecipeListView and AllRecipesListView to use closure-based callbacks
instead of Binding<Bool> for the create/import actions. Add preloadedRecipeDetail
support to RecipeView.ViewModel for imported recipes.

Add DE/ES/FR translations for all new UI strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 03:29:20 +01:00
parent 98c82dc537
commit 1536174586
13 changed files with 1085 additions and 444 deletions

View File

@@ -8,7 +8,8 @@ import SwiftUI
struct AllRecipesListView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager
@Binding var showEditView: Bool
var onCreateNew: () -> Void
var onImportFromURL: () -> Void
@State private var allRecipes: [Recipe] = []
@State private var searchText: String = ""
@@ -67,8 +68,17 @@ struct AllRecipesListView: View {
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showEditView = true
Menu {
Button {
onCreateNew()
} label: {
Label("Create New Recipe", systemImage: "square.and.pencil")
}
Button {
onImportFromURL()
} label: {
Label("Import from URL", systemImage: "link")
}
} label: {
Image(systemName: "plus.circle.fill")
}

View File

@@ -0,0 +1,81 @@
//
// ImportURLSheet.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct ImportURLSheet: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
var onImport: (RecipeDetail) -> Void
@State private var url: String = ""
@State private var isLoading: Bool = false
@State private var presentAlert: Bool = false
@State private var alertMessage: String = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField("Recipe URL", text: $url)
.keyboardType(.URL)
.textContentType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
} footer: {
Text("Paste the URL of a recipe you would like to import.")
}
Section {
Button {
Task {
await importRecipe()
}
} label: {
HStack {
Spacer()
if isLoading {
ProgressView()
} else {
Text("Import")
}
Spacer()
}
}
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
}
}
.navigationTitle("Import Recipe")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.alert("Import Failed", isPresented: $presentAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMessage)
}
}
}
private func importRecipe() async {
isLoading = true
let (recipeDetail, error) = await appState.importRecipe(url: url)
isLoading = false
if let recipeDetail {
dismiss()
onImport(recipeDetail)
} else {
alertMessage = error?.localizedDescription ?? String(localized: "The recipe could not be imported. Please check the URL and try again.")
presentAlert = true
}
}
}

View File

@@ -15,7 +15,8 @@ struct RecipeListView: View {
@EnvironmentObject var groceryList: GroceryListManager
@State var categoryName: String
@State var searchText: String = ""
@Binding var showEditView: Bool
var onCreateNew: () -> Void
var onImportFromURL: () -> Void
@State var selectedRecipe: Recipe? = nil
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
@@ -80,8 +81,17 @@ struct RecipeListView: View {
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showEditView = true
Menu {
Button {
onCreateNew()
} label: {
Label("Create New Recipe", systemImage: "square.and.pencil")
}
Button {
onImportFromURL()
} label: {
Label("Import from URL", systemImage: "link")
}
} label: {
Image(systemName: "plus.circle.fill")
}

View File

@@ -28,97 +28,16 @@ struct RecipeView: View {
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
Group {
if viewModel.editMode {
recipeEditForm
} else {
recipeViewContent
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : ""))
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
@@ -127,37 +46,9 @@ struct RecipeView: View {
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
.sheet(isPresented: $viewModel.presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if !viewModel.newRecipe {
@@ -176,17 +67,30 @@ struct RecipeView: View {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
}
viewModel.isDownloaded = viewModel.recipe.storedLocally
// Load recipe image
viewModel.recipeImage = await appState.getImage(
id: viewModel.recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
)
} else {
// Prepare view for a new recipe
viewModel.setupView(recipeDetail: RecipeDetail())
if let preloaded = viewModel.preloadedRecipeDetail {
viewModel.setupView(recipeDetail: preloaded)
viewModel.preloadedRecipeDetail = nil
// Load image if the import created a recipe with a valid id
if let recipeId = Int(preloaded.id), recipeId > 0 {
viewModel.recipeImage = await appState.getImage(
id: recipeId,
size: .FULL,
fetchMode: .onlyServer
)
}
} else {
viewModel.setupView(recipeDetail: RecipeDetail())
}
viewModel.editMode = true
viewModel.isDownloaded = false
}
@@ -230,6 +134,127 @@ struct RecipeView: View {
}
}
}
// MARK: - View Mode
private var recipeViewContent: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
HStack {
Text(viewModel.observableRecipeDetail.name)
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" {
Text(viewModel.observableRecipeDetail.description)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if !viewModel.observableRecipeDetail.recipeIngredient.isEmpty {
RecipeIngredientSection(viewModel: viewModel)
}
if !viewModel.observableRecipeDetail.recipeInstructions.isEmpty {
RecipeInstructionSection(viewModel: viewModel)
}
if !viewModel.observableRecipeDetail.tool.isEmpty {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
}
// MARK: - Edit Mode Form
private var recipeEditForm: some View {
Form {
if let recipeImage = viewModel.recipeImage {
Section {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: 200)
.clipped()
.listRowInsets(EdgeInsets())
}
}
Section {
TextField("Recipe Name", text: $viewModel.observableRecipeDetail.name)
.font(.headline)
TextField("Description", text: $viewModel.observableRecipeDetail.description, axis: .vertical)
.lineLimit(1...5)
}
RecipeEditMetadataSection(viewModel: viewModel)
.environmentObject(appState)
RecipeEditDurationSection(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
RecipeEditIngredientSection(ingredients: $viewModel.observableRecipeDetail.recipeIngredient)
RecipeEditInstructionSection(instructions: $viewModel.observableRecipeDetail.recipeInstructions)
RecipeEditToolSection(tools: $viewModel.observableRecipeDetail.tool)
RecipeEditNutritionSection(nutrition: $viewModel.observableRecipeDetail.nutrition)
}
}
// MARK: - RecipeView ViewModel
@@ -241,16 +266,14 @@ struct RecipeView: View {
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil
@Published var importUrl: String = ""
@Published var presentShareSheet: Bool = false
@Published var presentInstructionEditView: Bool = false
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
@Published var presentKeywordSheet: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
var newRecipe: Bool = false
var preloadedRecipeDetail: RecipeDetail? = nil
// Alerts
@Published var presentAlert = false
@@ -290,26 +313,6 @@ struct RecipeView: View {
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
// Fetch the image from the server if the import created a recipe with a valid id
if let recipeId = Int(scrapedRecipe.id), recipeId > 0 {
viewModel.recipeImage = await appState.getImage(
id: recipeId,
size: .FULL,
fetchMode: .onlyServer
)
}
return nil
}
return error
}
}
// MARK: - Tool Bar
@@ -321,32 +324,15 @@ struct RecipeViewToolBar: ToolbarContent {
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
@@ -375,10 +361,23 @@ struct RecipeViewToolBar: ToolbarContent {
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}

View File

@@ -21,23 +21,70 @@ struct RecipeDurationSection: View {
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
}
if viewModel.editMode {
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
)
}
}
// MARK: - Recipe Edit Duration Section (Form-based)
struct RecipeEditDurationSection: View {
@ObservedObject var prepTime: DurationComponents
@ObservedObject var cookTime: DurationComponents
@ObservedObject var totalTime: DurationComponents
var body: some View {
Section("Duration") {
DurationPickerRow(label: "Preparation", time: prepTime)
DurationPickerRow(label: "Cooking", time: cookTime)
DurationPickerRow(label: "Total time", time: totalTime)
}
.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
if minuteComponent >= 60 {
hourComponent += minuteComponent / 60
minuteComponent %= 60
}
totalTime.hourComponent = hourComponent
totalTime.minuteComponent = minuteComponent
}
}
fileprivate struct DurationPickerRow: View {
let label: LocalizedStringKey
@ObservedObject var time: DurationComponents
var body: some View {
HStack {
Text(label)
Spacer()
Menu {
ForEach(0..<25, id: \.self) { hour in
Button("\(hour) h") {
time.hourComponent = hour
}
}
} label: {
Text("\(time.hourComponent) h")
.monospacedDigit()
}
Menu {
ForEach(0..<60, id: \.self) { minute in
Button("\(minute) min") {
time.minuteComponent = minute
}
}
} label: {
Text("\(time.minuteComponent) min")
.monospacedDigit()
}
}
}
}

View File

@@ -69,20 +69,44 @@ struct RecipeIngredientSection: View {
}.padding(.top)
}
if viewModel.editMode {
Button {
viewModel.presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
}
}
// MARK: - Recipe Edit Ingredient Section (Form-based)
struct RecipeEditIngredientSection: View {
@Binding var ingredients: [String]
var body: some View {
Section {
ForEach(ingredients.indices, id: \.self) { index in
HStack {
TextField("Ingredient", text: $ingredients[index])
Image(systemName: "line.3.horizontal")
.foregroundStyle(.tertiary)
}
}
.onDelete { indexSet in
ingredients.remove(atOffsets: indexSet)
}
.onMove { from, to in
ingredients.move(fromOffsets: from, toOffset: to)
}
Button {
ingredients.append("")
} label: {
Label("Add Ingredient", systemImage: "plus.circle.fill")
}
} header: {
Text("Ingredients")
}
}
}
// MARK: - RecipeIngredientSection List Item
fileprivate struct IngredientListItem: View {

View File

@@ -22,14 +22,6 @@ struct RecipeInstructionSection: View {
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
if viewModel.editMode {
Button {
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}
.padding()
@@ -37,6 +29,44 @@ struct RecipeInstructionSection: View {
}
// MARK: - Recipe Edit Instruction Section (Form-based)
struct RecipeEditInstructionSection: View {
@Binding var instructions: [String]
var body: some View {
Section {
ForEach(instructions.indices, id: \.self) { index in
HStack(alignment: .top) {
Text("\(index + 1).")
.foregroundStyle(.secondary)
.monospacedDigit()
TextField("Step \(index + 1)", text: $instructions[index], axis: .vertical)
.lineLimit(1...10)
Image(systemName: "line.3.horizontal")
.foregroundStyle(.tertiary)
.padding(.top, 4)
}
}
.onDelete { indexSet in
instructions.remove(atOffsets: indexSet)
}
.onMove { from, to in
instructions.move(fromOffsets: from, toOffset: to)
}
Button {
instructions.append("")
} label: {
Label("Add Step", systemImage: "plus.circle.fill")
}
} header: {
Text("Instructions")
}
}
}
fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String

View File

@@ -87,6 +87,52 @@ struct RecipeMetadataSection: View {
}
}
// MARK: - Recipe Edit Metadata Section (Form-based)
struct RecipeEditMetadataSection: View {
@EnvironmentObject var appState: AppState
@ObservedObject var viewModel: RecipeView.ViewModel
var categories: [String] {
appState.categories.map { $0.name }
}
var body: some View {
Section("Details") {
Picker("Category", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("None").tag("")
ForEach(categories, id: \.self) { item in
Text(item).tag(item)
}
}
.pickerStyle(.menu)
Stepper("Servings: \(viewModel.observableRecipeDetail.recipeYield)", value: $viewModel.observableRecipeDetail.recipeYield, in: 1...99)
Button {
viewModel.presentKeywordSheet = true
} label: {
HStack {
Text("Keywords")
.foregroundStyle(.primary)
Spacer()
if viewModel.observableRecipeDetail.keywords.isEmpty {
Text("None")
.foregroundStyle(.secondary)
} else {
Text(viewModel.observableRecipeDetail.keywords.joined(separator: ", "))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
@Binding var isPresented: Bool
@Binding var value: Item

View File

@@ -63,10 +63,42 @@ struct RecipeNutritionSection: View {
func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
if let _ = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
return false
}
}
return true
}
}
// MARK: - Recipe Edit Nutrition Section (Form-based)
struct RecipeEditNutritionSection: View {
@Binding var nutrition: [String: String]
@State private var isExpanded: Bool = false
var body: some View {
Section {
DisclosureGroup("Nutrition Information", isExpanded: $isExpanded) {
ForEach(Nutrition.allCases, id: \.self) { item in
HStack {
Text(item.localizedDescription)
.lineLimit(1)
Spacer()
TextField("", text: nutritionBinding(for: item.dictKey))
.multilineTextAlignment(.trailing)
.frame(maxWidth: 150)
}
}
}
}
}
private func nutritionBinding(for key: String) -> Binding<String> {
Binding(
get: { nutrition[key, default: ""] },
set: { nutrition[key] = $0 }
)
}
}

View File

@@ -21,17 +21,40 @@ struct RecipeToolSection: View {
}
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
if viewModel.editMode {
Button {
viewModel.presentToolEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding()
}
}
// MARK: - Recipe Edit Tool Section (Form-based)
struct RecipeEditToolSection: View {
@Binding var tools: [String]
var body: some View {
Section {
ForEach(tools.indices, id: \.self) { index in
HStack {
TextField("Tool", text: $tools[index])
Image(systemName: "line.3.horizontal")
.foregroundStyle(.tertiary)
}
}
.onDelete { indexSet in
tools.remove(atOffsets: indexSet)
}
.onMove { from, to in
tools.move(fromOffsets: from, toOffset: to)
}
Button {
tools.append("")
} label: {
Label("Add Tool", systemImage: "plus.circle.fill")
}
} header: {
Text("Tools")
}
}
}