This commit is contained in:
Emin 2024-09-13 09:21:18 +05:00
parent d728b8c7d6
commit bcf18422b5
12 changed files with 832 additions and 121 deletions

View file

@ -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 = "<group>"; };
CED0E0382C904868008C61CA /* NavigationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationUtils.swift; sourceTree = "<group>"; };
CED0E03A2C904A06008C61CA /* FavoritesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewModel.swift; sourceTree = "<group>"; };
CED0E03D2C905140008C61CA /* Place.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Place.xcdatamodel; sourceTree = "<group>"; };
CED0E0412C9077D3008C61CA /* HashPersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashPersistenceController.swift; sourceTree = "<group>"; };
CED0E0442C918ED4008C61CA /* Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hash.swift; sourceTree = "<group>"; };
CED0E0462C919F44008C61CA /* PlacePersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacePersistenceController.swift; sourceTree = "<group>"; };
CED0E0492C91A2A9008C61CA /* DBUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBUtils.swift; sourceTree = "<group>"; };
CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatesEntity.swift; sourceTree = "<group>"; };
CED0E04D2C91A702008C61CA /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = "<group>"; };
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 = "<group>"; };
ED1080A62B791CFE0023F27E /* SocialMediaCollectionViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialMediaCollectionViewHeader.swift; sourceTree = "<group>"; };
@ -2814,12 +2828,12 @@
path = Widgets;
sourceTree = "<group>";
};
3D2D79B82C7C4FC30062BC3D /* Utils */ = {
3D2D79B82C7C4FC30062BC3D /* ControllerTemplates */ = {
isa = PBXGroup;
children = (
3D2D79B92C7C508E0062BC3D /* SingleEntityCoreDataController.swift */,
);
path = Utils;
path = ControllerTemplates;
sourceTree = "<group>";
};
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 = "<group>";
@ -3282,6 +3296,7 @@
529A5F2E2C86DF51004FE4A1 /* ReviewToPost.swift */,
529A5F302C86DF61004FE4A1 /* Review.swift */,
529A5F322C86DF6F004FE4A1 /* PlaceFull.swift */,
CED0E0442C918ED4008C61CA /* Hash.swift */,
);
path = Details;
sourceTree = "<group>";
@ -3474,6 +3489,9 @@
children = (
529A5F112C859535004FE4A1 /* PersonalData.xcdatamodeld */,
52ED91A12C7200C4000EE25B /* Currency.xcdatamodeld */,
CED0E03C2C905140008C61CA /* Place.xcdatamodeld */,
CED0E04B2C91A6A3008C61CA /* CoordinatesEntity.swift */,
CED0E04D2C91A702008C61CA /* UserEntity.swift */,
);
path = DataModels;
sourceTree = "<group>";
@ -3850,6 +3868,17 @@
path = Components;
sourceTree = "<group>";
};
CED0E0402C9077B7008C61CA /* PersistenceControllers */ = {
isa = PBXGroup;
children = (
52ED91A62C72C58A000EE25B /* CurrencyPersistenceController.swift */,
3D2D79C22C7C80E60062BC3D /* PersonalDataPersistenceController.swift */,
CED0E0412C9077D3008C61CA /* HashPersistenceController.swift */,
CED0E0462C919F44008C61CA /* PlacePersistenceController.swift */,
);
path = PersistenceControllers;
sourceTree = "<group>";
};
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 = "<group>";
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 = "<group>";
versionGroupType = wrapper.xcdatamodel;
};
/* End XCVersionGroup section */
};
rootObject = 29B97313FDCFA39411CA2CEA /* Project object */;

View file

