Compare commits
14 Commits
setup/proj
...
main
Author | SHA1 | Date | |
---|---|---|---|
1de87263ba | |||
842c3e1a6c | |||
57f4b3c237 | |||
8ae955008e | |||
c8d2c288af | |||
43c156a2c3 | |||
d51cc97aa4 | |||
9172827b8b | |||
cb90b3730d | |||
4210df9eb6 | |||
6da2e946ce | |||
14e39a40ae | |||
6d76ba467c | |||
9bcdaa697b |
124
.gitignore
vendored
124
.gitignore
vendored
@ -1,28 +1,44 @@
|
||||
# ---> Swift
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
### OSX ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Xcode ###
|
||||
*.xcodeproj/*
|
||||
!*.xcodeproj/project.pbxproj
|
||||
!*.xcodeproj/xcshareddata/
|
||||
!*.xcodeproj/project.xcworkspace/
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
/*.gcno
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
@ -31,70 +47,18 @@ DerivedData/
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# Accio dependency management
|
||||
Dependencies/
|
||||
.accio/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Code Injection
|
||||
#
|
||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
### Swift ###
|
||||
|
||||
iOSInjectionProject/
|
||||
|
||||
# ---> Xcode
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Xcode 8 and earlier
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
### SwiftPM ###
|
||||
Packages
|
||||
.build/
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "APICoreTests"
|
||||
BuildableName = "APICoreTests"
|
||||
BlueprintName = "APICoreTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CoreTests"
|
||||
BuildableName = "CoreTests"
|
||||
BlueprintName = "CoreTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "DependencyTests"
|
||||
BuildableName = "DependencyTests"
|
||||
BlueprintName = "DependencyTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Libraries"
|
||||
BuildableName = "Libraries"
|
||||
BlueprintName = "Libraries"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PersistenceTests"
|
||||
BuildableName = "PersistenceTests"
|
||||
BlueprintName = "PersistenceTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "RemoteTests"
|
||||
BuildableName = "RemoteTests"
|
||||
BlueprintName = "RemoteTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
77
Apps/Locations/Libraries/Package.swift
Normal file
77
Apps/Locations/Libraries/Package.swift
Normal file
@ -0,0 +1,77 @@
|
||||
// swift-tools-version: 5.8
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Libraries",
|
||||
platforms: [
|
||||
.iOS(.v16)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Libraries",
|
||||
targets: [
|
||||
"Core",
|
||||
"Dependency",
|
||||
"Persistence",
|
||||
"Remote"
|
||||
]
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "APICore",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Core",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Dependency",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Persistence",
|
||||
dependencies: []
|
||||
),
|
||||
.target(
|
||||
name: "Remote",
|
||||
dependencies: [
|
||||
"APICore"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "APICoreTests",
|
||||
dependencies: [
|
||||
"APICore"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CoreTests",
|
||||
dependencies: [
|
||||
"Core"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "DependencyTests",
|
||||
dependencies: [
|
||||
"Dependency"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "PersistenceTests",
|
||||
dependencies: [
|
||||
"Persistence"
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "RemoteTests",
|
||||
dependencies: [
|
||||
"APICore",
|
||||
"Remote"
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
@ -0,0 +1,63 @@
|
||||
//
|
||||
// MockURLProtocol.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This class overrides the `URLProtocol` protocol used by the `URLSession` to handle the loading of protocol-specific URL data so it is possible to mock URL response for testing purposes.
|
||||
public class MockURLProtocol: URLProtocol {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public static var mockData: [URL: MockURLResponse] = [:]
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public override class func canInit(with task: URLSessionTask) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public override class func canInit(with request: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
public override func startLoading() {
|
||||
guard
|
||||
let url = request.url,
|
||||
let response = Self.mockData[url]
|
||||
else {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
return
|
||||
}
|
||||
|
||||
if let data = response.data {
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
}
|
||||
|
||||
if let httpResponse = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: response.status,
|
||||
httpVersion: nil,
|
||||
headerFields: response.headers
|
||||
) {
|
||||
client?.urlProtocol(
|
||||
self,
|
||||
didReceive: httpResponse,
|
||||
cacheStoragePolicy: .allowedInMemoryOnly
|
||||
)
|
||||
}
|
||||
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
public override func stopLoading() {}
|
||||
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
//
|
||||
// HTTPRequestMethod.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// Enumeration that represents the available HTTP request methods to use in this library.
|
||||
public enum HTTPRequestMethod: String {
|
||||
case get = "GET"
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
//
|
||||
// MockURLResponse.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This model includes the data to be injected into an specific URL at the time of mocking its response.
|
||||
public struct MockURLResponse {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let status: Int
|
||||
public let headers: [String: String]
|
||||
public let data: Data?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init(
|
||||
status: Int,
|
||||
headers: [String : String],
|
||||
data: Data? = nil
|
||||
) {
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.data = data
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Client.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol defines a client that will be making the API calls.
|
||||
public protocol Client {
|
||||
func request<Model: Decodable>(
|
||||
endpoint: some Endpoint,
|
||||
for model: Model.Type
|
||||
) async throws -> Model
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
//
|
||||
// Endpoint.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This protocol defines an endpoint to be used in an API call.
|
||||
public protocol Endpoint {
|
||||
var scheme: String { get }
|
||||
var host: String { get }
|
||||
var port: Int? { get }
|
||||
var path: String { get }
|
||||
var method: HTTPRequestMethod { get }
|
||||
var headers: [String: String] { get }
|
||||
var body: Data? { get }
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
//
|
||||
// MakeURLRequestUseCase.swift
|
||||
// APICore
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This use case generate a url request out of a given endpoint.
|
||||
public struct MakeURLRequestUseCase {
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Generate a `URLRequest` instance out of a given endpoint that conforms to the `Endpoint` protocol.
|
||||
/// - Parameter endpoint: An endpoint which is used to generate a `URLRequest` instance from.
|
||||
/// - Returns: A `URLRequest` instance filled with data provided by the given endpoint.
|
||||
public func callAsFunction(endpoint: some Endpoint) throws -> URLRequest {
|
||||
var urlComponents = URLComponents()
|
||||
|
||||
urlComponents.scheme = endpoint.scheme
|
||||
urlComponents.host = endpoint.host
|
||||
urlComponents.path = endpoint.path
|
||||
|
||||
if let port = endpoint.port {
|
||||
urlComponents.port = port
|
||||
}
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
throw MakeURLRequestError.urlNotCreated
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
|
||||
urlRequest.httpMethod = endpoint.method.rawValue
|
||||
urlRequest.httpBody = endpoint.body
|
||||
urlRequest.allHTTPHeaderFields = endpoint.headers
|
||||
|
||||
return urlRequest
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum MakeURLRequestError: Error {
|
||||
case urlNotCreated
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
//
|
||||
// Application.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public protocol Application {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func canOpenURL(_ url: URL) -> Bool
|
||||
func open(
|
||||
_ url: URL,
|
||||
options: [UIApplication.OpenExternalURLOptionsKey : Any],
|
||||
completionHandler completion: ((Bool) -> Void)?
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
//
|
||||
// Coordinator.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol organize the flow logic between view controllers in the app.
|
||||
public protocol Coordinator: AnyObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The child coordinators that are being currently presented.
|
||||
var children: [Coordinator] { get set }
|
||||
|
||||
/// The router that handles how the view controllers in the coordinators will be shown or dismissed.
|
||||
var router: Router { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present the coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
|
||||
func present(animated: Bool, onDismiss: Router.OnDismissedClosure?)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Coordinator+Implementations
|
||||
|
||||
public extension Coordinator {
|
||||
|
||||
/// Present a child coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - child: A child coordinator to be presented.
|
||||
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
|
||||
func present(
|
||||
child: Coordinator,
|
||||
animated: Bool,
|
||||
onDismiss: Router.OnDismissedClosure? = nil
|
||||
) {
|
||||
store(child)
|
||||
child.present(animated: animated) { [weak self, weak child] in
|
||||
guard let self, let child else {
|
||||
return
|
||||
}
|
||||
|
||||
self.free(child)
|
||||
onDismiss?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss the coordinator animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameter animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
|
||||
func dismiss(animated: Bool) {
|
||||
router.dismiss(animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Coordinator {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func store(_ coordinator: Coordinator) {
|
||||
children.append(coordinator)
|
||||
}
|
||||
|
||||
func free(_ coordinator: Coordinator) {
|
||||
children = children.filter { $0 !== coordinator }
|
||||
}
|
||||
}
|
55
Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift
Normal file
55
Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// Router.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This protocol defines how view controllers will be shown and dismissed.
|
||||
public protocol Router: AnyObject {
|
||||
|
||||
// MARK: Typealiases
|
||||
|
||||
typealias OnDismissedClosure = () -> Void
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
|
||||
/// - Parameters:
|
||||
/// - viewController: A `UIViewController` view controller instance to present.
|
||||
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
/// - onDismiss: A closure to be called or executed when the presented view controller is dismissed.
|
||||
func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
)
|
||||
|
||||
/// Dismiss a view controller animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameter animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
func dismiss(animated: Bool)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Router+Implementations
|
||||
|
||||
public extension Router {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter.
|
||||
/// - Parameters:
|
||||
/// - viewController: A `UIViewController` view controller instance to present.
|
||||
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
|
||||
func present(_ viewController: UIViewController, animated: Bool) {
|
||||
present(
|
||||
viewController,
|
||||
animated: animated,
|
||||
onDismiss: nil
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
//
|
||||
// BaseNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This is a base class for the `NavigationRouter` concrete router implementations.
|
||||
public class BaseNavigationRouter: NSObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// A navigation controller to use within this concrete router.
|
||||
var navigationController: UINavigationController
|
||||
|
||||
/// Dictionary that persist `onDismiss` closure for its respective view controllers until one of the later is dismissed.
|
||||
var onDismissForViewController: [UIViewController: Router.OnDismissedClosure] = [:]
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter navigationController: A `UINavigationController` navigation controller instance to use in this router.
|
||||
init(navigationController: UINavigationController) {
|
||||
self.navigationController = navigationController
|
||||
|
||||
super.init()
|
||||
|
||||
self.navigationController.delegate = self
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Executes the `onDismiss` closure for a given view controller.
|
||||
/// - Parameter viewController: A `UIViewController` view controller instance for which the on dismiss closure will be executed.
|
||||
func performOnDismissed(for viewController: UIViewController) {
|
||||
guard let onDismiss = onDismissForViewController[viewController] else {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
|
||||
onDismissForViewController[viewController] = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UINavigationControllerDelegate
|
||||
|
||||
extension BaseNavigationRouter: UINavigationControllerDelegate {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
guard let dismissedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: dismissedViewController)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
//
|
||||
// ModalNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class ModalNavigationRouter: BaseNavigationRouter {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The parent view controller from where this router is being called from.
|
||||
public unowned let parentViewController: UIViewController
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter parentViewController: A `UIViewController` view controller instance from where this router is originated.
|
||||
public init(parentViewController: UIViewController) {
|
||||
self.parentViewController = parentViewController
|
||||
|
||||
super.init(navigationController: .init())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ModalNavigationRouter: Router {
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
onDismissForViewController[viewController] = onDismiss
|
||||
|
||||
if navigationController.viewControllers.isEmpty {
|
||||
presentModally(viewController, animated: animated)
|
||||
} else {
|
||||
navigationController.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
guard let firstViewController = navigationController.viewControllers.first else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: firstViewController)
|
||||
|
||||
parentViewController.dismiss(animated: animated)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ModalNavigationRouter {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func presentModally(_ viewController: UIViewController, animated: Bool) {
|
||||
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
title: "Cancel",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(onCancelPressed)
|
||||
)
|
||||
|
||||
navigationController.setViewControllers([viewController], animated: false)
|
||||
|
||||
parentViewController.present(navigationController, animated: animated)
|
||||
}
|
||||
|
||||
@objc func onCancelPressed() {
|
||||
guard let firstViewController = navigationController.viewControllers.first else {
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: firstViewController)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
//
|
||||
// PushNavigationRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This class is responsible for presenting view controllers, as it is a concrete implementation of the `Router` protocol, but it won't know what view controller or which view controller is next.
|
||||
public class PushNavigationRouter: BaseNavigationRouter {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// A root view controller coming in from the navigation controller, if any.
|
||||
private let rootViewController: UIViewController?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameters:
|
||||
/// - navigationController: A `UINavigationController` navigation controller instance to use in this router.
|
||||
/// - rootViewController: A `UIViewController` view controller instance to define as a root view controller of the navigation controller.
|
||||
/// - Note This initialiser added the `rootViewController` parameter although it is not really needed to differentiate itself from the `.init(navigationController:)` implemented for the `BaseNavigationRouter` base class.
|
||||
public init(
|
||||
navigationController: UINavigationController,
|
||||
rootViewController: UIViewController? = nil
|
||||
) {
|
||||
self.rootViewController = navigationController.viewControllers.first ?? rootViewController
|
||||
|
||||
super.init(navigationController: navigationController)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Router
|
||||
|
||||
extension PushNavigationRouter: Router {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
onDismissForViewController[viewController] = onDismiss
|
||||
|
||||
navigationController.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
guard let rootViewController else {
|
||||
navigationController.popViewController(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
performOnDismissed(for: rootViewController)
|
||||
|
||||
navigationController.popToViewController(rootViewController, animated: animated)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
//
|
||||
// WindowRouter.swift
|
||||
// Core
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This class is responsible for populating the window of an application.
|
||||
public class WindowRouter: Router {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The window to set manually with a `UIViewController` view controller instance.
|
||||
private let window: UIWindow?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this router.
|
||||
/// - Parameter window: A `UIWindow` window instance to be set manually.
|
||||
public init(window: UIWindow?) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
window?.rootViewController = viewController
|
||||
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
public func dismiss(animated: Bool) {
|
||||
// Nothing to do here...
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// Dependency.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This property wrapper provides a direct connection to the `DependencyService` service.
|
||||
@propertyWrapper
|
||||
public struct Dependency<D> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let keyPath: WritableKeyPath<DependencyService, D>
|
||||
|
||||
/// This property allows direct read/write access to a defined dependency attached to a selected key path.
|
||||
public var wrappedValue: D {
|
||||
get { DependencyService[keyPath] }
|
||||
set { DependencyService[keyPath] = newValue }
|
||||
}
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise the property wrapper by setting a key path to a defined dependency.
|
||||
/// - Parameter keyPath: A key path to a defined dependency in the `DependencyService` service.
|
||||
public init(_ keyPath: WritableKeyPath<DependencyService, D>) {
|
||||
self.keyPath = keyPath
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
//
|
||||
// DependencyKey.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This protocol defines a key to use in the dependency service.
|
||||
public protocol DependencyKey {
|
||||
|
||||
// MARK: Associated types
|
||||
|
||||
/// The associated type representing the type of the dependency key's value.
|
||||
associatedtype Value
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The default value for the dependency key.
|
||||
static var currentValue: Value { get set }
|
||||
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
//
|
||||
// DependencyService.swift
|
||||
// Dependency
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
/// This service provide write/read access to the injected dependencies.
|
||||
public struct DependencyService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private static var current = DependencyService()
|
||||
|
||||
// MARK: Subscripts
|
||||
|
||||
public static subscript<DK: DependencyKey>(key: DK.Type) -> DK.Value {
|
||||
get { key.currentValue }
|
||||
set { key.currentValue = newValue }
|
||||
}
|
||||
|
||||
public static subscript<D>(_ keyPath: WritableKeyPath<DependencyService, D>) -> D {
|
||||
get { current[keyPath: keyPath] }
|
||||
set { current[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
//
|
||||
// Location+CoreDataClass.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Location)
|
||||
public class Location: NSManagedObject {
|
||||
convenience init(context: NSManagedObjectContext) {
|
||||
self.init(
|
||||
entity: .entity(forEntityName: "Location", in: context)!,
|
||||
insertInto: context
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
//
|
||||
// Location+CoreDataProperties.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
extension Location {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Location> {
|
||||
return NSFetchRequest<Location>(entityName: "Location")
|
||||
}
|
||||
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var latitude: Float
|
||||
@NSManaged public var longitude: Float
|
||||
@NSManaged public var name: String?
|
||||
@NSManaged public var source: LocationSource
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Identifiable
|
||||
|
||||
extension Location: Identifiable {}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
@objc public enum LocationSource: Int16 {
|
||||
case remote = 0
|
||||
case local
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
//
|
||||
// NSFetchRequest+Location.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public extension NSFetchRequest where ResultType == Location {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
static func allLocations() -> NSFetchRequest<Location> {
|
||||
let request = Location.fetchRequest()
|
||||
|
||||
request.sortDescriptors = [
|
||||
.init(keyPath: \Location.source, ascending: true),
|
||||
.init(keyPath: \Location.createdAt, ascending: true)
|
||||
]
|
||||
request.resultType = .managedObjectResultType
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Locations.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22E261" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Location" representedClassName="Location" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="latitude" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="longitude" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="source" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
</entity>
|
||||
</model>
|
@ -0,0 +1,36 @@
|
||||
//
|
||||
// Service.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public protocol Service {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The main managed object context.
|
||||
var viewContext: NSManagedObjectContext { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Create a private queue context.
|
||||
/// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use.
|
||||
func makeTaskContext() -> NSManagedObjectContext
|
||||
|
||||
/// Create a child context of the view context.
|
||||
/// - Returns: A generated child `NSManagedObjectContext` context instance ready to use.
|
||||
func makeChildContext() -> NSManagedObjectContext
|
||||
|
||||
/// Save a given context,
|
||||
/// - Parameter context: A `NSManagedObjectContext` context instance to save.
|
||||
func save(context: NSManagedObjectContext)
|
||||
|
||||
/// Save a given child context as well as its respective parent context.
|
||||
/// - Parameter context: A child `NSManagedObjectContext` context instance to save.
|
||||
func save(childContext context: NSManagedObjectContext)
|
||||
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
//
|
||||
// LocationProvider.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
public class LocationProvider: NSObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let fetchedResultsController: NSFetchedResultsController<Location>
|
||||
|
||||
/// The publisher that emits the changes detected to the Location entities in a given object context.
|
||||
public let didChangePublisher = PassthroughSubject<[Change], Never>()
|
||||
|
||||
private var inProgressChanges: [Change] = []
|
||||
|
||||
/// The number of sections in the data.
|
||||
public var numberOfSections: Int { fetchedResultsController.sections?.count ?? 0 }
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
/// Initialise this provider with the managed object context that would be used.
|
||||
/// - Parameter managedContext: A `NSManagedObjectContext` object context instance that will be used to provide entities.
|
||||
public init(managedContext: NSManagedObjectContext) {
|
||||
self.fetchedResultsController = .init(
|
||||
fetchRequest: .allLocations(),
|
||||
managedObjectContext: managedContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
super.init()
|
||||
|
||||
self.fetchedResultsController.delegate = self
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Perform the fetching.
|
||||
public func fetch() throws {
|
||||
try fetchedResultsController.performFetch()
|
||||
}
|
||||
|
||||
/// Retrieve the number of locations inside a given section number.
|
||||
/// - Parameter section: The section number to inquiry about.
|
||||
/// - Returns: A number of locations inside the given section number.
|
||||
public func numberOfLocationsInSection(_ section: Int) -> Int {
|
||||
guard
|
||||
let sections = fetchedResultsController.sections,
|
||||
sections.endIndex > section
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return sections[section].numberOfObjects
|
||||
}
|
||||
|
||||
/// Retrieve a location entity out of a given index path.
|
||||
/// - Parameter indexPath: The index path to which retrieve a location entity.
|
||||
/// - Returns: A `Location` entity positioned in the given index path.
|
||||
public func location(at indexPath: IndexPath) -> Location {
|
||||
return fetchedResultsController.object(at: indexPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
|
||||
extension LocationProvider: NSFetchedResultsControllerDelegate {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
inProgressChanges.removeAll()
|
||||
}
|
||||
|
||||
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
didChangePublisher.send(inProgressChanges)
|
||||
}
|
||||
|
||||
public func controller(
|
||||
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
|
||||
didChange sectionInfo: NSFetchedResultsSectionInfo,
|
||||
atSectionIndex sectionIndex: Int,
|
||||
for type: NSFetchedResultsChangeType
|
||||
) {
|
||||
if type == .insert {
|
||||
inProgressChanges.append(.section(.inserted(sectionIndex)))
|
||||
} else if type == .delete {
|
||||
inProgressChanges.append(.section(.deleted(sectionIndex)))
|
||||
}
|
||||
}
|
||||
|
||||
public func controller(
|
||||
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
|
||||
didChange anObject: Any,
|
||||
at indexPath: IndexPath?,
|
||||
for type: NSFetchedResultsChangeType,
|
||||
newIndexPath: IndexPath?
|
||||
) {
|
||||
switch type {
|
||||
case .insert:
|
||||
guard let newIndexPath else { return }
|
||||
|
||||
inProgressChanges.append(.object(.inserted(at: newIndexPath)))
|
||||
case .delete:
|
||||
guard let indexPath else { return }
|
||||
|
||||
inProgressChanges.append(.object(.deleted(from: indexPath)))
|
||||
case .move:
|
||||
guard let indexPath, let newIndexPath else { return }
|
||||
|
||||
inProgressChanges.append(.object(.moved(from: indexPath, to: newIndexPath)))
|
||||
case .update:
|
||||
guard let indexPath else { return }
|
||||
|
||||
inProgressChanges.append(.object(.updated(at: indexPath)))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
public enum Change: Hashable {
|
||||
public enum SectionUpdate: Hashable {
|
||||
case inserted(Int)
|
||||
case deleted(Int)
|
||||
}
|
||||
|
||||
public enum ObjectUpdate: Hashable {
|
||||
case inserted(at: IndexPath)
|
||||
case deleted(from: IndexPath)
|
||||
case updated(at: IndexPath)
|
||||
case moved(from: IndexPath, to: IndexPath)
|
||||
}
|
||||
|
||||
case section(SectionUpdate)
|
||||
case object(ObjectUpdate)
|
||||
}
|
||||
|
@ -0,0 +1,135 @@
|
||||
//
|
||||
// PersistenceService.swift
|
||||
// Persistence
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public struct PersistenceService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public static let shared = PersistenceService()
|
||||
public static let inMemory = PersistenceService(inMemory: true)
|
||||
|
||||
public let container: NSPersistentContainer
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
guard
|
||||
let modelURL = Bundle.module.url(forResource: .Model.name, withExtension: .Model.extension),
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)
|
||||
else {
|
||||
fatalError("Could not load the model from the library.")
|
||||
}
|
||||
|
||||
container = NSPersistentContainer(
|
||||
name: .Model.name,
|
||||
managedObjectModel: managedObjectModel
|
||||
)
|
||||
|
||||
setContainer(inMemory)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
extension PersistenceService: Service {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var viewContext: NSManagedObjectContext { container.viewContext }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func makeTaskContext() -> NSManagedObjectContext {
|
||||
let taskContext = container.newBackgroundContext()
|
||||
|
||||
taskContext.automaticallyMergesChangesFromParent = true
|
||||
taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
return taskContext
|
||||
}
|
||||
|
||||
public func makeChildContext() -> NSManagedObjectContext {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
context.parent = container.viewContext
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
public func save(context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
public func save(childContext context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
||||
guard
|
||||
let parent = context.parent,
|
||||
parent == container.viewContext
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
try parent.performAndWait {
|
||||
try parent.save()
|
||||
}
|
||||
} catch {
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PersistenceService {
|
||||
func setContainer(_ inMemory: Bool) {
|
||||
container.persistentStoreDescriptions = [
|
||||
NSPersistentStoreDescription(url:
|
||||
inMemory
|
||||
? URL(fileURLWithPath: "/dev/null")
|
||||
: NSPersistentContainer.defaultDirectoryURL().appending(path: "\(String.Model.name).sqlite")
|
||||
)
|
||||
]
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
}
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Model {
|
||||
static let name = "Model"
|
||||
static let `extension` = "momd"
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
//
|
||||
// RemoteClient.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
struct RemoteClient {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder = .init()
|
||||
private let makeURLRequest: MakeURLRequestUseCase = .init()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(configuration: URLSessionConfiguration = .default) {
|
||||
self.session = .init(configuration: configuration)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
extension RemoteClient: Client {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func request<Model: Decodable>(
|
||||
endpoint: some Endpoint,
|
||||
for model: Model.Type
|
||||
) async throws -> Model {
|
||||
let urlRequest = try makeURLRequest(endpoint: endpoint)
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw LocationsClientError.responseNotReturned
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
return try decoder.decode(model, from: data)
|
||||
case 400...499:
|
||||
throw LocationsClientError.statusErrorClient
|
||||
case 500...599:
|
||||
throw LocationsClientError.statusErrorServer
|
||||
default:
|
||||
throw LocationsClientError.statusErrorUnexpected
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
public enum LocationsClientError: Error {
|
||||
case responseNotReturned
|
||||
case statusErrorClient
|
||||
case statusErrorServer
|
||||
case statusErrorUnexpected
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
//
|
||||
// GetLocationsEndpoint.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
struct GetLocationsEndpoint: Endpoint {
|
||||
let scheme: String = .Scheme.https
|
||||
let host: String = .Hosts.default
|
||||
let port: Int? = nil
|
||||
let path: String = .Paths.getLocations
|
||||
let method: HTTPRequestMethod = .get
|
||||
let headers: [String: String] = [:]
|
||||
let body: Data? = nil
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
//
|
||||
// String+Constants.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
extension String {
|
||||
enum Scheme {
|
||||
static let https = "https"
|
||||
}
|
||||
|
||||
enum Hosts {
|
||||
static let `default` = "raw.githubusercontent.com"
|
||||
}
|
||||
|
||||
enum Paths {
|
||||
static let getLocations = "/abnamrocoesd/assignment-ios/main/locations.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
//
|
||||
// Location.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
public struct Location: Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public let name: String?
|
||||
public let latitude: Float
|
||||
public let longitude: Float
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
name: String? = nil,
|
||||
latitude: Float,
|
||||
longitude: Float
|
||||
) {
|
||||
self.name = name
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Decodable
|
||||
|
||||
extension Location: Decodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case latitude = "lat"
|
||||
case longitude = "long"
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Service.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol Service {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Retrieve a set of locations.
|
||||
/// - Returns: The set of locations represented as a `Location` instances.
|
||||
func getLocations() async throws -> [Location]
|
||||
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
//
|
||||
// RemoteService.swift
|
||||
// Remote
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import Foundation
|
||||
|
||||
public struct RemoteService {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let client: Client
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
public init(configuration: URLSessionConfiguration = .default) {
|
||||
self.client = RemoteClient(configuration: configuration)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
extension RemoteService: Service {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
public func getLocations() async throws -> [Location] {
|
||||
try await client.request(
|
||||
endpoint: GetLocationsEndpoint(),
|
||||
for: Locations.self
|
||||
).locations
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct Locations: Decodable, Equatable {
|
||||
public let locations: [Location]
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
//
|
||||
// MakeURLRequestUseCaseTests.swift
|
||||
// APICoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import APICore
|
||||
|
||||
final class MakeURLRequestUseCaseTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
|
||||
// MARK: Test cases
|
||||
|
||||
func test_withEndpoint_initialisedByDefault() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint()
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithPort() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint(port: 8080)
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com:8080/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithHeaders() throws {
|
||||
// GIVEN
|
||||
let endpoint = TestEndpoint(headers: [
|
||||
"aHeader": "aValueForHead",
|
||||
"someOtherHeader": "someValueForOtherHeader"
|
||||
])
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [
|
||||
"aHeader": "aValueForHead",
|
||||
"someOtherHeader": "someValueForOtherHeader"
|
||||
])
|
||||
XCTAssertNil(result.httpBody)
|
||||
}
|
||||
|
||||
func test_withEndpoint_initialisedWithBody() throws {
|
||||
// GIVEN
|
||||
let data = "This is some data for a body of a request".data(using: .utf8)
|
||||
let endpoint = TestEndpoint(body: data)
|
||||
|
||||
// WHEN
|
||||
let result = try makeURLRequest(endpoint: endpoint)
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(result)
|
||||
XCTAssertEqual(result.url?.absoluteString, "http://www.something.com/path/to/endpoint")
|
||||
XCTAssertEqual(result.httpMethod, HTTPRequestMethod.get.rawValue)
|
||||
XCTAssertEqual(result.allHTTPHeaderFields, [:])
|
||||
XCTAssertEqual(result.httpBody, data)
|
||||
XCTAssertNotNil(data)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TestEndpoint
|
||||
|
||||
private struct TestEndpoint: Endpoint {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let scheme: String = "http"
|
||||
let host: String = "www.something.com"
|
||||
let path: String = "/path/to/endpoint"
|
||||
let method: HTTPRequestMethod = .get
|
||||
|
||||
var port: Int?
|
||||
var headers: [String : String]
|
||||
var body: Data?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
port: Int? = nil,
|
||||
headers: [String : String] = [:],
|
||||
body: Data? = nil
|
||||
) {
|
||||
self.port = port
|
||||
self.body = body
|
||||
self.headers = headers
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
//
|
||||
// TestCoordinators.swift
|
||||
// CoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
// MARK: - Test coordinators
|
||||
|
||||
class SomeCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
SomeViewController(),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SomeOtherCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
SomeOtherViewController(),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - SpyRouter
|
||||
|
||||
class SpyRouter: Router {
|
||||
|
||||
// MARK: Enumerations
|
||||
|
||||
enum State {
|
||||
case initialised
|
||||
case presented
|
||||
case dismissed
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var state: State = .initialised
|
||||
var viewController: UIViewController?
|
||||
var animated: Bool?
|
||||
var onDismiss: OnDismissedClosure?
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func present(
|
||||
_ viewController: UIViewController,
|
||||
animated: Bool,
|
||||
onDismiss: OnDismissedClosure?
|
||||
) {
|
||||
self.viewController = viewController
|
||||
self.animated = animated
|
||||
self.onDismiss = onDismiss
|
||||
self.state = .presented
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool) {
|
||||
self.animated = animated
|
||||
self.state = .dismissed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Test view controllers
|
||||
|
||||
class SomeViewController: UIViewController {}
|
||||
class SomeOtherViewController: UIViewController {}
|
@ -0,0 +1,138 @@
|
||||
//
|
||||
// CoordinatorTests.swift
|
||||
// CoreTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Core
|
||||
|
||||
final class CoordinatorTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var router: SpyRouter!
|
||||
private var coordinator: Coordinator!
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_present_withoutOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeCoordinator(router: self.router)
|
||||
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(animated: false, onDismiss: nil)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeViewController)
|
||||
XCTAssertFalse(self.router.animated ?? true)
|
||||
XCTAssertNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_present_withOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(animated: true) {}
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
|
||||
XCTAssertTrue(self.router.animated ?? false)
|
||||
XCTAssertNotNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_presentChild_withoutOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeCoordinator(router: self.router)
|
||||
|
||||
let child = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(child: child, animated: false)
|
||||
|
||||
// THEN
|
||||
XCTAssertFalse(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.coordinator.children.count, 1)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
|
||||
XCTAssertFalse(self.router.animated ?? true)
|
||||
XCTAssertNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_presentChild_withOnDismissClosure() {
|
||||
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
|
||||
DispatchQueue.main.async {
|
||||
// GIVEN
|
||||
self.router = SpyRouter()
|
||||
self.coordinator = SomeOtherCoordinator(router: self.router)
|
||||
|
||||
let child = SomeCoordinator(router: self.router)
|
||||
|
||||
// WHEN
|
||||
self.coordinator.present(child: child, animated: true) {}
|
||||
|
||||
// THEN
|
||||
XCTAssertFalse(self.coordinator.children.isEmpty)
|
||||
XCTAssertEqual(self.coordinator.children.count, 1)
|
||||
XCTAssertEqual(self.router.state, .presented)
|
||||
XCTAssertTrue(self.router.viewController is SomeViewController)
|
||||
XCTAssertTrue(self.router.animated ?? false)
|
||||
XCTAssertNotNil(self.router.onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
func test_dismiss_notAnimated() {
|
||||
// GIVEN
|
||||
router = SpyRouter()
|
||||
coordinator = SomeOtherCoordinator(router: router)
|
||||
|
||||
// WHEN
|
||||
coordinator.dismiss(animated: false)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(coordinator.children.isEmpty)
|
||||
XCTAssertEqual(router.state, .dismissed)
|
||||
XCTAssertNil(router.viewController)
|
||||
XCTAssertFalse(router.animated ?? true)
|
||||
XCTAssertNil(router.onDismiss)
|
||||
}
|
||||
|
||||
func test_dismiss_animated() {
|
||||
// GIVEN
|
||||
router = SpyRouter()
|
||||
coordinator = SomeOtherCoordinator(router: router)
|
||||
|
||||
// WHEN
|
||||
coordinator.dismiss(animated: true)
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(coordinator.children.isEmpty)
|
||||
XCTAssertEqual(router.state, .dismissed)
|
||||
XCTAssertNil(router.viewController)
|
||||
XCTAssertTrue(router.animated ?? false)
|
||||
XCTAssertNil(router.onDismiss)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
//
|
||||
// TestServices.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Dependency
|
||||
|
||||
// MARK: - Protocols
|
||||
|
||||
protocol TestService {}
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
struct SomeService: TestService, Equatable {}
|
||||
struct SomeOtherService: TestService, Equatable {}
|
||||
|
||||
// MARK: - DependencyKey
|
||||
|
||||
struct TestServiceKey: DependencyKey {
|
||||
static var currentValue: TestService = SomeService()
|
||||
}
|
||||
|
||||
// MARK: - DependencyService+Keys
|
||||
|
||||
extension DependencyService {
|
||||
var testService: TestService {
|
||||
get { Self[TestServiceKey.self] }
|
||||
set { Self[TestServiceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,74 @@
|
||||
//
|
||||
// DependencyTests.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Dependency
|
||||
|
||||
final class DependencyTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var subject: TestSubject!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() {
|
||||
DependencyService[\.testService] = SomeService()
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_readTestService() {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
// WHEN
|
||||
let service = subject.testService
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKey() async throws {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
subject.testService = SomeOtherService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeOtherService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKeyTwice() async throws {
|
||||
// GIVEN
|
||||
subject = .init()
|
||||
|
||||
subject.testService = SomeOtherService()
|
||||
subject.testService = SomeService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TestSubject
|
||||
|
||||
private struct TestSubject {
|
||||
@Dependency(\.testService) var testService
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
//
|
||||
// DependencyServiceTests.swift
|
||||
// DependencyTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Dependency
|
||||
|
||||
final class DependencyServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() {
|
||||
DependencyService[\.testService] = SomeService()
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_readDependencyKey() async throws {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKey() async throws {
|
||||
// GIVEN
|
||||
DependencyService[\.testService] = SomeOtherService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeOtherService)
|
||||
}
|
||||
|
||||
func test_writeDependencyKeyTwice() async throws {
|
||||
// GIVEN
|
||||
DependencyService[\.testService] = SomeOtherService()
|
||||
DependencyService[\.testService] = SomeService()
|
||||
|
||||
// WHEN
|
||||
let service = DependencyService[\.testService]
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(service)
|
||||
XCTAssert(service is SomeService)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
//
|
||||
// PersistenceServiceTests.swift
|
||||
// PersistenceTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import XCTest
|
||||
|
||||
@testable import Persistence
|
||||
|
||||
final class PersistenceServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private var persistence: PersistenceService!
|
||||
|
||||
// MARK: Initialiser tests
|
||||
|
||||
func test_initByDefault() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
persistence = .init()
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
func test_initWithInMemory() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
persistence = .init(inMemory: true)
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
// MARK: Static properties tests
|
||||
|
||||
func test_shared() {
|
||||
// GIVEN
|
||||
persistence = .shared
|
||||
|
||||
// WHEN
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
func test_inMemory() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
// THEN
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType)
|
||||
XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null")
|
||||
XCTAssertNotNil(persistence.container.viewContext)
|
||||
XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent)
|
||||
}
|
||||
|
||||
// MARK: Functions tests
|
||||
|
||||
func test_makeTaskContext() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
let context = persistence.makeTaskContext()
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(context.automaticallyMergesChangesFromParent)
|
||||
XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy)
|
||||
XCTAssertNil(context.parent)
|
||||
}
|
||||
|
||||
func test_makeChildContext() {
|
||||
// GIVEN
|
||||
persistence = .inMemory
|
||||
|
||||
// WHEN
|
||||
let context = persistence.makeChildContext()
|
||||
|
||||
// THEN
|
||||
XCTAssertTrue(context.automaticallyMergesChangesFromParent)
|
||||
XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy)
|
||||
XCTAssertEqual(context.parent, persistence.container.viewContext)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// LocationsClientTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class LocationsClientTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
private let sessionConfiguration = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
return configuration
|
||||
}()
|
||||
|
||||
private var client: LocationsClient!
|
||||
private var url: URL!
|
||||
private var data: Data!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() async throws {
|
||||
client = .init(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
client = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseOK() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 200,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN
|
||||
let result = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(result, Locations(locations: [
|
||||
.init(
|
||||
name: "Amsterdam",
|
||||
latitude: 52.3547498,
|
||||
longitude: 4.8339215
|
||||
),
|
||||
.init(
|
||||
name: "Mumbai",
|
||||
latitude: 19.0823998,
|
||||
longitude: 72.8111468
|
||||
),
|
||||
.init(
|
||||
name: "Copenhagen",
|
||||
latitude: 55.6713442,
|
||||
longitude: 12.523785
|
||||
),
|
||||
.init(
|
||||
latitude: 40.4380638,
|
||||
longitude: -3.7495758
|
||||
)
|
||||
]))
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseClientError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 404,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorClient {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseServerError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 500,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorServer {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_request_withGetLocationsEndpoint_forLocations_whenResponseUnexpectedError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 302,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await client.request(endpoint: endpoint, for: Locations.self)
|
||||
} catch LocationsClientError.statusErrorUnexpected {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
//
|
||||
// GetLocationsEndpointTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class GetLocationsEndpointTests: XCTestCase {
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_init() {
|
||||
// GIVEN
|
||||
// WHEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
// THEN
|
||||
XCTAssertNotNil(endpoint)
|
||||
XCTAssertEqual(endpoint.scheme, .Scheme.https)
|
||||
XCTAssertEqual(endpoint.host, .Hosts.default)
|
||||
XCTAssertNil(endpoint.port)
|
||||
XCTAssertEqual(endpoint.path, .Paths.getLocations)
|
||||
XCTAssertTrue(endpoint.headers.isEmpty)
|
||||
XCTAssertNil(endpoint.body)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// Data+Constants.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
enum Responses {
|
||||
static let locations = "{\"locations\":[{\"name\":\"Amsterdam\",\"lat\":52.3547498,\"long\":4.8339215},{\"name\":\"Mumbai\",\"lat\":19.0823998,\"long\":72.8111468},{\"name\":\"Copenhagen\",\"lat\":55.6713442,\"long\":12.523785},{\"lat\":40.4380638,\"long\":-3.7495758}]}".data(using: .utf8)
|
||||
}
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// LocationsServiceTests.swift
|
||||
// LocationsTests
|
||||
//
|
||||
// Created by Javier Cicchelli on 10/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import APICore
|
||||
import XCTest
|
||||
|
||||
@testable import Locations
|
||||
|
||||
final class LocationsServiceTests: XCTestCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let makeURLRequest = MakeURLRequestUseCase()
|
||||
private let sessionConfiguration = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
return configuration
|
||||
}()
|
||||
|
||||
private var service: LocationsService!
|
||||
private var url: URL!
|
||||
private var data: Data!
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
override func setUp() async throws {
|
||||
service = .init(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
service = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
func test_getLocations_whenResponseOK() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 200,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN
|
||||
let result = try await service.getLocations()
|
||||
|
||||
// THEN
|
||||
XCTAssertEqual(result, [
|
||||
.init(
|
||||
name: "Amsterdam",
|
||||
latitude: 52.3547498,
|
||||
longitude: 4.8339215
|
||||
),
|
||||
.init(
|
||||
name: "Mumbai",
|
||||
latitude: 19.0823998,
|
||||
longitude: 72.8111468
|
||||
),
|
||||
.init(
|
||||
name: "Copenhagen",
|
||||
latitude: 55.6713442,
|
||||
longitude: 12.523785
|
||||
),
|
||||
.init(
|
||||
latitude: 40.4380638,
|
||||
longitude: -3.7495758
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseClientError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 404,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorClient {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseServerError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 500,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorServer {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
func test_getLocations_whenResponseUnexpectedError() async throws {
|
||||
// GIVEN
|
||||
let endpoint = GetLocationsEndpoint()
|
||||
|
||||
url = try makeURLRequest(endpoint: endpoint).url
|
||||
data = .Responses.locations
|
||||
|
||||
MockURLProtocol.mockData[url] = MockURLResponse(
|
||||
status: 302,
|
||||
headers: [:],
|
||||
data: data
|
||||
)
|
||||
|
||||
// WHEN & THEN
|
||||
do {
|
||||
_ = try await service.getLocations()
|
||||
} catch LocationsClientError.statusErrorUnexpected {
|
||||
XCTAssertTrue(true)
|
||||
} catch {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
6
Apps/Locations/Resources/Assets.xcassets/Contents.json
Normal file
6
Apps/Locations/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "255",
|
||||
"green" : "255",
|
||||
"red" : "255"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
16
Apps/Locations/Resources/Assets.xcassets/Launch/LaunchImage.imageset/Contents.json
vendored
Normal file
16
Apps/Locations/Resources/Assets.xcassets/Launch/LaunchImage.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "location.fill.viewfinder.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "original"
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="260px" height="261px" viewBox="0 0 260 261" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>location.fill.viewfinder</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Large" transform="translate(-80.000000, -190.000000)" fill-rule="nonzero">
|
||||
<g id="location.fill.viewfinder" transform="translate(80.000000, 190.000000)">
|
||||
<rect id="Rectangle" fill="#000000" opacity="0" x="0" y="0" width="260" height="260.132576"></rect>
|
||||
<path d="M10.6230813,84.7191584 C17.6608642,84.7191584 21.3789426,80.7354179 21.3789426,73.8305851 L21.3789426,42.4923252 C21.3789426,28.6823195 28.6823195,21.6445367 41.9613411,21.6445367 L74.0960771,21.6445367 C81.133826,21.6445367 84.9846505,17.7936782 84.9846505,10.8886413 C84.9846505,3.98367249 81.133826,0.265577033 74.0960771,0.265577033 L41.6955091,0.265577033 C13.9427857,0.265577033 0,13.9427857 0,41.2971011 L0,73.8305851 C0,80.7354179 3.85085848,84.7191584 10.6230813,84.7191584 Z M249.244003,84.7191584 C256.281752,84.7191584 260,80.7354179 260,73.8305851 L260,41.2971011 C260,13.9427857 246.057078,0.265577033 218.304491,0.265577033 L185.771007,0.265577033 C178.866174,0.265577033 175.01535,3.98367249 175.01535,10.8886413 C175.01535,17.7936782 178.866174,21.6445367 185.771007,21.6445367 L217.906083,21.6445367 C231.052188,21.6445367 238.620921,28.6823195 238.620921,42.4923252 L238.620921,73.8305851 C238.620921,80.7354179 242.471746,84.7191584 249.244003,84.7191584 Z M41.6955091,260.132576 L74.0960771,260.132576 C81.133826,260.132576 84.9846505,256.281752 84.9846505,249.509495 C84.9846505,242.604662 81.133826,238.753837 74.0960771,238.753837 L41.9613411,238.753837 C28.6823195,238.753837 21.3789426,231.716089 21.3789426,217.906083 L21.3789426,186.567823 C21.3789426,179.530074 17.5280842,175.67925 10.6230813,175.67925 C3.71807846,175.67925 0,179.530074 0,186.567823 L0,218.968391 C0,246.455486 13.9427857,260.132576 41.6955091,260.132576 Z M185.771007,260.132576 L218.304491,260.132576 C246.057078,260.132576 260,246.32257 260,218.968391 L260,186.567823 C260,179.530074 256.149176,175.67925 249.244003,175.67925 C242.33917,175.67925 238.620921,179.530074 238.620921,186.567823 L238.620921,217.906083 C238.620921,231.716089 231.052188,238.753837 217.906083,238.753837 L185.771007,238.753837 C178.866174,238.753837 175.01535,242.604662 175.01535,249.509495 C175.01535,256.281752 178.866174,260.132576 185.771007,260.132576 Z" id="Shape" fill-opacity="0.5" fill="#FF3B30"></path>
|
||||
<path d="M59.6221713,141.419897 L115.260262,141.419897 C117.916203,141.419897 119.509495,143.146106 119.509495,145.801706 L119.509495,200.643321 C119.509495,212.594542 132.124616,213.789426 136.108357,205.158385 L195.066288,79.5401938 C199.846845,69.3155206 191.082548,60.4186475 180.725298,65.33212 L55.2400228,124.953951 C46.6086418,128.937692 48.0693579,141.419897 59.6221713,141.419897 Z" id="Path" fill="#FF3B30"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.0 KiB |
47
Apps/Locations/Resources/Base.lproj/LaunchScreen.storyboard
Normal file
47
Apps/Locations/Resources/Base.lproj/LaunchScreen.storyboard
Normal file
@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="tiM-Rq-Okn">
|
||||
<rect key="frame" x="66.666666666666686" y="296" width="260" height="260"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="260" id="gc5-0A-vWD"/>
|
||||
<constraint firstAttribute="width" constant="260" id="oJA-1G-TR6"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" name="LaunchBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="tiM-Rq-Okn" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="U36-s9-WSk"/>
|
||||
<constraint firstItem="tiM-Rq-Okn" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="sWf-oH-DcQ"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="260" height="261"/>
|
||||
<namedColor name="LaunchBackgroundColor">
|
||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
10
Apps/Locations/Resources/Info.plist
Normal file
10
Apps/Locations/Resources/Info.plist
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>wikipedia</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
36
Apps/Locations/Sources/AppDelegate.swift
Normal file
36
Apps/Locations/Sources/AppDelegate.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
lazy var coordinator: LocationsListCoordinator = .init(router: router)
|
||||
lazy var router: WindowRouter = .init(window: window)
|
||||
lazy var window: UIWindow? = .init(frame: UIScreen.main.bounds)
|
||||
|
||||
// MARK: UIApplicationDelegate
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
coordinator.present(animated: false, onDismiss: nil)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Save changes in the application's managed object context when the application transitions to the background.
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
//
|
||||
// LocationsAddCoordinator.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
class LocationsAddCoordinator: Coordinator {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
router.present(
|
||||
LocationsAddViewController(
|
||||
viewModel: LocationsAddViewModel(coordinator: self)
|
||||
),
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsAddCoordination
|
||||
|
||||
extension LocationsAddCoordinator: LocationsAddCoordination {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func closeLocationsAddScreen() {
|
||||
router.dismiss(animated: true)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
//
|
||||
// LocationsListCoordinator.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import Dependency
|
||||
import UIKit
|
||||
|
||||
class LocationsListCoordinator: Coordinator {
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.app) private var app
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var children: [Coordinator] = []
|
||||
var router: Router
|
||||
|
||||
private var viewController: UIViewController?
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(router: Router) {
|
||||
self.router = router
|
||||
}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
func present(animated: Bool, onDismiss: (() -> Void)?) {
|
||||
let navigationController = UINavigationController(rootViewController: LocationsListViewController(
|
||||
viewModel: LocationsListViewModel(coordinator: self)
|
||||
))
|
||||
|
||||
viewController = navigationController
|
||||
|
||||
router.present(
|
||||
navigationController,
|
||||
animated: animated,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsListCoordination
|
||||
|
||||
extension LocationsListCoordinator: LocationsListCoordination {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func openLocationsAddScreen() {
|
||||
guard let viewController else {
|
||||
return
|
||||
}
|
||||
|
||||
present(
|
||||
child: LocationsAddCoordinator(
|
||||
router: ModalNavigationRouter(parentViewController: viewController)
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
func openWikipediaApp(with url: URL) {
|
||||
guard app.canOpenURL(url) else {
|
||||
return
|
||||
}
|
||||
|
||||
app.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
//
|
||||
// DependencyService+Keys.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import Dependency
|
||||
import Persistence
|
||||
import Remote
|
||||
import UIKit
|
||||
|
||||
// MARK: - DependencyService+Keys
|
||||
|
||||
extension DependencyService {
|
||||
var app: Core.Application {
|
||||
get { Self[ApplicationKey.self] }
|
||||
set { Self[ApplicationKey.self] = newValue }
|
||||
}
|
||||
|
||||
var persistence: Persistence.Service {
|
||||
get { Self[PersistenceKey.self] }
|
||||
set { Self[PersistenceKey.self] = newValue }
|
||||
}
|
||||
|
||||
var remote: Remote.Service {
|
||||
get { Self[RemoteKey.self] }
|
||||
set { Self[RemoteKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dependency keys
|
||||
|
||||
struct ApplicationKey: DependencyKey {
|
||||
static var currentValue: Core.Application = UIApplication.shared
|
||||
}
|
||||
|
||||
struct PersistenceKey: DependencyKey {
|
||||
static var currentValue: Persistence.Service = PersistenceService.shared
|
||||
}
|
||||
|
||||
struct RemoteKey: DependencyKey {
|
||||
static var currentValue: Remote.Service = RemoteService()
|
||||
}
|
46
Apps/Locations/Sources/Extensions/Location+URLs.swift
Normal file
46
Apps/Locations/Sources/Extensions/Location+URLs.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// Location+URLs.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
extension Location {
|
||||
|
||||
var wikipediaPlacesURL: URL? {
|
||||
var urlComponents = URLComponents()
|
||||
|
||||
urlComponents.scheme = .Scheme.wikipedia
|
||||
urlComponents.host = .Host.places
|
||||
urlComponents.queryItems = [
|
||||
.init(
|
||||
name: .Query.key,
|
||||
value: .init(format: .Query.value, latitude, longitude)
|
||||
)
|
||||
]
|
||||
|
||||
return urlComponents.url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Scheme {
|
||||
static let wikipedia = "wikipedia"
|
||||
}
|
||||
|
||||
enum Host {
|
||||
static let places = "places"
|
||||
}
|
||||
|
||||
enum Query {
|
||||
static let key = "coordinates"
|
||||
static let value = "%f,%f"
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
//
|
||||
// UIApplication+Conformances.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 13/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
extension UIApplication: Application {}
|
@ -0,0 +1,15 @@
|
||||
//
|
||||
// LocationsAddCoordination.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
protocol LocationsAddCoordination: AnyObject {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func closeLocationsAddScreen()
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
//
|
||||
// LocationsListCoordination.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol LocationsListCoordination: AnyObject {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func openLocationsAddScreen()
|
||||
func openWikipediaApp(with url: URL)
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
//
|
||||
// LocationsAddViewModeling.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
protocol LocationsAddViewModeling: AnyObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var coordinator: LocationsAddCoordination? { get set }
|
||||
|
||||
var locationExistsPublisher: Published<Bool>.Publisher { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func cleanLocation()
|
||||
func saveLocation()
|
||||
func setLocation(latitude: Float, longitude: Float)
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// LocationsListViewModeling.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
protocol LocationsListViewModeling: AnyObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var coordinator: LocationsListCoordination? { get set }
|
||||
|
||||
var locationsDidChangePublisher: PassthroughSubject<[Change], Never> { get }
|
||||
var numberOfLocationSections: Int { get }
|
||||
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { get }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func loadLocations()
|
||||
func location(at indexPath: IndexPath) -> Location
|
||||
func numberOfLocations(in section: Int) -> Int
|
||||
func openLocationsAdd()
|
||||
func openWikipedia(at indexPath: IndexPath)
|
||||
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
//
|
||||
// LocationsAddViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Core
|
||||
import UIKit
|
||||
import MapKit
|
||||
|
||||
class LocationsAddViewController: BaseViewController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let viewModel: LocationsAddViewModeling
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// MARK: Outlets
|
||||
|
||||
private lazy var map = {
|
||||
let map = MKMapView()
|
||||
|
||||
map.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
map.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnMap)))
|
||||
|
||||
return map
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(viewModel: LocationsAddViewModeling) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBar()
|
||||
setupView()
|
||||
bindViewModel()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension LocationsAddViewController {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func bindViewModel() {
|
||||
viewModel
|
||||
.locationExistsPublisher
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { locationExists in
|
||||
self.navigationItem
|
||||
.rightBarButtonItems?
|
||||
.forEach { $0.isEnabled = locationExists }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setupBar() {
|
||||
title = "Add a location"
|
||||
navigationController?.navigationBar.prefersLargeTitles = false
|
||||
navigationController?.navigationBar.backgroundColor = .systemBackground
|
||||
navigationController?.navigationBar.isTranslucent = true
|
||||
navigationController?.navigationBar.tintColor = .red
|
||||
navigationItem.rightBarButtonItems = [
|
||||
.init(
|
||||
title: "Save",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(saveButtonPressed)
|
||||
),
|
||||
.init(
|
||||
title: "Clean",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(cleanButtonPressed)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
func setupView() {
|
||||
view.addSubview(map)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
view.bottomAnchor.constraint(equalTo: map.bottomAnchor),
|
||||
view.leadingAnchor.constraint(equalTo: map.leadingAnchor),
|
||||
view.topAnchor.constraint(equalTo: map.topAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: map.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc func cleanButtonPressed() {
|
||||
map.removeAnnotations(map.annotations)
|
||||
|
||||
viewModel.cleanLocation()
|
||||
}
|
||||
|
||||
@objc func saveButtonPressed() {
|
||||
viewModel.saveLocation()
|
||||
}
|
||||
|
||||
@objc func tapOnMap(recognizer: UITapGestureRecognizer) {
|
||||
let tapOnView = recognizer.location(in: map)
|
||||
let mapCoordinates = map.convert(tapOnView, toCoordinateFrom: map)
|
||||
let annotation = MKPointAnnotation()
|
||||
|
||||
annotation.coordinate = mapCoordinates
|
||||
|
||||
map.removeAnnotations(map.annotations)
|
||||
map.addAnnotation(annotation)
|
||||
map.setCenter(mapCoordinates, animated: true)
|
||||
|
||||
viewModel.setLocation(
|
||||
latitude: Float(mapCoordinates.latitude),
|
||||
longitude: Float(mapCoordinates.longitude)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
//
|
||||
// LocationsAddViewModel.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Core
|
||||
|
||||
class LocationsAddViewModel: ObservableObject {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
weak var coordinator: LocationsAddCoordination?
|
||||
|
||||
@Published private var location: Location?
|
||||
@Published private var locationExists: Bool = false
|
||||
|
||||
private let saveLocalLocation = SaveLocalLocationUseCase()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(coordinator: LocationsAddCoordination) {
|
||||
self.coordinator = coordinator
|
||||
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsAddViewModeling
|
||||
|
||||
extension LocationsAddViewModel: LocationsAddViewModeling {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var locationExistsPublisher: Published<Bool>.Publisher { $locationExists }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func cleanLocation() {
|
||||
location = nil
|
||||
}
|
||||
|
||||
func saveLocation() {
|
||||
guard let location else {
|
||||
return
|
||||
}
|
||||
|
||||
saveLocalLocation(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude
|
||||
)
|
||||
|
||||
coordinator?.closeLocationsAddScreen()
|
||||
}
|
||||
|
||||
func setLocation(latitude: Float, longitude: Float) {
|
||||
if location == nil {
|
||||
location = .init(latitude: latitude, longitude: longitude)
|
||||
} else {
|
||||
location?.latitude = latitude
|
||||
location?.longitude = longitude
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension LocationsAddViewModel {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func setupBindings() {
|
||||
$location
|
||||
.map { $0 != nil }
|
||||
.assign(to: &$locationExists)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
private extension LocationsAddViewModel {
|
||||
struct Location {
|
||||
var latitude: Float
|
||||
var longitude: Float
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
//
|
||||
// LocationsListViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 08/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Core
|
||||
import UIKit
|
||||
|
||||
class LocationsListViewController: BaseViewController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let viewModel: LocationsListViewModeling
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// MARK: Outlets
|
||||
|
||||
private lazy var error = ErrorMessageView()
|
||||
private lazy var loading = LoadingSpinnerView()
|
||||
private lazy var table = {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
table.register(LocationViewCell.self, forCellReuseIdentifier: LocationViewCell.identifier)
|
||||
|
||||
return table
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(viewModel: LocationsListViewModeling) {
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupBar()
|
||||
setupView()
|
||||
bindViewModel()
|
||||
|
||||
viewModel.loadLocations()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
extension LocationsListViewController: UITableViewDataSource {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.numberOfLocationSections
|
||||
}
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
viewModel.numberOfLocations(in: section)
|
||||
}
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: LocationViewCell.identifier,
|
||||
for: indexPath
|
||||
) as? LocationViewCell else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
let entity = viewModel.location(at: indexPath)
|
||||
|
||||
cell.update(
|
||||
iconName: entity.source == .remote ? "network" : "house",
|
||||
name: entity.name,
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension LocationsListViewController: UITableViewDelegate {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func tableView(
|
||||
_ tableView: UITableView,
|
||||
didSelectRowAt indexPath: IndexPath
|
||||
) {
|
||||
viewModel.openWikipedia(at: indexPath)
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension LocationsListViewController {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func setupBar() {
|
||||
navigationController?.navigationBar.prefersLargeTitles = true
|
||||
navigationController?.navigationBar.tintColor = .red
|
||||
navigationItem.rightBarButtonItem = .init(
|
||||
title: "Add",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(addLocationPressed)
|
||||
)
|
||||
title = "Locations"
|
||||
}
|
||||
|
||||
func setupView() {
|
||||
view.addSubview(table)
|
||||
view.addSubview(error)
|
||||
view.addSubview(loading)
|
||||
|
||||
error.onRetry = {
|
||||
self.viewModel.loadLocations()
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
error.widthAnchor.constraint(equalToConstant: 300),
|
||||
view.centerXAnchor.constraint(equalTo: error.centerXAnchor),
|
||||
view.centerYAnchor.constraint(equalTo: error.centerYAnchor),
|
||||
view.centerXAnchor.constraint(equalTo: loading.centerXAnchor),
|
||||
view.centerYAnchor.constraint(equalTo: loading.centerYAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: table.bottomAnchor),
|
||||
view.leadingAnchor.constraint(equalTo: table.leadingAnchor),
|
||||
view.topAnchor.constraint(equalTo: table.topAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: table.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
func bindViewModel() {
|
||||
viewModel
|
||||
.viewStatusPublisher
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { viewStatus in
|
||||
self.navigationItem.rightBarButtonItem?.isEnabled = viewStatus == .loaded
|
||||
self.error.isHidden = viewStatus != .error
|
||||
self.loading.isHidden = viewStatus != .loading
|
||||
self.table.isHidden = viewStatus != .loaded
|
||||
|
||||
if viewStatus == .loaded {
|
||||
self.table.reloadData()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel
|
||||
.locationsDidChangePublisher
|
||||
.sink(receiveValue: { [weak self] updates in
|
||||
var movedToIndexPaths = [IndexPath]()
|
||||
|
||||
self?.table.performBatchUpdates({
|
||||
for update in updates {
|
||||
switch update {
|
||||
case let .section(sectionUpdate):
|
||||
switch sectionUpdate {
|
||||
case let .inserted(index):
|
||||
self?.table.insertSections([index], with: .automatic)
|
||||
case let .deleted(index):
|
||||
self?.table.deleteSections([index], with: .automatic)
|
||||
}
|
||||
case let .object(objectUpdate):
|
||||
switch objectUpdate {
|
||||
case let .inserted(at: indexPath):
|
||||
self?.table.insertRows(at: [indexPath], with: .automatic)
|
||||
case let .deleted(from: indexPath):
|
||||
self?.table.deleteRows(at: [indexPath], with: .automatic)
|
||||
case let .updated(at: indexPath):
|
||||
self?.table.reloadRows(at: [indexPath], with: .automatic)
|
||||
case let .moved(from: source, to: target):
|
||||
self?.table.moveRow(at: source, to: target)
|
||||
movedToIndexPaths.append(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, completion: { done in
|
||||
self?.table.reloadRows(at: movedToIndexPaths, with: .automatic)
|
||||
})
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@objc func addLocationPressed() {
|
||||
viewModel.openLocationsAdd()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
//
|
||||
// LocationsListViewModel.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Dependency
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
class LocationsListViewModel: ObservableObject {
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.persistence) private var persistence
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
weak var coordinator: LocationsListCoordination?
|
||||
|
||||
private lazy var locationProvider = LocationProvider(managedContext: persistence.viewContext)
|
||||
|
||||
@Published private var viewStatus: LocationsListViewStatus = .initialised
|
||||
|
||||
private let loadRemoteLocations = LoadRemoteLocationsUseCase()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(coordinator: LocationsListCoordination) {
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LocationsListViewModeling
|
||||
|
||||
extension LocationsListViewModel: LocationsListViewModeling {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var locationsDidChangePublisher: PassthroughSubject<[Persistence.Change], Never> { locationProvider.didChangePublisher }
|
||||
var numberOfLocationSections: Int { locationProvider.numberOfSections }
|
||||
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { $viewStatus }
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func loadLocations() {
|
||||
Task {
|
||||
do {
|
||||
viewStatus = .loading
|
||||
|
||||
try await loadRemoteLocations()
|
||||
|
||||
try locationProvider.fetch()
|
||||
|
||||
viewStatus = .loaded
|
||||
} catch {
|
||||
viewStatus = .error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func location(at indexPath: IndexPath) -> Location {
|
||||
locationProvider.location(at: indexPath)
|
||||
}
|
||||
|
||||
func numberOfLocations(in section: Int) -> Int {
|
||||
locationProvider.numberOfLocationsInSection(section)
|
||||
}
|
||||
|
||||
func openLocationsAdd() {
|
||||
coordinator?.openLocationsAddScreen()
|
||||
}
|
||||
|
||||
func openWikipedia(at indexPath: IndexPath) {
|
||||
guard let url = locationProvider.location(at: indexPath).wikipediaPlacesURL else {
|
||||
return
|
||||
}
|
||||
|
||||
coordinator?.openWikipediaApp(with: url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
enum LocationsListViewStatus {
|
||||
case initialised
|
||||
case loading
|
||||
case loaded
|
||||
case error
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
//
|
||||
// LoadRemoteLocationsUseCase.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Dependency
|
||||
import Persistence
|
||||
import Remote
|
||||
|
||||
struct LoadRemoteLocationsUseCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let persistence: Persistence.Service
|
||||
private let remoteService: Remote.Service
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(
|
||||
persistence: Persistence.Service,
|
||||
remoteService: Remote.Service
|
||||
) {
|
||||
self.persistence = persistence
|
||||
self.remoteService = remoteService
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func callAsFunction() async throws {
|
||||
let context = persistence.makeTaskContext()
|
||||
let fetchRequest = NSFetchRequest<Persistence.Location>.allLocations()
|
||||
|
||||
try await context.perform {
|
||||
let localLocations = try context.fetch(fetchRequest)
|
||||
|
||||
localLocations
|
||||
.filter { $0.source == .remote }
|
||||
.forEach(context.delete)
|
||||
}
|
||||
|
||||
let remoteLocations = try await remoteService.getLocations()
|
||||
|
||||
_ = remoteLocations
|
||||
.map {
|
||||
let entity = Persistence.Location(context: context)
|
||||
|
||||
entity.createdAt = .now
|
||||
entity.name = $0.name
|
||||
entity.latitude = $0.latitude
|
||||
entity.longitude = $0.longitude
|
||||
entity.source = .remote
|
||||
|
||||
return entity
|
||||
}
|
||||
|
||||
persistence.save(context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LoadRemoteLocationsUseCase+Initialisers
|
||||
|
||||
extension LoadRemoteLocationsUseCase {
|
||||
init() {
|
||||
@Dependency(\.persistence) var persistence
|
||||
@Dependency(\.remote) var remote
|
||||
|
||||
self.init(
|
||||
persistence: persistence,
|
||||
remoteService: remote
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
//
|
||||
// SaveLocalLocationUseCase.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Dependency
|
||||
import Persistence
|
||||
|
||||
struct SaveLocalLocationUseCase {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let persistence: Persistence.Service
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init(persistence: Persistence.Service) {
|
||||
self.persistence = persistence
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func callAsFunction(
|
||||
name: String? = nil,
|
||||
latitude: Float,
|
||||
longitude: Float
|
||||
) {
|
||||
let context = persistence.makeTaskContext()
|
||||
let entity = Location(context: context)
|
||||
|
||||
entity.createdAt = .now
|
||||
entity.name = name
|
||||
entity.latitude = latitude
|
||||
entity.longitude = longitude
|
||||
entity.source = .local
|
||||
|
||||
persistence.save(context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - LoadRemoteLocationsUseCase+Initialisers
|
||||
|
||||
extension SaveLocalLocationUseCase {
|
||||
init() {
|
||||
@Dependency(\.persistence) var persistence
|
||||
|
||||
self.init(persistence: persistence)
|
||||
}
|
||||
}
|
||||
|
124
Apps/Locations/Sources/View Components/ErrorMessageView.swift
Normal file
124
Apps/Locations/Sources/View Components/ErrorMessageView.swift
Normal file
@ -0,0 +1,124 @@
|
||||
//
|
||||
// ErrorMessageView.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ErrorMessageView: UIView {
|
||||
|
||||
// MARK: Typealiases
|
||||
|
||||
typealias OnRetryClosure = () -> Void
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var onRetry: OnRetryClosure?
|
||||
|
||||
// MARK: Outlets
|
||||
|
||||
private lazy var stack: UIStackView = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .center
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 32
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var title = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .largeTitle)
|
||||
label.numberOfLines = 0
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.text = "Some error title goes in here..."
|
||||
label.textAlignment = .center
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var message = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.numberOfLines = 0
|
||||
label.text = "Some long, descriptive, explanatory error message goes in here..."
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var retry = {
|
||||
let button = UIButton()
|
||||
|
||||
button.backgroundColor = .red
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.layer.borderColor = UIColor.red.cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.cornerRadius = 5
|
||||
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
||||
|
||||
button.addTarget(self, action: #selector(retryPressed), for: .touchUpInside)
|
||||
button.setTitle("Try again", for: .normal)
|
||||
|
||||
return button
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ErrorMessageView {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func setupView() {
|
||||
backgroundColor = .clear
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(stack)
|
||||
|
||||
stack.addArrangedSubview(title)
|
||||
stack.addArrangedSubview(message)
|
||||
stack.addArrangedSubview(retry)
|
||||
stack.setCustomSpacing(160, after: message)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
||||
leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
topAnchor.constraint(equalTo: stack.topAnchor),
|
||||
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
retry.heightAnchor.constraint(equalToConstant: 44),
|
||||
retry.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
retry.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@objc func retryPressed() {
|
||||
onRetry?()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
//
|
||||
// LoadingSpinnerView.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LoadingSpinnerView: UIView {
|
||||
|
||||
// MARK: Outlets
|
||||
|
||||
private lazy var stack = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .center
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var spinner = {
|
||||
let activity = UIActivityIndicatorView(style: .large)
|
||||
|
||||
activity.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
activity.startAnimating()
|
||||
|
||||
return activity
|
||||
}()
|
||||
|
||||
private lazy var title = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.numberOfLines = 0
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.text = "Loading..."
|
||||
label.textAlignment = .center
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension LoadingSpinnerView {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func setupView() {
|
||||
backgroundColor = .clear
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(stack)
|
||||
|
||||
stack.addArrangedSubview(spinner)
|
||||
stack.addArrangedSubview(title)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
topAnchor.constraint(equalTo: stack.topAnchor),
|
||||
leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
223
Apps/Locations/Sources/View Components/LocationViewCell.swift
Normal file
223
Apps/Locations/Sources/View Components/LocationViewCell.swift
Normal file
@ -0,0 +1,223 @@
|
||||
//
|
||||
// LocationViewCell.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 12/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LocationViewCell: UITableViewCell {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
static let identifier = "LocationViewCell"
|
||||
|
||||
// MARK: Outlets
|
||||
|
||||
private lazy var icon = {
|
||||
let view = UIImageView()
|
||||
|
||||
view.contentMode = .top
|
||||
view.tintColor = .red
|
||||
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var latitudeTitle = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.numberOfLines = 1
|
||||
label.text = "• Latitude"
|
||||
label.textAlignment = .natural
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var latitudeValue = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.numberOfLines = 1
|
||||
label.textAlignment = .natural
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var longitudeTitle = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.numberOfLines = 1
|
||||
label.text = "• Longitude"
|
||||
label.textAlignment = .natural
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var longitudeValue = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.numberOfLines = 1
|
||||
label.textAlignment = .natural
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var name = {
|
||||
let label = UILabel()
|
||||
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.numberOfLines = 0
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.text = "Untitled"
|
||||
label.textAlignment = .natural
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var stack = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .center
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var stackData = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .leading
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var stackCoordinates = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .leading
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 2
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var stackLatitude = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .leading
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 4
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
private lazy var stackLongitude = {
|
||||
let stack = UIStackView()
|
||||
|
||||
stack.alignment = .leading
|
||||
stack.axis = .horizontal
|
||||
stack.distribution = .fill
|
||||
stack.spacing = 4
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return stack
|
||||
}()
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
override init(
|
||||
style: UITableViewCell.CellStyle,
|
||||
reuseIdentifier: String?
|
||||
) {
|
||||
super.init(
|
||||
style: style,
|
||||
reuseIdentifier: reuseIdentifier
|
||||
)
|
||||
|
||||
setupCell()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func update(
|
||||
iconName: String,
|
||||
name: String?,
|
||||
latitude: Float,
|
||||
longitude: Float
|
||||
) {
|
||||
self.icon.image = .init(systemName: iconName)
|
||||
self.name.text = name ?? "Untitled"
|
||||
self.latitudeValue.text = "\(latitude)"
|
||||
self.longitudeValue.text = "\(longitude)"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension LocationViewCell {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
func setupCell() {
|
||||
accessoryType = .disclosureIndicator
|
||||
backgroundColor = .clear
|
||||
|
||||
addSubview(stack)
|
||||
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(stackData)
|
||||
|
||||
stackData.addArrangedSubview(name)
|
||||
stackData.addArrangedSubview(stackCoordinates)
|
||||
|
||||
stackCoordinates.addArrangedSubview(stackLatitude)
|
||||
stackCoordinates.addArrangedSubview(stackLongitude)
|
||||
|
||||
stackLatitude.addArrangedSubview(latitudeTitle)
|
||||
stackLatitude.addArrangedSubview(latitudeValue)
|
||||
|
||||
stackLongitude.addArrangedSubview(longitudeTitle)
|
||||
stackLongitude.addArrangedSubview(longitudeValue)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 8),
|
||||
leadingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -20),
|
||||
topAnchor.constraint(equalTo: stack.topAnchor, constant: -8),
|
||||
trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
icon.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
|
||||
icon.topAnchor.constraint(equalTo: stack.topAnchor),
|
||||
icon.widthAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
//
|
||||
// BaseViewController.swift
|
||||
// Locations
|
||||
//
|
||||
// Created by Javier Cicchelli on 11/04/2023.
|
||||
// Copyright © 2023 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class BaseViewController: UIViewController {
|
||||
|
||||
// MARK: Initialisers
|
||||
|
||||
init() {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .systemBackground
|
||||
}
|
||||
|
||||
}
|
44
Apps/Wikipedia/.circleci/config.yml
Normal file
44
Apps/Wikipedia/.circleci/config.yml
Normal file
@ -0,0 +1,44 @@
|
||||
# For a detailed guide to building and testing on iOS, read the docs:
|
||||
# https://circleci.com/docs/2.0/testing-ios/
|
||||
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
xcode:
|
||||
macos:
|
||||
xcode: 14.2.0
|
||||
|
||||
commands:
|
||||
install_dependencies:
|
||||
description: "Install dependencies"
|
||||
steps:
|
||||
- restore_cache:
|
||||
key: 1-gems-{{ checksum "Gemfile.lock" }}
|
||||
- run: bundle check || bundle install --path vendor/bundle
|
||||
- save_cache:
|
||||
key: 1-gems-{{ checksum "Gemfile.lock" }}
|
||||
paths:
|
||||
- vendor/bundle
|
||||
work_around_swift_package_manager_bug:
|
||||
description: "Work around a Swift package manager bug" # https://support.circleci.com/hc/en-us/articles/360044709573?input_string=unable%2Bto%2Baccess%2Bprivate%2Bswift%2Bpackage%2Brepository
|
||||
steps:
|
||||
- run: sudo defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM YES
|
||||
- run: rm ~/.ssh/id_rsa || true
|
||||
- run: for ip in $(dig @8.8.8.8 github.com +short); do ssh-keyscan github.com,$ip; ssh-keyscan $ip; done 2>/dev/null >> ~/.ssh/known_hosts || true
|
||||
|
||||
jobs:
|
||||
test_pr:
|
||||
executor: xcode
|
||||
steps:
|
||||
- checkout
|
||||
- install_dependencies
|
||||
- work_around_swift_package_manager_bug
|
||||
- run:
|
||||
name: Fastlane
|
||||
command: bundle exec fastlane verify_pull_request
|
||||
- store_test_results:
|
||||
path: fastlane/test_output/
|
||||
workflows:
|
||||
test_pr:
|
||||
jobs:
|
||||
- test_pr
|
61
Apps/Wikipedia/.clang-format
Normal file
61
Apps/Wikipedia/.clang-format
Normal file
@ -0,0 +1,61 @@
|
||||
# https://clang.llvm.org/docs/ClangFormatStyleOptions.html
|
||||
BasedOnStyle: LLVM
|
||||
AccessModifierOffset: -2
|
||||
AlignAfterOpenBracket: true
|
||||
AlignEscapedNewlinesLeft: false
|
||||
AlignOperands: true
|
||||
AlignTrailingComments: true
|
||||
AllowAllParametersOfDeclarationOnNextLine: true
|
||||
AllowShortBlocksOnASingleLine: false
|
||||
AllowShortCaseLabelsOnASingleLine: false
|
||||
AllowShortFunctionsOnASingleLine: All
|
||||
AllowShortIfStatementsOnASingleLine: false
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
AlwaysBreakAfterDefinitionReturnType: false
|
||||
AlwaysBreakBeforeMultilineStrings: false
|
||||
AlwaysBreakTemplateDeclarations: false
|
||||
BinPackArguments: true
|
||||
BinPackParameters: true
|
||||
BreakBeforeBinaryOperators: None
|
||||
BreakBeforeBraces: Attach
|
||||
BreakBeforeTernaryOperators: true
|
||||
BreakConstructorInitializersBeforeComma: false
|
||||
ColumnLimit: 0
|
||||
CommentPragmas: '^ IWYU pragma:'
|
||||
ConstructorInitializerAllOnOneLineOrOnePerLine: false
|
||||
ConstructorInitializerIndentWidth: 4
|
||||
ContinuationIndentWidth: 4
|
||||
Cpp11BracedListStyle: true
|
||||
DerivePointerAlignment: false
|
||||
DisableFormat: false
|
||||
ExperimentalAutoDetectBinPacking: false
|
||||
IndentCaseLabels: true
|
||||
IndentWidth: 4
|
||||
IndentWrappedFunctionNames: false
|
||||
KeepEmptyLinesAtTheStartOfBlocks: true
|
||||
MaxEmptyLinesToKeep: 1
|
||||
NamespaceIndentation: None
|
||||
ObjCBlockIndentWidth: 4
|
||||
ObjCSpaceAfterProperty: true
|
||||
ObjCSpaceBeforeProtocolList: true
|
||||
PenaltyBreakBeforeFirstCallParameter: 19
|
||||
PenaltyBreakComment: 300
|
||||
PenaltyBreakFirstLessLess: 120
|
||||
PenaltyBreakString: 1000
|
||||
PenaltyExcessCharacter: 1000000
|
||||
PenaltyReturnTypeOnItsOwnLine: 60
|
||||
PointerAlignment: Right
|
||||
SpaceAfterCStyleCast: false
|
||||
SpaceBeforeAssignmentOperators: true
|
||||
SpaceBeforeParens: ControlStatements
|
||||
SpaceInEmptyParentheses: false
|
||||
SpacesBeforeTrailingComments: 1
|
||||
SpacesInAngles: false
|
||||
SpacesInCStyleCastParentheses: false
|
||||
SpacesInContainerLiterals: false
|
||||
SpacesInParentheses: false
|
||||
SpacesInSquareBrackets: false
|
||||
Standard: Cpp11
|
||||
TabWidth: 8
|
||||
UseTab: Never
|
||||
SortIncludes: false
|
1
Apps/Wikipedia/.github/FUNDING.yml
vendored
Normal file
1
Apps/Wikipedia/.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ['https://donate.wikimedia.org/?utm_medium=githubRepo']
|
10
Apps/Wikipedia/.github/pull_request_template.md
vendored
Normal file
10
Apps/Wikipedia/.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
**Phabricator:**
|
||||
|
||||
### Notes
|
||||
*
|
||||
|
||||
### Test Steps
|
||||
1.
|
||||
|
||||
### Screenshots/Videos
|
||||
|
29
Apps/Wikipedia/.github/workflows/localization.yml
vendored
Normal file
29
Apps/Wikipedia/.github/workflows/localization.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Transform localizations from TranslateWiki.net
|
||||
#
|
||||
# As of late 2020, TranslateWiki creates PRs automatically, in line with
|
||||
# how they work with other repos. However, the strings still need to be
|
||||
# translated to work well with the iOS app. This script runs when a new PR
|
||||
# is created by TranslateWiki, to add everything the app needs to the PR.
|
||||
#
|
||||
name: Import localizations from TranslateWiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- twn
|
||||
|
||||
jobs:
|
||||
update-localizations:
|
||||
runs-on: macOS-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Update localizations
|
||||
continue-on-error: true
|
||||
run: |
|
||||
$GITHUB_WORKSPACE/scripts/localization $GITHUB_WORKSPACE
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git add .
|
||||
git commit -m "Import translations from TranslateWiki"
|
||||
git push
|
3
Apps/Wikipedia/.jshintignore
Normal file
3
Apps/Wikipedia/.jshintignore
Normal file
@ -0,0 +1,3 @@
|
||||
Wikipedia/assets/**
|
||||
fastlane/**
|
||||
www/node_modules/**
|
1
Apps/Wikipedia/.node-version
Normal file
1
Apps/Wikipedia/.node-version
Normal file
@ -0,0 +1 @@
|
||||
10.16.0
|
1
Apps/Wikipedia/.ruby-version
Normal file
1
Apps/Wikipedia/.ruby-version
Normal file
@ -0,0 +1 @@
|
||||
3.0.5
|
37
Apps/Wikipedia/.swiftlint-autocorrect.yml
Normal file
37
Apps/Wikipedia/.swiftlint-autocorrect.yml
Normal file
@ -0,0 +1,37 @@
|
||||
disabled_rules:
|
||||
- class_delegate_protocol
|
||||
- colon
|
||||
- comma
|
||||
- compiler_protocol_init
|
||||
- cyclomatic_complexity
|
||||
- file_length
|
||||
- force_cast
|
||||
- force_try
|
||||
- for_where
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- identifier_name
|
||||
- is_disjoint
|
||||
- large_tuple
|
||||
- legacy_random
|
||||
- line_length
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
- no_fallthrough_only
|
||||
- operator_whitespace
|
||||
- private_over_fileprivate
|
||||
- redundant_objc_attribute
|
||||
- redundant_optional_initialization
|
||||
- redundant_string_enum_value
|
||||
- shorthand_operator
|
||||
- todo
|
||||
- trailing_newline
|
||||
- trailing_whitespace
|
||||
- type_body_length
|
||||
- type_name
|
||||
- unneeded_break_in_switch
|
||||
- unused_closure_parameter
|
||||
- notification_center_detachment
|
||||
|
||||
vertical_whitespace:
|
||||
max_empty_lines: 2
|
37
Apps/Wikipedia/.swiftlint.yml
Normal file
37
Apps/Wikipedia/.swiftlint.yml
Normal file
@ -0,0 +1,37 @@
|
||||
disabled_rules:
|
||||
- class_delegate_protocol
|
||||
- colon
|
||||
- comma
|
||||
- compiler_protocol_init
|
||||
- cyclomatic_complexity
|
||||
- file_length
|
||||
- force_cast
|
||||
- force_try
|
||||
- for_where
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- identifier_name
|
||||
- is_disjoint
|
||||
- large_tuple
|
||||
- legacy_random
|
||||
- line_length
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
- no_fallthrough_only
|
||||
- operator_whitespace
|
||||
- private_over_fileprivate
|
||||
- redundant_objc_attribute
|
||||
- redundant_optional_initialization
|
||||
- redundant_string_enum_value
|
||||
- shorthand_operator
|
||||
- todo
|
||||
- trailing_newline
|
||||
- trailing_whitespace
|
||||
- type_body_length
|
||||
- type_name
|
||||
- unneeded_break_in_switch
|
||||
- unused_closure_parameter
|
||||
- notification_center_detachment
|
||||
|
||||
vertical_whitespace:
|
||||
max_empty_lines: 2
|
7
Apps/Wikipedia/.xctool-args
Normal file
7
Apps/Wikipedia/.xctool-args
Normal file
@ -0,0 +1,7 @@
|
||||
[
|
||||
"-workspace", "Wikipedia.xcworkspace",
|
||||
"-scheme", "Wikipedia",
|
||||
"-configuration", "Debug",
|
||||
"-sdk", "iphonesimulator",
|
||||
"-destination", "platform=iOS Simulator,name=iPhone 6,OS=10.0"
|
||||
]
|
1
Apps/Wikipedia/.xcversion
Normal file
1
Apps/Wikipedia/.xcversion
Normal file
@ -0,0 +1 @@
|
||||
14.2.0
|
1
Apps/Wikipedia/CODE_OF_CONDUCT.md
Normal file
1
Apps/Wikipedia/CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1 @@
|
||||
The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Code_of_Conduct).
|
32
Apps/Wikipedia/CONTRIBUTING.md
Normal file
32
Apps/Wikipedia/CONTRIBUTING.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Contributing
|
||||
We welcome volunteers to contribute to the Wikipedia iOS app codebase.
|
||||
|
||||
## Development instructions
|
||||
Before developing, please read the [setup instructions](README.md).
|
||||
|
||||
Once your contributions are ready for review, add yourself in alphabetical order under contributors in `Code/AboutViewController.plist` and post a pull request on GitHub. One of the maintainers will review the PR. Thanks for contributing! 🎉
|
||||
|
||||
## What can I work on?
|
||||
If you're looking for easy work, look at the tasks marked with the "good first bug" tag. [This link](https://phabricator.wikimedia.org/project/board/782/query/7vYTqNgpvqjh/) will show you all the "good first bug" tasks in the iOS backlog.
|
||||
|
||||
If you're ready to pick up more difficult work, look at the iOS backlog and pick something from the Bug Backlog column. [This link](https://phabricator.wikimedia.org/project/board/782/) will show you all the tasks in the iOS backlog. If the status of the task is unclear or you need more information, feel free to leave a comment and we'll try to respond as soon as possible.
|
||||
|
||||
## I found my task. What next?
|
||||
Now you want to let the team know what you're working on.
|
||||
|
||||
1. In Phabricator, assign the task to yourself.
|
||||
2. Add the tag representing the current release to the task. [This link](https://phabricator.wikimedia.org/search/query/WlSMhOAWTG73/) will take you to currently open releases. Tagging your task with the name of the release will add it to the release board.
|
||||
3. On the release board, move the task to the "Doing" column.
|
||||
4. When you're done developing, move the task to the "Needs Code Review" column.
|
||||
|
||||
## I don't want to work on my task any more.
|
||||
You can let us know by unassigning the task and moving it back to the "Tasks from Product Backlog" column.
|
||||
|
||||
## How will I know that my contribution was accepted?
|
||||
Your PR will be merged and your task will get moved to the "Ready for PM Signoff" column. This means that your contribution will be included in the upcoming release.
|
||||
|
||||
## Tips
|
||||
[Wiki on how to use Phabricator](https://www.mediawiki.org/wiki/Phabricator/Project_management)
|
||||
|
||||
|
||||
|
@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Command line tool for updating prebuilt language lists and lookup tables used in the app
|
||||
class WikipediaLanguageCommandLineUtility {
|
||||
let pathComponents: [String]
|
||||
let api = WikipediaLanguageCommandLineUtilityAPI()
|
||||
|
||||
/// - Parameter path: the path to the wikipedia-ios project folder
|
||||
init(path: String) {
|
||||
pathComponents = path.components(separatedBy: "/")
|
||||
}
|
||||
|
||||
var cancellable: AnyCancellable?
|
||||
|
||||
/// Generates all the necessary files
|
||||
func run(_ completion: @escaping () -> Void) {
|
||||
cancellable = api.getSites().sink(receiveCompletion: { (result) in
|
||||
switch result {
|
||||
case .failure(let error):
|
||||
print("Error fetching sites: \(error)")
|
||||
abort()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}) { (sites) in
|
||||
let sortedSites = sites.sorted { (a, b) -> Bool in
|
||||
return a.languageCode < b.languageCode
|
||||
}
|
||||
self.writeCodable(sortedSites, to: ["Wikipedia", "Code", "wikipedia-languages.json"])
|
||||
self.cancellable = self.writeNamespaceFiles(with: sites) {
|
||||
self.cancellable = self.writeCodemirrorConfig(with: sites, completion: {
|
||||
completion()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func getOutputFileURL(with components: [String]) -> URL {
|
||||
let outputComponents = pathComponents + components
|
||||
let outputPath = outputComponents.joined(separator: "/")
|
||||
return URL(fileURLWithPath: outputPath)
|
||||
}
|
||||
|
||||
private func writeCodable<T: Codable>(_ codable: T, to pathComponents: [String]) {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(codable)
|
||||
let outputFileURL = getOutputFileURL(with: pathComponents)
|
||||
try data.write(to: outputFileURL)
|
||||
} catch let error {
|
||||
print("Error writing to file: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCodemirrorConfig(with sites: [Wikipedia], completion: @escaping () -> Void) -> AnyCancellable {
|
||||
Publishers.MergeMany(sites.map { site in
|
||||
api.getCodeMirrorConfigJSON(for: site.languageCode)
|
||||
.map { ($0, site) }
|
||||
})
|
||||
.sink(receiveCompletion: { (result) in
|
||||
switch result {
|
||||
case .finished:
|
||||
break
|
||||
case .failure(let error):
|
||||
print("Error writing codemirror config: \(error)")
|
||||
}
|
||||
completion()
|
||||
}) { (value) in
|
||||
let outputURL = self.getOutputFileURL(with: ["Wikipedia", "assets", "codemirror", "config", "codemirror-config-\(value.1.languageCode).json"])
|
||||
try! value.0.write(to: outputURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeNamespaceFiles(with sites: [Wikipedia], completion: @escaping () -> Void) -> AnyCancellable? {
|
||||
return Publishers.MergeMany(sites.map { site in
|
||||
api.getSiteInfo(with: site.languageCode)
|
||||
.map { ($0, site) }
|
||||
})
|
||||
.map {
|
||||
(self.getSiteInfoLookup(with: $0.0), $0.1)
|
||||
}
|
||||
.sink(receiveCompletion: { (result) in
|
||||
completion()
|
||||
}) { (siteInfoTuple) in
|
||||
self.writeCodable(siteInfoTuple.0, to: ["Wikipedia", "Code", "wikipedia-namespaces", "\(siteInfoTuple.1.languageCode).json"])
|
||||
}
|
||||
}
|
||||
|
||||
private func getSiteInfoLookup(with siteInfo: SiteInfo) -> WikipediaSiteInfoLookup {
|
||||
var namespaces = [String: PageNamespace].init(minimumCapacity: siteInfo.query.namespaces.count + siteInfo.query.namespacealiases.count)
|
||||
for (_, namespace) in siteInfo.query.namespaces {
|
||||
namespaces[namespace.name.uppercased()] = PageNamespace(rawValue: namespace.id)
|
||||
guard let canonical = namespace.canonical else {
|
||||
continue
|
||||
}
|
||||
namespaces[canonical.uppercased()] = PageNamespace(rawValue: namespace.id)
|
||||
}
|
||||
for namespaceAlias in siteInfo.query.namespacealiases {
|
||||
namespaces[namespaceAlias.alias.uppercased()] = PageNamespace(rawValue: namespaceAlias.id)
|
||||
}
|
||||
return WikipediaSiteInfoLookup(namespace: namespaces, mainpage: siteInfo.query.general.mainpage.uppercased())
|
||||
}
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Utility for making API calls that update prebuilt lists of information about different language Wikipedias
|
||||
class WikipediaLanguageCommandLineUtilityAPI {
|
||||
// We can't use codable because this API's response has a dictionary with arbitrary keys mapped to values of mixed type.
|
||||
// For example, the mixing of ints and dictionaries in "sitematrix":
|
||||
// "sitematrix": {
|
||||
// "count": 951,
|
||||
// "0": {
|
||||
// "code": "aa",
|
||||
// "name": "Qaf\u00e1r af",
|
||||
// ...
|
||||
// },
|
||||
// "1": {
|
||||
// "code": "ab",
|
||||
// "name":"аԥсшәа",
|
||||
// ...
|
||||
// },
|
||||
// ...
|
||||
// }
|
||||
func getSites() -> AnyPublisher<[Wikipedia], Error> {
|
||||
let sitematrixURL = URL(string: "https://meta.wikimedia.org/w/api.php?action=sitematrix&smsiteprop=url%7Cdbname%7Ccode%7Csitename%7Clang&format=json&formatversion=2&origin=*")!
|
||||
return URLSession.shared
|
||||
.dataTaskPublisher(for: sitematrixURL)
|
||||
.tryMap { result -> [String: Any] in
|
||||
/// See above as to why all of this is necessary instead of using codable
|
||||
guard let jsonObject = try JSONSerialization.jsonObject(with: result.data, options: .allowFragments) as? [String: Any] else {
|
||||
throw WikipediaLanguageUtilityAPIError.generic
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
.map { jsonObject -> [Wikipedia] in
|
||||
/// See above as to why all of this is necessary instead of using codable
|
||||
guard let sitematrix = jsonObject["sitematrix"] as? [String: Any] else {
|
||||
return []
|
||||
}
|
||||
var wikipedias = sitematrix.compactMap { (kv) -> Wikipedia? in
|
||||
guard
|
||||
let result = kv.value as? [String: Any],
|
||||
let code = result["code"] as? String,
|
||||
let name = result["name"] as? String,
|
||||
let localname = result["localname"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard code != "no" else {
|
||||
// Norwegian (Bokmål) has a different ISO code than it's subdomain, which is useful to reference in some instances (prepopulating preferredLanguages from iOS device languages, and choosing the correct alternative article language from the langlinks endpoint).
|
||||
// https://phabricator.wikimedia.org/T276645
|
||||
// https://phabricator.wikimedia.org/T272193
|
||||
return Wikipedia(languageCode: code, languageName: name, localName: localname, altISOCode: "nb")
|
||||
}
|
||||
|
||||
return Wikipedia(languageCode: code, languageName: name, localName: localname, altISOCode: nil)
|
||||
}
|
||||
// Add testwiki, it's not returned by the site matrix
|
||||
wikipedias.append(Wikipedia(languageCode: "test", languageName: "Test", localName: "Test", altISOCode: nil))
|
||||
return wikipedias
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getSiteInfo(with languageCode: String) -> AnyPublisher<SiteInfo, Error> {
|
||||
let siteInfoURL = URL(string: "https://\(languageCode).wikipedia.org/w/api.php?action=query&format=json&prop=&list=&meta=siteinfo&siprop=namespaces%7Cgeneral%7Cnamespacealiases&formatversion=2&origin=*")!
|
||||
return URLSession.shared
|
||||
.dataTaskPublisher(for: siteInfoURL)
|
||||
.tryMap { (result) -> SiteInfo in
|
||||
try JSONDecoder().decode(SiteInfo.self, from: result.data)
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getCodeMirrorConfigJSON(for wikiLanguage: String) -> AnyPublisher<String, Error> {
|
||||
let codeMirrorConfigURL = URL(string: "http://\(wikiLanguage).wikipedia.org/w/load.php?debug=false&lang=en&modules=ext.CodeMirror.data")!
|
||||
return URLSession.shared.dataTaskPublisher(for: codeMirrorConfigURL)
|
||||
.tryMap { (result) -> String in
|
||||
guard
|
||||
let responseString = String(data: result.data, encoding: .utf8),
|
||||
let soughtSubstring = self.extractJSONString(from: responseString)
|
||||
else {
|
||||
throw WikipediaLanguageUtilityAPIError.generic
|
||||
}
|
||||
return soughtSubstring.replacingOccurrences(of: "!0", with: "true")
|
||||
}.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let jsonExtractionRegex = try! NSRegularExpression(pattern: #"(?:mw\.config\.set\()(.*?)(?:\);\n*\}\);)"#, options: [.dotMatchesLineSeparators])
|
||||
|
||||
private func extractJSONString(from responseString: String) -> String? {
|
||||
let results = jsonExtractionRegex.matches(in: responseString, range: NSRange(responseString.startIndex..., in: responseString))
|
||||
guard
|
||||
results.count == 1,
|
||||
let firstResult = results.first,
|
||||
firstResult.numberOfRanges == 2,
|
||||
let soughtCaptureGroupRange = Range(firstResult.range(at: 1), in: responseString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(responseString[soughtCaptureGroupRange])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum WikipediaLanguageUtilityAPIError: Error {
|
||||
case generic
|
||||
}
|
||||
|
||||
struct SiteInfo: Codable {
|
||||
struct Namespace: Codable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let canonical: String?
|
||||
}
|
||||
struct NamespaceAlias: Codable {
|
||||
let id: Int
|
||||
let alias: String
|
||||
}
|
||||
struct General: Codable {
|
||||
let mainpage: String
|
||||
}
|
||||
struct Query: Codable {
|
||||
let general: General
|
||||
let namespaces: [String: Namespace]
|
||||
let namespacealiases: [NamespaceAlias]
|
||||
}
|
||||
let query: Query
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// **THIS IS NOT PART OF THE MAIN APP - IT'S A COMMAND LINE UTILITY**
|
||||
|
||||
let count = CommandLine.arguments.count
|
||||
guard count > 1 else {
|
||||
abort()
|
||||
}
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let utility = WikipediaLanguageCommandLineUtility(path: path)
|
||||
utility.run {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
dispatchMain()
|
@ -0,0 +1,609 @@
|
||||
import Foundation
|
||||
|
||||
/// **THIS IS NOT PART OF THE MAIN APP - IT'S A COMMAND LINE UTILITY**
|
||||
|
||||
fileprivate var dictionaryRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "(?:[{][{])(:?[^{]*)(?:[}][}])", options: [])
|
||||
} catch {
|
||||
assertionFailure("Localization regex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
fileprivate var curlyBraceRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "(?:[{][{][a-z]+:)(:?[^{]*)(?:[}][}])", options: [.caseInsensitive])
|
||||
} catch {
|
||||
assertionFailure("Localization regex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
|
||||
fileprivate var twnTokenRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "(?:[$])(:?[0-9]+)", options: [])
|
||||
} catch {
|
||||
assertionFailure("Localization token regex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
fileprivate var iOSTokenRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "%([0-9]*)\\$?([@dDuUxXoOfeEgGcCsSpaAF])", options: [])
|
||||
} catch {
|
||||
assertionFailure("Localization token regex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
fileprivate var mwLocalizedStringRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "(?:WMLocalizedString\\(@\\\")(:?[^\"]+)(?:[^\\)]*\\))", options: [])
|
||||
} catch {
|
||||
assertionFailure("mwLocalizedStringRegex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
fileprivate var countPrefixRegex: NSRegularExpression? = {
|
||||
do {
|
||||
return try NSRegularExpression(pattern: "(:?^[^\\=]+)(?:=)", options: [])
|
||||
} catch {
|
||||
assertionFailure("countPrefixRegex failed to compile")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// lookup from translatewiki prefix to iOS-supported stringsdict key
|
||||
let keysByPrefix = [
|
||||
"0":"zero",
|
||||
// "1":"one" digits on translatewiki mean only use the translation when the replacement number === that digit. On iOS one, two, and few are more generic. for example, the translation for one could map to 1, 21, 31, etc
|
||||
// "2":"two",
|
||||
// "3":"few"
|
||||
"zero":"zero",
|
||||
"one":"one",
|
||||
"two":"two",
|
||||
"few":"few",
|
||||
"many":"many",
|
||||
"other":"other"
|
||||
]
|
||||
|
||||
extension String {
|
||||
var fullRange: NSRange {
|
||||
return NSRange(startIndex..<endIndex, in: self)
|
||||
}
|
||||
var escapedString: String {
|
||||
return self.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\n", with: "\\n")
|
||||
}
|
||||
|
||||
/* supportsOneEquals indicates that the language's singular translation on iOS is only valid for n=1. digits on translatewiki mean only use the translation when the replacement number === that digit. On iOS one, two, and few are more generic. for example, the translation for one could map to 1, 21, 31, etc. Only use 1= for one when the iOS definition matches the translatewiki definition for a given language. */
|
||||
func pluralDictionary(with keys: [String], tokens: [String: String], supportsOneEquals: Bool) -> NSDictionary? {
|
||||
// https://developer.apple.com/library/content/documentation/MacOSX/Conceptual/BPInternational/StringsdictFileFormat/StringsdictFileFormat.html#//apple_ref/doc/uid/10000171i-CH16-SW1
|
||||
guard let dictionaryRegex = dictionaryRegex else {
|
||||
return nil
|
||||
}
|
||||
var remainingKeys = keys
|
||||
let fullRange = self.fullRange
|
||||
let mutableDictionary = NSMutableDictionary(capacity: 5)
|
||||
let results = dictionaryRegex.matches(in: self, options: [], range: fullRange)
|
||||
let nsSelf = self as NSString
|
||||
|
||||
// format is the full string with the plural tokens replaced by variables
|
||||
// it will be built by enumerating through the matches for the plural regex
|
||||
var format = ""
|
||||
var location = 0
|
||||
for result in results {
|
||||
// append the next part of the string after the last match and before this one
|
||||
format += nsSelf.substring(with: NSRange(location: location, length: result.range.location - location)).iOSNativeLocalization(tokens: tokens)
|
||||
location = result.range.location + result.range.length
|
||||
|
||||
// get the contents of the match - the content between {{ and }}
|
||||
let contents = dictionaryRegex.replacementString(for: result, in: self, offset: 0, template: "$1")
|
||||
|
||||
let components = contents.components(separatedBy: "|")
|
||||
|
||||
let countOfComponents = components.count
|
||||
guard countOfComponents > 1 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let pluralPrefix = "PLURAL:"
|
||||
let firstComponent = components[0]
|
||||
guard firstComponent.hasPrefix(pluralPrefix) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if firstComponent == pluralPrefix {
|
||||
print("Incorrectly formatted plural: \(self)")
|
||||
abort()
|
||||
}
|
||||
|
||||
let token = firstComponent.suffix(from: firstComponent.index(firstComponent.startIndex, offsetBy: 7)).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
let nsToken = (token as NSString)
|
||||
let tokenNumber = nsToken.substring(from: 1)
|
||||
|
||||
guard
|
||||
let tokenInt = Int(tokenNumber),
|
||||
tokenInt > 0
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let keyDictionary = NSMutableDictionary(capacity: 5)
|
||||
let formatValueType = tokens[tokenNumber] ?? "d"
|
||||
keyDictionary["NSStringFormatSpecTypeKey"] = "NSStringPluralRuleType"
|
||||
keyDictionary["NSStringFormatValueTypeKey"] = formatValueType
|
||||
|
||||
guard let countPrefixRegex = countPrefixRegex else {
|
||||
abort()
|
||||
}
|
||||
|
||||
var unlabeledComponents: [String] = []
|
||||
for component in components[1..<countOfComponents] {
|
||||
var keyForComponent: String?
|
||||
var actualComponent: String? = component
|
||||
guard let match = countPrefixRegex.firstMatch(in: component, options: [], range: component.fullRange) else {
|
||||
if component.contains("=") {
|
||||
print("Unsupported prefix: \(String(describing: component))")
|
||||
abort()
|
||||
}
|
||||
unlabeledComponents.append(component)
|
||||
continue
|
||||
}
|
||||
|
||||
// Support for 0= 1= 2= zero= one= few= many=
|
||||
let numberString = countPrefixRegex.replacementString(for: match, in: component, offset: 0, template: "$1")
|
||||
if let key = (supportsOneEquals && (numberString == "1" || numberString == "one")) ? "one" : keysByPrefix[numberString] {
|
||||
keyForComponent = key
|
||||
remainingKeys = remainingKeys.filter({ (aKey) -> Bool in
|
||||
return key != aKey
|
||||
})
|
||||
actualComponent = String(component.suffix(from: component.index(component.startIndex, offsetBy: match.range.length)))
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("Translatewiki prefix \(numberString) not supported on iOS for this language. Ignoring \(String(describing: component))")
|
||||
#endif
|
||||
}
|
||||
|
||||
guard let keyToInsert = keyForComponent, let componentToInsert = actualComponent else {
|
||||
continue
|
||||
}
|
||||
|
||||
keyDictionary[keyToInsert] = componentToInsert.iOSNativeLocalization(tokens: tokens)
|
||||
}
|
||||
|
||||
if let other = unlabeledComponents.last {
|
||||
keyDictionary["other"] = other.iOSNativeLocalization(tokens: tokens)
|
||||
|
||||
for (keyIndex, component) in unlabeledComponents.enumerated() {
|
||||
guard
|
||||
keyIndex < unlabeledComponents.count - 1,
|
||||
keyIndex < remainingKeys.count
|
||||
else {
|
||||
break
|
||||
}
|
||||
keyDictionary[remainingKeys[keyIndex]] = component.iOSNativeLocalization(tokens: tokens)
|
||||
}
|
||||
} else if keyDictionary["other"] == nil {
|
||||
if keyDictionary["many"] != nil {
|
||||
keyDictionary["other"] = keyDictionary["many"]
|
||||
} else {
|
||||
print("missing default translation")
|
||||
abort()
|
||||
}
|
||||
}
|
||||
|
||||
// set the variable name for the plural replacement
|
||||
let key = "v\(tokenInt)"
|
||||
// include the dictionary of possible replacements for the plural token
|
||||
mutableDictionary[key] = keyDictionary
|
||||
// append the variable name to the format string where the plural token used to be
|
||||
format += "%#@\(key)@"
|
||||
}
|
||||
// append the final part of the string after the last match
|
||||
format += nsSelf.substring(with: NSRange(location: location, length: nsSelf.length - location)).iOSNativeLocalization(tokens: tokens)
|
||||
mutableDictionary["NSStringLocalizedFormatKey"] = format
|
||||
return mutableDictionary
|
||||
}
|
||||
|
||||
public func replacingMatches(fromRegex regex: NSRegularExpression, withFormat format: String) -> String {
|
||||
let nativeLocalization = NSMutableString(string: self)
|
||||
var offset = 0
|
||||
let fullRange = NSRange(location: 0, length: nativeLocalization.length)
|
||||
var index = 1
|
||||
regex.enumerateMatches(in: self, options: [], range: fullRange) { (result, flags, stop) in
|
||||
guard let result = result else {
|
||||
return
|
||||
}
|
||||
var token = regex.replacementString(for: result, in: nativeLocalization as String, offset: offset, template: "$1")
|
||||
// If the token doesn't have an index, give it one
|
||||
// This allows us to support unordered tokens for single token strings
|
||||
if token == "" {
|
||||
token = "\(index)"
|
||||
}
|
||||
let replacement = String(format: format, token)
|
||||
let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
|
||||
nativeLocalization.replaceCharacters(in: replacementRange, with: replacement)
|
||||
offset += (replacement as NSString).length - result.range.length
|
||||
index += 1
|
||||
}
|
||||
return nativeLocalization as String
|
||||
}
|
||||
|
||||
public func replacingMatches(fromTokenRegex regex: NSRegularExpression, withFormat format: String, tokens: [String: String]) -> String {
|
||||
let nativeLocalization = NSMutableString(string: self)
|
||||
var offset = 0
|
||||
let fullRange = NSRange(location: 0, length: nativeLocalization.length)
|
||||
regex.enumerateMatches(in: self, options: [], range: fullRange) { (result, flags, stop) in
|
||||
guard let result = result else {
|
||||
return
|
||||
}
|
||||
let token = regex.replacementString(for: result, in: nativeLocalization as String, offset: offset, template: "$1")
|
||||
let replacement = String(format: format, token, tokens[token] ?? "@")
|
||||
let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
|
||||
nativeLocalization.replaceCharacters(in: replacementRange, with: replacement)
|
||||
offset += (replacement as NSString).length - result.range.length
|
||||
}
|
||||
return nativeLocalization as String
|
||||
}
|
||||
|
||||
func iOSNativeLocalization(tokens: [String: String]) -> String {
|
||||
guard let tokenRegex = twnTokenRegex, let braceRegex = curlyBraceRegex else {
|
||||
return ""
|
||||
}
|
||||
return self.replacingMatches(fromRegex: braceRegex, withFormat: "%@").replacingMatches(fromTokenRegex: tokenRegex, withFormat: "%%%@$%@", tokens: tokens)
|
||||
}
|
||||
|
||||
var twnNativeLocalization: String {
|
||||
guard let tokenRegex = iOSTokenRegex else {
|
||||
return ""
|
||||
}
|
||||
return self.replacingMatches(fromRegex: tokenRegex, withFormat: "$%@")
|
||||
}
|
||||
|
||||
var iOSTokenDictionary: [String: String] {
|
||||
guard let iOSTokenRegex = iOSTokenRegex else {
|
||||
print("Unable to compile iOS token regex")
|
||||
abort()
|
||||
}
|
||||
var tokenDictionary = [String:String]()
|
||||
iOSTokenRegex.enumerateMatches(in: self, options: [], range:self.fullRange, using: { (result, flags, stop) in
|
||||
guard let result = result else {
|
||||
return
|
||||
}
|
||||
var number = iOSTokenRegex.replacementString(for: result, in: self as String, offset: 0, template: "$1")
|
||||
// treat an un-numbered token as 1
|
||||
if number == "" {
|
||||
number = "1"
|
||||
}
|
||||
let token = iOSTokenRegex.replacementString(for: result, in: self as String, offset: 0, template: "$2")
|
||||
if tokenDictionary[number] == nil {
|
||||
tokenDictionary[number] = token
|
||||
} else if token != tokenDictionary[number] {
|
||||
print("Internal token mismatch: \(self)")
|
||||
abort()
|
||||
}
|
||||
})
|
||||
return tokenDictionary
|
||||
}
|
||||
}
|
||||
|
||||
func writeStrings(fromDictionary dictionary: NSDictionary, toFile: String) throws {
|
||||
var shouldWrite = true
|
||||
|
||||
if let existingDictionary = NSDictionary(contentsOfFile: toFile) {
|
||||
shouldWrite = existingDictionary.count != dictionary.count
|
||||
if !shouldWrite {
|
||||
for (key, value) in dictionary {
|
||||
guard let value = value as? String, let existingValue = existingDictionary[key] as? NSString else {
|
||||
shouldWrite = true
|
||||
break
|
||||
}
|
||||
shouldWrite = !existingValue.isEqual(to: value)
|
||||
if shouldWrite {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard shouldWrite else {
|
||||
return
|
||||
}
|
||||
|
||||
let folder = (toFile as NSString).deletingLastPathComponent
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch { }
|
||||
let output = dictionary.descriptionInStringsFileFormat
|
||||
try output.write(toFile: toFile, atomically: true, encoding: .utf16) // From Apple: Note: It is recommended that you save strings files using the UTF-16 encoding, which is the default encoding for standard strings files. It is possible to create strings files using other property-list formats, including binary property-list formats and XML formats that use the UTF-8 encoding, but doing so is not recommended. For more information about Unicode and its text encodings, go to http://www.unicode.org/ or http://en.wikipedia.org/wiki/Unicode.
|
||||
}
|
||||
|
||||
// See "Localized Metadata" section here: https://docs.fastlane.tools/actions/deliver/
|
||||
func fileURLForFastlaneMetadataFolder(for locale: String) -> URL {
|
||||
return URL(fileURLWithPath:"\(path)/fastlane/metadata/\(locale)")
|
||||
}
|
||||
|
||||
func fileURLForFastlaneMetadataFile(_ file: String, for locale: String) -> URL {
|
||||
return fileURLForFastlaneMetadataFolder(for: locale).appendingPathComponent(file)
|
||||
}
|
||||
|
||||
let defaultAppStoreMetadataLocale = "en-us"
|
||||
func writeFastlaneMetadata(_ metadata: Any?, to filename: String, for locale: String) throws {
|
||||
let metadataFileURL = fileURLForFastlaneMetadataFile(filename, for: locale)
|
||||
guard let metadata = metadata as? String, metadata.count > 0 else {
|
||||
let defaultDescriptionFileURL = fileURLForFastlaneMetadataFile(filename, for: defaultAppStoreMetadataLocale)
|
||||
let fm = FileManager.default
|
||||
try fm.removeItem(at: metadataFileURL)
|
||||
try fm.copyItem(at: defaultDescriptionFileURL, to: metadataFileURL)
|
||||
return
|
||||
}
|
||||
try metadata.write(to: metadataFileURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
func writeTWNStrings(fromDictionary dictionary: [String: String], toFile: String, escaped: Bool) throws {
|
||||
var output = ""
|
||||
let sortedDictionary = dictionary.sorted(by: { (kv1, kv2) -> Bool in
|
||||
return kv1.key < kv2.key
|
||||
})
|
||||
for (key, value) in sortedDictionary {
|
||||
output.append("\"\(key)\" = \"\(escaped ? value.escapedString : value)\";\n")
|
||||
}
|
||||
|
||||
try output.write(toFile: toFile, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
func exportLocalizationsFromSourceCode(_ path: String) {
|
||||
let iOSENPath = "\(path)/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings"
|
||||
let twnQQQPath = "\(path)/Wikipedia/Localizations/qqq.lproj/Localizable.strings"
|
||||
let twnENPath = "\(path)/Wikipedia/Localizations/en.lproj/Localizable.strings"
|
||||
guard let iOSEN = NSDictionary(contentsOfFile: iOSENPath) else {
|
||||
print("Unable to read \(iOSENPath)")
|
||||
abort()
|
||||
}
|
||||
|
||||
let twnQQQ = NSMutableDictionary()
|
||||
let twnEN = NSMutableDictionary()
|
||||
|
||||
do {
|
||||
let commentSet = CharacterSet(charactersIn: "/* ")
|
||||
let quoteSet = CharacterSet(charactersIn: "\"")
|
||||
let string = try String(contentsOfFile: iOSENPath)
|
||||
let lines = string.components(separatedBy: .newlines)
|
||||
var currentComment: String?
|
||||
var currentKey: String?
|
||||
var commentsByKey = [String: String]()
|
||||
for line in lines {
|
||||
let cleanedLine = line.trimmingCharacters(in: .whitespaces)
|
||||
if cleanedLine.hasPrefix("/*") {
|
||||
currentComment = cleanedLine.trimmingCharacters(in: commentSet)
|
||||
currentKey = nil
|
||||
} else if currentComment != nil {
|
||||
let quotesRemoved = cleanedLine.trimmingCharacters(in: quoteSet)
|
||||
|
||||
if let range = quotesRemoved.range(of: "\" = \"") {
|
||||
currentKey = String(quotesRemoved.prefix(upTo: range.lowerBound))
|
||||
}
|
||||
}
|
||||
if let key = currentKey, let comment = currentComment {
|
||||
commentsByKey[key] = comment
|
||||
}
|
||||
}
|
||||
|
||||
for (key, comment) in commentsByKey {
|
||||
twnQQQ[key] = comment.twnNativeLocalization
|
||||
}
|
||||
try writeTWNStrings(fromDictionary: twnQQQ as! [String: String], toFile: twnQQQPath, escaped: false)
|
||||
|
||||
for (key, value) in iOSEN {
|
||||
guard let value = value as? String, let key = key as? String else {
|
||||
continue
|
||||
}
|
||||
twnEN[key] = value.twnNativeLocalization
|
||||
}
|
||||
try writeTWNStrings(fromDictionary: twnEN as! [String: String], toFile: twnENPath, escaped: true)
|
||||
} catch let error {
|
||||
print("Error exporting localizations: \(error)")
|
||||
abort()
|
||||
}
|
||||
}
|
||||
|
||||
let locales: Set<String> = {
|
||||
var identifiers = Locale.availableIdentifiers
|
||||
if let filenames = try? FileManager.default.contentsOfDirectory(atPath: "\(path)/Wikipedia/iOS Native Localizations") {
|
||||
let additional = filenames.compactMap { $0.components(separatedBy: ".").first?.lowercased() }
|
||||
identifiers += additional
|
||||
}
|
||||
identifiers += ["ku"] // iOS 13 added support for ku but macOS 10.14 doesn't include it, add it manually. This line can be removed when macOS 10.15 ships.
|
||||
return Set<String>(identifiers)
|
||||
}()
|
||||
|
||||
// See supportsOneEquals documentation. Utilized this list: https://unicode-org.github.io/cldr-staging/charts/37/supplemental/language_plural_rules.html to verify languages where that applies for the cardinal -> one rule
|
||||
let localesWhereMediaWikiPluralRulesDoNotMatchiOSPluralRulesForOne = {
|
||||
return Set<String>(["be", "bs", "br", "ceb", "tzm", "hr", "fil", "is", "lv", "lt", "dsb", "mk", "gv", "prg", "ru", "gd", "sr", "sl", "uk", "hsb"]).intersection(locales)
|
||||
}()
|
||||
|
||||
func localeIsAvailable(_ locale: String) -> Bool {
|
||||
let prefix = locale.components(separatedBy: "-").first ?? locale
|
||||
return locales.contains(prefix)
|
||||
}
|
||||
|
||||
func importLocalizationsFromTWN(_ path: String) {
|
||||
let enPath = "\(path)/Wikipedia/iOS Native Localizations/en.lproj/Localizable.strings"
|
||||
|
||||
guard let enDictionary = NSDictionary(contentsOfFile: enPath) as? [String: String] else {
|
||||
print("Unable to read \(enPath)")
|
||||
abort()
|
||||
}
|
||||
|
||||
var enTokensByKey = [String: [String: String]]()
|
||||
|
||||
for (key, value) in enDictionary {
|
||||
enTokensByKey[key] = value.iOSTokenDictionary
|
||||
}
|
||||
|
||||
let fm = FileManager.default
|
||||
do {
|
||||
let keysByLanguage = ["pl": ["one", "few"], "sr": ["one", "few", "many"], "ru": ["one", "few", "many"]]
|
||||
let defaultKeys = ["one"]
|
||||
let appStoreMetadataLocales: [String: [String]] = [
|
||||
"da": ["da"],
|
||||
"de": ["de-de"],
|
||||
"el": ["el"],
|
||||
// "en": ["en-au", "en-ca", "en-gb"],
|
||||
"es": ["es-mx", "es-es"],
|
||||
"fi": ["fi"],
|
||||
"fr": ["fr-ca", "fr-fr"],
|
||||
"id": ["id"],
|
||||
"it": ["it"],
|
||||
"ja": ["ja"],
|
||||
"ko": ["ko"],
|
||||
"ms": ["ms"],
|
||||
"nl": ["nl-nl"],
|
||||
"no": ["no"],
|
||||
"pt": ["pt-br", "pt-pt"],
|
||||
"ru": ["ru"],
|
||||
"sv": ["sv"],
|
||||
"th": ["th"],
|
||||
"tr": ["tr"],
|
||||
"vi": ["vi"],
|
||||
"zh-hans": ["zh-hans"],
|
||||
"zh-hant": ["zh-hant"]
|
||||
]
|
||||
|
||||
let contents = try fm.contentsOfDirectory(atPath: "\(path)/Wikipedia/Localizations")
|
||||
var pathsForEnglishPlurals: [String] = [] // write english plurals to these paths as placeholders
|
||||
var englishPluralDictionary: NSMutableDictionary?
|
||||
for filename in contents {
|
||||
guard let locale = filename.components(separatedBy: ".").first?.lowercased() else {
|
||||
continue
|
||||
}
|
||||
|
||||
let localeFolder = "\(path)/Wikipedia/iOS Native Localizations/\(locale).lproj"
|
||||
|
||||
guard localeIsAvailable(locale), let twnStrings = NSDictionary(contentsOfFile: "\(path)/Wikipedia/Localizations/\(locale).lproj/Localizable.strings") else {
|
||||
try? fm.removeItem(atPath: localeFolder)
|
||||
continue
|
||||
}
|
||||
|
||||
let stringsDictFilePath = "\(localeFolder)/Localizable.stringsdict"
|
||||
let stringsFilePath = "\(localeFolder)/Localizable.strings"
|
||||
|
||||
let stringsDict = NSMutableDictionary(capacity: twnStrings.count)
|
||||
let strings = NSMutableDictionary(capacity: twnStrings.count)
|
||||
for (key, value) in twnStrings {
|
||||
guard let twnString = value as? String, let key = key as? String, let enTokens = enTokensByKey[key] else {
|
||||
continue
|
||||
}
|
||||
let nativeLocalization = twnString.iOSNativeLocalization(tokens: enTokens)
|
||||
let nativeLocalizationTokens = nativeLocalization.iOSTokenDictionary
|
||||
guard nativeLocalizationTokens == enTokens else {
|
||||
#if DEBUG
|
||||
print("Mismatched tokens in \(locale) for \(key):\n\(enDictionary[key] ?? "")\n\(nativeLocalization)")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
if twnString.contains("{{PLURAL:") {
|
||||
let lang = locale.components(separatedBy: "-").first ?? ""
|
||||
let keys = keysByLanguage[lang] ?? defaultKeys
|
||||
let supportsOneEquals = !localesWhereMediaWikiPluralRulesDoNotMatchiOSPluralRulesForOne.contains(lang)
|
||||
stringsDict[key] = twnString.pluralDictionary(with: keys, tokens:enTokens, supportsOneEquals: supportsOneEquals)
|
||||
strings[key] = nativeLocalization
|
||||
} else {
|
||||
strings[key] = nativeLocalization
|
||||
}
|
||||
}
|
||||
if locale != "en" { // only write the english plurals, skip the main file
|
||||
if strings.count > 0 {
|
||||
try writeStrings(fromDictionary: strings, toFile: stringsFilePath)
|
||||
} else {
|
||||
try? fm.removeItem(atPath: stringsFilePath)
|
||||
}
|
||||
} else {
|
||||
englishPluralDictionary = stringsDict
|
||||
}
|
||||
|
||||
|
||||
if let metadataLocales = appStoreMetadataLocales[locale] {
|
||||
for metadataLocale in metadataLocales {
|
||||
let folderURL = fileURLForFastlaneMetadataFolder(for: metadataLocale)
|
||||
try fm.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let infoPlistPath = "\(path)/Wikipedia/iOS Native Localizations/\(locale).lproj/InfoPlist.strings"
|
||||
let infoPlist = NSDictionary(contentsOfFile: infoPlistPath)
|
||||
|
||||
try? writeFastlaneMetadata(strings["app-store-short-description"], to: "description.txt", for: metadataLocale)
|
||||
try? writeFastlaneMetadata(strings["app-store-keywords"], to: "keywords.txt", for: metadataLocale)
|
||||
try? writeFastlaneMetadata(nil, to: "marketing_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
|
||||
try? writeFastlaneMetadata(infoPlist?["CFBundleDisplayName"], to: "name.txt", for: metadataLocale)
|
||||
try? writeFastlaneMetadata(nil, to: "privacy_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
|
||||
try? writeFastlaneMetadata(nil, to: "promotional_text.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
|
||||
try? writeFastlaneMetadata(nil, to: "release_notes.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
|
||||
try? writeFastlaneMetadata(strings["app-store-subtitle"], to: "subtitle.txt", for: metadataLocale)
|
||||
try? writeFastlaneMetadata(nil, to: "support_url.txt", for: metadataLocale) // use nil to copy from en-US. all fields need to be specified.
|
||||
}
|
||||
|
||||
} else {
|
||||
let folderURL = fileURLForFastlaneMetadataFolder(for: locale)
|
||||
try? fm.removeItem(at: folderURL)
|
||||
}
|
||||
|
||||
if stringsDict.count > 0 {
|
||||
stringsDict.write(toFile: stringsDictFilePath, atomically: true)
|
||||
} else {
|
||||
pathsForEnglishPlurals.append(stringsDictFilePath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for stringsDictFilePath in pathsForEnglishPlurals {
|
||||
englishPluralDictionary?.write(toFile: stringsDictFilePath, atomically: true)
|
||||
}
|
||||
|
||||
} catch let error {
|
||||
print("Error importing localizations: \(error)")
|
||||
abort()
|
||||
}
|
||||
}
|
||||
|
||||
// Code that updated source translations
|
||||
// var replacements = [String: String]()
|
||||
// for (key, comment) in qqq {
|
||||
// guard let value = en[key] else {
|
||||
// continue
|
||||
// }
|
||||
// replacements[key] = "WMFLocalizedStringWithDefaultValue(@\"\(key.escapedString)\", nil, NSBundle.mainBundle, @\"\(value.iOSNativeLocalization.escapedString)\", \"\(comment.escapedString)\")"
|
||||
// }
|
||||
//
|
||||
// let codePath = "WMF Framework"
|
||||
// let contents = try FileManager.default.contentsOfDirectory(atPath: codePath)
|
||||
// guard let mwLocalizedStringRegex = mwLocalizedStringRegex else {
|
||||
// abort()
|
||||
// }
|
||||
// for filename in contents {
|
||||
// do {
|
||||
// let path = codePath + "/" + filename
|
||||
// let string = try String(contentsOfFile: path)
|
||||
// //let string = try String(contentsOf: #fileLiteral(resourceName: "WMFContentGroup+WMFFeedContentDisplaying.m"))
|
||||
// let mutableString = NSMutableString(string: string)
|
||||
// var offset = 0
|
||||
// let fullRange = NSRange(location: 0, length: mutableString.length)
|
||||
// mwLocalizedStringRegex.enumerateMatches(in: string, options: [], range: fullRange) { (result, flags, stop) in
|
||||
// guard let result = result else {
|
||||
// return
|
||||
// }
|
||||
// let key = mwLocalizedStringRegex.replacementString(for: result, in: mutableString as String, offset: offset, template: "$1")
|
||||
// guard let replacement = replacements[key] else {
|
||||
// return
|
||||
// }
|
||||
// let replacementRange = NSRange(location: result.range.location + offset, length: result.range.length)
|
||||
// mutableString.replaceCharacters(in: replacementRange, with: replacement)
|
||||
// offset += (replacement as NSString).length - replacementRange.length
|
||||
// }
|
||||
// try mutableString.write(toFile: path, atomically: true, encoding: String.Encoding.utf8.rawValue)
|
||||
// } catch { }
|
||||
// }
|
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// **THIS IS A COMMAND LINE UTILITY FOR LOCALIZATION**
|
||||
let count = CommandLine.arguments.count
|
||||
guard count > 1 else {
|
||||
print("Please provide a path argument.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
|
||||
print("Extracting localizations from source code...")
|
||||
let extractProcess = Process.launchedProcess(launchPath: "\(path)/scripts/localization_extract", arguments: [path])
|
||||
extractProcess.waitUntilExit()
|
||||
|
||||
if extractProcess.terminationStatus == 0 {
|
||||
print("Localizations extracted successfully.")
|
||||
print("Exporting localizations from source code...")
|
||||
exportLocalizationsFromSourceCode(path)
|
||||
print("Importing localizations from TWN...")
|
||||
importLocalizationsFromTWN(path)
|
||||
print("Localizations imported successfully.")
|
||||
} else {
|
||||
print("Failed to extract localizations from source code.")
|
||||
exit(1)
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
/* Localized versions of Info.plist keys */
|
||||
|
||||
"CFBundleDisplayName" = "Wikipedia continue reading";
|
||||
"CFBundleName" = "Wikipedia continue reading";
|
@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="M4Y-Lb-cyx">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
<capability name="Constraints to layout margins" minToolsVersion="6.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Today Continue Reading Widget View Controller-->
|
||||
<scene sceneID="cwh-vc-ff4">
|
||||
<objects>
|
||||
<viewController id="M4Y-Lb-cyx" customClass="WMFTodayContinueReadingWidgetViewController" customModule="ContinueReadingWidget" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ft6-oW-KC0"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="FKl-LY-JtV"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" simulatedAppContext="notificationCenter" id="S3S-Oj-5AN" customClass="GroupedAccessibilityView" customModule="WMF">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="af3-Uc-bMS">
|
||||
<rect key="frame" x="219" y="7" width="86" height="86"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="86" id="Nz9-5s-phP"/>
|
||||
<constraint firstAttribute="width" constant="86" id="tPS-cg-OC1"/>
|
||||
</constraints>
|
||||
<userDefinedRuntimeAttributes>
|
||||
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
|
||||
<integer key="value" value="6"/>
|
||||
</userDefinedRuntimeAttribute>
|
||||
</userDefinedRuntimeAttributes>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vq4-5M-Tnk">
|
||||
<rect key="frame" x="16" y="10" width="195" height="20.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CwR-CM-SDN">
|
||||
<rect key="frame" x="16" y="30.5" width="195" height="26"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="750" constant="26" id="HZt-Qf-DQT"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view hidden="YES" opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="3Uu-vI-59e">
|
||||
<rect key="frame" x="16" y="70" width="15.5" height="20"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pDO-M3-3su">
|
||||
<rect key="frame" x="6" y="0.0" width="3.5" height="20"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
|
||||
<color key="textColor" red="0.33333333333333331" green="0.3529411764705882" blue="0.37254901960784315" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="0.40000000000000002" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="20" id="5UE-Nc-zWc"/>
|
||||
<constraint firstItem="pDO-M3-3su" firstAttribute="leading" secondItem="3Uu-vI-59e" secondAttribute="leading" constant="6" id="O6t-YJ-eax"/>
|
||||
<constraint firstItem="pDO-M3-3su" firstAttribute="top" secondItem="3Uu-vI-59e" secondAttribute="top" id="WId-7B-KqN"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pDO-M3-3su" secondAttribute="trailing" constant="6" id="dZE-Nv-NeO"/>
|
||||
<constraint firstAttribute="bottom" secondItem="pDO-M3-3su" secondAttribute="bottom" id="hev-Z5-2XR"/>
|
||||
</constraints>
|
||||
<userDefinedRuntimeAttributes>
|
||||
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
|
||||
<integer key="value" value="4"/>
|
||||
</userDefinedRuntimeAttribute>
|
||||
</userDefinedRuntimeAttributes>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ZR6-x7-9Of" customClass="GroupedAccessibilityView" customModule="WMF">
|
||||
<rect key="frame" x="16" y="20.5" width="288" height="59"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Explore Wikipedia for more articles to read" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rMd-L0-hne">
|
||||
<rect key="frame" x="0.0" y="21" width="288" height="38"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="21" id="bOM-vw-pko"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.69999999999999996" colorSpace="calibratedRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No recently read articles" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ejf-xt-rvu">
|
||||
<rect key="frame" x="0.0" y="0.0" width="288" height="21"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="21" id="Vl6-d1-Ot9"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
|
||||
<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="0.69999999999999996" colorSpace="calibratedRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="Ejf-xt-rvu" firstAttribute="top" secondItem="ZR6-x7-9Of" secondAttribute="top" id="55Z-x7-Jh9"/>
|
||||
<constraint firstItem="rMd-L0-hne" firstAttribute="top" secondItem="Ejf-xt-rvu" secondAttribute="bottom" id="5Hr-pE-8jt"/>
|
||||
<constraint firstItem="rMd-L0-hne" firstAttribute="leading" secondItem="ZR6-x7-9Of" secondAttribute="leading" id="CJI-7Y-hKi"/>
|
||||
<constraint firstItem="rMd-L0-hne" firstAttribute="top" secondItem="Ejf-xt-rvu" secondAttribute="bottom" id="GKz-jD-5B0"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Ejf-xt-rvu" secondAttribute="trailing" id="Mne-lj-W93"/>
|
||||
<constraint firstAttribute="trailing" secondItem="rMd-L0-hne" secondAttribute="trailing" id="OAh-PP-FYs"/>
|
||||
<constraint firstAttribute="bottom" secondItem="rMd-L0-hne" secondAttribute="bottom" id="fpD-K2-V7D"/>
|
||||
<constraint firstItem="Ejf-xt-rvu" firstAttribute="leading" secondItem="ZR6-x7-9Of" secondAttribute="leading" id="j4t-9z-J3E"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="accessibilityView0" destination="rMd-L0-hne" id="Cof-pV-laR"/>
|
||||
<outlet property="accessibilityView1" destination="Ejf-xt-rvu" id="est-B3-rBt"/>
|
||||
</connections>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="vq4-5M-Tnk" firstAttribute="top" secondItem="Ft6-oW-KC0" secondAttribute="bottom" constant="10" id="2e4-f1-47H"/>
|
||||
<constraint firstItem="3Uu-vI-59e" firstAttribute="top" relation="greaterThanOrEqual" secondItem="CwR-CM-SDN" secondAttribute="bottom" constant="8" id="4Lb-rh-iGP"/>
|
||||
<constraint firstItem="ZR6-x7-9Of" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="65O-ga-KaL"/>
|
||||
<constraint firstItem="ZR6-x7-9Of" firstAttribute="centerY" secondItem="S3S-Oj-5AN" secondAttribute="centerY" id="BQN-qh-xVn"/>
|
||||
<constraint firstItem="FKl-LY-JtV" firstAttribute="top" secondItem="3Uu-vI-59e" secondAttribute="bottom" constant="10" id="V4i-bE-94u"/>
|
||||
<constraint firstItem="CwR-CM-SDN" firstAttribute="top" secondItem="vq4-5M-Tnk" secondAttribute="bottom" id="YIo-3p-DKd"/>
|
||||
<constraint firstItem="CwR-CM-SDN" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="Yqs-Mz-SgU"/>
|
||||
<constraint firstItem="3Uu-vI-59e" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="YxV-va-6jY"/>
|
||||
<constraint firstItem="CwR-CM-SDN" firstAttribute="width" secondItem="vq4-5M-Tnk" secondAttribute="width" id="dlu-4c-Js5"/>
|
||||
<constraint firstAttribute="trailing" secondItem="af3-Uc-bMS" secondAttribute="trailing" constant="15" id="mWN-ky-uf3"/>
|
||||
<constraint firstItem="vq4-5M-Tnk" firstAttribute="leading" secondItem="S3S-Oj-5AN" secondAttribute="leadingMargin" id="n9q-aN-sgE"/>
|
||||
<constraint firstItem="ZR6-x7-9Of" firstAttribute="trailing" secondItem="S3S-Oj-5AN" secondAttribute="trailingMargin" id="pFm-6j-Ldv"/>
|
||||
<constraint firstItem="af3-Uc-bMS" firstAttribute="leading" secondItem="vq4-5M-Tnk" secondAttribute="trailing" constant="8" id="pVG-3w-TAN"/>
|
||||
<constraint firstItem="af3-Uc-bMS" firstAttribute="centerY" secondItem="S3S-Oj-5AN" secondAttribute="centerY" id="raJ-27-DZf"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="accessibilityView0" destination="vq4-5M-Tnk" id="bMV-lJ-rl5"/>
|
||||
<outlet property="accessibilityView1" destination="CwR-CM-SDN" id="qPB-vx-063"/>
|
||||
<outlet property="accessibilityView2" destination="pDO-M3-3su" id="JSB-jk-er5"/>
|
||||
</connections>
|
||||
</view>
|
||||
<extendedEdge key="edgesForExtendedLayout"/>
|
||||
<nil key="simulatedStatusBarMetrics"/>
|
||||
<nil key="simulatedTopBarMetrics"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<size key="freeformSize" width="320" height="100"/>
|
||||
<connections>
|
||||
<outlet property="daysAgoLabel" destination="pDO-M3-3su" id="eFO-2c-rzZ"/>
|
||||
<outlet property="daysAgoView" destination="3Uu-vI-59e" id="7LE-mR-ffp"/>
|
||||
<outlet property="emptyDescriptionLabel" destination="rMd-L0-hne" id="3kP-dD-tib"/>
|
||||
<outlet property="emptyTitleLabel" destination="Ejf-xt-rvu" id="osb-La-67R"/>
|
||||
<outlet property="emptyView" destination="ZR6-x7-9Of" id="p6Z-LW-3nv"/>
|
||||
<outlet property="imageView" destination="af3-Uc-bMS" id="91S-oM-RCp"/>
|
||||
<outlet property="imageWidthConstraint" destination="tPS-cg-OC1" id="Kg2-hi-pfg"/>
|
||||
<outlet property="textLabel" destination="CwR-CM-SDN" id="wXQ-Lk-zcc"/>
|
||||
<outlet property="titleLabel" destination="vq4-5M-Tnk" id="zJ9-bC-g8G"/>
|
||||
<outlet property="titleLabelTrailingConstraint" destination="pVG-3w-TAN" id="Ap3-Lq-h0z"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="vXp-U4-Rya" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
<view hidden="YES" contentMode="scaleToFill" id="5yZ-3S-P5X">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
</view>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="97.599999999999994" y="36.881559220389811"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user