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