Compare commits

...

9 Commits

Author SHA1 Message Date
1de87263ba [Improvement] Small, tiny things (#14)
This PR contains the work on implementing some public interfaces that were forgotten during the development of this app and, of course, improves the text of the README file a bit more.

To give further details about the work done:
- [x] implemented the `Service` protocol in the `Persistence` library and conformed the `PersistenceService` service to it;
- [x] implemented the `Service` protocol in the `Remote` library and conformed the `RemoteService` service to it;
- [x] implemented the `Application` protocol in the `Core` library and conformed the `UIApplication` class to it;
- [x] improved the dependency keys used by the `DependencyService` service to use these protocols instead;
- [x] tweaked the text of the README file.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#14
2023-04-13 13:34:06 +00:00
842c3e1a6c [Setup] Wrapping up (#13)
This PR contains the work on wrapping the development of this app, at least for the time being.

To give further details on the work done:
- [x] removed the `Shared` package from the project as it was not used;
- [x] added some missing Xcode target schemes;
- [x] added a design document and a demo video;
- [x] written a README document;

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#13
2023-04-13 09:25:45 +00:00
57f4b3c237 [Feature] Open Wikipedia app (#12)
This PR contains the work done to open the *Places* view of the **Wikipedia** app with the screen centered on the coordinates from a selected location in the `LocationsListViewController` view controller.

To give further details about the work done:
- [x] implemented the `wikipediaPlacesURL` property in the `Location+URLs` extension;
- [x] improved the `LocationsListCoordination` protocol and the `LocationsListCoordinator` coordinator to support the opening of the Wikipedia app;
- [x] improved the `LocationsListViewModeling` protocol and the `LocationsListViewModel` view model  to support the opening of the Wikipedia app;
- [x] implemented the "tableView(_: didSelectAt: )" function in the `LocationsListViewController` view controller;
- [x] added the "wikipedia" to the Queried URL schemes in the Info.plist file to support querying to the Wikipedia app;
- [x] improved the naming of some properties and functions in the `LocationsAddCoordination`, `LocationsListCoordination`, and `LocationsListViewModeling` protocols.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#12
2023-04-12 23:07:42 +00:00
8ae955008e [Feature] Location add (#11)
This PR contains the work done to add a location at a time and updates the locations in the list of locations screen right after.

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

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#11
2023-04-12 21:25:08 +00:00
c8d2c288af [Feature] Locations list (#10)
This PR contains the work done to fetch a set of locations from a remote server to then persist them into the persistence stack and finally to display these data into the locations list screen.

To give further details about the work done:
- [x] implemented the `LoadingSpinnerView` and `ErrorMessageView` custom views;
- [x] implemented the outlets of the `LocationsListViewController` view controller;
- [x] add properties and functions to the `LocationsListViewModeling` protocol to support reactive updates, load data, and table data source conformances;
- [x] deactivated the Location entity code generation from the Core Data model in the `Persistence` library;
- [x] add fetch requests builder functions to the `NSFetchRequest+Location` extension in the `Persistence` library;
- [x] implemented the `LoadRemoteLocationUseCase` use case;
- [x] implemented the loading of locations in the LocationsListViewModel view model;
- [x] implemented properties and functions in the LocationsListViewModel view model to support the table data source conformance of the `LocationsListViewController` view controller;
- [x] implemented the `LocationViewCell` custom cell;
- [x] registered the `LocationViewCell` with the table of the `LocationsListViewController` view controller and implemented its update with real data from Location entities.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#10
2023-04-12 16:58:27 +00:00
43c156a2c3 [Setup] App architecture (#9)
This PR contains the work done to put in the app an MVVM-C architecture plus other small fixes in the `Library` libraries.

To give further details about the work done:
- [x] remove the `SceneDelegate` delegate;
- [x] implemented the `WindowRouter`, `PushNavigationRouter`, and `ModalNavigationRouter` routers in the `Core` library;
- [x] defined the `LocationsAddCoordination` and `LocationsListCoordination` protocols;
- [x] defined the `LocationsAddViewModeling` and `LocationsListViewModeling` protocols;
- [x] implemented the `LocationsListCoordinator` and `LocationsAddCoordinator` coordinators;
- [x] implemented the `LocationsAddViewController` view controller and `LocationsAddViewModel` view model;
- [x] implemented the `LocationsListViewController` view controller and `LocationsListViewModel` view model;
- [x] implemented the `BaseViewController` base view controller;
- [x] implemented the `persistence` and `remote` properties in the `DependencyService+Keys` extension.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#9
2023-04-11 22:14:40 +00:00
d51cc97aa4 [Bugfix] Location library naming (#8)
This PR contains a bugfix that appeared while I started working on the app itself. Basically, the app was not building as the compiler was complaining about duplicated files in the project, but given that I couldn't find any, then I found out that the compiler doesn't like that a library name and an app target share the same name.

So I renamed the `Locations` library in the **Libraries** package as `Remote` (for the lack of a better word...) to fix this issue that was stopping me from continue working on implementing the app.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#8
2023-04-11 15:57:38 +00:00
9172827b8b [Libraries] Core (#7)
This PR contains the work that implements the Core library, which is used to define the architecture of the app.

To give further details on what was done:
- [x] created the `Core` library into the **Libraries** package;
- [x] defined the `Router` protocol;
- [x] defined the `Coordinator` protocol;
- [x] defined the `View` protocol;
- [x] defined the `ViewModel` protocol;
- [x] implemented the `NavigationRouter` router.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#7
2023-04-11 13:59:08 +00:00
cb90b3730d [Libraries] Dependency (#6)
This PR contains the work that implements the Dependency service, which is used as a dependency injection mechanism in the application.

To give further details on what was done:
- [x] created the `Dependency` library into the **Libraries** package;
- [x] defined the `DependencyKey` protocol;
- [x] implemented the `DependencyService` service;
- [x] implemented the `Dependency` property wrapper.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#6
2023-04-11 00:41:07 +00:00
71 changed files with 3347 additions and 228 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,10 @@ let package = Package(
.library(
name: "Libraries",
targets: [
"Locations"
"Core",
"Dependency",
"Persistence",
"Remote"
]
),
],
@ -22,15 +25,23 @@ let package = Package(
dependencies: []
),
.target(
name: "Locations",
dependencies: [
"APICore"
]
name: "Core",
dependencies: []
),
.target(
name: "Dependency",
dependencies: []
),
.target(
name: "Persistence",
dependencies: []
),
.target(
name: "Remote",
dependencies: [
"APICore"
]
),
.testTarget(
name: "APICoreTests",
dependencies: [
@ -38,10 +49,15 @@ let package = Package(
]
),
.testTarget(
name: "LocationsTests",
name: "CoreTests",
dependencies: [
"APICore",
"Locations"
"Core"
]
),
.testTarget(
name: "DependencyTests",
dependencies: [
"Dependency"
]
),
.testTarget(
@ -50,5 +66,12 @@ let package = Package(
"Persistence"
]
),
.testTarget(
name: "RemoteTests",
dependencies: [
"APICore",
"Remote"
]
),
]
)

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
//
// Location+CoreDataClass.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
//
import Foundation
import CoreData
@objc(Location)
public class Location: NSManagedObject {
convenience init(context: NSManagedObjectContext) {
self.init(
entity: .entity(forEntityName: "Location", in: context)!,
insertInto: context
)
}
}

View File

@ -0,0 +1,36 @@
//
// Location+CoreDataProperties.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
//
import Foundation
import CoreData
extension Location {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Location> {
return NSFetchRequest<Location>(entityName: "Location")
}
@NSManaged public var createdAt: Date
@NSManaged public var latitude: Float
@NSManaged public var longitude: Float
@NSManaged public var name: String?
@NSManaged public var source: LocationSource
}
// MARK: - Identifiable
extension Location: Identifiable {}
// MARK: - Enumerations
@objc public enum LocationSource: Int16 {
case remote = 0
case local
}

View File

@ -0,0 +1,27 @@
//
// NSFetchRequest+Location.swift
// Persistence
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import CoreData
public extension NSFetchRequest where ResultType == Location {
// MARK: Functions
static func allLocations() -> NSFetchRequest<Location> {
let request = Location.fetchRequest()
request.sortDescriptors = [
.init(keyPath: \Location.source, ascending: true),
.init(keyPath: \Location.createdAt, ascending: true)
]
request.resultType = .managedObjectResultType
return request
}
}

View File

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

View File

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

View File

@ -0,0 +1,149 @@
//
// LocationProvider.swift
// Persistence
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import CoreData
public class LocationProvider: NSObject {
// MARK: Properties
private let fetchedResultsController: NSFetchedResultsController<Location>
/// The publisher that emits the changes detected to the Location entities in a given object context.
public let didChangePublisher = PassthroughSubject<[Change], Never>()
private var inProgressChanges: [Change] = []
/// The number of sections in the data.
public var numberOfSections: Int { fetchedResultsController.sections?.count ?? 0 }
// MARK: Initialisers
/// Initialise this provider with the managed object context that would be used.
/// - Parameter managedContext: A `NSManagedObjectContext` object context instance that will be used to provide entities.
public init(managedContext: NSManagedObjectContext) {
self.fetchedResultsController = .init(
fetchRequest: .allLocations(),
managedObjectContext: managedContext,
sectionNameKeyPath: nil,
cacheName: nil
)
super.init()
self.fetchedResultsController.delegate = self
}
// MARK: Functions
/// Perform the fetching.
public func fetch() throws {
try fetchedResultsController.performFetch()
}
/// Retrieve the number of locations inside a given section number.
/// - Parameter section: The section number to inquiry about.
/// - Returns: A number of locations inside the given section number.
public func numberOfLocationsInSection(_ section: Int) -> Int {
guard
let sections = fetchedResultsController.sections,
sections.endIndex > section
else {
return 0
}
return sections[section].numberOfObjects
}
/// Retrieve a location entity out of a given index path.
/// - Parameter indexPath: The index path to which retrieve a location entity.
/// - Returns: A `Location` entity positioned in the given index path.
public func location(at indexPath: IndexPath) -> Location {
return fetchedResultsController.object(at: indexPath)
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension LocationProvider: NSFetchedResultsControllerDelegate {
// MARK: Functions
public func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
inProgressChanges.removeAll()
}
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
didChangePublisher.send(inProgressChanges)
}
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange sectionInfo: NSFetchedResultsSectionInfo,
atSectionIndex sectionIndex: Int,
for type: NSFetchedResultsChangeType
) {
if type == .insert {
inProgressChanges.append(.section(.inserted(sectionIndex)))
} else if type == .delete {
inProgressChanges.append(.section(.deleted(sectionIndex)))
}
}
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?
) {
switch type {
case .insert:
guard let newIndexPath else { return }
inProgressChanges.append(.object(.inserted(at: newIndexPath)))
case .delete:
guard let indexPath else { return }
inProgressChanges.append(.object(.deleted(from: indexPath)))
case .move:
guard let indexPath, let newIndexPath else { return }
inProgressChanges.append(.object(.moved(from: indexPath, to: newIndexPath)))
case .update:
guard let indexPath else { return }
inProgressChanges.append(.object(.updated(at: indexPath)))
default:
break
}
}
}
// MARK: - Enumerations
public enum Change: Hashable {
public enum SectionUpdate: Hashable {
case inserted(Int)
case deleted(Int)
}
public enum ObjectUpdate: Hashable {
case inserted(at: IndexPath)
case deleted(from: IndexPath)
case updated(at: IndexPath)
case moved(from: IndexPath, to: IndexPath)
}
case section(SectionUpdate)
case object(ObjectUpdate)
}

View File

@ -26,7 +26,7 @@ public struct PersistenceService {
else {
fatalError("Could not load the model from the library.")
}
container = NSPersistentContainer(
name: .Model.name,
managedObjectModel: managedObjectModel
@ -35,10 +35,18 @@ public struct PersistenceService {
setContainer(inMemory)
}
// MARK: Functions
}
// MARK: - Service
extension PersistenceService: Service {
/// Create a private queue context.
/// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use.
// MARK: Properties
public var viewContext: NSManagedObjectContext { container.viewContext }
// MARK: Functions
public func makeTaskContext() -> NSManagedObjectContext {
let taskContext = container.newBackgroundContext()
@ -48,8 +56,6 @@ public struct PersistenceService {
return taskContext
}
/// Create a child context of the view context.
/// - Returns: A generated child `NSManagedObjectContext` context instance ready to use.
public func makeChildContext() -> NSManagedObjectContext {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
@ -60,8 +66,6 @@ public struct PersistenceService {
return context
}
/// Save a given context,
/// - Parameter context: A `NSManagedObjectContext` context instance to save.
public func save(context: NSManagedObjectContext) {
guard context.hasChanges else {
return
@ -75,8 +79,6 @@ public struct PersistenceService {
}
}
/// Save a given child context as well as its respective parent context.
/// - Parameter context: A child `NSManagedObjectContext` context instance to save.
public func save(childContext context: NSManagedObjectContext) {
guard context.hasChanges else {
return
@ -100,7 +102,7 @@ public struct PersistenceService {
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
// MARK: - Helpers

View File

@ -1,6 +1,6 @@
//
// LocationsClient.swift
// Locations
// RemoteClient.swift
// Remote
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
@ -9,7 +9,7 @@
import APICore
import Foundation
struct LocationsClient {
struct RemoteClient {
// MARK: Properties
@ -27,7 +27,7 @@ struct LocationsClient {
// MARK: - Client
extension LocationsClient: Client {
extension RemoteClient: Client {
// MARK: Functions

View File

@ -1,6 +1,6 @@
//
// GetLocationsEndpoint.swift
// Locations
// Remote
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.

View File

@ -1,6 +1,6 @@
//
// String+Constants.swift
// Locations
// Remote
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.

View File

@ -1,6 +1,6 @@
//
// Location.swift
// Locations (Library)
// Remote
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
@ -16,7 +16,7 @@ public struct Location: Equatable {
// MARK: Initialisers
public init(
init(
name: String? = nil,
latitude: Float,
longitude: Float

View File

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

View File

@ -1,6 +1,6 @@
//
// LocationsService.swift
// Locations
// RemoteService.swift
// Remote
//
// Created by Javier Cicchelli on 10/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
@ -9,7 +9,7 @@
import APICore
import Foundation
public struct LocationsService {
public struct RemoteService {
// MARK: Properties
@ -18,9 +18,15 @@ public struct LocationsService {
// MARK: Initialisers
public init(configuration: URLSessionConfiguration = .default) {
self.client = LocationsClient(configuration: configuration)
self.client = RemoteClient(configuration: configuration)
}
}
// MARK: - Service
extension RemoteService: Service {
// MARK: Functions
public func getLocations() async throws -> [Location] {
@ -29,7 +35,7 @@ public struct LocationsService {
for: Locations.self
).locations
}
}
// MARK: - Models

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +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>
</dict>
</array>
</dict>
</dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>wikipedia</string>
</array>
</dict>
</plist>

View File

@ -6,40 +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 {
// Override point for customization after application launch.
coordinator.present(animated: false, onDismiss: nil)
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.
func applicationDidEnterBackground(_ application: UIApplication) {
// Save changes in the application's managed object context when the application transitions to the background.
}
}

View File

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

View File

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

View File

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

View 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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,67 +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 windowScene = scene as? UIWindowScene else {
return
}
window = {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = ViewController()
window.makeKeyAndVisible()
return window
}()
}
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.
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
//
// SaveLocalLocationUseCase.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Dependency
import Persistence
struct SaveLocalLocationUseCase {
// MARK: Properties
private let persistence: 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)
}
}

View File

@ -0,0 +1,124 @@
//
// ErrorMessageView.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
class ErrorMessageView: UIView {
// MARK: Typealiases
typealias OnRetryClosure = () -> Void
// MARK: Properties
var onRetry: OnRetryClosure?
// MARK: Outlets
private lazy var stack: UIStackView = {
let stack = UIStackView()
stack.alignment = .center
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 32
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var title = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .largeTitle)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.text = "Some error title goes in here..."
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var message = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .body)
label.lineBreakMode = .byWordWrapping
label.numberOfLines = 0
label.text = "Some long, descriptive, explanatory error message goes in here..."
label.textAlignment = .center
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var retry = {
let button = UIButton()
button.backgroundColor = .red
button.translatesAutoresizingMaskIntoConstraints = false
button.layer.borderColor = UIColor.red.cgColor
button.layer.borderWidth = 1
button.layer.cornerRadius = 5
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.addTarget(self, action: #selector(retryPressed), for: .touchUpInside)
button.setTitle("Try again", for: .normal)
return button
}()
// MARK: Initialisers
init() {
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Helpers
private extension ErrorMessageView {
// MARK: Functions
func setupView() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
stack.addArrangedSubview(title)
stack.addArrangedSubview(message)
stack.addArrangedSubview(retry)
stack.setCustomSpacing(160, after: message)
NSLayoutConstraint.activate([
bottomAnchor.constraint(equalTo: stack.bottomAnchor),
leadingAnchor.constraint(equalTo: stack.leadingAnchor),
topAnchor.constraint(equalTo: stack.topAnchor),
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
retry.heightAnchor.constraint(equalToConstant: 44),
retry.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
retry.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
])
}
@objc func retryPressed() {
onRetry?()
}
}

View File

@ -0,0 +1,87 @@
//
// LoadingSpinnerView.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
class LoadingSpinnerView: UIView {
// MARK: Outlets
private lazy var stack = {
let stack = UIStackView()
stack.alignment = .center
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var spinner = {
let activity = UIActivityIndicatorView(style: .large)
activity.translatesAutoresizingMaskIntoConstraints = false
activity.startAnimating()
return activity
}()
private lazy var title = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.text = "Loading..."
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// MARK: Initialisers
init() {
super.init(frame: .zero)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Helpers
private extension LoadingSpinnerView {
// MARK: Functions
func setupView() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
stack.addArrangedSubview(spinner)
stack.addArrangedSubview(title)
NSLayoutConstraint.activate([
topAnchor.constraint(equalTo: stack.topAnchor),
leadingAnchor.constraint(equalTo: stack.leadingAnchor),
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
bottomAnchor.constraint(equalTo: stack.bottomAnchor),
])
}
}

View File

@ -0,0 +1,223 @@
//
// LocationViewCell.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
class LocationViewCell: UITableViewCell {
// MARK: Properties
static let identifier = "LocationViewCell"
// MARK: Outlets
private lazy var icon = {
let view = UIImageView()
view.contentMode = .top
view.tintColor = .red
return view
}()
private lazy var latitudeTitle = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 1
label.text = "• Latitude"
label.textAlignment = .natural
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var latitudeValue = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 1
label.textAlignment = .natural
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var longitudeTitle = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 1
label.text = "• Longitude"
label.textAlignment = .natural
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var longitudeValue = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .subheadline)
label.numberOfLines = 1
label.textAlignment = .natural
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var name = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.text = "Untitled"
label.textAlignment = .natural
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var stack = {
let stack = UIStackView()
stack.alignment = .center
stack.axis = .horizontal
stack.distribution = .fill
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var stackData = {
let stack = UIStackView()
stack.alignment = .leading
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var stackCoordinates = {
let stack = UIStackView()
stack.alignment = .leading
stack.axis = .vertical
stack.distribution = .fill
stack.spacing = 2
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var stackLatitude = {
let stack = UIStackView()
stack.alignment = .leading
stack.axis = .horizontal
stack.distribution = .fill
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
private lazy var stackLongitude = {
let stack = UIStackView()
stack.alignment = .leading
stack.axis = .horizontal
stack.distribution = .fill
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
// MARK: Initialisers
override init(
style: UITableViewCell.CellStyle,
reuseIdentifier: String?
) {
super.init(
style: style,
reuseIdentifier: reuseIdentifier
)
setupCell()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Functions
func update(
iconName: String,
name: String?,
latitude: Float,
longitude: Float
) {
self.icon.image = .init(systemName: iconName)
self.name.text = name ?? "Untitled"
self.latitudeValue.text = "\(latitude)"
self.longitudeValue.text = "\(longitude)"
}
}
// MARK: - Helpers
private extension LocationViewCell {
// MARK: Functions
func setupCell() {
accessoryType = .disclosureIndicator
backgroundColor = .clear
addSubview(stack)
stack.addArrangedSubview(icon)
stack.addArrangedSubview(stackData)
stackData.addArrangedSubview(name)
stackData.addArrangedSubview(stackCoordinates)
stackCoordinates.addArrangedSubview(stackLatitude)
stackCoordinates.addArrangedSubview(stackLongitude)
stackLatitude.addArrangedSubview(latitudeTitle)
stackLatitude.addArrangedSubview(latitudeValue)
stackLongitude.addArrangedSubview(longitudeTitle)
stackLongitude.addArrangedSubview(longitudeValue)
NSLayoutConstraint.activate([
bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 8),
leadingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -20),
topAnchor.constraint(equalTo: stack.topAnchor, constant: -8),
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
icon.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
icon.topAnchor.constraint(equalTo: stack.topAnchor),
icon.widthAnchor.constraint(equalToConstant: 24),
])
}
}

View File

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

View File

@ -1,19 +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()
view.backgroundColor = .red
}
}

View File

@ -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" */;

View File

@ -7,13 +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 */; };
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 */
@ -111,16 +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>"; };
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 */
@ -131,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 */,
@ -200,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>";
@ -250,7 +385,6 @@
name = Locations;
packageProductDependencies = (
46EB334329E1D1EC001D5EAF /* Libraries */,
46EB334529E1D1F0001D5EAF /* Shared */,
);
productName = Locations;
productReference = 46EB331829E1CE04001D5EAF /* Locations.app */;
@ -407,9 +541,26 @@
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 */,
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;
};
@ -635,10 +786,6 @@
isa = XCSwiftPackageProductDependency;
productName = Libraries;
};
46EB334529E1D1F0001D5EAF /* Shared */ = {
isa = XCSwiftPackageProductDependency;
productName = Shared;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 46EB325129E1BBD1001D5EAF /* Project object */;

View File

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

View File

@ -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/Modelviewviewmodel)-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.

View File

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

View File

@ -1,6 +0,0 @@
public struct Shared {
public private(set) var text = "Hello, World!"
public init() {
}
}

View File

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

Binary file not shown.

BIN
deep-linking-app-design.pdf Normal file

Binary file not shown.