Compare commits
13 Commits
feature/pl
...
main
Author | SHA1 | Date | |
---|---|---|---|
1de87263ba | |||
842c3e1a6c | |||
57f4b3c237 | |||
8ae955008e | |||
c8d2c288af | |||
43c156a2c3 | |||
d51cc97aa4 | |||
9172827b8b | |||
cb90b3730d | |||
4210df9eb6 | |||
6da2e946ce | |||
14e39a40ae | |||
6d76ba467c |
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "APICoreTests"
|
||||
BuildableName = "APICoreTests"
|
||||
BlueprintName = "APICoreTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CoreTests"
|
||||
BuildableName = "CoreTests"
|
||||
BlueprintName = "CoreTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DependencyTests"
|
||||
BuildableName = "DependencyTests"
|
||||
BlueprintName = "DependencyTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PersistenceTests"
|
||||
BuildableName = "PersistenceTests"
|
||||
BlueprintName = "PersistenceTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "RemoteTests"
|
||||
BuildableName = "RemoteTests"
|
||||
BlueprintName = "RemoteTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -1,28 +1,77 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Libraries",
|
||||
platforms: [
|
||||
.iOS(.v16)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "Libraries",
|
||||
targets: ["Libraries"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
targets: [
|
||||
"Core",
|
||||
"Dependency",
|
||||
"Persistence",
|
||||
"Remote"
|
||||
]
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Libraries",
|
||||
dependencies: []),
|
||||
name: "APICore",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Core",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Dependency",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Persistence",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Remote",
|
||||
dependencies: [
|
||||
"APICore"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "LibrariesTests",
|
||||
dependencies: ["Libraries"]),
|
||||
name: "APICoreTests",
|
||||
dependencies: [
|
||||
"APICore"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CoreTests",
|
||||
dependencies: [
|
||||
"Core"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "DependencyTests",
|
||||
dependencies: [
|
||||
"Dependency"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "PersistenceTests",
|
||||
dependencies: [
|
||||
"Persistence"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "RemoteTests",
|
||||
dependencies: [
|
||||
"APICore",
|
||||
"Remote"
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
@ -0,0 +1,63 @@
|
||||
//
|
||||
// MockURLProtocol.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This class overrides the `URLProtocol` protocol used by the `URLSession` to handle the loading of protocol-specific URL data so it is possible to mock URL response for testing purposes.
|
||||
public class MockURLProtocol: URLProtocol {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public static var mockData: [URL: MockURLResponse] = [:]
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public override class func canInit(with task: URLSessionTask) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public override class func canInit(with request: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
public override func startLoading() {
|
||||
guard
|
||||
let url = request.url,
|
||||
let response = Self.mockData[url]
|
||||
else {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
return
|
||||
}
|
||||
|
||||
if let data = response.data {
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
}
|
||||
|
||||
if let httpResponse = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: response.status,
|
||||
httpVersion: nil,
|
||||
headerFields: response.headers
|
||||
) {
|
||||
client?.urlProtocol(
|
||||
self,
|
||||
didReceive: httpResponse,
|
||||
cacheStoragePolicy: .allowedInMemoryOnly
|
||||
)
|
||||
}
|
||||
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
public override func stopLoading() {}
|
||||
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
//
|
||||
// HTTPRequestMethod.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// Enumeration that represents the available HTTP request methods to use in this library.
|
||||
public enum HTTPRequestMethod: String {
|
||||
case get = "GET"
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
//
|
||||
// MockURLResponse.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This model includes the data to be injected into an specific URL at the time of mocking its response.
|
||||
public struct MockURLResponse {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let status: Int
|
||||
public let headers: [String: String]
|
||||
public let data: Data?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init(
|
||||
status: Int,
|
||||
headers: [String : String],
|
||||
data: Data? = nil
|
||||
) {
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.data = data
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Client.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol defines a client that will be making the API calls.
|
||||
public protocol Client {
|
||||
func request<Model: Decodable>(
|
||||
endpoint: some Endpoint,
|
||||
for model: Model.Type
|
||||
) async throws -> Model
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
//
|
||||
// Endpoint.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This protocol defines an endpoint to be used in an API call.
|
||||
public protocol Endpoint {
|
||||
var scheme: String { get }
|
||||
var host: String { get }
|
||||
var port: Int? { get }
|
||||
var path: String { get }
|
||||
var method: HTTPRequestMethod { get }
|
||||
var headers: [String: String] { get }
|
||||
var body: Data? { get }
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
//
|
||||
// MakeURLRequestUseCase.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This use case generate a url request out of a given endpoint.
|
||||
public struct MakeURLRequestUseCase {
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Generate a `URLRequest` instance out of a given endpoint that conforms to the `Endpoint` protocol.
|
||||
/// - Parameter endpoint: An endpoint which is used to generate a `URLRequest` instance from.
|
||||
/// - Returns: A `URLRequest` instance filled with data provided by the given endpoint.
|
||||
public func callAsFunction(endpoint: some Endpoint) throws -> URLRequest {
|
||||
var urlComponents = URLComponents()
|
||||
|
||||
urlComponents.scheme = endpoint.scheme
|
||||
urlComponents.host = endpoint.host
|
||||
urlComponents.path = endpoint.path
|
||||
|
||||
if let port = endpoint.port {
|
||||
urlComponents.port = port
|
||||
}
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
throw MakeURLRequestError.urlNotCreated
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
|
||||
urlRequest.httpMethod = endpoint.method.rawValue
|
||||
urlRequest.httpBody = endpoint.body
|
||||
urlRequest.allHTTPHeaderFields = endpoint.headers
|
||||
|
||||
return urlRequest
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum MakeURLRequestError: Error {
|
||||
case urlNotCreated
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
//
|
||||
// Application.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public protocol Application {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func canOpenURL(_ url: URL) -> Bool
|
||||
func open(
|
||||
_ url: URL,
|
||||
options: [UIApplication.OpenExternalURLOptionsKey : Any],
|
||||
completionHandler completion: ((Bool) -> Void)?
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
//
|
||||
// Coordinator.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol organize the flow logic between view controllers in the app.
|
||||
public protocol Coordinator: AnyObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The child coordinators that are being currently presented.
|
||||
var children: [Coordinator] { get set }
|
||||
|
||||
/// The router that handles how the view controllers in the coordinators will be shown or dismissed.
|
||||
var router: Router { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present the coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
|
||||
func present(animated: Bool, onDismiss: Router.OnDismissedClosure?)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Coordinator+Implementations
|
||||
|
||||
public extension Coordinator {
|
||||
|
||||
/// Present a child coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - child: A child coordinator to be presented.
|
||||
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
|
||||
func present(
|
||||
child: Coordinator,
|
||||
animated: Bool,
|
||||
onDismiss: Router.OnDismissedClosure? = nil
|
||||
) {
|
||||
store(child)
|
||||
child.present(animated: animated) { [weak self, weak child] in
|
||||
guard let self, let child else {
|
||||
return
|
||||
}
|
||||
|
||||
self.free(child)
|
||||
onDismiss?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss the coordinator animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameter animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
func dismiss(animated: Bool) {
|
||||
router.dismiss(animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Coordinator {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func store(_ coordinator: Coordinator) {
|
||||
children.append(coordinator)
|
||||
}
|
||||
|
||||
func free(_ coordinator: Coordinator) {
|
||||
children = children.filter { $0 !== coordinator }
|
||||
}
|
||||
}
|
55
Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift
Normal file
55
Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// Router.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This protocol defines how view controllers will be shown and dismissed.
|
||||
public protocol Router: AnyObject {
|
||||
|
||||
// MARK: Typealiases
|
||||
|
||||
typealias OnDismissedClosure = () -> Void
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - viewController: A `UIViewController` view controller instance to present.
|
||||
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented view controller is dismissed.
|
||||
func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
)
|
||||
|
||||
/// Dismiss a view controller animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameter animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
func dismiss(animated: Bool)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Router+Implementations
|
||||
|
||||
public extension Router {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameters:
|
||||
/// - viewController: A `UIViewController` view controller instance to present.
|
||||
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
func present(_ viewController: UIViewController, animated: Bool) {
|
||||
present(
|
||||
viewController,
|
||||
animated: animated,
|
||||
onDismiss: nil
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
//
|
||||
// BaseNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This is a base class for the `NavigationRouter` concrete router implementations.
|
||||
public class BaseNavigationRouter: NSObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// A navigation controller to use within this concrete router.
|
||||
var navigationController: UINavigationController
|
||||
|
||||
/// Dictionary that persist `onDismiss` closure for its respective view controllers until one of the later is dismissed.
|
||||
var onDismissForViewController: [UIViewController: Router.OnDismissedClosure] = [:]
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter navigationController: A `UINavigationController` navigation controller instance to use in this router.
|
||||
init(navigationController: UINavigationController) {
|
||||
self.navigationController = navigationController
|
||||
|
||||
super.init()
|
||||
|
||||
self.navigationController.delegate = self
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Executes the `onDismiss` closure for a given view controller.
|
||||
/// - Parameter viewController: A `UIViewController` view controller instance for which the on dismiss closure will be executed.
|
||||
func performOnDismissed(for viewController: UIViewController) {
|
||||
guard let onDismiss = onDismissForViewController[viewController] else {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
|
||||
onDismissForViewController[viewController] = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UINavigationControllerDelegate
|
||||
|
||||
extension BaseNavigationRouter: UINavigationControllerDelegate {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
guard let dismissedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: dismissedViewController)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
//
|
||||
// ModalNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class ModalNavigationRouter: BaseNavigationRouter {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The parent view controller from where this router is being called from.
|
||||
public unowned let parentViewController: UIViewController
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter parentViewController: A `UIViewController` view controller instance from where this router is originated.
|
||||
public init(parentViewController: UIViewController) {
|
||||
self.parentViewController = parentViewController
|
||||
|
||||
super.init(navigationController: .init())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ModalNavigationRouter: Router {
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
onDismissForViewController[viewController] = onDismiss
|
||||
|
||||
if navigationController.viewControllers.isEmpty {
|
||||
presentModally(viewController, animated: animated)
|
||||
} else {
|
||||
navigationController.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
guard let firstViewController = navigationController.viewControllers.first else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: firstViewController)
|
||||
|
||||
parentViewController.dismiss(animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ModalNavigationRouter {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func presentModally(_ viewController: UIViewController, animated: Bool) {
|
||||
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
title: "Cancel",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(onCancelPressed)
|
||||
)
|
||||
|
||||
navigationController.setViewControllers([viewController], animated: false)
|
||||
|
||||
parentViewController.present(navigationController, animated: animated)
|
||||
}
|
||||
|
||||
@objc func onCancelPressed() {
|
||||
guard let firstViewController = navigationController.viewControllers.first else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: firstViewController)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
//
|
||||
// PushNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This class is responsible for presenting view controllers, as it is a concrete implementation of the `Router` protocol, but it won't know what view controller or which view controller is next.
|
||||
public class PushNavigationRouter: BaseNavigationRouter {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// A root view controller coming in from the navigation controller, if any.
|
||||
private let rootViewController: UIViewController?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameters:
|
||||
/// - navigationController: A `UINavigationController` navigation controller instance to use in this router.
|
||||
/// - rootViewController: A `UIViewController` view controller instance to define as a root view controller of the navigation controller.
|
||||
/// - Note This initialiser added the `rootViewController` parameter although it is not really needed to differentiate itself from the `.init(navigationController:)` implemented for the `BaseNavigationRouter` base class.
|
||||
public init(
|
||||
navigationController: UINavigationController,
|
||||
rootViewController: UIViewController? = nil
|
||||
) {
|
||||
self.rootViewController = navigationController.viewControllers.first ?? rootViewController
|
||||
|
||||
super.init(navigationController: navigationController)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Router
|
||||
|
||||
extension PushNavigationRouter: Router {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
onDismissForViewController[viewController] = onDismiss
|
||||
|
||||
navigationController.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
guard let rootViewController else {
|
||||
navigationController.popViewController(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: rootViewController)
|
||||
|
||||
navigationController.popToViewController(rootViewController, animated: animated)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
//
|
||||
// WindowRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This class is responsible for populating the window of an application.
|
||||
public class WindowRouter: Router {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The window to set manually with a `UIViewController` view controller instance.
|
||||
private let window: UIWindow?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter window: A `UIWindow` window instance to be set manually.
|
||||
public init(window: UIWindow?) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
window?.rootViewController = viewController
|
||||
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
// Nothing to do here...
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// Dependency.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This property wrapper provides a direct connection to the `DependencyService` service.
|
||||
@propertyWrapper
|
||||
public struct Dependency<D> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let keyPath: WritableKeyPath<DependencyService, D>
|
||||
|
||||
/// This property allows direct read/write access to a defined dependency attached to a selected key path.
|
||||
public var wrappedValue: D {
|
||||
get { DependencyService[keyPath] }
|
||||
set { DependencyService[keyPath] = newValue }
|
||||
}
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise the property wrapper by setting a key path to a defined dependency.
|
||||
/// - Parameter keyPath: A key path to a defined dependency in the `DependencyService` service.
|
||||
public init(_ keyPath: WritableKeyPath<DependencyService, D>) {
|
||||
self.keyPath = keyPath
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
//
|
||||
// DependencyKey.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol defines a key to use in the dependency service.
|
||||
public protocol DependencyKey {
|
||||
|
||||
// MARK: Associated types
|
||||
|
||||
/// The associated type representing the type of the dependency key's value.
|
||||
associatedtype Value
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The default value for the dependency key.
|
||||
static var currentValue: Value { get set }
|
||||
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
//
|
||||
// DependencyService.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This service provide write/read access to the injected dependencies.
|
||||
public struct DependencyService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private static var current = DependencyService()
|
||||
|
||||
// MARK: Subscripts
|
||||
|
||||
public static subscript<DK: DependencyKey>(key: DK.Type) -> DK.Value {
|
||||
get { key.currentValue }
|
||||
set { key.currentValue = newValue }
|
||||
}
|
||||
|
||||
public static subscript<D>(_ keyPath: WritableKeyPath<DependencyService, D>) -> D {
|
||||
get { current[keyPath: keyPath] }
|
||||
set { current[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
public struct Libraries {
|
||||
public private(set) var text = "Hello, World!"
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
<?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">
|
||||
<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"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="source" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
</model>
|
@ -0,0 +1,36 @@
|
||||
//
|
||||
// Service.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public protocol Service {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The main managed object context.
|
||||
var viewContext: NSManagedObjectContext { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Create a private queue context.
|
||||
/// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use.
|
||||
func makeTaskContext() -> NSManagedObjectContext
|
||||
|
||||
/// Create a child context of the view context.
|
||||
/// - Returns: A generated child `NSManagedObjectContext` context instance ready to use.
|
||||
func makeChildContext() -> NSManagedObjectContext
|
||||
|
||||
/// Save a given context,
|
||||
/// - Parameter context: A `NSManagedObjectContext` context instance to save.
|
||||
func save(context: NSManagedObjectContext)
|
||||
|
||||
/// Save a given child context as well as its respective parent context.
|
||||
/// - Parameter context: A child `NSManagedObjectContext` context instance to save.
|
||||
func save(childContext context: NSManagedObjectContext)
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -0,0 +1,135 @@
|
||||
//
|
||||
// PersistenceService.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public struct PersistenceService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public static let shared = PersistenceService()
|
||||
public static let inMemory = PersistenceService(inMemory: true)
|
||||
|
||||
public let container: NSPersistentContainer
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
guard
|
||||
let modelURL = Bundle.module.url(forResource: .Model.name, withExtension: .Model.extension),
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)
|
||||
else {
|
||||
fatalError("Could not load the model from the library.")
|
||||
}
|
||||
|
||||
container = NSPersistentContainer(
|
||||
name: .Model.name,
|
||||
managedObjectModel: managedObjectModel
|
||||
)
|
||||
|
||||
setContainer(inMemory)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
extension PersistenceService: Service {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var viewContext: NSManagedObjectContext { container.viewContext }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func makeTaskContext() -> NSManagedObjectContext {
|
||||
let taskContext = container.newBackgroundContext()
|
||||
|
||||
taskContext.automaticallyMergesChangesFromParent = true
|
||||
taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
return taskContext
|
||||
}
|
||||
|
||||
public func makeChildContext() -> NSManagedObjectContext {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
context.parent = container.viewContext
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
public func save(context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
public func save(childContext context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
guard
|
||||
let parent = context.parent,
|
||||
parent == container.viewContext
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
try parent.performAndWait {
|
||||
try parent.save()
|
||||
}
|
||||
} catch {
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PersistenceService {
|
||||
func setContainer(_ inMemory: Bool) {
|
||||
container.persistentStoreDescriptions = [
|
||||
NSPersistentStoreDescription(url:
|
||||
inMemory
|
||||
? URL(fileURLWithPath: "/dev/null")
|
||||
: NSPersistentContainer.defaultDirectoryURL().appending(path: "\(String.Model.name).sqlite")
|
||||
)
|
||||
]
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
}
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Model {
|
||||
static let name = "Model"
|
||||
static let `extension` = "momd"
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
//
|
||||
// RemoteClient.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
struct RemoteClient {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder = .init()
|
||||
private let makeURLRequest: MakeURLRequestUseCase = .init()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(configuration: URLSessionConfiguration = .default) {
|
||||
self.session = .init(configuration: configuration)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
extension RemoteClient: Client {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func request<Model: Decodable>(
|
||||
endpoint: some Endpoint,
|
||||
for model: Model.Type
|
||||
) async throws -> Model {
|
||||
let urlRequest = try makeURLRequest(endpoint: endpoint)
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw LocationsClientError.responseNotReturned
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
return try decoder.decode(model, from: data)
|
||||
case 400...499:
|
||||
throw LocationsClientError.statusErrorClient
|
||||
case 500...599:
|
||||
throw LocationsClientError.statusErrorServer
|
||||
default:
|
||||
throw LocationsClientError.statusErrorUnexpected
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
public enum LocationsClientError: Error {
|
||||
case responseNotReturned
|
||||
case statusErrorClient
|
||||
case statusErrorServer
|
||||
case statusErrorUnexpected
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
//
|
||||
// GetLocationsEndpoint.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
struct GetLocationsEndpoint: Endpoint {
|
||||
let scheme: String = .Scheme.https
|
||||
let host: String = .Hosts.default
|
||||
let port: Int? = nil
|
||||
let path: String = .Paths.getLocations
|
||||
let method: HTTPRequestMethod = .get
|
||||
let headers: [String: String] = [:]
|
||||
let body: Data? = nil
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
//
|
||||
// String+Constants.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
extension String {
|
||||
enum Scheme {
|
||||
static let https = "https"
|
||||
}
|
||||
|
||||
enum Hosts {
|
||||
static let `default` = "raw.githubusercontent.com"
|
||||
}
|
||||
|
||||
enum Paths {
|
||||
static let getLocations = "/abnamrocoesd/assignment-ios/main/locations.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
//
|
||||
// Location.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
public struct Location: Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let name: String?
|
||||
public let latitude: Float
|
||||
public let longitude: Float
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
name: String? = nil,
|
||||
latitude: Float,
|
||||
longitude: Float
|
||||
) {
|
||||
self.name = name
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Decodable
|
||||
|
||||
extension Location: Decodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case latitude = "lat"
|
||||
case longitude = "long"
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Service.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol Service {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Retrieve a set of locations.
|
||||
/// - Returns: The set of locations represented as a `Location` instances.
|
||||
func getLocations() async throws -> [Location]
|
||||
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
//
|
||||
// RemoteService.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
public struct RemoteService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let client: Client
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init(configuration: URLSessionConfiguration = .default) {
|
||||
self.client = RemoteClient(configuration: configuration)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
extension RemoteService: Service {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func getLocations() async throws -> [Location] {
|
||||
try await client.request(
|
||||
endpoint: GetLocationsEndpoint(),
|
||||
for: Locations.self
|
||||
).locations
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct Locations: Decodable, Equatable {
|
||||
public let locations: [Location]
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
//
|
||||
// MakeURLRequestUseCaseTests.swift
|
||||
// APICoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import APICore
|
||||
|
||||
final class MakeURLRequestUseCaseTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
|
||||
// MARK: Test cases
|
||||
|
||||
func test_withEndpoint_initialisedByDefault() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint()
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithPort() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint(port: 8080)
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com:8080/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithHeaders() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint(headers: [
|
||||
"aHeader": "aValueForHead",
|
||||
"someOtherHeader": "someValueForOtherHeader"
|
||||
])
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [
|
||||
"aHeader": "aValueForHead",
|
||||
"someOtherHeader": "someValueForOtherHeader"
|
||||
])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithBody() throws {
|
||||
// GIVEN
|
||||
let data = "This is some data for a body of a request".data(using: .utf8)
|
||||
let endpoint = TestEndpoint(body: data)
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertEqual(result.httpBody, data)
|
||||
XCTAssertNotNil(data)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TestEndpoint
|
||||
|
||||
private struct TestEndpoint: Endpoint {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let scheme: String = "http"
|
||||
let host: String = "www.something.com"
|
||||
let path: String = "/path/to/endpoint"
|
||||
let method: HTTPRequestMethod = .get
|
||||
|
||||
var port: Int?
|
||||
var headers: [String : String]
|
||||
var body: Data?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
port: Int? = nil,
|
||||
headers: [String : String] = [:],
|
||||
body: Data? = nil
|
||||
) {
|
||||
self.port = port
|
||||
self.body = body
|
||||
self.headers = headers
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
//
|
||||
// TestCoordinators.swift
|
||||
// CoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
// MARK: - Test coordinators
|
||||
|
||||
class SomeCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
SomeViewController(),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SomeOtherCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
SomeOtherViewController(),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - SpyRouter
|
||||
|
||||
class SpyRouter: Router {
|
||||
|
||||
// MARK: Enumerations
|
||||
|
||||
enum State {
|
||||
case initialised
|
||||
case presented
|
||||
case dismissed
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var state: State = .initialised
|
||||
var viewController: UIViewController?
|
||||
var animated: Bool?
|
||||
var onDismiss: OnDismissedClosure?
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
self.viewController = viewController
|
||||
self.animated = animated
|
||||
self.onDismiss = onDismiss
|
||||
self.state = .presented
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool) {
|
||||
self.animated = animated
|
||||
self.state = .dismissed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Test view controllers
|
||||
|
||||
class SomeViewController: UIViewController {}
|
||||
class SomeOtherViewController: UIViewController {}
|
@ -0,0 +1,138 @@
|
||||
//
|
||||
// CoordinatorTests.swift
|
||||
// CoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Core
|
||||
|
||||
final class CoordinatorTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var router: SpyRouter!
|
||||
private var coordinator: Coordinator!
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_present_withoutOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeCoordinator(router: self.router)
|
||||
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(animated: false, onDismiss: nil)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeViewController)
|
||||
XCTAssertFalse(self.router.animated ?? true)
|
||||
XCTAssertNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_present_withOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(animated: true) {}
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
|
||||
XCTAssertTrue(self.router.animated ?? false)
|
||||
XCTAssertNotNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_presentChild_withoutOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeCoordinator(router: self.router)
|
||||
|
||||
let child = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(child: child, animated: false)
|
||||
|
||||
// THEN
|
||||
XCTAssertFalse(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.coordinator.children.count, 1)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
|
||||
XCTAssertFalse(self.router.animated ?? true)
|
||||
XCTAssertNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_presentChild_withOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
let child = SomeCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(child: child, animated: true) {}
|
||||
|
||||
// THEN
|
||||
XCTAssertFalse(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.coordinator.children.count, 1)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeViewController)
|
||||
XCTAssertTrue(self.router.animated ?? false)
|
||||
XCTAssertNotNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_dismiss_notAnimated() {
|
||||
// GIVEN
|
||||
router = SpyRouter()
|
||||
coordinator = SomeOtherCoordinator(router: router)
|
||||
|
||||
// WHEN
|
||||
coordinator.dismiss(animated: false)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(coordinator.children.isEmpty)
|
||||
XCTAssertEqual(router.state, .dismissed)
|
||||
XCTAssertNil(router.viewController)
|
||||
XCTAssertFalse(router.animated ?? true)
|
||||
XCTAssertNil(router.onDismiss)
|
||||
}
|
||||
|
||||
func test_dismiss_animated() {
|
||||
// GIVEN
|
||||
router = SpyRouter()
|
||||
coordinator = SomeOtherCoordinator(router: router)
|
||||
|
||||
// WHEN
|
||||
coordinator.dismiss(animated: true)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(coordinator.children.isEmpty)
|
||||
XCTAssertEqual(router.state, .dismissed)
|
||||
XCTAssertNil(router.viewController)
|
||||
XCTAssertTrue(router.animated ?? false)
|
||||
XCTAssertNil(router.onDismiss)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
//
|
||||
// TestServices.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Dependency
|
||||
|
||||
// MARK: - Protocols
|
||||
|
||||
protocol TestService {}
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
struct SomeService: TestService, Equatable {}
|
||||
struct SomeOtherService: TestService, Equatable {}
|
||||
|
||||
// MARK: - DependencyKey
|
||||
|
||||
struct TestServiceKey: DependencyKey {
|
||||
static var currentValue: TestService = SomeService()
|
||||
}
|
||||
|
||||
// MARK: - DependencyService+Keys
|
||||
|
||||
extension DependencyService {
|
||||
var testService: TestService {
|
||||
get { Self[TestServiceKey.self] }
|
||||
set { Self[TestServiceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,74 @@
|
||||
//
|
||||
// DependencyTests.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Dependency
|
||||
|
||||
final class DependencyTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var subject: TestSubject!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() {
|
||||
DependencyService[\.testService] = SomeService()
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_readTestService() {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
// WHEN
|
||||
let service = subject.testService
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKey() async throws {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
subject.testService = SomeOtherService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeOtherService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKeyTwice() async throws {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
subject.testService = SomeOtherService()
|
||||
subject.testService = SomeService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TestSubject
|
||||
|
||||
private struct TestSubject {
|
||||
@Dependency(\.testService) var testService
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
//
|
||||
// DependencyServiceTests.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Dependency
|
||||
|
||||
final class DependencyServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() {
|
||||
DependencyService[\.testService] = SomeService()
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_readDependencyKey() async throws {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKey() async throws {
|
||||
// GIVEN
|
||||
DependencyService[\.testService] = SomeOtherService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeOtherService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKeyTwice() async throws {
|
||||
// GIVEN
|
||||
DependencyService[\.testService] = SomeOtherService()
|
||||
DependencyService[\.testService] = SomeService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import XCTest
|
||||
@testable import Libraries
|
||||
|
||||
final class LibrariesTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(Libraries().text, "Hello, World!")
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
//
|
||||
// PersistenceServiceTests.swift
|
||||
// PersistenceTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import XCTest
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
final class PersistenceServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var persistence: PersistenceService!
|
||||
|
||||
// MARK: Initialiser tests
|
||||
|
||||
func test_initByDefault() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
persistence = .init()
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
func test_initWithInMemory() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
persistence = .init(inMemory: true)
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
// MARK: Static properties tests
|
||||
|
||||
func test_shared() {
|
||||
// GIVEN
|
||||
persistence = .shared
|
||||
|
||||
// WHEN
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
func test_inMemory() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
// MARK: Functions tests
|
||||
|
||||
func test_makeTaskContext() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
let context = persistence.makeTaskContext()
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(context.automaticallyMergesChangesFromParent)
|
||||
XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy)
|
||||
XCTAssertNil(context.parent)
|
||||
}
|
||||
|
||||
func test_makeChildContext() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
let context = persistence.makeChildContext()
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(context.automaticallyMergesChangesFromParent)
|
||||
XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy)
|
||||
XCTAssertEqual(context.parent, persistence.container.viewContext)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// LocationsClientTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class LocationsClientTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
private let sessionConfiguration = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
return configuration
|
||||
}()
|
||||
|
||||
private var client: LocationsClient!
|
||||
private var url: URL!
|
||||
private var data: Data!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() async throws {
|
||||
client = .init(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
client = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseOK() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 200,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN
|
||||
let result = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(result, Locations(locations: [
|
||||
.init(
|
||||
name: "Amsterdam",
|
||||
latitude: 52.3547498,
|
||||
longitude: 4.8339215
|
||||
),
|
||||
.init(
|
||||
name: "Mumbai",
|
||||
latitude: 19.0823998,
|
||||
longitude: 72.8111468
|
||||
),
|
||||
.init(
|
||||
name: "Copenhagen",
|
||||
latitude: 55.6713442,
|
||||
longitude: 12.523785
|
||||
),
|
||||
.init(
|
||||
latitude: 40.4380638,
|
||||
longitude: -3.7495758
|
||||
)
|
||||
]))
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseClientError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 404,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorClient {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseServerError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 500,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorServer {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseUnexpectedError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 302,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorUnexpected {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
//
|
||||
// GetLocationsEndpointTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class GetLocationsEndpointTests: XCTestCase {
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_init() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(endpoint)
|
||||
XCTAssertEqual(endpoint.scheme, .Scheme.https)
|
||||
XCTAssertEqual(endpoint.host, .Hosts.default)
|
||||
XCTAssertNil(endpoint.port)
|
||||
XCTAssertEqual(endpoint.path, .Paths.getLocations)
|
||||
XCTAssertTrue(endpoint.headers.isEmpty)
|
||||
XCTAssertNil(endpoint.body)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Data+Constants.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
enum Responses {
|
||||
static let locations = "{\"locations\":[{\"name\":\"Amsterdam\",\"lat\":52.3547498,\"long\":4.8339215},{\"name\":\"Mumbai\",\"lat\":19.0823998,\"long\":72.8111468},{\"name\":\"Copenhagen\",\"lat\":55.6713442,\"long\":12.523785},{\"lat\":40.4380638,\"long\":-3.7495758}]}".data(using: .utf8)
|
||||
}
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// LocationsServiceTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class LocationsServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
private let sessionConfiguration = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
return configuration
|
||||
}()
|
||||
|
||||
private var service: LocationsService!
|
||||
private var url: URL!
|
||||
private var data: Data!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() async throws {
|
||||
service = .init(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
service = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_getLocations_whenResponseOK() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 200,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN
|
||||
let result = try await service.getLocations()
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(result, [
|
||||
.init(
|
||||
name: "Amsterdam",
|
||||
latitude: 52.3547498,
|
||||
longitude: 4.8339215
|
||||
),
|
||||
.init(
|
||||
name: "Mumbai",
|
||||
latitude: 19.0823998,
|
||||
longitude: 72.8111468
|
||||
),
|
||||
.init(
|
||||
name: "Copenhagen",
|
||||
latitude: 55.6713442,
|
||||
longitude: 12.523785
|
||||
),
|
||||
.init(
|
||||
latitude: 40.4380638,
|
||||
longitude: -3.7495758
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseClientError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 404,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorClient {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseServerError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 500,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorServer {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseUnexpectedError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 302,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorUnexpected {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "255",
|
||||
"green" : "255",
|
||||
"red" : "255"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
16
Apps/Locations/Resources/Assets.xcassets/Launch/LaunchImage.imageset/Contents.json
vendored
Normal file
16
Apps/Locations/Resources/Assets.xcassets/Launch/LaunchImage.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "location.fill.viewfinder.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "original"
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="260px" height="261px" viewBox="0 0 260 261" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>location.fill.viewfinder</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Large" transform="translate(-80.000000, -190.000000)" fill-rule="nonzero">
|
||||
<g id="location.fill.viewfinder" transform="translate(80.000000, 190.000000)">
|
||||
<rect id="Rectangle" fill="#000000" opacity="0" x="0" y="0" width="260" height="260.132576"></rect>
|
||||
<path d="M10.6230813,84.7191584 C17.6608642,84.7191584 21.3789426,80.7354179 21.3789426,73.8305851 L21.3789426,42.4923252 C21.3789426,28.6823195 28.6823195,21.6445367 41.9613411,21.6445367 L74.0960771,21.6445367 C81.133826,21.6445367 84.9846505,17.7936782 84.9846505,10.8886413 C84.9846505,3.98367249 81.133826,0.265577033 74.0960771,0.265577033 L41.6955091,0.265577033 C13.9427857,0.265577033 0,13.9427857 0,41.2971011 L0,73.8305851 C0,80.7354179 3.85085848,84.7191584 10.6230813,84.7191584 Z M249.244003,84.7191584 C256.281752,84.7191584 260,80.7354179 260,73.8305851 L260,41.2971011 C260,13.9427857 246.057078,0.265577033 218.304491,0.265577033 L185.771007,0.265577033 C178.866174,0.265577033 175.01535,3.98367249 175.01535,10.8886413 C175.01535,17.7936782 178.866174,21.6445367 185.771007,21.6445367 L217.906083,21.6445367 C231.052188,21.6445367 238.620921,28.6823195 238.620921,42.4923252 L238.620921,73.8305851 C238.620921,80.7354179 242.471746,84.7191584 249.244003,84.7191584 Z M41.6955091,260.132576 L74.0960771,260.132576 C81.133826,260.132576 84.9846505,256.281752 84.9846505,249.509495 C84.9846505,242.604662 81.133826,238.753837 74.0960771,238.753837 L41.9613411,238.753837 C28.6823195,238.753837 21.3789426,231.716089 21.3789426,217.906083 L21.3789426,186.567823 C21.3789426,179.530074 17.5280842,175.67925 10.6230813,175.67925 C3.71807846,175.67925 0,179.530074 0,186.567823 L0,218.968391 C0,246.455486 13.9427857,260.132576 41.6955091,260.132576 Z M185.771007,260.132576 L218.304491,260.132576 C246.057078,260.132576 260,246.32257 260,218.968391 L260,186.567823 C260,179.530074 256.149176,175.67925 249.244003,175.67925 C242.33917,175.67925 238.620921,179.530074 238.620921,186.567823 L238.620921,217.906083 C238.620921,231.716089 231.052188,238.753837 217.906083,238.753837 L185.771007,238.753837 C178.866174,238.753837 175.01535,242.604662 175.01535,249.509495 C175.01535,256.281752 178.866174,260.132576 185.771007,260.132576 Z" id="Shape" fill-opacity="0.5" fill="#FF3B30"></path>
|
||||
<path d="M59.6221713,141.419897 L115.260262,141.419897 C117.916203,141.419897 119.509495,143.146106 119.509495,145.801706 L119.509495,200.643321 C119.509495,212.594542 132.124616,213.789426 136.108357,205.158385 L195.066288,79.5401938 C199.846845,69.3155206 191.082548,60.4186475 180.725298,65.33212 L55.2400228,124.953951 C46.6086418,128.937692 48.0693579,141.419897 59.6221713,141.419897 Z" id="Path" fill="#FF3B30"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.0 KiB |
@ -1,7 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
@ -11,15 +14,34 @@
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="tiM-Rq-Okn">
|
||||
<rect key="frame" x="66.666666666666686" y="296" width="260" height="260"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="260" id="gc5-0A-vWD"/>
|
||||
<constraint firstAttribute="width" constant="260" id="oJA-1G-TR6"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" name="LaunchBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="tiM-Rq-Okn" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="U36-s9-WSk"/>
|
||||
<constraint firstItem="tiM-Rq-Okn" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="sWf-oH-DcQ"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
<point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="260" height="261"/>
|
||||
<namedColor name="LaunchBackgroundColor">
|
||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
@ -2,24 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>wikipedia</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
||||
<elements/>
|
||||
</model>
|
@ -6,77 +6,31 @@
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
lazy var coordinator: LocationsListCoordinator = .init(router: router)
|
||||
lazy var router: WindowRouter = .init(window: window)
|
||||
lazy var window: UIWindow? = .init(frame: UIScreen.main.bounds)
|
||||
|
||||
// MARK: UIApplicationDelegate
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
coordinator.present(animated: false, onDismiss: nil)
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
|
||||
// MARK: - Core Data stack
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
/*
|
||||
The persistent container for the application. This implementation
|
||||
creates and returns a container, having loaded the store for the
|
||||
application to it. This property is optional since there are legitimate
|
||||
error conditions that could cause the creation of the store to fail.
|
||||
*/
|
||||
let container = NSPersistentContainer(name: "Locations")
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
return container
|
||||
}()
|
||||
|
||||
// MARK: - Core Data Saving support
|
||||
|
||||
func saveContext () {
|
||||
let context = persistentContainer.viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Save changes in the application's managed object context when the application transitions to the background.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,49 @@
|
||||
//
|
||||
// LocationsAddCoordinator.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
class LocationsAddCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
LocationsAddViewController(
|
||||
viewModel: LocationsAddViewModel(coordinator: self)
|
||||
),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsAddCoordination
|
||||
|
||||
extension LocationsAddCoordinator: LocationsAddCoordination {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func closeLocationsAddScreen() {
|
||||
router.dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
//
|
||||
// LocationsListCoordinator.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import Dependency
|
||||
import UIKit
|
||||
|
||||
class LocationsListCoordinator: Coordinator {
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.app) private var app
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
private var viewController: UIViewController?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
let navigationController = UINavigationController(rootViewController: LocationsListViewController(
|
||||
viewModel: LocationsListViewModel(coordinator: self)
|
||||
))
|
||||
|
||||
viewController = navigationController
|
||||
|
||||
router.present(
|
||||
navigationController,
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsListCoordination
|
||||
|
||||
extension LocationsListCoordinator: LocationsListCoordination {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func openLocationsAddScreen() {
|
||||
guard let viewController else {
|
||||
return
|
||||
}
|
||||
|
||||
present(
|
||||
child: LocationsAddCoordinator(
|
||||
router: ModalNavigationRouter(parentViewController: viewController)
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
func openWikipediaApp(with url: URL) {
|
||||
guard app.canOpenURL(url) else {
|
||||
return
|
||||
}
|
||||
|
||||
app.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
//
|
||||
// DependencyService+Keys.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import Dependency
|
||||
import Persistence
|
||||
import Remote
|
||||
import UIKit
|
||||
|
||||
// MARK: - DependencyService+Keys
|
||||
|
||||
extension DependencyService {
|
||||
var app: Core.Application {
|
||||
get { Self[ApplicationKey.self] }
|
||||
set { Self[ApplicationKey.self] = newValue }
|
||||
}
|
||||
|
||||
var persistence: Persistence.Service {
|
||||
get { Self[PersistenceKey.self] }
|
||||
set { Self[PersistenceKey.self] = newValue }
|
||||
}
|
||||
|
||||
var remote: Remote.Service {
|
||||
get { Self[RemoteKey.self] }
|
||||
set { Self[RemoteKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dependency keys
|
||||
|
||||
struct ApplicationKey: DependencyKey {
|
||||
static var currentValue: Core.Application = UIApplication.shared
|
||||
}
|
||||
|
||||
struct PersistenceKey: DependencyKey {
|
||||
static var currentValue: Persistence.Service = PersistenceService.shared
|
||||
}
|
||||
|
||||
struct RemoteKey: DependencyKey {
|
||||
static var currentValue: Remote.Service = RemoteService()
|
||||
}
|
46
Apps/Locations/Sources/Extensions/Location+URLs.swift
Normal file
46
Apps/Locations/Sources/Extensions/Location+URLs.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// Location+URLs.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
extension Location {
|
||||
|
||||
var wikipediaPlacesURL: URL? {
|
||||
var urlComponents = URLComponents()
|
||||
|
||||
urlComponents.scheme = .Scheme.wikipedia
|
||||
urlComponents.host = .Host.places
|
||||
urlComponents.queryItems = [
|
||||
.init(
|
||||
name: .Query.key,
|
||||
value: .init(format: .Query.value, latitude, longitude)
|
||||
)
|
||||
]
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Scheme {
|
||||
static let wikipedia = "wikipedia"
|
||||
}
|
||||
|
||||
enum Host {
|
||||
static let places = "places"
|
||||
}
|
||||
|
||||
enum Query {
|
||||
static let key = "coordinates"
|
||||
static let value = "%f,%f"
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
//
|
||||
// UIApplication+Conformances.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
extension UIApplication: Application {}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// LocationsAddCoordination.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
protocol LocationsAddCoordination: AnyObject {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func closeLocationsAddScreen()
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
//
|
||||
// LocationsListCoordination.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol LocationsListCoordination: AnyObject {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func openLocationsAddScreen()
|
||||
func openWikipediaApp(with url: URL)
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
//
|
||||
// LocationsAddViewModeling.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// 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)
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// LocationsListViewModeling.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// 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 locationsDidChangePublisher: PassthroughSubject<[Change], Never> { get }
|
||||
var numberOfLocationSections: Int { get }
|
||||
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func loadLocations()
|
||||
func location(at indexPath: IndexPath) -> Location
|
||||
func numberOfLocations(in section: Int) -> Int
|
||||
func openLocationsAdd()
|
||||
func openWikipedia(at indexPath: IndexPath)
|
||||
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
//
|
||||
// SceneDelegate.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||
guard let _ = (scene as? UIWindowScene) else { return }
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
// Called as the scene is being released by the system.
|
||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the background to the foreground.
|
||||
// Use this method to undo the changes made on entering the background.
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
|
||||
// Save changes in the application's managed object context when the application transitions to the background.
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,138 @@
|
||||
//
|
||||
// LocationsAddViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Core
|
||||
import UIKit
|
||||
import MapKit
|
||||
|
||||
class LocationsAddViewController: BaseViewController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
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
|
||||
|
||||
init(viewModel: LocationsAddViewModeling) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
//
|
||||
// LocationsAddViewModel.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
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 {
|
||||
|
||||
// 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?.closeLocationsAddScreen()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
//
|
||||
// LocationsListViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
class LocationsListViewController: BaseViewController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
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
|
||||
|
||||
init(viewModel: LocationsListViewModeling) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBar()
|
||||
setupView()
|
||||
bindViewModel()
|
||||
|
||||
viewModel.loadLocations()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
extension LocationsListViewController: UITableViewDataSource {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.numberOfLocationSections
|
||||
}
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
viewModel.numberOfLocations(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.location(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: Functions
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
didSelectRowAt indexPath: IndexPath
|
||||
) {
|
||||
viewModel.openWikipedia(at: indexPath)
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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,
|
||||
action: #selector(addLocationPressed)
|
||||
)
|
||||
title = "Locations"
|
||||
}
|
||||
|
||||
func setupView() {
|
||||
view.addSubview(table)
|
||||
view.addSubview(error)
|
||||
view.addSubview(loading)
|
||||
|
||||
error.onRetry = {
|
||||
self.viewModel.loadLocations()
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
viewModel
|
||||
.locationsDidChangePublisher
|
||||
.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() {
|
||||
viewModel.openLocationsAdd()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
//
|
||||
// LocationsListViewModel.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Dependency
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
class LocationsListViewModel: ObservableObject {
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.persistence) private var persistence
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
weak var coordinator: LocationsListCoordination?
|
||||
|
||||
private lazy var locationProvider = LocationProvider(managedContext: persistence.viewContext)
|
||||
|
||||
@Published private var viewStatus: LocationsListViewStatus = .initialised
|
||||
|
||||
private let loadRemoteLocations = LoadRemoteLocationsUseCase()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(coordinator: LocationsListCoordination) {
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsListViewModeling
|
||||
|
||||
extension LocationsListViewModel: LocationsListViewModeling {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var locationsDidChangePublisher: PassthroughSubject<[Persistence.Change], Never> { locationProvider.didChangePublisher }
|
||||
var numberOfLocationSections: Int { locationProvider.numberOfSections }
|
||||
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { $viewStatus }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func loadLocations() {
|
||||
Task {
|
||||
do {
|
||||
viewStatus = .loading
|
||||
|
||||
try await loadRemoteLocations()
|
||||
|
||||
try locationProvider.fetch()
|
||||
|
||||
viewStatus = .loaded
|
||||
} catch {
|
||||
viewStatus = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func location(at indexPath: IndexPath) -> Location {
|
||||
locationProvider.location(at: indexPath)
|
||||
}
|
||||
|
||||
func numberOfLocations(in section: Int) -> Int {
|
||||
locationProvider.numberOfLocationsInSection(section)
|
||||
}
|
||||
|
||||
func openLocationsAdd() {
|
||||
coordinator?.openLocationsAddScreen()
|
||||
}
|
||||
|
||||
func openWikipedia(at indexPath: IndexPath) {
|
||||
guard let url = locationProvider.location(at: indexPath).wikipediaPlacesURL else {
|
||||
return
|
||||
}
|
||||
|
||||
coordinator?.openWikipediaApp(with: url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
enum LocationsListViewStatus {
|
||||
case initialised
|
||||
case loading
|
||||
case loaded
|
||||
case error
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
//
|
||||
// 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: Persistence.Service
|
||||
private let remoteService: Remote.Service
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
persistence: Persistence.Service,
|
||||
remoteService: Remote.Service
|
||||
) {
|
||||
self.persistence = persistence
|
||||
self.remoteService = remoteService
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
@ -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: Persistence.Service
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(persistence: Persistence.Service) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
124
Apps/Locations/Sources/View Components/ErrorMessageView.swift
Normal file
124
Apps/Locations/Sources/View Components/ErrorMessageView.swift
Normal 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?()
|
||||
}
|
||||
|
||||
}
|
@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
223
Apps/Locations/Sources/View Components/LocationViewCell.swift
Normal file
223
Apps/Locations/Sources/View Components/LocationViewCell.swift
Normal 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),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// BaseViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class BaseViewController: UIViewController {
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .systemBackground
|
||||
}
|
||||
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -456,7 +456,6 @@
|
||||
41FCAA3721C844CB001D8411 /* ReadingListEntryCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FCAA3521C844CB001D8411 /* ReadingListEntryCollectionViewController.swift */; };
|
||||
41FCAA3821C844CB001D8411 /* ReadingListEntryCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FCAA3521C844CB001D8411 /* ReadingListEntryCollectionViewController.swift */; };
|
||||
41FCAA3921C844CB001D8411 /* ReadingListEntryCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FCAA3521C844CB001D8411 /* ReadingListEntryCollectionViewController.swift */; };
|
||||
46EB334829E1D204001D5EAF /* Shared in Frameworks */ = {isa = PBXBuildFile; productRef = 46EB334729E1D204001D5EAF /* Shared */; };
|
||||
533AB8AE259792A9003A43D9 /* wikipedia-language-variants.json in Resources */ = {isa = PBXBuildFile; fileRef = 533AB8AD259792A9003A43D9 /* wikipedia-language-variants.json */; };
|
||||
535F16D625CE11A300875AAD /* MWKDataStore+LanguageVariantMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535F16D525CE11A300875AAD /* MWKDataStore+LanguageVariantMigration.swift */; };
|
||||
53A575FA2602C845009835E6 /* WMFAppViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A575F92602C845009835E6 /* WMFAppViewController+Extensions.swift */; };
|
||||
@ -5776,7 +5775,6 @@
|
||||
D8D553621DF1B63200B90177 /* QuartzCore.framework in Frameworks */,
|
||||
D4E6D9121A5C65F9004916C1 /* CoreData.framework in Frameworks */,
|
||||
D499143B181D51DE00E6073C /* CoreGraphics.framework in Frameworks */,
|
||||
46EB334829E1D204001D5EAF /* Shared in Frameworks */,
|
||||
D499143D181D51DE00E6073C /* UIKit.framework in Frameworks */,
|
||||
D4991439181D51DE00E6073C /* Foundation.framework in Frameworks */,
|
||||
041EFC371996A1F800B2CB28 /* MapKit.framework in Frameworks */,
|
||||
@ -10079,7 +10077,6 @@
|
||||
);
|
||||
name = Wikipedia;
|
||||
packageProductDependencies = (
|
||||
46EB334729E1D204001D5EAF /* Shared */,
|
||||
);
|
||||
productName = "Wikipedia-iOS";
|
||||
productReference = D4991435181D51DE00E6073C /* Wikipedia.app */;
|
||||
@ -20146,10 +20143,6 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
46EB334729E1D204001D5EAF /* Shared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Shared;
|
||||
};
|
||||
67A770C7251BFE0400F94EF9 /* CocoaLumberjackSwift */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 67A770C6251BFE0400F94EF9 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */;
|
||||
|
@ -1,3 +1,4 @@
|
||||
@import CoreLocation;
|
||||
@import Foundation;
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@ -46,6 +47,8 @@ extern NSString *const WMFNavigateToActivityNotification;
|
||||
|
||||
- (nullable NSURL *)wmf_linkURL;
|
||||
|
||||
- (nullable CLLocation *)wmf_locationFromURL;
|
||||
|
||||
- (NSURL *)wmf_contentURL;
|
||||
|
||||
+ (NSURL *)wmf_baseURLForActivityOfType:(WMFUserActivityType)type;
|
||||
|
@ -62,15 +62,42 @@ __attribute__((annotate("returns_localized_nsstring"))) static inline NSString *
|
||||
+ (instancetype)wmf_placesActivityWithURL:(NSURL *)activityURL {
|
||||
NSURLComponents *components = [NSURLComponents componentsWithURL:activityURL resolvingAgainstBaseURL:NO];
|
||||
NSURL *articleURL = nil;
|
||||
NSNumber *latitude = nil;
|
||||
NSNumber *longitude = nil;
|
||||
|
||||
for (NSURLQueryItem *item in components.queryItems) {
|
||||
if ([item.name isEqualToString:@"WMFArticleURL"]) {
|
||||
NSString *articleURLString = item.value;
|
||||
articleURL = [NSURL URLWithString:articleURLString];
|
||||
break;
|
||||
}
|
||||
|
||||
if ([item.name isEqualToString:@"coordinates"]) {
|
||||
NSArray *numbers = [item.value componentsSeparatedByString:@","];
|
||||
|
||||
if (numbers.count == 2) {
|
||||
NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
|
||||
|
||||
latitude = [formatter numberFromString:numbers.firstObject];
|
||||
longitude = [formatter numberFromString:numbers.lastObject];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSUserActivity *activity = [self wmf_pageActivityWithName:@"Places"];
|
||||
activity.webpageURL = articleURL;
|
||||
|
||||
if (latitude != nil && longitude != nil) {
|
||||
NSMutableDictionary *userInfo = [activity.userInfo mutableCopy];
|
||||
|
||||
userInfo[@"WMFCoordinatesLatitude"] = latitude;
|
||||
userInfo[@"WMFCoordinatesLongitude"] = longitude;
|
||||
|
||||
activity.userInfo = [userInfo copy];
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@ -264,6 +291,18 @@ __attribute__((annotate("returns_localized_nsstring"))) static inline NSString *
|
||||
}
|
||||
}
|
||||
|
||||
- (CLLocation *)wmf_locationFromURL {
|
||||
NSNumber *latitude = self.userInfo[@"WMFCoordinatesLatitude"];
|
||||
NSNumber *longitude = self.userInfo[@"WMFCoordinatesLongitude"];
|
||||
|
||||
if (latitude != nil && longitude != nil) {
|
||||
return [[CLLocation alloc] initWithLatitude:(CLLocationDegrees)[latitude floatValue]
|
||||
longitude:(CLLocationDegrees)[longitude floatValue]];
|
||||
} else {
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSURL *)wmf_contentURL {
|
||||
return self.userInfo[@"WMFURL"];
|
||||
}
|
||||
|
@ -1126,6 +1126,10 @@ class PlacesViewController: ViewController, UISearchBarDelegate, ArticlePopoverV
|
||||
viewMode = .map
|
||||
}
|
||||
|
||||
@objc func centerMap(onLocation location: CLLocation) {
|
||||
zoomAndPanMapView(toLocation: location)
|
||||
}
|
||||
|
||||
func selectArticlePlace(_ articlePlace: ArticlePlace) {
|
||||
mapView.selectAnnotation(articlePlace, animated: articlePlace.identifier != previouslySelectedArticlePlaceIdentifier)
|
||||
previouslySelectedArticlePlaceIdentifier = articlePlace.identifier
|
||||
|
@ -1187,10 +1187,16 @@ NSString *const WMFLanguageVariantAlertsLibraryVersion = @"WMFLanguageVariantAle
|
||||
[self setSelectedIndex:WMFAppTabTypePlaces];
|
||||
[self.navigationController popToRootViewControllerAnimated:animated];
|
||||
NSURL *articleURL = activity.wmf_linkURL;
|
||||
if (articleURL) {
|
||||
CLLocation *locationFromURL = activity.wmf_locationFromURL;
|
||||
if (articleURL || locationFromURL) {
|
||||
// For "View on a map" action to succeed, view mode has to be set to map.
|
||||
[[self placesViewController] updateViewModeToMap];
|
||||
[[self placesViewController] showArticleURL:articleURL];
|
||||
if (locationFromURL) {
|
||||
[[self placesViewController] centerMapOnLocation:locationFromURL];
|
||||
}
|
||||
else if (articleURL) {
|
||||
[[self placesViewController] showArticleURL:articleURL];
|
||||
}
|
||||
}
|
||||
} break;
|
||||
case WMFUserActivityTypeContent: {
|
||||
|
@ -113,8 +113,6 @@ public enum WikimediaProject: Hashable {
|
||||
self = .wikibooks(languageCode, localizedLanguageName)
|
||||
} else if siteURLString.contains(Configuration.Domain.wiktionary) {
|
||||
self = .wiktionary(languageCode, localizedLanguageName)
|
||||
} else if siteURLString.contains(Configuration.Domain.wiktionary) {
|
||||
self = .wiktionary(languageCode, localizedLanguageName)
|
||||
} else if siteURLString.contains(Configuration.Domain.wikisource) {
|
||||
self = .wikisource(languageCode, localizedLanguageName)
|
||||
} else if siteURLString.contains(Configuration.Domain.wikinews) {
|
||||
|
@ -44,6 +44,76 @@
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypeSavedPages);
|
||||
}
|
||||
|
||||
- (void)testPlacesURL {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLatitude"]);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLongitude"]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinates {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates=52.370216,4.895168"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssert([activity.userInfo[@"WMFCoordinatesLatitude"] isEqualToNumber:@52.370216]);
|
||||
XCTAssert([activity.userInfo[@"WMFCoordinatesLongitude"] isEqualToNumber:@4.895168]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinatesButNegatives {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates=-34.603722,-58.381592"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssert([activity.userInfo[@"WMFCoordinatesLatitude"] isEqualToNumber:@-34.603722]);
|
||||
XCTAssert([activity.userInfo[@"WMFCoordinatesLongitude"] isEqualToNumber:@-58.381592]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinatesButNoLongitude {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates=52.370216,"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLatitude"]);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLongitude"]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinatesButNoLatitude {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates=,4.895168"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLatitude"]);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLongitude"]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinatesButNoCoordinates {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates=,"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLatitude"]);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLongitude"]);
|
||||
}
|
||||
|
||||
- (void)testPlacesURLWithCoordinatesButEmpty {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://places?coordinates="];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
||||
XCTAssertEqual(activity.wmf_type, WMFUserActivityTypePlaces);
|
||||
XCTAssertNil(activity.webpageURL);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLatitude"]);
|
||||
XCTAssertNil(activity.userInfo[@"WMFCoordinatesLongitude"]);
|
||||
}
|
||||
|
||||
- (void)testSearchURL {
|
||||
NSURL *url = [NSURL URLWithString:@"wikipedia://en.wikipedia.org/w/index.php?search=dog"];
|
||||
NSUserActivity *activity = [NSUserActivity wmf_activityForWikipediaScheme:url];
|
||||
|
@ -7,15 +7,29 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
02031F0829E75EF0003C108C /* SaveLocalLocationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031F0729E75EED003C108C /* SaveLocalLocationUseCase.swift */; };
|
||||
02031F0A29E7645F003C108C /* Location+URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02031F0929E7645F003C108C /* Location+URLs.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 */; };
|
||||
46C3B7D129E5D06D00F8F57C /* LocationsAddViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7D029E5D06D00F8F57C /* LocationsAddViewController.swift */; };
|
||||
46C3B7D629E5E50500F8F57C /* LocationsListViewModeling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7D529E5E50500F8F57C /* LocationsListViewModeling.swift */; };
|
||||
46C3B7D829E5E55000F8F57C /* LocationsListCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7D729E5E55000F8F57C /* LocationsListCoordination.swift */; };
|
||||
46C3B7DC29E5ED2300F8F57C /* LocationsAddCoordination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7DB29E5ED2300F8F57C /* LocationsAddCoordination.swift */; };
|
||||
46C3B7DE29E5ED2E00F8F57C /* LocationsAddCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C3B7DD29E5ED2E00F8F57C /* LocationsAddCoordinator.swift */; };
|
||||
46DF736D29E82A1500AA6D21 /* UIApplication+Conformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46DF736C29E82A1500AA6D21 /* UIApplication+Conformances.swift */; };
|
||||
46EB331B29E1CE04001D5EAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331A29E1CE04001D5EAF /* AppDelegate.swift */; };
|
||||
46EB331D29E1CE04001D5EAF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331C29E1CE04001D5EAF /* SceneDelegate.swift */; };
|
||||
46EB331F29E1CE04001D5EAF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331E29E1CE04001D5EAF /* ViewController.swift */; };
|
||||
46EB332229E1CE04001D5EAF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46EB332029E1CE04001D5EAF /* Main.storyboard */; };
|
||||
46EB332529E1CE04001D5EAF /* Locations.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */; };
|
||||
46EB331F29E1CE04001D5EAF /* LocationsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331E29E1CE04001D5EAF /* LocationsListViewController.swift */; };
|
||||
46EB332729E1CE05001D5EAF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46EB332629E1CE05001D5EAF /* Assets.xcassets */; };
|
||||
46EB332A29E1CE05001D5EAF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46EB332829E1CE05001D5EAF /* LaunchScreen.storyboard */; };
|
||||
46EB334429E1D1EC001D5EAF /* Libraries in Frameworks */ = {isa = PBXBuildFile; productRef = 46EB334329E1D1EC001D5EAF /* Libraries */; };
|
||||
46EB334629E1D1F0001D5EAF /* Shared in Frameworks */ = {isa = PBXBuildFile; productRef = 46EB334529E1D1F0001D5EAF /* Shared */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -113,18 +127,32 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
02031F0729E75EED003C108C /* SaveLocalLocationUseCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveLocalLocationUseCase.swift; sourceTree = "<group>"; };
|
||||
02031F0929E7645F003C108C /* Location+URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+URLs.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>"; };
|
||||
46C3B7D029E5D06D00F8F57C /* LocationsAddViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddViewController.swift; sourceTree = "<group>"; };
|
||||
46C3B7D529E5E50500F8F57C /* LocationsListViewModeling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListViewModeling.swift; sourceTree = "<group>"; };
|
||||
46C3B7D729E5E55000F8F57C /* LocationsListCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListCoordination.swift; sourceTree = "<group>"; };
|
||||
46C3B7DB29E5ED2300F8F57C /* LocationsAddCoordination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddCoordination.swift; sourceTree = "<group>"; };
|
||||
46C3B7DD29E5ED2E00F8F57C /* LocationsAddCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsAddCoordinator.swift; sourceTree = "<group>"; };
|
||||
46DF736C29E82A1500AA6D21 /* UIApplication+Conformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Conformances.swift"; sourceTree = "<group>"; };
|
||||
46EB325829E1BD5C001D5EAF /* Wikipedia.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Wikipedia.xcodeproj; path = Wikipedia/Wikipedia.xcodeproj; sourceTree = "<group>"; };
|
||||
46EB331829E1CE04001D5EAF /* Locations.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Locations.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
46EB331A29E1CE04001D5EAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
46EB331C29E1CE04001D5EAF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
46EB331E29E1CE04001D5EAF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
|
||||
46EB332129E1CE04001D5EAF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Locations.xcdatamodel; sourceTree = "<group>"; };
|
||||
46EB331E29E1CE04001D5EAF /* LocationsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListViewController.swift; sourceTree = "<group>"; };
|
||||
46EB332629E1CE05001D5EAF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
46EB332929E1CE05001D5EAF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
46EB332B29E1CE05001D5EAF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
46EB333229E1CFD9001D5EAF /* Libraries */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Libraries; sourceTree = "<group>"; };
|
||||
46EB333429E1D158001D5EAF /* Shared */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Shared; sourceTree = "<group>"; };
|
||||
46EB334929E1D34B001D5EAF /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
|
||||
46EB334A29E1D3C0001D5EAF /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@ -135,17 +163,115 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
46EB334429E1D1EC001D5EAF /* Libraries in Frameworks */,
|
||||
46EB334629E1D1F0001D5EAF /* Shared in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
02031EC429E5FEB1003C108C /* View Controllers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
02031EC529E5FEE4003C108C /* BaseViewController.swift */,
|
||||
);
|
||||
path = "View Controllers";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
02031EC729E60ADB003C108C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
02031EC829E60B29003C108C /* DependencyService+Keys.swift */,
|
||||
02031F0929E7645F003C108C /* Location+URLs.swift */,
|
||||
46DF736C29E82A1500AA6D21 /* UIApplication+Conformances.swift */,
|
||||
);
|
||||
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 = (
|
||||
0276C96229E5F5ED000B62AF /* Coordination */,
|
||||
0276C96129E5F5E5000B62AF /* ViewModeling */,
|
||||
);
|
||||
path = Protocols;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0276C96129E5F5E5000B62AF /* ViewModeling */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
02031EBE29E5F949003C108C /* LocationsAddViewModeling.swift */,
|
||||
46C3B7D529E5E50500F8F57C /* LocationsListViewModeling.swift */,
|
||||
);
|
||||
path = ViewModeling;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0276C96229E5F5ED000B62AF /* Coordination */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46C3B7DB29E5ED2300F8F57C /* LocationsAddCoordination.swift */,
|
||||
46C3B7D729E5E55000F8F57C /* LocationsListCoordination.swift */,
|
||||
);
|
||||
path = Coordination;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4656CBC029E6D31800600EE6 /* Use Cases */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4656CBC129E6D33C00600EE6 /* LoadRemoteLocationsUseCase.swift */,
|
||||
02031F0729E75EED003C108C /* SaveLocalLocationUseCase.swift */,
|
||||
);
|
||||
path = "Use Cases";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46C3B7C429E5BEE900F8F57C /* Coordinators */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46C3B7DD29E5ED2E00F8F57C /* LocationsAddCoordinator.swift */,
|
||||
46C3B7C529E5BF1500F8F57C /* LocationsListCoordinator.swift */,
|
||||
);
|
||||
path = Coordinators;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46C3B7C929E5CB8F00F8F57C /* Screens */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46C3B7CD29E5CFCD00F8F57C /* LocationsAdd */,
|
||||
46C3B7CC29E5CFBB00F8F57C /* LocationsList */,
|
||||
);
|
||||
path = Screens;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46C3B7CC29E5CFBB00F8F57C /* LocationsList */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46EB331E29E1CE04001D5EAF /* LocationsListViewController.swift */,
|
||||
46C3B7CA29E5CD3200F8F57C /* LocationsListViewModel.swift */,
|
||||
);
|
||||
path = LocationsList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46C3B7CD29E5CFCD00F8F57C /* LocationsAdd */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46C3B7D029E5D06D00F8F57C /* LocationsAddViewController.swift */,
|
||||
46C3B7CE29E5D00E00F8F57C /* LocationsAddViewModel.swift */,
|
||||
);
|
||||
path = LocationsAdd;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46EB325029E1BBD1001D5EAF = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46EB333429E1D158001D5EAF /* Shared */,
|
||||
46EB325729E1BCAB001D5EAF /* Apps */,
|
||||
46EB334B29E1D3D2001D5EAF /* Others */,
|
||||
46EB32EE29E1CD20001D5EAF /* Products */,
|
||||
@ -204,8 +330,13 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
46EB331A29E1CE04001D5EAF /* AppDelegate.swift */,
|
||||
46EB331C29E1CE04001D5EAF /* SceneDelegate.swift */,
|
||||
46EB331E29E1CE04001D5EAF /* ViewController.swift */,
|
||||
0276C96029E5F5DC000B62AF /* Protocols */,
|
||||
02031EC729E60ADB003C108C /* Extensions */,
|
||||
46C3B7C429E5BEE900F8F57C /* Coordinators */,
|
||||
46C3B7C929E5CB8F00F8F57C /* Screens */,
|
||||
02031EC429E5FEB1003C108C /* View Controllers */,
|
||||
02031EE629E68D7A003C108C /* View Components */,
|
||||
4656CBC029E6D31800600EE6 /* Use Cases */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
@ -215,9 +346,7 @@
|
||||
children = (
|
||||
46EB332629E1CE05001D5EAF /* Assets.xcassets */,
|
||||
46EB332B29E1CE05001D5EAF /* Info.plist */,
|
||||
46EB332029E1CE04001D5EAF /* Main.storyboard */,
|
||||
46EB332829E1CE05001D5EAF /* LaunchScreen.storyboard */,
|
||||
46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
@ -256,7 +385,6 @@
|
||||
name = Locations;
|
||||
packageProductDependencies = (
|
||||
46EB334329E1D1EC001D5EAF /* Libraries */,
|
||||
46EB334529E1D1F0001D5EAF /* Shared */,
|
||||
);
|
||||
productName = Locations;
|
||||
productReference = 46EB331829E1CE04001D5EAF /* Locations.app */;
|
||||
@ -403,7 +531,6 @@
|
||||
files = (
|
||||
46EB332A29E1CE05001D5EAF /* LaunchScreen.storyboard in Resources */,
|
||||
46EB332729E1CE05001D5EAF /* Assets.xcassets in Resources */,
|
||||
46EB332229E1CE04001D5EAF /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -414,24 +541,32 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
46EB331F29E1CE04001D5EAF /* ViewController.swift in Sources */,
|
||||
46C3B7C629E5BF1500F8F57C /* LocationsListCoordinator.swift in Sources */,
|
||||
46EB331F29E1CE04001D5EAF /* LocationsListViewController.swift in Sources */,
|
||||
02031EC629E5FEE4003C108C /* BaseViewController.swift in Sources */,
|
||||
46EB331B29E1CE04001D5EAF /* AppDelegate.swift in Sources */,
|
||||
46EB332529E1CE04001D5EAF /* Locations.xcdatamodeld in Sources */,
|
||||
46EB331D29E1CE04001D5EAF /* SceneDelegate.swift in Sources */,
|
||||
02031EBF29E5F949003C108C /* LocationsAddViewModeling.swift in Sources */,
|
||||
4656CBC829E6F2E400600EE6 /* LocationViewCell.swift in Sources */,
|
||||
46C3B7DE29E5ED2E00F8F57C /* LocationsAddCoordinator.swift in Sources */,
|
||||
02031F0A29E7645F003C108C /* Location+URLs.swift in Sources */,
|
||||
02031F0829E75EF0003C108C /* SaveLocalLocationUseCase.swift in Sources */,
|
||||
02031EEA29E6B495003C108C /* ErrorMessageView.swift in Sources */,
|
||||
46C3B7DC29E5ED2300F8F57C /* LocationsAddCoordination.swift in Sources */,
|
||||
46C3B7D829E5E55000F8F57C /* LocationsListCoordination.swift in Sources */,
|
||||
46DF736D29E82A1500AA6D21 /* UIApplication+Conformances.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;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
46EB332029E1CE04001D5EAF /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
46EB332129E1CE04001D5EAF /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46EB332829E1CE05001D5EAF /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@ -461,6 +596,7 @@
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@ -515,8 +651,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Locations;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
@ -528,7 +663,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.Locations";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.locations";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -548,6 +683,7 @@
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@ -596,8 +732,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Locations;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
@ -608,7 +743,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.Locations";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.locations";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -651,24 +786,7 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Libraries;
|
||||
};
|
||||
46EB334529E1D1F0001D5EAF /* Shared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Shared;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
children = (
|
||||
46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */,
|
||||
);
|
||||
currentVersion = 46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */;
|
||||
path = Locations.xcdatamodeld;
|
||||
sourceTree = "<group>";
|
||||
versionGroupType = wrapper.xcdatamodel;
|
||||
};
|
||||
/* End XCVersionGroup section */
|
||||
};
|
||||
rootObject = 46EB325129E1BBD1001D5EAF /* Project object */;
|
||||
}
|
||||
|
@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "46EB331729E1CE04001D5EAF"
|
||||
BuildableName = "Locations.app"
|
||||
BlueprintName = "Locations"
|
||||
ReferencedContainer = "container:DeepLinking.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "46EB331729E1CE04001D5EAF"
|
||||
BuildableName = "Locations.app"
|
||||
BlueprintName = "Locations"
|
||||
ReferencedContainer = "container:DeepLinking.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "46EB331729E1CE04001D5EAF"
|
||||
BuildableName = "Locations.app"
|
||||
BlueprintName = "Locations"
|
||||
ReferencedContainer = "container:DeepLinking.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
42
README.md
42
README.md
@ -1,2 +1,44 @@
|
||||
# Deep linking: Wikipedia
|
||||
|
||||
## App
|
||||
|
||||
The ultimate purpose of this application is to open the **Wikipedia** app in the right location when a user tap on one of the locations listed in the **Locations** app.
|
||||
|
||||
<center>
|
||||
<video controls>
|
||||
<source src="https://static.rock-n-code.com/mp4/deep-linking-app-demo.mp4" type="video/mp4">
|
||||
</video>
|
||||
</center>
|
||||
|
||||
Of course, to accomplish such goal the app therefore shows a list of locations (which are either fetched from a remote server or created by the user) and also, allows the user to add new location coordinates to this list by selecting them from a map.
|
||||
|
||||
## Features
|
||||
|
||||
In its current state, the **Locations** app does:
|
||||
|
||||
- [x] fetch locations from a remote server;
|
||||
- [x] handle `loading`, `loaded` and `error` states reactively when loading data;
|
||||
- [x] add locations manually to the list by obtaining locations from map;
|
||||
- [x] clean map of selected location if required;
|
||||
- [x] open the **Wikipedia** app when location is selected from a list;
|
||||
|
||||
While the **Wikipedia** app does:
|
||||
- [x] open a location in the right position on the map of *Places* screen from a deep link;
|
||||
|
||||
## Design
|
||||
|
||||
<object data="https://static.rock-n-code.com/pdf/deep-linking-app-design.pdf" type="application/pdf" width="100%" height="800px">
|
||||
<p>Unable to display a PDF file with some design considerations. Please <a href="https://static.rock-n-code.com/pdf/deep-linking-app-design.pdf">Download the file</a> instead.</p>
|
||||
</object>
|
||||
|
||||
## Implementation
|
||||
|
||||
This application was built as a `UIKit` application given that the [assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) explicitedly indicates the app should target the `iOS` platform (**iPhone** app only, in this particular case) and also, because it disallow the use of the `SwiftUI` framework. It is out of discussion that the `UIKit` framework has been battle-tested for over a decade now and [Apple](https://apple.com) keeps updating and adding features to it on a regular basis. The imperative nature of the framework, which is based in implementing how the `iOS` platform UI components should work, is perfectly suitable the developer who want absolute control over the UI, at a cost of maintaining platform-specific, more complex codebases.
|
||||
|
||||
With regards to the choice of framework to built this app, it also comes the question of the type of architecture pattern to use in it: for this particular case, and given the limitations of how the view controllers have been defined in the Apple platforms, [MVVM](https://en.wikipedia.org/wiki/Model–view–viewmodel)-C is the chosen architecrture as it facilitates the separation between logic and UI components while also, decoupling the navigation logic by using coordinates (and routers) from them.
|
||||
|
||||
Now that design patterns have been mentioned, in this exercise some well-known patterns are being used in some degree. For example, the *Singleton* pattern is used to initialise the `PersistenceService` service in the `Persistence` library. Both public and internal *Interfaces* that either describe an entity or how the entity should behave are used throughout this codebase, as this pattern is essential to create decoupled components that can be easily plugged as dependencies whenever needed as this forces the developer to think about (single) responsibilites and, as a consequence, these components are also easy to test in isolation as mocks, stubs and spies can be easily created out of them. Last, but definitely not least, the *Use cases* are a pattern from Android that basically execute a function based on some given input, and provides an output after that particular function is finished. This pattern is particularly useful to encapsulate in a simple way some certain logic from view models.
|
||||
|
||||
This application was built with scalability in terms of the codebase in mind, which tries to address how this codebase could grow in a controllable, organised manner. For this very reason, this application uses the [Swift Package Manager](https://www.swift.org/package-manager/) (or simply **SwiftPM**) to define the `Core`, `Dependency`, `Persistence` and `Remote` libraries of the `Library` package, that the **Locations** target should use extensively. These libraries focus on a specific purpose, and they can be self-contained, like in the case of the `Persistence` library that contains its own **CoreData** data model definitons and respective assets inside. Packages could use 3rd party dependencies if needed. This approach forces the developer to think about actual separation of concerns, as the different dependencies are grouped as independent, reusable building blocks, and to move the code into the SPM packages out of the main app target, reducing compiling time and overall weight of the application.
|
||||
|
||||
As a (indirect) consequence for the use of **SwiftPM** packages, the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern comes into question. I implemented my own simple **DI** mechanism that uses extensively the dynamic property wrapper functionality in the last versions of the [Swift](https://www.swift.org) language rather than using a 3rd party dependency for this case.
|
||||
|
@ -1,28 +0,0 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Shared",
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "Shared",
|
||||
targets: ["Shared"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||
.target(
|
||||
name: "Shared",
|
||||
dependencies: []),
|
||||
.testTarget(
|
||||
name: "SharedTests",
|
||||
dependencies: ["Shared"]),
|
||||
]
|
||||
)
|
@ -1,6 +0,0 @@
|
||||
public struct Shared {
|
||||
public private(set) var text = "Hello, World!"
|
||||
|
||||
public init() {
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import XCTest
|
||||
@testable import Shared
|
||||
|
||||
final class SharedTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(Shared().text, "Hello, World!")
|
||||
}
|
||||
}
|
BIN
deep-linking-app-demo.mp4
Normal file
BIN
deep-linking-app-demo.mp4
Normal file
Binary file not shown.
BIN
deep-linking-app-design.pdf
Normal file
BIN
deep-linking-app-design.pdf
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user