diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 5549ecc..c0d68ff 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.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 */; }; 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 */; }; @@ -153,6 +154,7 @@ A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = ""; }; A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = ""; }; + C1F0AB012D0B000100000001 /* ImportURLSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportURLSheet.swift; sourceTree = ""; }; 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 = ""; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = ""; }; @@ -406,6 +408,7 @@ A97506112B920D8100E86029 /* RecipeViewSections */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */, + C1F0AB012D0B000100000001 /* ImportURLSheet.swift */, ); path = Recipes; sourceTree = ""; @@ -578,7 +581,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */, + C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */, A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */, A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 1fd0f8d..ae5e881 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -317,7 +317,6 @@ } }, "%lld." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -518,6 +517,7 @@ } }, "Add cooking steps for fellow chefs to follow." : { + "extractionState" : "stale", "localizations" : { "de" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "extractionState" : "stale", "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: %@" : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "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." : { "comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.", "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "de" : { @@ -2005,6 +2335,7 @@ } }, "Instruction" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2159,6 +2490,7 @@ } }, "List your tools here. 🍴" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2589,16 +2921,6 @@ } } }, - "No recipes in this cookbook" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Rezepte in dieser Kategorie" - } - } - } - }, "No recipes found" : { "localizations" : { "de" : { @@ -2621,6 +2943,16 @@ } } }, + "No recipes in this cookbook" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Rezepte in dieser Kategorie" + } + } + } + }, "No results found" : { "comment" : "A message indicating that no recipes were found for the current search query.", "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" : { "localizations" : { "de" : { @@ -2765,6 +3119,10 @@ } } }, + "OK" : { + "comment" : "The text for an OK button.", + "isCommentAutoGenerated" : true + }, "On server" : { "extractionState" : "stale", "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" : { "localizations" : { "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." : { + "extractionState" : "stale", "localizations" : { "de" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "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" : { "localizations" : { "de" : { @@ -3622,6 +4095,7 @@ } }, "Start by adding your first ingredient! 🥬" : { + "extractionState" : "stale", "localizations" : { "de" : { "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" : { "localizations" : { "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" : { "extractionState" : "stale", "localizations" : { @@ -4371,6 +4871,7 @@ } }, "URL (e.g. example.com/recipe)" : { + "extractionState" : "stale", "localizations" : { "de" : { "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" diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift index 1edd0fa..bf46d44 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/AllRecipesListView.swift @@ -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") } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/ImportURLSheet.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/ImportURLSheet.swift new file mode 100644 index 0000000..dc3e620 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/ImportURLSheet.swift @@ -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 + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 91248ea..e83d867 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -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") } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 548465f..cd79b73 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -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") } - + } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift index 19749d6..83a5e9a 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift @@ -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() + } } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index 2a2f745..8347e42 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -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 { diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift index 4409f4b..a9d1389 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift @@ -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 diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift index 4eb75c8..f197aba 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -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: View where Collection.Element == Item { @Binding var isPresented: Bool @Binding var value: Item diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift index 03e02d0..5618250 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift @@ -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 { + Binding( + get: { nutrition[key, default: ""] }, + set: { nutrition[key] = $0 } + ) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift index 04dfb6d..ad7ce0c 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift @@ -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") + } + } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index f10b10b..5128fe5 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -18,13 +18,6 @@ struct RecipeTabView: View { private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)] - private var showEditViewBinding: Binding { - Binding( - get: { false }, - set: { if $0 { viewModel.navigateToNewRecipe() } } - ) - } - private var nonEmptyCategories: [Category] { appState.categories.filter { $0.recipe_count > 0 } } @@ -112,21 +105,34 @@ struct RecipeTabView: View { .environmentObject(appState) .environmentObject(groceryList) case .newRecipe: - RecipeView(viewModel: RecipeView.ViewModel()) - .environmentObject(appState) - .environmentObject(groceryList) + RecipeView(viewModel: { + let vm = RecipeView.ViewModel() + if let imported = viewModel.importedRecipeDetail { + vm.preloadedRecipeDetail = imported + } + return vm + }()) + .environmentObject(appState) + .environmentObject(groceryList) + .onAppear { + viewModel.importedRecipeDetail = nil + } case .category(let category): RecipeListView( categoryName: category.name, - showEditView: showEditViewBinding + onCreateNew: { viewModel.navigateToNewRecipe() }, + onImportFromURL: { viewModel.showImportURLSheet = true } ) .id(category.id) .environmentObject(appState) .environmentObject(groceryList) case .allRecipes: - AllRecipesListView(showEditView: showEditViewBinding) - .environmentObject(appState) - .environmentObject(groceryList) + AllRecipesListView( + onCreateNew: { viewModel.navigateToNewRecipe() }, + onImportFromURL: { viewModel.showImportURLSheet = true } + ) + .environmentObject(appState) + .environmentObject(groceryList) } } .navigationDestination(for: Recipe.self) { recipe in @@ -138,17 +144,27 @@ struct RecipeTabView: View { } detail: { NavigationStack { if viewModel.showAllRecipesInDetail { - AllRecipesListView(showEditView: showEditViewBinding) + AllRecipesListView( + onCreateNew: { viewModel.navigateToNewRecipe() }, + onImportFromURL: { viewModel.showImportURLSheet = true } + ) } else if let category = viewModel.selectedCategory { RecipeListView( categoryName: category.name, - showEditView: showEditViewBinding + onCreateNew: { viewModel.navigateToNewRecipe() }, + onImportFromURL: { viewModel.showImportURLSheet = true } ) .id(category.id) } } } .tint(.nextcloudBlue) + .sheet(isPresented: $viewModel.showImportURLSheet) { + ImportURLSheet { recipeDetail in + viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail) + } + .environmentObject(appState) + } .task { let connection = await appState.checkServerConnection() DispatchQueue.main.async { @@ -185,6 +201,9 @@ struct RecipeTabView: View { @Published var selectedCategory: Category? = nil @Published var showAllRecipesInDetail: Bool = false + @Published var showImportURLSheet: Bool = false + @Published var importedRecipeDetail: RecipeDetail? = nil + func navigateToSettings() { sidebarPath.append(SidebarDestination.settings) } @@ -193,6 +212,11 @@ struct RecipeTabView: View { sidebarPath.append(SidebarDestination.newRecipe) } + func navigateToImportedRecipe(recipeDetail: RecipeDetail) { + importedRecipeDetail = recipeDetail + sidebarPath.append(SidebarDestination.newRecipe) + } + func navigateToCategory(_ category: Category) { selectedCategory = category showAllRecipesInDetail = false @@ -273,9 +297,18 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { // Create new recipes ToolbarItem(placement: .topBarTrailing) { - Button { - Logger.view.debug("Add new recipe") - viewModel.navigateToNewRecipe() + Menu { + Button { + 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: { Image(systemName: "plus.circle.fill") }