├── .github └── FUNDING.yml ├── .gitignore ├── .swiftlint.yml ├── CountriesSwiftUI.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── CountriesSwiftUI.xcscheme ├── CountriesSwiftUI ├── Injected │ ├── AppState.swift │ ├── DependencyInjector.swift │ └── InteractorsContainer.swift ├── Interactors │ ├── CountriesInteractor.swift │ ├── ImagesInteractor.swift │ └── UserPermissionsInteractor.swift ├── Models │ ├── MockedData.swift │ ├── Models+CoreData.swift │ └── Models.swift ├── Persistence │ ├── CoreDataHelpers.swift │ ├── CoreDataStack.swift │ └── db_model_v1.xcdatamodeld │ │ └── db_model_v1.xcdatamodel │ │ └── contents ├── Repositories │ ├── CountriesDBRepository.swift │ ├── CountriesWebRepository.swift │ ├── ImageWebRepository.swift │ └── PushTokenWebRepository.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── CountriesSwiftUI.entitlements │ ├── Info.plist │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── en.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Localizable.strings │ ├── es.lproj │ │ └── Localizable.strings │ ├── fr.lproj │ │ └── Localizable.strings │ └── ja.lproj │ │ └── Localizable.strings ├── System │ ├── AppDelegate.swift │ ├── AppEnvironment.swift │ ├── DeepLinksHandler.swift │ ├── PushNotificationsHandler.swift │ ├── SceneDelegate.swift │ └── SystemEventsHandler.swift ├── UI │ ├── Components │ │ ├── ActivityIndicatorView.swift │ │ ├── CountryCell.swift │ │ ├── DetailRow.swift │ │ ├── ErrorView.swift │ │ ├── SVGImageView.swift │ │ └── SearchBar.swift │ ├── RootViewModifier.swift │ └── Screens │ │ ├── ContentView.swift │ │ ├── CountriesList.swift │ │ ├── CountryDetails.swift │ │ └── ModalDetailsView.swift └── Utilities │ ├── APICall.swift │ ├── CancelBag.swift │ ├── Helpers.swift │ ├── LazyList.swift │ ├── Loadable.swift │ ├── NetworkingHelpers.swift │ ├── Store.swift │ └── WebRepository.swift ├── LICENSE ├── PushNotificationPayload └── push_with_deeplink.apns ├── README.md ├── UnitTests ├── Interactors │ ├── CountriesInteractorTests.swift │ ├── ImagesInteractorTests.swift │ └── UserPermissionsInteractorTests.swift ├── Mocks │ ├── Mock.swift │ ├── MockedDBRepositories.swift │ ├── MockedInteractors.swift │ ├── MockedPersistentStore.swift │ ├── MockedSystemEventsHandler.swift │ └── MockedWebRepositories.swift ├── NetworkMocking │ ├── MockedResponse.swift │ └── RequestMocking.swift ├── Persistence │ └── CoreDataStackTests.swift ├── Repositories │ ├── CountriesDBRepositoryTests.swift │ ├── CountriesWebRepositoryTests.swift │ ├── ImageWebRepositoryTests.swift │ ├── PushTokenWebRepositoryTests.swift │ └── WebRepositoryTests.swift ├── Resources │ ├── Info.plist │ ├── svg_convert_01.html │ └── svg_convert_02.html ├── System │ ├── AppDelegateTests.swift │ ├── DeepLinksHandlerTests.swift │ ├── PushNotificationsHandlerTests.swift │ ├── SceneDelegateTests.swift │ ├── SystemEventsHandlerTests.swift │ ├── UIOpenURLContext_Init.h │ ├── UIOpenURLContext_Init.m │ └── UnitTests-Bridging-Header.h ├── TestHelpers.swift ├── UI │ ├── ContentViewTests.swift │ ├── CountriesListTests.swift │ ├── CountryDetailsTests.swift │ ├── DeepLinkUITests.swift │ ├── ModalDetailsViewTests.swift │ ├── RootViewAppearanceTests.swift │ ├── SVGImageViewTests.swift │ ├── SearchBarTests.swift │ └── ViewPreviewsTests.swift └── Utilities │ ├── HelpersTests.swift │ ├── LazyListTests.swift │ └── LoadableTests.swift └── codemagic.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nalexn] 2 | custom: ["https://venmo.com/nallexn"] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Mac OS 2 | .DS_Store 3 | 4 | ## Xcode workspace 5 | *.xcworkspace 6 | 7 | ## Build generated 8 | build/ 9 | DerivedData/ 10 | 11 | ## Various settings 12 | *.pbxuser 13 | !default.pbxuser 14 | *.mode1v3 15 | !default.mode1v3 16 | *.mode2v3 17 | !default.mode2v3 18 | !default.perspectivev3 19 | *.perspectivev3 20 | xcuserdata/ 21 | 22 | ## Other 23 | *.moved-aside 24 | *.xcuserstate 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | ## Tools artifacts 37 | cpd-output.xml 38 | 39 | ## SwiftyBeaver 40 | Hamew.beaver 41 | 42 | ## Swift Package Manager 43 | # Packages/ 44 | .build/ 45 | 46 | ## CocoaPods 47 | # Pods/ 48 | 49 | ## Carthage 50 | Carthage/Checkouts 51 | Carthage/Build 52 | 53 | ## fastlane 54 | fastlane/report.xml 55 | fastlane/Preview.html 56 | fastlane/screenshots 57 | fastlane/test_output 58 | fastlane/app_archive 59 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | # - colon 3 | # - comma 4 | # - control_statement 5 | # - file_length 6 | # - force_cast 7 | # - force_try 8 | # - function_body_length 9 | # - leading_whitespace 10 | # - line_length 11 | - nesting 12 | - large_tuple 13 | - unused_closure_parameter 14 | # - opening_brace 15 | # - operator_whitespace 16 | # - return_arrow_whitespace 17 | # - statement_position 18 | # - todo 19 | # - trailing_newline 20 | # - trailing_semicolon 21 | - trailing_whitespace 22 | # - type_body_length 23 | - type_name 24 | - xctfail_message 25 | # - variable_name_max_length 26 | # - variable_name_min_length 27 | # - variable_name 28 | #included: # paths to include during linting. `--path` is ignored if present. takes precendence over `excluded`. 29 | 30 | excluded: # paths to ignore during linting. overridden by `included`. 31 | - Carthage 32 | - Pods 33 | - Modules 34 | 35 | identifier_name: 36 | min_length: 2 -------------------------------------------------------------------------------- /CountriesSwiftUI.xcodeproj/xcshareddata/xcschemes/CountriesSwiftUI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 81 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Injected/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct AppState: Equatable { 13 | var userData = UserData() 14 | var routing = ViewRouting() 15 | var system = System() 16 | var permissions = Permissions() 17 | } 18 | 19 | extension AppState { 20 | struct UserData: Equatable { 21 | /* 22 | The list of countries (Loadable<[Country]>) used to be stored here. 23 | It was removed for performing countries' search by name inside a database, 24 | which made the resulting variable used locally by just one screen (CountriesList) 25 | Otherwise, the list of countries could have remained here, available for the entire app. 26 | */ 27 | } 28 | } 29 | 30 | extension AppState { 31 | struct ViewRouting: Equatable { 32 | var countriesList = CountriesList.Routing() 33 | var countryDetails = CountryDetails.Routing() 34 | } 35 | } 36 | 37 | extension AppState { 38 | struct System: Equatable { 39 | var isActive: Bool = false 40 | var keyboardHeight: CGFloat = 0 41 | } 42 | } 43 | 44 | extension AppState { 45 | struct Permissions: Equatable { 46 | var push: Permission.Status = .unknown 47 | } 48 | 49 | static func permissionKeyPath(for permission: Permission) -> WritableKeyPath { 50 | let pathToPermissions = \AppState.permissions 51 | switch permission { 52 | case .pushNotifications: 53 | return pathToPermissions.appending(path: \.push) 54 | } 55 | } 56 | } 57 | 58 | func == (lhs: AppState, rhs: AppState) -> Bool { 59 | return lhs.userData == rhs.userData && 60 | lhs.routing == rhs.routing && 61 | lhs.system == rhs.system && 62 | lhs.permissions == rhs.permissions 63 | } 64 | 65 | #if DEBUG 66 | extension AppState { 67 | static var preview: AppState { 68 | var state = AppState() 69 | state.system.isActive = true 70 | return state 71 | } 72 | } 73 | #endif 74 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Injected/DependencyInjector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DependencyInjector.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 28.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | // MARK: - DIContainer 13 | 14 | struct DIContainer: EnvironmentKey { 15 | 16 | let appState: Store 17 | let interactors: Interactors 18 | 19 | init(appState: Store, interactors: Interactors) { 20 | self.appState = appState 21 | self.interactors = interactors 22 | } 23 | 24 | init(appState: AppState, interactors: Interactors) { 25 | self.init(appState: Store(appState), interactors: interactors) 26 | } 27 | 28 | static var defaultValue: Self { Self.default } 29 | 30 | private static let `default` = Self(appState: AppState(), interactors: .stub) 31 | } 32 | 33 | extension EnvironmentValues { 34 | var injected: DIContainer { 35 | get { self[DIContainer.self] } 36 | set { self[DIContainer.self] = newValue } 37 | } 38 | } 39 | 40 | #if DEBUG 41 | extension DIContainer { 42 | static var preview: Self { 43 | .init(appState: .init(AppState.preview), interactors: .stub) 44 | } 45 | } 46 | #endif 47 | 48 | // MARK: - Injection in the view hierarchy 49 | 50 | extension View { 51 | 52 | func inject(_ appState: AppState, 53 | _ interactors: DIContainer.Interactors) -> some View { 54 | let container = DIContainer(appState: .init(appState), 55 | interactors: interactors) 56 | return inject(container) 57 | } 58 | 59 | func inject(_ container: DIContainer) -> some View { 60 | return self 61 | .modifier(RootViewAppearance()) 62 | .environment(\.injected, container) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Injected/InteractorsContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DIContainer.Interactors.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 24.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | extension DIContainer { 10 | struct Interactors { 11 | let countriesInteractor: CountriesInteractor 12 | let imagesInteractor: ImagesInteractor 13 | let userPermissionsInteractor: UserPermissionsInteractor 14 | 15 | init(countriesInteractor: CountriesInteractor, 16 | imagesInteractor: ImagesInteractor, 17 | userPermissionsInteractor: UserPermissionsInteractor) { 18 | self.countriesInteractor = countriesInteractor 19 | self.imagesInteractor = imagesInteractor 20 | self.userPermissionsInteractor = userPermissionsInteractor 21 | } 22 | 23 | static var stub: Self { 24 | .init(countriesInteractor: StubCountriesInteractor(), 25 | imagesInteractor: StubImagesInteractor(), 26 | userPermissionsInteractor: StubUserPermissionsInteractor()) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Interactors/CountriesInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountriesInteractor.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import SwiftUI 12 | 13 | protocol CountriesInteractor { 14 | func refreshCountriesList() -> AnyPublisher 15 | func load(countries: LoadableSubject>, search: String, locale: Locale) 16 | func load(countryDetails: LoadableSubject, country: Country) 17 | } 18 | 19 | struct RealCountriesInteractor: CountriesInteractor { 20 | 21 | let webRepository: CountriesWebRepository 22 | let dbRepository: CountriesDBRepository 23 | let appState: Store 24 | 25 | init(webRepository: CountriesWebRepository, dbRepository: CountriesDBRepository, appState: Store) { 26 | self.webRepository = webRepository 27 | self.dbRepository = dbRepository 28 | self.appState = appState 29 | } 30 | 31 | func load(countries: LoadableSubject>, search: String, locale: Locale) { 32 | 33 | let cancelBag = CancelBag() 34 | countries.wrappedValue.setIsLoading(cancelBag: cancelBag) 35 | 36 | Just 37 | .withErrorType(Error.self) 38 | .flatMap { [dbRepository] _ -> AnyPublisher in 39 | dbRepository.hasLoadedCountries() 40 | } 41 | .flatMap { hasLoaded -> AnyPublisher in 42 | if hasLoaded { 43 | return Just.withErrorType(Error.self) 44 | } else { 45 | return self.refreshCountriesList() 46 | } 47 | } 48 | .flatMap { [dbRepository] in 49 | dbRepository.countries(search: search, locale: locale) 50 | } 51 | .sinkToLoadable { countries.wrappedValue = $0 } 52 | .store(in: cancelBag) 53 | } 54 | 55 | func refreshCountriesList() -> AnyPublisher { 56 | return webRepository 57 | .loadCountries() 58 | .ensureTimeSpan(requestHoldBackTimeInterval) 59 | .flatMap { [dbRepository] in 60 | dbRepository.store(countries: $0) 61 | } 62 | .eraseToAnyPublisher() 63 | } 64 | 65 | func load(countryDetails: LoadableSubject, country: Country) { 66 | 67 | let cancelBag = CancelBag() 68 | countryDetails.wrappedValue.setIsLoading(cancelBag: cancelBag) 69 | 70 | dbRepository 71 | .countryDetails(country: country) 72 | .flatMap { details -> AnyPublisher in 73 | if details != nil { 74 | return Just.withErrorType(details, Error.self) 75 | } else { 76 | return self.loadAndStoreCountryDetailsFromWeb(country: country) 77 | } 78 | } 79 | .sinkToLoadable { countryDetails.wrappedValue = $0.unwrap() } 80 | .store(in: cancelBag) 81 | } 82 | 83 | private func loadAndStoreCountryDetailsFromWeb(country: Country) -> AnyPublisher { 84 | return webRepository 85 | .loadCountryDetails(country: country) 86 | .ensureTimeSpan(requestHoldBackTimeInterval) 87 | .flatMap { [dbRepository] in 88 | dbRepository.store(countryDetails: $0, for: country) 89 | } 90 | .eraseToAnyPublisher() 91 | } 92 | 93 | private var requestHoldBackTimeInterval: TimeInterval { 94 | return ProcessInfo.processInfo.isRunningTests ? 0 : 0.5 95 | } 96 | } 97 | 98 | struct StubCountriesInteractor: CountriesInteractor { 99 | 100 | func refreshCountriesList() -> AnyPublisher { 101 | return Just.withErrorType(Error.self) 102 | } 103 | 104 | func load(countries: LoadableSubject>, search: String, locale: Locale) { 105 | } 106 | 107 | func load(countryDetails: LoadableSubject, country: Country) { 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Interactors/ImagesInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagesInteractor.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 09.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import SwiftUI 12 | 13 | protocol ImagesInteractor { 14 | func load(image: LoadableSubject, url: URL?) 15 | } 16 | 17 | struct RealImagesInteractor: ImagesInteractor { 18 | 19 | let webRepository: ImageWebRepository 20 | 21 | init(webRepository: ImageWebRepository) { 22 | self.webRepository = webRepository 23 | } 24 | 25 | func load(image: LoadableSubject, url: URL?) { 26 | guard let url = url else { 27 | image.wrappedValue = .notRequested; return 28 | } 29 | let cancelBag = CancelBag() 30 | image.wrappedValue.setIsLoading(cancelBag: cancelBag) 31 | webRepository.load(imageURL: url, width: 300) 32 | .sinkToLoadable { 33 | image.wrappedValue = $0 34 | } 35 | .store(in: cancelBag) 36 | } 37 | } 38 | 39 | struct StubImagesInteractor: ImagesInteractor { 40 | func load(image: LoadableSubject, url: URL?) { 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Interactors/UserPermissionsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserPermissionsInteractor.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UserNotifications 11 | 12 | enum Permission { 13 | case pushNotifications 14 | } 15 | 16 | extension Permission { 17 | enum Status: Equatable { 18 | case unknown 19 | case notRequested 20 | case granted 21 | case denied 22 | } 23 | } 24 | 25 | protocol UserPermissionsInteractor: AnyObject { 26 | func resolveStatus(for permission: Permission) 27 | func request(permission: Permission) 28 | } 29 | 30 | // MARK: - RealUserPermissionsInteractor 31 | 32 | final class RealUserPermissionsInteractor: UserPermissionsInteractor { 33 | 34 | private let appState: Store 35 | private let openAppSettings: () -> Void 36 | 37 | init(appState: Store, openAppSettings: @escaping () -> Void) { 38 | self.appState = appState 39 | self.openAppSettings = openAppSettings 40 | } 41 | 42 | func resolveStatus(for permission: Permission) { 43 | let keyPath = AppState.permissionKeyPath(for: permission) 44 | let currentStatus = appState[keyPath] 45 | guard currentStatus == .unknown else { return } 46 | let onResolve: (Permission.Status) -> Void = { [weak appState] status in 47 | appState?[keyPath] = status 48 | } 49 | switch permission { 50 | case .pushNotifications: 51 | pushNotificationsPermissionStatus(onResolve) 52 | } 53 | } 54 | 55 | func request(permission: Permission) { 56 | let keyPath = AppState.permissionKeyPath(for: permission) 57 | let currentStatus = appState[keyPath] 58 | guard currentStatus != .denied else { 59 | openAppSettings() 60 | return 61 | } 62 | switch permission { 63 | case .pushNotifications: 64 | requestPushNotificationsPermission() 65 | } 66 | } 67 | } 68 | 69 | // MARK: - Push Notifications 70 | 71 | extension UNAuthorizationStatus { 72 | var map: Permission.Status { 73 | switch self { 74 | case .denied: return .denied 75 | case .authorized: return .granted 76 | case .notDetermined, .provisional, .ephemeral: return .notRequested 77 | @unknown default: return .notRequested 78 | } 79 | } 80 | } 81 | 82 | private extension RealUserPermissionsInteractor { 83 | 84 | func pushNotificationsPermissionStatus(_ resolve: @escaping (Permission.Status) -> Void) { 85 | let center = UNUserNotificationCenter.current() 86 | center.getNotificationSettings { settings in 87 | DispatchQueue.main.async { 88 | resolve(settings.authorizationStatus.map) 89 | } 90 | } 91 | } 92 | 93 | func requestPushNotificationsPermission() { 94 | let center = UNUserNotificationCenter.current() 95 | center.requestAuthorization(options: [.alert, .sound]) { (isGranted, error) in 96 | DispatchQueue.main.async { 97 | self.appState[\.permissions.push] = isGranted ? .granted : .denied 98 | } 99 | } 100 | } 101 | } 102 | 103 | // MARK: - 104 | 105 | final class StubUserPermissionsInteractor: UserPermissionsInteractor { 106 | 107 | func resolveStatus(for permission: Permission) { 108 | } 109 | func request(permission: Permission) { 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Models/MockedData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedModel.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 27.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if DEBUG 12 | 13 | extension Country { 14 | static let mockedData: [Country] = [ 15 | Country(name: "United States", translations: [:], population: 125000000, 16 | flag: URL(string: "https://flagcdn.com/us.svg"), alpha3Code: "USA"), 17 | Country(name: "Georgia", translations: [:], population: 2340000, flag: nil, alpha3Code: "GEO"), 18 | Country(name: "Canada", translations: [:], population: 57600000, flag: nil, alpha3Code: "CAN") 19 | ] 20 | } 21 | 22 | extension Country.Details { 23 | static var mockedData: [Country.Details] = { 24 | let neighbors = Country.mockedData 25 | return [ 26 | Country.Details(capital: "Sin City", currencies: Country.Currency.mockedData, neighbors: neighbors), 27 | Country.Details(capital: "Los Angeles", currencies: Country.Currency.mockedData, neighbors: []), 28 | Country.Details(capital: "New York", currencies: [], neighbors: []), 29 | Country.Details(capital: "Moscow", currencies: [], neighbors: neighbors) 30 | ] 31 | }() 32 | } 33 | 34 | extension Country.Currency { 35 | static let mockedData: [Country.Currency] = [ 36 | Country.Currency(code: "USD", symbol: "$", name: "US Dollar"), 37 | Country.Currency(code: "EUR", symbol: "€", name: "Euro"), 38 | Country.Currency(code: "RUB", symbol: "‡", name: "Rouble") 39 | ] 40 | } 41 | 42 | #endif 43 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Models/Models+CoreData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Models+CoreData.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 12.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | extension CountryMO: ManagedEntity { } 13 | extension NameTranslationMO: ManagedEntity { } 14 | extension CountryDetailsMO: ManagedEntity { } 15 | extension CurrencyMO: ManagedEntity { } 16 | 17 | extension Locale { 18 | static var backendDefault: Locale { 19 | return Locale(identifier: "en") 20 | } 21 | 22 | var shortIdentifier: String { 23 | return String(identifier.prefix(2)) 24 | } 25 | } 26 | 27 | extension Country.Details { 28 | 29 | init?(managedObject: CountryDetailsMO) { 30 | guard let capital = managedObject.capital 31 | else { return nil } 32 | 33 | let currencies = (managedObject.currencies ?? NSSet()) 34 | .toArray(of: CurrencyMO.self) 35 | .compactMap { Country.Currency(managedObject: $0) } 36 | 37 | let borders = (managedObject.borders ?? NSSet()) 38 | .toArray(of: CountryMO.self) 39 | .compactMap { Country(managedObject: $0) } 40 | .sorted(by: { $0.name < $1.name }) 41 | 42 | self.init(capital: capital, currencies: currencies, neighbors: borders) 43 | } 44 | } 45 | 46 | extension Country.Details.Intermediate { 47 | 48 | @discardableResult 49 | func store(in context: NSManagedObjectContext, 50 | country: CountryMO, borders: [CountryMO]) -> CountryDetailsMO? { 51 | guard let details = CountryDetailsMO.insertNew(in: context) 52 | else { return nil } 53 | details.capital = capital 54 | let storedCurrencies = currencies.compactMap { $0.store(in: context) } 55 | details.currencies = NSSet(array: storedCurrencies) 56 | details.borders = NSSet(array: borders) 57 | country.countryDetails = details 58 | return details 59 | } 60 | } 61 | 62 | extension Country.Currency { 63 | 64 | init?(managedObject: CurrencyMO) { 65 | guard let code = managedObject.code, 66 | let name = managedObject.name 67 | else { return nil } 68 | self.init(code: code, symbol: managedObject.symbol, name: name) 69 | } 70 | 71 | @discardableResult 72 | func store(in context: NSManagedObjectContext) -> CurrencyMO? { 73 | guard let currency = CurrencyMO.insertNew(in: context) 74 | else { return nil } 75 | currency.code = code 76 | currency.name = name 77 | currency.symbol = symbol 78 | return currency 79 | } 80 | } 81 | 82 | extension Country { 83 | 84 | @discardableResult 85 | func store(in context: NSManagedObjectContext) -> CountryMO? { 86 | guard let country = CountryMO.insertNew(in: context) 87 | else { return nil } 88 | country.name = name 89 | country.alpha3code = alpha3Code 90 | country.population = Int32(population) 91 | country.flagURL = flag?.absoluteString 92 | let translations = self.translations 93 | .compactMap { (locale, name) -> NameTranslationMO? in 94 | guard let name = name, 95 | let translation = NameTranslationMO.insertNew(in: context) 96 | else { return nil } 97 | translation.name = name 98 | translation.locale = locale 99 | return translation 100 | } 101 | country.nameTranslations = NSSet(array: translations) 102 | return country 103 | } 104 | 105 | init?(managedObject: CountryMO) { 106 | guard let nameTranslations = managedObject.nameTranslations 107 | else { return nil } 108 | let translations: [String: String?] = nameTranslations 109 | .toArray(of: NameTranslationMO.self) 110 | .reduce([:], { (dict, record) -> [String: String?] in 111 | guard let locale = record.locale, let name = record.name 112 | else { return dict } 113 | var dict = dict 114 | dict[locale] = name 115 | return dict 116 | }) 117 | guard let name = managedObject.name, 118 | let alpha3code = managedObject.alpha3code 119 | else { return nil } 120 | 121 | self.init(name: name, translations: translations, 122 | population: Int(managedObject.population), 123 | flag: managedObject.flagURL.flatMap { URL(string: $0) }, 124 | alpha3Code: alpha3code) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Models/Models.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Models.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Country: Codable, Equatable { 12 | let name: String 13 | let translations: [String: String?] 14 | let population: Int 15 | let flag: URL? 16 | let alpha3Code: Code 17 | 18 | typealias Code = String 19 | } 20 | 21 | extension Country { 22 | struct Details: Codable, Equatable { 23 | let capital: String 24 | let currencies: [Currency] 25 | let neighbors: [Country] 26 | } 27 | } 28 | 29 | extension Country.Details { 30 | struct Intermediate: Codable, Equatable { 31 | let capital: String 32 | let currencies: [Country.Currency] 33 | let borders: [String] 34 | } 35 | } 36 | 37 | extension Country { 38 | struct Currency: Codable, Equatable { 39 | let code: String 40 | let symbol: String? 41 | let name: String 42 | } 43 | } 44 | 45 | // MARK: - Helpers 46 | 47 | extension Country: Identifiable { 48 | var id: String { alpha3Code } 49 | } 50 | 51 | extension Country.Currency: Identifiable { 52 | var id: String { code } 53 | } 54 | 55 | extension Country { 56 | func name(locale: Locale) -> String { 57 | let localeId = locale.shortIdentifier 58 | if let value = translations[localeId], let localizedName = value { 59 | return localizedName 60 | } 61 | return name 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Persistence/CoreDataHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataHelpers.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 12.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import Combine 11 | 12 | // MARK: - ManagedEntity 13 | 14 | protocol ManagedEntity: NSFetchRequestResult { } 15 | 16 | extension ManagedEntity where Self: NSManagedObject { 17 | 18 | static var entityName: String { 19 | let nameMO = String(describing: Self.self) 20 | let suffixIndex = nameMO.index(nameMO.endIndex, offsetBy: -2) 21 | return String(nameMO[.. Self? { 25 | return NSEntityDescription 26 | .insertNewObject(forEntityName: entityName, into: context) as? Self 27 | } 28 | 29 | static func newFetchRequest() -> NSFetchRequest { 30 | return .init(entityName: entityName) 31 | } 32 | } 33 | 34 | // MARK: - NSManagedObjectContext 35 | 36 | extension NSManagedObjectContext { 37 | 38 | func configureAsReadOnlyContext() { 39 | automaticallyMergesChangesFromParent = true 40 | mergePolicy = NSRollbackMergePolicy 41 | undoManager = nil 42 | shouldDeleteInaccessibleFaults = true 43 | } 44 | 45 | func configureAsUpdateContext() { 46 | mergePolicy = NSOverwriteMergePolicy 47 | undoManager = nil 48 | } 49 | } 50 | 51 | // MARK: - Misc 52 | 53 | extension NSSet { 54 | func toArray(of type: T.Type) -> [T] { 55 | allObjects.compactMap { $0 as? T } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Persistence/CoreDataStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataStack.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 12.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import Combine 11 | 12 | protocol PersistentStore { 13 | typealias DBOperation = (NSManagedObjectContext) throws -> Result 14 | 15 | func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher 16 | func fetch(_ fetchRequest: NSFetchRequest, 17 | map: @escaping (T) throws -> V?) -> AnyPublisher, Error> 18 | func update(_ operation: @escaping DBOperation) -> AnyPublisher 19 | } 20 | 21 | struct CoreDataStack: PersistentStore { 22 | 23 | private let container: NSPersistentContainer 24 | private let isStoreLoaded = CurrentValueSubject(false) 25 | private let bgQueue = DispatchQueue(label: "coredata") 26 | 27 | init(directory: FileManager.SearchPathDirectory = .documentDirectory, 28 | domainMask: FileManager.SearchPathDomainMask = .userDomainMask, 29 | version vNumber: UInt) { 30 | let version = Version(vNumber) 31 | container = NSPersistentContainer(name: version.modelName) 32 | if let url = version.dbFileURL(directory, domainMask) { 33 | let store = NSPersistentStoreDescription(url: url) 34 | container.persistentStoreDescriptions = [store] 35 | } 36 | bgQueue.async { [weak isStoreLoaded, weak container] in 37 | container?.loadPersistentStores { (storeDescription, error) in 38 | DispatchQueue.main.async { 39 | if let error = error { 40 | isStoreLoaded?.send(completion: .failure(error)) 41 | } else { 42 | container?.viewContext.configureAsReadOnlyContext() 43 | isStoreLoaded?.value = true 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher { 51 | return onStoreIsReady 52 | .flatMap { [weak container] in 53 | Future { promise in 54 | do { 55 | let count = try container?.viewContext.count(for: fetchRequest) ?? 0 56 | promise(.success(count)) 57 | } catch { 58 | promise(.failure(error)) 59 | } 60 | } 61 | } 62 | .eraseToAnyPublisher() 63 | } 64 | 65 | func fetch(_ fetchRequest: NSFetchRequest, 66 | map: @escaping (T) throws -> V?) -> AnyPublisher, Error> { 67 | assert(Thread.isMainThread) 68 | let fetch = Future, Error> { [weak container] promise in 69 | guard let context = container?.viewContext else { return } 70 | context.performAndWait { 71 | do { 72 | let managedObjects = try context.fetch(fetchRequest) 73 | let results = LazyList(count: managedObjects.count, 74 | useCache: true) { [weak context] in 75 | let object = managedObjects[$0] 76 | let mapped = try map(object) 77 | if let mo = object as? NSManagedObject { 78 | // Turning object into a fault 79 | context?.refresh(mo, mergeChanges: false) 80 | } 81 | return mapped 82 | } 83 | promise(.success(results)) 84 | } catch { 85 | promise(.failure(error)) 86 | } 87 | } 88 | } 89 | return onStoreIsReady 90 | .flatMap { fetch } 91 | .eraseToAnyPublisher() 92 | } 93 | 94 | func update(_ operation: @escaping DBOperation) -> AnyPublisher { 95 | let update = Future { [weak bgQueue, weak container] promise in 96 | bgQueue?.async { 97 | guard let context = container?.newBackgroundContext() else { return } 98 | context.configureAsUpdateContext() 99 | context.performAndWait { 100 | do { 101 | let result = try operation(context) 102 | if context.hasChanges { 103 | try context.save() 104 | } 105 | context.reset() 106 | promise(.success(result)) 107 | } catch { 108 | context.reset() 109 | promise(.failure(error)) 110 | } 111 | } 112 | } 113 | } 114 | return onStoreIsReady 115 | .flatMap { update } 116 | // .subscribe(on: bgQueue) // Does not work as stated in the docs. Using `bgQueue.async` 117 | .receive(on: DispatchQueue.main) 118 | .eraseToAnyPublisher() 119 | } 120 | 121 | private var onStoreIsReady: AnyPublisher { 122 | return isStoreLoaded 123 | .filter { $0 } 124 | .map { _ in } 125 | .eraseToAnyPublisher() 126 | } 127 | } 128 | 129 | // MARK: - Versioning 130 | 131 | extension CoreDataStack.Version { 132 | static var actual: UInt { 1 } 133 | } 134 | 135 | extension CoreDataStack { 136 | struct Version { 137 | private let number: UInt 138 | 139 | init(_ number: UInt) { 140 | self.number = number 141 | } 142 | 143 | var modelName: String { 144 | return "db_model_v1" 145 | } 146 | 147 | func dbFileURL(_ directory: FileManager.SearchPathDirectory, 148 | _ domainMask: FileManager.SearchPathDomainMask) -> URL? { 149 | return FileManager.default 150 | .urls(for: directory, in: domainMask).first? 151 | .appendingPathComponent(subpathToDB) 152 | } 153 | 154 | private var subpathToDB: String { 155 | return "db.sql" 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Persistence/db_model_v1.xcdatamodeld/db_model_v1.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Repositories/CountriesDBRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountriesDBRepository.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 13.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import Combine 11 | 12 | protocol CountriesDBRepository { 13 | func hasLoadedCountries() -> AnyPublisher 14 | 15 | func store(countries: [Country]) -> AnyPublisher 16 | func countries(search: String, locale: Locale) -> AnyPublisher, Error> 17 | 18 | func store(countryDetails: Country.Details.Intermediate, 19 | for country: Country) -> AnyPublisher 20 | func countryDetails(country: Country) -> AnyPublisher 21 | } 22 | 23 | struct RealCountriesDBRepository: CountriesDBRepository { 24 | 25 | let persistentStore: PersistentStore 26 | 27 | func hasLoadedCountries() -> AnyPublisher { 28 | let fetchRequest = CountryMO.justOneCountry() 29 | return persistentStore 30 | .count(fetchRequest) 31 | .map { $0 > 0 } 32 | .eraseToAnyPublisher() 33 | } 34 | 35 | func store(countries: [Country]) -> AnyPublisher { 36 | return persistentStore 37 | .update { context in 38 | countries.forEach { 39 | $0.store(in: context) 40 | } 41 | } 42 | } 43 | 44 | func countries(search: String, locale: Locale) -> AnyPublisher, Error> { 45 | let fetchRequest = CountryMO.countries(search: search, locale: locale) 46 | return persistentStore 47 | .fetch(fetchRequest) { 48 | Country(managedObject: $0) 49 | } 50 | .eraseToAnyPublisher() 51 | } 52 | 53 | func store(countryDetails: Country.Details.Intermediate, 54 | for country: Country) -> AnyPublisher { 55 | return persistentStore 56 | .update { context in 57 | let parentRequest = CountryMO.countries(alpha3codes: [country.alpha3Code]) 58 | guard let parent = try context.fetch(parentRequest).first 59 | else { return nil } 60 | let neighbors = CountryMO.countries(alpha3codes: countryDetails.borders) 61 | let borders = try context.fetch(neighbors) 62 | let details = countryDetails.store(in: context, country: parent, borders: borders) 63 | return details.flatMap { Country.Details(managedObject: $0) } 64 | } 65 | } 66 | 67 | func countryDetails(country: Country) -> AnyPublisher { 68 | let fetchRequest = CountryDetailsMO.details(country: country) 69 | return persistentStore 70 | .fetch(fetchRequest) { 71 | Country.Details(managedObject: $0) 72 | } 73 | .map { $0.first } 74 | .eraseToAnyPublisher() 75 | } 76 | } 77 | 78 | // MARK: - Fetch Requests 79 | 80 | extension CountryMO { 81 | 82 | static func justOneCountry() -> NSFetchRequest { 83 | let request = newFetchRequest() 84 | request.predicate = NSPredicate(format: "alpha3code == %@", "USA") 85 | request.fetchLimit = 1 86 | return request 87 | } 88 | 89 | static func countries(search: String, locale: Locale) -> NSFetchRequest { 90 | let request = newFetchRequest() 91 | if search.count == 0 { 92 | request.predicate = NSPredicate(value: true) 93 | } else { 94 | let localeId = locale.shortIdentifier 95 | let nameMatch = NSPredicate(format: "name CONTAINS[cd] %@", search) 96 | let localizedMatch = NSPredicate(format: 97 | "(SUBQUERY(nameTranslations,$t,$t.locale == %@ AND $t.name CONTAINS[cd] %@).@count > 0)", localeId, search) 98 | request.predicate = NSCompoundPredicate(type: .or, subpredicates: [nameMatch, localizedMatch]) 99 | } 100 | request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] 101 | request.fetchBatchSize = 10 102 | return request 103 | } 104 | 105 | static func countries(alpha3codes: [String]) -> NSFetchRequest { 106 | let request = newFetchRequest() 107 | request.predicate = NSPredicate(format: "alpha3code in %@", alpha3codes) 108 | request.fetchLimit = alpha3codes.count 109 | return request 110 | } 111 | } 112 | 113 | extension CountryDetailsMO { 114 | static func details(country: Country) -> NSFetchRequest { 115 | let request = newFetchRequest() 116 | request.predicate = NSPredicate(format: "country.alpha3code == %@", country.alpha3Code) 117 | request.fetchLimit = 1 118 | return request 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Repositories/CountriesWebRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountriesWebRepository.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 29.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | protocol CountriesWebRepository: WebRepository { 13 | func loadCountries() -> AnyPublisher<[Country], Error> 14 | func loadCountryDetails(country: Country) -> AnyPublisher 15 | } 16 | 17 | struct RealCountriesWebRepository: CountriesWebRepository { 18 | 19 | let session: URLSession 20 | let baseURL: String 21 | let bgQueue = DispatchQueue(label: "bg_parse_queue") 22 | 23 | init(session: URLSession, baseURL: String) { 24 | self.session = session 25 | self.baseURL = baseURL 26 | } 27 | 28 | func loadCountries() -> AnyPublisher<[Country], Error> { 29 | return call(endpoint: API.allCountries) 30 | } 31 | 32 | func loadCountryDetails(country: Country) -> AnyPublisher { 33 | let request: AnyPublisher<[Country.Details.Intermediate], Error> = call(endpoint: API.countryDetails(country)) 34 | return request 35 | .tryMap { array -> Country.Details.Intermediate in 36 | guard let details = array.first 37 | else { throw APIError.unexpectedResponse } 38 | return details 39 | } 40 | .eraseToAnyPublisher() 41 | } 42 | } 43 | 44 | // MARK: - Endpoints 45 | 46 | extension RealCountriesWebRepository { 47 | enum API { 48 | case allCountries 49 | case countryDetails(Country) 50 | } 51 | } 52 | 53 | extension RealCountriesWebRepository.API: APICall { 54 | var path: String { 55 | switch self { 56 | case .allCountries: 57 | return "/all" 58 | case let .countryDetails(country): 59 | let encodedName = country.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) 60 | return "/name/\(encodedName ?? country.name)" 61 | } 62 | } 63 | var method: String { 64 | switch self { 65 | case .allCountries, .countryDetails: 66 | return "GET" 67 | } 68 | } 69 | var headers: [String: String]? { 70 | return ["Accept": "application/json"] 71 | } 72 | func body() throws -> Data? { 73 | return nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Repositories/PushTokenWebRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushTokenWebRepository.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | protocol PushTokenWebRepository: WebRepository { 13 | func register(devicePushToken: Data) -> AnyPublisher 14 | } 15 | 16 | struct RealPushTokenWebRepository: PushTokenWebRepository { 17 | 18 | let session: URLSession 19 | let baseURL: String 20 | let bgQueue = DispatchQueue(label: "bg_parse_queue") 21 | 22 | init(session: URLSession, baseURL: String) { 23 | self.session = session 24 | self.baseURL = baseURL 25 | } 26 | 27 | func register(devicePushToken: Data) -> AnyPublisher { 28 | // upload the push token to your server 29 | return Just.withErrorType(Error.self) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSPhotoLibraryAddUsageDescription 24 | EnvironmentOverrides screenshots saving 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | UISceneConfigurations 30 | 31 | UIWindowSceneSessionRoleApplication 32 | 33 | 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UIBackgroundModes 43 | 44 | remote-notification 45 | 46 | UILaunchStoryboardName 47 | LaunchScreen 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/en.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | 2 | "Running unit tests" = "Running unit tests"; 3 | "Countries" = "Countries"; 4 | "Basic Info" = "Basic Info"; 5 | "Code" = "Code"; 6 | "Population" = "Population"; 7 | "Capital" = "Capital"; 8 | "Currencies" = "Currencies"; 9 | "Neighboring countries" = "Neighboring countries"; 10 | "Close" = "Close"; 11 | "Population %lld" = "Population %lld"; 12 | "An Error Occured" = "An Error Occured"; 13 | "Retry" = "Retry"; 14 | "Unable to load image" = "Unable to load image"; 15 | "Back" = "Back"; 16 | "Cancel loading" = "Cancel loading"; 17 | "Canceled by user" = "Canceled by user"; 18 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | 2 | "Running unit tests" = "Ejecución de pruebas unitarias"; 3 | "Countries" = "Países"; 4 | "Basic Info" = "Información básica"; 5 | "Code" = "Código"; 6 | "Population" = "Población"; 7 | "Capital" = "Capital"; 8 | "Currencies" = "Monedas"; 9 | "Neighboring countries" = "Países vecinos"; 10 | "Close" = "Cerca"; 11 | "Population %lld" = "Población %lld"; 12 | "An Error Occured" = "Ocurrió un error"; 13 | "Retry" = "Procesar de nuevo"; 14 | "Unable to load image" = "No se puede cargar la imagen"; 15 | "Back" = "Atrás"; 16 | "Cancel loading" = "Cancelar carga"; 17 | "Canceled by user" = "Cancelado por el usuario"; 18 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/fr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | 2 | "Running unit tests" = "Exécution de tests unitaires"; 3 | "Countries" = "Des pays"; 4 | "Basic Info" = "Informations de base"; 5 | "Code" = "Code"; 6 | "Population" = "Population"; 7 | "Capital" = "Capitale"; 8 | "Currencies" = "Devises"; 9 | "Neighboring countries" = "Pays voisins"; 10 | "Close" = "Fermer"; 11 | "Population %lld" = "Population %lld"; 12 | "An Error Occured" = "Une erreur s'est produite"; 13 | "Retry" = "Retenter"; 14 | "Unable to load image" = "Impossible de charger l'image"; 15 | "Back" = "Retour"; 16 | "Cancel loading" = "Annuler le chargement"; 17 | "Canceled by user" = "Annulé par l'utilisateur"; 18 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Resources/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | 2 | "Running unit tests" = "単体テストの実行"; 3 | "Countries" = "国"; 4 | "Basic Info" = "基本情報"; 5 | "Code" = "コード"; 6 | "Population" = "人口"; 7 | "Capital" = "資本"; 8 | "Currencies" = "通貨"; 9 | "Neighboring countries" = "近隣諸国"; 10 | "Close" = "閉じる"; 11 | "Population %lld" = "人口 %lld"; 12 | "An Error Occured" = "エラーが発生しました"; 13 | "Retry" = "リトライ"; 14 | "Unable to load image" = "画像を読み込めません"; 15 | "Back" = "バック"; 16 | "Cancel loading" = "読み込みをキャンセル"; 17 | "Canceled by user" = "ユーザーによりキャンセルされました"; 18 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | 12 | typealias NotificationPayload = [AnyHashable: Any] 13 | typealias FetchCompletion = (UIBackgroundFetchResult) -> Void 14 | 15 | @UIApplicationMain 16 | final class AppDelegate: UIResponder, UIApplicationDelegate { 17 | 18 | lazy var systemEventsHandler: SystemEventsHandler? = { 19 | self.systemEventsHandler(UIApplication.shared) 20 | }() 21 | 22 | func application(_ application: UIApplication, didFinishLaunchingWithOptions 23 | launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 24 | return true 25 | } 26 | 27 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 28 | systemEventsHandler?.handlePushRegistration(result: .success(deviceToken)) 29 | } 30 | 31 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 32 | systemEventsHandler?.handlePushRegistration(result: .failure(error)) 33 | } 34 | 35 | func application(_ application: UIApplication, 36 | didReceiveRemoteNotification userInfo: NotificationPayload, 37 | fetchCompletionHandler completionHandler: @escaping FetchCompletion) { 38 | systemEventsHandler? 39 | .appDidReceiveRemoteNotification(payload: userInfo, fetchCompletion: completionHandler) 40 | } 41 | 42 | private func systemEventsHandler(_ application: UIApplication) -> SystemEventsHandler? { 43 | return sceneDelegate(application)?.systemEventsHandler 44 | } 45 | 46 | private func sceneDelegate(_ application: UIApplication) -> SceneDelegate? { 47 | return application.windows 48 | .compactMap({ $0.windowScene?.delegate as? SceneDelegate }) 49 | .first 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/AppEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppEnvironment.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 09.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | 12 | struct AppEnvironment { 13 | let container: DIContainer 14 | let systemEventsHandler: SystemEventsHandler 15 | } 16 | 17 | extension AppEnvironment { 18 | 19 | static func bootstrap() -> AppEnvironment { 20 | let appState = Store(AppState()) 21 | /* 22 | To see the deep linking in action: 23 | 24 | 1. Launch the app in iOS 13.4 simulator (or newer) 25 | 2. Subscribe on Push Notifications with "Allow Push" button 26 | 3. Minimize the app 27 | 4. Drag & drop "push_with_deeplink.apns" into the Simulator window 28 | 5. Tap on the push notification 29 | 30 | Alternatively, just copy the code below before the "return" and launch: 31 | 32 | DispatchQueue.main.async { 33 | deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: "AFG")) 34 | } 35 | */ 36 | let session = configuredURLSession() 37 | let webRepositories = configuredWebRepositories(session: session) 38 | let dbRepositories = configuredDBRepositories(appState: appState) 39 | let interactors = configuredInteractors(appState: appState, 40 | dbRepositories: dbRepositories, 41 | webRepositories: webRepositories) 42 | let diContainer = DIContainer(appState: appState, interactors: interactors) 43 | let deepLinksHandler = RealDeepLinksHandler(container: diContainer) 44 | let pushNotificationsHandler = RealPushNotificationsHandler(deepLinksHandler: deepLinksHandler) 45 | let systemEventsHandler = RealSystemEventsHandler( 46 | container: diContainer, deepLinksHandler: deepLinksHandler, 47 | pushNotificationsHandler: pushNotificationsHandler, 48 | pushTokenWebRepository: webRepositories.pushTokenWebRepository) 49 | return AppEnvironment(container: diContainer, 50 | systemEventsHandler: systemEventsHandler) 51 | } 52 | 53 | private static func configuredURLSession() -> URLSession { 54 | let configuration = URLSessionConfiguration.default 55 | configuration.timeoutIntervalForRequest = 60 56 | configuration.timeoutIntervalForResource = 120 57 | configuration.waitsForConnectivity = true 58 | configuration.httpMaximumConnectionsPerHost = 5 59 | configuration.requestCachePolicy = .returnCacheDataElseLoad 60 | configuration.urlCache = .shared 61 | return URLSession(configuration: configuration) 62 | } 63 | 64 | private static func configuredWebRepositories(session: URLSession) -> DIContainer.WebRepositories { 65 | let countriesWebRepository = RealCountriesWebRepository( 66 | session: session, 67 | baseURL: "https://restcountries.com/v2") 68 | let imageWebRepository = RealImageWebRepository( 69 | session: session, 70 | baseURL: "https://ezgif.com") 71 | let pushTokenWebRepository = RealPushTokenWebRepository( 72 | session: session, 73 | baseURL: "https://fake.backend.com") 74 | return .init(imageRepository: imageWebRepository, 75 | countriesRepository: countriesWebRepository, 76 | pushTokenWebRepository: pushTokenWebRepository) 77 | } 78 | 79 | private static func configuredDBRepositories(appState: Store) -> DIContainer.DBRepositories { 80 | let persistentStore = CoreDataStack(version: CoreDataStack.Version.actual) 81 | let countriesDBRepository = RealCountriesDBRepository(persistentStore: persistentStore) 82 | return .init(countriesRepository: countriesDBRepository) 83 | } 84 | 85 | private static func configuredInteractors(appState: Store, 86 | dbRepositories: DIContainer.DBRepositories, 87 | webRepositories: DIContainer.WebRepositories 88 | ) -> DIContainer.Interactors { 89 | 90 | let countriesInteractor = RealCountriesInteractor( 91 | webRepository: webRepositories.countriesRepository, 92 | dbRepository: dbRepositories.countriesRepository, 93 | appState: appState) 94 | 95 | let imagesInteractor = RealImagesInteractor( 96 | webRepository: webRepositories.imageRepository) 97 | 98 | let userPermissionsInteractor = RealUserPermissionsInteractor( 99 | appState: appState, openAppSettings: { 100 | URL(string: UIApplication.openSettingsURLString).flatMap { 101 | UIApplication.shared.open($0, options: [:], completionHandler: nil) 102 | } 103 | }) 104 | 105 | return .init(countriesInteractor: countriesInteractor, 106 | imagesInteractor: imagesInteractor, 107 | userPermissionsInteractor: userPermissionsInteractor) 108 | } 109 | } 110 | 111 | extension DIContainer { 112 | struct WebRepositories { 113 | let imageRepository: ImageWebRepository 114 | let countriesRepository: CountriesWebRepository 115 | let pushTokenWebRepository: PushTokenWebRepository 116 | } 117 | 118 | struct DBRepositories { 119 | let countriesRepository: CountriesDBRepository 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/DeepLinksHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinksHandler.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum DeepLink: Equatable { 12 | 13 | case showCountryFlag(alpha3Code: Country.Code) 14 | 15 | init?(url: URL) { 16 | guard 17 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true), 18 | components.host == "www.example.com", 19 | let query = components.queryItems 20 | else { return nil } 21 | if let item = query.first(where: { $0.name == "alpha3code" }), 22 | let alpha3Code = item.value { 23 | self = .showCountryFlag(alpha3Code: Country.Code(alpha3Code)) 24 | return 25 | } 26 | return nil 27 | } 28 | } 29 | 30 | // MARK: - DeepLinksHandler 31 | 32 | protocol DeepLinksHandler { 33 | func open(deepLink: DeepLink) 34 | } 35 | 36 | struct RealDeepLinksHandler: DeepLinksHandler { 37 | 38 | private let container: DIContainer 39 | 40 | init(container: DIContainer) { 41 | self.container = container 42 | } 43 | 44 | func open(deepLink: DeepLink) { 45 | switch deepLink { 46 | case let .showCountryFlag(alpha3Code): 47 | let routeToDestination = { 48 | self.container.appState.bulkUpdate { 49 | $0.routing.countriesList.countryDetails = alpha3Code 50 | $0.routing.countryDetails.detailsSheet = true 51 | } 52 | } 53 | /* 54 | SwiftUI is unable to perform complex navigation involving 55 | simultaneous dismissal or older screens and presenting new ones. 56 | A work around is to perform the navigation in two steps: 57 | */ 58 | let defaultRouting = AppState.ViewRouting() 59 | if container.appState.value.routing != defaultRouting { 60 | self.container.appState[\.routing] = defaultRouting 61 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: routeToDestination) 62 | } else { 63 | routeToDestination() 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/PushNotificationsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotificationsHandler.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UserNotifications 10 | 11 | protocol PushNotificationsHandler { } 12 | 13 | class RealPushNotificationsHandler: NSObject, PushNotificationsHandler { 14 | 15 | private let deepLinksHandler: DeepLinksHandler 16 | 17 | init(deepLinksHandler: DeepLinksHandler) { 18 | self.deepLinksHandler = deepLinksHandler 19 | super.init() 20 | UNUserNotificationCenter.current().delegate = self 21 | } 22 | } 23 | 24 | // MARK: - UNUserNotificationCenterDelegate 25 | 26 | extension RealPushNotificationsHandler: UNUserNotificationCenterDelegate { 27 | 28 | func userNotificationCenter(_ center: UNUserNotificationCenter, 29 | willPresent notification: UNNotification, 30 | withCompletionHandler completionHandler: 31 | @escaping (UNNotificationPresentationOptions) -> Void) { 32 | completionHandler([.alert, .sound]) 33 | } 34 | 35 | func userNotificationCenter(_ center: UNUserNotificationCenter, 36 | didReceive response: UNNotificationResponse, 37 | withCompletionHandler completionHandler: @escaping () -> Void) { 38 | let userInfo = response.notification.request.content.userInfo 39 | handleNotification(userInfo: userInfo, completionHandler: completionHandler) 40 | } 41 | 42 | func handleNotification(userInfo: [AnyHashable: Any], completionHandler: @escaping () -> Void) { 43 | guard let payload = userInfo["aps"] as? NotificationPayload, 44 | let countryCode = payload["country"] as? Country.Code else { 45 | completionHandler() 46 | return 47 | } 48 | deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: countryCode)) 49 | completionHandler() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Combine 12 | import Foundation 13 | 14 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 15 | 16 | var window: UIWindow? 17 | var systemEventsHandler: SystemEventsHandler? 18 | 19 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, 20 | options connectionOptions: UIScene.ConnectionOptions) { 21 | let environment = AppEnvironment.bootstrap() 22 | let contentView = ContentView(container: environment.container) 23 | if let windowScene = scene as? UIWindowScene { 24 | let window = UIWindow(windowScene: windowScene) 25 | window.rootViewController = UIHostingController(rootView: contentView) 26 | self.window = window 27 | window.makeKeyAndVisible() 28 | } 29 | self.systemEventsHandler = environment.systemEventsHandler 30 | if !connectionOptions.urlContexts.isEmpty { 31 | systemEventsHandler?.sceneOpenURLContexts(connectionOptions.urlContexts) 32 | } 33 | } 34 | 35 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 36 | systemEventsHandler?.sceneOpenURLContexts(URLContexts) 37 | } 38 | 39 | func sceneDidBecomeActive(_ scene: UIScene) { 40 | systemEventsHandler?.sceneDidBecomeActive() 41 | } 42 | 43 | func sceneWillResignActive(_ scene: UIScene) { 44 | systemEventsHandler?.sceneWillResignActive() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /CountriesSwiftUI/System/SystemEventsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemEventsHandler.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 27.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | 12 | protocol SystemEventsHandler { 13 | func sceneOpenURLContexts(_ urlContexts: Set) 14 | func sceneDidBecomeActive() 15 | func sceneWillResignActive() 16 | func handlePushRegistration(result: Result) 17 | func appDidReceiveRemoteNotification(payload: NotificationPayload, 18 | fetchCompletion: @escaping FetchCompletion) 19 | } 20 | 21 | struct RealSystemEventsHandler: SystemEventsHandler { 22 | 23 | let container: DIContainer 24 | let deepLinksHandler: DeepLinksHandler 25 | let pushNotificationsHandler: PushNotificationsHandler 26 | let pushTokenWebRepository: PushTokenWebRepository 27 | private var cancelBag = CancelBag() 28 | 29 | init(container: DIContainer, 30 | deepLinksHandler: DeepLinksHandler, 31 | pushNotificationsHandler: PushNotificationsHandler, 32 | pushTokenWebRepository: PushTokenWebRepository) { 33 | 34 | self.container = container 35 | self.deepLinksHandler = deepLinksHandler 36 | self.pushNotificationsHandler = pushNotificationsHandler 37 | self.pushTokenWebRepository = pushTokenWebRepository 38 | 39 | installKeyboardHeightObserver() 40 | installPushNotificationsSubscriberOnLaunch() 41 | } 42 | 43 | private func installKeyboardHeightObserver() { 44 | let appState = container.appState 45 | NotificationCenter.default.keyboardHeightPublisher 46 | .sink { [appState] height in 47 | appState[\.system.keyboardHeight] = height 48 | } 49 | .store(in: cancelBag) 50 | } 51 | 52 | private func installPushNotificationsSubscriberOnLaunch() { 53 | weak var permissions = container.interactors.userPermissionsInteractor 54 | container.appState 55 | .updates(for: AppState.permissionKeyPath(for: .pushNotifications)) 56 | .first(where: { $0 != .unknown }) 57 | .sink { status in 58 | if status == .granted { 59 | // If the permission was granted on previous launch 60 | // requesting the push token again: 61 | permissions?.request(permission: .pushNotifications) 62 | } 63 | } 64 | .store(in: cancelBag) 65 | } 66 | 67 | func sceneOpenURLContexts(_ urlContexts: Set) { 68 | guard let url = urlContexts.first?.url else { return } 69 | handle(url: url) 70 | } 71 | 72 | private func handle(url: URL) { 73 | guard let deepLink = DeepLink(url: url) else { return } 74 | deepLinksHandler.open(deepLink: deepLink) 75 | } 76 | 77 | func sceneDidBecomeActive() { 78 | container.appState[\.system.isActive] = true 79 | container.interactors.userPermissionsInteractor.resolveStatus(for: .pushNotifications) 80 | } 81 | 82 | func sceneWillResignActive() { 83 | container.appState[\.system.isActive] = false 84 | } 85 | 86 | func handlePushRegistration(result: Result) { 87 | if let pushToken = try? result.get() { 88 | pushTokenWebRepository 89 | .register(devicePushToken: pushToken) 90 | .sinkToResult { _ in } 91 | .store(in: cancelBag) 92 | } 93 | } 94 | 95 | func appDidReceiveRemoteNotification(payload: NotificationPayload, 96 | fetchCompletion: @escaping FetchCompletion) { 97 | container.interactors.countriesInteractor 98 | .refreshCountriesList() 99 | .sinkToResult { result in 100 | fetchCompletion(result.isSuccess ? .newData : .failed) 101 | } 102 | .store(in: cancelBag) 103 | } 104 | } 105 | 106 | // MARK: - Notifications 107 | 108 | private extension NotificationCenter { 109 | var keyboardHeightPublisher: AnyPublisher { 110 | let willShow = publisher(for: UIApplication.keyboardWillShowNotification) 111 | .map { $0.keyboardHeight } 112 | let willHide = publisher(for: UIApplication.keyboardWillHideNotification) 113 | .map { _ in CGFloat(0) } 114 | return Publishers.Merge(willShow, willHide) 115 | .eraseToAnyPublisher() 116 | } 117 | } 118 | 119 | private extension Notification { 120 | var keyboardHeight: CGFloat { 121 | return (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)? 122 | .cgRectValue.height ?? 0 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/ActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorView.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ActivityIndicatorView: UIViewRepresentable { 12 | 13 | func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { 14 | return UIActivityIndicatorView(style: .large) 15 | } 16 | 17 | func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) { 18 | uiView.startAnimating() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/CountryCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountryCell.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CountryCell: View { 12 | 13 | let country: Country 14 | @Environment(\.locale) var locale: Locale 15 | 16 | var body: some View { 17 | VStack(alignment: .leading) { 18 | Text(country.name(locale: locale)) 19 | .font(.title) 20 | Text("Population \(country.population)") 21 | .font(.caption) 22 | } 23 | .padding() 24 | .frame(maxWidth: .infinity, maxHeight: 60, alignment: .leading) 25 | } 26 | } 27 | 28 | #if DEBUG 29 | struct CountryCell_Previews: PreviewProvider { 30 | static var previews: some View { 31 | CountryCell(country: Country.mockedData[0]) 32 | .previewLayout(.fixed(width: 375, height: 60)) 33 | } 34 | } 35 | #endif 36 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/DetailRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailRow.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DetailRow: View { 12 | let leftLabel: Text 13 | let rightLabel: Text 14 | 15 | init(leftLabel: Text, rightLabel: Text) { 16 | self.leftLabel = leftLabel 17 | self.rightLabel = rightLabel 18 | } 19 | 20 | init(leftLabel: Text, rightLabel: LocalizedStringKey) { 21 | self.leftLabel = leftLabel 22 | self.rightLabel = Text(rightLabel) 23 | } 24 | 25 | var body: some View { 26 | HStack { 27 | leftLabel 28 | .font(.headline) 29 | Spacer() 30 | rightLabel 31 | .font(.callout) 32 | } 33 | .padding() 34 | .frame(maxWidth: .infinity, maxHeight: 40, alignment: .leading) 35 | } 36 | } 37 | 38 | #if DEBUG 39 | struct DetailRow_Previews: PreviewProvider { 40 | static var previews: some View { 41 | DetailRow(leftLabel: Text("Rate"), rightLabel: Text("$123.99")) 42 | .previewLayout(.fixed(width: 375, height: 40)) 43 | } 44 | } 45 | #endif 46 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ErrorView: View { 12 | let error: Error 13 | let retryAction: () -> Void 14 | 15 | var body: some View { 16 | VStack { 17 | Text("An Error Occured") 18 | .font(.title) 19 | Text(error.localizedDescription) 20 | .font(.callout) 21 | .multilineTextAlignment(.center) 22 | .padding(.bottom, 40).padding() 23 | Button(action: retryAction, label: { Text("Retry").bold() }) 24 | } 25 | } 26 | } 27 | 28 | #if DEBUG 29 | struct ErrorView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | ErrorView(error: NSError(domain: "", code: 0, userInfo: [ 32 | NSLocalizedDescriptionKey: "Something went wrong"]), 33 | retryAction: { }) 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/SVGImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SVGImageView.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import WebKit 12 | 13 | struct SVGImageView: View { 14 | 15 | let imageURL: URL 16 | @Environment(\.injected) var injected: DIContainer 17 | @State private var image: Loadable 18 | let inspection = Inspection() 19 | 20 | init(imageURL: URL, image: Loadable = .notRequested) { 21 | self.imageURL = imageURL 22 | self._image = .init(initialValue: image) 23 | } 24 | 25 | var body: some View { 26 | content 27 | .onReceive(inspection.notice) { self.inspection.visit(self, $0) } 28 | } 29 | 30 | private var content: AnyView { 31 | switch image { 32 | case .notRequested: return AnyView(notRequestedView) 33 | case .isLoading: return AnyView(loadingView) 34 | case let .loaded(image): return AnyView(loadedView(image)) 35 | case let .failed(error): return AnyView(failedView(error)) 36 | } 37 | } 38 | } 39 | 40 | // MARK: - Side Effects 41 | 42 | private extension SVGImageView { 43 | func loadImage() { 44 | injected.interactors.imagesInteractor 45 | .load(image: $image, url: imageURL) 46 | } 47 | } 48 | 49 | // MARK: - Content 50 | 51 | private extension SVGImageView { 52 | var notRequestedView: some View { 53 | Text("").onAppear { 54 | self.loadImage() 55 | } 56 | } 57 | 58 | var loadingView: some View { 59 | ActivityIndicatorView() 60 | } 61 | 62 | func failedView(_ error: Error) -> some View { 63 | Text("Unable to load image") 64 | .font(.footnote) 65 | .multilineTextAlignment(.center) 66 | .padding() 67 | } 68 | 69 | func loadedView(_ image: UIImage) -> some View { 70 | Image(uiImage: image) 71 | .resizable() 72 | .aspectRatio(contentMode: .fit) 73 | } 74 | } 75 | 76 | #if DEBUG 77 | struct SVGImageView_Previews: PreviewProvider { 78 | static var previews: some View { 79 | VStack { 80 | SVGImageView(imageURL: URL(string: "https://flagcdn.com/us.svg")!) 81 | SVGImageView(imageURL: URL(string: "https://flagcdn.com/al.svg")!) 82 | SVGImageView(imageURL: URL(string: "https://flagcdn.com/ru.svg")!) 83 | } 84 | } 85 | } 86 | #endif 87 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Components/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBar.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 14.01.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | struct SearchBar: UIViewRepresentable { 13 | 14 | @Binding var text: String 15 | 16 | func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { 17 | let searchBar = UISearchBar(frame: .zero) 18 | searchBar.delegate = context.coordinator 19 | return searchBar 20 | } 21 | 22 | func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { 23 | uiView.text = text 24 | } 25 | 26 | func makeCoordinator() -> SearchBar.Coordinator { 27 | return Coordinator(text: $text) 28 | } 29 | } 30 | 31 | extension SearchBar { 32 | final class Coordinator: NSObject, UISearchBarDelegate { 33 | 34 | let text: Binding 35 | 36 | init(text: Binding) { 37 | self.text = text 38 | } 39 | 40 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 41 | text.wrappedValue = searchText 42 | } 43 | 44 | func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { 45 | searchBar.setShowsCancelButton(true, animated: true) 46 | return true 47 | } 48 | 49 | func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { 50 | searchBar.setShowsCancelButton(false, animated: true) 51 | return true 52 | } 53 | 54 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 55 | searchBar.endEditing(true) 56 | searchBar.text = "" 57 | text.wrappedValue = "" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/RootViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootViewModifier.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 09.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | // MARK: - RootViewAppearance 13 | 14 | struct RootViewAppearance: ViewModifier { 15 | 16 | @Environment(\.injected) private var injected: DIContainer 17 | @State private var isActive: Bool = false 18 | internal let inspection = Inspection() 19 | 20 | func body(content: Content) -> some View { 21 | content 22 | .blur(radius: isActive ? 0 : 10) 23 | .onReceive(stateUpdate) { self.isActive = $0 } 24 | .onReceive(inspection.notice) { self.inspection.visit(self, $0) } 25 | } 26 | 27 | private var stateUpdate: AnyPublisher { 28 | injected.appState.updates(for: \.system.isActive) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Screens/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import EnvironmentOverrides 12 | 13 | struct ContentView: View { 14 | 15 | private let container: DIContainer 16 | private let isRunningTests: Bool 17 | 18 | init(container: DIContainer, isRunningTests: Bool = ProcessInfo.processInfo.isRunningTests) { 19 | self.container = container 20 | self.isRunningTests = isRunningTests 21 | } 22 | 23 | var body: some View { 24 | Group { 25 | if isRunningTests { 26 | Text("Running unit tests") 27 | } else { 28 | CountriesList() 29 | .attachEnvironmentOverrides(onChange: onChangeHandler) 30 | .inject(container) 31 | } 32 | } 33 | } 34 | 35 | var onChangeHandler: (EnvironmentValues.Diff) -> Void { 36 | return { diff in 37 | if !diff.isDisjoint(with: [.locale, .sizeCategory]) { 38 | self.container.appState[\.routing] = AppState.ViewRouting() 39 | } 40 | } 41 | } 42 | } 43 | 44 | // MARK: - Preview 45 | 46 | #if DEBUG 47 | struct ContentView_Previews: PreviewProvider { 48 | static var previews: some View { 49 | ContentView(container: .preview) 50 | } 51 | } 52 | #endif 53 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Screens/CountryDetails.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountryDetails.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 25.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct CountryDetails: View { 13 | 14 | let country: Country 15 | 16 | @Environment(\.locale) var locale: Locale 17 | @Environment(\.injected) private var injected: DIContainer 18 | @State private var details: Loadable 19 | @State private var routingState: Routing = .init() 20 | private var routingBinding: Binding { 21 | $routingState.dispatched(to: injected.appState, \.routing.countryDetails) 22 | } 23 | let inspection = Inspection() 24 | 25 | init(country: Country, details: Loadable = .notRequested) { 26 | self.country = country 27 | self._details = .init(initialValue: details) 28 | } 29 | 30 | var body: some View { 31 | content 32 | .navigationBarTitle(country.name(locale: locale)) 33 | .onReceive(routingUpdate) { self.routingState = $0 } 34 | .onReceive(inspection.notice) { self.inspection.visit(self, $0) } 35 | } 36 | 37 | private var content: AnyView { 38 | switch details { 39 | case .notRequested: return AnyView(notRequestedView) 40 | case .isLoading: return AnyView(loadingView) 41 | case let .loaded(countryDetails): return AnyView(loadedView(countryDetails)) 42 | case let .failed(error): return AnyView(failedView(error)) 43 | } 44 | } 45 | } 46 | 47 | // MARK: - Side Effects 48 | 49 | private extension CountryDetails { 50 | func loadCountryDetails() { 51 | injected.interactors.countriesInteractor 52 | .load(countryDetails: $details, country: country) 53 | } 54 | 55 | func showCountryDetailsSheet() { 56 | injected.appState[\.routing.countryDetails.detailsSheet] = true 57 | } 58 | } 59 | 60 | // MARK: - Loading Content 61 | 62 | private extension CountryDetails { 63 | var notRequestedView: some View { 64 | Text("").onAppear { 65 | self.loadCountryDetails() 66 | } 67 | } 68 | 69 | var loadingView: some View { 70 | VStack { 71 | ActivityIndicatorView() 72 | Button(action: { 73 | self.details.cancelLoading() 74 | }, label: { Text("Cancel loading") }) 75 | } 76 | } 77 | 78 | func failedView(_ error: Error) -> some View { 79 | ErrorView(error: error, retryAction: { 80 | self.loadCountryDetails() 81 | }) 82 | } 83 | } 84 | 85 | // MARK: - Displaying Content 86 | 87 | private extension CountryDetails { 88 | func loadedView(_ countryDetails: Country.Details) -> some View { 89 | List { 90 | country.flag.map { url in 91 | flagView(url: url) 92 | } 93 | basicInfoSectionView(countryDetails: countryDetails) 94 | if countryDetails.currencies.count > 0 { 95 | currenciesSectionView(currencies: countryDetails.currencies) 96 | } 97 | if countryDetails.neighbors.count > 0 { 98 | neighborsSectionView(neighbors: countryDetails.neighbors) 99 | } 100 | } 101 | .listStyle(GroupedListStyle()) 102 | .sheet(isPresented: routingBinding.detailsSheet, 103 | content: { self.modalDetailsView() }) 104 | } 105 | 106 | func flagView(url: URL) -> some View { 107 | HStack { 108 | Spacer() 109 | SVGImageView(imageURL: url) 110 | .frame(width: 120, height: 80) 111 | .onTapGesture { 112 | self.showCountryDetailsSheet() 113 | } 114 | Spacer() 115 | } 116 | } 117 | 118 | func basicInfoSectionView(countryDetails: Country.Details) -> some View { 119 | Section(header: Text("Basic Info")) { 120 | DetailRow(leftLabel: Text(country.alpha3Code), rightLabel: "Code") 121 | DetailRow(leftLabel: Text("\(country.population)"), rightLabel: "Population") 122 | DetailRow(leftLabel: Text("\(countryDetails.capital)"), rightLabel: "Capital") 123 | } 124 | } 125 | 126 | func currenciesSectionView(currencies: [Country.Currency]) -> some View { 127 | Section(header: Text("Currencies")) { 128 | ForEach(currencies) { currency in 129 | DetailRow(leftLabel: Text(currency.title), rightLabel: Text(currency.code)) 130 | } 131 | } 132 | } 133 | 134 | func neighborsSectionView(neighbors: [Country]) -> some View { 135 | Section(header: Text("Neighboring countries")) { 136 | ForEach(neighbors) { country in 137 | NavigationLink(destination: self.neighbourDetailsView(country: country)) { 138 | DetailRow(leftLabel: Text(country.name(locale: self.locale)), rightLabel: "") 139 | } 140 | } 141 | } 142 | } 143 | 144 | func neighbourDetailsView(country: Country) -> some View { 145 | CountryDetails(country: country) 146 | } 147 | 148 | func modalDetailsView() -> some View { 149 | ModalDetailsView(country: country, 150 | isDisplayed: routingBinding.detailsSheet) 151 | .inject(injected) 152 | } 153 | } 154 | 155 | // MARK: - Helpers 156 | 157 | private extension Country.Currency { 158 | var title: String { 159 | return name + (symbol.map {" " + $0} ?? "") 160 | } 161 | } 162 | 163 | // MARK: - Routing 164 | 165 | extension CountryDetails { 166 | struct Routing: Equatable { 167 | var detailsSheet: Bool = false 168 | } 169 | } 170 | 171 | // MARK: - State Updates 172 | 173 | private extension CountryDetails { 174 | 175 | var routingUpdate: AnyPublisher { 176 | injected.appState.updates(for: \.routing.countryDetails) 177 | } 178 | } 179 | 180 | // MARK: - Preview 181 | 182 | #if DEBUG 183 | struct CountryDetails_Previews: PreviewProvider { 184 | static var previews: some View { 185 | CountryDetails(country: Country.mockedData[0]) 186 | .inject(.preview) 187 | } 188 | } 189 | #endif 190 | -------------------------------------------------------------------------------- /CountriesSwiftUI/UI/Screens/ModalDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalDetailsView.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 26.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ModalDetailsView: View { 12 | 13 | let country: Country 14 | @Binding var isDisplayed: Bool 15 | let inspection = Inspection() 16 | 17 | var body: some View { 18 | NavigationView { 19 | VStack { 20 | country.flag.map { url in 21 | HStack { 22 | Spacer() 23 | SVGImageView(imageURL: url) 24 | .frame(width: 300, height: 200) 25 | Spacer() 26 | } 27 | } 28 | closeButton.padding(.top, 40) 29 | } 30 | .navigationBarTitle(Text(country.name), displayMode: .inline) 31 | } 32 | .navigationViewStyle(StackNavigationViewStyle()) 33 | .onReceive(inspection.notice) { self.inspection.visit(self, $0) } 34 | .attachEnvironmentOverrides() 35 | } 36 | 37 | private var closeButton: some View { 38 | Button(action: { 39 | self.isDisplayed = false 40 | }, label: { Text("Close") }) 41 | } 42 | } 43 | 44 | #if DEBUG 45 | struct ModalDetailsView_Previews: PreviewProvider { 46 | 47 | @State static var isDisplayed: Bool = true 48 | 49 | static var previews: some View { 50 | ModalDetailsView(country: Country.mockedData[0], isDisplayed: $isDisplayed) 51 | .inject(.preview) 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/APICall.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APICall.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol APICall { 12 | var path: String { get } 13 | var method: String { get } 14 | var headers: [String: String]? { get } 15 | func body() throws -> Data? 16 | } 17 | 18 | enum APIError: Swift.Error { 19 | case invalidURL 20 | case httpCode(HTTPCode) 21 | case unexpectedResponse 22 | case imageProcessing([URLRequest]) 23 | } 24 | 25 | extension APIError: LocalizedError { 26 | var errorDescription: String? { 27 | switch self { 28 | case .invalidURL: return "Invalid URL" 29 | case let .httpCode(code): return "Unexpected HTTP code: \(code)" 30 | case .unexpectedResponse: return "Unexpected response from the server" 31 | case .imageProcessing: return "Unable to load image" 32 | } 33 | } 34 | } 35 | 36 | extension APICall { 37 | func urlRequest(baseURL: String) throws -> URLRequest { 38 | guard let url = URL(string: baseURL + path) else { 39 | throw APIError.invalidURL 40 | } 41 | var request = URLRequest(url: url) 42 | request.httpMethod = method 43 | request.allHTTPHeaderFields = headers 44 | request.httpBody = try body() 45 | return request 46 | } 47 | } 48 | 49 | typealias HTTPCode = Int 50 | typealias HTTPCodes = Range 51 | 52 | extension HTTPCodes { 53 | static let success = 200 ..< 300 54 | } 55 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/CancelBag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancelBag.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 04.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | final class CancelBag { 12 | fileprivate(set) var subscriptions = Set() 13 | 14 | func cancel() { 15 | subscriptions.removeAll() 16 | } 17 | } 18 | 19 | extension AnyCancellable { 20 | 21 | func store(in cancelBag: CancelBag) { 22 | cancelBag.subscriptions.insert(self) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helpers.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 10.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | // MARK: - General 13 | 14 | extension ProcessInfo { 15 | var isRunningTests: Bool { 16 | environment["XCTestConfigurationFilePath"] != nil 17 | } 18 | } 19 | 20 | extension String { 21 | func localized(_ locale: Locale) -> String { 22 | let localeId = locale.shortIdentifier 23 | guard let path = Bundle.main.path(forResource: localeId, ofType: "lproj"), 24 | let bundle = Bundle(path: path) else { 25 | return NSLocalizedString(self, comment: "") 26 | } 27 | return bundle.localizedString(forKey: self, value: nil, table: nil) 28 | } 29 | } 30 | 31 | extension Result { 32 | var isSuccess: Bool { 33 | switch self { 34 | case .success: return true 35 | case .failure: return false 36 | } 37 | } 38 | } 39 | 40 | // MARK: - View Inspection helper 41 | 42 | internal final class Inspection { 43 | let notice = PassthroughSubject() 44 | var callbacks = [UInt: (V) -> Void]() 45 | 46 | func visit(_ view: V, _ line: UInt) { 47 | if let callback = callbacks.removeValue(forKey: line) { 48 | callback(view) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/LazyList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyList.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 18.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct LazyList { 12 | 13 | typealias Access = (Int) throws -> T? 14 | private let access: Access 15 | private let useCache: Bool 16 | private var cache = Cache() 17 | 18 | let count: Int 19 | 20 | init(count: Int, useCache: Bool, _ access: @escaping Access) { 21 | self.count = count 22 | self.useCache = useCache 23 | self.access = access 24 | } 25 | 26 | func element(at index: Int) throws -> T { 27 | guard useCache else { 28 | return try get(at: index) 29 | } 30 | return try cache.sync { elements in 31 | if let element = elements[index] { 32 | return element 33 | } 34 | let element = try get(at: index) 35 | elements[index] = element 36 | return element 37 | } 38 | } 39 | 40 | private func get(at index: Int) throws -> T { 41 | guard let element = try access(index) else { 42 | throw Error.elementIsNil(index: index) 43 | } 44 | return element 45 | } 46 | 47 | static var empty: Self { 48 | return .init(count: 0, useCache: false) { index in 49 | throw Error.elementIsNil(index: index) 50 | } 51 | } 52 | } 53 | 54 | private extension LazyList { 55 | class Cache { 56 | 57 | private var elements = [Int: T]() 58 | 59 | func sync(_ access: (inout [Int: T]) throws -> T) throws -> T { 60 | guard Thread.isMainThread else { 61 | var result: T! 62 | try DispatchQueue.main.sync { 63 | result = try access(&elements) 64 | } 65 | return result 66 | } 67 | return try access(&elements) 68 | } 69 | } 70 | } 71 | 72 | extension LazyList: Sequence { 73 | 74 | enum Error: LocalizedError { 75 | case elementIsNil(index: Int) 76 | 77 | var localizedDescription: String { 78 | switch self { 79 | case let .elementIsNil(index): 80 | return "Element at index \(index) is nil" 81 | } 82 | } 83 | } 84 | 85 | struct Iterator: IteratorProtocol { 86 | typealias Element = T 87 | private var index = -1 88 | private var list: LazyList 89 | 90 | init(list: LazyList) { 91 | self.list = list 92 | } 93 | 94 | mutating func next() -> Element? { 95 | index += 1 96 | guard index < list.count else { 97 | return nil 98 | } 99 | do { 100 | return try list.element(at: index) 101 | } catch _ { 102 | return nil 103 | } 104 | } 105 | } 106 | 107 | func makeIterator() -> Iterator { 108 | .init(list: self) 109 | } 110 | 111 | var underestimatedCount: Int { count } 112 | } 113 | 114 | extension LazyList: RandomAccessCollection { 115 | 116 | typealias Index = Int 117 | var startIndex: Index { 0 } 118 | var endIndex: Index { count } 119 | 120 | subscript(index: Index) -> Iterator.Element { 121 | do { 122 | return try element(at: index) 123 | } catch let error { 124 | fatalError("\(error)") 125 | } 126 | } 127 | 128 | public func index(after index: Index) -> Index { 129 | return index + 1 130 | } 131 | 132 | public func index(before index: Index) -> Index { 133 | return index - 1 134 | } 135 | } 136 | 137 | extension LazyList: Equatable where T: Equatable { 138 | static func == (lhs: LazyList, rhs: LazyList) -> Bool { 139 | guard lhs.count == rhs.count else { return false } 140 | return zip(lhs, rhs).first(where: { $0 != $1 }) == nil 141 | } 142 | } 143 | 144 | extension LazyList: CustomStringConvertible { 145 | var description: String { 146 | let elements = self.reduce("", { str, element in 147 | if str.count == 0 { 148 | return "\(element)" 149 | } 150 | return str + ", \(element)" 151 | }) 152 | return "LazyList<[\(elements)]>" 153 | } 154 | } 155 | 156 | extension RandomAccessCollection { 157 | var lazyList: LazyList { 158 | return .init(count: self.count, useCache: false) { 159 | guard $0 < self.count else { return nil } 160 | let index = self.index(self.startIndex, offsetBy: $0) 161 | return self[index] 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/Loadable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Loadable.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | typealias LoadableSubject = Binding> 13 | 14 | enum Loadable { 15 | 16 | case notRequested 17 | case isLoading(last: T?, cancelBag: CancelBag) 18 | case loaded(T) 19 | case failed(Error) 20 | 21 | var value: T? { 22 | switch self { 23 | case let .loaded(value): return value 24 | case let .isLoading(last, _): return last 25 | default: return nil 26 | } 27 | } 28 | var error: Error? { 29 | switch self { 30 | case let .failed(error): return error 31 | default: return nil 32 | } 33 | } 34 | } 35 | 36 | extension Loadable { 37 | 38 | mutating func setIsLoading(cancelBag: CancelBag) { 39 | self = .isLoading(last: value, cancelBag: cancelBag) 40 | } 41 | 42 | mutating func cancelLoading() { 43 | switch self { 44 | case let .isLoading(last, cancelBag): 45 | cancelBag.cancel() 46 | if let last = last { 47 | self = .loaded(last) 48 | } else { 49 | let error = NSError( 50 | domain: NSCocoaErrorDomain, code: NSUserCancelledError, 51 | userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Canceled by user", 52 | comment: "")]) 53 | self = .failed(error) 54 | } 55 | default: break 56 | } 57 | } 58 | 59 | func map(_ transform: (T) throws -> V) -> Loadable { 60 | do { 61 | switch self { 62 | case .notRequested: return .notRequested 63 | case let .failed(error): return .failed(error) 64 | case let .isLoading(value, cancelBag): 65 | return .isLoading(last: try value.map { try transform($0) }, 66 | cancelBag: cancelBag) 67 | case let .loaded(value): 68 | return .loaded(try transform(value)) 69 | } 70 | } catch { 71 | return .failed(error) 72 | } 73 | } 74 | } 75 | 76 | protocol SomeOptional { 77 | associatedtype Wrapped 78 | func unwrap() throws -> Wrapped 79 | } 80 | 81 | struct ValueIsMissingError: Error { 82 | var localizedDescription: String { 83 | NSLocalizedString("Data is missing", comment: "") 84 | } 85 | } 86 | 87 | extension Optional: SomeOptional { 88 | func unwrap() throws -> Wrapped { 89 | switch self { 90 | case let .some(value): return value 91 | case .none: throw ValueIsMissingError() 92 | } 93 | } 94 | } 95 | 96 | extension Loadable where T: SomeOptional { 97 | func unwrap() -> Loadable { 98 | map { try $0.unwrap() } 99 | } 100 | } 101 | 102 | extension Loadable: Equatable where T: Equatable { 103 | static func == (lhs: Loadable, rhs: Loadable) -> Bool { 104 | switch (lhs, rhs) { 105 | case (.notRequested, .notRequested): return true 106 | case let (.isLoading(lhsV, _), .isLoading(rhsV, _)): return lhsV == rhsV 107 | case let (.loaded(lhsV), .loaded(rhsV)): return lhsV == rhsV 108 | case let (.failed(lhsE), .failed(rhsE)): 109 | return lhsE.localizedDescription == rhsE.localizedDescription 110 | default: return false 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/NetworkingHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkingHelpers.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 04.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import Foundation 12 | 13 | extension Just where Output == Void { 14 | static func withErrorType(_ errorType: E.Type) -> AnyPublisher { 15 | return withErrorType((), E.self) 16 | } 17 | } 18 | 19 | extension Just { 20 | static func withErrorType(_ value: Output, _ errorType: E.Type 21 | ) -> AnyPublisher { 22 | return Just(value) 23 | .setFailureType(to: E.self) 24 | .eraseToAnyPublisher() 25 | } 26 | } 27 | 28 | extension Publisher { 29 | func sinkToResult(_ result: @escaping (Result) -> Void) -> AnyCancellable { 30 | return sink(receiveCompletion: { completion in 31 | switch completion { 32 | case let .failure(error): 33 | result(.failure(error)) 34 | default: break 35 | } 36 | }, receiveValue: { value in 37 | result(.success(value)) 38 | }) 39 | } 40 | 41 | func sinkToLoadable(_ completion: @escaping (Loadable) -> Void) -> AnyCancellable { 42 | return sink(receiveCompletion: { subscriptionCompletion in 43 | if let error = subscriptionCompletion.error { 44 | completion(.failed(error)) 45 | } 46 | }, receiveValue: { value in 47 | completion(.loaded(value)) 48 | }) 49 | } 50 | 51 | func extractUnderlyingError() -> Publishers.MapError { 52 | mapError { 53 | ($0.underlyingError as? Failure) ?? $0 54 | } 55 | } 56 | 57 | /// Holds the downstream delivery of output until the specified time interval passed after the subscription 58 | /// Does not hold the output if it arrives later than the time threshold 59 | /// 60 | /// - Parameters: 61 | /// - interval: The minimum time interval that should elapse after the subscription. 62 | /// - Returns: A publisher that optionally delays delivery of elements to the downstream receiver. 63 | 64 | func ensureTimeSpan(_ interval: TimeInterval) -> AnyPublisher { 65 | let timer = Just(()) 66 | .delay(for: .seconds(interval), scheduler: RunLoop.main) 67 | .setFailureType(to: Failure.self) 68 | return zip(timer) 69 | .map { $0.0 } 70 | .eraseToAnyPublisher() 71 | } 72 | } 73 | 74 | private extension Error { 75 | var underlyingError: Error? { 76 | let nsError = self as NSError 77 | if nsError.domain == NSURLErrorDomain && nsError.code == -1009 { 78 | // "The Internet connection appears to be offline." 79 | return self 80 | } 81 | return nsError.userInfo[NSUnderlyingErrorKey] as? Error 82 | } 83 | } 84 | 85 | extension Subscribers.Completion { 86 | var error: Failure? { 87 | switch self { 88 | case let .failure(error): return error 89 | default: return nil 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Store.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 04.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | typealias Store = CurrentValueSubject 13 | 14 | extension Store { 15 | 16 | subscript(keyPath: WritableKeyPath) -> T where T: Equatable { 17 | get { value[keyPath: keyPath] } 18 | set { 19 | var value = self.value 20 | if value[keyPath: keyPath] != newValue { 21 | value[keyPath: keyPath] = newValue 22 | self.value = value 23 | } 24 | } 25 | } 26 | 27 | func bulkUpdate(_ update: (inout Output) -> Void) { 28 | var value = self.value 29 | update(&value) 30 | self.value = value 31 | } 32 | 33 | func updates(for keyPath: KeyPath) -> 34 | AnyPublisher where Value: Equatable { 35 | return map(keyPath).removeDuplicates().eraseToAnyPublisher() 36 | } 37 | } 38 | 39 | // MARK: - 40 | 41 | extension Binding where Value: Equatable { 42 | func dispatched(to state: Store, 43 | _ keyPath: WritableKeyPath) -> Self { 44 | return onSet { state[keyPath] = $0 } 45 | } 46 | } 47 | 48 | extension Binding where Value: Equatable { 49 | typealias ValueClosure = (Value) -> Void 50 | 51 | func onSet(_ perform: @escaping ValueClosure) -> Self { 52 | return .init(get: { () -> Value in 53 | self.wrappedValue 54 | }, set: { value in 55 | if self.wrappedValue != value { 56 | self.wrappedValue = value 57 | } 58 | perform(value) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CountriesSwiftUI/Utilities/WebRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebRepository.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 23.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | protocol WebRepository { 13 | var session: URLSession { get } 14 | var baseURL: String { get } 15 | var bgQueue: DispatchQueue { get } 16 | } 17 | 18 | extension WebRepository { 19 | func call(endpoint: APICall, httpCodes: HTTPCodes = .success) -> AnyPublisher 20 | where Value: Decodable { 21 | do { 22 | let request = try endpoint.urlRequest(baseURL: baseURL) 23 | return session 24 | .dataTaskPublisher(for: request) 25 | .requestJSON(httpCodes: httpCodes) 26 | } catch let error { 27 | return Fail(error: error).eraseToAnyPublisher() 28 | } 29 | } 30 | } 31 | 32 | // MARK: - Helpers 33 | 34 | private extension Publisher where Output == URLSession.DataTaskPublisher.Output { 35 | func requestJSON(httpCodes: HTTPCodes) -> AnyPublisher where Value: Decodable { 36 | return tryMap { 37 | assert(!Thread.isMainThread) 38 | guard let code = ($0.1 as? HTTPURLResponse)?.statusCode else { 39 | throw APIError.unexpectedResponse 40 | } 41 | guard httpCodes.contains(code) else { 42 | throw APIError.httpCode(code) 43 | } 44 | return $0.0 45 | } 46 | .extractUnderlyingError() 47 | .decode(type: Value.self, decoder: JSONDecoder()) 48 | .receive(on: DispatchQueue.main) 49 | .eraseToAnyPublisher() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PushNotificationPayload/push_with_deeplink.apns: -------------------------------------------------------------------------------- 1 | { 2 | "Simulator Target Bundle": "com.countries.swiftui", 3 | "aps" : { 4 | "alert" : { 5 | "title": "SwiftUI Countries", 6 | "body" : "🇦🇩Tap to see Andorra's flag" 7 | }, 8 | "country" : "AND" 9 | } 10 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Articles related to this project 2 | 3 | * [Clean Architecture for SwiftUI](https://nalexn.github.io/clean-architecture-swiftui/?utm_source=nalexn_github) 4 | * [Programmatic navigation in SwiftUI project](https://nalexn.github.io/swiftui-deep-linking/?utm_source=nalexn_github) 5 | * [Separation of Concerns in Software Design](https://nalexn.github.io/separation-of-concerns/?utm_source=nalexn_github) 6 | 7 | --- 8 | 9 | # Clean Architecture for SwiftUI + Combine 10 | 11 | A demo project showcasing the setup of the SwiftUI app with Clean Architecture. 12 | 13 | The app uses the [restcountries.com](https://restcountries.com/) REST API to show the list of countries and details about them. 14 | 15 | **Check out [mvvm branch](https://github.com/nalexn/clean-architecture-swiftui/tree/mvvm) for the MVVM revision of the same app.** 16 | 17 | For the example of handling the **authentication state** in the app, you can refer to my [other tiny project](https://github.com/nalexn/uikit-swiftui) that harnesses the locks and keys principle for solving this problem. 18 | 19 | ![platforms](https://img.shields.io/badge/platforms-iPhone%20%7C%20iPad%20%7C%20macOS-lightgrey) [![Build Status](https://travis-ci.com/nalexn/clean-architecture-swiftui.svg?branch=master)](https://travis-ci.com/nalexn/clean-architecture-swiftui) [![codecov](https://codecov.io/gh/nalexn/clean-architecture-swiftui/branch/master/graph/badge.svg)](https://codecov.io/gh/nalexn/clean-architecture-swiftui) [![codebeat badge](https://codebeat.co/badges/db33561b-0b2b-4ee1-a941-a08efbd0ebd7)](https://codebeat.co/projects/github-com-nalexn-clean-architecture-swiftui-master) 20 | 21 |

