[Feature] Location add (#11)

This PR contains the work done to add a location at a time and updates the locations in the list of locations screen right after.

To give further details about the work done:
- [x] implemented the `LocationProvider` provider in the **Persistence** library;
- [x] implemented the `SaveLocalLocationUseCase` use case;
- [x] defined the properties and functions of the `LocationsAddViewModeling` protocol to support the clean, updating and saving of locations;
- [x] implemented the `LocationsAddViewModel` view model;
- [x] implemented the `LocationsAddViewController` view controller;
- [x] implemented the dismissal of the `LocationsAddCoordinator` coordinator.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#11
This commit is contained in:
Javier Cicchelli 2023-04-12 21:25:08 +00:00
parent c8d2c288af
commit 8ae955008e
13 changed files with 452 additions and 32 deletions

View File

@ -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<Location>
/// 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<NSFetchRequestResult>) {
inProgressChanges.removeAll()
}
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
didChangePublisher.send(inProgressChanges)
}
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
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<NSFetchRequestResult>,
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)
}

View File

@ -16,7 +16,7 @@ public struct Location: Equatable {
// MARK: Initialisers
public init(
init(
name: String? = nil,
latitude: Float,
longitude: Float

View File

@ -38,4 +38,12 @@ class LocationsAddCoordinator: Coordinator {
// MARK: - LocationsAddCoordination
extension LocationsAddCoordinator: LocationsAddCoordination {}
extension LocationsAddCoordinator: LocationsAddCoordination {
// MARK: Functions
func closeAddLocation() {
router.dismiss(animated: true)
}
}

View File

@ -6,4 +6,10 @@
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
protocol LocationsAddCoordination: AnyObject {}
protocol LocationsAddCoordination: AnyObject {
// MARK: Functions
func closeAddLocation()
}

View File

@ -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<Bool>.Publisher { get }
// MARK: Functions
func cleanLocation()
func saveLocation()
func setLocation(latitude: Float, longitude: Float)
}

View File

@ -16,6 +16,7 @@ protocol LocationsListViewModeling: AnyObject {
var coordinator: LocationsListCoordination? { get set }
var locationsDidChangePublisher: PassthroughSubject<[Change], Never> { get }
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { get }
var numberOfSectionsInData: Int { get }

View File

@ -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<AnyCancellable> = []
// 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)
)
}
}

View File

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

View File

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

View File

@ -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<Location>.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<LocationsListViewStatus>.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)
}
}

View File

@ -28,6 +28,8 @@ struct LoadRemoteLocationsUseCase {
self.remoteService = remoteService
}
// MARK: Functions
func callAsFunction() async throws {
let context = persistence.makeTaskContext()
let fetchRequest = NSFetchRequest<Persistence.Location>.allLocations()

View File

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

View File

@ -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 = "<group>"; };
4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadRemoteLocationsUseCase.swift; sourceTree = "<group>"; };
4656CBC729E6F2E400600EE6 /* LocationViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViewCell.swift; sourceTree = "<group>"; };
4656CBE529E7360B00600EE6 /* SaveLocalLocationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveLocalLocationUseCase.swift; sourceTree = "<group>"; };
46C3B7C529E5BF1500F8F57C /* LocationsListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListCoordinator.swift; sourceTree = "<group>"; };
46C3B7CA29E5CD3200F8F57C /* LocationsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListViewModel.swift; sourceTree = "<group>"; };
46C3B7CE29E5D00E00F8F57C /* LocationsAddViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddViewModel.swift; sourceTree = "<group>"; };
@ -223,6 +225,7 @@
isa = PBXGroup;
children = (
4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */,
4656CBE529E7360B00600EE6 /* SaveLocalLocationUseCase.swift */,
);
path = "Use Cases";
sourceTree = "<group>";
@ -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 */,