@ -2,71 +2,71 @@ import CoreData
import Combine
class SingleEntityCoreDataController<Entity: NSManagedObject>: NSObject, NSFetchedResultsControllerDelegate {
private let container: NSPersistentContainer
private var fetchedResultsController: NSFetchedResultsController<Entity>?
var entitySubject = PassthroughSubject<Entity?, ResourceError>()
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<Entity>?
var entitySubject = PassthroughSubject<Entity?, ResourceError>()
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<Entity>, 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<Entity>, sortDescriptor: NSSortDescriptor) {
fetchRequest.sortDescriptors = [sortDescriptor]
}
func updateEntity(updateBlock: @escaping (Entity) -> Void, fetchRequest: NSFetchRequest<Entity>) -> AnyPublisher<Void, ResourceError> {
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<Entity>) -> AnyPublisher<Void, ResourceError> {
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<NSFetchRequestResult>) {
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<NSFetchRequestResult>) {
guard let fetchedObjects = controller.fetchedObjects as? [Entity],
let updatedEntity = fetchedObjects.first else {
entitySubject.send(completion: .failure(ResourceError.cacheError))
return
}
entitySubject.send(updatedEntity)
}
}

View file

@ -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<T: Encodable>(_ 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<T: Decodable>(_ 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
}
}
}

View file

@ -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
}

View file

@ -1,2 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier=""/>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="FavoriteSyncEntity" representedClassName="FavoriteSyncEntity" syncable="YES" codeGenerationType="class">
<attribute name="isFavorite" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="placeId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<entity name="HashEntity" representedClassName="HashEntity" syncable="YES" codeGenerationType="class">
<attribute name="categoryId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="value" attributeType="String"/>
</entity>
<entity name="PlaceEntity" representedClassName="PlaceEntity" syncable="YES" codeGenerationType="class">
<attribute name="categoryId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="coordinatesJson" optional="YES" attributeType="String"/>
<attribute name="cover" attributeType="String"/>
<attribute name="descr" attributeType="String"/>
<attribute name="excerpt" attributeType="String"/>
<attribute name="galleryJson" attributeType="String"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isFavorite" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String"/>
<attribute name="rating" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
</entity>
<entity name="ReviewEntity" representedClassName="ReviewEntity" syncable="YES" codeGenerationType="class">
<attribute name="comment" attributeType="String"/>
<attribute name="date" attributeType="String"/>
<attribute name="deletionPlanned" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="imagesJson" attributeType="String"/>
<attribute name="placeId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rating" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="userJson" attributeType="String"/>
</entity>
<entity name="ReviewToPublishEntity" representedClassName="ReviewToPublishEntity" syncable="YES" codeGenerationType="class">
<attribute name="comment" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="imagesJson" attributeType="String"/>
<attribute name="placeId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rating" attributeType="String"/>
</entity>
</model>

View file

@ -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
}

View file

@ -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> = 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<Hash?, ResourceError> {
let context = container.viewContext
let fetchRequest: NSFetchRequest<HashEntity> = 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> = 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)
}
}
}

View file

@ -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<PlaceEntity>?
private var placesByCatFetchedResultsController: NSFetchedResultsController<PlaceEntity>?
private var topPlacesFetchedResultsController: NSFetchedResultsController<PlaceEntity>?
private var singlePlaceFetchedResultsController: NSFetchedResultsController<PlaceEntity>?
private var favoritePlacesFetchedResultsController: NSFetchedResultsController<PlaceEntity>?
let searchSubject = PassthroughSubject<[PlaceEntity], ResourceError>()
let placesByCatSubject = PassthroughSubject<[PlaceEntity], ResourceError>()
let topPlacesSubject = PassthroughSubject<[PlaceEntity], ResourceError>()
let singlePlaceSubject = PassthroughSubject<PlaceEntity?, ResourceError>()
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> = 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<NSFetchRequestResult> = 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<NSFetchRequestResult> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = FavoriteSyncEntity.fetchRequest()
do {
return try context.fetch(fetchRequest)
} catch {
print("Failed to fetch favorite sync data: \(error)")
return []
}
}
// MARK: - NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
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
}
}
}

View file

@ -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
}

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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<AnyCancellable>()
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()
}
}