Compare commits

...

14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#6
2023-04-11 00:41:07 +00:00
4210df9eb6 [Libraries] Persistence (#5)
This PR contains the work that implements the Persistence service, which is used to store and serve the data of the application.

To give further details on what was done:
- [x] created the `Persistence`library into the **Libraries** package;
- [x] defined the `Location` model into the **Model** core data model;
- [x] implemented the `PersistenceService` service;
- [x] removed the core data stack boilerplate code from the `AppDelegate` and `SceneDelegate` delegates in the *Locations* target.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#5
2023-04-10 22:59:58 +00:00
6da2e946ce [Libraries] Locations (#4)
This PR contains the work that implements the Locations service, which is used to retrieve location data from a remote server.

To give further details on what was done:
- [x] created the `APICore` and `Locations` libraries into the **Libraries** package;
- [x] defined the `Endpoint` and `Client` protocols;
- [x] implemented the `MakeURLRequestUseCase` use case;
- [x] implemented the `MockURLProtocol` protocol and the `MockURLResponse` models;
- [x] implemented the `Location` model;
- [x] implemented the `GetLocationsEndpoint` endpoint;
- [x] implemented the `LocationsClient` client;
- [x] implemented the `LocationsService` service.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#4
2023-04-10 15:31:22 +00:00
14e39a40ae [Setup] Locations app (#3)
This PR contains the work done to setup the Locations app and lay the basis to start working on it.

To give further details on what was done:
- [x] defined the app icon asset;
- [x] defined the color and image assets to use on the **Launch** screen;
- [x] implemented the **Launch** screen on storyboard due to the issues encountered while trying to define the launch screen directly from the `Info.plist`;
- [x] removed the **Main** storyboard from the `Locations` target;
- [x] implemented the manual loading of the `ViewController` view controller from the `SceneDelegate` delegate;
- [x] fixed the product bundle identifier of the `Locations` target.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#3
2023-04-09 18:47:10 +00:00
6d76ba467c [Feature] Enhance the places deep linking support in the Wikipedia app to support coordinates (#2)
This PR contains the work done to add support to the **Wikipedia** app to show a custom location in the map of the *Places* view coming from a places deep link.

To provide further details about the work:
- [x] implemented the handling of location coordinates coming from a URL inside the function that generates a `NSUSerActivity` instance out of *places* deep links;
- [x] added the `wmf_locationFromURL` property to the `NSUSerActivity` via an extension that returns a `CLLocation` instance with latitude and longitude coordinates if provided;
- [x] implemented the "centerMap(onLocation:)" function in the `PlacesViewController` view controller that, essentially, center the map of this view controller to a given location;
- [x] improved the handling of a *places* user activity in the `WMFAppViewController` view controller to center the map in case a location has been provided via a deep link;
- [x] fixed some duplicated code that was blocking the test from compiling.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#2
2023-04-09 16:27:53 +00:00
9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00
4199 changed files with 412489 additions and 81 deletions

124
.gitignore vendored
View File

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

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "APICoreTests"
BuildableName = "APICoreTests"
BlueprintName = "APICoreTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CoreTests"
BuildableName = "CoreTests"
BlueprintName = "CoreTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DependencyTests"
BuildableName = "DependencyTests"
BlueprintName = "DependencyTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Libraries"
BuildableName = "Libraries"
BlueprintName = "Libraries"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Libraries"
BuildableName = "Libraries"
BlueprintName = "Libraries"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PersistenceTests"
BuildableName = "PersistenceTests"
BlueprintName = "PersistenceTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "RemoteTests"
BuildableName = "RemoteTests"
BlueprintName = "RemoteTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
//
// Application.swift
// Core
//
// Created by Javier Cicchelli on 13/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
import UIKit
public protocol Application {
// MARK: Functions
func canOpenURL(_ url: URL) -> Bool
func open(
_ url: URL,
options: [UIApplication.OpenExternalURLOptionsKey : Any],
completionHandler completion: ((Bool) -> Void)?
)
}

View File

@ -0,0 +1,76 @@
//
// Coordinator.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
/// This protocol organize the flow logic between view controllers in the app.
public protocol Coordinator: AnyObject {
// MARK: Properties
/// The child coordinators that are being currently presented.
var children: [Coordinator] { get set }
/// The router that handles how the view controllers in the coordinators will be shown or dismissed.
var router: Router { get }
// MARK: Functions
/// Present the coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
/// - Parameters:
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
func present(animated: Bool, onDismiss: Router.OnDismissedClosure?)
}
// MARK: - Coordinator+Implementations
public extension Coordinator {
/// Present a child coordinator animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
/// - Parameters:
/// - child: A child coordinator to be presented.
/// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
/// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed.
func present(
child: Coordinator,
animated: Bool,
onDismiss: Router.OnDismissedClosure? = nil
) {
store(child)
child.present(animated: animated) { [weak self, weak child] in
guard let self, let child else {
return
}
self.free(child)
onDismiss?()
}
}
/// Dismiss the coordinator animatedly or not, dependencing on the given `animated` parameter.
/// - Parameter animated: A boolean that represents whether the coordinator should be dismissed animatedly or not.
func dismiss(animated: Bool) {
router.dismiss(animated: animated)
}
}
// MARK: - Helpers
private extension Coordinator {
// MARK: Functions
func store(_ coordinator: Coordinator) {
children.append(coordinator)
}
func free(_ coordinator: Coordinator) {
children = children.filter { $0 !== coordinator }
}
}

View File

@ -0,0 +1,55 @@
//
// Router.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
/// This protocol defines how view controllers will be shown and dismissed.
public protocol Router: AnyObject {
// MARK: Typealiases
typealias OnDismissedClosure = () -> Void
// MARK: Functions
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter, and also pass a closure that should be called on dismissal.
/// - Parameters:
/// - viewController: A `UIViewController` view controller instance to present.
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
/// - onDismiss: A closure to be called or executed when the presented view controller is dismissed.
func present(
_ viewController: UIViewController,
animated: Bool,
onDismiss: OnDismissedClosure?
)
/// Dismiss a view controller animatedly or not, dependencing on the given `animated` parameter.
/// - Parameter animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
func dismiss(animated: Bool)
}
// MARK: - Router+Implementations
public extension Router {
// MARK: Functions
/// Present a view controller animatedly or not, dependencing on the given `animated` parameter.
/// - Parameters:
/// - viewController: A `UIViewController` view controller instance to present.
/// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not.
func present(_ viewController: UIViewController, animated: Bool) {
present(
viewController,
animated: animated,
onDismiss: nil
)
}
}

View File

@ -0,0 +1,68 @@
//
// BaseNavigationRouter.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
/// This is a base class for the `NavigationRouter` concrete router implementations.
public class BaseNavigationRouter: NSObject {
// MARK: Properties
/// A navigation controller to use within this concrete router.
var navigationController: UINavigationController
/// Dictionary that persist `onDismiss` closure for its respective view controllers until one of the later is dismissed.
var onDismissForViewController: [UIViewController: Router.OnDismissedClosure] = [:]
// MARK: Initialisers
/// Initialise this router.
/// - Parameter navigationController: A `UINavigationController` navigation controller instance to use in this router.
init(navigationController: UINavigationController) {
self.navigationController = navigationController
super.init()
self.navigationController.delegate = self
}
// MARK: Functions
/// Executes the `onDismiss` closure for a given view controller.
/// - Parameter viewController: A `UIViewController` view controller instance for which the on dismiss closure will be executed.
func performOnDismissed(for viewController: UIViewController) {
guard let onDismiss = onDismissForViewController[viewController] else {
return
}
onDismiss()
onDismissForViewController[viewController] = nil
}
}
// MARK: - UINavigationControllerDelegate
extension BaseNavigationRouter: UINavigationControllerDelegate {
// MARK: Functions
public func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
guard let dismissedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
return
}
performOnDismissed(for: dismissedViewController)
}
}

View File

@ -0,0 +1,85 @@
//
// ModalNavigationRouter.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
public class ModalNavigationRouter: BaseNavigationRouter {
// MARK: Properties
/// The parent view controller from where this router is being called from.
public unowned let parentViewController: UIViewController
// MARK: Initialisers
/// Initialise this router.
/// - Parameter parentViewController: A `UIViewController` view controller instance from where this router is originated.
public init(parentViewController: UIViewController) {
self.parentViewController = parentViewController
super.init(navigationController: .init())
}
}
extension ModalNavigationRouter: Router {
public func present(
_ viewController: UIViewController,
animated: Bool,
onDismiss: OnDismissedClosure?
) {
onDismissForViewController[viewController] = onDismiss
if navigationController.viewControllers.isEmpty {
presentModally(viewController, animated: animated)
} else {
navigationController.pushViewController(viewController, animated: animated)
}
}
public func dismiss(animated: Bool) {
guard let firstViewController = navigationController.viewControllers.first else {
return
}
performOnDismissed(for: firstViewController)
parentViewController.dismiss(animated: animated)
}
}
// MARK: - Helpers
private extension ModalNavigationRouter {
// MARK: Functions
func presentModally(_ viewController: UIViewController, animated: Bool) {
viewController.navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Cancel",
style: .plain,
target: self,
action: #selector(onCancelPressed)
)
navigationController.setViewControllers([viewController], animated: false)
parentViewController.present(navigationController, animated: animated)
}
@objc func onCancelPressed() {
guard let firstViewController = navigationController.viewControllers.first else {
return
}
performOnDismissed(for: firstViewController)
dismiss(animated: true)
}
}

View File

@ -0,0 +1,64 @@
//
// PushNavigationRouter.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
/// This class is responsible for presenting view controllers, as it is a concrete implementation of the `Router` protocol, but it won't know what view controller or which view controller is next.
public class PushNavigationRouter: BaseNavigationRouter {
// MARK: Properties
/// A root view controller coming in from the navigation controller, if any.
private let rootViewController: UIViewController?
// MARK: Initialisers
/// Initialise this router.
/// - Parameters:
/// - navigationController: A `UINavigationController` navigation controller instance to use in this router.
/// - rootViewController: A `UIViewController` view controller instance to define as a root view controller of the navigation controller.
/// - Note This initialiser added the `rootViewController` parameter although it is not really needed to differentiate itself from the `.init(navigationController:)` implemented for the `BaseNavigationRouter` base class.
public init(
navigationController: UINavigationController,
rootViewController: UIViewController? = nil
) {
self.rootViewController = navigationController.viewControllers.first ?? rootViewController
super.init(navigationController: navigationController)
}
}
// MARK: - Router
extension PushNavigationRouter: Router {
// MARK: Functions
public func present(
_ viewController: UIViewController,
animated: Bool,
onDismiss: OnDismissedClosure?
) {
onDismissForViewController[viewController] = onDismiss
navigationController.pushViewController(viewController, animated: animated)
}
public func dismiss(animated: Bool) {
guard let rootViewController else {
navigationController.popViewController(animated: animated)
return
}
performOnDismissed(for: rootViewController)
navigationController.popToViewController(rootViewController, animated: animated)
}
}

View File

@ -0,0 +1,43 @@
//
// WindowRouter.swift
// Core
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
/// This class is responsible for populating the window of an application.
public class WindowRouter: Router {
// MARK: Properties
/// The window to set manually with a `UIViewController` view controller instance.
private let window: UIWindow?
// MARK: Initialisers
/// Initialise this router.
/// - Parameter window: A `UIWindow` window instance to be set manually.
public init(window: UIWindow?) {
self.window = window
}
// MARK: Functions
public func present(
_ viewController: UIViewController,
animated: Bool,
onDismiss: OnDismissedClosure?
) {
window?.rootViewController = viewController
window?.makeKeyAndVisible()
}
public func dismiss(animated: Bool) {
// Nothing to do here...
}
}

View File

@ -0,0 +1,31 @@
//
// Dependency.swift
// Dependency
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
/// This property wrapper provides a direct connection to the `DependencyService` service.
@propertyWrapper
public struct Dependency<D> {
// MARK: Properties
private let keyPath: WritableKeyPath<DependencyService, D>
/// This property allows direct read/write access to a defined dependency attached to a selected key path.
public var wrappedValue: D {
get { DependencyService[keyPath] }
set { DependencyService[keyPath] = newValue }
}
// MARK: Initialisers
/// Initialise the property wrapper by setting a key path to a defined dependency.
/// - Parameter keyPath: A key path to a defined dependency in the `DependencyService` service.
public init(_ keyPath: WritableKeyPath<DependencyService, D>) {
self.keyPath = keyPath
}
}

View File

@ -0,0 +1,22 @@
//
// DependencyKey.swift
// Dependency
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
/// This protocol defines a key to use in the dependency service.
public protocol DependencyKey {
// MARK: Associated types
/// The associated type representing the type of the dependency key's value.
associatedtype Value
// MARK: Properties
/// The default value for the dependency key.
static var currentValue: Value { get set }
}

View File

@ -0,0 +1,28 @@
//
// DependencyService.swift
// Dependency
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
/// This service provide write/read access to the injected dependencies.
public struct DependencyService {
// MARK: Properties
private static var current = DependencyService()
// MARK: Subscripts
public static subscript<DK: DependencyKey>(key: DK.Type) -> DK.Value {
get { key.currentValue }
set { key.currentValue = newValue }
}
public static subscript<D>(_ keyPath: WritableKeyPath<DependencyService, D>) -> D {
get { current[keyPath: keyPath] }
set { current[keyPath: keyPath] = newValue }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
//
// Service.swift
// Persistence
//
// Created by Javier Cicchelli on 13/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import CoreData
public protocol Service {
// MARK: Properties
/// The main managed object context.
var viewContext: NSManagedObjectContext { get }
// MARK: Functions
/// Create a private queue context.
/// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use.
func makeTaskContext() -> NSManagedObjectContext
/// Create a child context of the view context.
/// - Returns: A generated child `NSManagedObjectContext` context instance ready to use.
func makeChildContext() -> NSManagedObjectContext
/// Save a given context,
/// - Parameter context: A `NSManagedObjectContext` context instance to save.
func save(context: NSManagedObjectContext)
/// Save a given child context as well as its respective parent context.
/// - Parameter context: A child `NSManagedObjectContext` context instance to save.
func save(childContext context: NSManagedObjectContext)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
//
// Service.swift
// Remote
//
// Created by Javier Cicchelli on 13/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
public protocol Service {
// MARK: Functions
/// Retrieve a set of locations.
/// - Returns: The set of locations represented as a `Location` instances.
func getLocations() async throws -> [Location]
}

View File

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

View File

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

View File

@ -0,0 +1,106 @@
//
// TestCoordinators.swift
// CoreTests
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Core
import UIKit
// MARK: - Test coordinators
class SomeCoordinator: Coordinator {
// MARK: Properties
var children: [Coordinator] = []
var router: Router
// MARK: Initialisers
init(router: Router) {
self.router = router
}
// MARK: Functions
func present(animated: Bool, onDismiss: (() -> Void)?) {
router.present(
SomeViewController(),
animated: animated,
onDismiss: onDismiss
)
}
}
class SomeOtherCoordinator: Coordinator {
// MARK: Properties
var children: [Coordinator] = []
var router: Router
// MARK: Initialisers
init(router: Router) {
self.router = router
}
// MARK: Functions
func present(animated: Bool, onDismiss: (() -> Void)?) {
router.present(
SomeOtherViewController(),
animated: animated,
onDismiss: onDismiss
)
}
}
// MARK: - SpyRouter
class SpyRouter: Router {
// MARK: Enumerations
enum State {
case initialised
case presented
case dismissed
}
// MARK: Properties
var state: State = .initialised
var viewController: UIViewController?
var animated: Bool?
var onDismiss: OnDismissedClosure?
// MARK: Functions
func present(
_ viewController: UIViewController,
animated: Bool,
onDismiss: OnDismissedClosure?
) {
self.viewController = viewController
self.animated = animated
self.onDismiss = onDismiss
self.state = .presented
}
func dismiss(animated: Bool) {
self.animated = animated
self.state = .dismissed
}
}
// MARK: - Test view controllers
class SomeViewController: UIViewController {}
class SomeOtherViewController: UIViewController {}

View File

@ -0,0 +1,138 @@
//
// CoordinatorTests.swift
// CoreTests
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
import XCTest
@testable import Core
final class CoordinatorTests: XCTestCase {
// MARK: Properties
private var router: SpyRouter!
private var coordinator: Coordinator!
// MARK: Tests
func test_present_withoutOnDismissClosure() {
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
DispatchQueue.main.async {
// GIVEN
self.router = SpyRouter()
self.coordinator = SomeCoordinator(router: self.router)
// WHEN
self.coordinator.present(animated: false, onDismiss: nil)
// THEN
XCTAssertTrue(self.coordinator.children.isEmpty)
XCTAssertEqual(self.router.state, .presented)
XCTAssertTrue(self.router.viewController is SomeViewController)
XCTAssertFalse(self.router.animated ?? true)
XCTAssertNil(self.router.onDismiss)
}
}
func test_present_withOnDismissClosure() {
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
DispatchQueue.main.async {
// GIVEN
self.router = SpyRouter()
self.coordinator = SomeOtherCoordinator(router: self.router)
// WHEN
self.coordinator.present(animated: true) {}
// THEN
XCTAssertTrue(self.coordinator.children.isEmpty)
XCTAssertEqual(self.router.state, .presented)
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
XCTAssertTrue(self.router.animated ?? false)
XCTAssertNotNil(self.router.onDismiss)
}
}
func test_presentChild_withoutOnDismissClosure() {
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
DispatchQueue.main.async {
// GIVEN
self.router = SpyRouter()
self.coordinator = SomeCoordinator(router: self.router)
let child = SomeOtherCoordinator(router: self.router)
// WHEN
self.coordinator.present(child: child, animated: false)
// THEN
XCTAssertFalse(self.coordinator.children.isEmpty)
XCTAssertEqual(self.coordinator.children.count, 1)
XCTAssertEqual(self.router.state, .presented)
XCTAssertTrue(self.router.viewController is SomeOtherViewController)
XCTAssertFalse(self.router.animated ?? true)
XCTAssertNil(self.router.onDismiss)
}
}
func test_presentChild_withOnDismissClosure() {
// Executing this test on the main thread is required as a `UIViewController` instance in being initialised here.
DispatchQueue.main.async {
// GIVEN
self.router = SpyRouter()
self.coordinator = SomeOtherCoordinator(router: self.router)
let child = SomeCoordinator(router: self.router)
// WHEN
self.coordinator.present(child: child, animated: true) {}
// THEN
XCTAssertFalse(self.coordinator.children.isEmpty)
XCTAssertEqual(self.coordinator.children.count, 1)
XCTAssertEqual(self.router.state, .presented)
XCTAssertTrue(self.router.viewController is SomeViewController)
XCTAssertTrue(self.router.animated ?? false)
XCTAssertNotNil(self.router.onDismiss)
}
}
func test_dismiss_notAnimated() {
// GIVEN
router = SpyRouter()
coordinator = SomeOtherCoordinator(router: router)
// WHEN
coordinator.dismiss(animated: false)
// THEN
XCTAssertTrue(coordinator.children.isEmpty)
XCTAssertEqual(router.state, .dismissed)
XCTAssertNil(router.viewController)
XCTAssertFalse(router.animated ?? true)
XCTAssertNil(router.onDismiss)
}
func test_dismiss_animated() {
// GIVEN
router = SpyRouter()
coordinator = SomeOtherCoordinator(router: router)
// WHEN
coordinator.dismiss(animated: true)
// THEN
XCTAssertTrue(coordinator.children.isEmpty)
XCTAssertEqual(router.state, .dismissed)
XCTAssertNil(router.viewController)
XCTAssertTrue(router.animated ?? false)
XCTAssertNil(router.onDismiss)
}
}

View File

@ -0,0 +1,34 @@
//
// TestServices.swift
// DependencyTests
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Dependency
// MARK: - Protocols
protocol TestService {}
// MARK: - Services
struct SomeService: TestService, Equatable {}
struct SomeOtherService: TestService, Equatable {}
// MARK: - DependencyKey
struct TestServiceKey: DependencyKey {
static var currentValue: TestService = SomeService()
}
// MARK: - DependencyService+Keys
extension DependencyService {
var testService: TestService {
get { Self[TestServiceKey.self] }
set { Self[TestServiceKey.self] = newValue }
}
}

View File

@ -0,0 +1,74 @@
//
// DependencyTests.swift
// DependencyTests
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import XCTest
@testable import Dependency
final class DependencyTests: XCTestCase {
// MARK: Properties
private var subject: TestSubject!
// MARK: Setup
override func setUp() {
DependencyService[\.testService] = SomeService()
}
// MARK: Tests
func test_readTestService() {
// GIVEN
subject = .init()
// WHEN
let service = subject.testService
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeService)
}
func test_writeDependencyKey() async throws {
// GIVEN
subject = .init()
subject.testService = SomeOtherService()
// WHEN
let service = DependencyService[\.testService]
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeOtherService)
}
func test_writeDependencyKeyTwice() async throws {
// GIVEN
subject = .init()
subject.testService = SomeOtherService()
subject.testService = SomeService()
// WHEN
let service = DependencyService[\.testService]
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeService)
}
}
// MARK: - TestSubject
private struct TestSubject {
@Dependency(\.testService) var testService
}

View File

@ -0,0 +1,58 @@
//
// DependencyServiceTests.swift
// DependencyTests
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import XCTest
@testable import Dependency
final class DependencyServiceTests: XCTestCase {
// MARK: Setup
override func setUp() {
DependencyService[\.testService] = SomeService()
}
// MARK: Tests
func test_readDependencyKey() async throws {
// GIVEN
// WHEN
let service = DependencyService[\.testService]
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeService)
}
func test_writeDependencyKey() async throws {
// GIVEN
DependencyService[\.testService] = SomeOtherService()
// WHEN
let service = DependencyService[\.testService]
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeOtherService)
}
func test_writeDependencyKeyTwice() async throws {
// GIVEN
DependencyService[\.testService] = SomeOtherService()
DependencyService[\.testService] = SomeService()
// WHEN
let service = DependencyService[\.testService]
// THEN
XCTAssertNotNil(service)
XCTAssert(service is SomeService)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "1024x1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

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

View File

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

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

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

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

View File

@ -0,0 +1,49 @@
//
// LocationsAddCoordinator.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Core
import UIKit
class LocationsAddCoordinator: Coordinator {
// MARK: Properties
var children: [Coordinator] = []
var router: Router
// MARK: Initialisers
init(router: Router) {
self.router = router
}
// MARK: Coordinator
func present(animated: Bool, onDismiss: (() -> Void)?) {
router.present(
LocationsAddViewController(
viewModel: LocationsAddViewModel(coordinator: self)
),
animated: animated,
onDismiss: onDismiss
)
}
}
// MARK: - LocationsAddCoordination
extension LocationsAddCoordinator: LocationsAddCoordination {
// MARK: Functions
func closeLocationsAddScreen() {
router.dismiss(animated: true)
}
}

View File

@ -0,0 +1,77 @@
//
// LocationsListCoordinator.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Core
import Dependency
import UIKit
class LocationsListCoordinator: Coordinator {
// MARK: Dependencies
@Dependency(\.app) private var app
// MARK: Properties
var children: [Coordinator] = []
var router: Router
private var viewController: UIViewController?
// MARK: Initialisers
init(router: Router) {
self.router = router
}
// MARK: Coordinator
func present(animated: Bool, onDismiss: (() -> Void)?) {
let navigationController = UINavigationController(rootViewController: LocationsListViewController(
viewModel: LocationsListViewModel(coordinator: self)
))
viewController = navigationController
router.present(
navigationController,
animated: animated,
onDismiss: onDismiss
)
}
}
// MARK: - LocationsListCoordination
extension LocationsListCoordinator: LocationsListCoordination {
// MARK: Functions
func openLocationsAddScreen() {
guard let viewController else {
return
}
present(
child: LocationsAddCoordinator(
router: ModalNavigationRouter(parentViewController: viewController)
),
animated: true
)
}
func openWikipediaApp(with url: URL) {
guard app.canOpenURL(url) else {
return
}
app.open(url, options: [:], completionHandler: nil)
}
}

View File

@ -0,0 +1,46 @@
//
// DependencyService+Keys.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Core
import Dependency
import Persistence
import Remote
import UIKit
// MARK: - DependencyService+Keys
extension DependencyService {
var app: Core.Application {
get { Self[ApplicationKey.self] }
set { Self[ApplicationKey.self] = newValue }
}
var persistence: Persistence.Service {
get { Self[PersistenceKey.self] }
set { Self[PersistenceKey.self] = newValue }
}
var remote: Remote.Service {
get { Self[RemoteKey.self] }
set { Self[RemoteKey.self] = newValue }
}
}
// MARK: - Dependency keys
struct ApplicationKey: DependencyKey {
static var currentValue: Core.Application = UIApplication.shared
}
struct PersistenceKey: DependencyKey {
static var currentValue: Persistence.Service = PersistenceService.shared
}
struct RemoteKey: DependencyKey {
static var currentValue: Remote.Service = RemoteService()
}

View File

@ -0,0 +1,46 @@
//
// Location+URLs.swift
// Locations
//
// Created by Javier Cicchelli on 13/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
import Persistence
extension Location {
var wikipediaPlacesURL: URL? {
var urlComponents = URLComponents()
urlComponents.scheme = .Scheme.wikipedia
urlComponents.host = .Host.places
urlComponents.queryItems = [
.init(
name: .Query.key,
value: .init(format: .Query.value, latitude, longitude)
)
]
return urlComponents.url
}
}
// MARK: - String+Constants
private extension String {
enum Scheme {
static let wikipedia = "wikipedia"
}
enum Host {
static let places = "places"
}
enum Query {
static let key = "coordinates"
static let value = "%f,%f"
}
}

View File

@ -0,0 +1,12 @@
//
// UIApplication+Conformances.swift
// Locations
//
// Created by Javier Cicchelli on 13/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Core
import UIKit
extension UIApplication: Application {}

View File

@ -0,0 +1,15 @@
//
// LocationsAddCoordination.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
protocol LocationsAddCoordination: AnyObject {
// MARK: Functions
func closeLocationsAddScreen()
}

View File

@ -0,0 +1,18 @@
//
// LocationsListCoordination.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Foundation
protocol LocationsListCoordination: AnyObject {
// MARK: Functions
func openLocationsAddScreen()
func openWikipediaApp(with url: URL)
}

View File

@ -0,0 +1,25 @@
//
// LocationsAddViewModeling.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
protocol LocationsAddViewModeling: AnyObject {
// MARK: Properties
var coordinator: LocationsAddCoordination? { get set }
var locationExistsPublisher: Published<Bool>.Publisher { get }
// MARK: Functions
func cleanLocation()
func saveLocation()
func setLocation(latitude: Float, longitude: Float)
}

View File

@ -0,0 +1,31 @@
//
// LocationsListViewModeling.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import Foundation
import Persistence
protocol LocationsListViewModeling: AnyObject {
// MARK: Properties
var coordinator: LocationsListCoordination? { get set }
var locationsDidChangePublisher: PassthroughSubject<[Change], Never> { get }
var numberOfLocationSections: Int { get }
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { get }
// MARK: Functions
func loadLocations()
func location(at indexPath: IndexPath) -> Location
func numberOfLocations(in section: Int) -> Int
func openLocationsAdd()
func openWikipedia(at indexPath: IndexPath)
}

View File

@ -0,0 +1,138 @@
//
// LocationsAddViewController.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import Core
import UIKit
import MapKit
class LocationsAddViewController: BaseViewController {
// MARK: Properties
private let viewModel: LocationsAddViewModeling
private var cancellables: Set<AnyCancellable> = []
// MARK: Outlets
private lazy var map = {
let map = MKMapView()
map.translatesAutoresizingMaskIntoConstraints = false
map.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnMap)))
return map
}()
// MARK: Initialisers
init(viewModel: LocationsAddViewModeling) {
self.viewModel = viewModel
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: UIViewController
override func viewDidLoad() {
super.viewDidLoad()
setupBar()
setupView()
bindViewModel()
}
}
// MARK: - Helpers
private extension LocationsAddViewController {
// MARK: Functions
func bindViewModel() {
viewModel
.locationExistsPublisher
.receive(on: RunLoop.main)
.sink { locationExists in
self.navigationItem
.rightBarButtonItems?
.forEach { $0.isEnabled = locationExists }
}
.store(in: &cancellables)
}
func setupBar() {
title = "Add a location"
navigationController?.navigationBar.prefersLargeTitles = false
navigationController?.navigationBar.backgroundColor = .systemBackground
navigationController?.navigationBar.isTranslucent = true
navigationController?.navigationBar.tintColor = .red
navigationItem.rightBarButtonItems = [
.init(
title: "Save",
style: .plain,
target: self,
action: #selector(saveButtonPressed)
),
.init(
title: "Clean",
style: .plain,
target: self,
action: #selector(cleanButtonPressed)
),
]
}
func setupView() {
view.addSubview(map)
NSLayoutConstraint.activate([
view.bottomAnchor.constraint(equalTo: map.bottomAnchor),
view.leadingAnchor.constraint(equalTo: map.leadingAnchor),
view.topAnchor.constraint(equalTo: map.topAnchor),
view.trailingAnchor.constraint(equalTo: map.trailingAnchor),
])
}
// MARK: Actions
@objc func cleanButtonPressed() {
map.removeAnnotations(map.annotations)
viewModel.cleanLocation()
}
@objc func saveButtonPressed() {
viewModel.saveLocation()
}
@objc func tapOnMap(recognizer: UITapGestureRecognizer) {
let tapOnView = recognizer.location(in: map)
let mapCoordinates = map.convert(tapOnView, toCoordinateFrom: map)
let annotation = MKPointAnnotation()
annotation.coordinate = mapCoordinates
map.removeAnnotations(map.annotations)
map.addAnnotation(annotation)
map.setCenter(mapCoordinates, animated: true)
viewModel.setLocation(
latitude: Float(mapCoordinates.latitude),
longitude: Float(mapCoordinates.longitude)
)
}
}

