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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user