[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 <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#10
This commit is contained in:
Javier Cicchelli 2023-04-12 16:58:27 +00:00
parent 43c156a2c3
commit c8d2c288af
12 changed files with 820 additions and 8 deletions

View File

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

View File

@ -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<Location> {
return NSFetchRequest<Location>(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
}

View File

@ -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<Location> {
let request = Location.fetchRequest()
request.sortDescriptors = [
.init(keyPath: \Location.source, ascending: true),
.init(keyPath: \Location.createdAt, ascending: true)
]
request.resultType = .managedObjectResultType
return request
}
}

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22E261" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Location" representedClassName="Location" syncable="YES" codeGenerationType="class">
<entity name="Location" representedClassName="Location" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latitude" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="longitude" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>

View File

@ -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<LocationsListViewStatus>.Publisher { get }
var numberOfSectionsInData: Int { get }
// MARK: Functions
func openAddLocation()
func loadLocations()
func numberOfDataItems(in section: Int) -> Int
func dataItem(at indexPath: IndexPath) -> Location
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "<group>"; };
02031EC529E5FEE4003C108C /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = "<group>"; };
02031EC829E60B29003C108C /* DependencyService+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DependencyService+Keys.swift"; sourceTree = "<group>"; };
02031EE729E68D9B003C108C /* LoadingSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingSpinnerView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
@ -174,6 +182,16 @@
path = Extensions;
sourceTree = "<group>";
};
02031EE629E68D7A003C108C /* View Components */ = {
isa = PBXGroup;
children = (
02031EE729E68D9B003C108C /* LoadingSpinnerView.swift */,
02031EE929E6B495003C108C /* ErrorMessageView.swift */,
4656CBC729E6F2E400600EE6 /* LocationViewCell.swift */,
);
path = "View Components";
sourceTree = "<group>";
};
0276C96029E5F5DC000B62AF /* Protocols */ = {
isa = PBXGroup;
children = (
@ -201,6 +219,14 @@
path = Coordination;
sourceTree = "<group>";
};
4656CBC029E6D31800600EE6 /* Use Cases */ = {
isa = PBXGroup;
children = (
4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */,
);
path = "Use Cases";
sourceTree = "<group>";
};
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 = "<group>";
@ -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;