View File

@ -0,0 +1,91 @@
//
// LocationsAddViewModel.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import Core
class LocationsAddViewModel: ObservableObject {
// MARK: Properties
weak var coordinator: LocationsAddCoordination?
@Published private var location: Location?
@Published private var locationExists: Bool = false
private let saveLocalLocation = SaveLocalLocationUseCase()
// MARK: Initialisers
init(coordinator: LocationsAddCoordination) {
self.coordinator = coordinator
setupBindings()
}
}
// MARK: - LocationsAddViewModeling
extension LocationsAddViewModel: LocationsAddViewModeling {
// MARK: Properties
var locationExistsPublisher: Published<Bool>.Publisher { $locationExists }
// MARK: Functions
func cleanLocation() {
location = nil
}
func saveLocation() {
guard let location else {
return
}
saveLocalLocation(
latitude: location.latitude,
longitude: location.longitude
)
coordinator?.closeLocationsAddScreen()
}
func setLocation(latitude: Float, longitude: Float) {
if location == nil {
location = .init(latitude: latitude, longitude: longitude)
} else {
location?.latitude = latitude
location?.longitude = longitude
}
}
}
// MARK: - Helpers
private extension LocationsAddViewModel {
// MARK: Functions
func setupBindings() {
$location
.map { $0 != nil }
.assign(to: &$locationExists)
}
}
// MARK: - Structs
private extension LocationsAddViewModel {
struct Location {
var latitude: Float
var longitude: Float
}
}

