├── 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 |
99 | sk-SK
100 |
101 |
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 |
--------------------------------------------------------------------------------