22 | Diagram 23 |

24 | 25 | ## Key features 26 | * Vanilla **SwiftUI** + **Combine** implementation 27 | * Decoupled **Presentation**, **Business Logic**, and **Data Access** layers 28 | * Full test coverage, including the UI (thanks to the [ViewInspector](https://github.com/nalexn/ViewInspector)) 29 | * **Redux**-like centralized `AppState` as the single source of truth 30 | * Data persistence with **CoreData** 31 | * Native SwiftUI dependency injection 32 | * **Programmatic navigation**. Push notifications with deep link 33 | * Simple yet flexible networking layer built on Generics 34 | * Handling of the system events (such as `didBecomeActive`, `willResignActive`) 35 | * Built with SOLID, DRY, KISS, YAGNI in mind 36 | * Designed for scalability. It can be used as a reference for building large production apps 37 | 38 | ## Architecture overview 39 | 40 |

41 | Diagram 42 |

43 | 44 | ### Presentation Layer 45 | 46 | **SwiftUI views** that contain no business logic and are a function of the state. 47 | 48 | Side effects are triggered by the user's actions (such as a tap on a button) or view lifecycle event `onAppear` and are forwarded to the `Interactors`. 49 | 50 | State and business logic layer (`AppState` + `Interactors`) are navitely injected into the view hierarchy with `@Environment`. 51 | 52 | ### Business Logic Layer 53 | 54 | Business Logic Layer is represented by `Interactors`. 55 | 56 | Interactors receive requests to perform work, such as obtaining data from an external source or making computations, but they never return data back directly. 57 | 58 | Instead, they forward the result to the `AppState` or to a `Binding`. The latter is used when the result of work (the data) is used locally by one View and does not belong to the `AppState`. 59 | 60 | [Previously](https://github.com/nalexn/clean-architecture-swiftui/releases/tag/1.0), this app did not use CoreData for persistence, and all loaded data were stored in the `AppState`. 61 | 62 | With the persistence layer in place we have a choice - either to load the DB content onto the `AppState`, or serve the data from `Interactors` on an on-demand basis through `Binding`. 63 | 64 | The first option suits best when you don't have a lot of data, for example, when you just store the last used login email in the `UserDefaults`. Then, the corresponding string value can just be loaded onto the `AppState` at launch and updated by the `Interactor` when the user changes the input. 65 | 66 | The second option is better when you have massive amounts of data and introduce a fully-fledged database for storing it locally. 67 | 68 | ### Data Access Layer 69 | 70 | Data Access Layer is represented by `Repositories`. 71 | 72 | Repositories provide asynchronous API (`Publisher` from Combine) for making [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) operations on the backend or a local database. They don't contain business logic, neither do they mutate the `AppState`. Repositories are accessible and used only by the Interactors. 73 | 74 | --- 75 | 76 | [![Twitter](https://img.shields.io/badge/twitter-nallexn-blue)](https://twitter.com/nallexn) [![blog](https://img.shields.io/badge/blog-github-blue)](https://nalexn.github.io/?utm_source=nalexn_github) [![venmo](https://img.shields.io/badge/%F0%9F%8D%BA-Venmo-brightgreen)](https://venmo.com/nallexn) 77 | -------------------------------------------------------------------------------- /UnitTests/Interactors/ImagesInteractorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagesInteractorTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 10.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | final class ImagesInteractorTests: XCTestCase { 14 | 15 | var sut: RealImagesInteractor! 16 | var mockedWebRepository: MockedImageWebRepository! 17 | var subscriptions = Set() 18 | let testImageURL = URL(string: "https://test.com/test.png")! 19 | let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) 20 | 21 | override func setUp() { 22 | mockedWebRepository = MockedImageWebRepository() 23 | sut = RealImagesInteractor(webRepository: mockedWebRepository) 24 | subscriptions = Set() 25 | } 26 | 27 | func expectRepoActions(_ actions: [MockedImageWebRepository.Action]) { 28 | mockedWebRepository.actions = .init(expected: actions) 29 | } 30 | 31 | func verifyRepoActions(file: StaticString = #file, line: UInt = #line) { 32 | mockedWebRepository.verify(file: file, line: line) 33 | } 34 | 35 | func test_loadImage_nilURL() { 36 | let image = BindingWithPublisher(value: Loadable.notRequested) 37 | expectRepoActions([]) 38 | sut.load(image: image.binding, url: nil) 39 | let exp = XCTestExpectation(description: "Completion") 40 | image.updatesRecorder.sink { updates in 41 | XCTAssertEqual(updates, [ 42 | .notRequested, 43 | .notRequested 44 | ]) 45 | self.verifyRepoActions() 46 | exp.fulfill() 47 | }.store(in: &subscriptions) 48 | wait(for: [exp], timeout: 1) 49 | } 50 | 51 | func test_loadImage_loadedFromWeb() { 52 | let image = BindingWithPublisher(value: Loadable.notRequested) 53 | mockedWebRepository.imageResponse = .success(testImage) 54 | expectRepoActions([.loadImage(testImageURL)]) 55 | sut.load(image: image.binding, url: testImageURL) 56 | let exp = XCTestExpectation(description: "Completion") 57 | image.updatesRecorder.sink { updates in 58 | XCTAssertEqual(updates, [ 59 | .notRequested, 60 | .isLoading(last: nil, cancelBag: CancelBag()), 61 | .loaded(self.testImage) 62 | ]) 63 | self.verifyRepoActions() 64 | exp.fulfill() 65 | }.store(in: &subscriptions) 66 | wait(for: [exp], timeout: 1) 67 | } 68 | 69 | func test_loadImage_failed() { 70 | let image = BindingWithPublisher(value: Loadable.notRequested) 71 | let error = NSError.test 72 | mockedWebRepository.imageResponse = .failure(error) 73 | expectRepoActions([.loadImage(testImageURL)]) 74 | sut.load(image: image.binding, url: testImageURL) 75 | let exp = XCTestExpectation(description: "Completion") 76 | image.updatesRecorder.sink { updates in 77 | XCTAssertEqual(updates, [ 78 | .notRequested, 79 | .isLoading(last: nil, cancelBag: CancelBag()), 80 | .failed(error) 81 | ]) 82 | self.verifyRepoActions() 83 | exp.fulfill() 84 | }.store(in: &subscriptions) 85 | wait(for: [exp], timeout: 1) 86 | } 87 | 88 | func test_loadImage_hadLoadedImage() { 89 | let image = BindingWithPublisher(value: Loadable.loaded(testImage)) 90 | let error = NSError.test 91 | mockedWebRepository.imageResponse = .failure(error) 92 | expectRepoActions([.loadImage(testImageURL)]) 93 | sut.load(image: image.binding, url: testImageURL) 94 | let exp = XCTestExpectation(description: "Completion") 95 | image.updatesRecorder.sink { updates in 96 | XCTAssertEqual(updates, [ 97 | .loaded(self.testImage), 98 | .isLoading(last: self.testImage, cancelBag: CancelBag()), 99 | .failed(error) 100 | ]) 101 | self.verifyRepoActions() 102 | exp.fulfill() 103 | }.store(in: &subscriptions) 104 | wait(for: [exp], timeout: 1) 105 | } 106 | 107 | func test_stubInteractor() { 108 | let sut = StubImagesInteractor() 109 | let image = BindingWithPublisher(value: Loadable.notRequested) 110 | sut.load(image: image.binding, url: testImageURL) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /UnitTests/Interactors/UserPermissionsInteractorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserPermissionsInteractorTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | class UserPermissionsInteractorTests: XCTestCase { 14 | 15 | var state = Store(AppState()) 16 | var sut: RealUserPermissionsInteractor! 17 | 18 | override func setUp() { 19 | state.bulkUpdate { $0 = AppState() } 20 | } 21 | 22 | func delay(_ closure: @escaping () -> Void) { 23 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: closure) 24 | } 25 | 26 | func test_noSideEffectOnInit() { 27 | let exp = XCTestExpectation(description: #function) 28 | sut = RealUserPermissionsInteractor(appState: state) { 29 | XCTFail() 30 | } 31 | delay { 32 | XCTAssertEqual(self.state.value, AppState()) 33 | exp.fulfill() 34 | } 35 | wait(for: [exp], timeout: 0.5) 36 | } 37 | 38 | // MARK: - Push 39 | 40 | func test_pushFirstResolveStatus() { 41 | XCTAssertEqual(AppState().permissions.push, .unknown) 42 | let exp = XCTestExpectation(description: #function) 43 | sut = RealUserPermissionsInteractor(appState: state) { 44 | XCTFail() 45 | } 46 | sut.resolveStatus(for: .pushNotifications) 47 | delay { 48 | XCTAssertNotEqual(self.state.value.permissions.push, .unknown) 49 | exp.fulfill() 50 | } 51 | wait(for: [exp], timeout: 0.5) 52 | } 53 | 54 | func test_pushSecondResolveStatus() { 55 | XCTAssertEqual(AppState().permissions.push, .unknown) 56 | let exp = XCTestExpectation(description: #function) 57 | sut = RealUserPermissionsInteractor(appState: state) { 58 | XCTFail() 59 | } 60 | sut.resolveStatus(for: .pushNotifications) 61 | delay { 62 | self.sut.resolveStatus(for: .pushNotifications) 63 | XCTAssertNotEqual(self.state.value.permissions.push, .unknown) 64 | exp.fulfill() 65 | } 66 | wait(for: [exp], timeout: 0.5) 67 | } 68 | 69 | func test_pushRequestPermissionNotDetermined() { 70 | state[\.permissions.push] = .notRequested 71 | let exp = XCTestExpectation(description: #function) 72 | sut = RealUserPermissionsInteractor(appState: state) { 73 | XCTFail() 74 | } 75 | sut.request(permission: .pushNotifications) 76 | delay { 77 | XCTAssertNotEqual(self.state.value.permissions.push, .unknown) 78 | exp.fulfill() 79 | } 80 | wait(for: [exp], timeout: 0.5) 81 | } 82 | 83 | func test_pushRequestPermissionDenied() { 84 | state[\.permissions.push] = .denied 85 | let exp = XCTestExpectation(description: #function) 86 | sut = RealUserPermissionsInteractor(appState: state) { 87 | XCTAssertEqual(self.state.value.permissions.push, .denied) 88 | exp.fulfill() 89 | } 90 | sut.request(permission: .pushNotifications) 91 | wait(for: [exp], timeout: 0.5) 92 | } 93 | 94 | func test_authorizationStatusMapping() { 95 | XCTAssertEqual(UNAuthorizationStatus.notDetermined.map, .notRequested) 96 | XCTAssertEqual(UNAuthorizationStatus.provisional.map, .notRequested) 97 | XCTAssertEqual(UNAuthorizationStatus.denied.map, .denied) 98 | XCTAssertEqual(UNAuthorizationStatus.authorized.map, .granted) 99 | XCTAssertEqual(UNAuthorizationStatus(rawValue: 10)?.map, .notRequested) 100 | } 101 | 102 | // MARK: - Stub 103 | 104 | func test_stubUserPermissionsInteractor() { 105 | let sut = StubUserPermissionsInteractor() 106 | sut.request(permission: .pushNotifications) 107 | sut.resolveStatus(for: .pushNotifications) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /UnitTests/Mocks/Mock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mock.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 07.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import CountriesSwiftUI 11 | 12 | protocol Mock { 13 | associatedtype Action: Equatable 14 | var actions: MockActions { get } 15 | 16 | func register(_ action: Action) 17 | func verify(file: StaticString, line: UInt) 18 | } 19 | 20 | extension Mock { 21 | func register(_ action: Action) { 22 | actions.register(action) 23 | } 24 | 25 | func verify(file: StaticString = #file, line: UInt = #line) { 26 | actions.verify(file: file, line: line) 27 | } 28 | } 29 | 30 | final class MockActions where Action: Equatable { 31 | let expected: [Action] 32 | var factual: [Action] = [] 33 | 34 | init(expected: [Action]) { 35 | self.expected = expected 36 | } 37 | 38 | fileprivate func register(_ action: Action) { 39 | factual.append(action) 40 | } 41 | 42 | fileprivate func verify(file: StaticString, line: UInt) { 43 | if factual == expected { return } 44 | let factualNames = factual.map { "." + String(describing: $0) } 45 | let expectedNames = expected.map { "." + String(describing: $0) } 46 | XCTFail("\(name)\n\nExpected:\n\n\(expectedNames)\n\nReceived:\n\n\(factualNames)", file: file, line: line) 47 | } 48 | 49 | private var name: String { 50 | let fullName = String(describing: self) 51 | let nameComponents = fullName.components(separatedBy: ".") 52 | return nameComponents.dropLast().last ?? fullName 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /UnitTests/Mocks/MockedDBRepositories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedDBRepositories.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 18.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | // MARK: - CountriesWebRepository 14 | 15 | final class MockedCountriesDBRepository: Mock, CountriesDBRepository { 16 | 17 | enum Action: Equatable { 18 | case hasLoadedCountries 19 | case storeCountries([Country]) 20 | case fetchCountries(search: String, locale: Locale) 21 | case storeCountryDetails(Country.Details.Intermediate) 22 | case fetchCountryDetails(Country) 23 | } 24 | var actions = MockActions(expected: []) 25 | 26 | var hasLoadedCountriesResult: Result = .failure(MockError.valueNotSet) 27 | var storeCountriesResult: Result = .failure(MockError.valueNotSet) 28 | var fetchCountriesResult: Result, Error> = .failure(MockError.valueNotSet) 29 | var storeCountryDetailsResult: Result = .failure(MockError.valueNotSet) 30 | var fetchCountryDetailsResult: Result = .failure(MockError.valueNotSet) 31 | 32 | // MARK: - API 33 | 34 | func hasLoadedCountries() -> AnyPublisher { 35 | register(.hasLoadedCountries) 36 | return hasLoadedCountriesResult.publish() 37 | } 38 | 39 | func store(countries: [Country]) -> AnyPublisher { 40 | register(.storeCountries(countries)) 41 | return storeCountriesResult.publish() 42 | } 43 | 44 | func countries(search: String, locale: Locale) -> AnyPublisher, Error> { 45 | register(.fetchCountries(search: search, locale: locale)) 46 | return fetchCountriesResult.publish() 47 | } 48 | 49 | func store(countryDetails: Country.Details.Intermediate, 50 | for country: Country) -> AnyPublisher { 51 | register(.storeCountryDetails(countryDetails)) 52 | return storeCountryDetailsResult.publish() 53 | } 54 | 55 | func countryDetails(country: Country) -> AnyPublisher { 56 | register(.fetchCountryDetails(country)) 57 | return fetchCountryDetailsResult.publish() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /UnitTests/Mocks/MockedInteractors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedInteractors.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 07.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import Combine 12 | import ViewInspector 13 | @testable import CountriesSwiftUI 14 | 15 | extension DIContainer.Interactors { 16 | static func mocked( 17 | countriesInteractor: [MockedCountriesInteractor.Action] = [], 18 | imagesInteractor: [MockedImagesInteractor.Action] = [], 19 | permissionsInteractor: [MockedUserPermissionsInteractor.Action] = [] 20 | ) -> DIContainer.Interactors { 21 | .init(countriesInteractor: MockedCountriesInteractor(expected: countriesInteractor), 22 | imagesInteractor: MockedImagesInteractor(expected: imagesInteractor), 23 | userPermissionsInteractor: MockedUserPermissionsInteractor(expected: permissionsInteractor)) 24 | } 25 | 26 | func verify(file: StaticString = #file, line: UInt = #line) { 27 | (countriesInteractor as? MockedCountriesInteractor)? 28 | .verify(file: file, line: line) 29 | (imagesInteractor as? MockedImagesInteractor)? 30 | .verify(file: file, line: line) 31 | (userPermissionsInteractor as? MockedUserPermissionsInteractor)? 32 | .verify(file: file, line: line) 33 | } 34 | } 35 | 36 | // MARK: - CountriesInteractor 37 | 38 | struct MockedCountriesInteractor: Mock, CountriesInteractor { 39 | 40 | enum Action: Equatable { 41 | case refreshCountriesList 42 | case loadCountries(search: String, locale: Locale) 43 | case loadCountryDetails(Country) 44 | } 45 | 46 | let actions: MockActions 47 | 48 | init(expected: [Action]) { 49 | self.actions = .init(expected: expected) 50 | } 51 | 52 | func refreshCountriesList() -> AnyPublisher { 53 | register(.refreshCountriesList) 54 | return Just.withErrorType(Error.self) 55 | } 56 | 57 | func load(countries: LoadableSubject>, search: String, locale: Locale) { 58 | register(.loadCountries(search: search, locale: locale)) 59 | } 60 | 61 | func load(countryDetails: LoadableSubject, country: Country) { 62 | register(.loadCountryDetails(country)) 63 | } 64 | } 65 | 66 | // MARK: - ImagesInteractor 67 | 68 | struct MockedImagesInteractor: Mock, ImagesInteractor { 69 | 70 | enum Action: Equatable { 71 | case loadImage(URL?) 72 | } 73 | 74 | let actions: MockActions 75 | 76 | init(expected: [Action]) { 77 | self.actions = .init(expected: expected) 78 | } 79 | 80 | func load(image: LoadableSubject, url: URL?) { 81 | register(.loadImage(url)) 82 | } 83 | } 84 | 85 | // MARK: - ImagesInteractor 86 | 87 | class MockedUserPermissionsInteractor: Mock, UserPermissionsInteractor { 88 | 89 | enum Action: Equatable { 90 | case resolveStatus(Permission) 91 | case request(Permission) 92 | } 93 | 94 | let actions: MockActions 95 | 96 | init(expected: [Action]) { 97 | self.actions = .init(expected: expected) 98 | } 99 | 100 | func resolveStatus(for permission: Permission) { 101 | register(.resolveStatus(permission)) 102 | } 103 | 104 | func request(permission: Permission) { 105 | register(.request(permission)) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /UnitTests/Mocks/MockedPersistentStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedPersistentStore.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 19.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | final class MockedPersistentStore: Mock, PersistentStore { 14 | struct ContextSnapshot: Equatable { 15 | let inserted: Int 16 | let updated: Int 17 | let deleted: Int 18 | } 19 | enum Action: Equatable { 20 | case count 21 | case fetchCountries(ContextSnapshot) 22 | case fetchCountryDetails(ContextSnapshot) 23 | case update(ContextSnapshot) 24 | } 25 | var actions = MockActions(expected: []) 26 | 27 | var countResult: Int = 0 28 | 29 | deinit { 30 | destroyDatabase() 31 | } 32 | 33 | // MARK: - count 34 | 35 | func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher { 36 | register(.count) 37 | return Just.withErrorType(countResult, Error.self).publish() 38 | } 39 | 40 | // MARK: - fetch 41 | 42 | func fetch(_ fetchRequest: NSFetchRequest, 43 | map: @escaping (T) throws -> V?) -> AnyPublisher, Error> { 44 | do { 45 | let context = container.viewContext 46 | context.reset() 47 | let result = try context.fetch(fetchRequest) 48 | if T.self is CountryMO.Type { 49 | register(.fetchCountries(context.snapshot)) 50 | } else if T.self is CountryDetailsMO.Type { 51 | register(.fetchCountryDetails(context.snapshot)) 52 | } else { 53 | fatalError("Add a case for \(String(describing: T.self))") 54 | } 55 | let list = LazyList(count: result.count, useCache: true, { index in 56 | try map(result[index]) 57 | }) 58 | return Just>.withErrorType(list, Error.self).publish() 59 | } catch { 60 | return Fail, Error>(error: error).publish() 61 | } 62 | } 63 | 64 | // MARK: - update 65 | 66 | func update(_ operation: @escaping DBOperation) -> AnyPublisher { 67 | do { 68 | let context = container.viewContext 69 | context.reset() 70 | let result = try operation(context) 71 | register(.update(context.snapshot)) 72 | return Just(result).setFailureType(to: Error.self).publish() 73 | } catch { 74 | return Fail(error: error).publish() 75 | } 76 | } 77 | 78 | // MARK: - 79 | 80 | func preloadData(_ preload: (NSManagedObjectContext) throws -> Void) throws { 81 | try preload(container.viewContext) 82 | if container.viewContext.hasChanges { 83 | try container.viewContext.save() 84 | } 85 | container.viewContext.reset() 86 | } 87 | 88 | // MARK: - Database 89 | 90 | private let dbVersion = CoreDataStack.Version(CoreDataStack.Version.actual) 91 | 92 | private var dbURL: URL { 93 | guard let url = dbVersion.dbFileURL(.cachesDirectory, .userDomainMask) 94 | else { fatalError() } 95 | return url 96 | } 97 | 98 | private lazy var container: NSPersistentContainer = { 99 | let container = NSPersistentContainer(name: dbVersion.modelName) 100 | try? FileManager().removeItem(at: dbURL) 101 | let store = NSPersistentStoreDescription(url: dbURL) 102 | container.persistentStoreDescriptions = [store] 103 | let group = DispatchGroup() 104 | group.enter() 105 | container.loadPersistentStores { (desc, error) in 106 | if let error = error { 107 | fatalError("\(error)") 108 | } 109 | group.leave() 110 | } 111 | group.wait() 112 | container.viewContext.mergePolicy = NSOverwriteMergePolicy 113 | container.viewContext.undoManager = nil 114 | return container 115 | }() 116 | 117 | private func destroyDatabase() { 118 | try? container.persistentStoreCoordinator 119 | .destroyPersistentStore(at: dbURL, ofType: NSSQLiteStoreType, options: nil) 120 | try? FileManager().removeItem(at: dbURL) 121 | } 122 | } 123 | 124 | extension NSManagedObjectContext { 125 | var snapshot: MockedPersistentStore.ContextSnapshot { 126 | .init(inserted: insertedObjects.count, 127 | updated: updatedObjects.count, 128 | deleted: deletedObjects.count) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /UnitTests/Mocks/MockedSystemEventsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedSystemEventsHandler.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | // MARK: - SystemEventsHandler 14 | 15 | final class MockedSystemEventsHandler: Mock, SystemEventsHandler { 16 | enum Action: Equatable { 17 | case openURL 18 | case becomeActive 19 | case resignActive 20 | case pushRegistration 21 | case recevieRemoteNotification 22 | } 23 | var actions = MockActions(expected: []) 24 | 25 | init(expected: [Action]) { 26 | self.actions = .init(expected: expected) 27 | } 28 | 29 | func sceneOpenURLContexts(_ urlContexts: Set) { 30 | register(.openURL) 31 | } 32 | 33 | func sceneDidBecomeActive() { 34 | register(.becomeActive) 35 | } 36 | 37 | func sceneWillResignActive() { 38 | register(.resignActive) 39 | } 40 | 41 | func handlePushRegistration(result: Result) { 42 | register(.pushRegistration) 43 | } 44 | 45 | func appDidReceiveRemoteNotification(payload: NotificationPayload, 46 | fetchCompletion: @escaping FetchCompletion) { 47 | register(.recevieRemoteNotification) 48 | } 49 | } 50 | 51 | // MARK: - PushNotificationsHandler 52 | 53 | final class DummyPushNotificationsHandler: PushNotificationsHandler { } 54 | 55 | // MARK: - DeepLinksHandler 56 | 57 | final class MockedDeepLinksHandler: Mock, DeepLinksHandler { 58 | enum Action: Equatable { 59 | case open(DeepLink) 60 | } 61 | var actions = MockActions(expected: []) 62 | 63 | init(expected: [Action]) { 64 | self.actions = .init(expected: expected) 65 | } 66 | 67 | func open(deepLink: DeepLink) { 68 | register(.open(deepLink)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /UnitTests/Mocks/MockedWebRepositories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedWebRepositories.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 31.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | class TestWebRepository: WebRepository { 14 | let session: URLSession = .mockedResponsesOnly 15 | let baseURL = "https://test.com" 16 | let bgQueue = DispatchQueue(label: "test") 17 | } 18 | 19 | // MARK: - CountriesWebRepository 20 | 21 | final class MockedCountriesWebRepository: TestWebRepository, Mock, CountriesWebRepository { 22 | 23 | enum Action: Equatable { 24 | case loadCountries 25 | case loadCountryDetails(Country) 26 | } 27 | var actions = MockActions(expected: []) 28 | 29 | var countriesResponse: Result<[Country], Error> = .failure(MockError.valueNotSet) 30 | var detailsResponse: Result = .failure(MockError.valueNotSet) 31 | 32 | func loadCountries() -> AnyPublisher<[Country], Error> { 33 | register(.loadCountries) 34 | return countriesResponse.publish() 35 | } 36 | 37 | func loadCountryDetails(country: Country) -> AnyPublisher { 38 | register(.loadCountryDetails(country)) 39 | return detailsResponse.publish() 40 | } 41 | } 42 | 43 | // MARK: - ImageWebRepository 44 | 45 | final class MockedImageWebRepository: TestWebRepository, Mock, ImageWebRepository { 46 | 47 | enum Action: Equatable { 48 | case loadImage(URL?) 49 | } 50 | var actions = MockActions(expected: []) 51 | 52 | var imageResponse: Result = .failure(MockError.valueNotSet) 53 | 54 | func load(imageURL: URL, width: Int) -> AnyPublisher { 55 | register(.loadImage(imageURL)) 56 | return imageResponse.publish() 57 | } 58 | } 59 | 60 | // MARK: - PushTokenWebRepository 61 | 62 | final class MockedPushTokenWebRepository: TestWebRepository, Mock, PushTokenWebRepository { 63 | enum Action: Equatable { 64 | case register(Data) 65 | } 66 | var actions = MockActions(expected: []) 67 | 68 | init(expected: [Action]) { 69 | self.actions = .init(expected: expected) 70 | } 71 | 72 | func register(devicePushToken: Data) -> AnyPublisher { 73 | register(.register(devicePushToken)) 74 | return Just.withErrorType(Error.self) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /UnitTests/NetworkMocking/MockedResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockedResponse.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 30.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import CountriesSwiftUI 11 | 12 | extension RequestMocking { 13 | struct MockedResponse { 14 | let url: URL 15 | let result: Result 16 | let httpCode: HTTPCode 17 | let headers: [String: String] 18 | let loadingTime: TimeInterval 19 | let customResponse: URLResponse? 20 | } 21 | } 22 | 23 | extension RequestMocking.MockedResponse { 24 | enum Error: Swift.Error { 25 | case failedMockCreation 26 | } 27 | 28 | init(apiCall: APICall, baseURL: String, 29 | result: Result, 30 | httpCode: HTTPCode = 200, 31 | headers: [String: String] = ["Content-Type": "application/json"], 32 | loadingTime: TimeInterval = 0.1 33 | ) throws where T: Encodable { 34 | guard let url = try apiCall.urlRequest(baseURL: baseURL).url 35 | else { throw Error.failedMockCreation } 36 | self.url = url 37 | switch result { 38 | case let .success(value): 39 | self.result = .success(try JSONEncoder().encode(value)) 40 | case let .failure(error): 41 | self.result = .failure(error) 42 | } 43 | self.httpCode = httpCode 44 | self.headers = headers 45 | self.loadingTime = loadingTime 46 | customResponse = nil 47 | } 48 | 49 | init(apiCall: APICall, baseURL: String, customResponse: URLResponse) throws { 50 | guard let url = try apiCall.urlRequest(baseURL: baseURL).url 51 | else { throw Error.failedMockCreation } 52 | self.url = url 53 | result = .success(Data()) 54 | httpCode = 200 55 | headers = [String: String]() 56 | loadingTime = 0 57 | self.customResponse = customResponse 58 | } 59 | 60 | init(url: URL, result: Result) { 61 | self.url = url 62 | self.result = result 63 | httpCode = 200 64 | headers = [String: String]() 65 | loadingTime = 0 66 | customResponse = nil 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /UnitTests/NetworkMocking/RequestMocking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMocking.swift 3 | // CountriesSwiftUI 4 | // 5 | // Created by Alexey Naumov on 30.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension URLSession { 12 | static var mockedResponsesOnly: URLSession { 13 | let configuration = URLSessionConfiguration.default 14 | configuration.protocolClasses = [RequestMocking.self, RequestBlocking.self] 15 | configuration.timeoutIntervalForRequest = 1 16 | configuration.timeoutIntervalForResource = 1 17 | return URLSession(configuration: configuration) 18 | } 19 | } 20 | 21 | extension RequestMocking { 22 | static private var mocks: [MockedResponse] = [] 23 | 24 | static func add(mock: MockedResponse) { 25 | mocks.append(mock) 26 | } 27 | 28 | static func removeAllMocks() { 29 | mocks.removeAll() 30 | } 31 | 32 | static private func mock(for request: URLRequest) -> MockedResponse? { 33 | return mocks.first { $0.url == request.url } 34 | } 35 | } 36 | 37 | // MARK: - RequestMocking 38 | 39 | final class RequestMocking: URLProtocol { 40 | 41 | override class func canInit(with request: URLRequest) -> Bool { 42 | return mock(for: request) != nil 43 | } 44 | 45 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 46 | return request 47 | } 48 | 49 | // swiftlint:disable identifier_name 50 | override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { 51 | // swiftlint:enable identifier_name 52 | return false 53 | } 54 | 55 | override func startLoading() { 56 | if let mock = RequestMocking.mock(for: request), 57 | let url = request.url, 58 | let response = mock.customResponse ?? 59 | HTTPURLResponse(url: url, 60 | statusCode: mock.httpCode, 61 | httpVersion: "HTTP/1.1", 62 | headerFields: mock.headers) { 63 | DispatchQueue.main.asyncAfter(deadline: .now() + mock.loadingTime) { [weak self] in 64 | guard let self = self else { return } 65 | self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 66 | switch mock.result { 67 | case let .success(data): 68 | self.client?.urlProtocol(self, didLoad: data) 69 | self.client?.urlProtocolDidFinishLoading(self) 70 | case let .failure(error): 71 | let failure = NSError(domain: NSURLErrorDomain, code: 1, 72 | userInfo: [NSUnderlyingErrorKey: error]) 73 | self.client?.urlProtocol(self, didFailWithError: failure) 74 | } 75 | } 76 | } 77 | } 78 | 79 | override func stopLoading() { } 80 | } 81 | 82 | // MARK: - RequestBlocking 83 | 84 | private class RequestBlocking: URLProtocol { 85 | enum Error: Swift.Error { 86 | case requestBlocked 87 | } 88 | 89 | override class func canInit(with request: URLRequest) -> Bool { 90 | return true 91 | } 92 | 93 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 94 | return request 95 | } 96 | 97 | override func startLoading() { 98 | DispatchQueue(label: "").async { 99 | self.client?.urlProtocol(self, didFailWithError: Error.requestBlocked) 100 | } 101 | } 102 | override func stopLoading() { } 103 | } 104 | -------------------------------------------------------------------------------- /UnitTests/Persistence/CoreDataStackTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataStackTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 19.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | class CoreDataStackTests: XCTestCase { 14 | 15 | var sut: CoreDataStack! 16 | let testDirectory: FileManager.SearchPathDirectory = .cachesDirectory 17 | var dbVersion: UInt { fatalError("Override") } 18 | var cancelBag = CancelBag() 19 | 20 | override func setUp() { 21 | eraseDBFiles() 22 | sut = CoreDataStack(directory: testDirectory, version: dbVersion) 23 | } 24 | 25 | override func tearDown() { 26 | cancelBag = CancelBag() 27 | sut = nil 28 | eraseDBFiles() 29 | } 30 | 31 | func eraseDBFiles() { 32 | let version = CoreDataStack.Version(dbVersion) 33 | if let url = version.dbFileURL(testDirectory, .userDomainMask) { 34 | try? FileManager().removeItem(at: url) 35 | } 36 | } 37 | } 38 | 39 | // MARK: - Version 1 40 | 41 | final class CoreDataStackV1Tests: CoreDataStackTests { 42 | 43 | override var dbVersion: UInt { 1 } 44 | 45 | func test_initialization() { 46 | let exp = XCTestExpectation(description: #function) 47 | let request = CountryMO.newFetchRequest() 48 | request.predicate = NSPredicate(value: true) 49 | request.fetchLimit = 1 50 | sut.fetch(request) { _ -> Int? in 51 | return nil 52 | } 53 | .sinkToResult { result in 54 | result.assertSuccess(value: LazyList.empty) 55 | exp.fulfill() 56 | } 57 | .store(in: cancelBag) 58 | wait(for: [exp], timeout: 1) 59 | } 60 | 61 | func test_inaccessibleDirectory() { 62 | let sut = CoreDataStack(directory: .adminApplicationDirectory, 63 | domainMask: .systemDomainMask, version: dbVersion) 64 | let exp = XCTestExpectation(description: #function) 65 | let request = CountryMO.newFetchRequest() 66 | request.predicate = NSPredicate(value: true) 67 | request.fetchLimit = 1 68 | sut.fetch(request) { _ -> Int? in 69 | return nil 70 | } 71 | .sinkToResult { result in 72 | result.assertFailure() 73 | exp.fulfill() 74 | } 75 | .store(in: cancelBag) 76 | wait(for: [exp], timeout: 1) 77 | } 78 | 79 | func test_counting_onEmptyStore() { 80 | let request = CountryMO.newFetchRequest() 81 | request.predicate = NSPredicate(value: true) 82 | let exp = XCTestExpectation(description: #function) 83 | sut.count(request) 84 | .sinkToResult { result in 85 | result.assertSuccess(value: 0) 86 | exp.fulfill() 87 | } 88 | .store(in: cancelBag) 89 | wait(for: [exp], timeout: 1) 90 | } 91 | 92 | func test_storing_and_countring() { 93 | let countries = Country.mockedData 94 | 95 | let request = CountryMO.newFetchRequest() 96 | request.predicate = NSPredicate(value: true) 97 | 98 | let exp = XCTestExpectation(description: #function) 99 | sut.update { context in 100 | countries.forEach { 101 | $0.store(in: context) 102 | } 103 | } 104 | .flatMap { _ in 105 | self.sut.count(request) 106 | } 107 | .sinkToResult { result in 108 | result.assertSuccess(value: countries.count) 109 | exp.fulfill() 110 | } 111 | .store(in: cancelBag) 112 | wait(for: [exp], timeout: 1) 113 | } 114 | 115 | func test_storing_exception() { 116 | let exp = XCTestExpectation(description: #function) 117 | sut.update { context in 118 | throw NSError.test 119 | } 120 | .sinkToResult { result in 121 | result.assertFailure(NSError.test.localizedDescription) 122 | exp.fulfill() 123 | } 124 | .store(in: cancelBag) 125 | wait(for: [exp], timeout: 1) 126 | } 127 | 128 | func test_fetching() { 129 | let countries = Country.mockedData 130 | let exp = XCTestExpectation(description: #function) 131 | sut 132 | .update { context in 133 | countries.forEach { 134 | $0.store(in: context) 135 | } 136 | } 137 | .flatMap { _ -> AnyPublisher, Error> in 138 | let request = CountryMO.newFetchRequest() 139 | request.predicate = NSPredicate(format: "alpha3code == %@", countries[0].alpha3Code) 140 | return self.sut.fetch(request) { 141 | Country(managedObject: $0) 142 | } 143 | } 144 | .sinkToResult { result in 145 | result.assertSuccess(value: LazyList( 146 | count: 1, useCache: false, { _ in countries[0] }) 147 | ) 148 | exp.fulfill() 149 | } 150 | .store(in: cancelBag) 151 | wait(for: [exp], timeout: 1) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /UnitTests/Repositories/CountriesWebRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountriesWebRepositoryTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 30.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | final class CountriesWebRepositoryTests: XCTestCase { 14 | 15 | private var sut: RealCountriesWebRepository! 16 | private var subscriptions = Set() 17 | 18 | typealias API = RealCountriesWebRepository.API 19 | typealias Mock = RequestMocking.MockedResponse 20 | 21 | override func setUp() { 22 | subscriptions = Set() 23 | sut = RealCountriesWebRepository(session: .mockedResponsesOnly, 24 | baseURL: "https://test.com") 25 | } 26 | 27 | override func tearDown() { 28 | RequestMocking.removeAllMocks() 29 | } 30 | 31 | // MARK: - All Countries 32 | 33 | func test_allCountries() throws { 34 | let data = Country.mockedData 35 | try mock(.allCountries, result: .success(data)) 36 | let exp = XCTestExpectation(description: "Completion") 37 | sut.loadCountries().sinkToResult { result in 38 | result.assertSuccess(value: data) 39 | exp.fulfill() 40 | }.store(in: &subscriptions) 41 | wait(for: [exp], timeout: 2) 42 | } 43 | 44 | func test_countryDetails() throws { 45 | let countries = Country.mockedData 46 | let value = Country.Details.Intermediate( 47 | capital: "London", 48 | currencies: [Country.Currency(code: "12", symbol: "$", name: "US dollar")], 49 | borders: countries.map({ $0.alpha3Code })) 50 | try mock(.countryDetails(countries[0]), result: .success([value])) 51 | let exp = XCTestExpectation(description: "Completion") 52 | sut.loadCountryDetails(country: countries[0]).sinkToResult { result in 53 | result.assertSuccess(value: value) 54 | exp.fulfill() 55 | }.store(in: &subscriptions) 56 | wait(for: [exp], timeout: 2) 57 | } 58 | 59 | func test_countryDetails_whenDetailsAreEmpty() throws { 60 | let countries = Country.mockedData 61 | try mock(.countryDetails(countries[0]), result: .success([Country.Details.Intermediate]())) 62 | let exp = XCTestExpectation(description: "Completion") 63 | sut.loadCountryDetails(country: countries[0]).sinkToResult { result in 64 | result.assertFailure(APIError.unexpectedResponse.localizedDescription) 65 | exp.fulfill() 66 | }.store(in: &subscriptions) 67 | wait(for: [exp], timeout: 2) 68 | } 69 | 70 | func test_countryDetails_countryNameEncoding() { 71 | let name = String(bytes: [0xD8, 0x00] as [UInt8], encoding: .utf16BigEndian)! 72 | let country = Country(name: name, translations: [:], population: 1, flag: nil, alpha3Code: "ABC") 73 | let apiCall = RealCountriesWebRepository.API.countryDetails(country) 74 | XCTAssertTrue(apiCall.path.hasSuffix(name)) 75 | } 76 | 77 | // MARK: - Helper 78 | 79 | private func mock(_ apiCall: API, result: Result, 80 | httpCode: HTTPCode = 200) throws where T: Encodable { 81 | let mock = try Mock(apiCall: apiCall, baseURL: sut.baseURL, result: result, httpCode: httpCode) 82 | RequestMocking.add(mock: mock) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /UnitTests/Repositories/PushTokenWebRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushTokenWebRepositoryTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | class PushTokenWebRepositoryTests: XCTestCase { 14 | 15 | private var sut: RealPushTokenWebRepository! 16 | private var cancelBag = CancelBag() 17 | 18 | override func setUp() { 19 | sut = RealPushTokenWebRepository(session: .mockedResponsesOnly, 20 | baseURL: "https://test.com") 21 | } 22 | 23 | override func tearDown() { 24 | cancelBag = CancelBag() 25 | } 26 | 27 | func test_register() { 28 | let exp = XCTestExpectation(description: #function) 29 | sut.register(devicePushToken: Data()) 30 | .sinkToResult { result in 31 | result.assertSuccess() 32 | exp.fulfill() 33 | } 34 | .store(in: cancelBag) 35 | wait(for: [exp], timeout: 0.1) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /UnitTests/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /UnitTests/Resources/svg_convert_02.html: -------------------------------------------------------------------------------- 1 |