View File

@ -0,0 +1,218 @@
//
// LocationsListViewController.swift
// Locations
//
// Created by Javier Cicchelli on 08/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import Core
import UIKit
class LocationsListViewController: BaseViewController {
// MARK: Properties
private let viewModel: LocationsListViewModeling
private var cancellables: Set<AnyCancellable> = []
// MARK: Outlets
private lazy var error = ErrorMessageView()
private lazy var loading = LoadingSpinnerView()
private lazy var table = {
let table = UITableView(frame: .zero, style: .plain)
table.dataSource = self
table.delegate = self
table.translatesAutoresizingMaskIntoConstraints = false
table.register(LocationViewCell.self, forCellReuseIdentifier: LocationViewCell.identifier)
return table
}()
// MARK: Initialisers
init(viewModel: LocationsListViewModeling) {
self.viewModel = viewModel
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: UIViewController
override func viewDidLoad() {
super.viewDidLoad()
setupBar()
setupView()
bindViewModel()
viewModel.loadLocations()
}
}
// MARK: - UITableViewDataSource
extension LocationsListViewController: UITableViewDataSource {
// MARK: Functions
func numberOfSections(in tableView: UITableView) -> Int {
viewModel.numberOfLocationSections
}
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
viewModel.numberOfLocations(in: section)
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: LocationViewCell.identifier,
for: indexPath
) as? LocationViewCell else {
return .init()
}
let entity = viewModel.location(at: indexPath)
cell.update(
iconName: entity.source == .remote ? "network" : "house",
name: entity.name,
latitude: entity.latitude,
longitude: entity.longitude
)
return cell
}
}
// MARK: - UITableViewDelegate
extension LocationsListViewController: UITableViewDelegate {
// MARK: Functions
func tableView(
_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath
) {
viewModel.openWikipedia(at: indexPath)
tableView.deselectRow(at: indexPath, animated: true)
}
}
// MARK: - Helpers
private extension LocationsListViewController {
// MARK: Functions
func setupBar() {
navigationController?.navigationBar.prefersLargeTitles = true
navigationController?.navigationBar.tintColor = .red
navigationItem.rightBarButtonItem = .init(
title: "Add",
style: .plain,
target: self,
action: #selector(addLocationPressed)
)
title = "Locations"
}
func setupView() {
view.addSubview(table)
view.addSubview(error)
view.addSubview(loading)
error.onRetry = {
self.viewModel.loadLocations()
}
NSLayoutConstraint.activate([
error.widthAnchor.constraint(equalToConstant: 300),
view.centerXAnchor.constraint(equalTo: error.centerXAnchor),
view.centerYAnchor.constraint(equalTo: error.centerYAnchor),
view.centerXAnchor.constraint(equalTo: loading.centerXAnchor),
view.centerYAnchor.constraint(equalTo: loading.centerYAnchor),
view.bottomAnchor.constraint(equalTo: table.bottomAnchor),
view.leadingAnchor.constraint(equalTo: table.leadingAnchor),
view.topAnchor.constraint(equalTo: table.topAnchor),
view.trailingAnchor.constraint(equalTo: table.trailingAnchor),
])
}
func bindViewModel() {
viewModel
.viewStatusPublisher
.receive(on: RunLoop.main)
.sink { viewStatus in
self.navigationItem.rightBarButtonItem?.isEnabled = viewStatus == .loaded
self.error.isHidden = viewStatus != .error
self.loading.isHidden = viewStatus != .loading
self.table.isHidden = viewStatus != .loaded
if viewStatus == .loaded {
self.table.reloadData()
}
}
.store(in: &cancellables)
viewModel
.locationsDidChangePublisher
.sink(receiveValue: { [weak self] updates in
var movedToIndexPaths = [IndexPath]()
self?.table.performBatchUpdates({
for update in updates {
switch update {
case let .section(sectionUpdate):
switch sectionUpdate {
case let .inserted(index):
self?.table.insertSections([index], with: .automatic)
case let .deleted(index):
self?.table.deleteSections([index], with: .automatic)
}
case let .object(objectUpdate):
switch objectUpdate {
case let .inserted(at: indexPath):
self?.table.insertRows(at: [indexPath], with: .automatic)
case let .deleted(from: indexPath):
self?.table.deleteRows(at: [indexPath], with: .automatic)
case let .updated(at: indexPath):
self?.table.reloadRows(at: [indexPath], with: .automatic)
case let .moved(from: source, to: target):
self?.table.moveRow(at: source, to: target)
movedToIndexPaths.append(target)
}
}
}
}, completion: { done in
self?.table.reloadRows(at: movedToIndexPaths, with: .automatic)
})
})
.store(in: &cancellables)
}
@objc func addLocationPressed() {
viewModel.openLocationsAdd()
}
}

