diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 9236c86..cfc0862 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -14,7 +14,7 @@ A70171942AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171932AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift */; }; A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; }; A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; }; - A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* MainViewModel.swift */; }; + A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; }; A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; }; A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; @@ -47,8 +47,13 @@ A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; }; + A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; }; + A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; }; + A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; + A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; }; + A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -80,7 +85,7 @@ A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nextcloud Cookbook iOS ClientUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = ""; }; A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = ""; }; - A70171AC2AA8EF4700064C43 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; + A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = ""; }; A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = ""; }; @@ -112,7 +117,12 @@ A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; + A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = ""; }; + A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = ""; }; + A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = ""; }; A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = ""; }; + A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; + A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -145,6 +155,7 @@ A70171752AA8E71900064C43 = { isa = PBXGroup; children = ( + A9FA2AB42B50798800A43702 /* Resources */, A781E75E2AE9133B00452F6F /* Screenshots */, A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */, A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */, @@ -174,6 +185,7 @@ A781E75F2AF8228100452F6F /* RecipeImport */, A9CA6CED2B4C084100F78AB5 /* RecipeExport */, A703226B2ABAF60D00D7C4ED /* Extensions */, + A76B8A702AE002AE00096CEC /* Alerts.swift */, A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */, A7AEAE632AD5521400135378 /* Localizable.xcstrings */, A70171852AA8E71F00064C43 /* Assets.xcassets */, @@ -223,7 +235,7 @@ A70171B72AB2445700064C43 /* ViewModels */ = { isa = PBXGroup; children = ( - A70171AC2AA8EF4700064C43 /* MainViewModel.swift */, + A70171AC2AA8EF4700064C43 /* AppState.swift */, A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, ); path = ViewModels; @@ -232,17 +244,13 @@ A70171BA2AB4980100064C43 /* Views */ = { isa = PBXGroup; children = ( - A7FB0D782B25C65200A3469E /* Onboarding */, A70171832AA8E71900064C43 /* MainView.swift */, - A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, - A70171C12AB498C600064C43 /* RecipeCardView.swift */, - A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, - A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, A70171CC2AB501B100064C43 /* SettingsView.swift */, - A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, - A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, - A76B8A702AE002AE00096CEC /* Alerts.swift */, - A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, + A977D0DC2B6002DA009783A9 /* Tabs */, + A7FB0D782B25C65200A3469E /* Onboarding */, + A9C3BE502B630E3900562C79 /* Recipes */, + A9C3BE512B630E8300562C79 /* RecipeEditing */, + A9C3BE522B630F1300562C79 /* ReusableViews */, ); path = Views; sourceTree = ""; @@ -312,6 +320,45 @@ path = Onboarding; sourceTree = ""; }; + A977D0DC2B6002DA009783A9 /* Tabs */ = { + isa = PBXGroup; + children = ( + A977D0DD2B600300009783A9 /* SearchTabView.swift */, + A977D0DF2B600318009783A9 /* RecipeTabView.swift */, + A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, + ); + path = Tabs; + sourceTree = ""; + }; + A9C3BE502B630E3900562C79 /* Recipes */ = { + isa = PBXGroup; + children = ( + A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, + A70171C12AB498C600064C43 /* RecipeCardView.swift */, + A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, + A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, + ); + path = Recipes; + sourceTree = ""; + }; + A9C3BE512B630E8300562C79 /* RecipeEditing */ = { + isa = PBXGroup; + children = ( + A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, + A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, + A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, + ); + path = RecipeEditing; + sourceTree = ""; + }; + A9C3BE522B630F1300562C79 /* ReusableViews */ = { + isa = PBXGroup; + children = ( + A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, + ); + path = ReusableViews; + sourceTree = ""; + }; A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = { isa = PBXGroup; children = ( @@ -320,6 +367,14 @@ path = RecipeExport; sourceTree = ""; }; + A9FA2AB42B50798800A43702 /* Resources */ = { + isa = PBXGroup; + children = ( + A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */, + ); + path = Resources; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -435,6 +490,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */, A701718A2AA8E71F00064C43 /* Preview Assets.xcassets in Resources */, A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */, A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */, @@ -462,9 +518,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, + A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, @@ -489,12 +547,14 @@ A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, + A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, - A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, + A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, + A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -678,7 +738,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.7.1; + MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -721,7 +781,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.7.1; + MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index 323afc3..9f85451 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client/Views/Alerts.swift b/Nextcloud Cookbook iOS Client/Alerts.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/Views/Alerts.swift rename to Nextcloud Cookbook iOS Client/Alerts.swift diff --git a/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift b/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift index 774d877..edd017a 100644 --- a/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift +++ b/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift @@ -10,6 +10,22 @@ import SwiftUI class DurationComponents: ObservableObject { + @Published var secondComponent: String = "00" { + didSet { + if secondComponent.count > 2 { + secondComponent = oldValue + } else if secondComponent.count == 1 { + secondComponent = "0\(secondComponent)" + } else if secondComponent.count == 0 { + secondComponent = "00" + } + let filtered = secondComponent.filter { $0.isNumber } + if secondComponent != filtered { + secondComponent = filtered + } + } + } + @Published var minuteComponent: String = "00" { didSet { if minuteComponent.count > 2 { @@ -42,6 +58,19 @@ class DurationComponents: ObservableObject { } } + static func fromPTString(_ PTRepresentation: String) -> DurationComponents { + let duration = DurationComponents() + let hourRegex = /([0-9]{1,2})H/ + let minuteRegex = /([0-9]{1,2})M/ + if let match = PTRepresentation.firstMatch(of: hourRegex) { + duration.hourComponent = String(match.1) + } + if let match = PTRepresentation.firstMatch(of: minuteRegex) { + duration.minuteComponent = String(match.1) + } + return duration + } + func fromPTString(_ PTRepresentation: String) { let hourRegex = /([0-9]{1,2})H/ let minuteRegex = /([0-9]{1,2})M/ @@ -60,6 +89,7 @@ class DurationComponents: ObservableObject { func toText() -> LocalizedStringKey { let intHour = Int(hourComponent) ?? 0 let intMinute = Int(minuteComponent) ?? 0 + if intHour != 0 && intMinute != 0 { return "\(intHour) h, \(intMinute) min" } else if intHour == 0 && intMinute != 0 { @@ -71,6 +101,32 @@ class DurationComponents: ObservableObject { } } + func toTimerText() -> String { + var timeString = "" + if hourComponent != "00" { + timeString.append("\(hourComponent):") + } + timeString.append("\(minuteComponent):") + timeString.append("\(secondComponent)") + return timeString + } + + func toSeconds() -> Double { + guard let hours = Double(hourComponent) else { return 0 } + guard let minutes = Double(minuteComponent) else { return 0 } + guard let seconds = Double(secondComponent) else { return 0 } + return hours * 3600 + minutes * 60 + seconds + } + + func fromSeconds(_ totalSeconds: Int) { + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + self.hourComponent = String(hours) + self.minuteComponent = String(minutes) + self.secondComponent = String(seconds) + } + static func ptToText(_ ptString: String) -> String? { let hourRegex = /([0-9]{1,2})H/ let minuteRegex = /([0-9]{1,2})M/ diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index e5b9245..0543b7a 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -103,6 +103,12 @@ class UserSettings: ObservableObject { } } + @Published var keepScreenAwake: Bool { + didSet { + UserDefaults.standard.set(keepScreenAwake, forKey: "keepScreenAwake") + } + } + init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" @@ -119,6 +125,7 @@ class UserSettings: ObservableObject { 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 + self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true if authString == "" { if token != "" && username != "" { diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 7e37892..1f6a575 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -293,6 +293,28 @@ } } }, + "A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, "About" : { "localizations" : { "de" : { @@ -315,6 +337,28 @@ } } }, + "Acknowledgements" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwendete Bibliotheken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reconocimientos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remerciements" + } + } + } + }, "Action delayed" : { "localizations" : { "de" : { @@ -359,6 +403,28 @@ } } }, + "Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fügen Sie dieser Liste Rezeptzutaten hinzu, indem Sie entweder den Button neben einer Zutatenliste in einem Rezept verwenden, um alle Zutaten hinzuzufügen, oder indem Sie einzelne Zutaten eines Rezepts nach rechts wischen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agrega comestibles a esta lista usando el botón junto a una lista de ingredientes en una receta, o deslizando hacia la derecha en ingredientes individuales de una receta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez des articles à cette liste soit en utilisant le bouton à côté d'une liste d'ingrédients dans une recette, soit en balayant vers la droite sur des ingrédients individuels d'une recette." + } + } + } + }, "Add new recipe" : { "localizations" : { "de" : { @@ -381,6 +447,28 @@ } } }, + "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, "An unknown error occured." : { "localizations" : { "de" : { @@ -711,6 +799,28 @@ } } }, + "Cooking time" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kochen:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duración de cocción:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temps de cuisson:" + } + } + } + }, "Copy Link" : { "localizations" : { "de" : { @@ -1173,6 +1283,28 @@ } } }, + "Grocery List" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einkaufsliste" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de la compra" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liste de courses" + } + } + } + }, "If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : { "localizations" : { "de" : { @@ -1443,6 +1575,28 @@ } } }, + "Keep screen awake when viewing recipes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatische Bildschirmsperre beim Ansehen von Rezepten deaktivieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantener la pantalla encendida al ver recetas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Garder l'écran allumé lors de la consultation des recettes" + } + } + } + }, "Keywords" : { "localizations" : { "de" : { @@ -2565,6 +2719,28 @@ } } }, + "SwiftSoup" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, "Thank you for downloading" : { "localizations" : { "de" : { @@ -2829,6 +3005,28 @@ } } }, + "TPPDF" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, "Unable to complete action." : { "localizations" : { "de" : { @@ -3026,6 +3224,50 @@ } } } + }, + "You're all set for cooking 🍓" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sie sind bereit zum Kochen 🍓" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estás listo(a) para cocinar 🍓" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous êtes prêt(e) pour cuisiner 🍓" + } + } + } + }, + "Your grocery list is stored locally and therefore not synchronized across your devices." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Einkaufsliste wird lokal gespeichert und daher nicht auf andere Geräte übertragen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu lista de la compra se almacena localmente y, por lo tanto, no se sincroniza en tus dispositivos." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre liste de courses est stockée localement et n'est donc pas synchronisée sur vos appareils." + } + } + } } }, "version" : "1.0" diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index 5498875..f831488 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -11,7 +11,7 @@ import SwiftUI @main struct Nextcloud_Cookbook_iOS_ClientApp: App { - @StateObject var mainViewModel = MainViewModel() + @StateObject var mainViewModel = AppState() @AppStorage("onboarding") var onboarding = true @AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en" diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/AppState.swift similarity index 93% rename from Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift rename to Nextcloud Cookbook iOS Client/ViewModels/AppState.swift index 389cf69..b1278c9 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/AppState.swift @@ -10,16 +10,16 @@ import SwiftUI import UIKit -@MainActor class MainViewModel: ObservableObject { - @ObservedObject var userSettings = UserSettings.shared - +@MainActor class AppState: ObservableObject { @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:] + @Published var timers: [String: RecipeTimer] = [:] var recipeImages: [Int: [String: UIImage]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] + private let api: CookbookApi.Type private let dataStore: DataStore @@ -28,10 +28,10 @@ import UIKit self.api = api self.dataStore = DataStore() - if userSettings.authString == "" { - let loginString = "\(userSettings.username):\(userSettings.token)" + if UserSettings.shared.authString == "" { + let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginData = loginString.data(using: String.Encoding.utf8)! - userSettings.authString = loginData.base64EncodedString() + UserSettings.shared.authString = loginData.base64EncodedString() } } @@ -49,7 +49,7 @@ import UIKit */ func getCategories() async { let (categories, _) = await api.getCategories( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let categories = categories { print("Successfully loaded categories") @@ -97,7 +97,7 @@ import UIKit func getServer(store: Bool = false) async -> Bool { let (recipes, _) = await api.getCategory( - auth: userSettings.authString, + auth: UserSettings.shared.authString, named: categoryString ) if let recipes = recipes { @@ -130,16 +130,16 @@ import UIKit for category in self.categories { await updateRecipeDetails(in: category.name) } - userSettings.lastUpdate = Date() + UserSettings.shared.lastUpdate = Date() } func updateRecipeDetails(in category: String) async { - guard userSettings.storeRecipes else { return } + guard UserSettings.shared.storeRecipes else { return } guard let recipes = self.recipes[category] else { return } for recipe in recipes { if needsUpdate(category: category, lastModified: recipe.dateModified) { print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)") - await updateRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages) + await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } else { print("\(recipe.name) is up to date.") } @@ -159,7 +159,7 @@ import UIKit */ func getRecipes() async -> [Recipe] { let (recipes, error) = await api.getRecipes( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let recipes = recipes { return recipes @@ -199,7 +199,7 @@ import UIKit func getServer() async -> RecipeDetail? { let (recipe, error) = await api.getRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id ) if let recipe = recipe { @@ -292,7 +292,7 @@ import UIKit func getServer() async -> UIImage? { let (image, _) = await api.getImage( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id, size: size ) @@ -368,7 +368,7 @@ import UIKit func getServer() async -> [RecipeKeyword]? { let (tags, _) = await api.getTags( - auth: userSettings.authString + auth: UserSettings.shared.authString ) return tags } @@ -421,7 +421,7 @@ import UIKit */ func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { let (error) = await api.deleteRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, id: id ) @@ -452,7 +452,7 @@ import UIKit */ func checkServerConnection() async -> Bool { let (categories, _) = await api.getCategories( - auth: userSettings.authString + auth: UserSettings.shared.authString ) if let categories = categories { self.categories = categories @@ -481,12 +481,12 @@ import UIKit var error: NetworkError? = nil if createNew { error = await api.createRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, recipe: recipeDetail ) } else { error = await api.updateRecipe( - auth: userSettings.authString, + auth: UserSettings.shared.authString, recipe: recipeDetail ) } @@ -499,7 +499,7 @@ import UIKit 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( - auth: userSettings.authString, + auth: UserSettings.shared.authString, data: data ) if error != nil { @@ -507,12 +507,13 @@ import UIKit } return (recipeDetail, nil) } + } -extension MainViewModel { +extension AppState { func loadLocal(path: String) async -> T? { do { return try await dataStore.load(fromPath: path) @@ -608,3 +609,21 @@ extension DateFormatter { return dateFormatter.string(from: date) } } + + +// Timer logic +extension AppState { + func createTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { + let timer = RecipeTimer(duration: duration) + timers[recipeId] = timer + return timer + } + + func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { + return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration) + } + + func deleteTimer(forRecipe recipeId: String) { + timers.removeValue(forKey: recipeId) + } +} diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index b335fcc..a91be4d 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI @MainActor class RecipeEditViewModel: ObservableObject { - @ObservedObject var mainViewModel: MainViewModel + @ObservedObject var mainViewModel: AppState @Published var recipe: RecipeDetail = RecipeDetail() @Published var prepDuration: DurationComponents = DurationComponents() @@ -29,12 +29,12 @@ import SwiftUI var waitingForUpload: Bool = false - init(mainViewModel: MainViewModel, uploadNew: Bool) { + init(mainViewModel: AppState, uploadNew: Bool) { self.mainViewModel = mainViewModel self.uploadNew = uploadNew } - init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, uploadNew: Bool) { + init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) { self.mainViewModel = mainViewModel self.recipe = recipeDetail self.uploadNew = uploadNew diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 17e1794..b148126 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -7,8 +7,65 @@ import SwiftUI - struct MainView: View { + @StateObject var viewModel = AppState() + @StateObject var groceryList = GroceryList() + @StateObject var recipeViewModel = RecipeTabView.ViewModel() + @StateObject var searchViewModel = SearchTabView.ViewModel() + + enum Tab { + case recipes, search, groceryList + } + + var body: some View { + TabView { + RecipeTabView() + .environmentObject(recipeViewModel) + .environmentObject(viewModel) + .environmentObject(groceryList) + .tabItem { + Label("Recipes", systemImage: "book.closed.fill") + } + .tag(Tab.recipes) + + SearchTabView() + .environmentObject(searchViewModel) + .environmentObject(viewModel) + .environmentObject(groceryList) + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + .tag(Tab.search) + + GroceryListTabView() + .environmentObject(groceryList) + .tabItem { + Label("Grocery List", systemImage: "storefront") + } + .tag(Tab.groceryList) + } + .task { + recipeViewModel.presentLoadingIndicator = true + await viewModel.getCategories() + await viewModel.updateAllRecipeDetails() + + // Open detail view for default category + if UserSettings.shared.defaultCategory != "" { + if let cat = viewModel.categories.first(where: { c in + if c.name == UserSettings.shared.defaultCategory { + return true + } + return false + }) { + recipeViewModel.selectedCategory = cat + } + } + await groceryList.load() + recipeViewModel.presentLoadingIndicator = false + } + } +} +/*struct MainView: View { @ObservedObject var viewModel: MainViewModel @StateObject var userSettings: UserSettings = UserSettings.shared @@ -214,43 +271,5 @@ struct MainView: View { -struct RecipeSearchView: View { - @ObservedObject var viewModel: MainViewModel - @State var searchText: String = "" - @State var allRecipes: [Recipe] = [] - - var body: some View { - NavigationStack { - VStack { - ScrollView(showsIndicators: false) { - LazyVStack { - ForEach(recipesFiltered(), id: \.recipe_id) { recipe in - NavigationLink(value: recipe) { - RecipeCardView(viewModel: viewModel, recipe: recipe) - .shadow(radius: 2) - } - .buttonStyle(.plain) - } - } - } - .navigationDestination(for: Recipe.self) { recipe in - RecipeDetailView(viewModel: viewModel, recipe: recipe) - } - .searchable(text: $searchText, prompt: "Search recipes/keywords") - } - .navigationTitle("Search recipe") - } - .task { - allRecipes = await viewModel.getRecipes() - } - } - - func recipesFiltered() -> [Recipe] { - guard searchText != "" else { return allRecipes } - return allRecipes.filter { recipe in - recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term - (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term - } - } -} +*/ diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditing/CategoryPickerView.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/Views/CategoryPickerView.swift rename to Nextcloud Cookbook iOS Client/Views/RecipeEditing/CategoryPickerView.swift diff --git a/Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditing/KeywordPickerView.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/Views/KeywordPickerView.swift rename to Nextcloud Cookbook iOS Client/Views/RecipeEditing/KeywordPickerView.swift diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditing/RecipeEditView.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift rename to Nextcloud Cookbook iOS Client/Views/RecipeEditing/RecipeEditView.swift diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift similarity index 97% rename from Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift rename to Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift index 706e762..06607db 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift @@ -13,7 +13,7 @@ import SwiftUI struct CategoryDetailView: View { @State var categoryName: String @State var searchText: String = "" - @ObservedObject var viewModel: MainViewModel + @ObservedObject var viewModel: AppState @Binding var showEditView: Bool var body: some View { diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift similarity index 98% rename from Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift rename to Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift index 5f2b44d..abefdd1 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI struct RecipeCardView: View { - @State var viewModel: MainViewModel + @State var viewModel: AppState @State var recipe: Recipe @State var recipeThumb: UIImage? @State var isDownloaded: Bool? = nil diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift similarity index 79% rename from Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift rename to Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift index aa3f51e..510255d 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift @@ -10,7 +10,7 @@ import SwiftUI struct RecipeDetailView: View { - @ObservedObject var viewModel: MainViewModel + @ObservedObject var viewModel: AppState @State var recipe: Recipe @State var recipeDetail: RecipeDetail? @State var recipeImage: UIImage? @@ -25,13 +25,15 @@ struct RecipeDetailView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading) { - if let recipeImage = recipeImage { - Image(uiImage: recipeImage) - .resizable() - .scaledToFill() - .frame(maxHeight: 300) - .clipped() - } + ZStack { + if let recipeImage = recipeImage { + Image(uiImage: recipeImage) + .resizable() + .scaledToFill() + .frame(maxHeight: 300) + .clipped() + } + }.animation(.easeInOut, value: recipeImage) if let recipeDetail = recipeDetail { LazyVStack (alignment: .leading) { @@ -62,7 +64,7 @@ struct RecipeDetailView: View { Divider() - RecipeDurationSection(recipeDetail: recipeDetail) + RecipeDurationSection(viewModel: viewModel, recipeDetail: recipeDetail) LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { if(!recipeDetail.recipeIngredient.isEmpty) { @@ -82,7 +84,7 @@ struct RecipeDetailView: View { }.padding(.horizontal, 5) } - }.animation(.easeInOut, value: recipeImage) + } } .navigationBarTitleDisplayMode(.inline) .navigationTitle(showTitle ? recipe.name : "") @@ -157,6 +159,14 @@ struct RecipeDetailView: View { fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer ) } + .onAppear { + if UserSettings.shared.keepScreenAwake { + UIApplication.shared.isIdleTimerDisabled = true + } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } } } @@ -206,10 +216,11 @@ fileprivate struct ShareView: View { fileprivate struct RecipeDurationSection: View { + @ObservedObject var viewModel: AppState @State var recipeDetail: RecipeDetail var body: some View { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), alignment: .leading)]) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 250), alignment: .leading)]) { if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) { VStack(alignment: .leading) { HStack { @@ -220,6 +231,12 @@ fileprivate struct RecipeDurationSection: View { .lineLimit(1) }.padding() } + /* + if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) { + TimerView(timer: viewModel.getTimer(forRecipe: recipeDetail.id, duration: DurationComponents.fromPTString(cookTime))) + .padding() + } + */ if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) { VStack(alignment: .leading) { @@ -337,7 +354,9 @@ fileprivate struct MoreInformationSection: View { fileprivate struct RecipeIngredientSection: View { + @EnvironmentObject var groceryList: GroceryList @State var recipeDetail: RecipeDetail + var body: some View { VStack(alignment: .leading) { HStack { @@ -349,10 +368,25 @@ fileprivate struct RecipeIngredientSection: View { SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings")) } Spacer() + Button { + withAnimation { + if groceryList.containsRecipe(recipeDetail.id) { + groceryList.deleteGroceryRecipe(recipeDetail.id) + } else { + groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name) + } + } + } label: { + Image(systemName: "storefront") + } } + ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in - IngredientListItem(ingredient: ingredient) + IngredientListItem(ingredient: ingredient, recipeId: recipeDetail.id) { + groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name) + } .padding(4) + } }.padding() } @@ -375,12 +409,23 @@ fileprivate struct RecipeToolSection: View { fileprivate struct IngredientListItem: View { + @EnvironmentObject var groceryList: GroceryList @State var ingredient: String + @State var recipeId: String + let addToGroceryListAction: () -> Void @State var isSelected: Bool = false + // Drag animation + @State private var dragOffset: CGFloat = 0 + @State private var animationStartOffset: CGFloat = 0 + let maxDragDistance = 50.0 + var body: some View { HStack(alignment: .top) { - if isSelected { + if groceryList.containsItem(at: recipeId, item: ingredient) { + Image(systemName: "storefront") + .foregroundStyle(Color.green) + } else if isSelected { Image(systemName: "checkmark.circle") } else { Image(systemName: "circle") @@ -389,12 +434,43 @@ fileprivate struct IngredientListItem: View { Text("\(ingredient)") .multilineTextAlignment(.leading) .lineLimit(5) + Spacer() } .foregroundStyle(isSelected ? Color.secondary : Color.primary) .onTapGesture { isSelected.toggle() } + .offset(x: dragOffset, y: 0) .animation(.easeInOut, value: isSelected) + + .gesture( + DragGesture() + .onChanged { gesture in + // Update drag offset as the user drags + if animationStartOffset == 0 { + animationStartOffset = gesture.translation.width + } + let dragAmount = gesture.translation.width + let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset + self.dragOffset = max(0, offset) + } + .onEnded { gesture in + withAnimation { + if dragOffset > maxDragDistance * 0.3 { // Swipe threshold + if groceryList.containsItem(at: recipeId, item: ingredient) { + groceryList.deleteItem(ingredient, fromRecipe: recipeId) + } else { + addToGroceryListAction() + } + + } + // Animate back to original position + + self.dragOffset = 0 + self.animationStartOffset = 0 + } + } + ) } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift new file mode 100644 index 0000000..ce3ee6c --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift @@ -0,0 +1,206 @@ +// +// TimerView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 11.01.24. +// + +import Foundation +import SwiftUI +import Combine +import AVFoundation +import UserNotifications + + +struct TimerView: View { + @ObservedObject var timer: RecipeTimer + + var body: some View { + HStack { + Gauge(value: timer.timeTotal - timer.timeElapsed, in: 0...timer.timeTotal) { + Text("Cooking time") + } currentValueLabel: { + Button { + if timer.isRunning { + timer.pause() + } else { + timer.start() + } + } label: { + if timer.isRunning { + Image(systemName: "pause.fill") + } else { + Image(systemName: "play.fill") + } + } + } + .gaugeStyle(.accessoryCircularCapacity) + .animation(.easeInOut, value: timer.timeElapsed) + .tint(timer.isRunning ? .green : .nextcloudBlue) + .foregroundStyle(timer.isRunning ? Color.green : Color.nextcloudBlue) + + VStack(alignment: .leading) { + Text("Cooking") + Text(timer.duration.toTimerText()) + } + .padding(.horizontal) + + Button { + timer.cancel() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(timer.isRunning ? Color.nextcloudBlue : Color.secondary) + } + } + .bold() + .padding() + .background { + RoundedRectangle(cornerRadius: 20) + .foregroundStyle(.ultraThickMaterial) + } + } +} + + + +class RecipeTimer: ObservableObject { + var timeTotal: Double + @Published var duration: DurationComponents + private var startDate: Date? + private var pauseDate: Date? + @Published var timeElapsed: Double = 0 + @Published var isRunning: Bool = false + @Published var timerExpired: Bool = false + private var timer: Timer.TimerPublisher? + private var timerCancellable: Cancellable? + var audioPlayer: AVAudioPlayer? + + init(duration: DurationComponents) { + self.duration = duration + self.timeTotal = duration.toSeconds() + } + + func start() { + self.isRunning = true + if startDate == nil { + startDate = Date() + } else if let pauseDate = pauseDate { + // Adjust start date based on the pause duration + let pauseDuration = Date().timeIntervalSince(pauseDate) + startDate = startDate?.addingTimeInterval(pauseDuration) + } + requestNotificationPermissions() + scheduleTimerNotification(timeInterval: timeTotal) + // Prepare audio session + setupAudioSession() + prepareAudioPlayer(with: "alarm_sound_0") + + self.timer = Timer.publish(every: 1, on: .main, in: .common) + self.timerCancellable = self.timer?.autoconnect().sink { [weak self] _ in + DispatchQueue.main.async { + if let self = self, let startTime = self.startDate { + let elapsed = Date().timeIntervalSince(startTime) + if elapsed < self.timeTotal { + self.timeElapsed = elapsed + self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed)) + } else { + self.timerExpired = true + self.timeElapsed = self.timeTotal + self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed)) + self.pause() + + self.startAlarm() + } + } + } + } + } + + func pause() { + self.isRunning = false + pauseDate = Date() + self.timerCancellable?.cancel() + self.timerCancellable = nil + self.timer = nil + } + + func resume() { + self.isRunning = true + start() + } + + func cancel() { + self.isRunning = false + self.timerCancellable?.cancel() + self.timerCancellable = nil + self.timer = nil + self.timeElapsed = 0 + self.startDate = nil + self.pauseDate = nil + self.duration.fromSeconds(Int(timeTotal)) + } +} + +extension RecipeTimer { + func setupAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to set audio session category. Error: \(error)") + } + } + + func prepareAudioPlayer(with soundName: String) { + if let soundURL = Bundle.main.url(forResource: "alarm_sound_0", withExtension: "mp3") { + do { + audioPlayer = try AVAudioPlayer(contentsOf: soundURL) + audioPlayer?.prepareToPlay() + audioPlayer?.numberOfLoops = -1 // Loop indefinitely + } catch { + print("Error loading sound file: \(error)") + } + } + } + + func postNotification() { + NotificationCenter.default.post(name: Notification.Name("AlarmNotification"), object: nil) + } + + func startAlarm() { + audioPlayer?.play() + postNotification() + } + + func stopAlarm() { + audioPlayer?.stop() + try? AVAudioSession.sharedInstance().setActive(false) + } + + func requestNotificationPermissions() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if granted { + print("Notification permission granted.") + } else if let error = error { + print("Notification permission denied because: \(error.localizedDescription).") + } + } + } + + func scheduleTimerNotification(timeInterval: TimeInterval) { + let content = UNMutableNotificationContent() + content.title = "Timer Finished" + content.body = "Your timer is up!" + content.sound = UNNotificationSound.default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) + + let request = UNNotificationRequest(identifier: "timerNotification", content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error scheduling notification: \(error)") + } + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/CollapsibleView.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/Views/CollapsibleView.swift rename to Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index a01a156..5fa365b 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -11,7 +11,7 @@ import SwiftUI struct SettingsView: View { - @ObservedObject var viewModel: MainViewModel + @EnvironmentObject var viewModel: AppState @ObservedObject var userSettings = UserSettings.shared @State fileprivate var alertType: SettingsAlert = .NONE @@ -48,6 +48,12 @@ struct SettingsView: View { Text("Configure which sections in your recipes are expanded by default.") } + Section { + Toggle(isOn: $userSettings.keepScreenAwake) { + Text("Keep screen awake when viewing recipes") + } + } + Section { Toggle(isOn: $userSettings.storeRecipes) { Text("Offline recipes") @@ -112,6 +118,23 @@ struct SettingsView: View { } footer: { Text("Deleting local data will not affect the recipe data stored on your server.") } + + Section(header: Text("Acknowledgements")) { + VStack(alignment: .leading) { + if let url = URL(string: "https://github.com/scinfu/SwiftSoup") { + Link("SwiftSoup", destination: url) + .font(.headline) + Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.") + } + } + VStack(alignment: .leading) { + if let url = URL(string: "https://github.com/techprimate/TPPDF") { + Link("TPPDF", destination: url) + .font(.headline) + Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.") + } + } + } } .navigationTitle("Settings") .alert(alertType.getTitle(), isPresented: $showAlert) { diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift new file mode 100644 index 0000000..ebb4526 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -0,0 +1,235 @@ +// +// GroceryListTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct GroceryListTabView: View { + @EnvironmentObject var groceryList: GroceryList + + var body: some View { + NavigationStack { + if groceryList.groceryDict.isEmpty { + EmptyGroceryListView() + } else { + List { + ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in + Section { + ForEach(groceryList.groceryDict[key]!.items) { item in + GroceryListItemView(item: item, toggleAction: { + groceryList.toggleItemChecked(item) + groceryList.objectWillChange.send() + }, deleteAction: { + groceryList.deleteItem(item.name, fromRecipe: key) + withAnimation { + groceryList.objectWillChange.send() + } + }) + } + } header: { + HStack { + Text(groceryList.groceryDict[key]!.name) + .foregroundStyle(Color.nextcloudBlue) + Spacer() + Button { + groceryList.deleteGroceryRecipe(key) + } label: { + Image(systemName: "trash") + .foregroundStyle(Color.nextcloudBlue) + } + } + } + } + } + + .listStyle(.plain) + .navigationTitle("Grocery List") + .toolbar { + Button { + groceryList.deleteAll() + } label: { + Text("Delete") + .foregroundStyle(Color.nextcloudBlue) + } + } + } + } + } +} + + + +fileprivate struct GroceryListItemView: View { + let item: GroceryRecipeItem + let toggleAction: () -> Void + let deleteAction: () -> Void + + var body: some View { + HStack(alignment: .top) { + if item.isChecked { + Image(systemName: "checkmark.circle") + } else { + Image(systemName: "circle") + } + + Text("\(item.name)") + .multilineTextAlignment(.leading) + .lineLimit(5) + } + .padding(5) + .foregroundStyle(item.isChecked ? Color.secondary : Color.primary) + .onTapGesture(perform: toggleAction) + .animation(.easeInOut, value: item.isChecked) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(action: deleteAction) { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } +} + + + +fileprivate struct EmptyGroceryListView: View { + var body: some View { + List { + Text("You're all set for cooking 🍓") + .font(.headline) + Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.") + .foregroundStyle(.secondary) + Text("Your grocery list is stored locally and therefore not synchronized across your devices.") + .foregroundStyle(.secondary) + } + .navigationTitle("Grocery List") + } +} + + + +class GroceryRecipe: Identifiable, Codable { + let name: String + var items: [GroceryRecipeItem] + + init(name: String, items: [GroceryRecipeItem]) { + self.name = name + self.items = items + } + + init(name: String, item: GroceryRecipeItem) { + self.name = name + self.items = [item] + } +} + + + +class GroceryRecipeItem: Identifiable, Codable { + let name: String + var isChecked: Bool + + init(_ name: String, isChecked: Bool = false) { + self.name = name + self.isChecked = isChecked + } +} + + + +@MainActor class GroceryList: ObservableObject { + let dataStore: DataStore = DataStore() + @Published var groceryDict: [String: GroceryRecipe] = [:] + @Published var sortBySimilarity: Bool = false + + + func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) { + print("Adding item of recipe \(String(describing: recipeName))") + DispatchQueue.main.async { + if self.groceryDict[recipeId] != nil { + self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName)) + } else { + let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)]) + self.groceryDict[recipeId] = newRecipe + } + if saveGroceryDict { + self.save() + self.objectWillChange.send() + } + } + } + + func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) { + for item in items { + addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false) + } + save() + objectWillChange.send() + } + + func deleteItem(_ itemName: String, fromRecipe recipeId: String) { + print("Deleting item \(itemName)") + guard let recipe = groceryDict[recipeId] else { return } + guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return } + groceryDict[recipeId]?.items.remove(at: itemIndex) + if groceryDict[recipeId]!.items.isEmpty { + groceryDict.removeValue(forKey: recipeId) + } + save() + objectWillChange.send() + } + + func deleteGroceryRecipe(_ recipeId: String) { + print("Deleting grocery recipe with id \(recipeId)") + groceryDict.removeValue(forKey: recipeId) + save() + objectWillChange.send() + } + + func deleteAll() { + print("Deleting all grocery items") + groceryDict = [:] + save() + } + + func toggleItemChecked(_ groceryItem: GroceryRecipeItem) { + print("Item checked: \(groceryItem.name)") + groceryItem.isChecked.toggle() + save() + } + + func containsItem(at recipeId: String, item: String) -> Bool { + guard let recipe = groceryDict[recipeId] else { return false } + if recipe.items.contains(where: { $0.name == item }) { + return true + } + return false + } + + func containsRecipe(_ recipeId: String) -> Bool { + return groceryDict[recipeId] != nil + } + + func save() { + Task { + await dataStore.save(data: groceryDict, toPath: "grocery_list.data") + } + } + + func load() async { + do { + guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load( + fromPath: "grocery_list.data" + ) else { return } + self.groceryDict = groceryDict + } catch { + print("Unable to load grocery list") + } + } +} + + diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift new file mode 100644 index 0000000..d9c266f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -0,0 +1,171 @@ +// +// RecipeTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct RecipeTabView: View { + @EnvironmentObject var viewModel: RecipeTabView.ViewModel + @EnvironmentObject var mainViewModel: AppState + + var body: some View { + NavigationSplitView { + List(selection: $viewModel.selectedCategory) { + // Categories + ForEach(mainViewModel.categories) { category in + if category.recipe_count != 0 { + NavigationLink(value: category) { + HStack(alignment: .center) { + if viewModel.selectedCategory != nil && category.name == viewModel.selectedCategory!.name { + Image(systemName: "book") + } else { + Image(systemName: "book.closed.fill") + } + Text(category.name == "*" ? String(localized: "Other") : category.name) + .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) + } + } + } + } + .navigationTitle("Cookbooks") + .toolbar { + RecipeTabViewToolBar() + } + .navigationDestination(isPresented: $viewModel.presentSettingsView) { + SettingsView() + } + } detail: { + NavigationStack { + if let category = viewModel.selectedCategory { + CategoryDetailView( + categoryName: category.name, + viewModel: mainViewModel, + showEditView: $viewModel.presentEditView + ) + .id(category.id) // Workaround: This is needed to update the detail view when the selection changes + } + } + } + .tint(.nextcloudBlue) + .sheet(isPresented: $viewModel.presentEditView) { + RecipeEditView( + viewModel: + RecipeEditViewModel( + mainViewModel: mainViewModel, + uploadNew: true + ), + isPresented: $viewModel.presentEditView + ) + } + .task { + viewModel.serverConnection = await mainViewModel.checkServerConnection() + } + .refreshable { + viewModel.serverConnection = await mainViewModel.checkServerConnection() + await mainViewModel.getCategories() + } + } + + class ViewModel: ObservableObject { + @Published var presentEditView: Bool = false + @Published var presentSettingsView: Bool = false + + @Published var presentLoadingIndicator: Bool = false + @Published var presentConnectionPopover: Bool = false + @Published var serverConnection: Bool = false + + @Published var selectedCategory: Category? = nil + } +} + + +fileprivate struct RecipeTabViewToolBar: ToolbarContent { + @EnvironmentObject var mainViewModel: AppState + @EnvironmentObject var viewModel: RecipeTabView.ViewModel + + var body: some ToolbarContent { + // Top left menu toolbar item + ToolbarItem(placement: .topBarLeading) { + Menu { + Button { + Task { + viewModel.presentLoadingIndicator = true + UserSettings.shared.lastUpdate = Date.distantPast + await mainViewModel.getCategories() + for category in mainViewModel.categories { + await mainViewModel.getCategory(named: category.name, fetchMode: .preferServer) + } + await mainViewModel.updateAllRecipeDetails() + viewModel.presentLoadingIndicator = false + } + } label: { + Text("Refresh all") + Image(systemName: "icloud.and.arrow.down") + } + + Button { + viewModel.presentSettingsView = true + } label: { + Text("Settings") + Image(systemName: "gearshape") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + // Server connection indicator + ToolbarItem(placement: .topBarTrailing) { + Button { + print("Check server connection") + viewModel.presentConnectionPopover = true + } label: { + if viewModel.presentLoadingIndicator { + ProgressView() + } else if viewModel.serverConnection { + Image(systemName: "checkmark.icloud") + } else { + Image(systemName: "xmark.icloud") + } + }.popover(isPresented: $viewModel.presentConnectionPopover) { + VStack(alignment: .leading) { + Text(viewModel.serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server.")) + .bold() + + Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))") + .font(.caption) + .foregroundStyle(Color.secondary) + } + .padding() + .presentationCompactAdaptation(.popover) + } + } + + // Create new recipes + ToolbarItem(placement: .topBarTrailing) { + Button { + print("Add new recipe") + viewModel.presentEditView = true + } label: { + Image(systemName: "plus.circle.fill") + } + } + + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift new file mode 100644 index 0000000..e5fea0e --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -0,0 +1,59 @@ +// +// SearchTabView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 23.01.24. +// + +import Foundation +import SwiftUI + + +struct SearchTabView: View { + @EnvironmentObject var viewModel: SearchTabView.ViewModel + @EnvironmentObject var mainViewModel: AppState + + var body: some View { + NavigationStack { + VStack { + ScrollView(showsIndicators: false) { + LazyVStack { + ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in + NavigationLink(value: recipe) { + RecipeCardView(viewModel: mainViewModel, recipe: recipe) + .shadow(radius: 2) + } + .buttonStyle(.plain) + } + } + } + .navigationDestination(for: Recipe.self) { recipe in + RecipeDetailView(viewModel: mainViewModel, recipe: recipe) + } + .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") + } + .navigationTitle("Search recipe") + } + .task { + if viewModel.allRecipes.isEmpty { + viewModel.allRecipes = await mainViewModel.getRecipes() + } + } + .refreshable { + viewModel.allRecipes = await mainViewModel.getRecipes() + } + } + + class ViewModel: ObservableObject { + @Published var allRecipes: [Recipe] = [] + @Published var searchText: String = "" + + func recipesFiltered() -> [Recipe] { + guard searchText != "" else { return allRecipes } + return allRecipes.filter { recipe in + recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term + (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term + } + } + } +} diff --git a/Resources/alarm_sound_0.mp3 b/Resources/alarm_sound_0.mp3 new file mode 100644 index 0000000..8662518 Binary files /dev/null and b/Resources/alarm_sound_0.mp3 differ