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

@@ -66,6 +66,7 @@
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; }; A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; }; A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; };
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F0AB012D0B000100000001 /* ImportURLSheet.swift */; };
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; }; A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; }; D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; };
@@ -153,6 +154,7 @@
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; }; A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; }; A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; }; A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; };
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportURLSheet.swift; sourceTree = "<group>"; };
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; }; A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; }; A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
@@ -406,6 +408,7 @@
A97506112B920D8100E86029 /* RecipeViewSections */, A97506112B920D8100E86029 /* RecipeViewSections */,
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
A97B4D342B80B82A00EC1A88 /* ShareView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
); );
path = Recipes; path = Recipes;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -578,7 +581,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */, C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */,
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */, A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,

View File

@@ -317,7 +317,6 @@
} }
}, },
"%lld." : { "%lld." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -518,6 +517,7 @@
} }
}, },
"Add cooking steps for fellow chefs to follow." : { "Add cooking steps for fellow chefs to follow." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -561,6 +561,28 @@
} }
} }
}, },
"Add Ingredient" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zutat hinzufügen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añadir ingrediente"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter un ingrédient"
}
}
}
},
"Add new recipe" : { "Add new recipe" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -583,6 +605,50 @@
} }
} }
}, },
"Add Step" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schritt hinzufügen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añadir paso"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter une étape"
}
}
}
},
"Add Tool" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Werkzeug hinzufügen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añadir utensilio"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajouter un ustensile"
}
}
}
},
"All Recipes" : { "All Recipes" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -694,6 +760,28 @@
} }
} }
}, },
"Apple Reminders" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple Erinnerungen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recordatorios de Apple"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rappels Apple"
}
}
}
},
"Bad URL" : { "Bad URL" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -1139,6 +1227,28 @@
} }
} }
}, },
"Create New Recipe" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Neues Rezept erstellen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Crear nueva receta"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Créer une nouvelle recette"
}
}
}
},
"Created: %@" : { "Created: %@" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1360,6 +1470,28 @@
} }
} }
}, },
"Details" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Details"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Detalles"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Détails"
}
}
}
},
"Done" : { "Done" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1460,6 +1592,28 @@
} }
} }
}, },
"Duration" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dauer"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duración"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Durée"
}
}
}
},
"e.g.: example.com" : { "e.g.: example.com" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1504,6 +1658,28 @@
} }
} }
}, },
"Edit Recipe" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezept bearbeiten"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Editar receta"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Modifier la recette"
}
}
}
},
"Enter a recipe name or keyword to get started." : { "Enter a recipe name or keyword to get started." : {
"comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.", "comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -1738,6 +1914,72 @@
} }
} }
}, },
"Grant Reminders Access" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zugriff auf Erinnerungen erlauben"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permitir acceso a Recordatorios"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Autoriser l'accès aux Rappels"
}
}
}
},
"Grocery items are stored locally on this device." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsartikel werden lokal auf diesem Gerät gespeichert."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los artículos de la lista se almacenan localmente en este dispositivo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les articles sont stockés localement sur cet appareil."
}
}
}
},
"Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsartikel werden in Apple Erinnerungen gespeichert. Der Einkaufslisten-Tab wird ausgeblendet, da du die Artikel direkt in der Erinnerungen-App verwalten kannst."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los artículos de la lista se guardarán en Recordatorios de Apple. La pestaña de lista de compras se ocultará, ya que puedes gestionar los artículos directamente en la app Recordatorios."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les articles seront enregistrés dans Rappels Apple. L'onglet Liste de courses sera masqué car vous pouvez gérer les articles directement dans l'app Rappels."
}
}
}
},
"Grocery List" : { "Grocery List" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1760,6 +2002,28 @@
} }
} }
}, },
"Grocery list storage" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsliste Speicherort"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Almacenamiento de la lista de compras"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stockage de la liste de courses"
}
}
}
},
"Hours" : { "Hours" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1892,6 +2156,50 @@
} }
} }
}, },
"Import Failed" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Import fehlgeschlagen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Error de importación"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Échec de l'importation"
}
}
}
},
"Import from URL" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Von URL importieren"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Importar desde URL"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Importer depuis une URL"
}
}
}
},
"Import Recipe" : { "Import Recipe" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -1914,6 +2222,28 @@
} }
} }
}, },
"In-App" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In der App"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "En la app"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dans l'app"
}
}
}
},
"Ingredient" : { "Ingredient" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2005,6 +2335,7 @@
} }
}, },
"Instruction" : { "Instruction" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2159,6 +2490,7 @@
} }
}, },
"List your tools here. 🍴" : { "List your tools here. 🍴" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2589,16 +2921,6 @@
} }
} }
}, },
"No recipes in this cookbook" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine Rezepte in dieser Kategorie"
}
}
}
},
"No recipes found" : { "No recipes found" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2621,6 +2943,16 @@
} }
} }
}, },
"No recipes in this cookbook" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine Rezepte in dieser Kategorie"
}
}
}
},
"No results found" : { "No results found" : {
"comment" : "A message indicating that no recipes were found for the current search query.", "comment" : "A message indicating that no recipes were found for the current search query.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -2721,6 +3053,28 @@
} }
} }
}, },
"Nutrition Information" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nährwertinformationen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Información nutricional"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Informations nutritionnelles"
}
}
}
},
"Offline recipes" : { "Offline recipes" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2765,6 +3119,10 @@
} }
} }
}, },
"OK" : {
"comment" : "The text for an OK button.",
"isCommentAutoGenerated" : true
},
"On server" : { "On server" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -2776,6 +3134,28 @@
} }
} }
}, },
"Open Settings" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einstellungen öffnen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abrir Ajustes"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ouvrir les Réglages"
}
}
}
},
"Other" : { "Other" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2822,6 +3202,7 @@
} }
}, },
"Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : { "Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2843,6 +3224,28 @@
} }
} }
}, },
"Paste the URL of a recipe you would like to import." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Füge die URL eines Rezepts ein, das du importieren möchtest."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pega la URL de una receta que deseas importar."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Collez l'URL d'une recette que vous souhaitez importer."
}
}
}
},
"PDF Document" : { "PDF Document" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3118,6 +3521,28 @@
} }
} }
}, },
"Recipe URL" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezept-URL"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL de la receta"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL de la recette"
}
}
}
},
"Recipes" : { "Recipes" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3196,6 +3621,50 @@
} }
} }
}, },
"Reminders access was denied. Please enable it in System Settings to use this feature." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Der Zugriff auf Erinnerungen wurde verweigert. Bitte aktiviere ihn in den Systemeinstellungen, um diese Funktion zu nutzen."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se denegó el acceso a Recordatorios. Actívalo en los Ajustes del sistema para usar esta función."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'accès aux Rappels a été refusé. Veuillez l'activer dans les Réglages système pour utiliser cette fonctionnalité."
}
}
}
},
"Reminders list" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erinnerungsliste"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lista de recordatorios"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Liste de rappels"
}
}
}
},
"Same as Device" : { "Same as Device" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3465,6 +3934,10 @@
} }
} }
}, },
"Servings: %lld" : {
"comment" : "A stepper that allows the user to select the number of servings for a recipe. The first argument is a label describing the number of servings. The second argument is a binding to the number of servings, which is",
"isCommentAutoGenerated" : true
},
"Settings" : { "Settings" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3622,6 +4095,7 @@
} }
}, },
"Start by adding your first ingredient! 🥬" : { "Start by adding your first ingredient! 🥬" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3643,6 +4117,10 @@
} }
} }
}, },
"Step %lld" : {
"comment" : "A text field where the user can enter a recipe instruction.",
"isCommentAutoGenerated" : true
},
"Store recipe images locally" : { "Store recipe images locally" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3842,6 +4320,28 @@
} }
} }
}, },
"The recipe could not be imported. Please check the URL and try again." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Das Rezept konnte nicht importiert werden. Bitte überprüfe die URL und versuche es erneut."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No se pudo importar la receta. Por favor, verifica la URL e inténtalo de nuevo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "La recette n'a pas pu être importée. Veuillez vérifier l'URL et réessayer."
}
}
}
},
"The recipe has no image whose MIME type matches the Accept header" : { "The recipe has no image whose MIME type matches the Accept header" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -4371,6 +4871,7 @@
} }
}, },
"URL (e.g. example.com/recipe)" : { "URL (e.g. example.com/recipe)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4523,204 +5024,6 @@
} }
} }
} }
},
"In-App" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In der App"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "En la app"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dans l'app"
}
}
}
},
"Apple Reminders" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple Erinnerungen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recordatorios de Apple"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rappels Apple"
}
}
}
},
"Grocery list storage" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsliste Speicherort"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Almacenamiento de la lista de compras"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stockage de la liste de courses"
}
}
}
},
"Reminders list" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erinnerungsliste"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lista de recordatorios"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Liste de rappels"
}
}
}
},
"Grant Reminders Access" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zugriff auf Erinnerungen erlauben"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permitir acceso a Recordatorios"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Autoriser l'accès aux Rappels"
}
}
}
},
"Open Settings" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einstellungen öffnen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abrir Ajustes"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ouvrir les Réglages"
}
}
}
},
"Reminders access was denied. Please enable it in System Settings to use this feature." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Der Zugriff auf Erinnerungen wurde verweigert. Bitte aktiviere ihn in den Systemeinstellungen, um diese Funktion zu nutzen."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se denegó el acceso a Recordatorios. Actívalo en los Ajustes del sistema para usar esta función."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'accès aux Rappels a été refusé. Veuillez l'activer dans les Réglages système pour utiliser cette fonctionnalité."
}
}
}
},
"Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsartikel werden in Apple Erinnerungen gespeichert. Der Einkaufslisten-Tab wird ausgeblendet, da du die Artikel direkt in der Erinnerungen-App verwalten kannst."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los artículos de la lista se guardarán en Recordatorios de Apple. La pestaña de lista de compras se ocultará, ya que puedes gestionar los artículos directamente en la app Recordatorios."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les articles seront enregistrés dans Rappels Apple. L'onglet Liste de courses sera masqué car vous pouvez gérer les articles directement dans l'app Rappels."
}
}
}
},
"Grocery items are stored locally on this device." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einkaufsartikel werden lokal auf diesem Gerät gespeichert."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los artículos de la lista se almacenan localmente en este dispositivo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les articles sont stockés localement sur cet appareil."
}
}
}
} }
}, },
"version" : "1.1" "version" : "1.1"