View File

@ -0,0 +1,96 @@
//
// LocationsListViewModel.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Combine
import Dependency
import Foundation
import Persistence
class LocationsListViewModel: ObservableObject {
// MARK: Dependencies
@Dependency(\.persistence) private var persistence
// MARK: Properties
weak var coordinator: LocationsListCoordination?
private lazy var locationProvider = LocationProvider(managedContext: persistence.viewContext)
@Published private var viewStatus: LocationsListViewStatus = .initialised
private let loadRemoteLocations = LoadRemoteLocationsUseCase()
// MARK: Initialisers
init(coordinator: LocationsListCoordination) {
self.coordinator = coordinator
}
}
// MARK: - LocationsListViewModeling
extension LocationsListViewModel: LocationsListViewModeling {
// MARK: Properties
var locationsDidChangePublisher: PassthroughSubject<[Persistence.Change], Never> { locationProvider.didChangePublisher }
var numberOfLocationSections: Int { locationProvider.numberOfSections }
var viewStatusPublisher: Published<LocationsListViewStatus>.Publisher { $viewStatus }
// MARK: Functions
func loadLocations() {
Task {
do {
viewStatus = .loading
try await loadRemoteLocations()
try locationProvider.fetch()
viewStatus = .loaded
} catch {
viewStatus = .error
}
}
}
func location(at indexPath: IndexPath) -> Location {
locationProvider.location(at: indexPath)
}
func numberOfLocations(in section: Int) -> Int {
locationProvider.numberOfLocationsInSection(section)
}
func openLocationsAdd() {
coordinator?.openLocationsAddScreen()
}
func openWikipedia(at indexPath: IndexPath) {
guard let url = locationProvider.location(at: indexPath).wikipediaPlacesURL else {
return
}
coordinator?.openWikipediaApp(with: url)
}
}
// MARK: - Enumerations
enum LocationsListViewStatus {
case initialised
case loading
case loaded
case error
}

