diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index fc88f53..f1af7c4 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 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 */; }; + A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; }; 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 */; }; @@ -131,6 +132,7 @@ buildActionMask = 2147483647; files = ( A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, + A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -180,7 +182,7 @@ A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */, A70171C72AB4C4A100064C43 /* Data */, A70171BA2AB4980100064C43 /* Views */, - A70171B72AB2445700064C43 /* ViewModels */, + A70171B72AB2445700064C43 /* Models */, A70171B22AB211F000064C43 /* Network */, A781E75F2AF8228100452F6F /* RecipeImport */, A9CA6CED2B4C084100F78AB5 /* RecipeExport */, @@ -223,6 +225,7 @@ A70171B22AB211F000064C43 /* Network */ = { isa = PBXGroup; children = ( + A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, A79AA8EE2B063B33007D25F2 /* NextcloudApi */, A79AA8E72B062DB6007D25F2 /* CookbookApi */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, @@ -232,13 +235,13 @@ path = Network; sourceTree = ""; }; - A70171B72AB2445700064C43 /* ViewModels */ = { + A70171B72AB2445700064C43 /* Models */ = { isa = PBXGroup; children = ( A70171AC2AA8EF4700064C43 /* AppState.swift */, A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, ); - path = ViewModels; + path = Models; sourceTree = ""; }; A70171BA2AB4980100064C43 /* Views */ = { @@ -295,7 +298,6 @@ A79AA8E72B062DB6007D25F2 /* CookbookApi */ = { isa = PBXGroup; children = ( - A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, A79AA8E32B02A961007D25F2 /* CookbookApi.swift */, A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */, ); @@ -394,6 +396,7 @@ packageProductDependencies = ( A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */, + A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */, ); productName = "Nextcloud Cookbook iOS Client"; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; @@ -473,6 +476,7 @@ packageReferences = ( A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, + A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */, ); productRefGroup = A701717F2AA8E71900064C43 /* Products */; projectDirPath = ""; @@ -937,6 +941,14 @@ minimumVersion = 2.6.1; }; }; + A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ZachNagengast/similarity-search-kit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.13; + }; + }; A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/techprimate/TPPDF.git"; @@ -953,6 +965,11 @@ package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; + A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */ = { + isa = XCSwiftPackageProductDependency; + package = A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */; + productName = SimilaritySearchKit; + }; A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { isa = XCSwiftPackageProductDependency; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d488c1a..7f6ada7 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "similarity-search-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ZachNagengast/similarity-search-kit", + "state" : { + "revision" : "ddc8e458d0e826b2fe5dbce6f6eac96a8935e8eb", + "version" : "0.0.13" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", 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 e13864d..564cf0f 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/Data/DataStore.swift b/Nextcloud Cookbook iOS Client/Data/DataStore.swift index 66fbd1e..26472cb 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataStore.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataStore.swift @@ -10,7 +10,7 @@ import SwiftUI class DataStore { let fileManager = FileManager.default - + static let shared = DataStore() private static func fileURL(appending: String) throws -> URL { try FileManager.default.url( @@ -87,7 +87,28 @@ class DataStore { return true } +} + +// SimilarityIndex loading and saving +import SimilaritySearchKit +extension DataStore { + func loadIndex() async -> [IndexItem]? { + do { + let indexItems = try await SimilarityIndex().loadIndex(fromDirectory: Self.fileURL(appending: "similarity_index")) + return indexItems + } catch { + print("Unable to load SimilarityIndex") + return nil + } + } + func saveIndex(_ index: SimilarityIndex) { + do { + try index.saveIndex(toDirectory: Self.fileURL(appending: "similarity_index")) + } catch { + print("Unable to save SimilarityIndex") + } + } } diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index 0543b7a..de942c4 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -12,7 +12,7 @@ import Combine class UserSettings: ObservableObject { static let shared = UserSettings() - + @Published var username: String { didSet { UserDefaults.standard.set(username, forKey: "username") @@ -43,6 +43,12 @@ class UserSettings: ObservableObject { } } + @Published var cookbookApiVersion: CookbookApiVersion { + didSet { + UserDefaults.standard.set(cookbookApiVersion, forKey: "cookbookApiVersion") + } + } + @Published var onboarding: Bool { didSet { UserDefaults.standard.set(onboarding, forKey: "onboarding") @@ -115,6 +121,7 @@ class UserSettings: ObservableObject { self.authString = UserDefaults.standard.object(forKey: "authString") as? String ?? "" self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? "" self.serverProtocol = UserDefaults.standard.object(forKey: "serverProtocol") as? String ?? "https://" + self.cookbookApiVersion = UserDefaults.standard.object(forKey: "cookbookApiVersion") as? CookbookApiVersion ?? .v1 self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue @@ -134,15 +141,14 @@ class UserSettings: ObservableObject { authString = loginData.base64EncodedString() } } + } - func setAuthString() -> String { + func setAuthString() { if token != "" && username != "" { let loginString = "\(self.username):\(self.token)" let loginData = loginString.data(using: String.Encoding.utf8)! - return loginData.base64EncodedString() - } else { - return "" + self.authString = loginData.base64EncodedString() } } } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/AppState.swift b/Nextcloud Cookbook iOS Client/Models/AppState.swift similarity index 99% rename from Nextcloud Cookbook iOS Client/ViewModels/AppState.swift rename to Nextcloud Cookbook iOS Client/Models/AppState.swift index b1278c9..13d1e7b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/AppState.swift +++ b/Nextcloud Cookbook iOS Client/Models/AppState.swift @@ -19,7 +19,6 @@ import UIKit var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] - private let api: CookbookApi.Type private let dataStore: DataStore diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift rename to Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift similarity index 90% rename from Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift rename to Nextcloud Cookbook iOS Client/Network/ApiRequest.swift index 8056637..fd95965 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift @@ -16,7 +16,6 @@ struct ApiRequest { let body: Data? /// The path to the Cookbook application on the nextcloud server. - let cookbookPath = "/index.php/apps/cookbook" init( path: String, @@ -32,11 +31,11 @@ struct ApiRequest { self.body = body } - func send() async -> (Data?, NetworkError?) { + func send(pathCompletion: Bool = true) async -> (Data?, NetworkError?) { Logger.network.debug("\(method.rawValue) \(path) sending ...") // Prepare URL - let urlString = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + cookbookPath + path + let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path print("Full path: \(urlString)") //Logger.network.debug("Full path: \(urlString)") guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) } @@ -78,9 +77,10 @@ struct ApiRequest { return (nil, error) } if let data = data { - print(data, String(data: data, encoding: .utf8)) + print(data, String(data: data, encoding: .utf8) as Any) + return (data, nil) } - return (data!, nil) + return (nil, .unknownError) } catch { let error = decodeURLResponse(response: response as? HTTPURLResponse) Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index 7baed46..8b83fe4 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -10,7 +10,26 @@ import OSLog import UIKit +/// The Cookbook API class used for requests to the Nextcloud Cookbook service. +let cookbookApi: CookbookApi.Type = getApi() + +func getApi() -> CookbookApi.Type { + switch UserSettings.shared.cookbookApiVersion { + case .v1: + return CookbookApiV1.self + } +} + +/// The Cookbook API version. +enum CookbookApiVersion: String { + case v1 = "v1" +} + + +/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions. protocol CookbookApi { + static var basePath: String { get } + /// Not implemented yet. static func importRecipe( auth: String, diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index e4f4a0e..cbe6b59 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -10,9 +10,11 @@ import UIKit class CookbookApiV1: CookbookApi { + static let basePath: String = "/index.php/apps/cookbook/api/v1" + static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/import", + path: basePath + "/import", method: .POST, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)] @@ -26,7 +28,7 @@ class CookbookApiV1: CookbookApi { static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) { let imageSize = (size == .FULL ? "full" : "thumb") let request = ApiRequest( - path: "/api/v1/recipes/\(id)/image?size=\(imageSize)", + path: basePath + "/recipes/\(id)/image?size=\(imageSize)", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)] @@ -39,7 +41,7 @@ class CookbookApiV1: CookbookApi { static func getRecipes(auth: String) async -> ([Recipe]?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/recipes", + path: basePath + "/recipes", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -56,7 +58,7 @@ class CookbookApiV1: CookbookApi { } let request = ApiRequest( - path: "/api/v1/recipes", + path: basePath + "/recipes", method: .POST, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)], @@ -80,7 +82,7 @@ class CookbookApiV1: CookbookApi { static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/recipes/\(id)", + path: basePath + "/recipes/\(id)", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -96,7 +98,7 @@ class CookbookApiV1: CookbookApi { return .dataError } let request = ApiRequest( - path: "/api/v1/recipes/\(recipe.id)", + path: basePath + "/recipes/\(recipe.id)", method: .PUT, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)], @@ -120,7 +122,7 @@ class CookbookApiV1: CookbookApi { static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) { let request = ApiRequest( - path: "/api/v1/recipes/\(id)", + path: basePath + "/recipes/\(id)", method: .DELETE, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -133,7 +135,7 @@ class CookbookApiV1: CookbookApi { static func getCategories(auth: String) async -> ([Category]?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/categories", + path: basePath + "/categories", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -146,7 +148,7 @@ class CookbookApiV1: CookbookApi { static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/category/\(categoryName)", + path: basePath + "/category/\(categoryName)", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -159,7 +161,7 @@ class CookbookApiV1: CookbookApi { static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) { let request = ApiRequest( - path: "/api/v1/category/\(categoryName)", + path: basePath + "/category/\(categoryName)", method: .PUT, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -172,7 +174,7 @@ class CookbookApiV1: CookbookApi { static func getTags(auth: String) async -> ([RecipeKeyword]?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/keywords", + path: basePath + "/keywords", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] @@ -185,7 +187,7 @@ class CookbookApiV1: CookbookApi { static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) { let request = ApiRequest( - path: "/api/v1/tags/\(keyword)", + path: basePath + "/tags/\(keyword)", method: .GET, authString: auth, headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] diff --git a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift index d5caf9d..93a9875 100644 --- a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift @@ -7,6 +7,72 @@ import Foundation +/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication. class NextcloudApi { + /// Initiates the login process with Nextcloud using the Login Flow v2. + /// + /// This static function sends a POST request to the Nextcloud server to obtain a `LoginV2Request` object. + /// The object contains necessary details for the second step of the authentication process. + /// + /// - Returns: A tuple containing an optional `LoginV2Request` and an optional `NetworkError`. + /// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful. + /// - `NetworkError?`: An error encountered during the network request, if any. + + static func loginV2Request() async -> (LoginV2Request?, NetworkError?) { + let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + let request = ApiRequest( + path: path + "/index.php/login/v2", + method: .POST + ) + + let (data, error) = await request.send(pathCompletion: false) + + if let error = error { + return (nil, error) + } + guard let data = data else { + return (nil, NetworkError.dataError) + } + guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else { + return (nil, NetworkError.decodingFailed) + } + return (loginRequest, nil) + } + + /// Completes the user authentication process with Nextcloud using the Login Flow v2. + /// + /// This static function sends a POST request to the Nextcloud server with the login token obtained from `loginV2Request`. + /// On successful validation of the token, it returns a `LoginV2Response` object, completing the user login. + /// + /// - Parameter req: A `LoginV2Request` object containing the token and endpoint information for the authentication request. + /// + /// - Returns: A tuple containing an optional `LoginV2Response` and an optional `NetworkError`. + /// - `LoginV2Response?`: An object representing the response of the login process, if successful. + /// - `NetworkError?`: An error encountered during the network request, if any. + + static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) { + let request = ApiRequest( + path: req.poll.endpoint, + method: .POST, + headerFields: [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: .JSON), + HeaderField.contentType(value: .FORM) + ], + body: "token=\(req.poll.token)".data(using: .utf8) + ) + let (data, error) = await request.send(pathCompletion: false) + + if let error = error { + return (nil, error) + } + guard let data = data else { + return (nil, NetworkError.dataError) + } + guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else { + return (nil, NetworkError.decodingFailed) + } + return (loginResponse, nil) + } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 8d59eeb..03556d1 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SimilaritySearchKit struct MainView: View { @StateObject var viewModel = AppState() @@ -69,211 +70,3 @@ struct MainView: View { } } } -/*struct MainView: View { - @ObservedObject var viewModel: MainViewModel - @StateObject var userSettings: UserSettings = UserSettings.shared - - @State private var selectedCategory: Category? = nil - @State private var showEditView: Bool = false - @State private var showSearchView: Bool = false - @State private var showSettingsView: Bool = false - @State private var serverConnection: Bool = false - @State private var showLoadingIndicator: Bool = false - - - var columns: [GridItem] = [GridItem(.adaptive(minimum: 150), spacing: 0)] - - var body: some View { - NavigationSplitView { - List(selection: $selectedCategory) { - // All recipes - NavigationLink { - RecipeSearchView(viewModel: viewModel) - } label: { - HStack(alignment: .center) { - Image(systemName: "magnifyingglass") - Text("Search") - .font(.system(size: 20, weight: .medium, design: .default)) - } - .padding(7) - } - - // 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") - .navigationDestination(isPresented: $showSettingsView) { - SettingsView(viewModel: viewModel) - } - .navigationDestination(isPresented: $showSearchView) { - RecipeSearchView(viewModel: viewModel) - } - .toolbar { - MainViewToolBar( - viewModel: viewModel, - showEditView: $showEditView, - showSettingsView: $showSettingsView, - 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 { - showLoadingIndicator = true - self.serverConnection = await viewModel.checkServerConnection() - await viewModel.getCategories() - await viewModel.updateAllRecipeDetails() - - // Open detail view for default category - if userSettings.defaultCategory != "" { - if let cat = viewModel.categories.first(where: { c in - if c.name == userSettings.defaultCategory { - return true - } - return false - }) { - self.selectedCategory = cat - } - } - showLoadingIndicator = false - } - .refreshable { - self.serverConnection = await viewModel.checkServerConnection() - await viewModel.getCategories() - } - - } - } - - - - - struct MainViewToolBar: ToolbarContent { - @ObservedObject var viewModel: MainViewModel - @Binding var showEditView: Bool - @Binding var showSettingsView: 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 { - self.showSettingsView = true - } label: { - Text("Settings") - Image(systemName: "gearshape") - } - 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") - } - } - - } - } - - - - -*/ diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift index 89efc58..c47c625 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift @@ -87,44 +87,21 @@ struct TokenLoginView: View { showAlert = true return false } - let headerFields = [ - HeaderField.ocsRequest(value: true), - ] - let request = RequestWrapper.customRequest( - method: .GET, - path: .CATEGORIES, - headerFields: headerFields, - authenticate: true - ) - var (data, error): (Data?, Error?) = (nil, nil) - do { - let loginString = "\(userSettings.username):\(userSettings.token)" - let loginData = loginString.data(using: String.Encoding.utf8)! - let authString = loginData.base64EncodedString() - DispatchQueue.main.async { - userSettings.authString = authString - } - (data, error) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: "https://\(userSettings.serverAddress)/index.php/apps/cookbook/api/v1/", - authString: authString - ) - - } catch { - print("Error: ", error) + UserSettings.shared.setAuthString() + let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString) + + if let error = error { + alertMessage = "Login failed. Please check your inputs and internet connection." + showAlert = true + return false } + guard let data = data else { alertMessage = "Login failed. Please check your inputs." showAlert = true return false } - if let testRequest: [Category] = JSONDecoder.safeDecode(data) { - print("validationResponse: \(testRequest)") - return true - } - alertMessage = "Login failed. Please check your inputs and internet connection." - showAlert = true - return false + return true } } diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift index e3133d3..89f3eae 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift @@ -34,9 +34,7 @@ struct V2LoginView: View { @State var loginStage: V2LoginStage = .login @State var loginRequest: LoginV2Request? = nil - - @State var userSettings = UserSettings.shared - + // TextField handling enum Field { case server @@ -73,14 +71,18 @@ struct V2LoginView: View { HStack { Button { - if userSettings.serverAddress == "" { + if UserSettings.shared.serverAddress == "" { alertMessage = "Please enter a valid server address." showAlert = true return } Task { - await sendLoginV2Request() + let error = await sendLoginV2Request() + if let error = error { + alertMessage = "A network error occured (\(error.rawValue))." + showAlert = true + } if let loginRequest = loginRequest { await UIApplication.shared.open(URL(string: loginRequest.login)!) } else { @@ -107,20 +109,27 @@ struct V2LoginView: View { Button { // fetch login v2 response Task { - guard let res = await fetchLoginV2Response() else { + let (response, error) = await fetchLoginV2Response() + + if let error = error { + alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))" + showAlert = true + return + } + guard let response = response else { alertMessage = "Login failed. Please login via the browser and try again." showAlert = true return } - print("Login successfull for user \(res.loginName)!") - self.userSettings.username = res.loginName - self.userSettings.token = res.appPassword - let loginString = "\(userSettings.username):\(userSettings.token)" + print("Login successful for user \(response.loginName)!") + UserSettings.shared.username = response.loginName + UserSettings.shared.token = response.appPassword + let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginData = loginString.data(using: String.Encoding.utf8)! DispatchQueue.main.async { - userSettings.authString = loginData.base64EncodedString() + UserSettings.shared.authString = loginData.base64EncodedString() } - self.userSettings.onboarding = false + UserSettings.shared.onboarding = false } } label: { Text("Validate") @@ -141,64 +150,14 @@ struct V2LoginView: View { } } - func sendLoginV2Request() async { - let hostPath = "https://\(userSettings.serverAddress)" - let headerFields: [HeaderField] = [ - //HeaderField.ocsRequest(value: true), - //HeaderField.accept(value: .JSON) - ] - let request = RequestWrapper.customRequest( - method: .POST, - path: .LOGINV2REQ, - headerFields: headerFields - ) - do { - let (data, _): (Data?, Error?) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: hostPath, - authString: nil - ) - - guard let data = data else { return } - print("Data: \(data)") - let loginReq: LoginV2Request? = JSONDecoder.safeDecode(data) - self.loginRequest = loginReq - } catch { - print("Could not establish communication with the server.") - } - + func sendLoginV2Request() async -> NetworkError? { + let (req, error) = await NextcloudApi.loginV2Request() + self.loginRequest = req + return error } - func fetchLoginV2Response() async -> LoginV2Response? { - guard let loginRequest = loginRequest else { return nil } - let headerFields = [ - HeaderField.ocsRequest(value: true), - HeaderField.accept(value: .JSON), - HeaderField.contentType(value: .FORM) - ] - let request = RequestWrapper.customRequest( - method: .POST, - path: .NONE, - headerFields: headerFields, - body: "token=\(loginRequest.poll.token)".data(using: .utf8), - authenticate: false - ) - - var (data, error): (Data?, Error?) = (nil, nil) - do { - (data, error) = try await NetworkHandler.sendHTTPRequest( - request, - hostPath: loginRequest.poll.endpoint, - authString: nil - ) - } catch { - print("Error: ", error) - } - guard let data = data else { return nil } - if let loginRes: LoginV2Response = JSONDecoder.safeDecode(data) { - return loginRes - } - print("Could not decode.") - return nil + func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { + guard let loginRequest = loginRequest else { return (nil, .parametersNil) } + return await NextcloudApi.loginV2Response(req: loginRequest) } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift index e5fea0e..2f26d9a 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -7,7 +7,7 @@ import Foundation import SwiftUI - +import SimilaritySearchKit struct SearchTabView: View { @EnvironmentObject var viewModel: SearchTabView.ViewModel @@ -17,6 +17,13 @@ struct SearchTabView: View { NavigationStack { VStack { ScrollView(showsIndicators: false) { + /* + Picker("Topping", selection: $viewModel.searchMode) { + ForEach(ViewModel.SearchMode.allCases, id: \.self) { mode in + Text(mode.rawValue) + } + }.pickerStyle(.segmented) + */ LazyVStack { ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in NavigationLink(value: recipe) { @@ -47,13 +54,26 @@ struct SearchTabView: View { class ViewModel: ObservableObject { @Published var allRecipes: [Recipe] = [] @Published var searchText: String = "" + @Published var searchMode: SearchMode = .name + + var similarityIndex: SimilarityIndex? = nil + var similaritySearchResults: [SearchResult] = [] + + enum SearchMode: String, CaseIterable { + case name = "Name & Keywords", ingredient = "Ingredients" + } 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 + if searchMode == .name { + 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 + } + } else if searchMode == .ingredient { + // TODO: Fuzzy ingredient search } + return [] } } }