diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index f1af7c4..f3bb6d2 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -742,7 +742,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.8.1; + MARKETING_VERSION = 1.9.0; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -785,7 +785,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.8.1; + MARKETING_VERSION = 1.9.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 4b66b9b..b2caf32 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/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 1f6a575..cd63589 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -3180,6 +3180,9 @@ } } } + }, + "Username: %@" : { + }, "Validate" : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Models/AppState.swift b/Nextcloud Cookbook iOS Client/Models/AppState.swift index 13d1e7b..0333e13 100644 --- a/Nextcloud Cookbook iOS Client/Models/AppState.swift +++ b/Nextcloud Cookbook iOS Client/Models/AppState.swift @@ -19,12 +19,10 @@ import UIKit var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] - private let api: CookbookApi.Type private let dataStore: DataStore - init(apiVersion api: CookbookApi.Type = CookbookApiV1.self) { + init() { print("Created MainViewModel") - self.api = api self.dataStore = DataStore() if UserSettings.shared.authString == "" { @@ -47,7 +45,7 @@ import UIKit - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. */ func getCategories() async { - let (categories, _) = await api.getCategories( + let (categories, _) = await cookbookApi.getCategories( auth: UserSettings.shared.authString ) if let categories = categories { @@ -95,7 +93,7 @@ import UIKit } func getServer(store: Bool = false) async -> Bool { - let (recipes, _) = await api.getCategory( + let (recipes, _) = await cookbookApi.getCategory( auth: UserSettings.shared.authString, named: categoryString ) @@ -157,7 +155,7 @@ import UIKit let recipes = await mainViewModel.getRecipes() */ func getRecipes() async -> [Recipe] { - let (recipes, error) = await api.getRecipes( + let (recipes, error) = await cookbookApi.getRecipes( auth: UserSettings.shared.authString ) if let recipes = recipes { @@ -197,7 +195,7 @@ import UIKit } func getServer() async -> RecipeDetail? { - let (recipe, error) = await api.getRecipe( + let (recipe, error) = await cookbookApi.getRecipe( auth: UserSettings.shared.authString, id: id ) @@ -290,7 +288,7 @@ import UIKit } func getServer() async -> UIImage? { - let (image, _) = await api.getImage( + let (image, _) = await cookbookApi.getImage( auth: UserSettings.shared.authString, id: id, size: size @@ -366,7 +364,7 @@ import UIKit } func getServer() async -> [RecipeKeyword]? { - let (tags, _) = await api.getTags( + let (tags, _) = await cookbookApi.getTags( auth: UserSettings.shared.authString ) return tags @@ -419,7 +417,7 @@ import UIKit let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts") */ func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { - let (error) = await api.deleteRecipe( + let (error) = await cookbookApi.deleteRecipe( auth: UserSettings.shared.authString, id: id ) @@ -450,7 +448,7 @@ import UIKit let isConnected = await mainViewModel.checkServerConnection() */ func checkServerConnection() async -> Bool { - let (categories, _) = await api.getCategories( + let (categories, _) = await cookbookApi.getCategories( auth: UserSettings.shared.authString ) if let categories = categories { @@ -479,12 +477,12 @@ import UIKit func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? { var error: NetworkError? = nil if createNew { - error = await api.createRecipe( + error = await cookbookApi.createRecipe( auth: UserSettings.shared.authString, recipe: recipeDetail ) } else { - error = await api.updateRecipe( + error = await cookbookApi.updateRecipe( auth: UserSettings.shared.authString, recipe: recipeDetail ) @@ -497,7 +495,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( + let (recipeDetail, error) = await cookbookApi.importRecipe( auth: UserSettings.shared.authString, data: data ) diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index 8b83fe4..467d5a6 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -11,14 +11,12 @@ import UIKit /// The Cookbook API class used for requests to the Nextcloud Cookbook service. -let cookbookApi: CookbookApi.Type = getApi() - -func getApi() -> CookbookApi.Type { +let cookbookApi: CookbookApi.Type = { switch UserSettings.shared.cookbookApiVersion { case .v1: return CookbookApiV1.self } -} +}() /// The Cookbook API version. enum CookbookApiVersion: String { diff --git a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift index 93a9875..e4326cb 100644 --- a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI /// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication. class NextcloudApi { @@ -75,4 +76,47 @@ class NextcloudApi { } return (loginResponse, nil) } + + static func getAvatar() async -> (UIImage?, NetworkError?) { + let request = ApiRequest( + path: "/index.php/avatar/\(UserSettings.shared.username)/100", + method: .GET, + authString: UserSettings.shared.authString, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (UIImage(data: data), error) + } + + static func getHoverCard() async -> (UserData?, NetworkError?) { + let request = ApiRequest( + path: "/ocs/v2.php/hovercard/v1/\(UserSettings.shared.username)", + method: .GET, + authString: UserSettings.shared.authString, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + let data = (json?["ocs"] as? [String: Any])?["data"] as? [String: Any] + let userData = UserData( + userId: data?["userId"] as? String ?? "", + userDisplayName: data?["displayName"] as? String ?? "" + ) + print(userData) + return (userData, nil) + } catch { + print(error.localizedDescription) + return (nil, NetworkError.decodingFailed) + } + } +} + +struct UserData { + let userId: String + let userDisplayName: String } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 03556d1..039c37b 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -11,6 +11,8 @@ import SimilaritySearchKit struct MainView: View { @StateObject var viewModel = AppState() @StateObject var groceryList = GroceryList() + + // Tab ViewModels @StateObject var recipeViewModel = RecipeTabView.ViewModel() @StateObject var searchViewModel = SearchTabView.ViewModel() diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift index 06607db..56d06f9 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/CategoryDetailView.swift @@ -15,22 +15,33 @@ struct CategoryDetailView: View { @State var searchText: String = "" @ObservedObject var viewModel: AppState @Binding var showEditView: Bool + @State var selectedRecipe: Recipe? = nil + @State var presentRecipeView: Bool = false var body: some View { ScrollView(showsIndicators: false) { LazyVStack { ForEach(recipesFiltered(), id: \.recipe_id) { recipe in - NavigationLink(value: recipe) { + //NavigationLink(value: recipe) { RecipeCardView(viewModel: viewModel, recipe: recipe) .shadow(radius: 2) + + //} + //.buttonStyle(.plain) + .onTapGesture { + selectedRecipe = recipe + presentRecipeView = true } - .buttonStyle(.plain) } } } + .fullScreenCover(isPresented: $presentRecipeView) { + RecipeDetailView(viewModel: viewModel, recipe: selectedRecipe!) + } + /* .navigationDestination(for: Recipe.self) { recipe in RecipeDetailView(viewModel: viewModel, recipe: recipe) - } + }*/ .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 5fa365b..17387ae 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -11,18 +11,40 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var viewModel: AppState + @EnvironmentObject var appState: AppState @ObservedObject var userSettings = UserSettings.shared - - @State fileprivate var alertType: SettingsAlert = .NONE - @State var showAlert: Bool = false + @ObservedObject var viewModel = ViewModel() var body: some View { Form { + HStack(alignment: .center) { + if let avatarImage = viewModel.avatarImage { + Image(uiImage: avatarImage) + .resizable() + .clipShape(Circle()) + .frame(width: 100, height: 100) + + } + if let userData = viewModel.userData { + VStack(alignment: .leading) { + Text(userData.userDisplayName) + .font(.title) + .padding(.leading) + Text("Username: \(userData.userId)") + .font(.subheadline) + .padding(.leading) + + + // TODO: Add actions + } + } + Spacer() + } + Section { Picker("Select a default cookbook", selection: $userSettings.defaultCategory) { Text("None").tag("None") - ForEach(viewModel.categories, id: \.name) { category in + ForEach(appState.categories, id: \.name) { category in Text(category.name == "*" ? "Other" : category.name).tag(category) } } @@ -100,19 +122,19 @@ struct SettingsView: View { Section { Button("Log out") { print("Log out.") - alertType = .LOG_OUT - showAlert = true + viewModel.alertType = .LOG_OUT + viewModel.showAlert = true } .tint(.red) Button("Delete local data") { print("Clear cache.") - alertType = .DELETE_CACHE - showAlert = true + viewModel.alertType = .DELETE_CACHE + viewModel.showAlert = true } .tint(.red) - + } header: { Text("Other") } footer: { @@ -136,24 +158,27 @@ struct SettingsView: View { } } } + .navigationTitle("Settings") - .alert(alertType.getTitle(), isPresented: $showAlert) { + .alert(viewModel.alertType.getTitle(), isPresented: $viewModel.showAlert) { Button("Cancel", role: .cancel) { } - if alertType == .LOG_OUT { + if viewModel.alertType == .LOG_OUT { Button("Log out", role: .destructive) { logOut() } - } else if alertType == .DELETE_CACHE { + } else if viewModel.alertType == .DELETE_CACHE { Button("Delete", role: .destructive) { deleteCache() } } } message: { - Text(alertType.getMessage()) + Text(viewModel.alertType.getMessage()) } .onDisappear { Task { userSettings.lastUpdate = .distantPast - await viewModel.updateAllRecipeDetails() + await appState.updateAllRecipeDetails() } } - + .task { + await viewModel.getUserData() + } } func logOut() { @@ -161,35 +186,54 @@ struct SettingsView: View { userSettings.username = "" userSettings.token = "" userSettings.authString = "" - viewModel.deleteAllData() + appState.deleteAllData() userSettings.onboarding = true } func deleteCache() { - viewModel.deleteAllData() + appState.deleteAllData() } } - - -fileprivate enum SettingsAlert { - case LOG_OUT, - DELETE_CACHE, - NONE - - func getTitle() -> String { - switch self { - case .LOG_OUT: return "Log out" - case .DELETE_CACHE: return "Delete local data" - default: return "Please confirm your action." +extension SettingsView { + class ViewModel: ObservableObject { + @Published var avatarImage: UIImage? = nil + @Published var userData: UserData? = nil + + @Published var showAlert: Bool = false + fileprivate var alertType: SettingsAlert = .NONE + + enum SettingsAlert { + case LOG_OUT, + DELETE_CACHE, + NONE + + func getTitle() -> String { + switch self { + case .LOG_OUT: return "Log out" + case .DELETE_CACHE: return "Delete local data" + default: return "Please confirm your action." + } + } + + func getMessage() -> String { + switch self { + case .LOG_OUT: return "Are you sure that you want to log out of your account?" + case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server." + default: return "" + } + } } - } - - func getMessage() -> String { - switch self { - case .LOG_OUT: return "Are you sure that you want to log out of your account?" - case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server." - default: return "" + + func getUserData() async { + let (data, _) = await NextcloudApi.getAvatar() + avatarImage = data + + let (userData, _) = await NextcloudApi.getHoverCard() + self.userData = userData } } } + + + diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift index ebb4526..b3ece9a 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -111,6 +111,8 @@ fileprivate struct EmptyGroceryListView: View { } +// Grocery List Logic + class GroceryRecipe: Identifiable, Codable { let name: String diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index d9c266f..76bebfa 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -95,6 +95,7 @@ struct RecipeTabView: View { } + fileprivate struct RecipeTabViewToolBar: ToolbarContent { @EnvironmentObject var mainViewModel: AppState @EnvironmentObject var viewModel: RecipeTabView.ViewModel