From c8d2c288afcf65c33be6d6d4154dab5b2d1ef232 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Wed, 12 Apr 2023 16:58:27 +0000 Subject: [PATCH] [Feature] Locations list (#10) This PR contains the work done to fetch a set of locations from a remote server to then persist them into the persistence stack and finally to display these data into the locations list screen. To give further details about the work done: - [x] implemented the `LoadingSpinnerView` and `ErrorMessageView` custom views; - [x] implemented the outlets of the `LocationsListViewController` view controller; - [x] add properties and functions to the `LocationsListViewModeling` protocol to support reactive updates, load data, and table data source conformances; - [x] deactivated the Location entity code generation from the Core Data model in the `Persistence` library; - [x] add fetch requests builder functions to the `NSFetchRequest+Location` extension in the `Persistence` library; - [x] implemented the `LoadRemoteLocationUseCase` use case; - [x] implemented the loading of locations in the LocationsListViewModel view model; - [x] implemented properties and functions in the LocationsListViewModel view model to support the table data source conformance of the `LocationsListViewController` view controller; - [x] implemented the `LocationViewCell` custom cell; - [x] registered the `LocationViewCell` with the table of the `LocationsListViewController` view controller and implemented its update with real data from Location entities. Co-authored-by: Javier Cicchelli Reviewed-on: https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/pulls/10 --- .../Entities/Location+CoreDataClass.swift | 21 ++ .../Location+CoreDataProperties.swift | 36 +++ .../Extensions/NSFetchRequest+Location.swift | 27 +++ .../Locations.xcdatamodel/contents | 2 +- .../LocationsListViewModeling.swift | 10 + .../LocationsListViewController.swift | 126 +++++++++- .../LocationsListViewModel.swift | 65 ++++- .../LoadRemoteLocationsUseCase.swift | 75 ++++++ .../View Components/ErrorMessageView.swift | 124 ++++++++++ .../View Components/LoadingSpinnerView.swift | 87 +++++++ .../View Components/LocationViewCell.swift | 223 ++++++++++++++++++ DeepLinking.xcodeproj/project.pbxproj | 32 +++ 12 files changed, 820 insertions(+), 8 deletions(-) create mode 100644 Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataClass.swift create mode 100644 Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataProperties.swift create mode 100644 Apps/Locations/Libraries/Sources/Persistence/Extensions/NSFetchRequest+Location.swift create mode 100644 Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift create mode 100644 Apps/Locations/Sources/View Components/ErrorMessageView.swift create mode 100644 Apps/Locations/Sources/View Components/LoadingSpinnerView.swift create mode 100644 Apps/Locations/Sources/View Components/LocationViewCell.swift diff --git a/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataClass.swift b/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataClass.swift new file mode 100644 index 0000000..2478f9e --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataClass.swift @@ -0,0 +1,21 @@ +// +// Location+CoreDataClass.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// +// + +import Foundation +import CoreData + +@objc(Location) +public class Location: NSManagedObject { + convenience init(context: NSManagedObjectContext) { + self.init( + entity: .entity(forEntityName: "Location", in: context)!, + insertInto: context + ) + } +} diff --git a/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataProperties.swift b/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataProperties.swift new file mode 100644 index 0000000..e7776ee --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Entities/Location+CoreDataProperties.swift @@ -0,0 +1,36 @@ +// +// Location+CoreDataProperties.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// +// + +import Foundation +import CoreData + +extension Location { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Location") + } + + @NSManaged public var createdAt: Date + @NSManaged public var latitude: Float + @NSManaged public var longitude: Float + @NSManaged public var name: String? + @NSManaged public var source: LocationSource + +} + +// MARK: - Identifiable + +extension Location: Identifiable {} + +// MARK: - Enumerations + +@objc public enum LocationSource: Int16 { + case remote = 0 + case local +} diff --git a/Apps/Locations/Libraries/Sources/Persistence/Extensions/NSFetchRequest+Location.swift b/Apps/Locations/Libraries/Sources/Persistence/Extensions/NSFetchRequest+Location.swift new file mode 100644 index 0000000..acca57e --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Extensions/NSFetchRequest+Location.swift @@ -0,0 +1,27 @@ +// +// NSFetchRequest+Location.swift +// Persistence +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +public extension NSFetchRequest where ResultType == Location { + + // MARK: Functions + + static func allLocations() -> NSFetchRequest { + let request = Location.fetchRequest() + + request.sortDescriptors = [ + .init(keyPath: \Location.source, ascending: true), + .init(keyPath: \Location.createdAt, ascending: true) + ] + request.resultType = .managedObjectResultType + + return request + } + +} diff --git a/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents b/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents index bd27564..cae680f 100644 --- a/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents +++ b/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents @@ -1,6 +1,6 @@ - + diff --git a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift index 90deeab..aa3a71f 100644 --- a/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift +++ b/Apps/Locations/Sources/Protocols/ViewModeling/LocationsListViewModeling.swift @@ -6,14 +6,24 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import Combine +import Foundation +import Persistence + protocol LocationsListViewModeling: AnyObject { // MARK: Properties var coordinator: LocationsListCoordination? { get set } + var viewStatusPublisher: Published.Publisher { get } + var numberOfSectionsInData: Int { get } + // MARK: Functions func openAddLocation() + func loadLocations() + func numberOfDataItems(in section: Int) -> Int + func dataItem(at indexPath: IndexPath) -> Location } diff --git a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift index cc35c26..5daf098 100644 --- a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift +++ b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewController.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import Combine import Core import UIKit @@ -13,7 +14,25 @@ class LocationsListViewController: BaseViewController { // MARK: Properties - var viewModel: LocationsListViewModeling + private let viewModel: LocationsListViewModeling + + private var cancellables: Set = [] + + // MARK: Outlets + + private lazy var error = ErrorMessageView() + private lazy var loading = LoadingSpinnerView() + private lazy var table = { + let table = UITableView(frame: .zero, style: .plain) + + table.dataSource = self + table.delegate = self + table.translatesAutoresizingMaskIntoConstraints = false + + table.register(LocationViewCell.self, forCellReuseIdentifier: LocationViewCell.identifier) + + return table + }() // MARK: Initialisers @@ -31,8 +50,72 @@ class LocationsListViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() + + setupBar() + setupView() + bindViewModel() - navigationItem.rightBarButtonItem = UIBarButtonItem( + viewModel.loadLocations() + } + +} + +// MARK: - UITableViewDataSource + +extension LocationsListViewController: UITableViewDataSource { + + // MARK: Functions + + func numberOfSections(in tableView: UITableView) -> Int { + viewModel.numberOfSectionsInData + } + + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + viewModel.numberOfDataItems(in: section) + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: LocationViewCell.identifier, + for: indexPath + ) as? LocationViewCell else { + return .init() + } + + let entity = viewModel.dataItem(at: indexPath) + + cell.update( + iconName: entity.source == .remote ? "network" : "house", + name: entity.name, + latitude: entity.latitude, + longitude: entity.longitude + ) + + return cell + } + +} + +// MARK: - UITableViewDelegate + +extension LocationsListViewController: UITableViewDelegate {} + +// MARK: - Helpers + +private extension LocationsListViewController { + + // MARK: Functions + + func setupBar() { + navigationController?.navigationBar.prefersLargeTitles = true + navigationController?.navigationBar.tintColor = .red + navigationItem.rightBarButtonItem = .init( title: "Add", style: .plain, target: self, @@ -41,13 +124,44 @@ class LocationsListViewController: BaseViewController { title = "Locations" } -} + func setupView() { + view.addSubview(table) + view.addSubview(error) + view.addSubview(loading) -// MARK: - Helpers + error.onRetry = { + self.viewModel.loadLocations() + } -private extension LocationsListViewController { + NSLayoutConstraint.activate([ + error.widthAnchor.constraint(equalToConstant: 300), + view.centerXAnchor.constraint(equalTo: error.centerXAnchor), + view.centerYAnchor.constraint(equalTo: error.centerYAnchor), + view.centerXAnchor.constraint(equalTo: loading.centerXAnchor), + view.centerYAnchor.constraint(equalTo: loading.centerYAnchor), + view.bottomAnchor.constraint(equalTo: table.bottomAnchor), + view.leadingAnchor.constraint(equalTo: table.leadingAnchor), + view.topAnchor.constraint(equalTo: table.topAnchor), + view.trailingAnchor.constraint(equalTo: table.trailingAnchor), + ]) + } - // MARK: Functions + func bindViewModel() { + viewModel + .viewStatusPublisher + .receive(on: RunLoop.main) + .sink { viewStatus in + self.navigationItem.rightBarButtonItem?.isEnabled = viewStatus == .loaded + self.error.isHidden = viewStatus != .error + self.loading.isHidden = viewStatus != .loading + self.table.isHidden = viewStatus != .loaded + + if viewStatus == .loaded { + self.table.reloadData() + } + } + .store(in: &cancellables) + } @objc func addLocationPressed() { viewModel.openAddLocation() diff --git a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift index c3644fa..ebf3e9d 100644 --- a/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift +++ b/Apps/Locations/Sources/Screens/LocationsList/LocationsListViewModel.swift @@ -6,15 +6,33 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import CoreData import Combine -import Core +import Dependency +import Foundation +import Persistence class LocationsListViewModel: ObservableObject { + + // MARK: Dependencies + + @Dependency(\.persistence) private var persistence // MARK: Properties weak var coordinator: LocationsListCoordination? + @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 init(coordinator: LocationsListCoordination) { @@ -27,10 +45,55 @@ class LocationsListViewModel: ObservableObject { extension LocationsListViewModel: LocationsListViewModeling { + // MARK: Properties + + var viewStatusPublisher: Published.Publisher { $viewStatus } + var numberOfSectionsInData: Int { fetchedResultsController.sections?.count ?? 0 } + // MARK: Functions func openAddLocation() { coordinator?.openAddLocation() } + func loadLocations() { + Task { + do { + viewStatus = .loading + + try await loadRemoteLocations() + + try fetchedResultsController.performFetch() + + viewStatus = .loaded + } catch { + viewStatus = .error + } + } + } + + func numberOfDataItems(in section: Int) -> Int { + guard + let sections = fetchedResultsController.sections, + sections.endIndex > section + else { + return 0 + } + + return sections[section].numberOfObjects + } + + func dataItem(at indexPath: IndexPath) -> Location { + fetchedResultsController.object(at: indexPath) + } + +} + +// MARK: - Enumerations + +enum LocationsListViewStatus { + case initialised + case loading + case loaded + case error } diff --git a/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift b/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift new file mode 100644 index 0000000..e64bd01 --- /dev/null +++ b/Apps/Locations/Sources/Use Cases/LoadRemoteLocationsUseCase.swift @@ -0,0 +1,75 @@ +// +// LoadRemoteLocationsUseCase.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData +import Dependency +import Persistence +import Remote + +struct LoadRemoteLocationsUseCase { + + // MARK: Properties + + private let persistence: PersistenceService + private let remoteService: RemoteService + + // MARK: Initialisers + + init( + persistence: PersistenceService, + remoteService: RemoteService + ) { + self.persistence = persistence + self.remoteService = remoteService + } + + func callAsFunction() async throws { + let context = persistence.makeTaskContext() + let fetchRequest = NSFetchRequest.allLocations() + + try await context.perform { + let localLocations = try context.fetch(fetchRequest) + + localLocations + .filter { $0.source == .remote } + .forEach(context.delete) + } + + let remoteLocations = try await remoteService.getLocations() + + _ = remoteLocations + .map { + let entity = Persistence.Location(context: context) + + entity.createdAt = .now + entity.name = $0.name + entity.latitude = $0.latitude + entity.longitude = $0.longitude + entity.source = .remote + + return entity + } + + persistence.save(context: context) + } + +} + +// MARK: - LoadRemoteLocationsUseCase+Initialisers + +extension LoadRemoteLocationsUseCase { + init() { + @Dependency(\.persistence) var persistence + @Dependency(\.remote) var remote + + self.init( + persistence: persistence, + remoteService: remote + ) + } +} diff --git a/Apps/Locations/Sources/View Components/ErrorMessageView.swift b/Apps/Locations/Sources/View Components/ErrorMessageView.swift new file mode 100644 index 0000000..c250ffd --- /dev/null +++ b/Apps/Locations/Sources/View Components/ErrorMessageView.swift @@ -0,0 +1,124 @@ +// +// ErrorMessageView.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +class ErrorMessageView: UIView { + + // MARK: Typealiases + + typealias OnRetryClosure = () -> Void + + // MARK: Properties + + var onRetry: OnRetryClosure? + + // MARK: Outlets + + private lazy var stack: UIStackView = { + let stack = UIStackView() + + stack.alignment = .center + stack.axis = .vertical + stack.distribution = .fill + stack.spacing = 32 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var title = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .largeTitle) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.text = "Some error title goes in here..." + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var message = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .body) + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.text = "Some long, descriptive, explanatory error message goes in here..." + label.textAlignment = .center + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var retry = { + let button = UIButton() + + button.backgroundColor = .red + button.translatesAutoresizingMaskIntoConstraints = false + button.layer.borderColor = UIColor.red.cgColor + button.layer.borderWidth = 1 + button.layer.cornerRadius = 5 + button.titleLabel?.font = .preferredFont(forTextStyle: .headline) + + button.addTarget(self, action: #selector(retryPressed), for: .touchUpInside) + button.setTitle("Try again", for: .normal) + + return button + }() + + // MARK: Initialisers + + init() { + super.init(frame: .zero) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Helpers + +private extension ErrorMessageView { + + // MARK: Functions + + func setupView() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + + stack.addArrangedSubview(title) + stack.addArrangedSubview(message) + stack.addArrangedSubview(retry) + stack.setCustomSpacing(160, after: message) + + NSLayoutConstraint.activate([ + bottomAnchor.constraint(equalTo: stack.bottomAnchor), + leadingAnchor.constraint(equalTo: stack.leadingAnchor), + topAnchor.constraint(equalTo: stack.topAnchor), + trailingAnchor.constraint(equalTo: stack.trailingAnchor), + retry.heightAnchor.constraint(equalToConstant: 44), + retry.leadingAnchor.constraint(equalTo: stack.leadingAnchor), + retry.trailingAnchor.constraint(equalTo: stack.trailingAnchor), + ]) + } + + @objc func retryPressed() { + onRetry?() + } + +} diff --git a/Apps/Locations/Sources/View Components/LoadingSpinnerView.swift b/Apps/Locations/Sources/View Components/LoadingSpinnerView.swift new file mode 100644 index 0000000..b5d3f4b --- /dev/null +++ b/Apps/Locations/Sources/View Components/LoadingSpinnerView.swift @@ -0,0 +1,87 @@ +// +// LoadingSpinnerView.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +class LoadingSpinnerView: UIView { + + // MARK: Outlets + + private lazy var stack = { + let stack = UIStackView() + + stack.alignment = .center + stack.axis = .vertical + stack.distribution = .fill + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var spinner = { + let activity = UIActivityIndicatorView(style: .large) + + activity.translatesAutoresizingMaskIntoConstraints = false + + activity.startAnimating() + + return activity + }() + + private lazy var title = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .headline) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.text = "Loading..." + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + // MARK: Initialisers + + init() { + super.init(frame: .zero) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Helpers + +private extension LoadingSpinnerView { + + // MARK: Functions + + func setupView() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + + stack.addArrangedSubview(spinner) + stack.addArrangedSubview(title) + + NSLayoutConstraint.activate([ + topAnchor.constraint(equalTo: stack.topAnchor), + leadingAnchor.constraint(equalTo: stack.leadingAnchor), + trailingAnchor.constraint(equalTo: stack.trailingAnchor), + bottomAnchor.constraint(equalTo: stack.bottomAnchor), + ]) + } + +} diff --git a/Apps/Locations/Sources/View Components/LocationViewCell.swift b/Apps/Locations/Sources/View Components/LocationViewCell.swift new file mode 100644 index 0000000..e60875e --- /dev/null +++ b/Apps/Locations/Sources/View Components/LocationViewCell.swift @@ -0,0 +1,223 @@ +// +// LocationViewCell.swift +// Locations +// +// Created by Javier Cicchelli on 12/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +class LocationViewCell: UITableViewCell { + + // MARK: Properties + + static let identifier = "LocationViewCell" + + // MARK: Outlets + + private lazy var icon = { + let view = UIImageView() + + view.contentMode = .top + view.tintColor = .red + + return view + }() + + private lazy var latitudeTitle = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 1 + label.text = "• Latitude" + label.textAlignment = .natural + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var latitudeValue = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 1 + label.textAlignment = .natural + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var longitudeTitle = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 1 + label.text = "• Longitude" + label.textAlignment = .natural + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var longitudeValue = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 1 + label.textAlignment = .natural + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var name = { + let label = UILabel() + + label.font = .preferredFont(forTextStyle: .headline) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.text = "Untitled" + label.textAlignment = .natural + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private lazy var stack = { + let stack = UIStackView() + + stack.alignment = .center + stack.axis = .horizontal + stack.distribution = .fill + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var stackData = { + let stack = UIStackView() + + stack.alignment = .leading + stack.axis = .vertical + stack.distribution = .fill + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var stackCoordinates = { + let stack = UIStackView() + + stack.alignment = .leading + stack.axis = .vertical + stack.distribution = .fill + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var stackLatitude = { + let stack = UIStackView() + + stack.alignment = .leading + stack.axis = .horizontal + stack.distribution = .fill + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + private lazy var stackLongitude = { + let stack = UIStackView() + + stack.alignment = .leading + stack.axis = .horizontal + stack.distribution = .fill + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + }() + + // MARK: Initialisers + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init( + style: style, + reuseIdentifier: reuseIdentifier + ) + + setupCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Functions + + func update( + iconName: String, + name: String?, + latitude: Float, + longitude: Float + ) { + self.icon.image = .init(systemName: iconName) + self.name.text = name ?? "Untitled" + self.latitudeValue.text = "\(latitude)" + self.longitudeValue.text = "\(longitude)" + } + +} + +// MARK: - Helpers + +private extension LocationViewCell { + + // MARK: Functions + + func setupCell() { + accessoryType = .disclosureIndicator + backgroundColor = .clear + + addSubview(stack) + + stack.addArrangedSubview(icon) + stack.addArrangedSubview(stackData) + + stackData.addArrangedSubview(name) + stackData.addArrangedSubview(stackCoordinates) + + stackCoordinates.addArrangedSubview(stackLatitude) + stackCoordinates.addArrangedSubview(stackLongitude) + + stackLatitude.addArrangedSubview(latitudeTitle) + stackLatitude.addArrangedSubview(latitudeValue) + + stackLongitude.addArrangedSubview(longitudeTitle) + stackLongitude.addArrangedSubview(longitudeValue) + + NSLayoutConstraint.activate([ + bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 8), + leadingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -20), + topAnchor.constraint(equalTo: stack.topAnchor, constant: -8), + trailingAnchor.constraint(equalTo: stack.trailingAnchor), + icon.bottomAnchor.constraint(equalTo: stack.bottomAnchor), + icon.topAnchor.constraint(equalTo: stack.topAnchor), + icon.widthAnchor.constraint(equalToConstant: 24), + ]) + } + +} diff --git a/DeepLinking.xcodeproj/project.pbxproj b/DeepLinking.xcodeproj/project.pbxproj index 33b73ae..6d192f6 100644 --- a/DeepLinking.xcodeproj/project.pbxproj +++ b/DeepLinking.xcodeproj/project.pbxproj @@ -10,6 +10,10 @@ 02031EBF29E5F949003C108C /* LocationsAddViewModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031EBE29E5F949003C108C /* LocationsAddViewModeling.swift */; }; 02031EC629E5FEE4003C108C /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031EC529E5FEE4003C108C /* BaseViewController.swift */; }; 02031EC929E60B29003C108C /* DependencyService+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031EC829E60B29003C108C /* DependencyService+Keys.swift */; }; + 02031EE829E68D9B003C108C /* LoadingSpinnerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031EE729E68D9B003C108C /* LoadingSpinnerView.swift */; }; + 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 */; }; 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 */; }; @@ -124,6 +128,10 @@ 02031EBE29E5F949003C108C /* LocationsAddViewModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddViewModeling.swift; sourceTree = ""; }; 02031EC529E5FEE4003C108C /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = ""; }; 02031EC829E60B29003C108C /* DependencyService+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DependencyService+Keys.swift"; sourceTree = ""; }; + 02031EE729E68D9B003C108C /* LoadingSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingSpinnerView.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -174,6 +182,16 @@ path = Extensions; sourceTree = ""; }; + 02031EE629E68D7A003C108C /* View Components */ = { + isa = PBXGroup; + children = ( + 02031EE729E68D9B003C108C /* LoadingSpinnerView.swift */, + 02031EE929E6B495003C108C /* ErrorMessageView.swift */, + 4656CBC729E6F2E400600EE6 /* LocationViewCell.swift */, + ); + path = "View Components"; + sourceTree = ""; + }; 0276C96029E5F5DC000B62AF /* Protocols */ = { isa = PBXGroup; children = ( @@ -201,6 +219,14 @@ path = Coordination; sourceTree = ""; }; + 4656CBC029E6D31800600EE6 /* Use Cases */ = { + isa = PBXGroup; + children = ( + 4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */, + ); + path = "Use Cases"; + sourceTree = ""; + }; 46C3B7C429E5BEE900F8F57C /* Coordinators */ = { isa = PBXGroup; children = ( @@ -304,6 +330,8 @@ 46C3B7C429E5BEE900F8F57C /* Coordinators */, 46C3B7C929E5CB8F00F8F57C /* Screens */, 02031EC429E5FEB1003C108C /* View Controllers */, + 02031EE629E68D7A003C108C /* View Components */, + 4656CBC029E6D31800600EE6 /* Use Cases */, ); path = Sources; sourceTree = ""; @@ -514,13 +542,17 @@ 02031EC629E5FEE4003C108C /* BaseViewController.swift in Sources */, 46EB331B29E1CE04001D5EAF /* AppDelegate.swift in Sources */, 02031EBF29E5F949003C108C /* LocationsAddViewModeling.swift in Sources */, + 4656CBC829E6F2E400600EE6 /* LocationViewCell.swift in Sources */, 46C3B7DE29E5ED2E00F8F57C /* LocationsAddCoordinator.swift in Sources */, + 02031EEA29E6B495003C108C /* ErrorMessageView.swift in Sources */, 46C3B7DC29E5ED2300F8F57C /* LocationsAddCoordination.swift in Sources */, 46C3B7D829E5E55000F8F57C /* LocationsListCoordination.swift in Sources */, 46C3B7D629E5E50500F8F57C /* LocationsListViewModeling.swift in Sources */, + 4656CBC229E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift in Sources */, 46C3B7CF29E5D00E00F8F57C /* LocationsAddViewModel.swift in Sources */, 02031EC929E60B29003C108C /* DependencyService+Keys.swift in Sources */, 46C3B7D129E5D06D00F8F57C /* LocationsAddViewController.swift in Sources */, + 02031EE829E68D9B003C108C /* LoadingSpinnerView.swift in Sources */, 46C3B7CB29E5CD3200F8F57C /* LocationsListViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;