diff --git a/Apps/Locations/Libraries/Sources/Persistence/Providers/LocationProvider.swift b/Apps/Locations/Libraries/Sources/Persistence/Providers/LocationProvider.swift new file mode 100644 index 0000000..bc57917 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Providers/LocationProvider.swift @@ -0,0 +1,149 @@ +// +// LocationProvider.swift +// Persistence +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Combine +import CoreData + +public class LocationProvider: NSObject { + + // MARK: Properties + + private let fetchedResultsController: NSFetchedResultsController + + /// The publisher that emits the changes detected to the Location entities in a given object context. + public let didChangePublisher = PassthroughSubject<[Change], Never>() + + private var inProgressChanges: [Change] = [] + + /// The number of sections in the data. + public var numberOfSections: Int { fetchedResultsController.sections?.count ?? 0 } + + // MARK: Initialisers + + /// Initialise this provider with the managed object context that would be used. + /// - Parameter managedContext: A `NSManagedObjectContext` object context instance that will be used to provide entities. + public init(managedContext: NSManagedObjectContext) { + self.fetchedResultsController = .init( + fetchRequest: .allLocations(), + managedObjectContext: managedContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + super.init() + + self.fetchedResultsController.delegate = self + } + + // MARK: Functions + + /// Perform the fetching. + public func fetch() throws { + try fetchedResultsController.performFetch() + } + + /// Retrieve the number of locations inside a given section number. + /// - Parameter section: The section number to inquiry about. + /// - Returns: A number of locations inside the given section number. + public func numberOfLocationsInSection(_ section: Int) -> Int { + guard + let sections = fetchedResultsController.sections, + sections.endIndex > section + else { + return 0 + } + + return sections[section].numberOfObjects + } + + /// Retrieve a location entity out of a given index path. + /// - Parameter indexPath: The index path to which retrieve a location entity. + /// - Returns: A `Location` entity positioned in the given index path. + public func location(at indexPath: IndexPath) -> Location { + return fetchedResultsController.object(at: indexPath) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate + +extension LocationProvider: NSFetchedResultsControllerDelegate { + + // MARK: Functions + + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + inProgressChanges.removeAll() + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + didChangePublisher.send(inProgressChanges) + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange sectionInfo: NSFetchedResultsSectionInfo, + atSectionIndex sectionIndex: Int, + for type: NSFetchedResultsChangeType + ) { + if type == .insert { + inProgressChanges.append(.section(.inserted(sectionIndex))) + } else if type == .delete { + inProgressChanges.append(.section(.deleted(sectionIndex))) + } + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + switch type { + case .insert: + guard let newIndexPath else { return } + + inProgressChanges.append(.object(.inserted(at: newIndexPath))) + case .delete: + guard let indexPath else { return } + + inProgressChanges.append(.object(.deleted(from: indexPath))) + case .move: + guard let indexPath, let newIndexPath else { return } + + inProgressChanges.append(.object(.moved(from: indexPath, to: newIndexPath))) + case .update: + guard let indexPath else { return } + + inProgressChanges.append(.object(.updated(at: indexPath))) + default: + break + } + } + +} + +// MARK: - Enumerations + +public enum Change: Hashable { + public enum SectionUpdate: Hashable { + case inserted(Int) + case deleted(Int) + } + + public enum ObjectUpdate: Hashable { + case inserted(at: IndexPath) + case deleted(from: IndexPath) + case updated(at: IndexPath) + case moved(from: IndexPath, to: IndexPath) + } + + case section(SectionUpdate) + case object(ObjectUpdate) +} + diff --git a/Apps/Locations/Libraries/Sources/Remote/Models/Location.swift b/Apps/Locations/Libraries/Sources/Remote/Models/Location.swift index 0342f96..363e50f 100644 --- a/Apps/Locations/Libraries/Sources/Remote/Models/Location.swift +++ b/Apps/Locations/Libraries/Sources/Remote/Models/Location.swift @@ -16,7 +16,7 @@ public struct Location: Equatable { // MARK: Initialisers - public init( + init( name: String? = nil, latitude: Float, longitude: Float diff --git a/Apps/Locations/Sources/Coordinators/LocationsAddCoordinator.swift b/Apps/Locations/Sources/Coordinators/LocationsAddCoordinator.swift index 61dd9c0..7d8891d 100644 --- a/Apps/Locations/Sources/Coordinators/LocationsAddCoordinator.swift +++ b/Apps/Locations/Sources/Coordinators/LocationsAddCoordinator.swift @@ -38,4 +38,12 @@ class LocationsAddCoordinator: Coordinator { // MARK: - LocationsAddCoordination -extension LocationsAddCoordinator: LocationsAddCoordination {} +extension LocationsAddCoordinator: LocationsAddCoordination { + + // MARK: Functions + + func closeAddLocation() { + router.dismiss(animated: true) + } + +} diff --git a/Apps/Locations/Sources/Protocols/Coordination/LocationsAddCoordination.swift b/Apps/Locations/Sources/Protocols/Coordination/LocationsAddCoordination.swift index 6dd5bee..9819245 100644 --- a/Apps/Locations/Sources/Protocols/Coordination/LocationsAddCoordination.swift +++ b/Apps/Locations/Sources/Protocols/Coordination/LocationsAddCoordination.swift @@ -6,4 +6,10 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // -protocol LocationsAddCoordination: AnyObject {} +protocol LocationsAddCoordination: AnyObject { + + // MARK: Functions + + func closeAddLocation() + +} diff --git a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsAddViewModeling.swift b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsAddViewModeling.swift index b4e707c..8c26eaa 100644 --- a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsAddViewModeling.swift +++ b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsAddViewModeling.swift @@ -6,10 +6,20 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import Combine + protocol LocationsAddViewModeling: AnyObject { // MARK: Properties var coordinator: LocationsAddCoordination? { get set } + var locationExistsPublisher: Published.Publisher { get } + + // MARK: Functions + + func cleanLocation() + func saveLocation() + func setLocation(latitude: Float, longitude: Float) + } diff --git a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift index aa3a71f..385b599 100644 --- a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift +++ b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift @@ -16,6 +16,7 @@ protocol LocationsListViewModeling: AnyObject { var coordinator: LocationsListCoordination? { get set } + var locationsDidChangePublisher: PassthroughSubject<[Change], Never> { get } var viewStatusPublisher: Published.Publisher { get } var numberOfSectionsInData: Int { get } diff --git a/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewController.swift b/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewController.swift index 86f4421..6a7ced6 100644 --- a/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewController.swift +++ b/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewController.swift @@ -6,14 +6,30 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import Combine import Core import UIKit +import MapKit class LocationsAddViewController: BaseViewController { // MARK: Properties - var viewModel: LocationsAddViewModeling + private let viewModel: LocationsAddViewModeling + + private var cancellables: Set = [] + + // MARK: Outlets + + private lazy var map = { + let map = MKMapView() + + map.translatesAutoresizingMaskIntoConstraints = false + + map.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnMap))) + + return map + }() // MARK: Initialisers @@ -32,7 +48,91 @@ class LocationsAddViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - title = "Location Add" + setupBar() + setupView() + bindViewModel() + } + +} + +// MARK: - Helpers + +private extension LocationsAddViewController { + + // MARK: Functions + + func bindViewModel() { + viewModel + .locationExistsPublisher + .receive(on: RunLoop.main) + .sink { locationExists in + self.navigationItem + .rightBarButtonItems? + .forEach { $0.isEnabled = locationExists } + } + .store(in: &cancellables) + } + + func setupBar() { + title = "Add a location" + navigationController?.navigationBar.prefersLargeTitles = false + navigationController?.navigationBar.backgroundColor = .systemBackground + navigationController?.navigationBar.isTranslucent = true + navigationController?.navigationBar.tintColor = .red + navigationItem.rightBarButtonItems = [ + .init( + title: "Save", + style: .plain, + target: self, + action: #selector(saveButtonPressed) + ), + .init( + title: "Clean", + style: .plain, + target: self, + action: #selector(cleanButtonPressed) + ), + ] + } + + func setupView() { + view.addSubview(map) + + NSLayoutConstraint.activate([ + view.bottomAnchor.constraint(equalTo: map.bottomAnchor), + view.leadingAnchor.constraint(equalTo: map.leadingAnchor), + view.topAnchor.constraint(equalTo: map.topAnchor), + view.trailingAnchor.constraint(equalTo: map.trailingAnchor), + ]) + } + + // MARK: Actions + + @objc func cleanButtonPressed() { + map.removeAnnotations(map.annotations) + + viewModel.cleanLocation() + } + + @objc func saveButtonPressed() { + viewModel.saveLocation() + } + + @objc func tapOnMap(recognizer: UITapGestureRecognizer) { + let tapOnView = recognizer.location(in: map) + let mapCoordinates = map.convert(tapOnView, toCoordinateFrom: map) + let annotation = MKPointAnnotation() + + annotation.coordinate = mapCoordinates + + map.removeAnnotations(map.annotations) + map.addAnnotation(annotation) + map.setCenter(mapCoordinates, animated: true) + + viewModel.setLocation( + latitude: Float(mapCoordinates.latitude), + longitude: Float(mapCoordinates.longitude) + ) } } diff --git a/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewModel.swift b/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewModel.swift index 84e9678..b447c1c 100644 --- a/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewModel.swift +++ b/Apps/Locations/Sources/Screens/LocationsAdd/LocationsAddViewModel.swift @@ -10,19 +10,82 @@ import Combine import Core class LocationsAddViewModel: ObservableObject { - + // MARK: Properties weak var coordinator: LocationsAddCoordination? + @Published private var location: Location? + @Published private var locationExists: Bool = false + + private let saveLocalLocation = SaveLocalLocationUseCase() + // MARK: Initialisers init(coordinator: LocationsAddCoordination) { self.coordinator = coordinator + + setupBindings() } } // MARK: - LocationsAddViewModeling -extension LocationsAddViewModel: LocationsAddViewModeling {} +extension LocationsAddViewModel: LocationsAddViewModeling { + + // MARK: Properties + + var locationExistsPublisher: Published.Publisher { $locationExists } + + // MARK: Functions + + func cleanLocation() { + location = nil + } + + func saveLocation() { + guard let location else { + return + } + + saveLocalLocation( + latitude: location.latitude, + longitude: location.longitude + ) + + coordinator?.closeAddLocation() + } + + func setLocation(latitude: Float, longitude: Float) { + if location == nil { + location = .init(latitude: latitude, longitude: longitude) + } else { + location?.latitude = latitude + location?.longitude = longitude + } + } + +} + +// MARK: - Helpers + +private extension LocationsAddViewModel { + + // MARK: Functions + + func setupBindings() { + $location + .map { $0 != nil } + .assign(to: &$locationExists) + } +} + +// MARK: - Structs + +private extension LocationsAddViewModel { + struct Location { + var latitude: Float + var longitude: Float + } +} diff --git a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift index 5daf098..e20b00e 100644 --- a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift +++ b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift @@ -161,6 +161,41 @@ private extension LocationsListViewController { } } .store(in: &cancellables) + + viewModel + .controllerDidChangePublisher + .sink(receiveValue: { [weak self] updates in + var movedToIndexPaths = [IndexPath]() + + self?.table.performBatchUpdates({ + for update in updates { + switch update { + case let .section(sectionUpdate): + switch sectionUpdate { + case let .inserted(index): + self?.table.insertSections([index], with: .automatic) + case let .deleted(index): + self?.table.deleteSections([index], with: .automatic) + } + case let .object(objectUpdate): + switch objectUpdate { + case let .inserted(at: indexPath): + self?.table.insertRows(at: [indexPath], with: .automatic) + case let .deleted(from: indexPath): + self?.table.deleteRows(at: [indexPath], with: .automatic) + case let .updated(at: indexPath): + self?.table.reloadRows(at: [indexPath], with: .automatic) + case let .moved(from: source, to: target): + self?.table.moveRow(at: source, to: target) + movedToIndexPaths.append(target) + } + } + } + }, completion: { done in + self?.table.reloadRows(at: movedToIndexPaths, with: .automatic) + }) + }) + .store(in: &cancellables) } @objc func addLocationPressed() { diff --git a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift index ebf3e9d..b5e2384 100644 --- a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift +++ b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift @@ -6,7 +6,6 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // -import CoreData import Combine import Dependency import Foundation @@ -21,16 +20,11 @@ class LocationsListViewModel: ObservableObject { // MARK: Properties weak var coordinator: LocationsListCoordination? + + private lazy var locationProvider = LocationProvider(managedContext: persistence.container.viewContext) @Published private var viewStatus: LocationsListViewStatus = .initialised - - private lazy var fetchedResultsController = NSFetchedResultsController( - fetchRequest: NSFetchRequest.allLocations(), - managedObjectContext: persistence.container.viewContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - + private let loadRemoteLocations = LoadRemoteLocationsUseCase() // MARK: Initialisers @@ -44,18 +38,19 @@ class LocationsListViewModel: ObservableObject { // MARK: - LocationsListViewModeling extension LocationsListViewModel: LocationsListViewModeling { - + // MARK: Properties - + + var locationsDidChangePublisher: PassthroughSubject<[Persistence.Change], Never> { locationProvider.didChangePublisher } + var numberOfSectionsInData: Int { locationProvider.numberOfSections } var viewStatusPublisher: Published.Publisher { $viewStatus } - var numberOfSectionsInData: Int { fetchedResultsController.sections?.count ?? 0 } - + // MARK: Functions - + func openAddLocation() { coordinator?.openAddLocation() } - + func loadLocations() { Task { do { @@ -63,7 +58,7 @@ extension LocationsListViewModel: LocationsListViewModeling { try await loadRemoteLocations() - try fetchedResultsController.performFetch() + try locationProvider.fetch() viewStatus = .loaded } catch { @@ -73,18 +68,11 @@ extension LocationsListViewModel: LocationsListViewModeling { } func numberOfDataItems(in section: Int) -> Int { - guard - let sections = fetchedResultsController.sections, - sections.endIndex > section - else { - return 0 - } - - return sections[section].numberOfObjects + locationProvider.numberOfLocationsInSection(section) } func dataItem(at indexPath: IndexPath) -> Location { - fetchedResultsController.object(at: indexPath) + locationProvider.location(at: indexPath) } } diff --git a/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift b/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift index e64bd01..50c3cc1 100644 --- a/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift +++ b/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift @@ -28,6 +28,8 @@ struct LoadRemoteLocationsUseCase { self.remoteService = remoteService } + // MARK: Functions + func callAsFunction() async throws { let context = persistence.makeTaskContext() let fetchRequest = NSFetchRequest.allLocations() diff --git a/Apps/Locations/Sources/Use Cases/SaveLocalLocationUseCase.swift b/Apps/Locations/Sources/Use Cases/SaveLocalLocationUseCase.swift new file mode 100644 index 0000000..0d2d074 --- /dev/null +++ b/Apps/Locations/Sources/Use Cases/SaveLocalLocationUseCase.swift @@ -0,0 +1,54 @@ +// +// SaveLocalLocationUseCase.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Dependency +import Persistence + +struct SaveLocalLocationUseCase { + + // MARK: Properties + + private let persistence: PersistenceService + + // MARK: Initialisers + + init(persistence: PersistenceService) { + self.persistence = persistence + } + + // MARK: Functions + + func callAsFunction( + name: String? = nil, + latitude: Float, + longitude: Float + ) { + let context = persistence.makeTaskContext() + let entity = Location(context: context) + + entity.createdAt = .now + entity.name = name + entity.latitude = latitude + entity.longitude = longitude + entity.source = .local + + persistence.save(context: context) + } + +} + +// MARK: - LoadRemoteLocationsUseCase+Initialisers + +extension SaveLocalLocationUseCase { + init() { + @Dependency(\.persistence) var persistence + + self.init(persistence: persistence) + } +} + diff --git a/DeepLinking.xcodeproj/project.pbxproj b/DeepLinking.xcodeproj/project.pbxproj index 6d192f6..00cc327 100644 --- a/DeepLinking.xcodeproj/project.pbxproj +++ b/DeepLinking.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 02031EEA29E6B495003C108C /* ErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031EE929E6B495003C108C /* ErrorMessageView.swift */; }; 4656CBC229E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */; }; 4656CBC829E6F2E400600EE6 /* LocationViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4656CBC729E6F2E400600EE6 /* LocationViewCell.swift */; }; + 4656CBE629E7360B00600EE6 /* SaveLocalLocationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4656CBE529E7360B00600EE6 /* SaveLocalLocationUseCase.swift */; }; 46C3B7C629E5BF1500F8F57C /* LocationsListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7C529E5BF1500F8F57C /* LocationsListCoordinator.swift */; }; 46C3B7CB29E5CD3200F8F57C /* LocationsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7CA29E5CD3200F8F57C /* LocationsListViewModel.swift */; }; 46C3B7CF29E5D00E00F8F57C /* LocationsAddViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7CE29E5D00E00F8F57C /* LocationsAddViewModel.swift */; }; @@ -132,6 +133,7 @@ 02031EE929E6B495003C108C /* ErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageView.swift; sourceTree = ""; }; 4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadRemoteLocationsUseCase.swift; sourceTree = ""; }; 4656CBC729E6F2E400600EE6 /* LocationViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViewCell.swift; sourceTree = ""; }; + 4656CBE529E7360B00600EE6 /* SaveLocalLocationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveLocalLocationUseCase.swift; sourceTree = ""; }; 46C3B7C529E5BF1500F8F57C /* LocationsListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListCoordinator.swift; sourceTree = ""; }; 46C3B7CA29E5CD3200F8F57C /* LocationsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListViewModel.swift; sourceTree = ""; }; 46C3B7CE29E5D00E00F8F57C /* LocationsAddViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddViewModel.swift; sourceTree = ""; }; @@ -223,6 +225,7 @@ isa = PBXGroup; children = ( 4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */, + 4656CBE529E7360B00600EE6 /* SaveLocalLocationUseCase.swift */, ); path = "Use Cases"; sourceTree = ""; @@ -544,6 +547,7 @@ 02031EBF29E5F949003C108C /* LocationsAddViewModeling.swift in Sources */, 4656CBC829E6F2E400600EE6 /* LocationViewCell.swift in Sources */, 46C3B7DE29E5ED2E00F8F57C /* LocationsAddCoordinator.swift in Sources */, + 4656CBE629E7360B00600EE6 /* SaveLocalLocationUseCase.swift in Sources */, 02031EEA29E6B495003C108C /* ErrorMessageView.swift in Sources */, 46C3B7DC29E5ED2300F8F57C /* LocationsAddCoordination.swift in Sources */, 46C3B7D829E5E55000F8F57C /* LocationsListCoordination.swift in Sources */,