[svg-to-png output image]

File size: 5.02KiB (+438.47%), width: 300px, height: 200px, type: png

cropcropresizeresizerotaterotateoptimizeoptimizeeffectseffectswritewriteoverlayoverlayto JPGto JPGsavesave

Please do not directly link this file, but save it when finished.
The image will soon be deleted from our servers.
You can host images at sites like imgur.com

-------------------------------------------------------------------------------- /UnitTests/System/AppDelegateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegateTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | @testable import CountriesSwiftUI 12 | 13 | final class AppDelegateTests: XCTestCase { 14 | 15 | func test_didFinishLaunching() { 16 | let sut = AppDelegate() 17 | let eventsHandler = MockedSystemEventsHandler(expected: []) 18 | sut.systemEventsHandler = eventsHandler 19 | _ = sut.application(UIApplication.shared, didFinishLaunchingWithOptions: [:]) 20 | eventsHandler.verify() 21 | } 22 | 23 | func test_pushRegistration() { 24 | let sut = AppDelegate() 25 | let eventsHandler = MockedSystemEventsHandler(expected: [ 26 | .pushRegistration, .pushRegistration 27 | ]) 28 | sut.systemEventsHandler = eventsHandler 29 | sut.application(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: Data()) 30 | sut.application(UIApplication.shared, didFailToRegisterForRemoteNotificationsWithError: NSError.test) 31 | eventsHandler.verify() 32 | } 33 | 34 | func test_didRecevieRemoteNotification() { 35 | let sut = AppDelegate() 36 | let eventsHandler = MockedSystemEventsHandler(expected: [ 37 | .recevieRemoteNotification 38 | ]) 39 | sut.systemEventsHandler = eventsHandler 40 | sut.application(UIApplication.shared, didReceiveRemoteNotification: [:], fetchCompletionHandler: { _ in }) 41 | eventsHandler.verify() 42 | } 43 | 44 | func test_systemEventsHandler() { 45 | let sut = AppDelegate() 46 | let handler = sut.systemEventsHandler 47 | XCTAssertTrue(handler is RealSystemEventsHandler) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /UnitTests/System/DeepLinksHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinksHandlerTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import CountriesSwiftUI 11 | 12 | class DeepLinksHandlerTests: XCTestCase { 13 | 14 | func test_noSideEffectOnInit() { 15 | let interactors: DIContainer.Interactors = .mocked() 16 | let container = DIContainer(appState: AppState(), interactors: interactors) 17 | _ = RealDeepLinksHandler(container: container) 18 | interactors.verify() 19 | XCTAssertEqual(container.appState.value, AppState()) 20 | } 21 | 22 | func test_openingDeeplinkFromDefaultRouting() { 23 | let interactors: DIContainer.Interactors = .mocked() 24 | let initialState = AppState() 25 | let container = DIContainer(appState: initialState, interactors: interactors) 26 | let sut = RealDeepLinksHandler(container: container) 27 | sut.open(deepLink: .showCountryFlag(alpha3Code: "ITA")) 28 | XCTAssertNil(initialState.routing.countriesList.countryDetails) 29 | XCTAssertFalse(initialState.routing.countryDetails.detailsSheet) 30 | var expectedState = AppState() 31 | expectedState.routing.countriesList.countryDetails = "ITA" 32 | expectedState.routing.countryDetails.detailsSheet = true 33 | interactors.verify() 34 | XCTAssertEqual(container.appState.value, expectedState) 35 | } 36 | 37 | func test_openingDeeplinkFromNonDefaultRouting() { 38 | let interactors: DIContainer.Interactors = .mocked() 39 | var initialState = AppState() 40 | initialState.routing.countriesList.countryDetails = "FRA" 41 | initialState.routing.countryDetails.detailsSheet = true 42 | let container = DIContainer(appState: initialState, interactors: interactors) 43 | let sut = RealDeepLinksHandler(container: container) 44 | sut.open(deepLink: .showCountryFlag(alpha3Code: "ITA")) 45 | 46 | let resettedState = AppState() 47 | var finalState = AppState() 48 | finalState.routing.countriesList.countryDetails = "ITA" 49 | finalState.routing.countryDetails.detailsSheet = true 50 | 51 | XCTAssertEqual(container.appState.value, resettedState) 52 | let exp = XCTestExpectation(description: #function) 53 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 54 | interactors.verify() 55 | XCTAssertEqual(container.appState.value, finalState) 56 | exp.fulfill() 57 | } 58 | wait(for: [exp], timeout: 2.5) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /UnitTests/System/PushNotificationsHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotificationsHandlerTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UserNotifications 11 | @testable import CountriesSwiftUI 12 | 13 | class PushNotificationsHandlerTests: XCTestCase { 14 | 15 | var sut: RealPushNotificationsHandler! 16 | 17 | func test_isCenterDelegate() { 18 | let mockedHandler = MockedDeepLinksHandler(expected: []) 19 | sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) 20 | let center = UNUserNotificationCenter.current() 21 | XCTAssertTrue(center.delegate === sut) 22 | mockedHandler.verify() 23 | } 24 | 25 | func test_emptyPayload() { 26 | let mockedHandler = MockedDeepLinksHandler(expected: []) 27 | sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) 28 | let exp = XCTestExpectation(description: #function) 29 | sut.handleNotification(userInfo: [:]) { 30 | mockedHandler.verify() 31 | exp.fulfill() 32 | } 33 | wait(for: [exp], timeout: 0.1) 34 | } 35 | 36 | func test_deepLinkPayload() { 37 | let mockedHandler = MockedDeepLinksHandler(expected: [ 38 | .open(.showCountryFlag(alpha3Code: "USA")) 39 | ]) 40 | sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) 41 | let exp = XCTestExpectation(description: #function) 42 | let userInfo: [String: Any] = [ 43 | "aps": ["country": "USA"] 44 | ] 45 | sut.handleNotification(userInfo: userInfo) { 46 | mockedHandler.verify() 47 | exp.fulfill() 48 | } 49 | wait(for: [exp], timeout: 0.1) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /UnitTests/System/SceneDelegateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegateTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 26.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | @testable import CountriesSwiftUI 12 | 13 | final class SceneDelegateTests: XCTestCase { 14 | 15 | private lazy var scene: UIScene = { 16 | UIApplication.shared.connectedScenes.first! 17 | }() 18 | 19 | func test_openURLContexts() { 20 | let sut = SceneDelegate() 21 | let eventsHandler = MockedSystemEventsHandler(expected: [ 22 | .openURL 23 | ]) 24 | sut.systemEventsHandler = eventsHandler 25 | sut.scene(scene, openURLContexts: .init()) 26 | eventsHandler.verify() 27 | } 28 | 29 | func test_didBecomeActive() { 30 | let sut = SceneDelegate() 31 | let eventsHandler = MockedSystemEventsHandler(expected: [ 32 | .becomeActive 33 | ]) 34 | sut.systemEventsHandler = eventsHandler 35 | sut.sceneDidBecomeActive(scene) 36 | eventsHandler.verify() 37 | } 38 | 39 | func test_willResignActive() { 40 | let sut = SceneDelegate() 41 | let eventsHandler = MockedSystemEventsHandler(expected: [ 42 | .resignActive 43 | ]) 44 | sut.systemEventsHandler = eventsHandler 45 | sut.sceneWillResignActive(scene) 46 | eventsHandler.verify() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /UnitTests/System/UIOpenURLContext_Init.h: -------------------------------------------------------------------------------- 1 | // 2 | // UIOpenURLContext+UIOpenURLContext_Init.h 3 | // UnitTests 4 | // 5 | // Created by Alexey on 18.05.2021. 6 | // Copyright © 2021 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | @interface UIOpenURLContext (Init) 14 | 15 | + (instancetype)createInstance; 16 | 17 | @end 18 | 19 | NS_ASSUME_NONNULL_END 20 | -------------------------------------------------------------------------------- /UnitTests/System/UIOpenURLContext_Init.m: -------------------------------------------------------------------------------- 1 | // 2 | // UIOpenURLContext_Init.m 3 | // UnitTests 4 | // 5 | // Created by Alexey on 18.05.2021. 6 | // Copyright © 2021 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | #import "UIOpenURLContext_Init.h" 10 | 11 | @implementation UIOpenURLContext (Init) 12 | 13 | + (instancetype)createInstance { 14 | return [[self alloc] init]; 15 | } 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /UnitTests/System/UnitTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "UIOpenURLContext_Init.h" 6 | -------------------------------------------------------------------------------- /UnitTests/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestHelpers.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 30.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import Combine 12 | import ViewInspector 13 | @testable import CountriesSwiftUI 14 | 15 | // MARK: - UI 16 | 17 | extension UIColor { 18 | func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { 19 | let format = UIGraphicsImageRendererFormat() 20 | format.scale = 1 21 | return UIGraphicsImageRenderer(size: size, format: format).image { rendererContext in 22 | setFill() 23 | rendererContext.fill(CGRect(origin: .zero, size: size)) 24 | } 25 | } 26 | } 27 | 28 | // MARK: - Result 29 | 30 | extension Result where Success: Equatable { 31 | func assertSuccess(value: Success, file: StaticString = #file, line: UInt = #line) { 32 | switch self { 33 | case let .success(resultValue): 34 | XCTAssertEqual(resultValue, value, file: file, line: line) 35 | case let .failure(error): 36 | XCTFail("Unexpected error: \(error)", file: file, line: line) 37 | } 38 | } 39 | } 40 | 41 | extension Result where Success == Void { 42 | func assertSuccess(file: StaticString = #file, line: UInt = #line) { 43 | switch self { 44 | case let .failure(error): 45 | XCTFail("Unexpected error: \(error)", file: file, line: line) 46 | case .success: 47 | break 48 | } 49 | } 50 | } 51 | 52 | extension Result { 53 | func assertFailure(_ message: String? = nil, file: StaticString = #file, line: UInt = #line) { 54 | switch self { 55 | case let .success(value): 56 | XCTFail("Unexpected success: \(value)", file: file, line: line) 57 | case let .failure(error): 58 | if let message = message { 59 | XCTAssertEqual(error.localizedDescription, message, file: file, line: line) 60 | } 61 | } 62 | } 63 | } 64 | 65 | extension Result { 66 | func publish() -> AnyPublisher { 67 | return publisher.publish() 68 | } 69 | } 70 | 71 | extension Publisher { 72 | func publish() -> AnyPublisher { 73 | delay(for: .milliseconds(10), scheduler: RunLoop.main) 74 | .eraseToAnyPublisher() 75 | } 76 | } 77 | 78 | // MARK: - XCTestCase 79 | 80 | func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, 81 | _ expression2: @autoclosure () throws -> T, 82 | removing prefixes: [String], 83 | file: StaticString = #file, line: UInt = #line) where T: Equatable { 84 | do { 85 | let exp1 = try expression1() 86 | let exp2 = try expression2() 87 | if exp1 != exp2 { 88 | let desc1 = prefixes.reduce(String(describing: exp1), { (str, prefix) in 89 | str.replacingOccurrences(of: prefix, with: "") 90 | }) 91 | let desc2 = prefixes.reduce(String(describing: exp2), { (str, prefix) in 92 | str.replacingOccurrences(of: prefix, with: "") 93 | }) 94 | XCTFail("XCTAssertEqual failed:\n\n\(desc1)\n\nis not equal to\n\n\(desc2)", file: file, line: line) 95 | } 96 | } catch { 97 | XCTFail("Unexpected exception: \(error)") 98 | } 99 | } 100 | 101 | protocol PrefixRemovable { } 102 | 103 | extension PrefixRemovable { 104 | static var prefixes: [String] { 105 | let name = String(reflecting: Self.self) 106 | var components = name.components(separatedBy: ".") 107 | let module = components.removeFirst() 108 | let fullTypeName = components.joined(separator: ".") 109 | return [ 110 | "\(module).", 111 | "Loadable<\(fullTypeName)>", 112 | "Loadable>" 113 | ] 114 | } 115 | } 116 | 117 | // MARK: - BindingWithPublisher 118 | 119 | struct BindingWithPublisher { 120 | 121 | let binding: Binding 122 | let updatesRecorder: AnyPublisher<[Value], Never> 123 | 124 | init(value: Value, recordingTimeInterval: TimeInterval = 0.5) { 125 | var value = value 126 | var updates = [value] 127 | binding = Binding( 128 | get: { value }, 129 | set: { value = $0; updates.append($0) }) 130 | updatesRecorder = Future<[Value], Never> { completion in 131 | DispatchQueue.main.asyncAfter(deadline: .now() + recordingTimeInterval) { 132 | completion(.success(updates)) 133 | } 134 | }.eraseToAnyPublisher() 135 | } 136 | } 137 | 138 | // MARK: - Error 139 | 140 | enum MockError: Swift.Error { 141 | case valueNotSet 142 | case codeDataModel 143 | } 144 | 145 | extension NSError { 146 | static var test: NSError { 147 | return NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Test error"]) 148 | } 149 | } 150 | 151 | extension Inspection: InspectionEmissary where V: Inspectable { } 152 | -------------------------------------------------------------------------------- /UnitTests/UI/ContentViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import ViewInspector 3 | @testable import CountriesSwiftUI 4 | 5 | extension ContentView: Inspectable { } 6 | 7 | final class ContentViewTests: XCTestCase { 8 | 9 | func test_content_for_tests() throws { 10 | let sut = ContentView(container: .defaultValue, isRunningTests: true) 11 | XCTAssertNoThrow(try sut.inspect().group().text(0)) 12 | } 13 | 14 | func test_content_for_build() throws { 15 | let sut = ContentView(container: .defaultValue, isRunningTests: false) 16 | XCTAssertNoThrow(try sut.inspect().group().view(CountriesList.self, 0)) 17 | } 18 | 19 | func test_change_handler_for_colorScheme() throws { 20 | var appState = AppState() 21 | appState.routing.countriesList = .init(countryDetails: "USA") 22 | let container = DIContainer(appState: .init(appState), interactors: .mocked()) 23 | let sut = ContentView(container: container) 24 | sut.onChangeHandler(.colorScheme) 25 | XCTAssertEqual(container.appState.value, appState) 26 | container.interactors.verify() 27 | } 28 | 29 | func test_change_handler_for_sizeCategory() throws { 30 | var appState = AppState() 31 | appState.routing.countriesList = .init(countryDetails: "USA") 32 | let container = DIContainer(appState: .init(appState), interactors: .mocked()) 33 | let sut = ContentView(container: container) 34 | XCTAssertEqual(container.appState.value, appState) 35 | sut.onChangeHandler(.sizeCategory) 36 | XCTAssertEqual(container.appState.value, AppState()) 37 | container.interactors.verify() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /UnitTests/UI/CountriesListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountriesListTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 01.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ViewInspector 11 | import SwiftUI 12 | @testable import CountriesSwiftUI 13 | 14 | extension CountriesList: Inspectable { } 15 | extension ActivityIndicatorView: Inspectable { } 16 | extension CountryCell: Inspectable { } 17 | extension ErrorView: Inspectable { } 18 | 19 | final class CountriesListTests: XCTestCase { 20 | 21 | func test_countries_notRequested() { 22 | let container = DIContainer(appState: AppState(), interactors: 23 | .mocked( 24 | countriesInteractor: [.loadCountries(search: "", locale: .current)] 25 | )) 26 | let sut = CountriesList(countries: .notRequested) 27 | let exp = sut.inspection.inspect { view in 28 | XCTAssertNoThrow(try view.content().text()) 29 | XCTAssertEqual(container.appState.value, AppState()) 30 | container.interactors.verify() 31 | } 32 | ViewHosting.host(view: sut.inject(container)) 33 | wait(for: [exp], timeout: 2) 34 | } 35 | 36 | func test_countries_isLoading_initial() { 37 | let container = DIContainer(appState: AppState(), interactors: .mocked()) 38 | let sut = CountriesList(countries: .isLoading(last: nil, cancelBag: CancelBag())) 39 | let exp = sut.inspection.inspect { view in 40 | let content = try view.content() 41 | XCTAssertNoThrow(try content.view(ActivityIndicatorView.self)) 42 | XCTAssertEqual(container.appState.value, AppState()) 43 | container.interactors.verify() 44 | } 45 | ViewHosting.host(view: sut.inject(container)) 46 | wait(for: [exp], timeout: 2) 47 | } 48 | 49 | func test_countries_isLoading_refresh() { 50 | let container = DIContainer(appState: AppState(), interactors: .mocked()) 51 | let sut = CountriesList(countries: .isLoading( 52 | last: Country.mockedData.lazyList, cancelBag: CancelBag())) 53 | let exp = sut.inspection.inspect { view in 54 | let content = try view.content() 55 | XCTAssertNoThrow(try content.find(SearchBar.self)) 56 | XCTAssertNoThrow(try content.find(ActivityIndicatorView.self)) 57 | let cell = try content.find(CountryCell.self).actualView() 58 | XCTAssertEqual(cell.country, Country.mockedData[0]) 59 | XCTAssertEqual(container.appState.value, AppState()) 60 | container.interactors.verify() 61 | } 62 | ViewHosting.host(view: sut.inject(container)) 63 | wait(for: [exp], timeout: 2) 64 | } 65 | 66 | func test_countries_loaded() { 67 | let container = DIContainer(appState: AppState(), interactors: .mocked()) 68 | let sut = CountriesList(countries: .loaded(Country.mockedData.lazyList)) 69 | let exp = sut.inspection.inspect { view in 70 | let content = try view.content() 71 | XCTAssertNoThrow(try content.find(SearchBar.self)) 72 | XCTAssertThrowsError(try content.find(ActivityIndicatorView.self)) 73 | let cell = try content.find(CountryCell.self).actualView() 74 | XCTAssertEqual(cell.country, Country.mockedData[0]) 75 | XCTAssertEqual(container.appState.value, AppState()) 76 | container.interactors.verify() 77 | } 78 | ViewHosting.host(view: sut.inject(container)) 79 | wait(for: [exp], timeout: 2) 80 | } 81 | 82 | func test_countries_failed() { 83 | let container = DIContainer(appState: AppState(), interactors: .mocked()) 84 | let sut = CountriesList(countries: .failed(NSError.test)) 85 | let exp = sut.inspection.inspect { view in 86 | XCTAssertNoThrow(try view.content().view(ErrorView.self)) 87 | XCTAssertEqual(container.appState.value, AppState()) 88 | container.interactors.verify() 89 | } 90 | ViewHosting.host(view: sut.inject(container)) 91 | wait(for: [exp], timeout: 2) 92 | } 93 | 94 | func test_countries_failed_retry() { 95 | let container = DIContainer(appState: AppState(), interactors: .mocked( 96 | countriesInteractor: [.loadCountries(search: "", locale: .current)] 97 | )) 98 | let sut = CountriesList(countries: .failed(NSError.test)) 99 | let exp = sut.inspection.inspect { view in 100 | let errorView = try view.content().view(ErrorView.self) 101 | try errorView.vStack().button(2).tap() 102 | XCTAssertEqual(container.appState.value, AppState()) 103 | container.interactors.verify() 104 | } 105 | ViewHosting.host(view: sut.inject(container)) 106 | wait(for: [exp], timeout: 2) 107 | } 108 | 109 | func test_countries_navigation_to_details() { 110 | let countries = Country.mockedData 111 | let container = DIContainer(appState: AppState(), interactors: .mocked()) 112 | XCTAssertNil(container.appState.value.routing.countriesList.countryDetails) 113 | let sut = CountriesList(countries: .loaded(countries.lazyList)) 114 | let exp = sut.inspection.inspect { view in 115 | let firstCountryRow = try view.content().find(ViewType.NavigationLink.self) 116 | try firstCountryRow.activate() 117 | let selected = container.appState.value.routing.countriesList.countryDetails 118 | XCTAssertEqual(selected, countries[0].alpha3Code) 119 | _ = try firstCountryRow.find(where: { try $0.callOnAppear(); return true }) 120 | container.interactors.verify() 121 | } 122 | ViewHosting.host(view: sut.inject(container)) 123 | wait(for: [exp], timeout: 2) 124 | } 125 | } 126 | 127 | final class LocalizationTests: XCTestCase { 128 | func test_country_localized_name() { 129 | let sut = Country(name: "Abc", translations: ["fr": "Xyz"], population: 0, flag: nil, alpha3Code: "") 130 | let locale = Locale(identifier: "fr") 131 | XCTAssertEqual(sut.name(locale: locale), "Xyz") 132 | } 133 | 134 | func test_string_for_locale() throws { 135 | let sut = "Countries".localized(Locale(identifier: "fr")) 136 | XCTAssertEqual(sut, "Des pays") 137 | } 138 | } 139 | 140 | // MARK: - CountriesList inspection helper 141 | 142 | extension InspectableView where View == ViewType.View { 143 | func content() throws -> InspectableView { 144 | return try find(ViewType.AnyView.self) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /UnitTests/UI/CountryDetailsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CountryDetailsTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 01.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ViewInspector 11 | @testable import CountriesSwiftUI 12 | 13 | extension CountryDetails: Inspectable { } 14 | extension DetailRow: Inspectable { } 15 | 16 | final class CountryDetailsTests: XCTestCase { 17 | 18 | let country = Country.mockedData[0] 19 | 20 | func test_details_notRequested() { 21 | let interactors = DIContainer.Interactors.mocked( 22 | countriesInteractor: [.loadCountryDetails(country)] 23 | ) 24 | let sut = CountryDetails(country: country, details: .notRequested) 25 | let exp = sut.inspection.inspect { view in 26 | XCTAssertNoThrow(try view.find(text: "")) 27 | interactors.verify() 28 | } 29 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 30 | wait(for: [exp], timeout: 2) 31 | } 32 | 33 | func test_details_isLoading_initial() { 34 | let interactors = DIContainer.Interactors.mocked() 35 | let sut = CountryDetails(country: country, details: 36 | .isLoading(last: nil, cancelBag: CancelBag())) 37 | let exp = sut.inspection.inspect { view in 38 | XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) 39 | interactors.verify() 40 | } 41 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 42 | wait(for: [exp], timeout: 2) 43 | } 44 | 45 | func test_details_isLoading_refresh() { 46 | let interactors = DIContainer.Interactors.mocked() 47 | let sut = CountryDetails(country: country, details: 48 | .isLoading(last: Country.Details.mockedData[0], cancelBag: CancelBag()) 49 | ) 50 | let exp = sut.inspection.inspect { view in 51 | XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) 52 | interactors.verify() 53 | } 54 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 55 | wait(for: [exp], timeout: 2) 56 | } 57 | 58 | func test_details_isLoading_cancellation() { 59 | let interactors = DIContainer.Interactors.mocked() 60 | let sut = CountryDetails(country: country, details: 61 | .isLoading(last: Country.Details.mockedData[0], cancelBag: CancelBag()) 62 | ) 63 | let exp = sut.inspection.inspect { view in 64 | XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) 65 | try view.find(button: "Cancel loading").tap() 66 | interactors.verify() 67 | } 68 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 69 | wait(for: [exp], timeout: 2) 70 | } 71 | 72 | func test_details_loaded() { 73 | let interactors = DIContainer.Interactors.mocked( 74 | imagesInteractor: [.loadImage(country.flag)] 75 | ) 76 | let sut = CountryDetails(country: country, details: 77 | .loaded(Country.Details.mockedData[0]) 78 | ) 79 | let exp = sut.inspection.inspect { view in 80 | XCTAssertNoThrow(try view.find(SVGImageView.self)) 81 | XCTAssertNoThrow(try view.find(DetailRow.self).find(text: self.country.alpha3Code)) 82 | interactors.verify() 83 | } 84 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 85 | wait(for: [exp], timeout: 3) 86 | } 87 | 88 | func test_details_failed() { 89 | let interactors = DIContainer.Interactors.mocked() 90 | let sut = CountryDetails(country: country, details: .failed(NSError.test)) 91 | let exp = sut.inspection.inspect { view in 92 | XCTAssertNoThrow(try view.find(ErrorView.self)) 93 | interactors.verify() 94 | } 95 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 96 | wait(for: [exp], timeout: 2) 97 | } 98 | 99 | func test_details_failed_retry() { 100 | let interactors = DIContainer.Interactors.mocked( 101 | countriesInteractor: [.loadCountryDetails(country)] 102 | ) 103 | let sut = CountryDetails(country: country, details: .failed(NSError.test)) 104 | let exp = sut.inspection.inspect { view in 105 | let errorView = try view.find(ErrorView.self) 106 | try errorView.vStack().button(2).tap() 107 | interactors.verify() 108 | } 109 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 110 | wait(for: [exp], timeout: 2) 111 | } 112 | 113 | func test_sheetPresentation() { 114 | let images: [MockedImagesInteractor.Action] = [.loadImage(country.flag), .loadImage(country.flag)] 115 | let interactors = DIContainer.Interactors.mocked( 116 | imagesInteractor: images 117 | ) 118 | let container = DIContainer(appState: .init(AppState()), interactors: interactors) 119 | XCTAssertFalse(container.appState.value.routing.countryDetails.detailsSheet) 120 | let sut = CountryDetails(country: country, details: .loaded(Country.Details.mockedData[0])) 121 | let exp1 = sut.inspection.inspect { view in 122 | try view.find(SVGImageView.self).callOnTapGesture() 123 | } 124 | let exp2 = sut.inspection.inspect(after: 0.5) { view in 125 | XCTAssertTrue(container.appState.value.routing.countryDetails.detailsSheet) 126 | interactors.verify() 127 | } 128 | ViewHosting.host(view: sut.inject(container)) 129 | wait(for: [exp1, exp2], timeout: 2) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /UnitTests/UI/DeepLinkUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkUITests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 10.01.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ViewInspector 11 | import Combine 12 | @testable import CountriesSwiftUI 13 | 14 | final class DeepLinkUITests: XCTestCase { 15 | 16 | func test_countriesList_selectsCountry() { 17 | 18 | let store = appStateWithDeepLink() 19 | let interactors = mockedInteractors(store: store) 20 | let container = DIContainer(appState: store, interactors: interactors) 21 | let sut = CountriesList() 22 | let exp = sut.inspection.inspect(after: 0.1) { view in 23 | let firstRowLink = try view.content().find(ViewType.NavigationLink.self) 24 | XCTAssertTrue(try firstRowLink.isActive()) 25 | } 26 | ViewHosting.host(view: sut.inject(container)) 27 | wait(for: [exp], timeout: 2) 28 | } 29 | 30 | func test_countryDetails_presentsSheet() { 31 | 32 | let store = appStateWithDeepLink() 33 | let interactors = mockedInteractors(store: store) 34 | let container = DIContainer(appState: store, interactors: interactors) 35 | let sut = CountryDetails(country: Country.mockedData[0]) 36 | let exp = sut.inspection.inspect(after: 0.1) { view in 37 | XCTAssertNoThrow(try view.find(ViewType.List.self)) 38 | XCTAssertTrue(store.value.routing.countryDetails.detailsSheet) 39 | } 40 | ViewHosting.host(view: sut.inject(container)) 41 | wait(for: [exp], timeout: 2) 42 | } 43 | } 44 | 45 | // MARK: - Setup 46 | 47 | private extension DeepLinkUITests { 48 | 49 | func appStateWithDeepLink() -> Store { 50 | let countries = Country.mockedData 51 | var appState = AppState() 52 | appState.routing.countriesList.countryDetails = countries[0].alpha3Code 53 | appState.routing.countryDetails.detailsSheet = true 54 | return Store(appState) 55 | } 56 | 57 | func mockedInteractors(store: Store) -> DIContainer.Interactors { 58 | 59 | let countries = Country.mockedData 60 | let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) 61 | let detailsIntermediate = Country.Details.Intermediate(capital: "", currencies: [], borders: []) 62 | let details = Country.Details(capital: "", currencies: [], neighbors: []) 63 | 64 | let countriesDBRepo = MockedCountriesDBRepository() 65 | let countriesWebRepo = MockedCountriesWebRepository() 66 | let imagesRepo = MockedImageWebRepository() 67 | 68 | // Mocking successful loading the list of countries: 69 | countriesDBRepo.hasLoadedCountriesResult = .success(false) 70 | countriesWebRepo.countriesResponse = .success(countries) 71 | countriesDBRepo.storeCountriesResult = .success(()) 72 | countriesDBRepo.fetchCountriesResult = .success(countries.lazyList) 73 | 74 | // Mocking successful loading the country details: 75 | countriesDBRepo.fetchCountryDetailsResult = .success(nil) 76 | countriesWebRepo.detailsResponse = .success(detailsIntermediate) 77 | countriesDBRepo.storeCountryDetailsResult = .success(details) 78 | 79 | // Mocking successful loading of the flag: 80 | imagesRepo.imageResponse = .success(testImage) 81 | 82 | let countriesInteractor = RealCountriesInteractor(webRepository: countriesWebRepo, 83 | dbRepository: countriesDBRepo, 84 | appState: store) 85 | let imagesInteractor = RealImagesInteractor(webRepository: imagesRepo) 86 | let permissionsInteractor = RealUserPermissionsInteractor(appState: store, openAppSettings: { }) 87 | return DIContainer.Interactors(countriesInteractor: countriesInteractor, 88 | imagesInteractor: imagesInteractor, 89 | userPermissionsInteractor: permissionsInteractor) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /UnitTests/UI/ModalDetailsViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModalDetailsViewTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 01.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import ViewInspector 12 | @testable import CountriesSwiftUI 13 | 14 | extension ModalDetailsView: Inspectable { } 15 | 16 | final class ModalDetailsViewTests: XCTestCase { 17 | 18 | func test_modalDetails() { 19 | let country = Country.mockedData[0] 20 | let interactors = DIContainer.Interactors.mocked( 21 | imagesInteractor: [.loadImage(country.flag)] 22 | ) 23 | let isDisplayed = Binding(wrappedValue: true) 24 | let sut = ModalDetailsView(country: country, isDisplayed: isDisplayed) 25 | let exp = sut.inspection.inspect { view in 26 | XCTAssertNoThrow(try view.find(SVGImageView.self)) 27 | XCTAssertNoThrow(try view.find(button: "Close")) 28 | interactors.verify() 29 | } 30 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 31 | wait(for: [exp], timeout: 2) 32 | } 33 | 34 | func test_modalDetails_close() { 35 | let country = Country.mockedData[0] 36 | let interactors = DIContainer.Interactors.mocked( 37 | imagesInteractor: [.loadImage(country.flag)] 38 | ) 39 | let isDisplayed = Binding(wrappedValue: true) 40 | let sut = ModalDetailsView(country: country, isDisplayed: isDisplayed) 41 | let exp = sut.inspection.inspect { view in 42 | XCTAssertTrue(isDisplayed.wrappedValue) 43 | try view.find(button: "Close").tap() 44 | XCTAssertFalse(isDisplayed.wrappedValue) 45 | interactors.verify() 46 | } 47 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 48 | wait(for: [exp], timeout: 2) 49 | } 50 | 51 | func test_modalDetails_close_localization() throws { 52 | let isDisplayed = Binding(wrappedValue: true) 53 | let sut = ModalDetailsView(country: Country.mockedData[0], isDisplayed: isDisplayed) 54 | let labelText = try sut.inspect().find(text: "Close") 55 | XCTAssertEqual(try labelText.string(), "Close") 56 | XCTAssertEqual(try labelText.string(locale: Locale(identifier: "fr")), "Fermer") 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /UnitTests/UI/RootViewAppearanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootViewAppearanceTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 05.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import ViewInspector 12 | @testable import CountriesSwiftUI 13 | 14 | extension RootViewAppearance: Inspectable { } 15 | 16 | final class RootViewAppearanceTests: XCTestCase { 17 | 18 | func test_blur_whenInactive() { 19 | let sut = RootViewAppearance() 20 | let container = DIContainer(appState: .init(AppState()), 21 | interactors: .mocked()) 22 | XCTAssertFalse(container.appState.value.system.isActive) 23 | let exp = sut.inspection.inspect { modifier in 24 | let content = try modifier.viewModifierContent() 25 | XCTAssertEqual(try content.blur().radius, 10) 26 | } 27 | let view = EmptyView().modifier(sut) 28 | .environment(\.injected, container) 29 | ViewHosting.host(view: view) 30 | wait(for: [exp], timeout: 0.1) 31 | } 32 | 33 | func test_blur_whenActive() { 34 | let sut = RootViewAppearance() 35 | let container = DIContainer(appState: .init(AppState()), 36 | interactors: .mocked()) 37 | container.appState[\.system.isActive] = true 38 | XCTAssertTrue(container.appState.value.system.isActive) 39 | let exp = sut.inspection.inspect { modifier in 40 | let content = try modifier.viewModifierContent() 41 | XCTAssertEqual(try content.blur().radius, 0) 42 | } 43 | let view = EmptyView().modifier(sut) 44 | .environment(\.injected, container) 45 | ViewHosting.host(view: view) 46 | wait(for: [exp], timeout: 0.1) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /UnitTests/UI/SVGImageViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SVGImageViewTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 10.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import ViewInspector 12 | @testable import CountriesSwiftUI 13 | 14 | extension SVGImageView: Inspectable { } 15 | 16 | final class SVGImageViewTests: XCTestCase { 17 | 18 | let url = URL(string: "https://test.com/test.png")! 19 | 20 | func test_imageView_notRequested() { 21 | let interactors = DIContainer.Interactors.mocked( 22 | imagesInteractor: [.loadImage(url)]) 23 | let sut = SVGImageView(imageURL: url, image: .notRequested) 24 | let exp = sut.inspection.inspect { view in 25 | XCTAssertNoThrow(try view.find(text: "")) 26 | interactors.verify() 27 | } 28 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 29 | wait(for: [exp], timeout: 2) 30 | } 31 | 32 | func test_imageView_isLoading_initial() { 33 | let interactors = DIContainer.Interactors.mocked() 34 | let sut = SVGImageView(imageURL: url, image: 35 | .isLoading(last: nil, cancelBag: CancelBag())) 36 | let exp = sut.inspection.inspect { view in 37 | XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) 38 | interactors.verify() 39 | } 40 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 41 | wait(for: [exp], timeout: 2) 42 | } 43 | 44 | func test_imageView_isLoading_refresh() { 45 | let interactors = DIContainer.Interactors.mocked() 46 | let image = UIColor.red.image(CGSize(width: 10, height: 10)) 47 | let sut = SVGImageView(imageURL: url, image: 48 | .isLoading(last: image, cancelBag: CancelBag())) 49 | let exp = sut.inspection.inspect { view in 50 | XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) 51 | interactors.verify() 52 | } 53 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 54 | wait(for: [exp], timeout: 2) 55 | } 56 | 57 | func test_imageView_loaded() { 58 | let interactors = DIContainer.Interactors.mocked() 59 | let image = UIColor.red.image(CGSize(width: 10, height: 10)) 60 | let sut = SVGImageView(imageURL: url, image: .loaded(image)) 61 | let exp = sut.inspection.inspect { view in 62 | let loadedImage = try view.find(ViewType.Image.self).actualImage().uiImage() 63 | XCTAssertEqual(loadedImage, image) 64 | interactors.verify() 65 | } 66 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 67 | wait(for: [exp], timeout: 3) 68 | } 69 | 70 | func test_imageView_failed() { 71 | let interactors = DIContainer.Interactors.mocked() 72 | let sut = SVGImageView(imageURL: url, image: .failed(NSError.test)) 73 | let exp = sut.inspection.inspect { view in 74 | XCTAssertNoThrow(try view.find(text: "Unable to load image")) 75 | interactors.verify() 76 | } 77 | ViewHosting.host(view: sut.inject(AppState(), interactors)) 78 | wait(for: [exp], timeout: 2) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /UnitTests/UI/SearchBarTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 15.01.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | import ViewInspector 12 | @testable import CountriesSwiftUI 13 | 14 | extension SearchBar: Inspectable { } 15 | 16 | final class SearchBarTests: XCTestCase { 17 | 18 | func test_searchBarCoordinator_beginEditing() { 19 | let text = Binding(wrappedValue: "abc") 20 | let sut = SearchBar.Coordinator(text: text) 21 | let searchBar = UISearchBar(frame: .zero) 22 | searchBar.delegate = sut 23 | XCTAssertTrue(sut.searchBarShouldBeginEditing(searchBar)) 24 | XCTAssertTrue(searchBar.showsCancelButton) 25 | XCTAssertEqual(text.wrappedValue, "abc") 26 | } 27 | 28 | func test_searchBarCoordinator_endEditing() { 29 | let text = Binding(wrappedValue: "abc") 30 | let sut = SearchBar.Coordinator(text: text) 31 | let searchBar = UISearchBar(frame: .zero) 32 | searchBar.delegate = sut 33 | XCTAssertTrue(sut.searchBarShouldEndEditing(searchBar)) 34 | XCTAssertFalse(searchBar.showsCancelButton) 35 | XCTAssertEqual(text.wrappedValue, "abc") 36 | } 37 | 38 | func test_searchBarCoordinator_textDidChange() { 39 | let text = Binding(wrappedValue: "abc") 40 | let sut = SearchBar.Coordinator(text: text) 41 | let searchBar = UISearchBar(frame: .zero) 42 | searchBar.delegate = sut 43 | sut.searchBar(searchBar, textDidChange: "test") 44 | XCTAssertEqual(text.wrappedValue, "test") 45 | } 46 | 47 | func test_searchBarCoordinator_cancelButtonClicked() { 48 | let text = Binding(wrappedValue: "abc") 49 | let sut = SearchBar.Coordinator(text: text) 50 | let searchBar = UISearchBar(frame: .zero) 51 | searchBar.text = text.wrappedValue 52 | searchBar.delegate = sut 53 | sut.searchBarCancelButtonClicked(searchBar) 54 | XCTAssertEqual(searchBar.text, "") 55 | XCTAssertEqual(text.wrappedValue, "") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /UnitTests/UI/ViewPreviewsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewPreviewsTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 01.11.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ViewInspector 11 | @testable import CountriesSwiftUI 12 | 13 | final class ViewPreviewsTests: XCTestCase { 14 | 15 | func test_contentView_previews() { 16 | _ = ContentView_Previews.previews 17 | } 18 | 19 | func test_countriesList_previews() { 20 | _ = CountriesList_Previews.previews 21 | } 22 | 23 | func test_countryDetails_previews() { 24 | _ = CountryDetails_Previews.previews 25 | } 26 | 27 | func test_modalDetailsView_previews() { 28 | _ = ModalDetailsView_Previews.previews 29 | } 30 | 31 | func test_countryCell_previews() { 32 | _ = CountryCell_Previews.previews 33 | } 34 | 35 | func test_detailRow_previews() { 36 | _ = DetailRow_Previews.previews 37 | } 38 | 39 | func test_errorView_previews() throws { 40 | let view = ErrorView_Previews.previews 41 | try view.inspect().view(ErrorView.self).actualView().retryAction() 42 | } 43 | 44 | func test_svgImageView_previews() { 45 | _ = SVGImageView_Previews.previews 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /UnitTests/Utilities/HelpersTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelpersTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 27.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import CountriesSwiftUI 11 | 12 | class HelpersTests: XCTestCase { 13 | 14 | func test_localized_knownLocale() { 15 | let sut = "Countries".localized(Locale(identifier: "fr")) 16 | XCTAssertEqual(sut, "Des pays") 17 | } 18 | 19 | func test_localized_unknownLocale() { 20 | let sut = "Countries".localized(Locale(identifier: "ch")) 21 | XCTAssertEqual(sut, "Countries") 22 | } 23 | 24 | func test_result_isSuccess() { 25 | let sut1 = Result.success(()) 26 | let sut2 = Result.failure(NSError.test) 27 | XCTAssertTrue(sut1.isSuccess) 28 | XCTAssertFalse(sut2.isSuccess) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /UnitTests/Utilities/LazyListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyListTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 18.04.2020. 6 | // Copyright © 2020 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | final class LazyListTests: XCTestCase { 14 | 15 | func test_empty() { 16 | let list = LazyList.empty 17 | XCTAssertThrowsError(try list.element(at: 0)) 18 | } 19 | 20 | func test_nil_element() { 21 | let list1 = LazyList(count: 1, useCache: false, { _ in nil }) 22 | XCTAssertThrowsError(try list1.element(at: 0)) 23 | let list2 = [0, 1].lazyList 24 | XCTAssertThrowsError(try list2.element(at: 2)) 25 | } 26 | 27 | func test_nil_element_error() { 28 | let error = LazyList.Error.elementIsNil(index: 5) 29 | XCTAssertEqual(error.localizedDescription, "Element at index 5 is nil") 30 | } 31 | 32 | func test_access_noCache() { 33 | var counter = 0 34 | let list = LazyList(count: 3, useCache: false) { _ in 35 | counter += 1 36 | return counter 37 | } 38 | [0, 1, 2, 0, 1, 2].forEach { index in 39 | _ = list[index] 40 | } 41 | XCTAssertEqual(counter, 6) 42 | } 43 | 44 | func test_access_withCache() { 45 | var counter = 0 46 | let list = LazyList(count: 3, useCache: true) { _ in 47 | counter += 1 48 | return counter 49 | } 50 | [0, 1, 2, 0, 1, 2].forEach { index in 51 | _ = list[index] 52 | } 53 | XCTAssertEqual(counter, 3) 54 | } 55 | 56 | let bgQueue1 = DispatchQueue(label: "bg1") 57 | let bgQueue2 = DispatchQueue(label: "bg2") 58 | 59 | func test_concurrent_access() { 60 | let indices = Array(stride(from: 0, to: 100, by: 1)) 61 | var counter = 0 62 | let list = LazyList(count: indices.count, useCache: true) { index in 63 | counter += 1 64 | return index 65 | } 66 | let exp1 = XCTestExpectation(description: "queue1") 67 | let exp2 = XCTestExpectation(description: "queue2") 68 | bgQueue1.async { 69 | let result1 = indices.map { list[$0] } 70 | XCTAssertEqual(result1, indices) 71 | XCTAssertEqual(counter, indices.count) 72 | exp1.fulfill() 73 | } 74 | bgQueue2.async { 75 | let result2 = indices.map { list[$0] } 76 | XCTAssertEqual(result2, indices) 77 | XCTAssertEqual(counter, indices.count) 78 | exp2.fulfill() 79 | } 80 | wait(for: [exp1, exp2], timeout: 0.5) 81 | } 82 | 83 | func test_sequence() { 84 | let indices = Array(stride(from: 0, to: 10, by: 1)) 85 | let list = LazyList(count: indices.count, useCache: true) { $0 } 86 | XCTAssertEqual(list.underestimatedCount, indices.count) 87 | XCTAssertEqual(list.reversed(), indices.reversed()) 88 | 89 | let nilList = LazyList(count: 1, useCache: false) { _ in nil } 90 | var iterator = nilList.makeIterator() 91 | XCTAssertNil(iterator.next()) 92 | } 93 | 94 | func test_randomAccessCollection() { 95 | let list = LazyList(count: 10, useCache: true) { $0 } 96 | XCTAssertEqual(list.firstIndex(of: 2), 2) 97 | XCTAssertEqual(list.last, 9) 98 | } 99 | 100 | func test_equatable() { 101 | let list1 = LazyList(count: 10, useCache: true) { $0 } 102 | let list2 = LazyList(count: 11, useCache: true) { $0 } 103 | let list3 = Array(stride(from: 0, to: 10, by: 1)).lazyList 104 | XCTAssertNotEqual(list1, list2) 105 | XCTAssertEqual(list1, list1) 106 | XCTAssertEqual(list1, list3) 107 | } 108 | 109 | func test_description() { 110 | let emptyList = LazyList.empty 111 | let oneElementList = LazyList(count: 1, useCache: false) { $0 + 1 } 112 | let nonEmptyList = LazyList(count: 3, useCache: false) { $0 * 2 } 113 | XCTAssertEqual(emptyList.description, "LazyList<[]>") 114 | XCTAssertEqual(oneElementList.description, "LazyList<[1]>") 115 | XCTAssertEqual(nonEmptyList.description, "LazyList<[0, 2, 4]>") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /UnitTests/Utilities/LoadableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadableTests.swift 3 | // UnitTests 4 | // 5 | // Created by Alexey Naumov on 31.10.2019. 6 | // Copyright © 2019 Alexey Naumov. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | @testable import CountriesSwiftUI 12 | 13 | final class LoadableTests: XCTestCase { 14 | 15 | func test_equality() { 16 | let possibleValues: [Loadable] = [ 17 | .notRequested, 18 | .isLoading(last: nil, cancelBag: CancelBag()), 19 | .isLoading(last: 9, cancelBag: CancelBag()), 20 | .loaded(5), 21 | .loaded(6), 22 | .failed(NSError.test) 23 | ] 24 | possibleValues.enumerated().forEach { (index1, value1) in 25 | possibleValues.enumerated().forEach { (index2, value2) in 26 | if index1 == index2 { 27 | XCTAssertEqual(value1, value2) 28 | } else { 29 | XCTAssertNotEqual(value1, value2) 30 | } 31 | } 32 | } 33 | } 34 | 35 | func test_cancelLoading() { 36 | let cancenBag1 = CancelBag(), cancenBag2 = CancelBag() 37 | let subject = PassthroughSubject() 38 | subject.sink { _ in } 39 | .store(in: cancenBag1) 40 | subject.sink { _ in } 41 | .store(in: cancenBag2) 42 | var sut1 = Loadable.isLoading(last: nil, cancelBag: cancenBag1) 43 | XCTAssertEqual(cancenBag1.subscriptions.count, 1) 44 | sut1.cancelLoading() 45 | XCTAssertEqual(cancenBag1.subscriptions.count, 0) 46 | XCTAssertNotNil(sut1.error) 47 | var sut2 = Loadable.isLoading(last: 7, cancelBag: cancenBag2) 48 | XCTAssertEqual(cancenBag2.subscriptions.count, 1) 49 | sut2.cancelLoading() 50 | XCTAssertEqual(cancenBag2.subscriptions.count, 0) 51 | XCTAssertEqual(sut2.value, 7) 52 | } 53 | 54 | func test_map() { 55 | let values: [Loadable] = [ 56 | .notRequested, 57 | .isLoading(last: nil, cancelBag: CancelBag()), 58 | .isLoading(last: 5, cancelBag: CancelBag()), 59 | .loaded(7), 60 | .failed(NSError.test) 61 | ] 62 | let expect: [Loadable] = [ 63 | .notRequested, 64 | .isLoading(last: nil, cancelBag: CancelBag()), 65 | .isLoading(last: "5", cancelBag: CancelBag()), 66 | .loaded("7"), 67 | .failed(NSError.test) 68 | ] 69 | let sut = values.map { value in 70 | value.map { "\($0)" } 71 | } 72 | XCTAssertEqual(sut, expect) 73 | } 74 | 75 | func test_helperFunctions() { 76 | let notRequested = Loadable.notRequested 77 | let loadingNil = Loadable.isLoading(last: nil, cancelBag: CancelBag()) 78 | let loadingValue = Loadable.isLoading(last: 9, cancelBag: CancelBag()) 79 | let loaded = Loadable.loaded(5) 80 | let failedErrValue = Loadable.failed(NSError.test) 81 | [notRequested, loadingNil].forEach { 82 | XCTAssertNil($0.value) 83 | } 84 | [loadingValue, loaded].forEach { 85 | XCTAssertNotNil($0.value) 86 | } 87 | [notRequested, loadingNil, loadingValue, loaded].forEach { 88 | XCTAssertNil($0.error) 89 | } 90 | XCTAssertNotNil(failedErrValue.error) 91 | } 92 | 93 | func test_throwingMap() { 94 | let value = Loadable.loaded(5) 95 | let sut = value.map { _ in throw NSError.test } 96 | XCTAssertNotNil(sut.error) 97 | } 98 | 99 | func test_valueIsMissing() { 100 | XCTAssertEqual(ValueIsMissingError().localizedDescription, "Data is missing") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /codemagic.yaml: -------------------------------------------------------------------------------- 1 | workflows: 2 | testing-workflow: 3 | name: iOS Workflow 4 | instance_type: mac_pro 5 | environment: 6 | vars: 7 | XCODE_PROJECT: "CountriesSwiftUI.xcodeproj" 8 | XCODE_SCHEME: "CountriesSwiftUI" 9 | xcode: latest 10 | cocoapods: default 11 | triggering: 12 | branch_patterns: 13 | - pattern: "master" 14 | include: true 15 | source: true 16 | scripts: 17 | - name: Run tests 18 | script: | 19 | xcode-project run-tests \ 20 | --project "$XCODE_PROJECT" \ 21 | --scheme "$XCODE_SCHEME" \ 22 | --device "iPhone 12" 23 | test_report: build/ios/test/*.xml 24 | --------------------------------------------------------------------------------