View File

@ -0,0 +1,77 @@
//
// LoadRemoteLocationsUseCase.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import CoreData
import Dependency
import Persistence
import Remote
struct LoadRemoteLocationsUseCase {
// MARK: Properties
private let persistence: Persistence.Service
private let remoteService: Remote.Service
// MARK: Initialisers
init(
persistence: Persistence.Service,
remoteService: Remote.Service
) {
self.persistence = persistence
self.remoteService = remoteService
}
// MARK: Functions
func callAsFunction() async throws {
let context = persistence.makeTaskContext()
let fetchRequest = NSFetchRequest<Persistence.Location>.allLocations()
try await context.perform {
let localLocations = try context.fetch(fetchRequest)
localLocations
.filter { $0.source == .remote }
.forEach(context.delete)
}
let remoteLocations = try await remoteService.getLocations()
_ = remoteLocations
.map {
let entity = Persistence.Location(context: context)
entity.createdAt = .now
entity.name = $0.name
entity.latitude = $0.latitude
entity.longitude = $0.longitude
entity.source = .remote
return entity
}
persistence.save(context: context)
}
}
// MARK: - LoadRemoteLocationsUseCase+Initialisers
extension LoadRemoteLocationsUseCase {
init() {
@Dependency(\.persistence) var persistence
@Dependency(\.remote) var remote
self.init(
persistence: persistence,
remoteService: remote
)
}
}

