├── Sources ├── iOSSampleApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Back.imageset │ │ │ ├── Back.png │ │ │ ├── Back@2x.png │ │ │ ├── Back@3x.png │ │ │ └── Contents.json │ │ ├── Logo.imageset │ │ │ ├── Icon-180.png │ │ │ └── Contents.json │ │ ├── Forward.imageset │ │ │ ├── Forward.png │ │ │ ├── Forward@2x.png │ │ │ ├── Forward@3x.png │ │ │ └── Contents.json │ │ ├── About.imageset │ │ │ ├── 1532-info@1x.png │ │ │ ├── 1532-info@2x.png │ │ │ ├── 1532-info@3x.png │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Icon-1024.png │ │ │ └── Contents.json │ │ └── Settings.imageset │ │ │ ├── 1548-gear@1x.png │ │ │ ├── 1548-gear@2x.png │ │ │ ├── 1548-gear@3x.png │ │ │ └── Contents.json │ ├── Scenarios │ │ ├── About │ │ │ ├── Model │ │ │ │ ├── Library.swift │ │ │ │ └── AboutMenuItem.swift │ │ │ ├── ViewModels │ │ │ │ ├── AboutViewModel.swift │ │ │ │ └── LibrariesViewModel.swift │ │ │ ├── Cells │ │ │ │ ├── AboutCell.swift │ │ │ │ └── LibraryCell.swift │ │ │ ├── ViewControllers │ │ │ │ ├── LibrariesViewController.swift │ │ │ │ └── AboutViewController.swift │ │ │ └── Coordinators │ │ │ │ └── AboutCoordinator.swift │ │ ├── Feed │ │ │ ├── Models │ │ │ │ ├── RssItem.swift │ │ │ │ └── RssItem+FeedKit.swift │ │ │ ├── Services │ │ │ │ ├── Protocols │ │ │ │ │ └── DataService.swift │ │ │ │ └── RssDataService.swift │ │ │ ├── Cells │ │ │ │ └── FeedCell.swift │ │ │ ├── ViewModels │ │ │ │ └── FeedViewModel.swift │ │ │ ├── Coordinators │ │ │ │ └── FeedCoordinator.swift │ │ │ └── ViewControllers │ │ │ │ ├── FeedViewController.swift │ │ │ │ └── DetailViewController.swift │ │ ├── Common │ │ │ ├── Services │ │ │ │ ├── Protocols │ │ │ │ │ └── SettingsService.swift │ │ │ │ └── UserDefaultsSettingsService.swift │ │ │ ├── Base.lproj │ │ │ │ └── LaunchScreen.storyboard │ │ │ └── Coordinators │ │ │ │ └── AppCoordinator.swift │ │ └── Setup │ │ │ ├── Models │ │ │ └── RssSource.swift │ │ │ ├── ViewModels │ │ │ ├── CustomSourceViewModel.swift │ │ │ ├── RssSourceViewModel.swift │ │ │ └── SourceSelectionViewModel.swift │ │ │ ├── Views │ │ │ └── FormFieldView.swift │ │ │ ├── Cells │ │ │ └── RssSourceCell.swift │ │ │ ├── Coordinators │ │ │ └── SetupCoordinator.swift │ │ │ └── ViewControllers │ │ │ ├── CustomSourceViewController.swift │ │ │ └── SourceSelectionViewController.swift │ ├── Supporting Files │ │ ├── Extensions │ │ │ ├── UIEdgeInsets+Extensions.swift │ │ │ ├── Optional+Extensions.swift │ │ │ ├── Array+Extensions.swift │ │ │ ├── Logger+Extensions.swift │ │ │ ├── UIScrollView+Extensions.swift │ │ │ ├── UINavigationController+Extensions.swift │ │ │ ├── String+Extensions.swift │ │ │ ├── Bundle+Extensions.swift │ │ │ ├── RxSwift+WKWebView.swift │ │ │ ├── UIKit+Preview.swift │ │ │ ├── Reactive+Extensions.swift │ │ │ ├── UIView+Layout.swift │ │ │ └── UIViewController+Extensions.swift │ │ ├── Coordinators │ │ │ └── Coordinator.swift │ │ ├── Operators.swift │ │ └── Reusable.swift │ ├── Data │ │ ├── sources.json │ │ └── Licenses.plist │ ├── AppDelegate.swift │ ├── Info.plist │ ├── Container.swift │ └── Resources │ │ └── Localizable.xcstrings ├── swiftgen.yml ├── iOSSampleApp.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── iOSSampleApp.xcscheme ├── iOSSampleAppTests │ ├── Mocks │ │ ├── SettingsServiceMock.swift │ │ └── DataServiceMock.swift │ ├── Info.plist │ ├── TestExtensions.swift │ ├── FeedViewModelTests.swift │ ├── DataServiceTests.swift │ ├── ViewControllerLeakTests.swift │ ├── CustomSourceViewModelTests.swift │ ├── SourceSelectionViewModelTests.swift │ └── Support │ │ └── RxBlocking.swift ├── iOSSampleAppUITests │ ├── Info.plist │ └── AppUITests.swift └── .swiftlint.yml ├── fastlane ├── screenshots │ ├── en-US │ │ ├── iPhone 7 Plus-1-Setup.png │ │ ├── iPhone 7 Plus-2-List.png │ │ ├── iPhone 7 Plus-4-About.png │ │ └── iPhone 7 Plus-3-Detail.png │ ├── sk-SK │ │ ├── iPhone 7 Plus-1-Setup.png │ │ ├── iPhone 7 Plus-2-List.png │ │ ├── iPhone 7 Plus-4-About.png │ │ └── iPhone 7 Plus-3-Detail.png │ └── screenshots.html ├── Snapfile ├── README.md ├── Fastfile └── SnapshotHelper.swift ├── Gemfile ├── support ├── bootstrap.sh └── fetch_licenses.swift ├── .gitignore ├── .github └── workflows │ └── test.yml ├── LICENSE ├── README.md ├── Dangerfile └── Gemfile.lock /Sources/iOSSampleApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /fastlane/screenshots/en-US/iPhone 7 Plus-1-Setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/en-US/iPhone 7 Plus-1-Setup.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-US/iPhone 7 Plus-2-List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/en-US/iPhone 7 Plus-2-List.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-US/iPhone 7 Plus-4-About.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/en-US/iPhone 7 Plus-4-About.png -------------------------------------------------------------------------------- /fastlane/screenshots/sk-SK/iPhone 7 Plus-1-Setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/sk-SK/iPhone 7 Plus-1-Setup.png -------------------------------------------------------------------------------- /fastlane/screenshots/sk-SK/iPhone 7 Plus-2-List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/sk-SK/iPhone 7 Plus-2-List.png -------------------------------------------------------------------------------- /fastlane/screenshots/sk-SK/iPhone 7 Plus-4-About.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/sk-SK/iPhone 7 Plus-4-About.png -------------------------------------------------------------------------------- /fastlane/screenshots/en-US/iPhone 7 Plus-3-Detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/en-US/iPhone 7 Plus-3-Detail.png -------------------------------------------------------------------------------- /fastlane/screenshots/sk-SK/iPhone 7 Plus-3-Detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/fastlane/screenshots/sk-SK/iPhone 7 Plus-3-Detail.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back@2x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Back@3x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Logo.imageset/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Logo.imageset/Icon-180.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@1x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@2x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/About.imageset/1532-info@3x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward@2x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Forward@3x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@1x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@2x.png -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorkulman/iOSSampleApp/HEAD/Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/1548-gear@3x.png -------------------------------------------------------------------------------- /Sources/swiftgen.yml: -------------------------------------------------------------------------------- 1 | strings: 2 | inputs: iOSSampleApp/Resources/Base.lproj/Localizable.strings 3 | outputs: 4 | - templateName: structured-swift4 5 | output: iOSSampleApp/Resources/Strings.swift -------------------------------------------------------------------------------- /Sources/iOSSampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | gem "danger" 5 | gem 'danger-swiftlint' 6 | 7 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 8 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 9 | -------------------------------------------------------------------------------- /support/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | brew update 4 | brew ls --versions swiftlint && brew upgrade swiftlint || brew install swiftlint 5 | brew ls --versions swiftgen && brew upgrade swiftgen || brew install swiftgen 6 | sudo gem install fastlane -NV -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /fastlane/report.xml 3 | /fastlane/test_output 4 | Sources/Carthage 5 | Sources/iOSSampleApp.xcodeproj/project.xcworkspace/xcuserdata/igorkulman.xcuserdatad/UserInterfaceState.xcuserstate 6 | Sources/iOSSampleApp.xcodeproj/xcuserdata/ 7 | Sources/iOSSampleApp.xcodeproj/project.xcworkspace/xcshareddata/ 8 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/Model/Library.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Library.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 12.11.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Library { 12 | let title: String 13 | let license: String 14 | } 15 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | job-test: 7 | name: Run unit tests 8 | runs-on: macos-15 9 | steps: 10 | - uses: actions/checkout@v1 11 | 12 | - name: Install needed software 13 | run: | 14 | gem install xcpretty 15 | gem install bundler:1.17.2 16 | 17 | - name: Run unit tests 18 | run: fastlane tests -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Models/RssItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssItem.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RssItem { 12 | let title: String 13 | let description: String? 14 | let link: URL 15 | let pubDate: Date? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/Mocks/SettingsServiceMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsServiceMock.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 16/09/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | 12 | class SettingsServiceMock: SettingsService { 13 | var selectedSource: RssSource? 14 | } 15 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Icon-180.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UIEdgeInsets+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIEdgeInsets+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIEdgeInsets { 13 | init(all: CGFloat) { 14 | self.init(top: all, left: all, bottom: all, right: all) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/Optional+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Optional+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 17/04/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Optional { 12 | var isNone: Bool { 13 | return self == nil 14 | } 15 | 16 | var isSome: Bool { 17 | return self != nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Common/Services/Protocols/SettingsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsService.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 20/02/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | App specific settings 13 | */ 14 | protocol SettingsService: AnyObject { 15 | /** 16 | Currently selected RSS source 17 | */ 18 | var selectedSource: RssSource? { get set } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/Array+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 10/05/2019. 6 | // Copyright © 2019 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Array { 12 | func inserting(_ newElement: Element, at index: Int) -> [Element] { 13 | var arr = self 14 | arr.insert(newElement, at: index) 15 | return arr 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/Mocks/DataServiceMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataServiceMock.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 16/09/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | 12 | class DataServiceMock: DataService { 13 | var result: RssResult? 14 | 15 | func getFeed(source: RssSource, onCompletion: @escaping (RssResult) -> Void) { 16 | onCompletion(result!) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Back.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Back.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Back@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Back@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/ViewModels/AboutViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class AboutViewModel { 12 | 13 | let appName: String 14 | let appVersion: String 15 | 16 | init() { 17 | appName = Bundle.main.appName 18 | appVersion = "\(Bundle.main.appVersion) (\(Bundle.main.appBuild))" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Forward.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Forward.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Forward@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Forward@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/About.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "1532-info@1x.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "1532-info@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "1532-info@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/Logger+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 23/07/2020. 6 | // Copyright © 2020 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | extension Logger { 13 | private static var subsystem = Bundle.main.bundleIdentifier! 14 | 15 | static let data = Logger(subsystem: subsystem, category: "data") 16 | static let appFlow = Logger(subsystem: subsystem, category: "appFlow") 17 | } 18 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Assets.xcassets/Settings.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "1548-gear@1x.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "1548-gear@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "1548-gear@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/Models/RssSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssSource.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RssSource: Codable { 12 | let title: String 13 | let url: URL 14 | let rss: URL 15 | let icon: URL? 16 | } 17 | 18 | extension RssSource: Equatable { 19 | static func == (lhs: RssSource, rhs: RssSource) -> Bool { 20 | return lhs.rss == rhs.rss 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UIScrollView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScrollView+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIScrollView { 13 | func setBottomInset(height: CGFloat) { 14 | let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: height, right: 0) 15 | contentInset = contentInsets 16 | scrollIndicatorInsets = contentInsets 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /fastlane/Snapfile: -------------------------------------------------------------------------------- 1 | # Uncomment the lines below you want to change by removing the # in the beginning 2 | 3 | # A list of devices you want to take the screenshots from 4 | devices([ 5 | "iPhone 14" 6 | ]) 7 | 8 | languages([ 9 | "en-US", 10 | "sk-SK" 11 | ]) 12 | 13 | # The name of the scheme which contains the UI Tests 14 | scheme("iOSSampleApp") 15 | 16 | # Where should the resulting screenshots be stored? 17 | output_directory("./fastlane/screenshots") 18 | 19 | # remove the '#' to clear all previously generated screenshots before creating new ones 20 | clear_previous_screenshots(true) -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Coordinators/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol Coordinator: AnyObject { 13 | /** 14 | Entry point starting the coordinator 15 | */ 16 | func start() 17 | } 18 | 19 | protocol NavigationCoordinator: Coordinator { 20 | /** 21 | Navigation controller 22 | */ 23 | var navigationController: UINavigationController { get } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UINavigationController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationController+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 05/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UINavigationController { 13 | func setBackButton() { 14 | let backButton = UIBarButtonItem() 15 | backButton.title = NSLocalizedString("back", comment: "") 16 | viewControllers.last?.navigationItem.backBarButtonItem = backButton 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/Cells/AboutCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutCell.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | final class AboutCell: UITableViewCell, Reusable { 13 | 14 | // MARK: - Properties 15 | 16 | var model: AboutMenuItem? 17 | 18 | // MARK: - Configuration 19 | 20 | override func updateConfiguration(using state: UICellConfigurationState) { 21 | contentConfiguration = defaultContentConfiguration() &> { 22 | $0.text = model?.title 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Operators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Operators.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | precedencegroup FunctionApplicationPrecedence { 12 | associativity: left 13 | higherThan: BitwiseShiftPrecedence 14 | } 15 | 16 | infix operator &>: FunctionApplicationPrecedence 17 | 18 | // swiftlint:disable static_operator 19 | @discardableResult 20 | public func &> (value: Input, function: (inout Input) throws -> Void) rethrows -> Input { 21 | var m_value = value 22 | try function(&m_value) 23 | return m_value 24 | } 25 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/Cells/LibraryCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryCell.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | final class LibraryCell: UITableViewCell, Reusable { 13 | 14 | // MARK: - Properties 15 | 16 | var model: Library? 17 | 18 | // MARK: - Configuration 19 | 20 | override func updateConfiguration(using state: UICellConfigurationState) { 21 | contentConfiguration = UIListContentConfiguration.subtitleCell() &> { 22 | $0.text = model?.title 23 | $0.secondaryText = model?.license 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/Model/AboutMenuItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutMenuItem.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AboutMenuItem: CaseIterable { 12 | case libraries 13 | case aboutAuthor 14 | case authorsBlog 15 | } 16 | 17 | extension AboutMenuItem { 18 | var title: String { 19 | switch self { 20 | case .libraries: 21 | return NSLocalizedString("libraries", comment: "") 22 | case .aboutAuthor: 23 | return NSLocalizedString("author", comment: "") 24 | case .authorsBlog: 25 | return NSLocalizedString("blog", comment: "") 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppUITests/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Services/Protocols/DataService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataService.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 20/02/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum RssError: Error, CustomStringConvertible { 12 | case emptyResponse 13 | case networkError(Error) 14 | 15 | var description: String { 16 | switch self { 17 | case .emptyResponse: 18 | return NSLocalizedString("empty_response", comment: "") 19 | case let .networkError(error): 20 | return error.localizedDescription 21 | } 22 | } 23 | } 24 | 25 | enum RssResult { 26 | case failure(RssError) 27 | case success([RssItem]) 28 | } 29 | 30 | protocol DataService: AnyObject { 31 | func getFeed(source: RssSource, onCompletion: @escaping (RssResult) -> Void) 32 | } 33 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | var isValidURL: Bool { 13 | return isStringLink(string: self) 14 | } 15 | 16 | private func isStringLink(string: String) -> Bool { 17 | let types: NSTextCheckingResult.CheckingType = [.link] 18 | let detector = try? NSDataDetector(types: types.rawValue) 19 | guard detector != nil && !string.isEmpty else { return false } 20 | if detector!.numberOfMatches(in: string, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSRange(location: 0, length: string.count)) > 0 { 21 | return true 22 | } 23 | return false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Data/sources.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Coding Journal", 4 | "url": "https://blog.kulman.sk", 5 | "rss": "https://blog.kulman.sk/index.xml", 6 | "icon": "https://blog.kulman.sk/apple-touch-icon.png" 7 | }, 8 | { 9 | "title": "Hacker News", 10 | "url": "https://news.ycombinator.com", 11 | "rss": "https://news.ycombinator.com/rss", 12 | "icon": "https://upload.wikimedia.org/wikipedia/commons/d/d5/Y_Combinator_Logo_400.gif" 13 | }, 14 | { 15 | "title": "The Verge", 16 | "url": "https://www.theverge.com", 17 | "rss": "https://www.theverge.com/rss/index.xml", 18 | "icon": "https://cdn0.vox-cdn.com/uploads/chorus_asset/file/7395351/android-chrome-192x192.0.png" 19 | }, 20 | { 21 | "title": "Wired", 22 | "url": "https://www.wired.com", 23 | "rss": "https://www.wired.com/feed/rss", 24 | "icon": "https://logowik.com/content/uploads/images/703_wired_logo.jpg" 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/ViewModels/LibrariesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibrariesViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class LibrariesViewModel { 14 | 15 | // MARK: - Properties 16 | 17 | let libraries: Driver<[Library]> 18 | 19 | init() { 20 | guard let path = Bundle.main.path(forResource: "Licenses", ofType: "plist"), let array = NSArray(contentsOfFile: path) as? [[String: Any]] else { 21 | fatalError("Invalid bundle linceses file") 22 | } 23 | 24 | libraries = Observable.just(array.map { 25 | let title = $0["title"] as! String 26 | let license = $0["license"] as! String 27 | return Library(title: title, license: license) 28 | }).asDriver(onErrorJustReturn: []) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Bundle { 12 | var appName: String { 13 | return infoDictionary?["CFBundleName"] as! String 14 | } 15 | 16 | var appVersion: String { 17 | return infoDictionary?["CFBundleShortVersionString"] as! String 18 | } 19 | 20 | var appBuild: String { 21 | return infoDictionary?["CFBundleVersion"] as! String 22 | } 23 | 24 | func loadFile(filename fileName: String) -> Data? { 25 | let parts = fileName.components(separatedBy: ".") 26 | if let url = Bundle.main.url(forResource: parts[0], withExtension: parts[1]), let data = try? Data(contentsOf: url) { 27 | return data 28 | } else { 29 | return nil 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | private var container: Container! 16 | 17 | private var appCoordinator: AppCoordinator! 18 | 19 | func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 20 | container = Container() 21 | 22 | return true 23 | } 24 | 25 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 26 | window = UIWindow() 27 | 28 | appCoordinator = AppCoordinator(window: window!, container: container) 29 | appCoordinator.start() 30 | 31 | window?.makeKeyAndVisible() 32 | 33 | return true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Cells/FeedCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedCell.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class FeedCell: UITableViewCell, Reusable { 12 | 13 | // MARK: - Properties 14 | 15 | var model: RssItem? 16 | 17 | // MARK: - Configuration 18 | 19 | override func updateConfiguration(using state: UICellConfigurationState) { 20 | contentConfiguration = UIListContentConfiguration.subtitleCell() &> { 21 | $0.text = model?.title 22 | $0.secondaryText = model?.description?.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) 23 | 24 | $0.textProperties.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 17, weight: .semibold)) 25 | $0.secondaryTextProperties.font = UIFont.preferredFont(forTextStyle: .subheadline) 26 | $0.secondaryTextProperties.numberOfLines = 3 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Reusable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reusable.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 27.05.2025. 6 | // Copyright © 2025 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol Reusable: AnyObject { 13 | static var reuseIdentifier: String { get } 14 | } 15 | 16 | extension Reusable { 17 | static var reuseIdentifier: String { 18 | return String(describing: Self.self) 19 | } 20 | } 21 | 22 | extension UITableView { 23 | func register(cellType: T.Type) where T: Reusable { 24 | self.register(cellType, forCellReuseIdentifier: cellType.reuseIdentifier) 25 | } 26 | 27 | func dequeueReusableCell(for indexPath: IndexPath) -> T where T: Reusable { 28 | guard let cell = self.dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as? T else { 29 | fatalError("Could not dequeue cell with identifier: \(T.reuseIdentifier)") 30 | } 31 | return cell 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ### tests 17 | 18 | ```sh 19 | [bundle exec] fastlane tests 20 | ``` 21 | 22 | Run all unit tests 23 | 24 | ### update_licenses 25 | 26 | ```sh 27 | [bundle exec] fastlane update_licenses 28 | ``` 29 | 30 | Updates libraries licenses 31 | 32 | ---- 33 | 34 | 35 | ## iOS 36 | 37 | ### ios screenshots 38 | 39 | ```sh 40 | [bundle exec] fastlane ios screenshots 41 | ``` 42 | 43 | Generate new localized screenshots 44 | 45 | ---- 46 | 47 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 48 | 49 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 50 | 51 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 - 2018 Igor Kulman 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 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/RxSwift+WKWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RxSwift+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/12/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | import WebKit 13 | 14 | extension Reactive where Base: WKWebView { 15 | var title: Observable { 16 | return observeWeakly(String.self, "title") 17 | } 18 | 19 | var loading: Observable { 20 | return observeWeakly(Bool.self, "loading").map { $0 ?? false } 21 | } 22 | 23 | var estimatedProgress: Observable { 24 | return observeWeakly(Double.self, "estimatedProgress").map { $0 ?? 0.0 } 25 | } 26 | 27 | var url: Observable { 28 | return observeWeakly(URL.self, "URL") 29 | } 30 | 31 | var canGoBack: Observable { 32 | return observeWeakly(Bool.self, "canGoBack").map { $0 ?? false } 33 | } 34 | 35 | var canGoForward: Observable { 36 | return observeWeakly(Bool.self, "canGoForward").map { $0 ?? false } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Common/Services/UserDefaultsSettingsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsSettingsService 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | App specific settings implemented using user defaults 13 | */ 14 | final class UserDefaultsSettingsService: SettingsService { 15 | /** 16 | Currently selected RSS source 17 | */ 18 | var selectedSource: RssSource? { 19 | get { 20 | let coder = JSONDecoder() 21 | guard let value = UserDefaults.standard.data(forKey: "source"), let source = try? coder.decode(RssSource.self, from: value) else { 22 | return nil 23 | } 24 | 25 | return source 26 | } 27 | set { 28 | guard let value = newValue else { 29 | UserDefaults.standard.removeObject(forKey: "source") 30 | return 31 | } 32 | 33 | let coder = JSONEncoder() 34 | guard let data = try? coder.encode(value) else { 35 | fatalError("Encoding RssSource should never fail") 36 | } 37 | 38 | UserDefaults.standard.set(data, forKey: "source") 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UIKit+Preview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKit+Preview.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 07.02.2024. 6 | // Copyright © 2024 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import UIKit 12 | 13 | // MARK: - UIViewController extensions 14 | 15 | extension UIViewController { 16 | private struct Preview: UIViewControllerRepresentable { 17 | var viewController: UIViewController 18 | 19 | func makeUIViewController(context: Context) -> UIViewController { 20 | viewController 21 | } 22 | 23 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) { 24 | // No-op 25 | } 26 | } 27 | 28 | func asPreview() -> some View { 29 | Preview(viewController: self) 30 | } 31 | } 32 | 33 | // MARK: - UIView Extensions 34 | 35 | extension UIView { 36 | private struct Preview: UIViewRepresentable { 37 | var view: UIView 38 | 39 | func makeUIView(context: Context) -> UIView { 40 | view 41 | } 42 | 43 | func updateUIView(_ view: UIView, context: Context) { 44 | // No-op 45 | } 46 | } 47 | 48 | @available(iOS 13, *) 49 | func asPreview() -> some View { 50 | Preview(view: self) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/TestExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestExtensions.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 21/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import XCTest 12 | 13 | extension RssResult: Equatable { 14 | public static func == (lhs: RssResult, rhs: RssResult) -> Bool { 15 | switch (lhs, rhs) { 16 | case let (.failure(lerror), .failure(rerror)): 17 | switch (lerror, rerror) { 18 | case (RssError.emptyResponse, RssError.emptyResponse): 19 | return true 20 | case (RssError.networkError(let lerror), RssError.networkError(let rerror)): 21 | return lerror.localizedDescription == rerror.localizedDescription 22 | default: 23 | return false 24 | } 25 | case (.success, .success): 26 | return true 27 | default: 28 | return false 29 | } 30 | } 31 | 32 | } 33 | 34 | extension XCTestCase { 35 | func assertDeallocatedAfterTest(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { 36 | addTeardownBlock { [weak instance] in 37 | XCTAssertNil(instance, "Instance was not deallocated, potential memory leak.", file: file, line: line) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 106 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationLandscapeLeft 34 | UIInterfaceOrientationLandscapeRight 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationPortraitUpsideDown 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/ViewModels/CustomSourceViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSourceViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | 13 | final class CustomSourceViewModel { 14 | 15 | // MARK: - Properties 16 | 17 | let title = BehaviorRelay(value: nil) 18 | let url = BehaviorRelay(value: nil) 19 | let logoUrl = BehaviorRelay(value: nil) 20 | let rssUrl = BehaviorRelay(value: nil) 21 | 22 | let isValid: Driver 23 | let source: Driver 24 | 25 | init() { 26 | source = Observable.combineLatest(title.asObservable(), url.asObservable(), rssUrl.asObservable(), logoUrl.asObservable()) { title, url, rssUrl, logoUrl in 27 | guard let title = title, !title.isEmpty, let url = url, url.isValidURL, let urlValue = URL(string: url), let rssUrl = rssUrl, rssUrl.isValidURL, let rssUrlValue = URL(string: rssUrl) else { 28 | return nil 29 | } 30 | 31 | return RssSource(title: title, url: urlValue, rss: rssUrlValue, icon: logoUrl.flatMap({ URL(string: $0) })) 32 | }.asDriver(onErrorJustReturn: nil) 33 | 34 | isValid = source.map({ $0.isSome }).asDriver(onErrorJustReturn: false) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Models/RssItem+FeedKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssItem+FeedKit.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 12.04.2025. 6 | // Copyright © 2025 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | import Foundation 11 | 12 | private let sanitize = { (string: String?) -> String? in 13 | return string? 14 | .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) 15 | .trimmingCharacters(in: .whitespacesAndNewlines) 16 | } 17 | 18 | extension RssItem { 19 | init?(item: AtomFeedEntry) { 20 | guard let title = item.title, 21 | let link = item.links? 22 | .compactMap({ $0.attributes?.href }) 23 | .first.flatMap({ URL(string: $0) }) else { 24 | return nil 25 | } 26 | self.init( 27 | title: title, 28 | description: sanitize(item.content?.value), 29 | link: link, 30 | pubDate: item.updated 31 | ) 32 | } 33 | 34 | init?(item: RSSFeedItem) { 35 | guard let title = item.title, 36 | let link = item.link.flatMap({ URL(string: $0) }) else { 37 | return nil 38 | } 39 | self.init( 40 | title: title, 41 | description: sanitize(item.description), 42 | link: link, 43 | pubDate: item.pubDate 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Services/RssDataService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssDataService.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import FeedKit 10 | import Foundation 11 | import OSLog 12 | import UIKit 13 | 14 | final class RssDataService: DataService { 15 | func getFeed(source: RssSource, onCompletion: @escaping (RssResult) -> Void) { 16 | let parser = FeedParser(URL: source.rss) 17 | 18 | Logger.data.debug("Loading \(source.rss.absoluteString)") 19 | 20 | parser.parseAsync(queue: DispatchQueue.global(qos: .userInitiated)) { result in 21 | switch result { 22 | case let .success(feed): 23 | if let rss = feed.rssFeed, let items = rss.items { 24 | onCompletion(.success(items.compactMap({ RssItem(item: $0) }))) 25 | return 26 | } 27 | 28 | if let atom = feed.atomFeed, let items = atom.entries { 29 | onCompletion(.success(items.compactMap({ RssItem(item: $0) }))) 30 | return 31 | } 32 | 33 | onCompletion(.failure(RssError.emptyResponse)) 34 | case let .failure(error): 35 | Logger.data.error("Loading data failed with \(error)") 36 | onCompletion(.failure(RssError.networkError(error))) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/Reactive+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RxSwift+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | import UIKit 13 | 14 | extension Reactive where Base: NotificationCenter { 15 | func keyboardHeightChanged() -> ControlEvent { 16 | let showSource = notification(UIResponder.keyboardDidShowNotification).map({ (value: Notification) -> CGFloat in 17 | let userInfo: NSDictionary = value.userInfo! as NSDictionary 18 | let keyboardInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue 19 | let keyboardSize = keyboardInfo.cgRectValue.size 20 | return keyboardSize.height 21 | }) 22 | let hideSource = NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification).map({ _ in CGFloat(0) }) 23 | 24 | let source = Observable.merge(showSource, hideSource) 25 | return ControlEvent(events: source) 26 | } 27 | 28 | func applicationWillEnterForeground() -> ControlEvent { 29 | let source = NotificationCenter.default.rx.notification(UIApplication.willEnterForegroundNotification).map({ _ in Void() }) 30 | return ControlEvent(events: source) 31 | } 32 | } 33 | 34 | func ignoreNil(x: A?) -> Driver { 35 | return x.map { Driver.just($0) } ?? Driver.empty() 36 | } 37 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/FeedViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeedViewModelTests.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 16/09/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import Testing 12 | import RxSwift 13 | 14 | struct FeedViewModelTests { 15 | 16 | @Test("Load RSS items when initialized") 17 | func testInitialLoad() throws { 18 | // Given 19 | let dataService = DataServiceMock() 20 | dataService.result = RssResult.success([ 21 | RssItem(title: "Test 1", description: nil, link: URL(string: "https://news.ycombinator.com")!, pubDate: nil), 22 | RssItem(title: "Test 2", description: nil, link: URL(string: "https://news.ycombinator.com")!, pubDate: nil) 23 | ]) 24 | 25 | let settingsService = SettingsServiceMock() 26 | settingsService.selectedSource = RssSource( 27 | title: "Coding Journal", 28 | url: URL(string: "https://blog.kulman.sk")!, 29 | rss: URL(string: "https://blog.kulman.sk/index.xml")!, 30 | icon: nil 31 | ) 32 | 33 | // When 34 | let vm = FeedViewModel(dataService: dataService, settingsService: settingsService) 35 | let feed = try vm.feed.toBlocking().first()! 36 | 37 | // Then 38 | #expect(feed.count == 2) 39 | #expect(feed[0].title == "Test 1") 40 | #expect(feed[1].title == "Test 2") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppUITests/AppUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iOSSampleAppUITests.swift 3 | // iOSSampleAppUITests 4 | // 5 | // Created by Igor Kulman on 23/03/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class AppUITests: XCTestCase { 12 | var app: XCUIApplication! 13 | 14 | override func setUp() { 15 | super.setUp() 16 | 17 | // Put setup code here. This method is called before the invocation of each test method in the class. 18 | 19 | // In UI tests it is usually best to stop immediately when a failure occurs. 20 | continueAfterFailure = false 21 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 22 | app = XCUIApplication() 23 | XCUIDevice.shared.orientation = .portrait 24 | setupSnapshot(app) 25 | app.launchArguments += ["testMode"] 26 | app.launch() 27 | 28 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 29 | } 30 | 31 | func testScreenshots() { 32 | snapshot("1-Setup") 33 | 34 | app.tables.cells.element(boundBy: 1).tap() 35 | app.buttons["done"].tap() 36 | 37 | snapshot("2-List") 38 | 39 | app.tables.cells.element(boundBy: 0).tap() 40 | snapshot("3-Detail") 41 | 42 | app.navigationBars.buttons.element(boundBy: 0).tap() 43 | app.buttons["about"].tap() 44 | 45 | snapshot("4-About") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/ViewModels/RssSourceViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssSourceViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | import UIKit 13 | 14 | final class RssSourceViewModel { 15 | let source: RssSource 16 | let isSelected = BehaviorRelay(value: false) 17 | let icon: Driver 18 | 19 | private static let cache = NSCache() 20 | 21 | init(source: RssSource) { 22 | self.source = source 23 | 24 | guard let iconUrl = source.icon else { 25 | icon = Driver.just(nil) 26 | return 27 | } 28 | 29 | icon = Observable.create { observer in 30 | if let cached = RssSourceViewModel.cache.object(forKey: iconUrl as NSURL) { 31 | observer.onNext(cached) 32 | observer.onCompleted() 33 | return Disposables.create() 34 | } 35 | 36 | let task = URLSession.shared.dataTask(with: iconUrl) { data, _, error in 37 | guard let data = data, 38 | let image = UIImage(data: data), 39 | error == nil else { 40 | observer.onNext(nil) 41 | observer.onCompleted() 42 | return 43 | } 44 | 45 | RssSourceViewModel.cache.setObject(image, forKey: iconUrl as NSURL) 46 | observer.onNext(image) 47 | observer.onCompleted() 48 | } 49 | 50 | task.resume() 51 | 52 | return Disposables.create { 53 | task.cancel() 54 | } 55 | } 56 | .asDriver(onErrorJustReturn: nil) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/ViewControllers/LibrariesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibrariesViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import UIKit 11 | 12 | final class LibrariesViewController: UITableViewController { 13 | 14 | // MARK: - Properties 15 | 16 | private let viewModel: LibrariesViewModel 17 | 18 | // MARK: - Fields 19 | 20 | private var disposeBag = DisposeBag() 21 | 22 | init(viewModel: LibrariesViewModel) { 23 | self.viewModel = viewModel 24 | super.init(nibName: nil, bundle: nil) 25 | } 26 | 27 | @available(*, unavailable) 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | // MARK: - Setup 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | 37 | setupUI() 38 | setupData() 39 | } 40 | 41 | private func setupUI() { 42 | title = NSLocalizedString("libraries", comment: "") 43 | } 44 | 45 | private func setupData() { 46 | tableView.dataSource = nil 47 | tableView.register(cellType: LibraryCell.self) 48 | tableView.allowsSelection = false 49 | 50 | viewModel.libraries.drive(tableView.rx.items(cellIdentifier: LibraryCell.reuseIdentifier, cellType: LibraryCell.self)) { _, element, cell in 51 | cell.model = element 52 | }.disposed(by: disposeBag) 53 | } 54 | } 55 | 56 | #if canImport(SwiftUI) && DEBUG 57 | import SwiftUI 58 | struct LibrariesViewControllerPreview: PreviewProvider { 59 | static var previews: some View { 60 | UINavigationController(rootViewController: LibrariesViewController(viewModel: LibrariesViewModel())).asPreview() 61 | } 62 | } 63 | #endif 64 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | 9 | # Uncomment the line if you want fastlane to automatically update itself 10 | #update_fastlane 11 | opt_out_usage 12 | 13 | default_platform(:ios) 14 | 15 | platform :ios do 16 | desc "Generate new localized screenshots" 17 | lane :screenshots do 18 | capture_screenshots(project: "Sources/iOSSampleApp.xcodeproj", 19 | xcargs: "-skipPackagePluginValidation", 20 | scheme: "iOSSampleApp") 21 | end 22 | end 23 | 24 | desc "Run all unit tests" 25 | lane :tests do 26 | run_tests(devices: ["iPhone 14"], 27 | project: "Sources/iOSSampleApp.xcodeproj", 28 | skip_testing: "iOSSampleAppUITests", 29 | xcargs: "-skipPackagePluginValidation", 30 | scheme: "iOSSampleApp") 31 | end 32 | 33 | desc "Updates libraries licenses" 34 | lane :update_licenses do 35 | require 'xcodeproj' 36 | 37 | dependencies = [] 38 | 39 | project = Xcodeproj::Project.open("../Sources/iOSSampleApp.xcodeproj") 40 | 41 | project.targets.each do |target| 42 | next if target.name.end_with? "Tests" 43 | 44 | target.package_product_dependencies.each do |dependency| 45 | dependencies.push((dependency.package.repositoryURL.sub "https://github.com/", "github \"") + "\"") 46 | end 47 | end 48 | 49 | dependencies = dependencies.uniq.sort 50 | 51 | File.open("../support/Cartfile.spm", "w+") do |f| 52 | f.puts(dependencies) 53 | end 54 | 55 | Dir.chdir ".." do 56 | sh "swift support/fetch_licenses.swift support/Cartfile.spm Sources/iOSSampleApp/Data" 57 | end 58 | 59 | File.delete("../support/Cartfile.spm") 60 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iOS Sample App 2 | 3 | Sample iOS app written the way I write iOS apps because I cannot share the app I currently work on. 4 | 5 | **SwiftUI**: I created a SwiftUI version of this sample app, using a bit defferent concepts: https://github.com/igorkulman/SwiftUISampleApp 6 | 7 | ## Shown concepts 8 | 9 | ### Architecture concepts 10 | 11 | * [Coordinators](https://blog.kulman.sk/architecting-ios-apps-coordinators/) 12 | * Dependency Injection 13 | * MVVM 14 | * [Data Binding](https://blog.kulman.sk/using-data-binding-in-ios/) 15 | * Dependencies management 16 | 17 | ### Other concepts 18 | 19 | * Localization to 2 languages with String catalogs 20 | * Continuous integration with Github Actions 21 | * Unit testing, including testing view controllers for leaks 22 | * Creating a view controller in code with dependency injection 23 | * Using static UITableView cells in a typed way with enums 24 | * Creating simple cells with UIListContentConfiguration 25 | * Automated AppStore screenshots taking in multiple languages 26 | * Adding custom reactive properties 27 | * Basic Dark mode support 28 | * Custom operator for simple UI code 29 | * Structured logging 30 | * Xcode build plugins 31 | * Xcode previews for UIKit 32 | 33 | ## Getting started 34 | 35 | ### Prerequisites 36 | 37 | * Xcode 16 38 | * [Fastlane](https://fastlane.tools/) (optional) 39 | 40 | ## Built with 41 | 42 | - [RxSwift](https://github.com/ReactiveX/RxSwift) - Reactive Programming in Swift 43 | - [RxSwiftExt](https://github.com/RxSwiftCommunity/RxSwiftExt) - A collection of Rx operators & tools not found in the core RxSwift distribution 44 | - [FeedKit](https://github.com/nmdias/FeedKit) - An RSS, Atom and JSON Feed parser written in Swift 45 | - [SwiftLint](https://github.com/realm/SwiftLint) - A tool to enforce Swift style and conventions 46 | 47 | ## Author 48 | 49 | Igor Kulman - igor@kulman.sk 50 | 51 | ## License 52 | 53 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 54 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/Views/FormFieldView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldView.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 02.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | import UIKit 13 | 14 | final class FormFieldView: UIView { 15 | 16 | // MARK: - UI 17 | 18 | private lazy var label = UILabel() &> { 19 | $0.font = UIFont.preferredFont(forTextStyle: .body) 20 | } 21 | 22 | fileprivate lazy var textField = UITextField() &> { 23 | $0.borderStyle = .roundedRect 24 | $0.font = UIFont.preferredFont(forTextStyle: .body) 25 | } 26 | 27 | // MARK: - Properties 28 | 29 | var title: String? { 30 | get { 31 | label.text 32 | } 33 | set { 34 | label.text = newValue 35 | } 36 | } 37 | 38 | var value: String? { 39 | get { 40 | textField.text 41 | } 42 | set { 43 | textField.text = newValue 44 | } 45 | } 46 | 47 | var isValid = true { 48 | didSet { 49 | textField.textColor = isValid ? UIColor.label : UIColor.red 50 | } 51 | } 52 | 53 | // MARK: - Setup 54 | 55 | override init(frame: CGRect) { 56 | super.init(frame: .zero) 57 | setup() 58 | } 59 | 60 | @available(*, unavailable) 61 | required init?(coder: NSCoder) { 62 | fatalError("init(coder:) has not been implemented") 63 | } 64 | 65 | private func setup() { 66 | let stackView = UIStackView(arrangedSubviews: [label, textField]) &> { 67 | $0.axis = .vertical 68 | $0.spacing = 6 69 | } 70 | stackView.pin(to: self) 71 | } 72 | } 73 | 74 | // MARK: - Reactive 75 | 76 | extension Reactive where Base: FormFieldView { 77 | var isValid: Binder { 78 | return Binder(base) { view, isValid in 79 | view.isValid = isValid 80 | } 81 | } 82 | 83 | var value: ControlProperty { 84 | return base.textField.rx.text 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/DataServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataServiceTests.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import Testing 12 | 13 | struct DataServiceTests { 14 | 15 | @Test("Successfully fetch from valid RSS feed") 16 | func testValidRssFeed() async throws { 17 | // Given 18 | let service = RssDataService() 19 | let source = RssSource( 20 | title: "Hacker News", 21 | url: URL(string: "https://news.ycombinator.com")!, 22 | rss: URL(string: "https://news.ycombinator.com/rss")!, 23 | icon: nil 24 | ) 25 | 26 | // When 27 | let result = await withCheckedContinuation { continuation in 28 | service.getFeed(source: source) { result in 29 | continuation.resume(returning: result) 30 | } 31 | } 32 | 33 | // Then 34 | switch result { 35 | case let .success(items): 36 | #expect(!items.isEmpty) 37 | case let .failure(error): 38 | Issue.record("Failed with error: \(error)") 39 | } 40 | } 41 | 42 | @Test("Fail when fetching from invalid RSS feed") 43 | func testInvalidRssFeed() async throws { 44 | // Given 45 | let service = RssDataService() 46 | let source = RssSource( 47 | title: "Fake", 48 | url: URL(string: "https://news.ycombinator.com")!, 49 | rss: URL(string: "https://news.ycombinator.com")!, 50 | icon: nil 51 | ) 52 | 53 | // When 54 | let result = await withCheckedContinuation { continuation in 55 | service.getFeed(source: source) { result in 56 | continuation.resume(returning: result) 57 | } 58 | } 59 | 60 | // Then 61 | switch result { 62 | case .success: 63 | Issue.record("Expected failure but got success") 64 | case .failure: 65 | break 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | # Sometimes it's a README fix, or something like that - which isn't relevant for 4 | # including in a project's CHANGELOG for example 5 | declared_trivial = github.pr_title.include? "#trivial" 6 | 7 | # Make it more obvious that a PR is a work in progress and shouldn't be merged yet 8 | warn("PR is classed as Work in Progress") if github.pr_title.include? "[WIP]" 9 | warn("PR is classed as Work in Progress") if github.pr_title.include? "WIP" 10 | 11 | # Message on Carthage changes 12 | message("This PR changes Carthage dependencies") if git.modified_files.include? "Sources/Cartfile" 13 | 14 | # Message on configuration changes 15 | message("This PR changes CI dendency gems") if git.modified_files.include? "Gemfile" 16 | message("This PR changes Fastlane configuration") if git.modified_files.include? "fastlane/Fastfile" 17 | message("This PR changes Danger configuration") if git.modified_files.include? "Dangerfile" 18 | 19 | # Warn when there is a big PR 20 | warn("This PR seems bigger than it should be!") if git.lines_of_code > 500 21 | 22 | # Custom linting 23 | def markdown_issues(results, heading) 24 | message = "#### #{heading}\n\n".dup 25 | 26 | message << "File | Line | Reason |\n" 27 | message << "| --- | ----- | ----- |\n" 28 | 29 | results.each do |r| 30 | filename = r['file'].split('/').last 31 | line = r['line'] 32 | reason = r['reason'] 33 | rule = r['rule_id'] 34 | # Other available properties can be found int SwiftLint/…/JSONReporter.swift 35 | message << "#{filename} | #{line} | #{reason} (#{rule})\n" 36 | end 37 | 38 | message 39 | end 40 | 41 | # Custom linting 42 | Dir.chdir "Sources" do 43 | result = `swiftlint --reporter json --config .swiftlint.yml` 44 | issues = JSON.parse(result).flatten 45 | 46 | if issues.count > 0 47 | # Filter warnings and errors 48 | warnings = issues.select { |issue| issue['severity'] == 'Warning' } 49 | errors = issues.select { |issue| issue['severity'] == 'Error' } 50 | 51 | message = "### SwiftLint found issues \n\n".dup 52 | message << markdown_issues(warnings, 'Warnings') unless warnings.empty? 53 | message << markdown_issues(errors, 'Errors') unless errors.empty? 54 | 55 | markdown message 56 | end 57 | end -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/ViewControllerLeakTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewControllerLeakTests.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 26/11/2018. 6 | // Copyright © 2018 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import XCTest 12 | 13 | class ViewControllerLeakTests: XCTestCase { 14 | private lazy var container = Container() &> { 15 | $0.settingsService = SettingsServiceMock() 16 | $0.dataService = DataServiceMock() 17 | } 18 | 19 | func testAboutViewController() { 20 | let viewController = container.makeAboutViewController() 21 | assertDeallocatedAfterTest(viewController) 22 | viewController.loadViewIfNeeded() 23 | } 24 | 25 | func testLibrariesViewController() { 26 | let viewController = container.makeLibrariesViewController() 27 | assertDeallocatedAfterTest(viewController) 28 | viewController.loadViewIfNeeded() 29 | } 30 | 31 | func testFeedViewController() { 32 | container.settingsService.selectedSource = RssSource(title: "Test", url: URL(string:"https://blog.kulman.sk")!, rss: URL(string:"https://blog.kulman.sk/index.xml")!, icon: nil) 33 | let dataService = container.dataService as! DataServiceMock 34 | dataService.result = .success([]) 35 | let viewController = container.makeFeedViewController() 36 | assertDeallocatedAfterTest(viewController) 37 | viewController.loadViewIfNeeded() 38 | } 39 | 40 | func testDetailViewController() { 41 | let viewController = DetailViewController(item: RssItem(title: "Test", description: "Test sesc", link: URL(string:"https://blog.kulman.sk")!, pubDate: Date())) 42 | assertDeallocatedAfterTest(viewController) 43 | viewController.loadViewIfNeeded() 44 | } 45 | 46 | func testCustomSourceViewController() { 47 | let viewController = container.makeCustomSourceViewController() 48 | assertDeallocatedAfterTest(viewController) 49 | viewController.loadViewIfNeeded() 50 | } 51 | 52 | func testSourceSelectionViewController() { 53 | let viewController = container.makeSourceSelectionViewController() 54 | assertDeallocatedAfterTest(viewController) 55 | viewController.loadViewIfNeeded() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Container.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppContainer.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 27.05.2025. 6 | // Copyright © 2025 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | 12 | final class Container { 13 | 14 | init() { 15 | Logger.appFlow.debug("Registering dependencies") 16 | #if DEBUG 17 | if ProcessInfo().arguments.contains("testMode") { 18 | Logger.appFlow.debug("Running in UI tests, deleting selected source to start clean") 19 | settingsService.selectedSource = nil 20 | } 21 | #endif 22 | } 23 | 24 | // MARK: - Services 25 | 26 | lazy var dataService: DataService = RssDataService() 27 | lazy var settingsService: SettingsService = UserDefaultsSettingsService() 28 | 29 | // MARK: - ViewModels 30 | 31 | func makeSourceSelectionViewModell() -> SourceSelectionViewModel { 32 | SourceSelectionViewModel(settingsService: settingsService) 33 | } 34 | 35 | func makeCustomSourceViewModel() -> CustomSourceViewModel { 36 | CustomSourceViewModel() 37 | } 38 | 39 | func makeFeedViewModel() -> FeedViewModel { 40 | FeedViewModel(dataService: dataService, settingsService: settingsService) 41 | } 42 | 43 | func makeLibrariesViewModel() -> LibrariesViewModel { 44 | LibrariesViewModel() 45 | } 46 | 47 | func makeAboutViewModel() -> AboutViewModel { 48 | AboutViewModel() 49 | } 50 | 51 | // MARK: - ViewControllers 52 | 53 | func makeSourceSelectionViewController() -> SourceSelectionViewController { 54 | SourceSelectionViewController(viewModel: makeSourceSelectionViewModell()) 55 | } 56 | 57 | func makeCustomSourceViewController() -> CustomSourceViewController { 58 | CustomSourceViewController(viewModel: makeCustomSourceViewModel()) 59 | } 60 | 61 | func makeFeedViewController() -> FeedViewController { 62 | FeedViewController(viewModel: makeFeedViewModel()) 63 | } 64 | 65 | func makeLibrariesViewController() -> LibrariesViewController { 66 | LibrariesViewController(viewModel: makeLibrariesViewModel()) 67 | } 68 | 69 | func makeAboutViewController() -> AboutViewController { 70 | AboutViewController(viewModel: makeAboutViewModel()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UIView+Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Layout.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 01.01.2023. 6 | // Copyright © 2023 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIView { 13 | func pin(to view: UIView, guide: UILayoutGuide? = nil, insets: UIEdgeInsets = .zero) { 14 | translatesAutoresizingMaskIntoConstraints = false 15 | view.addSubview(self) 16 | 17 | let guide = guide ?? view.safeAreaLayoutGuide 18 | NSLayoutConstraint.activate([ 19 | leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: insets.left), 20 | trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -insets.right), 21 | topAnchor.constraint(equalTo: guide.topAnchor, constant: insets.top), 22 | bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -insets.bottom) 23 | ]) 24 | } 25 | 26 | func fixSize(width: CGFloat, height: CGFloat) { 27 | translatesAutoresizingMaskIntoConstraints = false 28 | 29 | NSLayoutConstraint.activate([ 30 | widthAnchor.constraint(equalToConstant: width), 31 | heightAnchor.constraint(equalToConstant: height) 32 | ]) 33 | } 34 | } 35 | 36 | extension UIScrollView { 37 | func pin(to view: UIView, with contentView: UIView) { 38 | translatesAutoresizingMaskIntoConstraints = false 39 | contentView.translatesAutoresizingMaskIntoConstraints = false 40 | addSubview(contentView) 41 | 42 | view.addSubview(self) 43 | let guide = view.safeAreaLayoutGuide 44 | 45 | NSLayoutConstraint.activate([ 46 | frameLayoutGuide.leadingAnchor.constraint(equalTo: guide.leadingAnchor), 47 | frameLayoutGuide.topAnchor.constraint(equalTo: guide.topAnchor), 48 | frameLayoutGuide.trailingAnchor.constraint(equalTo: guide.trailingAnchor), 49 | frameLayoutGuide.bottomAnchor.constraint(equalTo: guide.bottomAnchor), 50 | contentLayoutGuide.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 51 | contentLayoutGuide.topAnchor.constraint(equalTo: contentView.topAnchor), 52 | contentLayoutGuide.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 53 | contentLayoutGuide.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 54 | contentLayoutGuide.widthAnchor.constraint(equalTo: contentView.widthAnchor), 55 | contentView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor) 56 | ]) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/ViewModels/FeedViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RxCocoa 11 | import RxSwift 12 | import UIKit 13 | 14 | final class FeedViewModel { 15 | 16 | // MARK: - Properties 17 | 18 | /** 19 | Driver with error from the feed refresh 20 | */ 21 | let onError: Driver 22 | 23 | /** 24 | Driver with actual data. Does not change when feed refresh errors out 25 | */ 26 | let feed: Driver<[RssItem]> 27 | 28 | /* 29 | Signal that starts feed loading when called from the VC 30 | */ 31 | let load = PublishSubject() 32 | 33 | /** 34 | Feed title 35 | */ 36 | let title: String 37 | 38 | // MARK: - Fields 39 | 40 | private var disposeBag = DisposeBag() 41 | 42 | init(dataService: DataService, settingsService: SettingsService) { 43 | guard let source = settingsService.selectedSource else { 44 | fatalError("Source not selected, nothing to show in feed") 45 | } 46 | 47 | // converting callback based data service call to an observable 48 | // this observable can error out 49 | let loadFeed: Observable<[RssItem]> = Observable.create { observer in 50 | dataService.getFeed(source: source) { result in 51 | switch result { 52 | case let .failure(error): 53 | observer.onError(error) 54 | case let .success(items): 55 | observer.onNext(items) 56 | observer.onCompleted() 57 | } 58 | } 59 | 60 | return Disposables.create { 61 | // empty because the data service does not support cancelling requests 62 | } 63 | } 64 | 65 | let response = load 66 | .startWith(()) // start loading immediately 67 | .flatMapLatest { _ in 68 | return loadFeed.materialize() // converting the feed to Observable> containing both data and error so the observable does not complete on error 69 | } 70 | .share() 71 | 72 | feed = response.elements() 73 | .asDriver(onErrorJustReturn: []) 74 | 75 | onError = response.errors() 76 | .asDriver(onErrorJustReturn: RssError.emptyResponse) 77 | 78 | title = source.title 79 | 80 | // refreshing the feed on app activation 81 | NotificationCenter.default.rx.applicationWillEnterForeground().withUnretained(self).bind { owner, _ in 82 | owner.load.onNext(()) 83 | }.disposed(by: disposeBag) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/Cells/RssSourceCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RssSourceCell.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import UIKit 11 | 12 | final class RssSourceCell: UITableViewCell, Reusable { 13 | 14 | // MARK: - UI 15 | 16 | private lazy var logoImage = UIImageView() &> { 17 | $0.contentMode = .scaleAspectFit 18 | $0.fixSize(width: 36, height: 36) 19 | } 20 | 21 | private lazy var titleLabel = UILabel() &> { 22 | $0.font = UIFont.preferredFont(forTextStyle: .body) 23 | } 24 | 25 | private lazy var urlLabel = UILabel() &> { 26 | $0.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 12)) 27 | $0.adjustsFontForContentSizeCategory = true 28 | } 29 | 30 | // MARK: - Properties 31 | 32 | var viewModel: RssSourceViewModel? { 33 | didSet { 34 | guard let vm = viewModel else { 35 | return 36 | } 37 | 38 | logoImage.image = nil 39 | titleLabel.text = vm.source.title 40 | urlLabel.text = vm.source.url.absoluteString 41 | 42 | disposeBag = DisposeBag { 43 | vm.isSelected.map({ $0 ? .checkmark : .none }).bind(to: rx.accessoryType) 44 | vm.icon.drive(logoImage.rx.image) 45 | } 46 | } 47 | } 48 | 49 | // MARK: - Fields 50 | 51 | private var disposeBag = DisposeBag() 52 | 53 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 54 | super.init(style: style, reuseIdentifier: reuseIdentifier) 55 | setup() 56 | } 57 | 58 | @available(*, unavailable) 59 | required init?(coder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented") 61 | } 62 | 63 | // MARK: - Setup 64 | 65 | private func setup() { 66 | preservesSuperviewLayoutMargins = true 67 | contentView.preservesSuperviewLayoutMargins = true 68 | 69 | let stackView = UIStackView(arrangedSubviews: [titleLabel, urlLabel]) &> { 70 | $0.translatesAutoresizingMaskIntoConstraints = false 71 | $0.axis = .vertical 72 | } 73 | 74 | addSubview(logoImage) 75 | NSLayoutConstraint.activate([ 76 | logoImage.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 77 | logoImage.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor) 78 | ]) 79 | 80 | addSubview(stackView) 81 | NSLayoutConstraint.activate([ 82 | stackView.leadingAnchor.constraint(equalTo: logoImage.trailingAnchor, constant: 8), 83 | stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 84 | stackView.centerYAnchor.constraint(equalTo: logoImage.centerYAnchor) 85 | ]) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Carthage 3 | - fastlane 4 | - support 5 | - Build-Phases 6 | - iOSSampleApp/Resources 7 | - iOSSampleAppTests/Support 8 | 9 | disabled_rules: 10 | - line_length 11 | - identifier_name 12 | - force_cast 13 | - force_try 14 | - file_length 15 | - function_body_length 16 | - nimble_operator 17 | - blanket_disable_command 18 | 19 | analyzer_rules: 20 | - unused_import 21 | 22 | number_separator: 23 | minimum_length: 7 24 | 25 | opt_in_rules: 26 | - anyobject_protocol 27 | - array_init 28 | - attributes 29 | - closure_end_indentation 30 | - closure_spacing 31 | - collection_alignment 32 | - contains_over_first_not_nil 33 | - empty_string 34 | - empty_xctest_method 35 | - explicit_init 36 | - extension_access_modifier 37 | - fallthrough 38 | - fatal_error_message 39 | - first_where 40 | - identical_operands 41 | - joined_default_parameter 42 | - let_var_whitespace 43 | - literal_expression_end_indentation 44 | - lower_acl_than_parent 45 | - nimble_operator 46 | - number_separator 47 | - object_literal 48 | - operator_usage_whitespace 49 | - overridden_super_call 50 | - override_in_extension 51 | - pattern_matching_keywords 52 | - private_action 53 | - private_outlet 54 | - prohibited_super_call 55 | - quick_discouraged_focused_test 56 | - quick_discouraged_pending_test 57 | - redundant_nil_coalescing 58 | - redundant_type_annotation 59 | - single_test_class 60 | - sorted_first_last 61 | - sorted_imports 62 | - static_operator 63 | - unavailable_function 64 | - unneeded_parentheses_in_closure_argument 65 | - untyped_error_in_catch 66 | - vertical_parameter_alignment_on_call 67 | - yoda_condition 68 | 69 | function_parameter_count: 70 | warning: 7 71 | error: 10 72 | 73 | large_tuple: 74 | warning: 3 75 | custom_rules: 76 | double_space: # from https://github.com/IBM-Swift/Package-Builder 77 | include: "*.swift" 78 | name: "Double space" 79 | regex: '([a-z,A-Z] \s+)' 80 | message: "Double space between keywords" 81 | match_kinds: keyword 82 | severity: warning 83 | comments_space: # from https://github.com/brandenr/swiftlintconfig 84 | name: "Space After Comment" 85 | regex: '(^ *//\w+)' 86 | message: "There should be a space after //" 87 | severity: warning 88 | empty_line_after_guard: # from https://github.com/brandenr/swiftlintconfig 89 | name: "Empty Line After Guard" 90 | regex: '(^ *guard[ a-zA-Z0-9=?.\(\),> { 49 | $0.delegate = self 50 | } 51 | navigationController.pushViewController(sourceSelectionViewController, animated: true) 52 | } 53 | 54 | /** 55 | Shows the user a screen to add a custom RSS source 56 | */ 57 | private func showAddSourceForm() { 58 | let customSourceViewController = container.makeCustomSourceViewController() &> { 59 | $0.delegate = self 60 | } 61 | navigationController.pushViewController(customSourceViewController, animated: true) 62 | } 63 | } 64 | 65 | // MARK: - Delegate 66 | 67 | extension SetupCoordinator: SourceSelectionViewControllerDelegate { 68 | /** 69 | Invoked when the user finished setting the RSS source 70 | */ 71 | func sourceSelectionViewControllerDidFinish() { 72 | delegate?.setupCoordinatorDidFinish() 73 | } 74 | 75 | /** 76 | Invoked when user requests adding a new custom source 77 | */ 78 | func userDidRequestCustomSource() { 79 | showAddSourceForm() 80 | } 81 | } 82 | 83 | extension SetupCoordinator: CustomSourceViewControllerDelegate { 84 | /** 85 | Invokes when user adds a new custom RSS source 86 | 87 | - Parameter source: newly added RSS source 88 | */ 89 | func userDidAddCustomSource(source: RssSource) { 90 | if navigationController.viewControllers.count > 1, let sourceSelectionViewController = navigationController.viewControllers[navigationController.viewControllers.count - 2] as? SourceSelectionViewController { 91 | sourceSelectionViewController.viewModel.addNewSource(source: source) 92 | } 93 | 94 | navigationController.popViewController(animated: true) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Supporting Files/Extensions/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Extensions.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 16/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol ToastCapable: AnyObject { 13 | func showErrorToast(message: String) 14 | } 15 | 16 | extension ToastCapable { 17 | func showErrorToast(message: String) { 18 | ToastBanner.show(message: message) 19 | } 20 | } 21 | 22 | final class ToastBanner { 23 | static func show(message: String) { 24 | guard let windowScene = UIApplication.shared.connectedScenes 25 | .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, 26 | let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { 27 | return 28 | } 29 | 30 | let banner = UIView() &> { 31 | $0.backgroundColor = .systemRed 32 | $0.translatesAutoresizingMaskIntoConstraints = false 33 | } 34 | 35 | let label = UILabel() &> { 36 | $0.text = message 37 | $0.textColor = .white 38 | $0.font = UIFont.preferredFont(forTextStyle: .body) 39 | $0.textAlignment = .center 40 | $0.numberOfLines = 0 41 | $0.translatesAutoresizingMaskIntoConstraints = false 42 | } 43 | 44 | banner.addSubview(label) 45 | window.addSubview(banner) 46 | 47 | let topConstraint = banner.topAnchor.constraint(equalTo: window.topAnchor) 48 | NSLayoutConstraint.activate([ 49 | banner.leadingAnchor.constraint(equalTo: window.leadingAnchor), 50 | banner.trailingAnchor.constraint(equalTo: window.trailingAnchor), 51 | topConstraint, 52 | 53 | label.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 12), 54 | label.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -12), 55 | label.topAnchor.constraint(equalTo: banner.safeAreaLayoutGuide.topAnchor, constant: 6), 56 | label.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -6) 57 | ]) 58 | 59 | window.layoutIfNeeded() 60 | let height = banner.frame.height 61 | banner.transform = CGAffineTransform(translationX: 0, y: -height) 62 | 63 | UIView.animate( 64 | withDuration: 0.3, 65 | animations: { 66 | banner.transform = .identity 67 | }, 68 | completion: { _ in 69 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 70 | UIView.animate( 71 | withDuration: 0.3, 72 | animations: { 73 | banner.transform = CGAffineTransform(translationX: 0, y: -height) 74 | }, 75 | completion: { _ in 76 | banner.removeFromSuperview() 77 | } 78 | ) 79 | } 80 | } 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/CustomSourceViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSourceViewModelTests.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import Testing 12 | import RxSwift 13 | 14 | struct CustomSourceViewModelTests { 15 | 16 | @Test("Empty data should not be valid") 17 | func testEmptyDataValidation() throws { 18 | // Given 19 | let vm = CustomSourceViewModel() 20 | 21 | // When 22 | let isValid = try vm.isValid.toBlocking().first()! 23 | 24 | // Then 25 | #expect(isValid == false) 26 | } 27 | 28 | @Test("Valid data should validate correctly") 29 | func testValidDataValidation() throws { 30 | // Given 31 | let vm = CustomSourceViewModel() 32 | vm.title.accept("Coding Journal") 33 | vm.rssUrl.accept("https://blog.kulman.sk/index.xml") 34 | vm.url.accept("https://blog.kulman.sk") 35 | 36 | // When 37 | let isValid = try vm.isValid.toBlocking().first()! 38 | 39 | // Then 40 | #expect(isValid == true) 41 | } 42 | 43 | @Test("Missing URL should not validate") 44 | func testMissingUrlValidation() throws { 45 | // Given 46 | let vm = CustomSourceViewModel() 47 | vm.title.accept("Coding Journal") 48 | vm.rssUrl.accept("https://blog.kulman.sk/index.xml") 49 | vm.url.accept(nil) 50 | 51 | // When 52 | let isValid = try vm.isValid.toBlocking().first()! 53 | 54 | // Then 55 | #expect(isValid == false) 56 | } 57 | 58 | @Test("Invalid URL should not validate") 59 | func testInvalidUrlValidation() throws { 60 | // Given 61 | let vm = CustomSourceViewModel() 62 | vm.title.accept("Coding Journal") 63 | vm.rssUrl.accept("https://blog.kulman.sk/index.xml") 64 | vm.url.accept("blog") 65 | 66 | // When 67 | let isValid = try vm.isValid.toBlocking().first()! 68 | 69 | // Then 70 | #expect(isValid == false) 71 | } 72 | 73 | @Test("Invalid RSS URL should not validate") 74 | func testInvalidRssUrlValidation() throws { 75 | // Given 76 | let vm = CustomSourceViewModel() 77 | vm.title.accept("Coding Journal") 78 | vm.rssUrl.accept("dss") 79 | vm.url.accept("https://blog.kulman.sk") 80 | 81 | // When 82 | let isValid = try vm.isValid.toBlocking().first()! 83 | 84 | // Then 85 | #expect(isValid == false) 86 | } 87 | 88 | @Test("Missing title should not validate") 89 | func testMissingTitleValidation() throws { 90 | // Given 91 | let vm = CustomSourceViewModel() 92 | vm.title.accept(nil) 93 | vm.rssUrl.accept("https://blog.kulman.sk/index.xml") 94 | vm.url.accept("https://blog.kulman.sk") 95 | 96 | // When 97 | let isValid = try vm.isValid.toBlocking().first()! 98 | 99 | // Then 100 | #expect(isValid == false) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Common/Base.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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/Coordinators/AboutCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutCoordinator.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SafariServices 11 | import UIKit 12 | 13 | protocol AboutCoordinatorDelegate: AnyObject { 14 | /** 15 | Invoked when the About flow is no longer needed 16 | */ 17 | func aboutCoordinatorDidFinish() 18 | } 19 | 20 | final class AboutCoordinator: NavigationCoordinator { 21 | 22 | // MARK: - Properties 23 | 24 | let navigationController: UINavigationController 25 | private let container: Container 26 | weak var delegate: AboutCoordinatorDelegate? 27 | 28 | init(container: Container, navigationController: UINavigationController) { 29 | self.container = container 30 | self.navigationController = navigationController 31 | } 32 | 33 | // MARK: - Coordinator core 34 | 35 | /** 36 | Starts the Abotu flow by showing the basic info and additional menu items 37 | */ 38 | func start() { 39 | let aboutViewController = container.makeAboutViewController() &> { 40 | $0.delegate = self 41 | } 42 | navigationController.setBackButton() 43 | navigationController.pushViewController(aboutViewController, animated: true) 44 | } 45 | 46 | /** 47 | Shows the list of open source libraries used by the app 48 | */ 49 | private func showLibraries() { 50 | let librariesViewController = container.makeLibrariesViewController() 51 | navigationController.pushViewController(librariesViewController, animated: true) 52 | } 53 | 54 | /** 55 | Shows the authors info in SafariVC 56 | */ 57 | private func showAuthorsInfo() { 58 | showUrl(url: URL(string: "https://kulman.sk")!) 59 | } 60 | 61 | /** 62 | Shows the authors blog in SafariVC 63 | */ 64 | private func showAuthorsBlog() { 65 | showUrl(url: URL(string: "https://nlog.kulman.sk")!) 66 | } 67 | 68 | /** 69 | Shows provided URL in SafariVC 70 | 71 | - Parameter url: URL to show 72 | */ 73 | private func showUrl(url: URL) { 74 | let safariViewController = SFSafariViewController(url: url) &> { 75 | $0.modalPresentationStyle = .fullScreen 76 | } 77 | navigationController.present(safariViewController, animated: true, completion: nil) 78 | } 79 | } 80 | 81 | // MARK: - Delegate 82 | 83 | extension AboutCoordinator: AboutViewControllerDelegate { 84 | /** 85 | Invoked when user requests the authors blog 86 | */ 87 | func userDidRequestAuthorsBlog() { 88 | showAuthorsBlog() 89 | } 90 | 91 | /** 92 | Invoked when user requests the authors info 93 | */ 94 | func userDidRequestAuthorsInfo() { 95 | showAuthorsInfo() 96 | } 97 | 98 | /** 99 | Invoked when user requests the list of used open source libraries 100 | */ 101 | func userDidRequestLibraries() { 102 | showLibraries() 103 | } 104 | 105 | /** 106 | Invoked when user naviages back from the About screen 107 | */ 108 | func aboutViewControllerDismissed() { 109 | delegate?.aboutCoordinatorDidFinish() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/ViewModels/SourceSelectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceSelectionViewModel.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | import RxCocoa 12 | import RxSwift 13 | 14 | final class SourceSelectionViewModel { 15 | 16 | // MARK: - Properties 17 | 18 | let sources: Driver<[RssSourceViewModel]> 19 | let filter = BehaviorRelay(value: nil) 20 | let isValid: Driver 21 | 22 | // MARK: - Fields 23 | 24 | private let allSources = BehaviorRelay<[RssSourceViewModel]>(value: []) 25 | private let settingsService: SettingsService 26 | private var disposeBag = DisposeBag() 27 | 28 | init(settingsService: SettingsService) { 29 | self.settingsService = settingsService 30 | 31 | Logger.data.debug("Loading bundled sources") 32 | 33 | let jsonData = Bundle.main.loadFile(filename: "sources.json")! 34 | 35 | let jsonDecoder = JSONDecoder() 36 | let all = (try! jsonDecoder.decode(Array.self, from: jsonData)).map({ RssSourceViewModel(source: $0) }) 37 | 38 | sources = Observable.combineLatest(allSources.asObservable(), filter.asObservable()) { (all: [RssSourceViewModel], filter: String?) -> [RssSourceViewModel] in 39 | if let filter = filter, !filter.isEmpty { 40 | return all.filter({ $0.source.title.lowercased().contains(filter.lowercased()) }) 41 | } else { 42 | return all 43 | } 44 | } 45 | .asDriver(onErrorJustReturn: []) 46 | 47 | isValid = sources.asObservable().flatMap { Observable.combineLatest($0.map { $0.isSelected.asObservable() }) }.map({ $0.filter({ $0 }).count == 1 }) 48 | .asDriver(onErrorJustReturn: false) 49 | 50 | allSources.accept(all) 51 | 52 | // selecting again from feed 53 | if let selected = settingsService.selectedSource { 54 | if let index = allSources.value.firstIndex(where: { $0.source == selected }) { // pre-selecting the current source 55 | allSources.value[index].isSelected.accept(true) 56 | } else { // using a custom source 57 | let vm = RssSourceViewModel(source: selected) 58 | vm.isSelected.accept(true) 59 | allSources.accept(allSources.value.inserting(vm, at: 0)) 60 | } 61 | } 62 | } 63 | 64 | // MARK: - Actions 65 | 66 | func toggleSource(source: RssSourceViewModel) { 67 | let selected = source.isSelected.value 68 | 69 | allSources.value.forEach { 70 | $0.isSelected.accept(false) 71 | } 72 | 73 | source.isSelected.accept(!selected) 74 | } 75 | 76 | func addNewSource(source: RssSource) { 77 | let rssSourceViewModel = RssSourceViewModel(source: source) 78 | allSources.accept(allSources.value.inserting(rssSourceViewModel, at: 0)) 79 | toggleSource(source: rssSourceViewModel) 80 | } 81 | 82 | func saveSelectedSource() -> Bool { 83 | guard let selected = allSources.value.first(where: { $0.isSelected.value }) else { 84 | Logger.data.error("Cannot save, no source selected") 85 | return false 86 | } 87 | 88 | settingsService.selectedSource = selected.source 89 | return true 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Common/Coordinators/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OSLog 11 | import UIKit 12 | 13 | enum AppChildCoordinator { 14 | case setup 15 | case feed 16 | } 17 | 18 | /** 19 | Main coordinator responseible for starting the setup process or showing the feed depending on the app state 20 | */ 21 | final class AppCoordinator: Coordinator { 22 | 23 | // MARK: - Properties 24 | 25 | private let window: UIWindow 26 | private let container: Container 27 | private var childCoordinators = [AppChildCoordinator: Coordinator]() 28 | private let settingsService: SettingsService 29 | private let navigationController: UINavigationController 30 | 31 | // MARK: - Coordinator core 32 | 33 | init(window: UIWindow, container: Container) { 34 | self.window = window 35 | self.container = container 36 | 37 | navigationController = UINavigationController() &> { 38 | $0.view.backgroundColor = .systemBackground 39 | $0.navigationBar.prefersLargeTitles = true 40 | $0.navigationBar.isTranslucent = false 41 | let appearance = UINavigationBarAppearance() 42 | appearance.configureWithOpaqueBackground() 43 | appearance.backgroundColor = .systemBackground 44 | $0.navigationBar.standardAppearance = appearance 45 | $0.navigationBar.scrollEdgeAppearance = $0.navigationBar.standardAppearance 46 | } 47 | 48 | settingsService = container.settingsService 49 | 50 | self.window.rootViewController = navigationController 51 | } 52 | 53 | /** 54 | Starts the app showing either the setup flow or the feed depending on the app state 55 | */ 56 | func start() { 57 | if settingsService.selectedSource.isSome { 58 | Logger.appFlow.debug("Setup complete, starting dashboard") 59 | showFeed() 60 | } else { 61 | Logger.appFlow.debug("Starting setup") 62 | showSetup() 63 | } 64 | } 65 | 66 | /** 67 | Shows the feed using the FeedCoordinator 68 | */ 69 | private func showFeed() { 70 | let feedCoordinator = FeedCoordinator(container: container, navigationController: navigationController) &> { 71 | $0.delegate = self 72 | } 73 | childCoordinators[.feed] = feedCoordinator 74 | feedCoordinator.start() 75 | } 76 | 77 | /** 78 | Starts the setup flow using the SetupCoordinator 79 | */ 80 | private func showSetup() { 81 | let setupCoordinator = SetupCoordinator(container: container, navigationController: navigationController) &> { 82 | $0.delegate = self 83 | } 84 | childCoordinators[.setup] = setupCoordinator 85 | setupCoordinator.start() 86 | } 87 | } 88 | 89 | // MARK: - Delegate 90 | 91 | extension AppCoordinator: SetupCoordinatorDelegate { 92 | /** 93 | Invoked when the setup flow finishes, setting a RSS source 94 | */ 95 | func setupCoordinatorDidFinish() { 96 | childCoordinators[.setup] = nil 97 | showFeed() 98 | } 99 | } 100 | 101 | extension AppCoordinator: FeedCoordinatorDelegate { 102 | /** 103 | Invoked when the feed flow is no longer needed 104 | */ 105 | func feedCoordinatorDidFinish() { 106 | childCoordinators[.feed] = nil 107 | showSetup() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/Coordinators/FeedCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardCoordinator.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | enum FeedChildCoordinator { 13 | case about 14 | } 15 | 16 | protocol FeedCoordinatorDelegate: AnyObject { 17 | /** 18 | Invoked when the feed flow is no longer needed 19 | */ 20 | func feedCoordinatorDidFinish() 21 | } 22 | 23 | final class FeedCoordinator: NavigationCoordinator { 24 | 25 | // MARK: - Properties 26 | 27 | let navigationController: UINavigationController 28 | private let container: Container 29 | private var childCoordinators = [FeedChildCoordinator: Coordinator]() 30 | 31 | weak var delegate: FeedCoordinatorDelegate? 32 | 33 | init(container: Container, navigationController: UINavigationController) { 34 | self.container = container 35 | self.navigationController = navigationController 36 | } 37 | 38 | // MARK: - Coordinator core 39 | 40 | /** 41 | Starts the feed flow showing the list of articles from the currently selected RSS source 42 | */ 43 | func start() { 44 | let isNavigationStackEmpty = navigationController.viewControllers.isEmpty 45 | let feedViewController = container.makeFeedViewController() &> { 46 | $0.delegate = self 47 | $0.navigationItem.hidesBackButton = true 48 | } 49 | navigationController.pushViewController(feedViewController, animated: true) 50 | 51 | // FeedViewController should be always the top most VC 52 | if !isNavigationStackEmpty { 53 | navigationController.viewControllers.remove(at: 0) 54 | } 55 | } 56 | 57 | /** 58 | Shows RSS item detail in a separate modal screen 59 | 60 | - Parameter item: RSS item to show detail 61 | */ 62 | private func showDetail(item: RssItem) { 63 | let detailViewController = DetailViewController(item: item) &> { 64 | $0.delegate = self 65 | } 66 | let internalNavigationController = UINavigationController(rootViewController: detailViewController) &> { 67 | $0.modalPresentationStyle = .fullScreen 68 | } 69 | navigationController.present(internalNavigationController, animated: true, completion: nil) 70 | } 71 | 72 | /** 73 | Shows the About screen by starting the AboutCoordinator 74 | */ 75 | private func showAbout() { 76 | let aboutCoordinator = AboutCoordinator(container: container, navigationController: navigationController) &> { 77 | $0.delegate = self 78 | } 79 | childCoordinators[.about] = aboutCoordinator 80 | aboutCoordinator.start() 81 | } 82 | } 83 | 84 | // MARK: - Delegate 85 | 86 | extension FeedCoordinator: AboutCoordinatorDelegate { 87 | /** 88 | Invoked when the About flow is no longer needed 89 | */ 90 | func aboutCoordinatorDidFinish() { 91 | childCoordinators[.about] = nil 92 | } 93 | } 94 | 95 | extension FeedCoordinator: FeedViewControllerDelegeate { 96 | /** 97 | Invoked when user resuests the About screen 98 | */ 99 | func userDidRequestAbout() { 100 | showAbout() 101 | } 102 | 103 | /** 104 | Invoked when user requests starting the setup process again 105 | */ 106 | func userDidRequestSetup() { 107 | delegate?.feedCoordinatorDidFinish() 108 | } 109 | 110 | /** 111 | Invoked when user requests showing RSS item detail 112 | 113 | - Parameter item: RSS item to show detail 114 | */ 115 | func userDidRequestItemDetail(item: RssItem) { 116 | showDetail(item: item) 117 | } 118 | } 119 | 120 | extension FeedCoordinator: DetailViewControllerDelegate { 121 | /** 122 | Invoked when user finished looking at the RSS source detail 123 | */ 124 | func detailViewControllerDidFinish() { 125 | navigationController.dismiss(animated: true, completion: nil) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Data/Licenses.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | license 7 | MIT License 8 | text 9 | **The MIT License** 10 | **Copyright © 2015 Krunoslav Zaher, Shai Mishali** 11 | **All rights reserved.** 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | 19 | title 20 | RxSwift 21 | 22 | 23 | license 24 | MIT License 25 | text 26 | Copyright (c) 2016-latest RxSwiftCommunity https://github.com/RxSwiftCommunity 27 | 28 | Permission is hereby granted, free of charge, to any person obtaining a copy 29 | of this software and associated documentation files (the "Software"), to deal 30 | in the Software without restriction, including without limitation the rights 31 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 32 | copies of the Software, and to permit persons to whom the Software is 33 | furnished to do so, subject to the following conditions: 34 | 35 | The above copyright notice and this permission notice shall be included in 36 | all copies or substantial portions of the Software. 37 | 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 39 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 40 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 41 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 42 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 43 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 44 | THE SOFTWARE. 45 | 46 | title 47 | RxSwiftExt 48 | 49 | 50 | license 51 | MIT License 52 | text 53 | The MIT License (MIT) 54 | 55 | Copyright (c) 2016 - 2018 Nuno Manuel Dias 56 | 57 | Permission is hereby granted, free of charge, to any person obtaining a copy 58 | of this software and associated documentation files (the "Software"), to deal 59 | in the Software without restriction, including without limitation the rights 60 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 61 | copies of the Software, and to permit persons to whom the Software is 62 | furnished to do so, subject to the following conditions: 63 | 64 | The above copyright notice and this permission notice shall be included in all 65 | copies or substantial portions of the Software. 66 | 67 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 68 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 69 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 70 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 71 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 72 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 73 | SOFTWARE. 74 | 75 | title 76 | FeedKit 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /support/fetch_licenses.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xcrun swift 2 | 3 | import Cocoa 4 | 5 | func loadResolvedCartfile(file: String) throws -> String { 6 | let string = try String(contentsOfFile: file, encoding: String.Encoding.utf8) 7 | return string 8 | } 9 | 10 | func parseResolvedCartfile(contents: String) -> [CartfileEntry] { 11 | let lines = contents.components(separatedBy: "\n") 12 | return lines.filter({ $0.utf16.count > 0 }).map { CartfileEntry(line: $0) } 13 | } 14 | 15 | struct CartfileEntry: CustomStringConvertible { 16 | let name: String, version: String 17 | var license: String? 18 | 19 | init(line: String) { 20 | let line = line.replacingOccurrences(of: "github ", with: "") 21 | let components = line.components(separatedBy: "\" \"") 22 | name = components[0].replacingOccurrences(of: "\"", with: "") 23 | version = components.count > 1 ? components[1].replacingOccurrences(of: "\"", with: "") : "" 24 | } 25 | 26 | var projectName: String { 27 | return name.components(separatedBy: "/")[1] 28 | } 29 | 30 | var description: String { 31 | return ([name, version]).joined(separator: " ") 32 | } 33 | 34 | func fetchLicense() -> (String, String) { 35 | var licenseName = "" 36 | var licenseContent = "" 37 | let semaphore = DispatchSemaphore(value: 0) 38 | 39 | print("Fetching license name for \(name) ...") 40 | 41 | var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(name)/license")!) 42 | request.addValue("application/vnd.github.drax-preview+json", forHTTPHeaderField: "Accept") 43 | let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) -> Void in 44 | 45 | if let response = response as? HTTPURLResponse { 46 | if response.statusCode == 404 { 47 | semaphore.signal() 48 | return 49 | } 50 | } 51 | 52 | do { 53 | let json = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as! [String: Any] 54 | if let info = json["license"] as? [String: Any] { 55 | licenseName = info["name"] as? String ?? "" 56 | } 57 | if let content = json["content"] as? String { 58 | let normalized = content.replacingOccurrences(of: "\n", with: "") 59 | 60 | if let decodedData = Data(base64Encoded: normalized, options: NSData.Base64DecodingOptions(rawValue: 0)), let decodedString = String(data: decodedData, encoding: String.Encoding.utf8) { 61 | licenseContent = decodedString 62 | } 63 | } 64 | } catch let error as NSError { 65 | print(error) 66 | } 67 | 68 | semaphore.signal() 69 | }) 70 | task.resume() 71 | 72 | _ = semaphore.wait(timeout: .distantFuture) 73 | return (licenseName, licenseContent) 74 | } 75 | } 76 | 77 | var c = 0 78 | if CommandLine.arguments.count >= 3 { 79 | let outputDirectory = CommandLine.arguments[CommandLine.arguments.count - 1] 80 | var error: NSError? 81 | do { 82 | var entries: [CartfileEntry] = [] 83 | for i in 1 ... CommandLine.arguments.count - 2 { 84 | let content = try loadResolvedCartfile(file: CommandLine.arguments[i]) 85 | let fileEntries = parseResolvedCartfile(contents: content) 86 | entries.append(contentsOf: fileEntries.filter({ !entries.map({ $0.name }).contains($0.name) })) 87 | } 88 | let licenses = entries.map({ (entry: CartfileEntry) -> [String: Any] in 89 | let (licenseName, licenseContent) = entry.fetchLicense() 90 | return ["title": entry.projectName, "text": licenseContent, "license": licenseName] 91 | }) 92 | let fileName = (outputDirectory as NSString).appendingPathComponent("Licenses.plist") 93 | (licenses as NSArray).write(toFile: fileName, atomically: true) 94 | print("Super awesome! Your licenses are at \(fileName) 🍻") 95 | } catch { 96 | print(error) 97 | } 98 | } else { 99 | print("USAGE: ./fetch_licenses Cartfile.resolved output_directory/") 100 | } 101 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/About/ViewControllers/AboutViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 21/11/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import UIKit 11 | 12 | protocol AboutViewControllerDelegate: AnyObject { 13 | /** 14 | Invoked when user naviages back from the About screen 15 | */ 16 | func aboutViewControllerDismissed() 17 | /** 18 | Invoked when user requests the list of used open source libraries 19 | */ 20 | func userDidRequestLibraries() 21 | /** 22 | Invoked when user requests the authors info 23 | */ 24 | func userDidRequestAuthorsInfo() 25 | /** 26 | Invoked when user requests the authors blog 27 | */ 28 | func userDidRequestAuthorsBlog() 29 | } 30 | 31 | final class AboutViewController: UITableViewController { 32 | 33 | // MARK: - UI 34 | 35 | private lazy var titleLabel = UILabel() &> { 36 | $0.textAlignment = .center 37 | $0.font = UIFont.preferredFont(forTextStyle: .headline) 38 | } 39 | 40 | private lazy var versionLabel = UILabel() &> { 41 | $0.textAlignment = .center 42 | $0.font = UIFont.preferredFont(forTextStyle: .caption2) 43 | } 44 | 45 | private lazy var logoImageView = UIImageView() &> { 46 | $0.image = .logo 47 | $0.contentMode = .scaleAspectFit 48 | $0.fixSize(width: 48, height: 48) 49 | } 50 | 51 | private lazy var headerView = UIView() &> { 52 | let textsStackView = UIStackView(arrangedSubviews: [titleLabel, versionLabel]) &> { 53 | $0.axis = .vertical 54 | } 55 | 56 | let stackView = UIStackView(arrangedSubviews: [logoImageView, textsStackView]) &> { 57 | $0.translatesAutoresizingMaskIntoConstraints = false 58 | $0.axis = .vertical 59 | $0.spacing = 8 60 | } 61 | 62 | stackView.pin(to: $0, insets: .init(top: 0, left: 0, bottom: 16, right: 0)) 63 | } 64 | 65 | // MARK: - Properties 66 | 67 | private let viewModel: AboutViewModel 68 | 69 | weak var delegate: AboutViewControllerDelegate? 70 | 71 | // MARK: - Fields 72 | 73 | private var disposeBag = DisposeBag() 74 | 75 | // MARK: - Setup 76 | 77 | init(viewModel: AboutViewModel) { 78 | self.viewModel = viewModel 79 | super.init(nibName: nil, bundle: nil) 80 | } 81 | 82 | @available(*, unavailable) 83 | required init?(coder: NSCoder) { 84 | fatalError("init(coder:) has not been implemented") 85 | } 86 | 87 | override func viewDidLoad() { 88 | super.viewDidLoad() 89 | 90 | setupUI() 91 | setupBinding() 92 | } 93 | 94 | private func setupUI() { 95 | title = NSLocalizedString("about", comment: "") 96 | 97 | titleLabel.text = viewModel.appName 98 | versionLabel.text = viewModel.appVersion 99 | } 100 | 101 | private func setupBinding() { 102 | tableView.dataSource = nil 103 | tableView.register(cellType: AboutCell.self) 104 | 105 | Observable.just(AboutMenuItem.allCases).bind(to: tableView.rx.items(cellIdentifier: AboutCell.reuseIdentifier, cellType: AboutCell.self)) { _, element, cell in 106 | cell.model = element 107 | }.disposed(by: disposeBag) 108 | 109 | tableView.rx.modelSelected(AboutMenuItem.self).withUnretained(self).bind { owner, menuItem in 110 | switch menuItem { 111 | case .libraries: 112 | owner.delegate?.userDidRequestLibraries() 113 | case .aboutAuthor: 114 | owner.delegate?.userDidRequestAuthorsInfo() 115 | case .authorsBlog: 116 | owner.delegate?.userDidRequestAuthorsBlog() 117 | } 118 | }.disposed(by: disposeBag) 119 | } 120 | 121 | deinit { 122 | delegate?.aboutViewControllerDismissed() 123 | } 124 | 125 | override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { 126 | return headerView 127 | } 128 | } 129 | 130 | #if canImport(SwiftUI) && DEBUG 131 | import SwiftUI 132 | struct AboutViewControllerPreview: PreviewProvider { 133 | static var previews: some View { 134 | UINavigationController(rootViewController: AboutViewController(viewModel: AboutViewModel())).asPreview() 135 | } 136 | } 137 | #endif 138 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/SourceSelectionViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceSelectionViewModelTests.swift 3 | // iOSSampleAppTests 4 | // 5 | // Created by Igor Kulman on 04/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @testable import iOSSampleApp 11 | import Testing 12 | import RxSwift 13 | 14 | struct SourceSelectionViewModelTests { 15 | 16 | @Test("Load default RSS sources when initialized") 17 | func testInitialSourcesLoad() throws { 18 | // Given 19 | let settingsService = SettingsServiceMock() 20 | let vm = SourceSelectionViewModel(settingsService: settingsService) 21 | 22 | // When 23 | let sources = try vm.sources.toBlocking().first()! 24 | 25 | // Then 26 | #expect(sources.count == 4) 27 | #expect(sources[0].source.title == "Coding Journal") 28 | #expect(sources[1].source.title == "Hacker News") 29 | #expect(!sources[0].isSelected.value) 30 | } 31 | 32 | @Test("Pre-select feed when already configured") 33 | func testPreselectedFeed() throws { 34 | // Given 35 | let settingsService = SettingsServiceMock() 36 | settingsService.selectedSource = RssSource( 37 | title: "Coding Journal", 38 | url: URL(string: "https://blog.kulman.sk")!, 39 | rss: URL(string: "https://blog.kulman.sk/index.xml")!, 40 | icon: nil 41 | ) 42 | let vm = SourceSelectionViewModel(settingsService: settingsService) 43 | 44 | // When 45 | let sources = try vm.sources.toBlocking().first()! 46 | 47 | // Then 48 | #expect(sources.count == 4) 49 | #expect(sources[0].source.title == "Coding Journal") 50 | #expect(sources[0].isSelected.value) 51 | #expect(!sources[1].isSelected.value) 52 | } 53 | 54 | @Test("Add new source and make it selected") 55 | func testAddNewSource() throws { 56 | // Given 57 | let settingsService = SettingsServiceMock() 58 | let vm = SourceSelectionViewModel(settingsService: settingsService) 59 | 60 | // When 61 | vm.addNewSource(source: RssSource( 62 | title: "Example", 63 | url: URL(string:"http://example.com")!, 64 | rss: URL(string:"http://example.com")!, 65 | icon: nil 66 | )) 67 | 68 | // Then 69 | let sources = try vm.sources.toBlocking().first()! 70 | #expect(sources.count == 5) 71 | #expect(sources[0].isSelected.value) 72 | #expect(sources[0].source.title == "Example") 73 | #expect(!sources[1].isSelected.value) 74 | } 75 | 76 | @Test("Toggle source selection") 77 | func testToggleSource() throws { 78 | // Given 79 | let settingsService = SettingsServiceMock() 80 | settingsService.selectedSource = RssSource( 81 | title: "Coding Journal", 82 | url: URL(string:"https://blog.kulman.sk")!, 83 | rss: URL(string:"https://blog.kulman.sk/index.xml")!, 84 | icon: nil 85 | ) 86 | let vm = SourceSelectionViewModel(settingsService: settingsService) 87 | let sources = try vm.sources.toBlocking().first()! 88 | 89 | // When 90 | vm.toggleSource(source: sources[2]) 91 | 92 | // Then 93 | #expect(!sources[0].isSelected.value) 94 | #expect(!sources[1].isSelected.value) 95 | #expect(sources[2].isSelected.value) 96 | #expect(!sources[3].isSelected.value) 97 | } 98 | 99 | @Test("Cannot save when no source selected") 100 | func testSaveWhenNoSourceSelected() { 101 | // Given 102 | let settingsService = SettingsServiceMock() 103 | let vm = SourceSelectionViewModel(settingsService: settingsService) 104 | 105 | // When 106 | let result = vm.saveSelectedSource() 107 | 108 | // Then 109 | #expect(!result) 110 | #expect(settingsService.selectedSource == nil) 111 | } 112 | 113 | @Test("Successfully save when source selected") 114 | func testSaveWhenSourceSelected() throws { 115 | // Given 116 | let settingsService = SettingsServiceMock() 117 | let vm = SourceSelectionViewModel(settingsService: settingsService) 118 | let sources = try vm.sources.toBlocking().first()! 119 | vm.toggleSource(source: sources[2]) 120 | 121 | // When 122 | let result = vm.saveSelectedSource() 123 | 124 | // Then 125 | #expect(result) 126 | #expect(settingsService.selectedSource == sources[2].source) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/ViewControllers/CustomSourceViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomSourceViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | import UIKit 12 | 13 | protocol CustomSourceViewControllerDelegate: AnyObject { 14 | /** 15 | Invokes when user adds a new custom RSS source 16 | 17 | - Parameter source: newly added RSS source 18 | */ 19 | func userDidAddCustomSource(source: RssSource) 20 | } 21 | 22 | final class CustomSourceViewController: UIViewController { 23 | 24 | // MARK: - UI 25 | 26 | private lazy var doneButton = UIBarButtonItem() &> { 27 | $0.title = NSLocalizedString("done", comment: "") 28 | $0.style = .plain 29 | } 30 | 31 | private lazy var rssUrlFormField = FormFieldView() &> { 32 | $0.title = NSLocalizedString("rss_url", comment: "") 33 | } 34 | 35 | private lazy var urlFormField = FormFieldView() &> { 36 | $0.title = NSLocalizedString("url", comment: "") 37 | } 38 | 39 | private lazy var titleFormField = FormFieldView() &> { 40 | $0.title = NSLocalizedString("title", comment: "") 41 | } 42 | 43 | private lazy var logoUrlFormField = FormFieldView() &> { 44 | $0.title = "\(NSLocalizedString("logo_url", comment: "")) (\(NSLocalizedString("optional", comment: "")))" 45 | } 46 | 47 | private lazy var scrollView = UIScrollView() &> { 48 | $0.backgroundColor = .systemBackground 49 | } 50 | 51 | // MARK: - Properties 52 | 53 | weak var delegate: CustomSourceViewControllerDelegate? 54 | 55 | // MARK: - Fields 56 | 57 | private let viewModel: CustomSourceViewModel 58 | private var disposeBag = DisposeBag() 59 | 60 | init(viewModel: CustomSourceViewModel) { 61 | self.viewModel = viewModel 62 | super.init(nibName: nil, bundle: nil) 63 | } 64 | 65 | @available(*, unavailable) 66 | required init?(coder: NSCoder) { 67 | fatalError("init(coder:) has not been implemented") 68 | } 69 | 70 | // MARK: - Setup 71 | 72 | override func loadView() { 73 | let view = UIView() 74 | defer { self.view = view } 75 | 76 | let stackView = UIStackView(arrangedSubviews: [titleFormField, urlFormField, rssUrlFormField, logoUrlFormField]) &> { 77 | $0.axis = .vertical 78 | $0.spacing = 12 79 | } 80 | 81 | let contentView = UIView() &> { 82 | stackView.pin(to: $0, guide: $0.layoutMarginsGuide, insets: .init(all: 8)) 83 | } 84 | 85 | scrollView.pin(to: view, with: contentView) 86 | } 87 | 88 | override func viewDidLoad() { 89 | super.viewDidLoad() 90 | 91 | setupUI() 92 | setupBinding() 93 | } 94 | 95 | private func setupUI() { 96 | title = NSLocalizedString("add_custom_source", comment: "") 97 | navigationItem.rightBarButtonItem = doneButton 98 | } 99 | 100 | private func setupBinding() { 101 | viewModel.isValid.drive(doneButton.rx.isEnabled).disposed(by: disposeBag) 102 | titleFormField.rx.value.bind(to: viewModel.title).disposed(by: disposeBag) 103 | urlFormField.rx.value.bind(to: viewModel.url).disposed(by: disposeBag) 104 | rssUrlFormField.rx.value.bind(to: viewModel.rssUrl).disposed(by: disposeBag) 105 | logoUrlFormField.rx.value.bind(to: viewModel.logoUrl).disposed(by: disposeBag) 106 | 107 | viewModel.rssUrl.map({ $0?.isValidURL == true }).bind(to: rssUrlFormField.rx.isValid).disposed(by: disposeBag) 108 | viewModel.url.asObservable().map({ $0?.isValidURL == true }).bind(to: urlFormField.rx.isValid).disposed(by: disposeBag) 109 | viewModel.logoUrl.asObservable().map({ $0?.isValidURL == true }).bind(to: logoUrlFormField.rx.isValid).disposed(by: disposeBag) 110 | 111 | NotificationCenter.default.rx.keyboardHeightChanged().withUnretained(self).bind { owner, height in owner.scrollView.setBottomInset(height: height) }.disposed(by: disposeBag) 112 | 113 | doneButton.rx.tap.withLatestFrom(viewModel.source.flatMap(ignoreNil)).withUnretained(self).bind { owner, source in owner.delegate?.userDidAddCustomSource(source: source) }.disposed(by: disposeBag) 114 | } 115 | } 116 | 117 | #if canImport(SwiftUI) && DEBUG 118 | import SwiftUI 119 | struct CustomSourceViewControllerPreview: PreviewProvider { 120 | static var previews: some View { 121 | UINavigationController(rootViewController: CustomSourceViewController(viewModel: CustomSourceViewModel())).asPreview() 122 | } 123 | } 124 | #endif 125 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Setup/ViewControllers/SourceSelectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SourceSelectionViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | import UIKit 12 | 13 | protocol SourceSelectionViewControllerDelegate: AnyObject { 14 | /** 15 | Invoked when the user finished setting the RSS source 16 | */ 17 | func sourceSelectionViewControllerDidFinish() 18 | /** 19 | Invoked when user requests adding a new custom source 20 | */ 21 | func userDidRequestCustomSource() 22 | } 23 | 24 | final class SourceSelectionViewController: UIViewController { 25 | 26 | // MARK: - UI 27 | 28 | private lazy var tableView = UITableView() &> { 29 | $0.rowHeight = 60 30 | $0.tableFooterView = UIView() 31 | } 32 | 33 | private lazy var doneButton = UIBarButtonItem() &> { 34 | $0.title = NSLocalizedString("done", comment: "") 35 | $0.style = .plain 36 | $0.accessibilityIdentifier = "done" 37 | } 38 | 39 | private lazy var addCustomButton = UIBarButtonItem() &> { 40 | $0.title = NSLocalizedString("add_custom", comment: "") 41 | $0.style = .plain 42 | } 43 | 44 | private lazy var searchController = UISearchController() 45 | 46 | // MARK: - Properties 47 | 48 | let viewModel: SourceSelectionViewModel 49 | 50 | weak var delegate: SourceSelectionViewControllerDelegate? 51 | 52 | // MARK: - Fields 53 | 54 | private var disposeBag = DisposeBag() 55 | 56 | init(viewModel: SourceSelectionViewModel) { 57 | self.viewModel = viewModel 58 | super.init(nibName: nil, bundle: nil) 59 | } 60 | 61 | @available(*, unavailable) 62 | required init?(coder: NSCoder) { 63 | fatalError("init(coder:) has not been implemented") 64 | } 65 | 66 | // MARK: - Setup 67 | 68 | override func loadView() { 69 | let view = UIView() 70 | defer { self.view = view } 71 | 72 | tableView.pin(to: view) 73 | } 74 | 75 | override func viewDidLoad() { 76 | super.viewDidLoad() 77 | 78 | setupUI() 79 | setupBinding() 80 | setupData() 81 | } 82 | 83 | private func setupUI() { 84 | title = NSLocalizedString("select_source", comment: "") 85 | navigationItem.rightBarButtonItem = doneButton 86 | navigationItem.leftBarButtonItem = addCustomButton 87 | 88 | navigationItem.searchController = searchController 89 | definesPresentationContext = true 90 | 91 | tableView.estimatedRowHeight = 0 92 | } 93 | 94 | private func setupBinding() { 95 | tableView.rx.modelSelected(RssSourceViewModel.self).withUnretained(self).bind { owner, source in owner.viewModel.toggleSource(source: source) }.disposed(by: disposeBag) 96 | tableView.rx.itemSelected.withUnretained(self).bind { owner, indexPath in owner.tableView.deselectRow(at: indexPath, animated: true) }.disposed(by: disposeBag) 97 | viewModel.isValid.drive(doneButton.rx.isEnabled).disposed(by: disposeBag) 98 | doneButton.rx.tap.withUnretained(self).bind { owner, _ in 99 | if owner.viewModel.saveSelectedSource() { 100 | owner.delegate?.sourceSelectionViewControllerDidFinish() 101 | } 102 | 103 | }.disposed(by: disposeBag) 104 | addCustomButton.rx.tap.withUnretained(self).bind { owner, _ in owner.delegate?.userDidRequestCustomSource() }.disposed(by: disposeBag) 105 | 106 | searchController.searchBar.rx.text.throttle(RxTimeInterval.milliseconds(100), scheduler: MainScheduler.instance).bind(to: viewModel.filter).disposed(by: disposeBag) 107 | searchController.searchBar.rx.textDidBeginEditing.withUnretained(self).bind { owner, _ in owner.searchController.searchBar.setShowsCancelButton(true, animated: true) }.disposed(by: disposeBag) 108 | searchController.searchBar.rx.cancelButtonClicked.withUnretained(self).bind { owner, _ in 109 | owner.searchController.searchBar.resignFirstResponder() 110 | owner.searchController.searchBar.setShowsCancelButton(false, animated: true) 111 | owner.viewModel.filter.accept(nil) 112 | owner.searchController.searchBar.text = nil 113 | }.disposed(by: disposeBag) 114 | } 115 | 116 | private func setupData() { 117 | tableView.register(cellType: RssSourceCell.self) 118 | 119 | viewModel.sources 120 | .drive(tableView.rx.items(cellIdentifier: RssSourceCell.reuseIdentifier, cellType: RssSourceCell.self)) { _, element, cell in 121 | cell.viewModel = element 122 | } 123 | .disposed(by: disposeBag) 124 | } 125 | } 126 | 127 | #if canImport(SwiftUI) && DEBUG 128 | import SwiftUI 129 | struct SourceSelectionViewControllerPreview: PreviewProvider { 130 | static var previews: some View { 131 | UINavigationController(rootViewController: SourceSelectionViewController(viewModel: SourceSelectionViewModel(settingsService: UserDefaultsSettingsService()))).asPreview() 132 | } 133 | } 134 | #endif 135 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/ViewControllers/FeedViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DashboardViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 03/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | import RxSwiftExt 12 | import UIKit 13 | 14 | protocol FeedViewControllerDelegeate: AnyObject { 15 | /** 16 | Invoked when user requests showing RSS item detail 17 | 18 | - Parameter item: RSS item to show detail 19 | */ 20 | func userDidRequestItemDetail(item: RssItem) 21 | /** 22 | Invoked when user requests starting the setup process again 23 | */ 24 | func userDidRequestSetup() 25 | /** 26 | Invoked when user resuests the About screen 27 | */ 28 | func userDidRequestAbout() 29 | } 30 | 31 | final class FeedViewController: UIViewController, ToastCapable { 32 | 33 | // MARK: - UI 34 | 35 | private lazy var tableView = UITableView() &> { 36 | $0.estimatedRowHeight = 0 37 | $0.rowHeight = 100 38 | $0.refreshControl = refreshControl 39 | $0.tableFooterView = UIView() 40 | } 41 | 42 | private lazy var refreshControl = UIRefreshControl() &> { 43 | $0.attributedTitle = NSAttributedString(string: NSLocalizedString("pull_to_refresh", comment: "")) 44 | } 45 | 46 | private lazy var setupButton = UIBarButtonItem() &> { 47 | $0.image = .settings 48 | $0.style = .plain 49 | } 50 | 51 | private lazy var aboutButton = UIBarButtonItem() &> { 52 | $0.image = .about 53 | $0.style = .plain 54 | $0.accessibilityIdentifier = "about" 55 | } 56 | 57 | // MARK: - Properties 58 | 59 | weak var delegate: FeedViewControllerDelegeate? 60 | 61 | // MARK: - Fields 62 | 63 | private let viewModel: FeedViewModel 64 | private var disposeBag = DisposeBag() 65 | 66 | init(viewModel: FeedViewModel) { 67 | self.viewModel = viewModel 68 | super.init(nibName: nil, bundle: nil) 69 | } 70 | 71 | @available(*, unavailable) 72 | required init?(coder: NSCoder) { 73 | fatalError("init(coder:) has not been implemented") 74 | } 75 | 76 | // MARK: - Setup 77 | 78 | override func loadView() { 79 | let view = UIView() 80 | defer { self.view = view } 81 | 82 | tableView.pin(to: view) 83 | } 84 | 85 | override func viewDidLoad() { 86 | super.viewDidLoad() 87 | 88 | setupUI() 89 | setupBinding() 90 | setupData() 91 | } 92 | 93 | private func setupUI() { 94 | title = viewModel.title 95 | 96 | navigationItem.leftBarButtonItem = setupButton 97 | navigationItem.rightBarButtonItem = aboutButton 98 | } 99 | 100 | private func setupBinding() { 101 | tableView.rx.modelSelected(RssItem.self).withUnretained(self).bind { owner, item in owner.delegate?.userDidRequestItemDetail(item: item) }.disposed(by: disposeBag) 102 | tableView.rx.itemSelected.withUnretained(self).bind { owner, indexPath in owner.tableView.deselectRow(at: indexPath, animated: true) }.disposed(by: disposeBag) 103 | 104 | refreshControl.rx.controlEvent(.valueChanged).bind(to: viewModel.load).disposed(by: disposeBag) 105 | 106 | setupButton.rx.tap.withUnretained(self).bind { owner, _ in owner.delegate?.userDidRequestSetup() }.disposed(by: disposeBag) 107 | aboutButton.rx.tap.withUnretained(self).bind { owner, _ in owner.delegate?.userDidRequestAbout() }.disposed(by: disposeBag) 108 | } 109 | 110 | private func setupData() { 111 | tableView.register(cellType: FeedCell.self) 112 | 113 | // announcing errors with a toast 114 | viewModel.onError.drive(onNext: { [weak self] error in 115 | guard let self = self else { 116 | return 117 | } 118 | switch error { 119 | case let rssError as RssError: 120 | self.showErrorToast(message: rssError.description) 121 | default: 122 | self.showErrorToast(message: NSLocalizedString("network_problem", comment: "")) 123 | } 124 | }).disposed(by: disposeBag) 125 | 126 | // refresh is considered finished when new data arrives or when the request fails 127 | Driver.merge( 128 | viewModel.feed.map({ _ in Void() }), 129 | viewModel.onError.map({ _ in Void() }) 130 | ) 131 | .drive(onNext: { [weak self] _ in 132 | self?.refreshControl.endRefreshing() 133 | }) 134 | .disposed(by: disposeBag) 135 | 136 | viewModel.feed 137 | .drive(tableView.rx.items(cellIdentifier: FeedCell.reuseIdentifier, cellType: FeedCell.self)) { _, element, cell in 138 | cell.model = element 139 | } 140 | .disposed(by: disposeBag) 141 | } 142 | } 143 | 144 | #if canImport(SwiftUI) && DEBUG 145 | import SwiftUI 146 | final class PreviewDataService: DataService { 147 | func getFeed(source: RssSource, onCompletion: @escaping (RssResult) -> Void) { 148 | onCompletion(.success([ 149 | RssItem(title: "Post 1", description: "Description", link: URL(string: "https://news.ycombinator.com")!, pubDate: Date()), 150 | RssItem(title: "Post 2", description: "Description", link: URL(string: "https://news.ycombinator.com")!, pubDate: Date()), 151 | RssItem(title: "Post 3", description: "Description", link: URL(string: "https://news.ycombinator.com")!, pubDate: Date()) 152 | ])) 153 | } 154 | } 155 | final class PreviewSettingsService: SettingsService { 156 | var selectedSource: RssSource? = RssSource( 157 | title: "Hacker News", 158 | url: URL(string: "https://news.ycombinator.com")!, 159 | rss: URL(string: "https://news.ycombinator.com/rss")!, 160 | icon: URL("https://upload.wikimedia.org/wikipedia/commons/d/d5/Y_Combinator_Logo_400.gif")! 161 | ) 162 | } 163 | 164 | struct FeedViewControllerPreview: PreviewProvider { 165 | static var previews: some View { 166 | UINavigationController(rootViewController: FeedViewController(viewModel: FeedViewModel(dataService: PreviewDataService(), settingsService: PreviewSettingsService()))).asPreview() 167 | } 168 | } 169 | #endif 170 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Scenarios/Feed/ViewControllers/DetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailViewController.swift 3 | // iOSSampleApp 4 | // 5 | // Created by Igor Kulman on 05/10/2017. 6 | // Copyright © 2017 Igor Kulman. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | import UIKit 12 | import WebKit 13 | 14 | protocol DetailViewControllerDelegate: AnyObject { 15 | /** 16 | Invoked when user finished looking at the RSS source detail 17 | */ 18 | func detailViewControllerDidFinish() 19 | } 20 | 21 | final class DetailViewController: UIViewController { 22 | 23 | // MARK: - UI 24 | 25 | private lazy var backBarButtonItem = UIBarButtonItem() &> { 26 | $0.image = .back 27 | $0.style = .plain 28 | } 29 | 30 | private lazy var forwardBarButtonItem = UIBarButtonItem() &> { 31 | $0.image = .forward 32 | $0.style = .plain 33 | } 34 | 35 | private lazy var reloadBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: nil) 36 | 37 | private lazy var stopBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: nil) 38 | 39 | private lazy var doneBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: nil) 40 | 41 | private lazy var flexibleSpaceBarButtonItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) 42 | 43 | private lazy var progressView = UIProgressView(progressViewStyle: .default) &> { 44 | $0.trackTintColor = .clear 45 | } 46 | 47 | // MARK: - Properties 48 | 49 | weak var delegate: DetailViewControllerDelegate? 50 | 51 | // MARK: - Fields 52 | 53 | private let item: RssItem 54 | private var webView: WKWebView? 55 | private var disposeBag = DisposeBag() 56 | 57 | // MARK: - Setup 58 | 59 | init(item: RssItem) { 60 | self.item = item 61 | super.init(nibName: nil, bundle: nil) 62 | } 63 | 64 | @available(*, unavailable) 65 | required init?(coder: NSCoder) { 66 | fatalError("init(coder:) has not been implemented") 67 | } 68 | 69 | override func loadView() { 70 | let webConfiguration = WKWebViewConfiguration() 71 | let webView = WKWebView(frame: .zero, configuration: webConfiguration) 72 | 73 | webView.allowsBackForwardNavigationGestures = true 74 | webView.isMultipleTouchEnabled = true 75 | 76 | view = webView 77 | self.webView = webView 78 | } 79 | 80 | override func viewWillLayoutSubviews() { 81 | super.viewWillLayoutSubviews() 82 | 83 | guard let navigationController = navigationController else { 84 | return 85 | } 86 | progressView.frame = CGRect(x: 0, y: navigationController.navigationBar.frame.size.height - progressView.frame.size.height, width: navigationController.navigationBar.frame.size.width, height: progressView.frame.size.height) 87 | } 88 | 89 | override func viewDidLoad() { 90 | super.viewDidLoad() 91 | 92 | setupUI() 93 | setupBinding() 94 | setupData() 95 | } 96 | 97 | private func setupUI() { 98 | navigationItem.rightBarButtonItem = doneBarButtonItem 99 | title = item.title 100 | 101 | navigationController?.setToolbarHidden(false, animated: false) 102 | navigationController?.navigationBar.addSubview(progressView) 103 | } 104 | 105 | private func setupData() { 106 | load(item.link) 107 | } 108 | 109 | private func setupBinding() { 110 | backBarButtonItem.rx.tap.withUnretained(self).bind { owner, _ in 111 | owner.webView?.goBack() 112 | }.disposed(by: disposeBag) 113 | 114 | forwardBarButtonItem.rx.tap.withUnretained(self).bind { owner, _ in 115 | owner.webView?.goForward() 116 | }.disposed(by: disposeBag) 117 | 118 | doneBarButtonItem.rx.tap.withUnretained(self).bind { owner, _ in 119 | owner.delegate?.detailViewControllerDidFinish() 120 | }.disposed(by: disposeBag) 121 | 122 | reloadBarButtonItem.rx.tap.withUnretained(self).bind { owner, _ in 123 | owner.webView?.stopLoading() 124 | if owner.webView?.url != nil { 125 | owner.webView?.reload() 126 | } else { 127 | owner.load(owner.item.link) 128 | } 129 | }.disposed(by: disposeBag) 130 | 131 | guard let webView = webView else { 132 | return 133 | } 134 | 135 | webView.rx.canGoBack.bind(to: backBarButtonItem.rx.isEnabled).disposed(by: disposeBag) 136 | webView.rx.canGoForward.bind(to: forwardBarButtonItem.rx.isEnabled).disposed(by: disposeBag) 137 | 138 | webView.rx.title.bind(to: navigationItem.rx.title).disposed(by: disposeBag) 139 | webView.rx.estimatedProgress.withUnretained(self).bind { owner, estimatedProgress in 140 | owner.progressView.alpha = 1 141 | owner.progressView.setProgress(Float(estimatedProgress), animated: true) 142 | 143 | guard estimatedProgress >= 1.0 else { 144 | return 145 | } 146 | 147 | owner.animateProgressAlpha() 148 | }.disposed(by: disposeBag) 149 | 150 | webView.rx.loading.map { [backBarButtonItem, flexibleSpaceBarButtonItem, forwardBarButtonItem, reloadBarButtonItem, stopBarButtonItem] (isLoading: Bool) -> [UIBarButtonItem] in 151 | if isLoading { 152 | return [backBarButtonItem, flexibleSpaceBarButtonItem, forwardBarButtonItem, flexibleSpaceBarButtonItem, stopBarButtonItem] 153 | } else { 154 | return [backBarButtonItem, flexibleSpaceBarButtonItem, forwardBarButtonItem, flexibleSpaceBarButtonItem, reloadBarButtonItem] 155 | } 156 | }.bind(to: self.rx.toolbarItems).disposed(by: disposeBag) 157 | } 158 | 159 | // MARK: - Internal 160 | 161 | private func animateProgressAlpha() { 162 | UIView.animate(withDuration: 0.3, delay: 0.3, options: .curveEaseOut, animations: { [weak self] in 163 | self?.progressView.alpha = 0 164 | }, completion: { [weak self] _ in 165 | self?.progressView.setProgress(0, animated: false) 166 | }) 167 | } 168 | 169 | private func load(_ url: URL) { 170 | guard let webView = webView else { 171 | return 172 | } 173 | let request = URLRequest(url: url) 174 | DispatchQueue.main.async { 175 | webView.load(request) 176 | } 177 | } 178 | } 179 | 180 | #if canImport(SwiftUI) && DEBUG 181 | import SwiftUI 182 | struct DetailViewControllerPreview: PreviewProvider { 183 | static var previews: some View { 184 | UINavigationController(rootViewController: DetailViewController(item: RssItem(title: "New post", description: "Some description", link: URL(string: "http://www.github.com/igorkulman/iOSSampleApp")!, pubDate: Date()))).asPreview() 185 | } 186 | } 187 | #endif 188 | -------------------------------------------------------------------------------- /Sources/iOSSampleApp/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "about" : { 5 | "localizations" : { 6 | "en" : { 7 | "stringUnit" : { 8 | "state" : "translated", 9 | "value" : "About" 10 | } 11 | }, 12 | "sk" : { 13 | "stringUnit" : { 14 | "state" : "translated", 15 | "value" : "O aplikácii" 16 | } 17 | } 18 | } 19 | }, 20 | "add_custom" : { 21 | "localizations" : { 22 | "en" : { 23 | "stringUnit" : { 24 | "state" : "translated", 25 | "value" : "Add custom" 26 | } 27 | }, 28 | "sk" : { 29 | "stringUnit" : { 30 | "state" : "translated", 31 | "value" : "Pridať vlastný" 32 | } 33 | } 34 | } 35 | }, 36 | "add_custom_source" : { 37 | "localizations" : { 38 | "en" : { 39 | "stringUnit" : { 40 | "state" : "translated", 41 | "value" : "Add custom source" 42 | } 43 | }, 44 | "sk" : { 45 | "stringUnit" : { 46 | "state" : "translated", 47 | "value" : "Pridať vlastný zdroj" 48 | } 49 | } 50 | } 51 | }, 52 | "author" : { 53 | "localizations" : { 54 | "en" : { 55 | "stringUnit" : { 56 | "state" : "translated", 57 | "value" : "About author" 58 | } 59 | }, 60 | "sk" : { 61 | "stringUnit" : { 62 | "state" : "translated", 63 | "value" : "O autorovi" 64 | } 65 | } 66 | } 67 | }, 68 | "back" : { 69 | "localizations" : { 70 | "en" : { 71 | "stringUnit" : { 72 | "state" : "translated", 73 | "value" : "Back" 74 | } 75 | }, 76 | "sk" : { 77 | "stringUnit" : { 78 | "state" : "translated", 79 | "value" : "Späť" 80 | } 81 | } 82 | } 83 | }, 84 | "blog" : { 85 | "localizations" : { 86 | "en" : { 87 | "stringUnit" : { 88 | "state" : "translated", 89 | "value" : "Author's blog" 90 | } 91 | }, 92 | "sk" : { 93 | "stringUnit" : { 94 | "state" : "translated", 95 | "value" : "Autorov blog" 96 | } 97 | } 98 | } 99 | }, 100 | "done" : { 101 | "localizations" : { 102 | "en" : { 103 | "stringUnit" : { 104 | "state" : "translated", 105 | "value" : "Done" 106 | } 107 | }, 108 | "sk" : { 109 | "stringUnit" : { 110 | "state" : "translated", 111 | "value" : "Hotovo" 112 | } 113 | } 114 | } 115 | }, 116 | "empty_response" : { 117 | "localizations" : { 118 | "en" : { 119 | "stringUnit" : { 120 | "state" : "translated", 121 | "value" : "No items" 122 | } 123 | }, 124 | "sk" : { 125 | "stringUnit" : { 126 | "state" : "translated", 127 | "value" : "Žiadne položky" 128 | } 129 | } 130 | } 131 | }, 132 | "libraries" : { 133 | "localizations" : { 134 | "en" : { 135 | "stringUnit" : { 136 | "state" : "translated", 137 | "value" : "Used libraries" 138 | } 139 | }, 140 | "sk" : { 141 | "stringUnit" : { 142 | "state" : "translated", 143 | "value" : "Použité knižnice" 144 | } 145 | } 146 | } 147 | }, 148 | "logo_url" : { 149 | "localizations" : { 150 | "en" : { 151 | "stringUnit" : { 152 | "state" : "translated", 153 | "value" : "Logo URL" 154 | } 155 | }, 156 | "sk" : { 157 | "stringUnit" : { 158 | "state" : "translated", 159 | "value" : "URL loga" 160 | } 161 | } 162 | } 163 | }, 164 | "network_problem" : { 165 | "localizations" : { 166 | "en" : { 167 | "stringUnit" : { 168 | "state" : "translated", 169 | "value" : "Network problem has occured" 170 | } 171 | }, 172 | "sk" : { 173 | "stringUnit" : { 174 | "state" : "translated", 175 | "value" : "Chyba pripojenia" 176 | } 177 | } 178 | } 179 | }, 180 | "optional" : { 181 | "localizations" : { 182 | "en" : { 183 | "stringUnit" : { 184 | "state" : "translated", 185 | "value" : "Optional" 186 | } 187 | }, 188 | "sk" : { 189 | "stringUnit" : { 190 | "state" : "translated", 191 | "value" : "Voliteľné" 192 | } 193 | } 194 | } 195 | }, 196 | "pull_to_refresh" : { 197 | "localizations" : { 198 | "en" : { 199 | "stringUnit" : { 200 | "state" : "translated", 201 | "value" : "Pull to refresh" 202 | } 203 | }, 204 | "sk" : { 205 | "stringUnit" : { 206 | "state" : "translated", 207 | "value" : "Aktualizujte potiahnutím" 208 | } 209 | } 210 | } 211 | }, 212 | "rss_url" : { 213 | "localizations" : { 214 | "en" : { 215 | "stringUnit" : { 216 | "state" : "translated", 217 | "value" : "RSS URL" 218 | } 219 | }, 220 | "sk" : { 221 | "stringUnit" : { 222 | "state" : "translated", 223 | "value" : "URL RSS" 224 | } 225 | } 226 | } 227 | }, 228 | "select_source" : { 229 | "localizations" : { 230 | "en" : { 231 | "stringUnit" : { 232 | "state" : "translated", 233 | "value" : "Select source" 234 | } 235 | }, 236 | "sk" : { 237 | "stringUnit" : { 238 | "state" : "translated", 239 | "value" : "Vybrať zdroj" 240 | } 241 | } 242 | } 243 | }, 244 | "title" : { 245 | "localizations" : { 246 | "en" : { 247 | "stringUnit" : { 248 | "state" : "translated", 249 | "value" : "Title" 250 | } 251 | }, 252 | "sk" : { 253 | "stringUnit" : { 254 | "state" : "translated", 255 | "value" : "Názov" 256 | } 257 | } 258 | } 259 | }, 260 | "url" : { 261 | "localizations" : { 262 | "en" : { 263 | "stringUnit" : { 264 | "state" : "translated", 265 | "value" : "URL" 266 | } 267 | }, 268 | "sk" : { 269 | "stringUnit" : { 270 | "state" : "translated", 271 | "value" : "URL" 272 | } 273 | } 274 | } 275 | } 276 | }, 277 | "version" : "1.0" 278 | } -------------------------------------------------------------------------------- /Sources/iOSSampleApp.xcodeproj/xcshareddata/xcschemes/iOSSampleApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 37 | 38 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 52 | 55 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 74 | 75 | 76 | 77 | 79 | 85 | 86 | 87 | 89 | 95 | 96 | 97 | 98 | 99 | 109 | 111 | 117 | 118 | 119 | 120 | 126 | 128 | 134 | 135 | 136 | 137 | 139 | 140 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.5) 5 | rexml 6 | addressable (2.8.1) 7 | public_suffix (>= 2.0.2, < 6.0) 8 | artifactory (3.0.15) 9 | atomos (0.1.3) 10 | aws-eventstream (1.2.0) 11 | aws-partitions (1.686.0) 12 | aws-sdk-core (3.168.4) 13 | aws-eventstream (~> 1, >= 1.0.2) 14 | aws-partitions (~> 1, >= 1.651.0) 15 | aws-sigv4 (~> 1.5) 16 | jmespath (~> 1, >= 1.6.1) 17 | aws-sdk-kms (1.61.0) 18 | aws-sdk-core (~> 3, >= 3.165.0) 19 | aws-sigv4 (~> 1.1) 20 | aws-sdk-s3 (1.117.2) 21 | aws-sdk-core (~> 3, >= 3.165.0) 22 | aws-sdk-kms (~> 1) 23 | aws-sigv4 (~> 1.4) 24 | aws-sigv4 (1.5.2) 25 | aws-eventstream (~> 1, >= 1.0.2) 26 | babosa (1.0.4) 27 | claide (1.1.0) 28 | claide-plugins (0.9.2) 29 | cork 30 | nap 31 | open4 (~> 1.3) 32 | colored (1.2) 33 | colored2 (3.1.2) 34 | commander (4.6.0) 35 | highline (~> 2.0.0) 36 | cork (0.3.0) 37 | colored2 (~> 3.1) 38 | danger (9.1.0) 39 | claide (~> 1.0) 40 | claide-plugins (>= 0.9.2) 41 | colored2 (~> 3.1) 42 | cork (~> 0.1) 43 | faraday (>= 0.9.0, < 2.0) 44 | faraday-http-cache (~> 2.0) 45 | git (~> 1.7) 46 | kramdown (~> 2.3) 47 | kramdown-parser-gfm (~> 1.0) 48 | no_proxy_fix 49 | octokit (~> 5.0) 50 | terminal-table (>= 1, < 4) 51 | danger-swiftlint (0.31.0) 52 | danger 53 | rake (> 10) 54 | thor (~> 0.19) 55 | declarative (0.0.20) 56 | digest-crc (0.6.4) 57 | rake (>= 12.0.0, < 14.0.0) 58 | domain_name (0.5.20190701) 59 | unf (>= 0.0.5, < 1.0.0) 60 | dotenv (2.8.1) 61 | emoji_regex (3.2.3) 62 | excon (0.95.0) 63 | faraday (1.10.2) 64 | faraday-em_http (~> 1.0) 65 | faraday-em_synchrony (~> 1.0) 66 | faraday-excon (~> 1.1) 67 | faraday-httpclient (~> 1.0) 68 | faraday-multipart (~> 1.0) 69 | faraday-net_http (~> 1.0) 70 | faraday-net_http_persistent (~> 1.0) 71 | faraday-patron (~> 1.0) 72 | faraday-rack (~> 1.0) 73 | faraday-retry (~> 1.0) 74 | ruby2_keywords (>= 0.0.4) 75 | faraday-cookie_jar (0.0.7) 76 | faraday (>= 0.8.0) 77 | http-cookie (~> 1.0.0) 78 | faraday-em_http (1.0.0) 79 | faraday-em_synchrony (1.0.0) 80 | faraday-excon (1.1.0) 81 | faraday-http-cache (2.4.1) 82 | faraday (>= 0.8) 83 | faraday-httpclient (1.0.1) 84 | faraday-multipart (1.0.4) 85 | multipart-post (~> 2) 86 | faraday-net_http (1.0.1) 87 | faraday-net_http_persistent (1.2.0) 88 | faraday-patron (1.0.0) 89 | faraday-rack (1.0.0) 90 | faraday-retry (1.0.3) 91 | faraday_middleware (1.2.0) 92 | faraday (~> 1.0) 93 | fastimage (2.2.6) 94 | fastlane (2.211.0) 95 | CFPropertyList (>= 2.3, < 4.0.0) 96 | addressable (>= 2.8, < 3.0.0) 97 | artifactory (~> 3.0) 98 | aws-sdk-s3 (~> 1.0) 99 | babosa (>= 1.0.3, < 2.0.0) 100 | bundler (>= 1.12.0, < 3.0.0) 101 | colored 102 | commander (~> 4.6) 103 | dotenv (>= 2.1.1, < 3.0.0) 104 | emoji_regex (>= 0.1, < 4.0) 105 | excon (>= 0.71.0, < 1.0.0) 106 | faraday (~> 1.0) 107 | faraday-cookie_jar (~> 0.0.6) 108 | faraday_middleware (~> 1.0) 109 | fastimage (>= 2.1.0, < 3.0.0) 110 | gh_inspector (>= 1.1.2, < 2.0.0) 111 | google-apis-androidpublisher_v3 (~> 0.3) 112 | google-apis-playcustomapp_v1 (~> 0.1) 113 | google-cloud-storage (~> 1.31) 114 | highline (~> 2.0) 115 | json (< 3.0.0) 116 | jwt (>= 2.1.0, < 3) 117 | mini_magick (>= 4.9.4, < 5.0.0) 118 | multipart-post (~> 2.0.0) 119 | naturally (~> 2.2) 120 | optparse (~> 0.1.1) 121 | plist (>= 3.1.0, < 4.0.0) 122 | rubyzip (>= 2.0.0, < 3.0.0) 123 | security (= 0.1.3) 124 | simctl (~> 1.6.3) 125 | terminal-notifier (>= 2.0.0, < 3.0.0) 126 | terminal-table (>= 1.4.5, < 2.0.0) 127 | tty-screen (>= 0.6.3, < 1.0.0) 128 | tty-spinner (>= 0.8.0, < 1.0.0) 129 | word_wrap (~> 1.0.0) 130 | xcodeproj (>= 1.13.0, < 2.0.0) 131 | xcpretty (~> 0.3.0) 132 | xcpretty-travis-formatter (>= 0.0.3) 133 | gh_inspector (1.1.3) 134 | git (1.13.0) 135 | addressable (~> 2.8) 136 | rchardet (~> 1.8) 137 | google-apis-androidpublisher_v3 (0.32.0) 138 | google-apis-core (>= 0.9.1, < 2.a) 139 | google-apis-core (0.9.2) 140 | addressable (~> 2.5, >= 2.5.1) 141 | googleauth (>= 0.16.2, < 2.a) 142 | httpclient (>= 2.8.1, < 3.a) 143 | mini_mime (~> 1.0) 144 | representable (~> 3.0) 145 | retriable (>= 2.0, < 4.a) 146 | rexml 147 | webrick 148 | google-apis-iamcredentials_v1 (0.16.0) 149 | google-apis-core (>= 0.9.1, < 2.a) 150 | google-apis-playcustomapp_v1 (0.12.0) 151 | google-apis-core (>= 0.9.1, < 2.a) 152 | google-apis-storage_v1 (0.19.0) 153 | google-apis-core (>= 0.9.0, < 2.a) 154 | google-cloud-core (1.6.0) 155 | google-cloud-env (~> 1.0) 156 | google-cloud-errors (~> 1.0) 157 | google-cloud-env (1.6.0) 158 | faraday (>= 0.17.3, < 3.0) 159 | google-cloud-errors (1.3.0) 160 | google-cloud-storage (1.44.0) 161 | addressable (~> 2.8) 162 | digest-crc (~> 0.4) 163 | google-apis-iamcredentials_v1 (~> 0.1) 164 | google-apis-storage_v1 (~> 0.19.0) 165 | google-cloud-core (~> 1.6) 166 | googleauth (>= 0.16.2, < 2.a) 167 | mini_mime (~> 1.0) 168 | googleauth (1.3.0) 169 | faraday (>= 0.17.3, < 3.a) 170 | jwt (>= 1.4, < 3.0) 171 | memoist (~> 0.16) 172 | multi_json (~> 1.11) 173 | os (>= 0.9, < 2.0) 174 | signet (>= 0.16, < 2.a) 175 | highline (2.0.3) 176 | http-cookie (1.0.5) 177 | domain_name (~> 0.5) 178 | httpclient (2.8.3) 179 | jmespath (1.6.2) 180 | json (2.6.3) 181 | jwt (2.6.0) 182 | kramdown (2.4.0) 183 | rexml 184 | kramdown-parser-gfm (1.1.0) 185 | kramdown (~> 2.0) 186 | memoist (0.16.2) 187 | mini_magick (4.12.0) 188 | mini_mime (1.1.2) 189 | multi_json (1.15.0) 190 | multipart-post (2.0.0) 191 | nanaimo (0.3.0) 192 | nap (1.1.0) 193 | naturally (2.2.1) 194 | no_proxy_fix (0.1.2) 195 | octokit (5.6.1) 196 | faraday (>= 1, < 3) 197 | sawyer (~> 0.9) 198 | open4 (1.3.4) 199 | optparse (0.1.1) 200 | os (1.1.4) 201 | plist (3.6.0) 202 | public_suffix (5.0.1) 203 | rake (13.0.6) 204 | rchardet (1.8.0) 205 | representable (3.2.0) 206 | declarative (< 0.1.0) 207 | trailblazer-option (>= 0.1.1, < 0.2.0) 208 | uber (< 0.2.0) 209 | retriable (3.1.2) 210 | rexml (3.2.5) 211 | rouge (2.0.7) 212 | ruby2_keywords (0.0.5) 213 | rubyzip (2.3.2) 214 | sawyer (0.9.2) 215 | addressable (>= 2.3.5) 216 | faraday (>= 0.17.3, < 3) 217 | security (0.1.3) 218 | signet (0.17.0) 219 | addressable (~> 2.8) 220 | faraday (>= 0.17.5, < 3.a) 221 | jwt (>= 1.5, < 3.0) 222 | multi_json (~> 1.10) 223 | simctl (1.6.8) 224 | CFPropertyList 225 | naturally 226 | terminal-notifier (2.0.0) 227 | terminal-table (1.8.0) 228 | unicode-display_width (~> 1.1, >= 1.1.1) 229 | thor (0.20.3) 230 | trailblazer-option (0.1.2) 231 | tty-cursor (0.7.1) 232 | tty-screen (0.8.1) 233 | tty-spinner (0.9.3) 234 | tty-cursor (~> 0.7) 235 | uber (0.1.0) 236 | unf (0.1.4) 237 | unf_ext 238 | unf_ext (0.0.8.2) 239 | unicode-display_width (1.8.0) 240 | webrick (1.7.0) 241 | word_wrap (1.0.0) 242 | xcodeproj (1.22.0) 243 | CFPropertyList (>= 2.3.3, < 4.0) 244 | atomos (~> 0.1.3) 245 | claide (>= 1.0.2, < 2.0) 246 | colored2 (~> 3.1) 247 | nanaimo (~> 0.3.0) 248 | rexml (~> 3.2.4) 249 | xcpretty (0.3.0) 250 | rouge (~> 2.0.7) 251 | xcpretty-travis-formatter (1.0.1) 252 | xcpretty (~> 0.2, >= 0.0.7) 253 | 254 | PLATFORMS 255 | ruby 256 | 257 | DEPENDENCIES 258 | danger 259 | danger-swiftlint 260 | fastlane 261 | 262 | BUNDLED WITH 263 | 2.1.4 264 | -------------------------------------------------------------------------------- /fastlane/screenshots/screenshots.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fastlane/snapshot 5 | 6 | 66 | 67 | 68 |

en-US

69 |
70 | 71 | 72 | 75 | 76 | 77 | 82 | 87 | 92 | 97 | 98 |
73 | iPhone 7 Plus (5.5-Inch) 74 |
78 | 79 | en-US iPhone 7 Plus (5.5-Inch) 80 | 81 | 83 | 84 | en-US iPhone 7 Plus (5.5-Inch) 85 | 86 | 88 | 89 | en-US iPhone 7 Plus (5.5-Inch) 90 | 91 | 93 | 94 | en-US iPhone 7 Plus (5.5-Inch) 95 | 96 |
99 |

sk-SK

100 |
101 | 102 | 103 | 106 | 107 | 108 | 113 | 118 | 123 | 128 | 129 |
104 | iPhone 7 Plus (5.5-Inch) 105 |
109 | 110 | sk-SK iPhone 7 Plus (5.5-Inch) 111 | 112 | 114 | 115 | sk-SK iPhone 7 Plus (5.5-Inch) 116 | 117 | 119 | 120 | sk-SK iPhone 7 Plus (5.5-Inch) 121 | 122 | 124 | 125 | sk-SK iPhone 7 Plus (5.5-Inch) 126 | 127 |
130 |
131 | 132 |
133 |
134 | 235 | 236 | 237 | -------------------------------------------------------------------------------- /Sources/iOSSampleAppTests/Support/RxBlocking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RxBlocking.swift 3 | // TeamwireTests 4 | // 5 | // Created by Igor Kulman on 12/02/2020. 6 | // Copyright © 2020 Teamwire GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | import RxSwift 13 | import Foundation 14 | 15 | extension ObservableConvertibleType { 16 | /// Converts an Observable into a `BlockingObservable` (an Observable with blocking operators). 17 | /// 18 | /// - parameter timeout: Maximal time interval BlockingObservable can block without throwing `RxError.timeout`. 19 | /// - returns: `BlockingObservable` version of `self` 20 | public func toBlocking(timeout: TimeInterval? = nil) -> BlockingObservable { 21 | return BlockingObservable(timeout: timeout, source: self.asObservable()) 22 | } 23 | } 24 | 25 | /** 26 | `BlockingObservable` is a variety of `Observable` that provides blocking operators. 27 | It can be useful for testing and demo purposes, but is generally inappropriate for production applications. 28 | If you think you need to use a `BlockingObservable` this is usually a sign that you should rethink your 29 | design. 30 | */ 31 | public struct BlockingObservable { 32 | let timeout: TimeInterval? 33 | let source: Observable 34 | } 35 | 36 | extension BlockingObservable { 37 | /// Blocks current thread until sequence produces first element. 38 | /// 39 | /// If sequence terminates with error before producing first element, terminating error will be thrown. 40 | /// 41 | /// - returns: First element of sequence. If sequence is empty `nil` is returned. 42 | public func first() throws -> Element? { 43 | let results = self.materializeResult(max: 1) 44 | return try self.elementsOrThrow(results).first 45 | } 46 | } 47 | 48 | 49 | /// The `MaterializedSequenceResult` enum represents the materialized 50 | /// output of a BlockingObservable. 51 | /// 52 | /// If the sequence terminates successfully, the result is represented 53 | /// by `.completed` with the array of elements. 54 | /// 55 | /// If the sequence terminates with error, the result is represented 56 | /// by `.failed` with both the array of elements and the terminating error. 57 | public enum MaterializedSequenceResult { 58 | case completed(elements: [T]) 59 | case failed(elements: [T], error: Error) 60 | } 61 | 62 | extension BlockingObservable { 63 | fileprivate func materializeResult(max: Int? = nil, predicate: @escaping (Element) throws -> Bool = { _ in true }) -> MaterializedSequenceResult { 64 | var elements = [Element]() 65 | var error: Swift.Error? 66 | 67 | let lock = RunLoopLock(timeout: self.timeout) 68 | 69 | let d = SingleAssignmentDisposable() 70 | 71 | defer { 72 | d.dispose() 73 | } 74 | 75 | lock.dispatch { 76 | let subscription = self.source.subscribe { event in 77 | if d.isDisposed { 78 | return 79 | } 80 | switch event { 81 | case .next(let element): 82 | do { 83 | if try predicate(element) { 84 | elements.append(element) 85 | } 86 | if let max = max, elements.count >= max { 87 | d.dispose() 88 | lock.stop() 89 | } 90 | } catch let err { 91 | error = err 92 | d.dispose() 93 | lock.stop() 94 | } 95 | case .error(let err): 96 | error = err 97 | d.dispose() 98 | lock.stop() 99 | case .completed: 100 | d.dispose() 101 | lock.stop() 102 | } 103 | } 104 | 105 | d.setDisposable(subscription) 106 | } 107 | 108 | do { 109 | try lock.run() 110 | } catch let err { 111 | error = err 112 | } 113 | 114 | if let error = error { 115 | return MaterializedSequenceResult.failed(elements: elements, error: error) 116 | } 117 | 118 | return MaterializedSequenceResult.completed(elements: elements) 119 | } 120 | 121 | fileprivate func elementsOrThrow(_ results: MaterializedSequenceResult) throws -> [Element] { 122 | switch results { 123 | case .failed(_, let error): 124 | throw error 125 | case .completed(let elements): 126 | return elements 127 | } 128 | } 129 | } 130 | 131 | #if os(Linux) 132 | import Foundation 133 | #if compiler(>=5.0) 134 | let runLoopMode: RunLoop.Mode = .default 135 | #else 136 | let runLoopMode: RunLoopMode = .defaultRunLoopMode 137 | #endif 138 | 139 | let runLoopModeRaw: CFString = unsafeBitCast(runLoopMode.rawValue._bridgeToObjectiveC(), to: CFString.self) 140 | #else 141 | let runLoopMode: CFRunLoopMode = CFRunLoopMode.defaultMode 142 | let runLoopModeRaw = runLoopMode.rawValue 143 | #endif 144 | 145 | final class RunLoopLock { 146 | let _currentRunLoop: CFRunLoop 147 | 148 | let _calledRun = AtomicInt(0) 149 | let _calledStop = AtomicInt(0) 150 | var _timeout: TimeInterval? 151 | 152 | init(timeout: TimeInterval?) { 153 | self._timeout = timeout 154 | self._currentRunLoop = CFRunLoopGetCurrent() 155 | } 156 | 157 | func dispatch(_ action: @escaping () -> Void) { 158 | CFRunLoopPerformBlock(self._currentRunLoop, runLoopModeRaw) { 159 | if CurrentThreadScheduler.isScheduleRequired { 160 | _ = CurrentThreadScheduler.instance.schedule(()) { _ in 161 | action() 162 | return Disposables.create() 163 | } 164 | } 165 | else { 166 | action() 167 | } 168 | } 169 | CFRunLoopWakeUp(self._currentRunLoop) 170 | } 171 | 172 | func stop() { 173 | if decrement(self._calledStop) > 1 { 174 | return 175 | } 176 | CFRunLoopPerformBlock(self._currentRunLoop, runLoopModeRaw) { 177 | CFRunLoopStop(self._currentRunLoop) 178 | } 179 | CFRunLoopWakeUp(self._currentRunLoop) 180 | } 181 | 182 | func run() throws { 183 | if increment(self._calledRun) != 0 { 184 | fatalError("Run can be only called once") 185 | } 186 | if let timeout = self._timeout { 187 | #if os(Linux) 188 | switch Int(CFRunLoopRunInMode(runLoopModeRaw, timeout, false)) { 189 | case kCFRunLoopRunFinished: 190 | return 191 | case kCFRunLoopRunHandledSource: 192 | return 193 | case kCFRunLoopRunStopped: 194 | return 195 | case kCFRunLoopRunTimedOut: 196 | throw RxError.timeout 197 | default: 198 | fatalError("This failed because `CFRunLoopRunResult` wasn't bridged to Swift.") 199 | } 200 | #else 201 | switch CFRunLoopRunInMode(runLoopMode, timeout, false) { 202 | case .finished: 203 | return 204 | case .handledSource: 205 | return 206 | case .stopped: 207 | return 208 | case .timedOut: 209 | throw RxError.timeout 210 | default: 211 | return 212 | } 213 | #endif 214 | } 215 | else { 216 | CFRunLoopRun() 217 | } 218 | } 219 | } 220 | 221 | import class Foundation.NSLock 222 | 223 | final class AtomicInt: NSLock { 224 | fileprivate var value: Int32 225 | public init(_ value: Int32 = 0) { 226 | self.value = value 227 | } 228 | } 229 | 230 | @discardableResult 231 | @inline(__always) 232 | func add(_ this: AtomicInt, _ value: Int32) -> Int32 { 233 | this.lock() 234 | let oldValue = this.value 235 | this.value += value 236 | this.unlock() 237 | return oldValue 238 | } 239 | 240 | @discardableResult 241 | @inline(__always) 242 | func sub(_ this: AtomicInt, _ value: Int32) -> Int32 { 243 | this.lock() 244 | let oldValue = this.value 245 | this.value -= value 246 | this.unlock() 247 | return oldValue 248 | } 249 | 250 | @discardableResult 251 | @inline(__always) 252 | func fetchOr(_ this: AtomicInt, _ mask: Int32) -> Int32 { 253 | this.lock() 254 | let oldValue = this.value 255 | this.value |= mask 256 | this.unlock() 257 | return oldValue 258 | } 259 | 260 | @inline(__always) 261 | func load(_ this: AtomicInt) -> Int32 { 262 | this.lock() 263 | let oldValue = this.value 264 | this.unlock() 265 | return oldValue 266 | } 267 | 268 | @discardableResult 269 | @inline(__always) 270 | func increment(_ this: AtomicInt) -> Int32 { 271 | return add(this, 1) 272 | } 273 | 274 | @discardableResult 275 | @inline(__always) 276 | func decrement(_ this: AtomicInt) -> Int32 { 277 | return sub(this, 1) 278 | } 279 | 280 | @inline(__always) 281 | func isFlagSet(_ this: AtomicInt, _ mask: Int32) -> Bool { 282 | return (load(this) & mask) != 0 283 | } 284 | -------------------------------------------------------------------------------- /fastlane/SnapshotHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapshotHelper.swift 3 | // Example 4 | // 5 | // Created by Felix Krause on 10/8/15. 6 | // 7 | 8 | // ----------------------------------------------------- 9 | // IMPORTANT: When modifying this file, make sure to 10 | // increment the version number at the very 11 | // bottom of the file to notify users about 12 | // the new SnapshotHelper.swift 13 | // ----------------------------------------------------- 14 | 15 | import Foundation 16 | import XCTest 17 | 18 | var deviceLanguage = "" 19 | var locale = "" 20 | 21 | func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { 22 | Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) 23 | } 24 | 25 | func snapshot(_ name: String, waitForLoadingIndicator: Bool) { 26 | if waitForLoadingIndicator { 27 | Snapshot.snapshot(name) 28 | } else { 29 | Snapshot.snapshot(name, timeWaitingForIdle: 0) 30 | } 31 | } 32 | 33 | /// - Parameters: 34 | /// - name: The name of the snapshot 35 | /// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. 36 | func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { 37 | Snapshot.snapshot(name, timeWaitingForIdle: timeout) 38 | } 39 | 40 | enum SnapshotError: Error, CustomDebugStringConvertible { 41 | case cannotFindSimulatorHomeDirectory 42 | case cannotRunOnPhysicalDevice 43 | 44 | var debugDescription: String { 45 | switch self { 46 | case .cannotFindSimulatorHomeDirectory: 47 | return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." 48 | case .cannotRunOnPhysicalDevice: 49 | return "Can't use Snapshot on a physical device." 50 | } 51 | } 52 | } 53 | 54 | @objcMembers 55 | open class Snapshot: NSObject { 56 | static var app: XCUIApplication? 57 | static var waitForAnimations = true 58 | static var cacheDirectory: URL? 59 | static var screenshotsDirectory: URL? { 60 | return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) 61 | } 62 | 63 | open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { 64 | 65 | Snapshot.app = app 66 | Snapshot.waitForAnimations = waitForAnimations 67 | 68 | do { 69 | let cacheDir = try getCacheDirectory() 70 | Snapshot.cacheDirectory = cacheDir 71 | setLanguage(app) 72 | setLocale(app) 73 | setLaunchArguments(app) 74 | } catch let error { 75 | NSLog(error.localizedDescription) 76 | } 77 | } 78 | 79 | class func setLanguage(_ app: XCUIApplication) { 80 | guard let cacheDirectory = self.cacheDirectory else { 81 | NSLog("CacheDirectory is not set - probably running on a physical device?") 82 | return 83 | } 84 | 85 | let path = cacheDirectory.appendingPathComponent("language.txt") 86 | 87 | do { 88 | let trimCharacterSet = CharacterSet.whitespacesAndNewlines 89 | deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) 90 | app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] 91 | } catch { 92 | NSLog("Couldn't detect/set language...") 93 | } 94 | } 95 | 96 | class func setLocale(_ app: XCUIApplication) { 97 | guard let cacheDirectory = self.cacheDirectory else { 98 | NSLog("CacheDirectory is not set - probably running on a physical device?") 99 | return 100 | } 101 | 102 | let path = cacheDirectory.appendingPathComponent("locale.txt") 103 | 104 | do { 105 | let trimCharacterSet = CharacterSet.whitespacesAndNewlines 106 | locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) 107 | } catch { 108 | NSLog("Couldn't detect/set locale...") 109 | } 110 | 111 | if locale.isEmpty && !deviceLanguage.isEmpty { 112 | locale = Locale(identifier: deviceLanguage).identifier 113 | } 114 | 115 | if !locale.isEmpty { 116 | app.launchArguments += ["-AppleLocale", "\"\(locale)\""] 117 | } 118 | } 119 | 120 | class func setLaunchArguments(_ app: XCUIApplication) { 121 | guard let cacheDirectory = self.cacheDirectory else { 122 | NSLog("CacheDirectory is not set - probably running on a physical device?") 123 | return 124 | } 125 | 126 | let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") 127 | app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] 128 | 129 | do { 130 | let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) 131 | let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) 132 | let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) 133 | let results = matches.map { result -> String in 134 | (launchArguments as NSString).substring(with: result.range) 135 | } 136 | app.launchArguments += results 137 | } catch { 138 | NSLog("Couldn't detect/set launch_arguments...") 139 | } 140 | } 141 | 142 | open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { 143 | if timeout > 0 { 144 | waitForLoadingIndicatorToDisappear(within: timeout) 145 | } 146 | 147 | NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work 148 | 149 | if Snapshot.waitForAnimations { 150 | sleep(1) // Waiting for the animation to be finished (kind of) 151 | } 152 | 153 | #if os(OSX) 154 | guard let app = self.app else { 155 | NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") 156 | return 157 | } 158 | 159 | app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) 160 | #else 161 | 162 | guard self.app != nil else { 163 | NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") 164 | return 165 | } 166 | 167 | let screenshot = XCUIScreen.main.screenshot() 168 | #if os(iOS) && !targetEnvironment(macCatalyst) 169 | let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image 170 | #else 171 | let image = screenshot.image 172 | #endif 173 | 174 | guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } 175 | 176 | do { 177 | // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices 178 | let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") 179 | let range = NSRange(location: 0, length: simulator.count) 180 | simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") 181 | 182 | let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") 183 | #if swift(<5.0) 184 | try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) 185 | #else 186 | try image.pngData()?.write(to: path, options: .atomic) 187 | #endif 188 | } catch let error { 189 | NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") 190 | NSLog(error.localizedDescription) 191 | } 192 | #endif 193 | } 194 | 195 | class func fixLandscapeOrientation(image: UIImage) -> UIImage { 196 | #if os(watchOS) 197 | return image 198 | #else 199 | if #available(iOS 10.0, *) { 200 | let format = UIGraphicsImageRendererFormat() 201 | format.scale = image.scale 202 | let renderer = UIGraphicsImageRenderer(size: image.size, format: format) 203 | return renderer.image { context in 204 | image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) 205 | } 206 | } else { 207 | return image 208 | } 209 | #endif 210 | } 211 | 212 | class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { 213 | #if os(tvOS) 214 | return 215 | #endif 216 | 217 | guard let app = self.app else { 218 | NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") 219 | return 220 | } 221 | 222 | let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element 223 | let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) 224 | _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) 225 | } 226 | 227 | class func getCacheDirectory() throws -> URL { 228 | let cachePath = "Library/Caches/tools.fastlane" 229 | // on OSX config is stored in /Users//Library 230 | // and on iOS/tvOS/WatchOS it's in simulator's home dir 231 | #if os(OSX) 232 | let homeDir = URL(fileURLWithPath: NSHomeDirectory()) 233 | return homeDir.appendingPathComponent(cachePath) 234 | #elseif arch(i386) || arch(x86_64) || arch(arm64) 235 | guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { 236 | throw SnapshotError.cannotFindSimulatorHomeDirectory 237 | } 238 | let homeDir = URL(fileURLWithPath: simulatorHostHome) 239 | return homeDir.appendingPathComponent(cachePath) 240 | #else 241 | throw SnapshotError.cannotRunOnPhysicalDevice 242 | #endif 243 | } 244 | } 245 | 246 | private extension XCUIElementAttributes { 247 | var isNetworkLoadingIndicator: Bool { 248 | if hasAllowListedIdentifier { return false } 249 | 250 | let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) 251 | let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) 252 | 253 | return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize 254 | } 255 | 256 | var hasAllowListedIdentifier: Bool { 257 | let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] 258 | 259 | return allowListedIdentifiers.contains(identifier) 260 | } 261 | 262 | func isStatusBar(_ deviceWidth: CGFloat) -> Bool { 263 | if elementType == .statusBar { return true } 264 | guard frame.origin == .zero else { return false } 265 | 266 | let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) 267 | let newStatusBarSize = CGSize(width: deviceWidth, height: 44) 268 | 269 | return [oldStatusBarSize, newStatusBarSize].contains(frame.size) 270 | } 271 | } 272 | 273 | private extension XCUIElementQuery { 274 | var networkLoadingIndicators: XCUIElementQuery { 275 | let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in 276 | guard let element = evaluatedObject as? XCUIElementAttributes else { return false } 277 | 278 | return element.isNetworkLoadingIndicator 279 | } 280 | 281 | return self.containing(isNetworkLoadingIndicator) 282 | } 283 | 284 | var deviceStatusBars: XCUIElementQuery { 285 | guard let app = Snapshot.app else { 286 | fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") 287 | } 288 | 289 | let deviceWidth = app.windows.firstMatch.frame.width 290 | 291 | let isStatusBar = NSPredicate { (evaluatedObject, _) in 292 | guard let element = evaluatedObject as? XCUIElementAttributes else { return false } 293 | 294 | return element.isStatusBar(deviceWidth) 295 | } 296 | 297 | return self.containing(isStatusBar) 298 | } 299 | } 300 | 301 | private extension CGFloat { 302 | func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { 303 | return numberA...numberB ~= self 304 | } 305 | } 306 | 307 | // Please don't remove the lines below 308 | // They are used to detect outdated configuration files 309 | // SnapshotHelperVersion [1.29] 310 | --------------------------------------------------------------------------------