View File

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

View File

@@ -28,97 +28,16 @@ struct RecipeView: View {
} }
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { Group {
VStack(spacing: 0) { if viewModel.editMode {
ParallaxHeader( recipeEditForm
coordinateSpace: CoordinateSpaces.scrollView, } else {
defaultHeight: imageHeight recipeViewContent
) {
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)))
} }
} }
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar) .toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline) .navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : ""))
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar { .toolbar {
RecipeViewToolBar(viewModel: viewModel) RecipeViewToolBar(viewModel: viewModel)
} }
@@ -127,37 +46,9 @@ struct RecipeView: View {
recipeImage: viewModel.recipeImage, recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet) presentShareSheet: $viewModel.presentShareSheet)
} }
.sheet(isPresented: $viewModel.presentInstructionEditView) { .sheet(isPresented: $viewModel.presentKeywordSheet) {
EditableListView( KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
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.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 { .task {
// Load recipe detail // Load recipe detail
if !viewModel.newRecipe { if !viewModel.newRecipe {
@@ -176,17 +67,30 @@ struct RecipeView: View {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id) viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
} }
viewModel.isDownloaded = viewModel.recipe.storedLocally viewModel.isDownloaded = viewModel.recipe.storedLocally
// Load recipe image // Load recipe image
viewModel.recipeImage = await appState.getImage( viewModel.recipeImage = await appState.getImage(
id: viewModel.recipe.recipe_id, id: viewModel.recipe.recipe_id,
size: .FULL, size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
) )
} else { } else {
// Prepare view for a new recipe // 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.editMode = true
viewModel.isDownloaded = false 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 // MARK: - RecipeView ViewModel
@@ -241,16 +266,14 @@ struct RecipeView: View {
@Published var editMode: Bool = false @Published var editMode: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
@Published var importUrl: String = ""
@Published var presentShareSheet: Bool = false @Published var presentShareSheet: Bool = false
@Published var presentInstructionEditView: Bool = false @Published var presentKeywordSheet: Bool = false
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe var recipe: Recipe
var sharedURL: URL? = nil var sharedURL: URL? = nil
var newRecipe: Bool = false var newRecipe: Bool = false
var preloadedRecipeDetail: RecipeDetail? = nil
// Alerts // Alerts
@Published var presentAlert = false @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 // MARK: - Tool Bar
@@ -321,32 +324,15 @@ struct RecipeViewToolBar: ToolbarContent {
var body: some ToolbarContent { var body: some ToolbarContent {
if viewModel.editMode { if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { Button("Cancel") {
viewModel.editMode = false viewModel.editMode = false
if viewModel.newRecipe { if viewModel.newRecipe {
dismiss() 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) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
Task { Task {
@@ -375,10 +361,23 @@ struct RecipeViewToolBar: ToolbarContent {
} label: { } label: {
Label("Share Recipe", systemImage: "square.and.arrow.up") 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: { } label: {
Image(systemName: "ellipsis.circle") 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.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time")) DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
} }
if viewModel.editMode {
Button {
presentPopover.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
.padding(.top, 5)
}
} }
.padding() .padding()
.popover(isPresented: $presentPopover) { }
EditableDurationView( }
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime, // MARK: - Recipe Edit Duration Section (Form-based)
totalTime: viewModel.observableRecipeDetail.totalTime
) 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) }.padding(.top)
} }
if viewModel.editMode {
Button {
viewModel.presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
} }
.padding() .padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier) .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 // MARK: - RecipeIngredientSection List Item
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {

View File

@@ -22,14 +22,6 @@ struct RecipeInstructionSection: View {
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1) RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
} }
if viewModel.editMode {
Button {
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
} }
.padding() .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 { fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String @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 { fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@Binding var value: Item @Binding var value: Item

View File

@@ -63,10 +63,42 @@ struct RecipeNutritionSection: View {
func nutritionEmpty() -> Bool { func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases { for nutrition in Nutrition.allCases {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] { if let _ = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
return false return false
} }
} }
return true 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) RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
if viewModel.editMode {
Button {
viewModel.presentToolEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding() }.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")
}
}
} }

View File

@@ -18,13 +18,6 @@ struct RecipeTabView: View {
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
private var showEditViewBinding: Binding<Bool> {
Binding(
get: { false },
set: { if $0 { viewModel.navigateToNewRecipe() } }
)
}
private var nonEmptyCategories: [Category] { private var nonEmptyCategories: [Category] {
appState.categories.filter { $0.recipe_count > 0 } appState.categories.filter { $0.recipe_count > 0 }
} }
@@ -112,21 +105,34 @@ struct RecipeTabView: View {
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
case .newRecipe: case .newRecipe:
RecipeView(viewModel: RecipeView.ViewModel()) RecipeView(viewModel: {
.environmentObject(appState) let vm = RecipeView.ViewModel()
.environmentObject(groceryList) if let imported = viewModel.importedRecipeDetail {
vm.preloadedRecipeDetail = imported
}
return vm
}())
.environmentObject(appState)
.environmentObject(groceryList)
.onAppear {
viewModel.importedRecipeDetail = nil
}
case .category(let category): case .category(let category):
RecipeListView( RecipeListView(
categoryName: category.name, categoryName: category.name,
showEditView: showEditViewBinding onCreateNew: { viewModel.navigateToNewRecipe() },
onImportFromURL: { viewModel.showImportURLSheet = true }
) )
.id(category.id) .id(category.id)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
case .allRecipes: case .allRecipes:
AllRecipesListView(showEditView: showEditViewBinding) AllRecipesListView(
.environmentObject(appState) onCreateNew: { viewModel.navigateToNewRecipe() },
.environmentObject(groceryList) onImportFromURL: { viewModel.showImportURLSheet = true }
)
.environmentObject(appState)
.environmentObject(groceryList)
} }
} }
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
@@ -138,17 +144,27 @@ struct RecipeTabView: View {
} detail: { } detail: {
NavigationStack { NavigationStack {
if viewModel.showAllRecipesInDetail { if viewModel.showAllRecipesInDetail {
AllRecipesListView(showEditView: showEditViewBinding) AllRecipesListView(
onCreateNew: { viewModel.navigateToNewRecipe() },
onImportFromURL: { viewModel.showImportURLSheet = true }
)
} else if let category = viewModel.selectedCategory { } else if let category = viewModel.selectedCategory {
RecipeListView( RecipeListView(
categoryName: category.name, categoryName: category.name,
showEditView: showEditViewBinding onCreateNew: { viewModel.navigateToNewRecipe() },
onImportFromURL: { viewModel.showImportURLSheet = true }
) )
.id(category.id) .id(category.id)
} }
} }
} }
.tint(.nextcloudBlue) .tint(.nextcloudBlue)
.sheet(isPresented: $viewModel.showImportURLSheet) {
ImportURLSheet { recipeDetail in
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)
}
.environmentObject(appState)
}
.task { .task {
let connection = await appState.checkServerConnection() let connection = await appState.checkServerConnection()
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -185,6 +201,9 @@ struct RecipeTabView: View {
@Published var selectedCategory: Category? = nil @Published var selectedCategory: Category? = nil
@Published var showAllRecipesInDetail: Bool = false @Published var showAllRecipesInDetail: Bool = false
@Published var showImportURLSheet: Bool = false
@Published var importedRecipeDetail: RecipeDetail? = nil
func navigateToSettings() { func navigateToSettings() {
sidebarPath.append(SidebarDestination.settings) sidebarPath.append(SidebarDestination.settings)
} }
@@ -193,6 +212,11 @@ struct RecipeTabView: View {
sidebarPath.append(SidebarDestination.newRecipe) sidebarPath.append(SidebarDestination.newRecipe)
} }
func navigateToImportedRecipe(recipeDetail: RecipeDetail) {
importedRecipeDetail = recipeDetail
sidebarPath.append(SidebarDestination.newRecipe)
}
func navigateToCategory(_ category: Category) { func navigateToCategory(_ category: Category) {
selectedCategory = category selectedCategory = category
showAllRecipesInDetail = false showAllRecipesInDetail = false
@@ -273,9 +297,18 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Create new recipes // Create new recipes
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Menu {
Logger.view.debug("Add new recipe") Button {
viewModel.navigateToNewRecipe() Logger.view.debug("Add new recipe")
viewModel.navigateToNewRecipe()
} label: {
Label("Create New Recipe", systemImage: "square.and.pencil")
}
Button {
viewModel.showImportURLSheet = true
} label: {
Label("Import from URL", systemImage: "link")
}
} label: { } label: {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
} }