View File

@ -0,0 +1,54 @@
//
// SaveLocalLocationUseCase.swift
// Locations
//
// Created by Javier Cicchelli on 12/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import Dependency
import Persistence
struct SaveLocalLocationUseCase {
// MARK: Properties
private let persistence: Persistence.Service
// MARK: Initialisers
init(persistence: Persistence.Service) {
self.persistence = persistence
}
// MARK: Functions
func callAsFunction(
name: String? = nil,
latitude: Float,
longitude: Float
) {
let context = persistence.makeTaskContext()
let entity = Location(context: context)
entity.createdAt = .now
entity.name = name
entity.latitude = latitude
entity.longitude = longitude
entity.source = .local
persistence.save(context: context)
}
}
// MARK: - LoadRemoteLocationsUseCase+Initialisers
extension SaveLocalLocationUseCase {
init() {
@Dependency(\.persistence) var persistence
self.init(persistence: persistence)
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
//
// BaseViewController.swift
// Locations
//
// Created by Javier Cicchelli on 11/04/2023.
// Copyright © 2023 Röck+Cöde. All rights reserved.
//
import UIKit
class BaseViewController: UIViewController {
// MARK: Initialisers
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: UIViewController
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
}

View File

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

View 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
View File

@ -0,0 +1 @@
custom: ['https://donate.wikimedia.org/?utm_medium=githubRepo']

View File

@ -0,0 +1,10 @@
**Phabricator:**
### Notes
*
### Test Steps
1.
### Screenshots/Videos

View 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

View File

@ -0,0 +1,3 @@
Wikipedia/assets/**
fastlane/**
www/node_modules/**

View File

@ -0,0 +1 @@
10.16.0

View File

@ -0,0 +1 @@
3.0.5

View 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

View 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

View File

@ -0,0 +1,7 @@
[
"-workspace", "Wikipedia.xcworkspace",
"-scheme", "Wikipedia",
"-configuration", "Debug",
"-sdk", "iphonesimulator",
"-destination", "platform=iOS Simulator,name=iPhone 6,OS=10.0"
]

View File

@ -0,0 +1 @@
14.2.0

View File

@ -0,0 +1 @@
The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Code_of_Conduct).

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
/* Localized versions of Info.plist keys */
"CFBundleDisplayName" = "Wikipedia continue reading";
"CFBundleName" = "Wikipedia continue reading";

View File

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