Release Candidate Version 1.6

This commit is contained in:
Vicnet
2023-12-15 13:43:56 +01:00
parent 222685e05d
commit bb68b29bdf
16 changed files with 1020 additions and 275 deletions

View File

@@ -41,6 +41,7 @@
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */; };
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; };
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; };
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
@@ -103,6 +104,7 @@
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiRequest.swift; sourceTree = "<group>"; };
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; };
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
@@ -235,6 +237,7 @@
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
A76B8A702AE002AE00096CEC /* Alerts.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -456,6 +459,7 @@
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
@@ -658,7 +662,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -701,7 +705,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;

View File

@@ -173,6 +173,10 @@ struct RecipeKeyword: Codable {
let recipe_count: Int
}
struct RecipeImportRequest: Codable {
let url: String
}

View File

@@ -79,6 +79,24 @@ class UserSettings: ObservableObject {
}
}
@Published var expandNutritionSection: Bool {
didSet {
UserDefaults.standard.set(expandNutritionSection, forKey: "expandNutritionSection")
}
}
@Published var expandKeywordSection: Bool {
didSet {
UserDefaults.standard.set(expandKeywordSection, forKey: "expandKeywordSection")
}
}
@Published var expandInfoSection: Bool {
didSet {
UserDefaults.standard.set(expandInfoSection, forKey: "expandInfoSection")
}
}
init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
@@ -91,6 +109,9 @@ class UserSettings: ObservableObject {
self.storeImages = UserDefaults.standard.object(forKey: "storeImages") as? Bool ?? true
self.storeThumb = UserDefaults.standard.object(forKey: "storeThumb") as? Bool ?? true
self.lastUpdate = UserDefaults.standard.object(forKey: "lastUpdate") as? Date ?? Date.distantPast
self.expandNutritionSection = UserDefaults.standard.object(forKey: "expandNutritionSection") as? Bool ?? false
self.expandKeywordSection = UserDefaults.standard.object(forKey: "expandKeywordSection") as? Bool ?? false
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
if authString == "" {
if token != "" && username != "" {

View File

@@ -68,7 +68,26 @@
}
},
"(%lld)" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "(%lld)"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "(%lld)"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "(%lld)"
}
}
}
},
"%@" : {
"localizations" : {
@@ -297,7 +316,26 @@
}
},
"Action delayed" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : ""
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Acción retrasada"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Action retardée"
}
}
}
},
"Add" : {
"localizations" : {
@@ -343,28 +381,6 @@
}
}
},
"All" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alle"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tout"
}
}
}
},
"An unknown error occured." : {
"localizations" : {
"de" : {
@@ -387,6 +403,28 @@
}
}
},
"An unknown server error occured." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ein unbekannter Server-Fehler ist aufgetreten."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ocurrió un error desconocido del servidor."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Une erreur inconnue du serveur s'est produite."
}
}
}
},
"App Token Login" : {
"localizations" : {
"de" : {
@@ -498,7 +536,48 @@
}
},
"Configure what is stored on your device." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Legen Sie fest, was lokal auf diesem Gerät gespeichert werden soll."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure lo que se almacena en su dispositivo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configurez ce qui est stocké sur votre appareil."
}
}
}
},
"Configure which sections in your recipes are expanded by default." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Legen Sie fest, welche Rezept-Abschnitte standardmäßig gezeigt werden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure qué secciones de sus recetas se expanden por defecto."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configurez les sections de vos recettes qui sont déployées par défaut."
}
}
}
},
"Connected to server." : {
"localizations" : {
@@ -633,10 +712,48 @@
}
},
"Could not establish a connection to the server. The action will be retried upon reconnection." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Es konnte keine Verbindung zum Server hergestellt werden. Die Aktion wird bei erneuter Verbindung wiederholt."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No se pudo establecer una conexión con el servidor. La acción se reintentará al reconectar."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Impossible d'établir une connexion avec le serveur. L'action sera réessayée lors de la reconnexion."
}
}
}
},
"Created: %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erstellt: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Creado: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Créé : %@"
}
}
}
},
"Delete" : {
"localizations" : {
@@ -770,61 +887,56 @@
}
}
},
"Download all recipes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alle Rezepte herunterladen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Descargar todas las recetas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Télécharger toutes les recettes"
}
}
}
},
"Download recipes" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezepte herunterladen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Descargar recetas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Télécharger des recettes"
}
}
}
},
"Downloads" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Downloads"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Descargas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Téléchargements"
}
}
}
},
"Duplicate Recipe" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duplikat"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Receta duplicada"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recette en double"
}
}
}
},
"Duplicate recipe." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezept bereits vorhanden."
"value" : "Duplikat."
}
},
"es" : {
@@ -863,31 +975,27 @@
}
}
},
"Entering the server address will open a web browser. Please follow the login instructions provided there. If the browser does not open, click the link 'Open in browser'\nAfter a successfull login, return to this application and press 'Validate'." : {
"extractionState" : "stale",
"Error" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Das Eingeben der Serveradresse wird einen Webbrowser öffnen. Bitte folgen Sie dort den bereitgestellten Anmeldeanweisungen. Falls der Browser nicht geöffnet wird, klicken Sie auf den Link 'Im Browser öffnen'.\nNach erfolgreicher Anmeldung kehren Sie zu dieser Anwendung zurück und drücken Sie 'Überprüfen'."
"value" : "Fehler"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingresar la dirección del servidor abrirá un navegador web. Por favor, siga las instrucciones de inicio de sesión proporcionadas allí. Si el navegador no se abre, haga clic en el enlace 'Abrir en el navegador'.\nDespués de iniciar sesión con éxito, regrese a esta aplicación y presione 'Validar'."
"value" : "Error"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'ajout de l'adresse du serveur ouvrira un navigateur web. Suivez les instructions de connexion fournies là-bas. Si le navigateur ne s'ouvre pas, cliquez sur le lien 'Ouvrir dans le navigateur'. Après une connexion réussie, revenez à cette application et appuyez sur 'Valider'."
"value" : "Erreur"
}
}
}
},
"Error" : {
},
"Error." : {
"localizations" : {
@@ -911,6 +1019,72 @@
}
}
},
"Expand information section" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Weitere Informationen zeigen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expandir sección de información"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Développer la section d'informations"
}
}
}
},
"Expand keyword section" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schlagwörter zeigen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expandir sección de palabras clave"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Développer la section des mots-clés"
}
}
}
},
"Expand nutrition section" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nährwerte zeigen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expandir sección de nutrición"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Développer la section nutrition"
}
}
}
},
"General" : {
"localizations" : {
"de" : {
@@ -978,7 +1152,26 @@
}
},
"If the login button does not open your browser, copy the following link and paste it in your browser manually:" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wenn der Anmeldebutton Ihren Browser nicht öffnet, kopieren Sie den folgenden Link und fügen Sie ihn manuell in Ihren Browser ein:"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Si el botón de inicio de sesión no abre su navegador, copie el siguiente enlace y péguelo manualmente en su navegador:"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Si le bouton de connexion n'ouvre pas votre navigateur, copiez le lien suivant et collez-le manuellement dans votre navigateur :"
}
}
}
},
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
"localizations" : {
@@ -1024,6 +1217,28 @@
}
}
},
"Image MIME Error" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "MIME fehler"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : ""
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : ""
}
}
}
},
"Import" : {
"localizations" : {
"de" : {
@@ -1229,10 +1444,48 @@
}
},
"Last modified: %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zuletzt geändert: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Última modificación: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dernière modification : %@"
}
}
}
},
"Last updated: %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Letztes update: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Última actualización: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dernière mise à jour : %@"
}
}
}
},
"Log out" : {
"localizations" : {
@@ -1257,7 +1510,26 @@
}
},
"Login" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Anmelden"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Iniciar sesión"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connexion"
}
}
}
},
"Login failed." : {
"localizations" : {
@@ -1304,7 +1576,48 @@
}
},
"Make sure to enter the server address in the form 'example.com'. Currently, only servers using the 'https' protocol are supported." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stellen Sie sicher, dass Sie die Serveradresse in der Form 'beispiel.com' eingeben. Derzeit werden nur Server unterstützt, die das 'https'-Protokoll verwenden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Asegúrese de ingresar la dirección del servidor en el formato 'example.com'. Actualmente, solo se admiten servidores que usan el protocolo 'https'."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Assurez-vous de saisir l'adresse du serveur sous la forme 'example.com'. Actuellement, seuls les serveurs utilisant le protocole 'https' sont pris en charge."
}
}
}
},
"Missing Name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fehlender Name"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nombre faltante"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nom manquant"
}
}
}
},
"Missing recipe name." : {
"localizations" : {
@@ -1328,6 +1641,50 @@
}
}
},
"Missing Request Body" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Missing Request Body"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Missing Request Body"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Missing Request Body"
}
}
}
},
"More information" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Weitere Informationen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Más información"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Plus d'informations"
}
}
}
},
"Network error." : {
"localizations" : {
"de" : {
@@ -1505,7 +1862,26 @@
}
},
"Offline recipes" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezepte herunterladen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recetas sin conexión"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recettes hors ligne"
}
}
}
},
"Ok" : {
"localizations" : {
@@ -1529,29 +1905,6 @@
}
}
},
"Open in browser" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Browser öffnen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abrir en el navegador."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ouvrir dans le navigateur"
}
}
}
},
"Other" : {
"localizations" : {
"de" : {
@@ -1728,8 +2081,49 @@
}
}
},
"Recipes" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezepte"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recetas"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recettes"
}
}
}
},
"Refresh all" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Synchronisieren"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Actualizar todo"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tout actualiser"
}
}
}
},
"Same as Device" : {
"localizations" : {
@@ -1753,6 +2147,28 @@
}
}
},
"Search" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suchen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Buscar"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recherche"
}
}
}
},
"Search recipe" : {
"localizations" : {
"de" : {
@@ -1886,13 +2302,70 @@
}
},
"Show help" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hilfe"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostrar ayuda"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afficher l'aide"
}
}
}
},
"Store recipe images locally" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezept-Bild herunterladen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Almacenar imágenes de recetas localmente"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stocker les images des recettes localement"
}
}
}
},
"Store recipe thumbnails locally" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rezept-Thumbnail herunterladen"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Almacenar miniaturas de recetas localmente"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stocker les vignettes des recettes localement"
}
}
}
},
"Submit" : {
"localizations" : {
@@ -1961,7 +2434,48 @@
}
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Der 'Anmelden'-Button wird einen Webbrowser öffnen. Bitte folgen Sie den dort angegebenen Anmeldeanweisungen.\nNach einer erfolgreichen Anmeldung kehren Sie zu dieser Anwendung zurück und drücken Sie 'Validieren'."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "El botón 'Iniciar sesión' abrirá un navegador web. Por favor, siga las instrucciones de inicio de sesión proporcionadas allí. Después de un inicio de sesión exitoso, regrese a esta aplicación y presione 'Validar'."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Le bouton 'Connexion' ouvrira un navigateur web. Veuillez suivre les instructions de connexion fournies là-bas. Après une connexion réussie, revenez à cette application et appuyez sur 'Valider'."
}
}
}
},
"The recipe has no image whose MIME type matches the Accept header" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "The recipe has no image whose MIME type matches the Accept header"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "The recipe has no image whose MIME type matches the Accept header"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "The recipe has no image whose MIME type matches the Accept header"
}
}
}
},
"The selected cookbook will open on app launch by default." : {
"localizations" : {
@@ -1985,6 +2499,28 @@
}
}
},
"There was no name in the request given for the recipe. Cannot save the recipe." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Der Rezeptname fehlt. Das Rezept konnte nicht gespeichert werden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No había un nombre en la solicitud dada para la receta. No se puede guardar la receta."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Il n'y avait pas de nom dans la demande faite pour la recette."
}
}
}
},
"This action is not reversible!" : {
"localizations" : {
"de" : {
@@ -2140,7 +2676,26 @@
}
},
"Unable to complete action." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die Aktion kann momentan nicht durchgeführt werden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "No se puede completar la acción."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Impossible de compléter l'action."
}
}
}
},
"Unable to connect to server." : {
"localizations" : {
@@ -2252,6 +2807,28 @@
}
}
},
"URL:" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL:"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL:"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "URL:"
}
}
}
},
"Validate" : {
"localizations" : {
"de" : {

View File

@@ -16,7 +16,7 @@ protocol CookbookApi {
from serverAdress: String,
auth: String,
data: Data
) async -> (NetworkError?)
) async -> (RecipeDetail?, NetworkError?)
/// Get either the full image or a thumbnail sized version.
/// - Parameters:

View File

@@ -10,8 +10,18 @@ import UIKit
class CookbookApiV1: CookbookApi {
static func importRecipe(from serverAdress: String, auth: String, data: Data) async -> (NetworkError?) {
return .unknownError
static func importRecipe(from serverAdress: String, auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) {
let request = ApiRequest(
serverAdress: serverAdress,
path: "/api/v1/import",
method: .POST,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
}
static func getImage(from serverAdress: String, auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {

View File

@@ -6,6 +6,7 @@
//
import Foundation
import SwiftUI
public enum NotImplementedError: Error, CustomStringConvertible {
case notImplemented
@@ -27,3 +28,43 @@ public enum NetworkError: String, Error {
case dataError = "Invalid data error."
}
public enum ServerError: Error {
case unknownError, missingRequestBody, duplicateRecipe, noImage, missingRecipeName, recipeNotFound, deleteFailed, requestUnsuccessful
static func decodeFromURLResponse(response: HTTPURLResponse?) -> ServerError? {
guard let response = response else {
return ServerError.unknownError
}
print("Status code: ", response.statusCode)
switch response.statusCode {
case 200...299: return nil
case 400: return .missingRequestBody
case 404: return .recipeNotFound
case 409: return .duplicateRecipe
case 406: return .noImage
case 422: return .missingRecipeName
case 500: return .requestUnsuccessful
case 502: return .deleteFailed
default: return ServerError.unknownError
}
}
var localizedDescription: LocalizedStringKey {
switch self {
case .noImage: return "The recipe has no image whose MIME type matches the Accept header"
case .missingRecipeName: return "There was no name in the request given for the recipe. Cannot save the recipe."
default: return "An unknown server error occured."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .missingRequestBody: return "Missing Request Body"
case .duplicateRecipe: return "Duplicate Recipe"
case .noImage: return "Image MIME Error"
case .missingRecipeName: return "Missing Name"
default: return "Error"
}
}
}

View File

@@ -496,11 +496,24 @@ import UIKit
recipe: recipeDetail
)
}
if let error = error {
if error != nil {
return .REQUEST_DROPPED
}
return nil
}
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await api.importRecipe(
from: userSettings.serverAddress,
auth: userSettings.authString,
data: data
)
if error != nil {
return (nil, .REQUEST_DROPPED)
}
return (recipeDetail, nil)
}
}

View File

@@ -112,6 +112,13 @@ import SwiftUI
}
func importRecipe() async -> UserAlert? {
let (scrapedRecipe, error) = await mainViewModel.importRecipe(url: importURL)
if let scrapedRecipe = scrapedRecipe {
self.recipe = scrapedRecipe
prepareView()
return nil
}
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL)
if let scrapedRecipe = scrapedRecipe {
@@ -125,5 +132,6 @@ import SwiftUI
print("Error")
}
return nil
}
}

View File

@@ -0,0 +1,50 @@
//
// CollapsibleView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.12.23.
//
import Foundation
import SwiftUI
struct CollapsibleView<C: View, T: View>: View {
@State var titleColor: Color = .white
@State var isCollapsed: Bool = true
@State var content: () -> C
@State var title: () -> T
@State private var rotationAngle: Double = -90
var body: some View {
VStack(alignment: .leading) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCollapsed.toggle()
if isCollapsed {
rotationAngle += 90
} else {
rotationAngle -= 90
}
}
rotationAngle = isCollapsed ? -90 : 0
} label: {
HStack {
Image(systemName: "chevron.down")
.bold()
.rotationEffect(Angle(degrees: rotationAngle))
title()
}.foregroundStyle(titleColor)
}
if !isCollapsed {
content()
.padding(.top)
}
}
.onAppear {
rotationAngle = isCollapsed ? -90 : 0
}
}
}

View File

@@ -30,10 +30,9 @@ struct MainView: View {
RecipeSearchView(viewModel: viewModel)
} label: {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
Text("All")
.font(.system(size: 20, weight: .light, design: .serif))
.italic()
Image(systemName: "magnifyingglass")
Text("Search")
.font(.system(size: 20, weight: .medium, design: .default))
}
.padding(7)
}
@@ -43,10 +42,23 @@ struct MainView: View {
if category.recipe_count != 0 {
NavigationLink(value: category) {
HStack(alignment: .center) {
Image(systemName: "book.closed.fill")
if selectedCategory != nil && category.name == selectedCategory!.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
Text(category.name == "*" ? String(localized: "Other") : category.name)
.font(.system(size: 20, weight: .light, design: .serif))
.italic()
.font(.system(size: 20, weight: .medium, design: .default))
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}

View File

@@ -13,10 +13,7 @@ struct TokenLoginView: View {
@Binding var alertMessage: String
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = false
@State var userSettings = UserSettings.shared
// TextField handling
enum Field {
@@ -28,14 +25,14 @@ struct TokenLoginView: View {
var body: some View {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
LoginTextField(example: "e.g.: example.com", text: $serverAddress)
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.next)
.padding(.bottom)
LoginLabel(text: "User name")
LoginTextField(example: "username", text: $userName)
LoginTextField(example: "username", text: $userSettings.username)
.focused($focusedField, equals: .username)
.textContentType(.username)
.submitLabel(.next)
@@ -43,7 +40,7 @@ struct TokenLoginView: View {
LoginLabel(text: "App Token")
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $token)
LoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
.focused($focusedField, equals: .token)
.textContentType(.password)
.submitLabel(.join)
@@ -52,7 +49,7 @@ struct TokenLoginView: View {
Button {
Task {
if await loginCheck(nextcloudLogin: false) {
onboarding = false
userSettings.onboarding = false
}
}
} label: {
@@ -83,11 +80,11 @@ struct TokenLoginView: View {
}
func loginCheck(nextcloudLogin: Bool) async -> Bool {
if serverAddress == "" {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!"
showAlert = true
return false
} else if !nextcloudLogin && (userName == "" || token == "") {
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
alertMessage = "Please enter a user name and app token!"
showAlert = true
return false
@@ -104,14 +101,18 @@ struct TokenLoginView: View {
var (data, error): (Data?, Error?) = (nil, nil)
do {
let loginString = "\(userName):\(token)"
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
let authString = loginData.base64EncodedString()
DispatchQueue.main.async {
userSettings.authString = authString
}
(data, error) = try await NetworkHandler.sendHTTPRequest(
request,
hostPath: "https://\(serverAddress)/index.php/apps/cookbook/api/v1/",
hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/",
authString: authString
)
} catch {
print("Error: ", error)
}

View File

@@ -28,42 +28,7 @@ enum V2LoginStage: LoginStage {
}
}
struct CollapsibleView<T: View>: View {
@State var titleColor: Color = .white
@State var content: () -> T
@State var title: () -> Text
@State var isCollapsed: Bool = true
@State var rotationAngle: Double = -90
var body: some View {
VStack(alignment: .leading) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCollapsed.toggle()
if isCollapsed {
rotationAngle += 90
} else {
rotationAngle -= 90
}
}
rotationAngle = isCollapsed ? -90 : 0
} label: {
HStack {
Image(systemName: "chevron.down")
.bold()
.rotationEffect(Angle(degrees: rotationAngle))
title()
}.foregroundStyle(titleColor)
}
if !isCollapsed {
content()
.padding(.top, 1)
}
}
}
}
struct V2LoginView: View {
@Binding var showAlert: Bool
@@ -73,10 +38,7 @@ struct V2LoginView: View {
@State var loginRequest: LoginV2Request? = nil
@FocusState private var focusedField: Field?
@AppStorage("serverAddress") var serverAddress = ""
@AppStorage("username") var userName = ""
@AppStorage("token") var token = ""
@AppStorage("onboarding") var onboarding = true
@State var userSettings = UserSettings.shared
// TextField handling
enum Field {
@@ -90,7 +52,7 @@ struct V2LoginView: View {
VStack(alignment: .leading) {
LoginLabel(text: "Server address")
.padding()
LoginTextField(example: "e.g.: example.com", text: $serverAddress, color: loginStage == .serverAddress ? .white : .secondary)
LoginTextField(example: "e.g.: example.com", text: $userSettings.serverAddress, color: loginStage == .serverAddress ? .white : .secondary)
.focused($focusedField, equals: .server)
.textContentType(.URL)
.submitLabel(.done)
@@ -124,7 +86,7 @@ struct V2LoginView: View {
HStack {
if loginStage == .login || loginStage == .validate {
Button {
if serverAddress == "" {
if userSettings.serverAddress == "" {
alertMessage = "Please enter a valid server address."
showAlert = true
return
@@ -164,9 +126,14 @@ struct V2LoginView: View {
return
}
print("Login successfull for user \(res.loginName)!")
self.userName = res.loginName
self.token = res.appPassword
self.onboarding = false
self.userSettings.username = res.loginName
self.userSettings.token = res.appPassword
let loginString = "\(userSettings.username):\(userSettings.token)"
let loginData = loginString.data(using: String.Encoding.utf8)!
DispatchQueue.main.async {
userSettings.authString = loginData.base64EncodedString()
}
self.userSettings.onboarding = false
}
} label: {
Text("Validate")
@@ -188,7 +155,7 @@ struct V2LoginView: View {
}
func sendLoginV2Request() async {
let hostPath = "https://\(serverAddress)"
let hostPath = "https://\(userSettings.serverAddress)"
let headerFields: [HeaderField] = [
//HeaderField.ocsRequest(value: true),
//HeaderField.accept(value: .JSON)

View File

@@ -21,21 +21,20 @@ struct RecipeCardView: View {
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 17))
} else {
ZStack {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
}
.background(Color("ncblue"))
.frame(width: 80, height: 80)
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
.background(Color("ncblue"))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
}
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer()
if let isDownloaded = isDownloaded {

View File

@@ -66,23 +66,16 @@ struct RecipeDetailView: View {
if(!recipeDetail.recipeIngredient.isEmpty) {
RecipeIngredientSection(recipeDetail: recipeDetail)
}
if(!recipeDetail.tool.isEmpty) {
RecipeListSection(title: "Tools", list: recipeDetail.tool)
}
if(!recipeDetail.recipeInstructions.isEmpty) {
RecipeInstructionSection(recipeDetail: recipeDetail)
}
RecipeNutritionSection(recipeDetail: recipeDetail, presentNutritionPopover: $presentNutritionPopover)
RecipeKeywordSection(recipeDetail: recipeDetail, presentKeywordPopover: $presentKeywordPopover)
if(!recipeDetail.tool.isEmpty) {
RecipeToolSection(recipeDetail: recipeDetail)
}
RecipeNutritionSection(recipeDetail: recipeDetail)
RecipeKeywordSection(recipeDetail: recipeDetail)
MoreInformationSection(recipeDetail: recipeDetail)
}
VStack(alignment: .leading) {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
}
.font(.caption)
.foregroundStyle(Color.secondary)
.padding()
}.padding(.horizontal, 5)
}
@@ -151,7 +144,10 @@ fileprivate struct RecipeDurationSection: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), alignment: .leading)]) {
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Preparation"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Preparation"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -159,7 +155,10 @@ fileprivate struct RecipeDurationSection: View {
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Cooking"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Cooking"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -167,7 +166,10 @@ fileprivate struct RecipeDurationSection: View {
if let totalTime = recipeDetail.totalTime, let time = DurationComponents.ptToText(totalTime) {
VStack(alignment: .leading) {
SecondaryLabel(text: LocalizedStringKey("Total time"))
HStack {
SecondaryLabel(text: LocalizedStringKey("Total time"))
Spacer()
}
Text(time)
.lineLimit(1)
}.padding()
@@ -180,76 +182,53 @@ fileprivate struct RecipeDurationSection: View {
fileprivate struct RecipeNutritionSection: View {
@State var recipeDetail: RecipeDetail
@Binding var presentNutritionPopover: Bool
var body: some View {
Button {
presentNutritionPopover.toggle()
} label: {
HStack {
SecondaryLabel(text: "Nutrition")
Image(systemName: "chevron.right")
.foregroundStyle(Color.secondary)
.bold()
Spacer()
}.padding()
}
.buttonStyle(.plain)
.popover(isPresented: $presentNutritionPopover) {
if let nutritionList = recipeDetail.getNutritionList() {
ScrollView(showsIndicators: false) {
if let servingSize = recipeDetail.nutrition["servingSize"] {
RecipeListSection(title: "Nutrition (\(servingSize))", list: nutritionList)
.presentationCompactAdaptation(.popover)
HStack() {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
Group {
if let nutritionList = recipeDetail.getNutritionList() {
RecipeListSection(list: nutritionList)
} else {
RecipeListSection(title: "Nutrition", list: nutritionList)
.presentationCompactAdaptation(.popover)
Text(LocalizedStringKey("No nutritional information."))
}
}
} else {
Text(LocalizedStringKey("No nutritional information."))
.foregroundStyle(Color.secondary)
.bold()
.padding()
.presentationCompactAdaptation(.popover)
} title: {
HStack {
if let servingSize = recipeDetail.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
}
Spacer()
}
}
.padding()
}
}
}
fileprivate struct RecipeKeywordSection: View {
@State var recipeDetail: RecipeDetail
@Binding var presentKeywordPopover: Bool
var body: some View {
Button {
presentKeywordPopover.toggle()
} label: {
HStack {
SecondaryLabel(text: "Keywords")
Image(systemName: "chevron.right")
.foregroundStyle(Color.secondary)
.bold()
Spacer()
}.padding()
}
.buttonStyle(.plain)
.popover(isPresented: $presentKeywordPopover) {
if let keywords = getKeywords() {
ScrollView(showsIndicators: false) {
RecipeListSection(title: "Keywords", list: keywords)
.presentationCompactAdaptation(.popover)
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if let keywords = getKeywords() {
RecipeListSection(list: keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
} else {
Text(LocalizedStringKey("No keywords."))
.foregroundStyle(Color.secondary)
.bold()
.padding()
.presentationCompactAdaptation(.popover)
}
} title: {
HStack {
SecondaryLabel(text: LocalizedStringKey("Keywords"))
Spacer()
}
}
.padding()
}
func getKeywords() -> [String]? {
@@ -259,6 +238,35 @@ fileprivate struct RecipeKeywordSection: View {
}
fileprivate struct MoreInformationSection: View {
let recipeDetail: RecipeDetail
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
if recipeDetail.url != "", let url = URL(string: recipeDetail.url) {
HStack() {
Text("URL:")
Link(destination: url) {
Text(recipeDetail.url)
}
}
}
}
.font(.caption)
.foregroundStyle(Color.secondary)
} title: {
HStack {
SecondaryLabel(text: "More information")
Spacer()
}
}
.padding()
}
}
fileprivate struct RecipeIngredientSection: View {
@State var recipeDetail: RecipeDetail
@@ -283,6 +291,20 @@ fileprivate struct RecipeIngredientSection: View {
}
fileprivate struct RecipeToolSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: "Tools")
Spacer()
}
RecipeListSection(list: recipeDetail.tool)
}.padding()
}
}
fileprivate struct IngredientListItem: View {
@State var ingredient: String
@@ -311,15 +333,10 @@ fileprivate struct IngredientListItem: View {
fileprivate struct RecipeListSection: View {
@State var title: LocalizedStringKey
@State var list: [String]
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: title)
Spacer()
}
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
@@ -328,12 +345,11 @@ fileprivate struct RecipeListSection: View {
}
.padding(4)
}
}.padding()
}
}
}
fileprivate struct RecipeInstructionSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {

View File

@@ -32,6 +32,22 @@ struct SettingsView: View {
Text("The selected cookbook will open on app launch by default.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
@@ -108,6 +124,12 @@ struct SettingsView: View {
} message: {
Text(alertType.getMessage())
}
.onDisappear {
Task {
userSettings.lastUpdate = .distantPast
await viewModel.updateAllRecipeDetails()
}
}
}