Added tabs and a grocery list
This commit is contained in:
@@ -47,6 +47,9 @@
|
|||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.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 */; };
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
||||||
@@ -114,6 +117,9 @@
|
|||||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
||||||
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
||||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
||||||
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
||||||
@@ -237,8 +243,9 @@
|
|||||||
A70171BA2AB4980100064C43 /* Views */ = {
|
A70171BA2AB4980100064C43 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A7FB0D782B25C65200A3469E /* Onboarding */,
|
|
||||||
A70171832AA8E71900064C43 /* MainView.swift */,
|
A70171832AA8E71900064C43 /* MainView.swift */,
|
||||||
|
A977D0DC2B6002DA009783A9 /* Tabs */,
|
||||||
|
A7FB0D782B25C65200A3469E /* Onboarding */,
|
||||||
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
|
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
|
||||||
@@ -318,6 +325,16 @@
|
|||||||
path = Onboarding;
|
path = Onboarding;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
|
);
|
||||||
|
path = Tabs;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = {
|
A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -481,6 +498,7 @@
|
|||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||||
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
||||||
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
||||||
@@ -505,12 +523,14 @@
|
|||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||||
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
||||||
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
|
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||||
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
Binary file not shown.
@@ -402,6 +402,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Add new recipe" : {
|
"Add new recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1241,6 +1244,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Grocery List" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3161,6 +3167,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"You're all set for cooking 🍓" : {
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import UIKit
|
|||||||
|
|
||||||
|
|
||||||
@MainActor class MainViewModel: ObservableObject {
|
@MainActor class MainViewModel: ObservableObject {
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
|
||||||
|
|
||||||
@Published var categories: [Category] = []
|
@Published var categories: [Category] = []
|
||||||
@Published var recipes: [String: [Recipe]] = [:]
|
@Published var recipes: [String: [Recipe]] = [:]
|
||||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||||
@@ -30,10 +28,10 @@ import UIKit
|
|||||||
self.api = api
|
self.api = api
|
||||||
self.dataStore = DataStore()
|
self.dataStore = DataStore()
|
||||||
|
|
||||||
if userSettings.authString == "" {
|
if UserSettings.shared.authString == "" {
|
||||||
let loginString = "\(userSettings.username):\(userSettings.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
let loginData = loginString.data(using: String.Encoding.utf8)!
|
let loginData = loginString.data(using: String.Encoding.utf8)!
|
||||||
userSettings.authString = loginData.base64EncodedString()
|
UserSettings.shared.authString = loginData.base64EncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func getCategories() async {
|
func getCategories() async {
|
||||||
let (categories, _) = await api.getCategories(
|
let (categories, _) = await api.getCategories(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let categories = categories {
|
if let categories = categories {
|
||||||
print("Successfully loaded categories")
|
print("Successfully loaded categories")
|
||||||
@@ -99,7 +97,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer(store: Bool = false) async -> Bool {
|
func getServer(store: Bool = false) async -> Bool {
|
||||||
let (recipes, _) = await api.getCategory(
|
let (recipes, _) = await api.getCategory(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
named: categoryString
|
named: categoryString
|
||||||
)
|
)
|
||||||
if let recipes = recipes {
|
if let recipes = recipes {
|
||||||
@@ -132,16 +130,16 @@ import UIKit
|
|||||||
for category in self.categories {
|
for category in self.categories {
|
||||||
await updateRecipeDetails(in: category.name)
|
await updateRecipeDetails(in: category.name)
|
||||||
}
|
}
|
||||||
userSettings.lastUpdate = Date()
|
UserSettings.shared.lastUpdate = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRecipeDetails(in category: String) async {
|
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 }
|
guard let recipes = self.recipes[category] else { return }
|
||||||
for recipe in recipes {
|
for recipe in recipes {
|
||||||
if needsUpdate(category: category, lastModified: recipe.dateModified) {
|
if needsUpdate(category: category, lastModified: recipe.dateModified) {
|
||||||
print("\(recipe.name) needs an update. (last modified: \(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 {
|
} else {
|
||||||
print("\(recipe.name) is up to date.")
|
print("\(recipe.name) is up to date.")
|
||||||
}
|
}
|
||||||
@@ -161,7 +159,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func getRecipes() async -> [Recipe] {
|
func getRecipes() async -> [Recipe] {
|
||||||
let (recipes, error) = await api.getRecipes(
|
let (recipes, error) = await api.getRecipes(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let recipes = recipes {
|
if let recipes = recipes {
|
||||||
return recipes
|
return recipes
|
||||||
@@ -201,7 +199,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> RecipeDetail? {
|
func getServer() async -> RecipeDetail? {
|
||||||
let (recipe, error) = await api.getRecipe(
|
let (recipe, error) = await api.getRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id
|
id: id
|
||||||
)
|
)
|
||||||
if let recipe = recipe {
|
if let recipe = recipe {
|
||||||
@@ -294,7 +292,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> UIImage? {
|
func getServer() async -> UIImage? {
|
||||||
let (image, _) = await api.getImage(
|
let (image, _) = await api.getImage(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id,
|
id: id,
|
||||||
size: size
|
size: size
|
||||||
)
|
)
|
||||||
@@ -370,7 +368,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> [RecipeKeyword]? {
|
func getServer() async -> [RecipeKeyword]? {
|
||||||
let (tags, _) = await api.getTags(
|
let (tags, _) = await api.getTags(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
@@ -423,7 +421,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
||||||
let (error) = await api.deleteRecipe(
|
let (error) = await api.deleteRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id
|
id: id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -454,7 +452,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func checkServerConnection() async -> Bool {
|
func checkServerConnection() async -> Bool {
|
||||||
let (categories, _) = await api.getCategories(
|
let (categories, _) = await api.getCategories(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let categories = categories {
|
if let categories = categories {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
@@ -483,12 +481,12 @@ import UIKit
|
|||||||
var error: NetworkError? = nil
|
var error: NetworkError? = nil
|
||||||
if createNew {
|
if createNew {
|
||||||
error = await api.createRecipe(
|
error = await api.createRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
recipe: recipeDetail
|
recipe: recipeDetail
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
error = await api.updateRecipe(
|
error = await api.updateRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
recipe: recipeDetail
|
recipe: recipeDetail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -501,7 +499,7 @@ import UIKit
|
|||||||
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
||||||
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
||||||
let (recipeDetail, error) = await api.importRecipe(
|
let (recipeDetail, error) = await api.importRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
data: data
|
data: data
|
||||||
)
|
)
|
||||||
if error != nil {
|
if error != nil {
|
||||||
@@ -509,6 +507,7 @@ import UIKit
|
|||||||
}
|
}
|
||||||
return (recipeDetail, nil)
|
return (recipeDetail, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,66 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
|
@StateObject var viewModel = MainViewModel()
|
||||||
|
@State var selectedCategory: Category? = nil
|
||||||
|
@State var showLoadingIndicator: Bool = false
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case recipes, search, shoppingList, settings
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
RecipeTabView(selectedCategory: $selectedCategory, showLoadingIndicator: $showLoadingIndicator)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.tabItem {
|
||||||
|
Label("Recipes", systemImage: "book.closed.fill")
|
||||||
|
}
|
||||||
|
.tag(Tab.recipes)
|
||||||
|
|
||||||
|
SearchTabView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.tabItem {
|
||||||
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
|
}
|
||||||
|
.tag(Tab.search)
|
||||||
|
|
||||||
|
GroceryListTabView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Grocery List", systemImage: "storefront")
|
||||||
|
}
|
||||||
|
.tag(Tab.shoppingList)
|
||||||
|
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
.tag(Tab.settings)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
showLoadingIndicator = 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
|
||||||
|
}) {
|
||||||
|
self.selectedCategory = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showLoadingIndicator = false
|
||||||
|
await GroceryList.shared.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*struct MainView: View {
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@ObservedObject var viewModel: MainViewModel
|
||||||
@StateObject var userSettings: UserSettings = UserSettings.shared
|
@StateObject var userSettings: UserSettings = UserSettings.shared
|
||||||
|
|
||||||
@@ -214,43 +272,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|||||||
@@ -364,10 +364,17 @@ fileprivate struct RecipeIngredientSection: View {
|
|||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button {
|
||||||
|
GroceryList.shared.addItems(recipeDetail.recipeIngredient)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "storefront")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
||||||
IngredientListItem(ingredient: ingredient)
|
IngredientListItem(ingredient: ingredient)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
|
|
||||||
}
|
}
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
@@ -392,9 +399,17 @@ fileprivate struct RecipeToolSection: View {
|
|||||||
fileprivate struct IngredientListItem: View {
|
fileprivate struct IngredientListItem: View {
|
||||||
@State var ingredient: String
|
@State var ingredient: String
|
||||||
@State var isSelected: Bool = false
|
@State var isSelected: Bool = false
|
||||||
|
@State private var dragOffset: CGFloat = 0
|
||||||
|
let maxDragDistance = 30.0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
|
if dragOffset > 0 {
|
||||||
|
Image(systemName: "storefront")
|
||||||
|
.padding(2)
|
||||||
|
.background(Color.green)
|
||||||
|
.opacity((dragOffset - 10)/(maxDragDistance-10))
|
||||||
|
}
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
} else {
|
} else {
|
||||||
@@ -404,12 +419,31 @@ fileprivate struct IngredientListItem: View {
|
|||||||
Text("\(ingredient)")
|
Text("\(ingredient)")
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isSelected.toggle()
|
isSelected.toggle()
|
||||||
}
|
}
|
||||||
.animation(.easeInOut, value: isSelected)
|
.animation(.easeInOut, value: isSelected)
|
||||||
|
.offset(x: dragOffset, y: 0)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { gesture in
|
||||||
|
// Update drag offset as the user drags
|
||||||
|
let dragAmount = gesture.translation.width
|
||||||
|
self.dragOffset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7))
|
||||||
|
}
|
||||||
|
.onEnded { gesture in
|
||||||
|
if gesture.translation.width > maxDragDistance * 0.8 { // Swipe right threshold
|
||||||
|
GroceryList.shared.addItem(ingredient)
|
||||||
|
}
|
||||||
|
// Animate back to original position
|
||||||
|
withAnimation {
|
||||||
|
self.dragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@EnvironmentObject var viewModel: MainViewModel
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
|
|
||||||
@State fileprivate var alertType: SettingsAlert = .NONE
|
@State fileprivate var alertType: SettingsAlert = .NONE
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// GroceryListTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct GroceryListTabView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if GroceryList.shared.listItems.isEmpty {
|
||||||
|
List {
|
||||||
|
Text("You're all set for cooking 🍓")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Grocery List")
|
||||||
|
} else {
|
||||||
|
List(GroceryList.shared.listItems) { item in
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
if item.isChecked {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(item.name)")
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(5)
|
||||||
|
}
|
||||||
|
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
item.isChecked.toggle()
|
||||||
|
}
|
||||||
|
.animation(.easeInOut, value: item.isChecked)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button {
|
||||||
|
GroceryList.shared.removeItem(item.name)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Grocery List")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class GroceryListItem: ObservableObject, Identifiable, Codable {
|
||||||
|
var name: String
|
||||||
|
var isChecked: Bool
|
||||||
|
|
||||||
|
init(_ name: String, isChecked: Bool = false) {
|
||||||
|
self.name = name
|
||||||
|
self.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class GroceryList: ObservableObject {
|
||||||
|
static let shared: GroceryList = GroceryList()
|
||||||
|
|
||||||
|
let dataStore: DataStore = DataStore()
|
||||||
|
@Published var listItems: [GroceryListItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
func addItem(_ name: String) {
|
||||||
|
listItems.append(GroceryListItem(name))
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addItems(_ items: [String]) {
|
||||||
|
for item in items {
|
||||||
|
addItem(item)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeItem(_ name: String) {
|
||||||
|
guard let ix = listItems.firstIndex(where: { item in
|
||||||
|
item.name == name
|
||||||
|
}) else { return }
|
||||||
|
listItems.remove(at: ix)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: listItems, toPath: "grocery_list.data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
do {
|
||||||
|
guard let listItems: [GroceryListItem] = try await dataStore.load(
|
||||||
|
fromPath: "grocery_list.data"
|
||||||
|
) else { return }
|
||||||
|
self.listItems = listItems
|
||||||
|
} catch {
|
||||||
|
print("Unable to load grocery list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal file
166
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//
|
||||||
|
// RecipeTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct RecipeTabView: View {
|
||||||
|
@Binding var selectedCategory: Category?
|
||||||
|
@Binding var showLoadingIndicator: Bool
|
||||||
|
|
||||||
|
@EnvironmentObject var viewModel: MainViewModel
|
||||||
|
@StateObject var userSettings: UserSettings = UserSettings.shared
|
||||||
|
|
||||||
|
@State private var showEditView: Bool = false
|
||||||
|
@State private var serverConnection: Bool = false
|
||||||
|
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List(selection: $selectedCategory) {
|
||||||
|
// Categories
|
||||||
|
ForEach(viewModel.categories) { category in
|
||||||
|
if category.recipe_count != 0 {
|
||||||
|
NavigationLink(value: category) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
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: .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(
|
||||||
|
viewModel: viewModel,
|
||||||
|
showEditView: $showEditView,
|
||||||
|
serverConnection: $serverConnection,
|
||||||
|
showLoadingIndicator: $showLoadingIndicator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
NavigationStack {
|
||||||
|
if let category = selectedCategory {
|
||||||
|
CategoryDetailView(
|
||||||
|
categoryName: category.name,
|
||||||
|
viewModel: viewModel,
|
||||||
|
showEditView: $showEditView
|
||||||
|
)
|
||||||
|
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(.nextcloudBlue)
|
||||||
|
.sheet(isPresented: $showEditView) {
|
||||||
|
RecipeEditView(
|
||||||
|
viewModel:
|
||||||
|
RecipeEditViewModel(
|
||||||
|
mainViewModel: viewModel,
|
||||||
|
uploadNew: true
|
||||||
|
),
|
||||||
|
isPresented: $showEditView
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
self.serverConnection = await viewModel.checkServerConnection()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
self.serverConnection = await viewModel.checkServerConnection()
|
||||||
|
await viewModel.getCategories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
|
@ObservedObject var viewModel: MainViewModel
|
||||||
|
@Binding var showEditView: Bool
|
||||||
|
@Binding var serverConnection: Bool
|
||||||
|
@Binding var showLoadingIndicator: Bool
|
||||||
|
@State private var presentPopover: Bool = false
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
|
// Top left menu toolbar item
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
showLoadingIndicator = true
|
||||||
|
UserSettings.shared.lastUpdate = Date.distantPast
|
||||||
|
await viewModel.getCategories()
|
||||||
|
for category in viewModel.categories {
|
||||||
|
await viewModel.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
|
}
|
||||||
|
await viewModel.updateAllRecipeDetails()
|
||||||
|
showLoadingIndicator = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Refresh all")
|
||||||
|
Image(systemName: "icloud.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server connection indicator
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
print("Check server connection")
|
||||||
|
presentPopover = true
|
||||||
|
} label: {
|
||||||
|
if showLoadingIndicator {
|
||||||
|
ProgressView()
|
||||||
|
} else if serverConnection {
|
||||||
|
Image(systemName: "checkmark.icloud")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "xmark.icloud")
|
||||||
|
}
|
||||||
|
}.popover(isPresented: $presentPopover) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(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")
|
||||||
|
showEditView = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal file
58
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// SearchTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct SearchTabView: View {
|
||||||
|
@EnvironmentObject var viewModel: MainViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RecipeSearchView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user