From bcf18422b5db393f520caf6e7fba36d246588f1f Mon Sep 17 00:00:00 2001 From: Emin Date: Fri, 13 Sep 2024 09:21:18 +0500 Subject: [PATCH] backup --- iphone/Maps/Maps.xcodeproj/project.pbxproj | 57 ++- .../SingleEntityCoreDataController.swift | 122 +++--- iphone/Maps/Tourism/Data/Db/DBUtils.swift | 37 +- .../Db/DataModels/CoordinatesEntity.swift | 13 +- .../Place.xcdatamodel/contents | 40 +- .../Data/Db/DataModels/UserEntity.swift | 15 +- .../HashPersistenceController.swift | 106 +++++- .../PlacePersistenceController.swift | 349 +++++++++++++++++- .../Tourism/Domain/Models/Details/Hash.swift | 13 +- .../Domain/Models/Details/PlaceFull.swift | 2 +- .../Tourism/Domain/Models/PlaceLocation.swift | 4 + .../Presentation/Home/TabBarController.swift | 195 +++++++++- 12 files changed, 832 insertions(+), 121 deletions(-) diff --git a/iphone/Maps/Maps.xcodeproj/project.pbxproj b/iphone/Maps/Maps.xcodeproj/project.pbxproj index 159e838537..4dc476d784 100644 --- a/iphone/Maps/Maps.xcodeproj/project.pbxproj +++ b/iphone/Maps/Maps.xcodeproj/project.pbxproj @@ -603,6 +603,13 @@ CED0E0372C902532008C61CA /* ThemeDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0362C902532008C61CA /* ThemeDTO.swift */; }; CED0E0392C904868008C61CA /* NavigationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0382C904868008C61CA /* NavigationUtils.swift */; }; CED0E03B2C904A06008C61CA /* FavoritesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */; }; + CED0E03E2C905140008C61CA /* Place.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = CED0E03C2C905140008C61CA /* Place.xcdatamodeld */; }; + CED0E0422C9077D3008C61CA /* HashPersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0412C9077D3008C61CA /* HashPersistenceController.swift */; }; + CED0E0452C918ED4008C61CA /* Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0442C918ED4008C61CA /* Hash.swift */; }; + CED0E0472C919F44008C61CA /* PlacePersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0462C919F44008C61CA /* PlacePersistenceController.swift */; }; + CED0E04A2C91A2A9008C61CA /* DBUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E0492C91A2A9008C61CA /* DBUtils.swift */; }; + CED0E04C2C91A6A3008C61CA /* CoordinatesEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */; }; + CED0E04E2C91A702008C61CA /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED0E04D2C91A702008C61CA /* UserEntity.swift */; }; ED0B1C312BC2951F00FB8EDD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = ED0B1C302BC2951F00FB8EDD /* PrivacyInfo.xcprivacy */; }; ED1080A72B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1080A62B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift */; }; ED1263AB2B6F99F900AD99F3 /* UIView+AddSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1263AA2B6F99F900AD99F3 /* UIView+AddSeparator.swift */; }; @@ -1643,6 +1650,13 @@ CED0E0362C902532008C61CA /* ThemeDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeDTO.swift; sourceTree = ""; }; CED0E0382C904868008C61CA /* NavigationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationUtils.swift; sourceTree = ""; }; CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewModel.swift; sourceTree = ""; }; + CED0E03D2C905140008C61CA /* Place.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Place.xcdatamodel; sourceTree = ""; }; + CED0E0412C9077D3008C61CA /* HashPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashPersistenceController.swift; sourceTree = ""; }; + CED0E0442C918ED4008C61CA /* Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hash.swift; sourceTree = ""; }; + CED0E0462C919F44008C61CA /* PlacePersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacePersistenceController.swift; sourceTree = ""; }; + CED0E0492C91A2A9008C61CA /* DBUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBUtils.swift; sourceTree = ""; }; + CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatesEntity.swift; sourceTree = ""; }; + CED0E04D2C91A702008C61CA /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = ""; }; ED097E762BB80C320006ED01 /* OMapsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OMapsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED0B1C302BC2951F00FB8EDD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; ED1080A62B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialMediaCollectionViewHeader.swift; sourceTree = ""; }; @@ -2814,12 +2828,12 @@ path = Widgets; sourceTree = ""; }; - 3D2D79B82C7C4FC30062BC3D /* Utils */ = { + 3D2D79B82C7C4FC30062BC3D /* ControllerTemplates */ = { isa = PBXGroup; children = ( 3D2D79B92C7C508E0062BC3D /* SingleEntityCoreDataController.swift */, ); - path = Utils; + path = ControllerTemplates; sourceTree = ""; }; 3D585BF72C768BED005DF71F /* Buttons */ = { @@ -3093,11 +3107,11 @@ 5260D3C92C64F58A00C673B4 /* Db */ = { isa = PBXGroup; children = ( - 3D2D79B82C7C4FC30062BC3D /* Utils */, + CED0E0492C91A2A9008C61CA /* DBUtils.swift */, + CED0E0402C9077B7008C61CA /* PersistenceControllers */, + 3D2D79B82C7C4FC30062BC3D /* ControllerTemplates */, 52ED91A02C72007C000EE25B /* DataModels */, - 52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */, 52ED91B22C73211F000EE25B /* EntitiesMapping.swift */, - 3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */, ); path = Db; sourceTree = ""; @@ -3282,6 +3296,7 @@ 529A5F2E2C86DF51004FE4A1 /* ReviewToPost.swift */, 529A5F302C86DF61004FE4A1 /* Review.swift */, 529A5F322C86DF6F004FE4A1 /* PlaceFull.swift */, + CED0E0442C918ED4008C61CA /* Hash.swift */, ); path = Details; sourceTree = ""; @@ -3474,6 +3489,9 @@ children = ( 529A5F112C859535004FE4A1 /* PersonalData.xcdatamodeld */, 52ED91A12C7200C4000EE25B /* Currency.xcdatamodeld */, + CED0E03C2C905140008C61CA /* Place.xcdatamodeld */, + CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */, + CED0E04D2C91A702008C61CA /* UserEntity.swift */, ); path = DataModels; sourceTree = ""; @@ -3850,6 +3868,17 @@ path = Components; sourceTree = ""; }; + CED0E0402C9077B7008C61CA /* PersistenceControllers */ = { + isa = PBXGroup; + children = ( + 52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */, + 3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */, + CED0E0412C9077D3008C61CA /* HashPersistenceController.swift */, + CED0E0462C919F44008C61CA /* PlacePersistenceController.swift */, + ); + path = PersistenceControllers; + sourceTree = ""; + }; ED1ADA312BC6B19E0029209F /* Tests */ = { isa = PBXGroup; children = ( @@ -5011,6 +5040,7 @@ 34B846A82029E8110081ECCD /* BMCDefaultViewModel.swift in Sources */, 993DF12123F6BDB100AC231A /* UIViewControllerRenderer.swift in Sources */, 529A5F192C85BFF0004FE4A1 /* ToastView.swift in Sources */, + CED0E0422C9077D3008C61CA /* HashPersistenceController.swift in Sources */, 34D3AFF61E37A36A004100F9 /* UICollectionView+Cells.swift in Sources */, 4767CDA420AAF66B00BD8166 /* NSAttributedString+HTML.swift in Sources */, 6741A9A91BF340DE002C974C /* MWMDefaultAlert.mm in Sources */, @@ -5053,6 +5083,7 @@ 993DF12C23F6BDB100AC231A /* Theme.swift in Sources */, 47CA68D8250044C500671019 /* BookmarksListRouter.swift in Sources */, 52ED91AB2C7302A7000EE25B /* CurrencyRatesDTO.swift in Sources */, + CED0E0472C919F44008C61CA /* PlacePersistenceController.swift in Sources */, 34D3B0421E389D05004100F9 /* MWMEditorTextTableViewCell.m in Sources */, 99F3EB1223F418C900C713F8 /* PlacePageInteractor.swift in Sources */, 52522F3B2C6DDA750015709C /* ThemeViewModel.swift in Sources */, @@ -5301,7 +5332,9 @@ 993DF10223F6BDB100AC231A /* Colors.swift in Sources */, 34AB66201FC5AA330078E451 /* RouteStartButton.swift in Sources */, AC79C8922A65AB9500594C24 /* UIColor+hexString.swift in Sources */, + CED0E03E2C905140008C61CA /* Place.xcdatamodeld in Sources */, 99DEF9D723E420F6006BFD21 /* ElevationProfileDescriptionCell.swift in Sources */, + CED0E04A2C91A2A9008C61CA /* DBUtils.swift in Sources */, 993DF11623F6BDB100AC231A /* UIWindowRenderer.swift in Sources */, F660DEE51EAF4F59004DC056 /* MWMLocationManager+SpeedAndAltitude.swift in Sources */, F6E2FDF21E097BA00083EBEC /* MWMOpeningHoursAddScheduleTableViewCell.mm in Sources */, @@ -5358,6 +5391,7 @@ 3472B5CB200F43EF00DC6CD5 /* BackgroundFetchScheduler.swift in Sources */, 34FE5A6F1F18F30F00BCA729 /* TrafficButtonArea.swift in Sources */, 993DF10D23F6BDB100AC231A /* UIPageControlRenderer.swift in Sources */, + CED0E04C2C91A6A3008C61CA /* CoordinatesEntity.swift in Sources */, FA8E808925F412E2002A1434 /* FirstSession.mm in Sources */, F6E2FF691E097BA00083EBEC /* MWMUnitsController.mm in Sources */, 52ED91B32C73211F000EE25B /* EntitiesMapping.swift in Sources */, @@ -5519,12 +5553,14 @@ F6E2FF571E097BA00083EBEC /* MWMMobileInternetViewController.m in Sources */, 993DF11323F6BDB100AC231A /* UITableViewRenderer.swift in Sources */, 34AB66261FC5AA330078E451 /* RouteManagerDimView.swift in Sources */, + CED0E0452C918ED4008C61CA /* Hash.swift in Sources */, EDFDFB4A2B722A310013A44C /* SocialMediaCollectionViewCell.swift in Sources */, 529A5F282C86DEC5004FE4A1 /* UserDTO.swift in Sources */, 6741AA2B1BF340DE002C974C /* CircleView.m in Sources */, 4788739220EE326500F6826B /* VerticallyAlignedButton.swift in Sources */, EDFDFB462B7139490013A44C /* AboutInfo.swift in Sources */, 3444DFDE1F18A5AF00E73099 /* SideButtonsArea.swift in Sources */, + CED0E04E2C91A702008C61CA /* UserEntity.swift in Sources */, CDCA278622451F5000167D87 /* RouteInfo.swift in Sources */, 3467CEB6202C6FA900D3C670 /* BMCNotificationsCell.swift in Sources */, 529A5F6A2C8707F9004FE4A1 /* FavoritesViewController.swift in Sources */, @@ -5987,6 +6023,17 @@ sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; + CED0E03C2C905140008C61CA /* Place.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + CED0E03D2C905140008C61CA /* Place.xcdatamodel */, + ); + currentVersion = CED0E03D2C905140008C61CA /* Place.xcdatamodel */; + name = Place.xcdatamodeld; + path = /Users/user/Projects/Tourism/iphone/Maps/Tourism/Data/Db/DataModels/Place.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; /* End XCVersionGroup section */ }; rootObject = 29B97313FDCFA39411CA2CEA /* Project object */; diff --git a/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift b/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift index a94b2cdcf7..76a1bed399 100644 --- a/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift +++ b/iphone/Maps/Tourism/Data/Db/ControllerTemplates/SingleEntityCoreDataController.swift @@ -2,71 +2,71 @@ import CoreData import Combine class SingleEntityCoreDataController: NSObject, NSFetchedResultsControllerDelegate { - private let container: NSPersistentContainer - private var fetchedResultsController: NSFetchedResultsController? - var entitySubject = PassthroughSubject() - - init(modelName: String) { - container = NSPersistentContainer(name: modelName) - super.init() - container.loadPersistentStores { (description, error) in - if let error = error { - fatalError("Failed to load Core Data stack: \(error)") - } - } + private let container: NSPersistentContainer + private var fetchedResultsController: NSFetchedResultsController? + var entitySubject = PassthroughSubject() + + init(modelName: String) { + container = NSPersistentContainer(name: modelName) + super.init() + container.loadPersistentStores { (description, error) in + if let error = error { + fatalError("Failed to load Core Data stack: \(error)") + } } + } + + var context: NSManagedObjectContext { + return container.viewContext + } + + func observeEntity(fetchRequest: NSFetchRequest, sortDescriptor: NSSortDescriptor) { + fetchRequest.sortDescriptors = [sortDescriptor] - var context: NSManagedObjectContext { - return container.viewContext + fetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + fetchedResultsController?.delegate = self + + do { + try fetchedResultsController?.performFetch() + if let fetchedEntity = fetchedResultsController?.fetchedObjects?.first { + entitySubject.send(fetchedEntity) + } else { + entitySubject.send(nil) + } + } catch { + entitySubject.send(completion: .failure(ResourceError.cacheError)) } - - func observeEntity(fetchRequest: NSFetchRequest, sortDescriptor: NSSortDescriptor) { - fetchRequest.sortDescriptors = [sortDescriptor] + } + + func updateEntity(updateBlock: @escaping (Entity) -> Void, fetchRequest: NSFetchRequest) -> AnyPublisher { + Future { promise in + do { + let entityToUpdate = try self.context.fetch(fetchRequest).first ?? Entity(context: self.context) + updateBlock(entityToUpdate) + try self.context.save() - fetchedResultsController = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context, - sectionNameKeyPath: nil, - cacheName: nil - ) - - fetchedResultsController?.delegate = self - - do { - try fetchedResultsController?.performFetch() - if let fetchedEntity = fetchedResultsController?.fetchedObjects?.first { - entitySubject.send(fetchedEntity) - } else { - entitySubject.send(nil) - } - } catch { - entitySubject.send(completion: .failure(ResourceError.cacheError)) - } + promise(.success(())) + } catch { + promise(.failure(ResourceError.cacheError)) + } } - - func updateEntity(updateBlock: @escaping (Entity) -> Void, fetchRequest: NSFetchRequest) -> AnyPublisher { - Future { promise in - do { - let entityToUpdate = try self.context.fetch(fetchRequest).first ?? Entity(context: self.context) - updateBlock(entityToUpdate) - try self.context.save() - - promise(.success(())) - } catch { - promise(.failure(ResourceError.cacheError)) - } - } - .eraseToAnyPublisher() - } - - - // NSFetchedResultsControllerDelegate - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard let fetchedObjects = controller.fetchedObjects as? [Entity], - let updatedEntity = fetchedObjects.first else { - entitySubject.send(completion: .failure(ResourceError.cacheError)) - return - } - entitySubject.send(updatedEntity) + .eraseToAnyPublisher() + } + + + // NSFetchedResultsControllerDelegate + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let fetchedObjects = controller.fetchedObjects as? [Entity], + let updatedEntity = fetchedObjects.first else { + entitySubject.send(completion: .failure(ResourceError.cacheError)) + return } + entitySubject.send(updatedEntity) + } } diff --git a/iphone/Maps/Tourism/Data/Db/DBUtils.swift b/iphone/Maps/Tourism/Data/Db/DBUtils.swift index cd00d690e9..d28a176647 100644 --- a/iphone/Maps/Tourism/Data/Db/DBUtils.swift +++ b/iphone/Maps/Tourism/Data/Db/DBUtils.swift @@ -1,9 +1,28 @@ -// -// DBUtils.swift -// OMaps -// -// Created by user on 9/11/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - -import Foundation +class DBUtils { + static func encodeToJsonString(_ body: T) -> String? { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let encoded = try encoder.encode(body) + return convertDataToString(encoded) + } catch { + return nil + } + } + + private static func convertDataToString(_ data: Data) -> String? { + return String(data: data, encoding: .utf8) + } + + static func decodeFromJsonString(_ jsonString: String, to type: T.Type) -> T? { + guard let data = jsonString.data(using: .utf8) else { + return nil + } + do { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch { + return nil + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift b/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift index 96e52d46fc..73c13dcfc9 100644 --- a/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift +++ b/iphone/Maps/Tourism/Data/Db/DataModels/CoordinatesEntity.swift @@ -1,9 +1,4 @@ -// -// CoordinatesEntity.swift -// OMaps -// -// Created by user on 9/11/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - -import Foundation +struct CoordinatesEntity: Codable { + let latitude: Double + let longitude: Double +} diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/Place.xcdatamodeld/Place.xcdatamodel/contents b/iphone/Maps/Tourism/Data/Db/DataModels/Place.xcdatamodeld/Place.xcdatamodel/contents index 660e645131..1d7cb7e4cd 100644 --- a/iphone/Maps/Tourism/Data/Db/DataModels/Place.xcdatamodeld/Place.xcdatamodel/contents +++ b/iphone/Maps/Tourism/Data/Db/DataModels/Place.xcdatamodeld/Place.xcdatamodel/contents @@ -1,2 +1,40 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift b/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift index 54494ed659..74a33af1ed 100644 --- a/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift +++ b/iphone/Maps/Tourism/Data/Db/DataModels/UserEntity.swift @@ -1,9 +1,6 @@ -// -// UserEntity.swift -// OMaps -// -// Created by user on 9/11/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - -import Foundation +struct UserEntity { + let userId: Int64 + let fullName: String + let avatar: String + let country: String +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashPersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashPersistenceController.swift index d299748798..4606bef789 100644 --- a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashPersistenceController.swift +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/HashPersistenceController.swift @@ -1,9 +1,99 @@ -// -// HashPersistenceController.swift -// OMaps -// -// Created by user on 9/10/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - import Foundation +import CoreData + +class HashPersistenceController { + static let shared = HashPersistenceController() + + let container: NSPersistentContainer + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "Place") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + container.loadPersistentStores { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + } + + // MARK: - CRUD Operations + func putOneHash(_ hash: Hash) { + putHash(hash, shouldSave: true) + } + + func putHashes(hashes: [Hash]) { + hashes.forEach { hash in + putHash(hash, shouldSave: false) // Don't save in each iteration + } + + // Save the context once after all inserts/updates + let context = container.viewContext + do { + try context.save() + } catch { + print("Failed to save context: \(error)") + } + } + + func putHash(_ hash: Hash, shouldSave: Bool) { + let context = container.viewContext + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", hash.categoryId) + fetchRequest.fetchLimit = 1 + + do { + let result = try context.fetch(fetchRequest).first + + if let existingHash = result { + existingHash.value = hash.value + } else { + let newHash = HashEntity(context: context) + newHash.categoryId = hash.categoryId + newHash.value = hash.value + } + + if shouldSave { + try context.save() + } + } catch { + print("Failed to insert or update hash: \(error)") + } + } + + func getHash(id: Int64) -> Result { + let context = container.viewContext + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", id) + fetchRequest.fetchLimit = 1 + + do { + let result = try context.fetch(fetchRequest).first + if let result = result { + return .success(Hash(categoryId: result.categoryId, value: result.value!)) + } else { + return .success(nil) + } + } catch { + print("Failed to fetch hash: \(error)") + return .failure(.cacheError) + } + } + + func getHashes() -> Result<[Hash], ResourceError> { + let context = container.viewContext + let fetchRequest: NSFetchRequest = HashEntity.fetchRequest() + + do { + let result = try context.fetch(fetchRequest) + let hashes = result.map { hashEntity in + Hash(categoryId: hashEntity.categoryId, value: hashEntity.value!) + } + return .success(hashes) + } catch { + print("Failed to fetch hashes: \(error)") + return .failure(.cacheError) + } + } +} diff --git a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacePersistenceController.swift b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacePersistenceController.swift index d39c018e11..150c8494d3 100644 --- a/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacePersistenceController.swift +++ b/iphone/Maps/Tourism/Data/Db/PersistenceControllers/PlacePersistenceController.swift @@ -1,9 +1,342 @@ -// -// PlacePersistenceController.swift -// OMaps -// -// Created by user on 9/11/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - import Foundation +import CoreData +import Combine + +class PlacePersistenceController: NSObject, NSFetchedResultsControllerDelegate { + static let shared = PlacePersistenceController() + + let container: NSPersistentContainer + + private var searchFetchedResultsController: NSFetchedResultsController? + private var placesByCatFetchedResultsController: NSFetchedResultsController? + private var topPlacesFetchedResultsController: NSFetchedResultsController? + private var singlePlaceFetchedResultsController: NSFetchedResultsController? + private var favoritePlacesFetchedResultsController: NSFetchedResultsController? + + let searchSubject = PassthroughSubject<[PlaceEntity], ResourceError>() + let placesByCatSubject = PassthroughSubject<[PlaceEntity], ResourceError>() + let topPlacesSubject = PassthroughSubject<[PlaceEntity], ResourceError>() + let singlePlaceSubject = PassthroughSubject() + let favoritePlacesSubject = PassthroughSubject<[PlaceEntity], ResourceError>() + + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "Place") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + super.init() + container.loadPersistentStores { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + } + + // MARK: Places + func putPlaces(_ places: [PlaceFull], categoryId: Int64) { + let context = container.viewContext + + places.forEach { place in + putPlace(place, categoryId: categoryId, context: context) + } + + // Save the context once after all inserts/updates + do { + try context.save() + } catch { + print("Failed to save context: \(error)") + } + } + + private func putPlace(_ place: PlaceFull, categoryId: Int64, context: NSManagedObjectContext) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", place.id) + fetchRequest.fetchLimit = 1 + + do { + if let existingPlace = try context.fetch(fetchRequest).first { + // Update the existing place + existingPlace.categoryId = categoryId + existingPlace.name = place.name + existingPlace.excerpt = place.excerpt + existingPlace.descr = place.description + existingPlace.cover = place.cover + let galleryJson = DBUtils.encodeToJsonString(place.pics) + existingPlace.galleryJson = galleryJson + let coordinatesJson = DBUtils.encodeToJsonString(place.placeLocation?.toCoordinatesEntity()) + existingPlace.coordinatesJson = coordinatesJson + existingPlace.rating = place.rating + existingPlace.isFavorite = place.isFavorite + } else { + // Insert a new place + let newPlace = PlaceEntity(context: context) + newPlace.id = place.id + newPlace.categoryId = categoryId + newPlace.name = place.name + newPlace.excerpt = place.excerpt + newPlace.descr = place.description + newPlace.cover = place.cover + let galleryJson = DBUtils.encodeToJsonString(place.pics) + newPlace.galleryJson = galleryJson + let coordinatesJson = DBUtils.encodeToJsonString(place.placeLocation?.toCoordinatesEntity()) + newPlace.coordinatesJson = coordinatesJson + newPlace.rating = place.rating + newPlace.isFavorite = place.isFavorite + } + } catch { + print("Failed to insert or update place: \(error)") + } + } + + func deleteAllPlaces() { + let context = container.viewContext + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + // Configure the request to return the IDs of the deleted objects + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try context.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + try context.save() + } catch { + print("Failed to delete all places: \(error)") + } + } + + func deleteAllPlacesByCategory(categoryId: Int64) { + let context = container.viewContext + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %d", categoryId) + + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + + // Configure the request to return the IDs of the deleted objects + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let result = try context.execute(deleteRequest) as? NSBatchDeleteResult + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? [] + ] + + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + try context.save() + } catch { + print("Failed to delete places by category \(categoryId): \(error)") + } + } + + // MARK: Observe places + func observeSearch(query: String) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", query) + + searchFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: container.viewContext, sectionNameKeyPath: nil, cacheName: nil) + + searchFetchedResultsController?.delegate = self + + do { + try searchFetchedResultsController!.performFetch() + if let results = searchFetchedResultsController!.fetchedObjects { + searchSubject.send(results) + } + } catch { + searchSubject.send(completion: .failure(.cacheError)) + } + } + + func observePlacesByCategoryId(categoryId: Int64) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + fetchRequest.predicate = NSPredicate(format: "categoryId == %d", categoryId) + + placesByCatFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: container.viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + placesByCatFetchedResultsController?.delegate = self + + do { + try placesByCatFetchedResultsController!.performFetch() + if let results = placesByCatFetchedResultsController!.fetchedObjects { + placesByCatSubject.send(results) + } + } catch { + placesByCatSubject.send(completion: .failure(.cacheError)) + } + } + + func observeTopPlacesByCategoryId(categoryId: Int64) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "categoryId == %lld", categoryId) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "rating", ascending: false)] + fetchRequest.fetchLimit = 15 + + topPlacesFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: container.viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + topPlacesFetchedResultsController?.delegate = self + + do { + try topPlacesFetchedResultsController!.performFetch() + if let results = topPlacesFetchedResultsController!.fetchedObjects { + topPlacesSubject.send(results) + } + } catch { + topPlacesSubject.send(completion: .failure(.cacheError)) + } + } + + func observePlaceById(placeId: Int64) { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", placeId) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + fetchRequest.fetchLimit = 1 + + singlePlaceFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: container.viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + singlePlaceFetchedResultsController?.delegate = self + + do { + try singlePlaceFetchedResultsController!.performFetch() + if let results = singlePlaceFetchedResultsController!.fetchedObjects { + singlePlaceSubject.send(results.first) + } + } catch { + singlePlaceSubject.send(completion: .failure(.cacheError)) + } + } + + // MARK: Favorites + func observeFavoritePlaces(query: String = "") { + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)] + let predicates = [ + NSPredicate(format: "isFavorite == YES"), + NSPredicate(format: "name CONTAINS[cd] %@", query) + ] + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + + favoritePlacesFetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, managedObjectContext: container.viewContext, sectionNameKeyPath: nil, cacheName: nil + ) + + favoritePlacesFetchedResultsController?.delegate = self + + do { + try favoritePlacesFetchedResultsController!.performFetch() + if let results = favoritePlacesFetchedResultsController!.fetchedObjects { + favoritePlacesSubject.send(results) + } + } catch { + favoritePlacesSubject.send(completion: .failure(.cacheError)) + } + } + + func getFavoritePlaces(query: String = "") -> [PlaceEntity] { + let context = container.viewContext + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + let predicates = [ + NSPredicate(format: "isFavorite == YES"), + NSPredicate(format: "name CONTAINS[cd] %@", query) + ] + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + + do { + return try context.fetch(fetchRequest) + } catch { + print("Failed to fetch favorite places: \(error)") + return [] + } + } + + func setFavorite(placeId: Int64, isFavorite: Bool) { + let context = container.viewContext + let fetchRequest: NSFetchRequest = PlaceEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %lld", placeId) + + do { + if let place = try context.fetch(fetchRequest).first { + place.isFavorite = isFavorite + try context.save() + } + } catch { + print("Failed to set favorite status: \(error)") + } + } + + func addFavoriteSync(placeId: Int64, isFavorite: Bool) { + let context = container.viewContext + let favoriteSyncEntity = FavoriteSyncEntity(context: context) + favoriteSyncEntity.placeId = placeId + favoriteSyncEntity.isFavorite = isFavorite + + do { + try context.save() + } catch { + print("Failed to add favorite sync: \(error)") + } + } + + func removeFavoriteSync(placeIds: [Int64]) { + let context = container.viewContext + let fetchRequest: NSFetchRequest = FavoriteSyncEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "placeId IN %@", placeIds) + + do { + let favoriteSyncs = try context.fetch(fetchRequest) + for favoriteSync in favoriteSyncs { + context.delete(favoriteSync) + } + try context.save() + } catch { + print("Failed to remove favorite syncs: \(error)") + } + } + + func getFavoriteSyncData() -> [FavoriteSyncEntity] { + let context = container.viewContext + let fetchRequest: NSFetchRequest = FavoriteSyncEntity.fetchRequest() + + do { + return try context.fetch(fetchRequest) + } catch { + print("Failed to fetch favorite sync data: \(error)") + return [] + } + } + + // MARK: - NSFetchedResultsControllerDelegate + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let fetchedObjects = controller.fetchedObjects as? [PlaceEntity] else { + return + } + + switch controller { + case searchFetchedResultsController: + searchSubject.send(fetchedObjects) + case placesByCatFetchedResultsController: + placesByCatSubject.send(fetchedObjects) + case topPlacesFetchedResultsController: + topPlacesSubject.send(fetchedObjects) + case singlePlaceFetchedResultsController: + singlePlaceSubject.send(fetchedObjects.first) + case favoritePlacesFetchedResultsController: + favoritePlacesSubject.send(fetchedObjects) + default: + break + } + } +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift b/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift index b6fc95542f..7206e8483f 100644 --- a/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift +++ b/iphone/Maps/Tourism/Domain/Models/Details/Hash.swift @@ -1,9 +1,4 @@ -// -// Hash.swift -// OMaps -// -// Created by user on 9/11/24. -// Copyright © 2024 Organic Maps. All rights reserved. -// - -import Foundation +struct Hash { + let categoryId: Int64 + let value: String +} diff --git a/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift b/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift index 86f0144237..f792c0ba62 100644 --- a/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift +++ b/iphone/Maps/Tourism/Domain/Models/Details/PlaceFull.swift @@ -10,7 +10,7 @@ struct PlaceFull: Codable { let cover: String let pics: [String] let reviews: [Review]? - let isFavorite: Bool + var isFavorite: Bool func toPlaceShort() -> PlaceShort { return PlaceShort( diff --git a/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift b/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift index 58f1a6193e..747aa5bad5 100644 --- a/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift +++ b/iphone/Maps/Tourism/Domain/Models/PlaceLocation.swift @@ -4,4 +4,8 @@ struct PlaceLocation: Codable { let name: String let lat: Double let lon: Double + + func toCoordinatesEntity() -> CoordinatesEntity { + return CoordinatesEntity(latitude: lat, longitude: lon) + } } diff --git a/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift b/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift index 5704668470..9c7ee483a7 100644 --- a/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift +++ b/iphone/Maps/Tourism/Presentation/Home/TabBarController.swift @@ -1,11 +1,12 @@ import UIKit import SwiftUI +import Combine class TabBarController: UITabBarController { override func viewDidAppear(_ animated: Bool) { if let theme = UserPreferences.shared.getTheme() { - changeTheme(themeCode: theme.code) + changeTheme(themeCode: theme.code) } } @@ -73,6 +74,198 @@ class TabBarController: UITabBarController { profileNav.tabBarItem = profileTab viewControllers = [homeNav, categoriesNav, favoritesNav, profileNav] + PlacePersistenceControllerTesterBro.testAll() } } + +class PlacePersistenceControllerTesterBro { + private static var cancellables = Set() + private static let persistenceController = PlacePersistenceController.shared + private static let searchQuery = "place" + + static func testAll() { + testSearchOperation() + testPlacesByCatFetchOperation() + testTopPlacesFetchOperation() + testSinglePlaceFetchOperation() + testFavoritePlacesFetchOperation() + testCRUDOperations() + } + + private static func testCRUDOperations() { + print("Testing CRUD Operations...") + + // Example PlaceFull object + let place = PlaceFull( + id: 1, + name: "Test Place", + rating: 5, + excerpt: "A great place", + description: "Detailed description", + placeLocation: nil, + cover: Constants.imageUrlExample, + pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], + reviews: nil, + isFavorite: true + ) + + let place2 = PlaceFull( + id: 2, + name: "Test Place 2222", + rating: 4.9, + excerpt: "A great place", + description: "Detailed description", + placeLocation: nil, + cover: Constants.imageUrlExample, + pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], + reviews: nil, + isFavorite: true + ) + + let place3 = PlaceFull( + id: 3, + name: "Test Place 3", + rating: 5, + excerpt: "A great place", + description: "Detailed description", + placeLocation: nil, + cover: Constants.imageUrlExample, + pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], + reviews: nil, + isFavorite: false + ) + + var place4 = PlaceFull( + id: 4, + name: "Test Place 4", + rating: 4, + excerpt: "A great place", + description: "Detailed description", + placeLocation: nil, + cover: Constants.imageUrlExample, + pics: [Constants.imageUrlExample, Constants.imageUrlExample, Constants.anotherImageExample], + reviews: nil, + isFavorite: false + ) + + // Insert or update place + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.persistenceController.putPlaces([place], categoryId: 1) + print("Inserted/Updated places with ID: \(place.id)") + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.persistenceController.putPlaces([place2, place3], categoryId: 2) + print("Inserted/Updated places with ID: \(place2.id), \(place3.id)") + } + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.persistenceController.putPlaces([place4], categoryId: 3) + print("Inserted/Updated places with ID: \(place4.id)") + } + DispatchQueue.main.asyncAfter(deadline: .now() + 9) { + place4.isFavorite = !place4.isFavorite + self.persistenceController.putPlaces([place4], categoryId: 3) + print("Inserted/Updated places with ID: \(place4.id)") + } + // Delete all + DispatchQueue.main.asyncAfter(deadline: .now() + 0) { + self.persistenceController.deleteAllPlaces() + print("Deleted all places") + } + // Delete places by category (assuming `categoryId` is available) + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { + self.persistenceController.deleteAllPlacesByCategory(categoryId: 3) + print("Deleted places with category ID: 2") + } + } + + private static func testSearchOperation() { + print("Testing Search Operation...") + persistenceController.searchSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Search failed with error: \(error)") + } + }, receiveValue: { places in + print("Search Results:") + places.forEach { place in + print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") + } + }) + .store(in: &cancellables) + + persistenceController.observeSearch(query: searchQuery) + } + + private static func testPlacesByCatFetchOperation() { + print("Testing PlacesByCat Operation...") + persistenceController.placesByCatSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("PlacesByCat failed with error: \(error)") + } + }, receiveValue: { places in + print("PlacesByCat Results:") + places.forEach { place in + print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") + } + }) + .store(in: &cancellables) + + persistenceController.observePlacesByCategoryId(categoryId: 3) + } + + private static func testTopPlacesFetchOperation() { + print("Testing TopPlaces Operation...") + persistenceController.topPlacesSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("TopPlaces failed with error: \(error)") + } + }, receiveValue: { places in + print("TopPlaces Results:") + places.forEach { place in + print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") + } + }) + .store(in: &cancellables) + + persistenceController.observeTopPlacesByCategoryId(categoryId: 2) + } + + private static func testSinglePlaceFetchOperation() { + print("Testing SinglePlace Operation...") + persistenceController.singlePlaceSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("SinglePlace failed with error: \(error)") + } + }, receiveValue: { place in + print("SinglePlace Results:") + if let place = place { + print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") + } + }) + .store(in: &cancellables) + + persistenceController.observePlaceById(placeId: 1) + } + + private static func testFavoritePlacesFetchOperation() { + print("Testing FavoritePlaces Operation...") + persistenceController.favoritePlacesSubject + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + print("FavoritePlaces failed with error: \(error)") + } + }, receiveValue: { places in + print("FavoritePlaces Results:") + places.forEach { place in + print("ID: \(place.id), Name: \(place.name ?? ""), Excerpt: \(place.excerpt ?? "No excerpt")") + } + }) + .store(in: &cancellables) + + persistenceController.observeFavoritePlaces() + } +}