├── .github
└── workflows
│ └── ios.yml
├── .gitignore
├── .swiftformat
├── External Resources
├── alert.gif
├── drill_down.gif
├── inline_inception.gif
├── popup_inception.gif
├── present_dismiss.gif
├── push_pop.gif
├── uikit_alert.gif
├── uikit_drilldown.gif
├── uikit_flow_popup.gif
├── uikit_inline_flow.gif
├── uikit_popup.gif
└── uikit_push.gif
├── LICENSE.md
├── README.md
└── SwiftUI Navigation
├── BuildTools
├── Empty.swift
├── Package.resolved
└── Package.swift
├── SwiftUI Navigation.xcodeproj
├── project.pbxproj
└── xcshareddata
│ └── xcschemes
│ ├── SwiftUINavigation.xcscheme
│ └── Tests.xcscheme
├── SwiftUI Navigation
├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── Contents.json
│ │ └── app-icon-gold.jpg
│ └── Contents.json
├── Common
│ ├── Configuration
│ │ └── AppConfiguration.swift
│ ├── Data Providers
│ │ ├── AssetsProvider.swift
│ │ ├── AssetsRatesProvider.swift
│ │ ├── BaseAssetManager.swift
│ │ ├── FavouriteAssetsManager.swift
│ │ └── HistoricalAssetRatesProvider.swift
│ ├── Date Provider
│ │ └── DateProvider.swift
│ ├── Dependency Provider
│ │ └── DependencyProvider.swift
│ ├── Extensions
│ │ ├── BindingExtenstions.swift
│ │ ├── ColorExtensions.swift
│ │ ├── DataExtensions.swift
│ │ ├── DateFormatterExtensions.swift
│ │ ├── EncodableExtensions.swift
│ │ ├── FoundationExtensions.swift
│ │ └── ViewExtensions.swift
│ ├── Networking
│ │ ├── Actions
│ │ │ └── AddApiKeyAction.swift
│ │ ├── NetworkingFactory.swift
│ │ ├── Requests
│ │ │ ├── GetAllAssetsRequest.swift
│ │ │ ├── GetAssetsRatesRequest.swift
│ │ │ └── GetHistoricalAssetRatesRequest.swift
│ │ └── Responses
│ │ │ ├── GetAllAssetsResponse.swift
│ │ │ ├── GetAssetsRatesResponse.swift
│ │ │ └── GetHistoricalAssetRatesResponse.swift
│ ├── Storage
│ │ └── LocalStorage.swift
│ ├── Styles
│ │ ├── ButtonStyles.swift
│ │ ├── CellStyles.swift
│ │ └── TextStyles.swift
│ ├── SwiftUI Helpers
│ │ └── SwiftUINavigationHelpers.swift
│ └── Views
│ │ ├── Add Asset
│ │ ├── AddAssetView.swift
│ │ └── View Model
│ │ │ └── AddAssetViewModel.swift
│ │ ├── App Info
│ │ ├── AppInfoView.swift
│ │ └── View Model
│ │ │ └── AppInfoViewModel.swift
│ │ ├── Asset Details
│ │ ├── AssetDetailsView.swift
│ │ └── View Model
│ │ │ ├── AssetDetailsViewData.swift
│ │ │ └── AssetDetailsViewModel.swift
│ │ ├── AssetCellView.swift
│ │ ├── Assets List
│ │ ├── AssetsListView.swift
│ │ └── View Model
│ │ │ └── AssetsListViewModel.swift
│ │ ├── ChartView.swift
│ │ ├── Edit Asset
│ │ ├── EditAssetView.swift
│ │ └── View Model
│ │ │ ├── EditAssetViewData.swift
│ │ │ └── EditAssetViewModel.swift
│ │ ├── FavouriteAssetCellView.swift
│ │ ├── LoaderView.swift
│ │ ├── PrimaryButton.swift
│ │ └── SwiftUISearchBar.swift
├── Home
│ └── HomeView.swift
├── Model
│ ├── Asset.swift
│ ├── AssetHistoricalRate.swift
│ ├── AssetPerfromance.swift
│ └── AssetPrice.swift
├── Preview Content
│ ├── Preview Assets.xcassets
│ │ └── Contents.json
│ └── PreviewFixtures.swift
├── SwiftUI Router
│ ├── App Flows
│ │ ├── Add Asset
│ │ │ └── SwiftUIRouterAddAssetViewModel.swift
│ │ ├── App Info
│ │ │ └── SwiftUIRouterAppInfoViewModel.swift
│ │ ├── Asset Details
│ │ │ └── SwiftUIRouterAssetDetailsViewModel.swift
│ │ ├── Assets List
│ │ │ └── SwiftUIRouterAssetsListViewModel.swift
│ │ └── Edit Asset
│ │ │ └── SwiftUIRouterEditAssetViewModel.swift
│ ├── Router
│ │ ├── AlertRoute.swift
│ │ ├── NavigationRoute.swift
│ │ ├── PopupRoute.swift
│ │ ├── SwiftUINavigationRouter.swift
│ │ └── SwiftUIRouterHomeView.swift
│ └── SwiftUIRouterHomeViewModel.swift
├── SwiftUINavigationApp.swift
└── UIKit Router
│ ├── Alert
│ ├── AlertPresenter.swift
│ └── DefaultAlertPresenter.swift
│ ├── App Flows
│ ├── Add Asset Flow
│ │ ├── Add Asset
│ │ │ └── UIKitRouterAddAssetViewModel.swift
│ │ ├── AddAssetFlowCoordinator.swift
│ │ └── AddAssetRoute.swift
│ ├── App Info Flow
│ │ ├── App Info
│ │ │ └── UIKitRouterAppInfoViewModel.swift
│ │ ├── AppInfoFlowCoordinator.swift
│ │ └── AppInfoRoute.swift
│ └── Main App Flow
│ │ ├── Asset Details
│ │ └── UIKitRouterAssetDetailsViewModel.swift
│ │ ├── Assets List
│ │ └── UIKitRouterAssetsListViewModel.swift
│ │ ├── Edit Asset
│ │ └── UIKitRouterEditAssetViewModel.swift
│ │ ├── MainAppFlowCoordinator.swift
│ │ └── MainAppRoute.swift
│ ├── Flow Coordinator
│ ├── Component
│ │ ├── ViewComponent.swift
│ │ └── ViewComponentFactory.swift
│ ├── FlowCoordinator.swift
│ ├── FlowCoordinatorFactory.swift
│ ├── Helpers
│ │ ├── NavigationStackChangesHandler.swift
│ │ └── PopupDismissHandler.swift
│ ├── Navigtor
│ │ └── Navigator.swift
│ └── Route
│ │ └── Route.swift
│ ├── RootViewController.swift
│ ├── Router
│ └── UIKitNavigationRouter.swift
│ └── UIKitRouterHomeView.swift
└── SwiftUI NavigationTests
├── Fakes and Mocks
├── FakeAlertPresenter.swift
├── FakeAssetsProvider.swift
├── FakeAssetsRatesProvider.swift
├── FakeDependencyProvider.swift
├── FakeFavouriteAssetsManager.swift
├── FakeFlowCoordinator.swift
├── FakeHistoricalAssetRatesProvider.swift
├── FakeNavigator.swift
├── FakeSwiftUINavigationRouter.swift
├── FakeSwiftUIRouterHomeViewModel.swift
├── FakeUIKitNavigationRouter.swift
└── FakeUINavigationController.swift
├── SwiftUI Router
├── NavigationRouterTests.swift
├── SwiftUIRouterHomeViewTests.swift
└── __Snapshots__
│ └── SwiftUIRouterHomeViewTests
│ ├── iPhone12.SwiftUIRouterNavi_Home_InitialView.png
│ ├── iPhone12.SwiftUIRouterNavi_Home_PresentedAlert.png
│ ├── iPhone12.SwiftUIRouterNavi_Home_PresentedView.png
│ ├── iPhone12.SwiftUIRouterNavi_Home_PushedView.png
│ └── iPhone12.SwiftUIRouterNavi_Home_PushedView_Popped.png
├── Test Helpers
├── SnapshottingExtensions.swift
├── UIAlertControllerExtensions.swift
├── UIApplicationExtensions.swift
├── UIViewControllerExtensions.swift
├── UIViewExtension.swift
└── XCTestCaseSnapshotExtensions.swift
└── UIKit Router
├── DefaultAlertPresenterTests.swift
├── MainAppFlowCoordinatorTests.swift
├── RouteTests.swift
└── UIKitNavigationRouterTests.swift
/.github/workflows/ios.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 | name: Build and Test default scheme using any available iPhone simulator
12 | runs-on: macos-13
13 | steps:
14 | - uses: maxim-lobanov/setup-xcode@v1
15 | with:
16 | xcode-version: '14.3.1'
17 | - name: Checkout
18 | uses: actions/checkout@v3
19 | - name: Test
20 | run: |
21 | xcodebuild test -scheme Tests -project 'SwiftUI Navigation/SwiftUI Navigation.xcodeproj' -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.4' -resultBundlePath TestsResult.xcresult build test | xcpretty && exit ${PIPESTATUS[0]}
22 | - uses: kishikawakatsumi/xcresulttool@v1
23 | if: success() || failure()
24 | with:
25 | path: TestsResult.xcresult
26 | token: ${{ secrets.REPO_TOKEN }}
27 | - name: Archive failed snapshots
28 | if: always()
29 | uses: actions/upload-artifact@v3
30 | with:
31 | name: test-results
32 | path: 'SwiftUI Navigation/failed-snapshots'
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | SwiftUI Navigation/.idea
16 | SwiftUI Navigation/SwiftUI Navigation.xcodeproj/project.xcworkspace
17 | SwiftUI Navigation/failed-snapshots/
18 | *.moved-aside
19 | *.pbxuser
20 | !default.pbxuser
21 | *.mode1v3
22 | !default.mode1v3
23 | *.mode2v3
24 | !default.mode2v3
25 | *.perspectivev3
26 | !default.perspectivev3
27 |
28 | ## Obj-C/Swift specific
29 | *.hmap
30 |
31 | ## App packaging
32 | *.ipa
33 | *.dSYM.zip
34 | *.dSYM
35 |
36 | ## Playgrounds
37 | timeline.xctimeline
38 | playground.xcworkspace
39 |
40 | # Swift Package Manager
41 | #
42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
43 | # Packages/
44 | # Package.pins
45 | # Package.resolved
46 | # *.xcodeproj
47 | #
48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
49 | # hence it is not needed unless you have added a package configuration file to your project
50 | # .swiftpm
51 |
52 | .build/
53 |
54 | # CocoaPods
55 | #
56 | # We recommend against adding the Pods directory to your .gitignore. However
57 | # you should judge for yourself, the pros and cons are mentioned at:
58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
59 | #
60 | # Pods/
61 | #
62 | # Add this line if you want to avoid checking in source code from the Xcode workspace
63 | # *.xcworkspace
64 |
65 | # Carthage
66 | #
67 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
68 | # Carthage/Checkouts
69 |
70 | Carthage/Build/
71 |
72 | # Accio dependency management
73 | Dependencies/
74 | .accio/
75 |
76 | # fastlane
77 | #
78 | # It is recommended to not store the screenshots in the git repo.
79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
80 | # For more information about the recommended setup visit:
81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
82 |
83 | fastlane/report.xml
84 | fastlane/Preview.html
85 | fastlane/screenshots/**/*.png
86 | fastlane/test_output
87 |
88 | # Code Injection
89 | #
90 | # After new code Injection tools there's a generated folder /iOSInjectionProject
91 | # https://github.com/johnno1962/injectionforxcode
92 |
93 | iOSInjectionProject/
94 |
--------------------------------------------------------------------------------
/External Resources/alert.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/alert.gif
--------------------------------------------------------------------------------
/External Resources/drill_down.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/drill_down.gif
--------------------------------------------------------------------------------
/External Resources/inline_inception.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/inline_inception.gif
--------------------------------------------------------------------------------
/External Resources/popup_inception.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/popup_inception.gif
--------------------------------------------------------------------------------
/External Resources/present_dismiss.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/present_dismiss.gif
--------------------------------------------------------------------------------
/External Resources/push_pop.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/push_pop.gif
--------------------------------------------------------------------------------
/External Resources/uikit_alert.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_alert.gif
--------------------------------------------------------------------------------
/External Resources/uikit_drilldown.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_drilldown.gif
--------------------------------------------------------------------------------
/External Resources/uikit_flow_popup.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_flow_popup.gif
--------------------------------------------------------------------------------
/External Resources/uikit_inline_flow.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_inline_flow.gif
--------------------------------------------------------------------------------
/External Resources/uikit_popup.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_popup.gif
--------------------------------------------------------------------------------
/External Resources/uikit_push.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/External Resources/uikit_push.gif
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Pawel Kozielecki
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 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/BuildTools/Empty.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Empty.swift
3 | // KISS Views
4 | //
5 |
6 | // Just to satisfy SPM requirements.
7 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/BuildTools/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "SwiftFormat",
6 | "repositoryURL": "https://github.com/nicklockwood/SwiftFormat",
7 | "state": {
8 | "branch": null,
9 | "revision": "8d3dc46b96d0f19fb44ff14b5e0570a941afe859",
10 | "version": "0.51.2"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/BuildTools/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.1
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "BuildTools",
6 | platforms: [.macOS(.v10_11)],
7 | dependencies: [
8 | .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.7")
9 | ],
10 | targets: [.target(name: "BuildTools", path: "")]
11 | )
12 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation.xcodeproj/xcshareddata/xcschemes/SwiftUINavigation.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 |
14 |
20 |
21 |
22 |
23 |
24 |
30 |
31 |
41 |
43 |
49 |
50 |
51 |
54 |
55 |
56 |
62 |
63 |
69 |
70 |
71 |
72 |
74 |
75 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation.xcodeproj/xcshareddata/xcschemes/Tests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 |
14 |
20 |
21 |
22 |
23 |
24 |
31 |
32 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
54 |
55 |
56 |
57 |
59 |
65 |
66 |
67 |
68 |
69 |
79 |
82 |
83 |
84 |
90 |
91 |
97 |
98 |
99 |
100 |
102 |
103 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-icon-gold.jpg",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Assets.xcassets/AppIcon.appiconset/app-icon-gold.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI Navigation/Assets.xcassets/AppIcon.appiconset/app-icon-gold.jpg
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Configuration/AppConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppConfiguration.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure containing app configuration data.
9 | enum AppConfiguration {
10 |
11 | static let baseURL = URL(string: "https://api.metalpriceapi.com/")!
12 | }
13 |
14 | extension AppConfiguration {
15 |
16 | static var apiKey: String {
17 | fatalError("Set up the https://metalpriceapi.com access token")
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Data Providers/AssetsProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetsProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import ConcurrentNgNetworkModule
7 | import Foundation
8 | import NgNetworkModuleCore
9 |
10 | /// An abstraction providing all available assets.
11 | protocol AssetsProvider: Actor {
12 |
13 | /// Retrieves assets.
14 | ///
15 | /// - Returns: an asset collection.
16 | func getAllAssets() async -> [Asset]
17 | }
18 |
19 | /// A default AssetsProvider implementation.
20 | final actor DefaultAssetsProvider: AssetsProvider {
21 | private let networkModule: NetworkModule
22 |
23 | /// A default initializer for DefaultAssetsProvider.
24 | ///
25 | /// - Parameter networkModule: a networking module.
26 | init(networkModule: NetworkModule = NetworkingFactory.makeNetworkingModule()) {
27 | self.networkModule = networkModule
28 | }
29 |
30 | /// - SeeAlso: AssetsProvider.getAllAssets()
31 | func getAllAssets() async -> [Asset] {
32 | let request = GetAllAssetsRequest()
33 | do {
34 | return try await networkModule.performAndDecode(request: request, responseType: GetAllAssetsResponse.self).assets
35 | } catch {
36 | return []
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Data Providers/AssetsRatesProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetsRatesProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import ConcurrentNgNetworkModule
7 | import Foundation
8 | import NgNetworkModuleCore
9 |
10 | /// An abstraction providing rates of exchange for favourite assets.
11 | protocol AssetsRatesProvider: Actor {
12 |
13 | /// Retrieves exchange rates for assets compared to the exchange base.
14 | ///
15 | /// - Returns: a collection of asset performances.
16 | func getAssetRates() async -> [AssetPerformance]
17 | }
18 |
19 | /// A default AssetsRatesProvider implementation.
20 | final actor DefaultAssetsRatesProvider: AssetsRatesProvider {
21 | private let favouriteAssetsProvider: FavouriteAssetsProvider
22 | private let networkModule: NetworkModule
23 | private let baseAssetProvider: BaseAssetProvider
24 |
25 | /// A default initializer for DefaultAssetsRatesProvider.
26 | ///
27 | /// - Parameter favouriteAssetsProvider: a favourite assets provider.
28 | /// - Parameter networkModule: a networking module.
29 | /// - Parameter baseAssetProvider: a base asset provider.
30 | init(
31 | favouriteAssetsProvider: FavouriteAssetsProvider,
32 | networkModule: NetworkModule = NetworkingFactory.makeNetworkingModule(),
33 | baseAssetProvider: BaseAssetProvider = DefaultBaseAssetManager()
34 | ) {
35 | self.favouriteAssetsProvider = favouriteAssetsProvider
36 | self.networkModule = networkModule
37 | self.baseAssetProvider = baseAssetProvider
38 | }
39 |
40 | /// - SeeAlso: AssetsRatesProvider.getAssetRates()
41 | func getAssetRates() async -> [AssetPerformance] {
42 | let baseAsset = baseAssetProvider.baseAsset
43 | let favouriteAssets = favouriteAssetsProvider.retrieveFavouriteAssets()
44 | let request = GetAssetsRatesRequest(assetIDs: favouriteAssets.map { $0.id }, base: baseAsset.id)
45 | do {
46 | let response = try await networkModule.performAndDecode(request: request, responseType: GetAssetsRatesResponse.self)
47 | return response.rates.map { key, value -> AssetPerformance in
48 | let date = Date(timeIntervalSince1970: response.timestamp)
49 | let price = AssetPrice(value: value, date: date, base: baseAsset)
50 | let assetName = favouriteAssets.filter { $0.id == key }.first?.name ?? ""
51 | return AssetPerformance(id: key, name: assetName, price: price)
52 | }
53 | } catch {
54 | return []
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Data Providers/BaseAssetManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseAssetManager.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An abstraction providing currently selected base asset.
10 | protocol BaseAssetProvider: AnyObject {
11 |
12 | /// A currently selected base asset.
13 | var baseAsset: Asset { get }
14 |
15 | /// Emits value whenever base asset is updated.
16 | var baseAssetUpdated: AnyPublisher { get }
17 | }
18 |
19 | /// An abstraction allowing to set a base asset.
20 | protocol BaseAssetSetter: AnyObject {
21 |
22 | /// Sets base asset.
23 | ///
24 | /// - Parameter asset: an asset to be used as base.
25 | func store(baseAsset asset: Asset)
26 | }
27 |
28 | protocol BaseAssetManager: BaseAssetProvider, BaseAssetSetter {}
29 |
30 | final class DefaultBaseAssetManager: BaseAssetManager {
31 | private let localStorage: LocalStorage
32 | private let baseAssetUpdatedSubject = PassthroughSubject()
33 |
34 | /// - SeeAlso: BaseAssetManager.baseAsset
35 | var baseAsset: Asset {
36 | let storedObject = localStorage.data(forKey: Const.Key)
37 | return storedObject?.decoded(into: Asset.self) ?? Const.USD
38 | }
39 |
40 | /// A DefaultBaseAssetManager initializer.
41 | ///
42 | /// - Parameter localStorage: a local storage.
43 | init(localStorage: LocalStorage = UserDefaults.standard) {
44 | self.localStorage = localStorage
45 | }
46 |
47 | /// - SeeAlso: BaseAssetManager.store(assets:)
48 | func store(baseAsset asset: Asset) {
49 | localStorage.set(asset.data, forKey: Const.Key)
50 | baseAssetUpdatedSubject.send()
51 | }
52 | }
53 |
54 | extension DefaultBaseAssetManager {
55 |
56 | /// - SeeAlso: BaseAssetManager.baseAssetUpdated
57 | var baseAssetUpdated: AnyPublisher {
58 | baseAssetUpdatedSubject.eraseToAnyPublisher()
59 | }
60 | }
61 |
62 | private extension DefaultBaseAssetManager {
63 |
64 | enum Const {
65 | static let Key = "baseAsset"
66 | static let USD = Asset(id: "USD", name: "United States Dollar", colorCode: "FF0000")
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Data Providers/FavouriteAssetsManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FavouriteAssetsManager.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An abstraction providing list of favourite assets.
10 | protocol FavouriteAssetsProvider: AnyObject {
11 |
12 | /// Provides list of favourite assets.
13 | func retrieveFavouriteAssets() -> [Asset]
14 |
15 | /// A publisher notifying about changes in the favourite assets collection.
16 | var didChange: AnyPublisher { get }
17 | }
18 |
19 | /// An abstraction allowing to store favourite assets.
20 | protocol FavouriteAssetsStorage: AnyObject {
21 |
22 | /// Stores assets as favourites.
23 | ///
24 | /// - Parameter assets: a list of assets.
25 | func store(favouriteAssets assets: [Asset])
26 |
27 | /// Clears the list of favourite assets.
28 | func clear()
29 | }
30 |
31 | /// An abstraction managing favourite assets.
32 | protocol FavouriteAssetsManager: FavouriteAssetsProvider, FavouriteAssetsStorage {}
33 |
34 | /// A default FavouriteAssetsManager implementation.
35 | final class DefaultFavouriteAssetsManager: FavouriteAssetsManager {
36 | private let localStorage: LocalStorage
37 | private let didChangeSubject = PassthroughSubject()
38 |
39 | /// A DefaultFavouriteAssetsManager initializer.
40 | ///
41 | /// - Parameter localStorage: a local storage.
42 | init(localStorage: LocalStorage = UserDefaults.standard) {
43 | self.localStorage = localStorage
44 | }
45 |
46 | /// - SeeAlso: FavouriteAssetsManager.retrieveFavouriteAssets()
47 | func retrieveFavouriteAssets() -> [Asset] {
48 | let storedObject = localStorage.data(forKey: Const.Key)
49 | return storedObject?.decoded(into: [Asset].self) ?? []
50 | }
51 |
52 | /// - SeeAlso: FavouriteAssetsManager.store(assets:)
53 | func store(favouriteAssets assets: [Asset]) {
54 | localStorage.set(assets.data, forKey: Const.Key)
55 | didChangeSubject.send(())
56 | }
57 |
58 | /// - SeeAlso: FavouriteAssetsManager.clear()
59 | func clear() {
60 | localStorage.removeObject(forKey: Const.Key)
61 | didChangeSubject.send(())
62 | }
63 | }
64 |
65 | extension DefaultFavouriteAssetsManager {
66 |
67 | /// - SeeAlso: FavouriteAssetsProvider.didChange
68 | var didChange: AnyPublisher {
69 | didChangeSubject.eraseToAnyPublisher()
70 | }
71 | }
72 |
73 | private extension DefaultFavouriteAssetsManager {
74 |
75 | enum Const {
76 | static let Key = "favouriteAssets"
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Data Providers/HistoricalAssetRatesProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HistoricalAssetRatesProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import ConcurrentNgNetworkModule
7 | import Foundation
8 | import NgNetworkModuleCore
9 |
10 | /// An abstraction providing rates of exchange for favourite assets.
11 | protocol HistoricalAssetRatesProvider: Actor {
12 |
13 | /// Retrieves historical exchange rates for an asset compared to the exchange base.
14 | ///
15 | /// - Returns: a collection of asset historical rates.
16 | func getHistoricalRates(for assetID: String, range: ChartView.Scope) async -> [AssetHistoricalRate]
17 | }
18 |
19 | /// A default HistoricalAssetRatesProvider implementation.
20 | final actor DefaultHistoricalAssetRatesProvider: HistoricalAssetRatesProvider {
21 | private let networkModule: NetworkModule
22 | private let baseAssetProvider: BaseAssetProvider
23 | private let dateProvider: DateProvider
24 |
25 | /// A default initializer for DefaultHistoricalAssetRatesProvider.
26 | ///
27 | /// - Parameter networkModule: a networking module.
28 | /// - Parameter baseAssetProvider: a base asset provider.
29 | /// - Parameter dateProvider: a current date provider.
30 | init(
31 | networkModule: NetworkModule = NetworkingFactory.makeNetworkingModule(),
32 | baseAssetProvider: BaseAssetProvider = DefaultBaseAssetManager(),
33 | dateProvider: DateProvider = DefaultDateProvider()
34 | ) {
35 | self.networkModule = networkModule
36 | self.baseAssetProvider = baseAssetProvider
37 | self.dateProvider = dateProvider
38 | }
39 |
40 | /// - SeeAlso: HistoricalAssetRatesProvider.getAssetRates(assetID:range:)
41 | func getHistoricalRates(for assetID: String, range: ChartView.Scope) async -> [AssetHistoricalRate] {
42 | let baseAsset = baseAssetProvider.baseAsset
43 | let rangeDates = calculateStartEndDates(for: range)
44 | let request = GetHistoricalRatesRequest(
45 | assetID: assetID,
46 | base: baseAsset.id,
47 | startDate: rangeDates.0,
48 | endDate: rangeDates.1
49 | )
50 | do {
51 | let response = try await networkModule.performAndDecode(request: request, responseType: GetHistoricalAssetRatesResponse.self)
52 | return response.composeHistoricalAssetRates(for: assetID)
53 | } catch {
54 | return []
55 | }
56 | }
57 | }
58 |
59 | private extension DefaultHistoricalAssetRatesProvider {
60 |
61 | func calculateStartEndDates(for range: ChartView.Scope) -> (Date, Date) {
62 | let currentDate = dateProvider.currentDate()
63 | let endDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate) ?? currentDate
64 |
65 | switch range {
66 | case .week:
67 | let startDate = Calendar.current.date(byAdding: .day, value: -7, to: endDate) ?? endDate
68 | return (startDate, endDate)
69 | case .month:
70 | let startDate = Calendar.current.date(byAdding: .month, value: -1, to: endDate) ?? endDate
71 | return (startDate, endDate)
72 | case .quarter:
73 | let startDate = Calendar.current.date(byAdding: .month, value: -3, to: endDate) ?? endDate
74 | return (startDate, endDate)
75 | case .year:
76 | let startDate = Calendar.current.date(byAdding: .year, value: -1, to: endDate) ?? endDate
77 | return (startDate, endDate)
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Date Provider/DateProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DateProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An abstraction providing current date.
9 | protocol DateProvider {
10 |
11 | /// Provides current date.
12 | ///
13 | /// - Returns: current date.
14 | func currentDate() -> Date
15 | }
16 |
17 | /// Default DateProvider implementation.
18 | final class DefaultDateProvider: DateProvider {
19 |
20 | /// SeeAlso: DateProvider.currentDate()
21 | func currentDate() -> Date {
22 | Date()
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Dependency Provider/DependencyProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DependencyProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An abstraction describing a dependency provider.
9 | protocol DependencyProvider {
10 | var favouriteAssetsManager: FavouriteAssetsManager { get }
11 | var assetsProvider: AssetsProvider { get }
12 | var assetsRatesProvider: AssetsRatesProvider { get }
13 | var historicalAssetRatesProvider: HistoricalAssetRatesProvider { get }
14 | var router: UIKitNavigationRouter { get }
15 | var alertPresenter: AlertPresenter { get }
16 | var rootAppNavigator: Navigator { get }
17 | }
18 |
19 | /// A default implementation of DependencyProvider.
20 | struct DefaultDependencyProvider: DependencyProvider {
21 | let favouriteAssetsManager: FavouriteAssetsManager
22 | let assetsRatesProvider: AssetsRatesProvider
23 | let assetsProvider: AssetsProvider
24 | let historicalAssetRatesProvider: HistoricalAssetRatesProvider
25 | let router: UIKitNavigationRouter
26 | let alertPresenter: AlertPresenter
27 | let rootAppNavigator: Navigator
28 |
29 | /// A default initializer for DefaultDependencyProvider.
30 | init(rootAppNavigator: Navigator) {
31 | let favouriteAssetsManager = DefaultFavouriteAssetsManager()
32 | let networkModule = NetworkingFactory.makeNetworkingModule()
33 | let baseAssetManager = DefaultBaseAssetManager()
34 |
35 | router = DefaultUIKitNavigationRouter()
36 | alertPresenter = DefaultAlertPresenter()
37 | assetsRatesProvider = DefaultAssetsRatesProvider(
38 | favouriteAssetsProvider: favouriteAssetsManager,
39 | networkModule: networkModule,
40 | baseAssetProvider: baseAssetManager
41 | )
42 | historicalAssetRatesProvider = DefaultHistoricalAssetRatesProvider(
43 | networkModule: networkModule,
44 | baseAssetProvider: baseAssetManager
45 | )
46 | assetsProvider = DefaultAssetsProvider(networkModule: networkModule)
47 |
48 | self.favouriteAssetsManager = favouriteAssetsManager
49 | self.rootAppNavigator = rootAppNavigator
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/BindingExtenstions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BindingExtenstions.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | public extension Binding {
9 |
10 | /// A convenience initializer allowing to create a binding to an optional value, with an initial value.
11 | ///
12 | /// - Parameters:
13 | /// - currentValue: a binding to an optional, current value.
14 | /// - initialValue: an initial value. Used if a current value is nil.
15 | init(currentValue: Binding, initialValue: Value) {
16 | self.init(
17 | get: {
18 | currentValue.wrappedValue ?? initialValue
19 | },
20 | set: {
21 | currentValue.wrappedValue = $0
22 | }
23 | )
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/ColorExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ColorExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension Color {
9 |
10 | /// A convenience initializer creating SwiftUI Color from hex value.
11 | /// Source: https://stackoverflow.com/questions/56874133/use-hex-color-in-swiftui
12 | ///
13 | /// - Parameter hex: a color hex value.
14 | init(hex: String) {
15 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
16 | var int: UInt64 = 0
17 | Scanner(string: hex).scanHexInt64(&int)
18 | let a, r, g, b: UInt64
19 | switch hex.count {
20 | case 3: // RGB (12-bit)
21 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
22 | case 6: // RGB (24-bit)
23 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
24 | case 8: // ARGB (32-bit)
25 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
26 | default:
27 | (a, r, g, b) = (1, 1, 1, 0)
28 | }
29 |
30 | self.init(
31 | .sRGB,
32 | red: Double(r) / 255,
33 | green: Double(g) / 255,
34 | blue: Double(b) / 255,
35 | opacity: Double(a) / 255
36 | )
37 | }
38 |
39 | /// A helper method generating hex value of a given Color.
40 | /// Source: https://stackoverflow.com/questions/56874133/use-hex-color-in-swiftui
41 | ///
42 | /// - Returns: a hex value.
43 | func toHex() -> String? {
44 | let uic = UIColor(self)
45 | guard let components = uic.cgColor.components, components.count >= 3 else {
46 | return nil
47 | }
48 | let r = Float(components[0])
49 | let g = Float(components[1])
50 | let b = Float(components[2])
51 | var a = Float(1.0)
52 |
53 | if components.count >= 4 {
54 | a = Float(components[3])
55 | }
56 |
57 | if a != Float(1.0) {
58 | return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255))
59 | } else {
60 | return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255))
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/DataExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | extension Data {
9 |
10 | /// Attempts to decode a encoded object to a provided type.
11 | ///
12 | /// - Parameter type: a type to decode data into.
13 | /// - Returns: a decoded object.
14 | func decoded(into type: T.Type) -> T? {
15 | try? JSONDecoder().decode(type, from: self)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/DateFormatterExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DateFormatterExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | extension DateFormatter {
9 |
10 | /// A full date and time data formatter.
11 | static let fullDateFormatter: DateFormatter = {
12 | let formatter = makeDateFormatter(dateFormat: "yyyy-MM-dd HH:mm:ss")
13 | formatter.timeZone = .current
14 | return formatter
15 | }()
16 |
17 | /// A year, month and day date formatter.
18 | static let dayMonthYearFormatter: DateFormatter = {
19 | let formatter = makeDateFormatter(dateFormat: "yyyy-MM-dd")
20 | formatter.timeZone = .current
21 | return formatter
22 | }()
23 | }
24 |
25 | private extension DateFormatter {
26 |
27 | static func makeDateFormatter(dateFormat: String) -> DateFormatter {
28 | let dateFormatter = DateFormatter()
29 | dateFormatter.dateFormat = dateFormat
30 | return dateFormatter
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/EncodableExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EncodableExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | extension Encodable {
9 |
10 | /// A helper encoding an Encodable type into data.
11 | var data: Data? {
12 | try? JSONEncoder().encode(self)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/FoundationExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FoundationExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A helper filed to check if the app is running unit tests.
9 | var isRunningTests: Bool {
10 | ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
11 | }
12 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Extensions/ViewExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | extension View {
10 |
11 | /// Wraps a SwiftUI View into a UIViewController.
12 | public var viewController: UIViewController {
13 | UIHostingController(rootView: self)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Actions/AddApiKeyAction.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddApiKeyAction.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import NgNetworkModuleCore
8 |
9 | /// A network module action adding API key to an outgoing request as a parameter.
10 | public final class AddApiKeyNetworkModuleAction: NetworkModuleAction {
11 | private let authenticationTokenProvider: AuthenticationTokenProvider
12 |
13 | /// A default AddApiKeyNetworkModuleAction initializer.
14 | ///
15 | /// - Parameter authenticationTokenProvider: an authentication token / API key provider.
16 | public init(authenticationTokenProvider: AuthenticationTokenProvider) {
17 | self.authenticationTokenProvider = authenticationTokenProvider
18 | }
19 |
20 | /// - SeeAlso: NetworkModuleAction.performBeforeExecutingNetworkRequest(request:urlRequest:)
21 | public func performBeforeExecutingNetworkRequest(request: NetworkRequest?, urlRequest: inout URLRequest) {
22 | guard request?.requiresAuthenticationToken == true else {
23 | return
24 | }
25 |
26 | let apiKeyValue = authenticationTokenProvider.authenticationToken
27 | var components = URLComponents(url: urlRequest.url!, resolvingAgainstBaseURL: false)
28 | var queryItems = components?.queryItems ?? []
29 | queryItems.append(.init(name: "api_key", value: apiKeyValue))
30 | components?.queryItems = queryItems
31 | urlRequest.url = components?.url
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/NetworkingFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkingFactory.swift
3 | // KISS Views
4 | //
5 |
6 | import ConcurrentNgNetworkModule
7 | import Foundation
8 | import NgNetworkModuleCore
9 |
10 | /// A networking components factory.
11 | enum NetworkingFactory {
12 |
13 | /// Creates default network client.
14 | ///
15 | /// - Returns: a network client.
16 | static func makeNetworkingModule() -> NetworkModule {
17 | let builder = DefaultRequestBuilder(baseURL: AppConfiguration.baseURL)
18 | let apiKeyAction = AddApiKeyNetworkModuleAction(authenticationTokenProvider: isRunningTests ? "" : AppConfiguration.apiKey)
19 | return DefaultNetworkModule(requestBuilder: builder, actions: [apiKeyAction])
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Requests/GetAllAssetsRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAllAssetsRequest.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import NgNetworkModuleCore
8 |
9 | /// A request fetching all available assets.
10 | struct GetAllAssetsRequest: NetworkRequest {
11 | let path = "v1/symbols"
12 | let method = NetworkRequestType.get
13 | let requiresAuthenticationToken = true
14 | }
15 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Requests/GetAssetsRatesRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAssetsRatesRequest.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import NgNetworkModuleCore
8 |
9 | /// A request fetching selected assets rate.
10 | struct GetAssetsRatesRequest: NetworkRequest {
11 | let path = "v1/latest"
12 | let method = NetworkRequestType.get
13 | let requiresAuthenticationToken = true
14 | let parameters: [String: String]?
15 |
16 | /// A default GetAssetsRatesRequest initializer.
17 | ///
18 | /// - Parameters:
19 | /// - assetIDs: a collection of asset IDs to get rates for.
20 | /// - base: an ID of an asset base to calculate rated for.
21 | init(assetIDs: [String], base: String) {
22 | var parameters = [String: String]()
23 | parameters["base"] = base
24 | parameters["currencies"] = assetIDs.joined(separator: ",")
25 | self.parameters = parameters
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Requests/GetHistoricalAssetRatesRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetHistoricalAssetRatesRequest.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import NgNetworkModuleCore
8 |
9 | /// A request fetching selected asset historical rates.
10 | struct GetHistoricalRatesRequest: NetworkRequest {
11 | let path = "v1/timeframe"
12 | let method = NetworkRequestType.get
13 | let requiresAuthenticationToken = true
14 | let parameters: [String: String]?
15 |
16 | /// A default GetHistoricalAssetRatesRequest initializer.
17 | ///
18 | /// - Parameters:
19 | /// - assetID: an asset ID.
20 | /// - base: an ID of an asset base to calculate rates for.
21 | /// - startDate: a day to start the range.
22 | /// - endDate: a day to finish the range.
23 | init(assetID: String, base: String, startDate: Date, endDate: Date) {
24 | let formatter = DateFormatter.dayMonthYearFormatter
25 | var parameters = [String: String]()
26 | parameters["base"] = base
27 | parameters["currencies"] = assetID
28 | parameters["start_date"] = formatter.string(from: startDate)
29 | parameters["end_date"] = formatter.string(from: endDate)
30 | self.parameters = parameters
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Responses/GetAllAssetsResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAllAssetsResponse.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A response structure for GetAllAssets request.
9 | struct GetAllAssetsResponse: Codable, Equatable {
10 | let symbols: [String: String]
11 | }
12 |
13 | extension GetAllAssetsResponse {
14 |
15 | /// Convenience field converting retrieved assets into app internal format.
16 | var assets: [Asset] {
17 | symbols.map { key, value in
18 | Asset(id: key, name: value, colorCode: nil)
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Responses/GetAssetsRatesResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAssetsRatesResponse.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A response structure for GetAssetsRates request.
9 | struct GetAssetsRatesResponse: Codable, Equatable {
10 | let base: String
11 | let timestamp: Double
12 | let rates: [String: Double]
13 | }
14 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Networking/Responses/GetHistoricalAssetRatesResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetHistoricalAssetRatesResponse.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A response structure for GetHistoricalAssetRates request.
9 | struct GetHistoricalAssetRatesResponse: Codable {
10 | let rates: [String: [String: Double]]
11 | }
12 |
13 | extension GetHistoricalAssetRatesResponse {
14 |
15 | /// A convenience function parsing the response into app data model.
16 | ///
17 | /// - Parameter assetID: an ID of an asset to get rates for.
18 | /// - Returns: a collection of asset historical rates.
19 | func composeHistoricalAssetRates(for assetID: String) -> [AssetHistoricalRate] {
20 | var rates = [AssetHistoricalRate]()
21 | for (date, assetsValues) in self.rates {
22 | if let assetValue = assetsValues[assetID] {
23 | rates.append(AssetHistoricalRate(id: assetID, date: date, value: 1 / assetValue))
24 | }
25 | }
26 | return rates
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Storage/LocalStorage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalStorage.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An abstraction allowing to store & retrieve data from local storage.
9 | protocol LocalStorage {
10 |
11 | /// Retrieves a data value stored under a provided key.
12 | ///
13 | /// - Parameter defaultName: key.
14 | /// - Returns: value.
15 | func data(forKey defaultName: String) -> Data?
16 |
17 | /// Sets a value under a provided key.
18 | ///
19 | /// - Parameters:
20 | /// - value: value to store.
21 | /// - defaultName: key.
22 | func set(_ value: Any?, forKey defaultName: String)
23 |
24 | /// Removed value stored under a provided key.
25 | ///
26 | /// - Parameter defaultName: key.
27 | func removeObject(forKey defaultName: String)
28 |
29 | /// Immediately synchronizes values storage.
30 | ///
31 | /// - Returns: synchronisation status.
32 | func synchronize() -> Bool
33 | }
34 |
35 | extension UserDefaults: LocalStorage {}
36 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Styles/ButtonStyles.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ButtonStyles.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension Button {
9 | func primaryButton() -> some View {
10 | buttonStyle(.borderedProminent)
11 | }
12 |
13 | func plain() -> some View {
14 | buttonStyle(.plain)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Styles/CellStyles.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CellStyles.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct NoInsetsCellModifier: ViewModifier {
9 |
10 | func body(content: Content) -> some View {
11 | content
12 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
13 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
14 | }
15 | }
16 |
17 | extension View {
18 | func noInsetsCell() -> some View {
19 | modifier(NoInsetsCellModifier())
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Styles/TextStyles.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextStyles.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct ViewTitleModifier: ViewModifier {
9 |
10 | func body(content: Content) -> some View {
11 | content
12 | .lineLimit(1)
13 | .minimumScaleFactor(0.5)
14 | .font(.largeTitle)
15 | .fontWeight(.bold)
16 | .padding(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
17 | }
18 | }
19 |
20 | struct ViewDescriptionModifier: ViewModifier {
21 |
22 | func body(content: Content) -> some View {
23 | content
24 | .lineLimit(10)
25 | .multilineTextAlignment(.center)
26 | .font(.body)
27 | }
28 | }
29 |
30 | struct PrimaryButtonLabelModifier: ViewModifier {
31 |
32 | func body(content: Content) -> some View {
33 | content
34 | .lineLimit(1)
35 | .font(.title3)
36 | .fontWeight(.bold)
37 | .padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
38 | }
39 | }
40 |
41 | struct SecondaryButtonLabelModifier: ViewModifier {
42 |
43 | func body(content: Content) -> some View {
44 | content
45 | .lineLimit(1)
46 | .underline()
47 | .font(.headline)
48 | .fontWeight(.medium)
49 | .padding(EdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10))
50 | }
51 | }
52 |
53 | extension Text {
54 | func viewTitle() -> some View {
55 | modifier(ViewTitleModifier())
56 | }
57 |
58 | func viewDescription() -> some View {
59 | modifier(ViewDescriptionModifier())
60 | }
61 |
62 | func primaryButtonLabel() -> some View {
63 | modifier(PrimaryButtonLabelModifier())
64 | }
65 |
66 | func secondaryButtonLabel() -> some View {
67 | modifier(SecondaryButtonLabelModifier())
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/SwiftUI Helpers/SwiftUINavigationHelpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUINavigationHelpers.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension Binding {
9 |
10 | /// A wrapper turning a binding to an enum into a binding of bool.
11 | /// To be used to display alerts with `presenting:` API.
12 | ///
13 | /// - Returns: a binding of a bool value.
14 | func isPresent() -> Binding
15 | where Value == Wrapped? {
16 | .init(
17 | get: {
18 | wrappedValue != nil
19 | },
20 | set: { isPresented in
21 | if !isPresented {
22 | wrappedValue = nil
23 | }
24 | }
25 | )
26 | }
27 | }
28 |
29 | extension View {
30 |
31 | /// A convenience initializer for an alert.
32 | ///
33 | /// - Parameters:
34 | /// - data: a binding to data the alert is presenting. The data must conform to `AlertRoutePresentable` protocol.
35 | /// - confirmationActionCallback: a confirmation action callback. Called when user taps on a `Confirm` button.
36 | /// - cancellationActionCallback: a cancellation action callback. Called when user taps on a `Cancel` button.
37 | /// - Returns: an alert view.
38 | func alert(
39 | presenting data: Binding,
40 | confirmationActionCallback: @escaping (_ alert: T) -> Void,
41 | cancellationActionCallback: ((_ alert: T) -> Void)? = nil
42 | ) -> some View where T: AlertRoutePresentable {
43 | let title = data.wrappedValue?.title ?? ""
44 | let message = data.wrappedValue?.message ?? ""
45 | let confirmationActionTitle = data.wrappedValue?.confirmationActionText ?? "Confirm"
46 | let cancellationActionTitle = data.wrappedValue?.cancellationActionText ?? "Cancel"
47 | return alert(
48 | Text(title),
49 | isPresented: data.isPresent(),
50 | presenting: data.wrappedValue,
51 | actions: { alert in
52 | Button(role: .destructive) {
53 | confirmationActionCallback(alert)
54 | } label: {
55 | Text(confirmationActionTitle)
56 | }
57 | Button(role: .cancel) {
58 | cancellationActionCallback?(alert)
59 | } label: {
60 | Text(cancellationActionTitle)
61 | }
62 | },
63 | message: { _ in
64 | Text(message)
65 | }
66 | )
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Add Asset/AddAssetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddAssetView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AddAssetView: View where ViewModel: AddAssetViewModel {
9 | @StateObject var viewModel: ViewModel
10 |
11 | var body: some View {
12 | ZStack {
13 | if showPreloader {
14 | LoaderView(configuration: .default)
15 | } else if hasAssets {
16 | VStack(alignment: .center) {
17 | List {
18 |
19 | // A search bar section:
20 | SwiftUISearchBar(text: $viewModel.searchPhrase)
21 |
22 | // An assets section:
23 | Section("Assets:") {
24 | ForEach(assetCellsData) { data in
25 | AssetCellView(data: data) { _ in
26 | viewModel.onAssetTapped(id: data.id)
27 | }
28 | .noInsetsCell()
29 | }
30 | if !hasFilteredAssets {
31 | VStack {
32 | Spacer()
33 | Text("Couldn't find any assets matching the search criteria")
34 | Spacer()
35 | PrimaryButton(label: "Back to my assets") {
36 | viewModel.onPopToRootTapped()
37 | }
38 | }
39 | .padding(20)
40 | }
41 | }
42 |
43 | // A footer with add assets button:
44 | PrimaryButton(label: "Add selected \(formattedSelectedAssetsCount)asset(s) to ❤️") {
45 | viewModel.onAssetsSelectionConfirmed()
46 | }
47 | .disabled(viewModel.selectedAssetsIds.isEmpty)
48 | }
49 | .listStyle(.grouped)
50 | }
51 | } else {
52 | VStack {
53 | Spacer()
54 | Text("No assets to show")
55 | Spacer()
56 | PrimaryButton(label: "Back to my assets") {
57 | viewModel.onPopToRootTapped()
58 | }
59 | }
60 | .padding(20)
61 | }
62 | }
63 | }
64 | }
65 |
66 | private extension AddAssetView {
67 |
68 | var selectedAssetsCount: Int {
69 | viewModel.selectedAssetsIds.count
70 | }
71 |
72 | var formattedSelectedAssetsCount: String {
73 | selectedAssetsCount == 0 ? "" : "\(selectedAssetsCount) "
74 | }
75 |
76 | var assetCellsData: [AssetCellView.Data] {
77 | if case let .loaded(assets) = viewModel.viewState {
78 | return assets
79 | }
80 | return []
81 | }
82 |
83 | var hasAssets: Bool {
84 | if case .noAssets = viewModel.viewState {
85 | return false
86 | }
87 | return true
88 | }
89 |
90 | var hasFilteredAssets: Bool {
91 | !assetCellsData.isEmpty
92 | }
93 |
94 | var showPreloader: Bool {
95 | if case .loading = viewModel.viewState {
96 | return true
97 | }
98 | return false
99 | }
100 | }
101 |
102 | struct AddAssetView_Previews: PreviewProvider {
103 | static var previews: some View {
104 | // let viewState = AddAssetViewState.loading
105 | // let viewState = AddAssetViewState.noAssets
106 | let viewState = AddAssetViewState.loaded([
107 | AssetCellView.Data(id: "Au", title: "Gold", isSelected: false),
108 | AssetCellView.Data(id: "Ag", title: "Silver", isSelected: true)
109 | ])
110 | let viewModel = PreviewAddAssetViewModel(state: viewState)
111 | return AddAssetView(viewModel: viewModel)
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Add Asset/View Model/AddAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An enumeration describing Add Asset View state.
10 | enum AddAssetViewState {
11 | case loading
12 | case loaded([AssetCellView.Data])
13 | case noAssets
14 | }
15 |
16 | /// An abstraction describing a View Model for Add Asset View.
17 | protocol AddAssetViewModel: ObservableObject {
18 | /// A view state.
19 | var viewState: AddAssetViewState { get }
20 | var viewStatePublished: Published { get }
21 | var viewStatePublisher: Published.Publisher { get }
22 |
23 | /// A search phrase.
24 | var searchPhrase: String { get set }
25 | var searchPhrasePublished: Published { get }
26 | var searchPhrasePublisher: Published.Publisher { get }
27 |
28 | /// A list of selected assets ids.
29 | var selectedAssetsIds: [String] { get }
30 |
31 | /// Executed on tapping an asset call.
32 | func onAssetTapped(id: String)
33 |
34 | /// Executed on confirming assets selection.
35 | func onAssetsSelectionConfirmed()
36 |
37 | /// Executed on tapping a pop to root button.
38 | func onPopToRootTapped()
39 | }
40 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/App Info/AppInfoView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppInfoView.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import SwiftUI
8 |
9 | struct AppInfoView: View where ViewModel: AppInfoViewModel {
10 | @StateObject var viewModel: ViewModel
11 |
12 | var body: some View {
13 |
14 | VStack(alignment: .center, spacing: 30) {
15 |
16 | Spacer()
17 |
18 | Text("App is up to date")
19 | .viewTitle()
20 |
21 | Text(titleText)
22 | .viewDescription()
23 |
24 | Spacer()
25 |
26 | if isUpdateAvailable {
27 | PrimaryButton(label: "Update now") {
28 | viewModel.appUpdateTapped()
29 | }
30 | } else {
31 | PrimaryButton(label: "Add an asset") {
32 | viewModel.addAssetTapped()
33 | }
34 | }
35 |
36 | }.padding(20)
37 | }
38 | }
39 |
40 | private extension AppInfoView {
41 |
42 | var titleText: String {
43 | if let availableVersion = availableVersion {
44 | return "App update available: \(availableVersion)\nCurrent version: \(currentVersion)"
45 | }
46 | return "Current version: \(currentVersion)"
47 | }
48 |
49 | var isUpdateAvailable: Bool {
50 | availableVersion != nil
51 | }
52 |
53 | var currentVersion: String {
54 | switch viewModel.viewState {
55 | case let .appUpToDate(currentVersion), let .appUpdateAvailable(currentVersion, _):
56 | return currentVersion
57 | }
58 | }
59 |
60 | var availableVersion: String? {
61 | if case let .appUpdateAvailable(_, availableVersion) = viewModel.viewState {
62 | return availableVersion
63 | }
64 | return nil
65 | }
66 | }
67 |
68 | struct AppInfoView_Previews: PreviewProvider {
69 | static var previews: some View {
70 | let viewState = AppInfoViewState.appUpToDate(currentVersion: "0.9")
71 | let viewModel = PreviewAppInfoViewModel(state: viewState)
72 | return AppInfoView(viewModel: viewModel)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/App Info/View Model/AppInfoViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppInfoViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import SwiftUI
8 |
9 | /// A view state for AppInfoView.
10 | enum AppInfoViewState: Equatable {
11 |
12 | /// App is up to date.
13 | case appUpToDate(currentVersion: String)
14 |
15 | /// App update is available.
16 | case appUpdateAvailable(currentVersion: String, availableVersion: String)
17 | }
18 |
19 | /// An abstraction describing AppInfo view model.
20 | protocol AppInfoViewModel: ObservableObject {
21 | /// A view state.
22 | var viewState: AppInfoViewState { get }
23 | var viewStatePublished: Published { get }
24 | var viewStatePublisher: Published.Publisher { get }
25 |
26 | /// Triggered when user taps on add asset button.
27 | func addAssetTapped()
28 |
29 | /// Triggered when user taps on app update button.
30 | func appUpdateTapped()
31 | }
32 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Asset Details/AssetDetailsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetDetailsView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AssetDetailsView: View where ViewModel: AssetDetailsViewModel {
9 |
10 | @StateObject var viewModel: ViewModel
11 |
12 | @State private var scope: String?
13 |
14 | var body: some View {
15 |
16 | List {
17 | Section {
18 | HStack {
19 | // Asset name label:
20 | Text(assetData.name)
21 | .viewTitle()
22 |
23 | Spacer()
24 |
25 | // Edit asset button:
26 | Button {
27 | viewModel.edit(asset: assetData.id)
28 | } label: {
29 | Image(systemName: "pencil.line")
30 | }
31 | }
32 | }
33 |
34 | Section {
35 | if let chartData {
36 |
37 | // Chart view:
38 | // TODO: Fix date labels!
39 | // TODO: Fix Y axis
40 | ChartView(data: chartData, xAxisName: "Data", yAxisName: "Price")
41 | .frame(height: 200)
42 | .padding(.top, 10)
43 |
44 | // Chart scope selector:
45 | Picker("", selection: .init(currentValue: $scope, initialValue: ChartView.Scope.week.rawValue)) {
46 | ForEach(ChartView.Scope.allCases, id: \.rawValue) { scope in
47 | Text("\(scope.rawValue)")
48 | }
49 | }
50 | .pickerStyle(.segmented)
51 | .padding(.bottom, 10)
52 |
53 | } else if let error {
54 |
55 | VStack(spacing: 20) {
56 | Spacer()
57 |
58 | // Error description:
59 | Text(error)
60 | .viewDescription()
61 |
62 | Spacer()
63 |
64 | // Try again button:
65 | PrimaryButton(label: "Try again?") {
66 | print("try again?")
67 | }
68 |
69 | Spacer()
70 | }
71 |
72 | } else {
73 |
74 | // A chart loader view:
75 | LoaderView(configuration: .chartLoader)
76 | }
77 | }
78 | }
79 | .onChange(of: scope) { scope in
80 | if let scope, let chartScope = ChartView.Scope(rawValue: scope) {
81 | Task {
82 | await viewModel.reloadChart(scope: chartScope)
83 | }
84 | }
85 | }
86 | .onAppear {
87 | Task {
88 | await viewModel.showInitialChart()
89 | }
90 | }
91 | }
92 | }
93 |
94 | private extension AssetDetailsView {
95 |
96 | var assetData: AssetDetailsViewData {
97 | viewModel.assetData
98 | }
99 |
100 | var chartData: [ChartView.ChartPoint]? {
101 | if case let .loaded(points) = viewModel.viewState {
102 | return points
103 | }
104 | return nil
105 | }
106 |
107 | var error: String? {
108 | if case let .failed(error) = viewModel.viewState {
109 | return error
110 | }
111 | return nil
112 | }
113 | }
114 |
115 | struct AssetDetailsView_Previews: PreviewProvider {
116 | static var previews: some View {
117 | let viewState = AssetDetailsViewState.loading
118 | // let viewState = AssetDetailsViewState.loaded([
119 | // .init(label: "01/2023", value: 10123),
120 | // .init(label: "02/2023", value: 15000),
121 | // .init(label: "03/2023", value: 13000),
122 | // .init(label: "04/2023", value: 17000),
123 | // .init(label: "05/2023", value: 20000)
124 | // ])
125 | // let viewState = AssetDetailsViewState.failed("An unknown network error has occurred\nTry again later.")
126 | let viewModel = PreviewAssetDetailsViewModel(state: viewState)
127 | return AssetDetailsView(viewModel: viewModel)
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Asset Details/View Model/AssetDetailsViewData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetDetailsViewData.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure describing data shown by Asset Details View.
9 | struct AssetDetailsViewData: Identifiable, Hashable {
10 |
11 | /// An asset id.
12 | let id: String
13 |
14 | /// An asset name.
15 | let name: String
16 | }
17 |
18 | extension AssetDetailsViewData {
19 |
20 | /// An empty asset data:
21 | static var empty: AssetDetailsViewData {
22 | .init(id: "", name: "Unknown asset")
23 | }
24 | }
25 |
26 | extension Asset {
27 |
28 | /// A convenience method converting an asset into AssetDetailsViewData.
29 | func toAssetDetailsViewData() -> AssetDetailsViewData {
30 | AssetDetailsViewData(id: id, name: name)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Asset Details/View Model/AssetDetailsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetDetailsViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An enumeration describing AssetDetailsView state.
10 | enum AssetDetailsViewState {
11 | case loading
12 | case loaded([ChartView.ChartPoint])
13 | case failed(String)
14 | }
15 |
16 | /// An abstraction describing a View Model for AssetDetailsView.
17 | protocol AssetDetailsViewModel: ObservableObject {
18 | /// A view state.
19 | var viewState: AssetDetailsViewState { get }
20 | var viewStatePublished: Published { get }
21 | var viewStatePublisher: Published.Publisher { get }
22 |
23 | /// A basic asset data.
24 | var assetData: AssetDetailsViewData { get }
25 |
26 | /// Triggers editing of an asset.
27 | ///
28 | /// - Parameter assetID: a selected asset ID.
29 | func edit(asset assetID: String)
30 |
31 | /// Triggers reloading of a chart.
32 | ///
33 | /// - Parameter scope: a selected chart timing scope.
34 | func reloadChart(scope: ChartView.Scope) async
35 |
36 | /// Shows initial asset performance chart.
37 | func showInitialChart() async
38 | }
39 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/AssetCellView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetCellView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AssetCellView: View {
9 | let data: AssetCellView.Data
10 | let onSelectTapped: ((String) -> Void)?
11 |
12 | var body: some View {
13 | ZStack(alignment: .leading) {
14 |
15 | Color(data.isSelected ? .lightGray : .clear)
16 | .animation(.easeInOut(duration: 0.2), value: data.isSelected)
17 |
18 | Button(action: {
19 | onSelectTapped?(data.id)
20 | }, label: {
21 | HStack {
22 | Text(data.id)
23 | .padding(.leading, 20)
24 | .frame(minWidth: 60)
25 | .fontWeight(.bold)
26 |
27 | Text(data.title)
28 | .lineLimit(1)
29 |
30 | Spacer()
31 | }
32 | .background(.secondary.opacity(0.0001))
33 | })
34 | .plain()
35 | }
36 | }
37 | }
38 |
39 | extension AssetCellView {
40 |
41 | struct Data: Hashable, Identifiable {
42 | let id: String
43 | let title: String
44 | let isSelected: Bool
45 | }
46 | }
47 |
48 | struct AssetCellView_Previews: PreviewProvider {
49 | static var previews: some View {
50 | AssetCellView(
51 | data: .init(id: "AU", title: "Gold", isSelected: false),
52 | onSelectTapped: nil
53 | ).frame(width: .infinity, height: 50)
54 | AssetCellView(
55 | data: .init(id: "AU", title: "Gold", isSelected: true),
56 | onSelectTapped: nil
57 | )
58 | .frame(width: .infinity, height: 50)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Assets List/AssetsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetsListView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AssetsListView: View {
9 | @StateObject var viewModel: ViewModel
10 |
11 | var body: some View {
12 | ZStack {
13 | if hasNoAssets {
14 | VStack(spacing: 30) {
15 | Spacer()
16 |
17 | Text("No favourite assets")
18 | .viewTitle()
19 |
20 | Text("Select your favourite assets to check their exchange rates!")
21 | .viewDescription()
22 |
23 | Spacer()
24 |
25 | PrimaryButton(
26 | label: "Select favourite assets",
27 | onTapCallback: viewModel.onAddNewAssetTapped
28 | )
29 | }
30 | .padding(20)
31 | } else {
32 | List {
33 | Section(header:
34 | HStack(alignment: .center) {
35 | Text("Your asssets")
36 | Spacer()
37 | Button {
38 | viewModel.onAppInfoTapped()
39 | } label: {
40 | Image(systemName: "info.circle.fill")
41 | }
42 | Button {
43 | viewModel.onAddNewAssetTapped()
44 | } label: {
45 | Image(systemName: "plus.circle.fill")
46 | }
47 | .padding(.top, 10)
48 | .padding(.bottom, 10)
49 | }, footer:
50 | Text("Last updated: \(lastUpdated)")
51 | ) {
52 | ForEach(assets) { data in
53 | FavouriteAssetCellView(
54 | data: data,
55 | onSelectTapped: onAssetSelected,
56 | onEditTapped: onEditAssetSelected,
57 | onDeleteTapped: onAssetRemovalRequest
58 | )
59 | .noInsetsCell()
60 | }
61 | }
62 | }
63 | .navigationTitle("Assets list")
64 | .refreshable {
65 | viewModel.onRefreshRequested()
66 | }
67 | }
68 | }
69 | }
70 | }
71 |
72 | private extension AssetsListView {
73 |
74 | var hasNoAssets: Bool {
75 | if case .noFavouriteAssets = viewModel.viewState {
76 | return true
77 | }
78 | return false
79 | }
80 |
81 | var assets: [FavouriteAssetCellView.Data] {
82 | switch viewModel.viewState {
83 | case let .loaded(assets, _), let .loading(assets):
84 | return assets
85 | default:
86 | return []
87 | }
88 | }
89 |
90 | var lastUpdated: String {
91 | switch viewModel.viewState {
92 | case let .loaded(_, date):
93 | return date
94 | default:
95 | return ""
96 | }
97 | }
98 |
99 | func onAssetSelected(id: String) {
100 | viewModel.onAssetSelected(id: id)
101 | }
102 |
103 | func onEditAssetSelected(id: String) {
104 | viewModel.onAssetSelectedToBeEdited(id: id)
105 | }
106 |
107 | func onAssetRemovalRequest(id: String) {
108 | viewModel.onAssetSelectedForRemoval(id: id)
109 | }
110 | }
111 |
112 | struct AssetsListView_Previews: PreviewProvider {
113 | static var previews: some View {
114 | // let state = AssetsListViewState.noFavouriteAssets
115 | // let state = AssetsListViewState.loading([.init(id: "EUR", title: "Euro", value: nil), .init(id: "BTC", title: "Bitcoin", value: nil)])
116 | let state = AssetsListViewState.loaded([.init(id: "EUR", title: "Euro", color: .primary, value: "1.2"), .init(id: "BTC", title: "Bitcoin", color: .primary, value: "28872")], "2023-05-10 12:30:12")
117 | AssetsListView(viewModel: PreviewAssetsListViewModel(state: state))
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Assets List/View Model/AssetsListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetsListViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An enumeration describing Add Asset View state.
10 | enum AssetsListViewState {
11 | case noFavouriteAssets
12 | case loading([FavouriteAssetCellView.Data])
13 | case loaded([FavouriteAssetCellView.Data], String)
14 | }
15 |
16 | /// An abstraction describing a View Model for .
17 | protocol AssetsListViewModel: ObservableObject {
18 | /// A view state.
19 | var viewState: AssetsListViewState { get }
20 | var viewStatePublished: Published { get }
21 | var viewStatePublisher: Published.Publisher { get }
22 |
23 | /// Triggered on manual refresh requested.
24 | func onRefreshRequested()
25 |
26 | /// Triggerred on tapping Add Asset button.
27 | func onAddNewAssetTapped()
28 |
29 | /// Triggered on tapping App Info button.
30 | func onAppInfoTapped()
31 |
32 | /// Triggered on selecting an asset cell.
33 | ///
34 | /// - Parameter id: a selected asset id.
35 | func onAssetSelected(id: String)
36 |
37 | /// Triggered when user confirmed removal of an asset
38 | ///
39 | /// - Parameter id: an asset id.
40 | func removeAssetFromFavourites(id: String)
41 |
42 | /// Triggered on selecting an asset to be edited.
43 | ///
44 | /// - Parameter id: a selected asset id.
45 | func onAssetSelectedToBeEdited(id: String)
46 |
47 | /// Triggered on selecting an asset for removal.
48 | ///
49 | /// - Parameter id: a selected asset id.
50 | func onAssetSelectedForRemoval(id: String)
51 | }
52 |
53 | extension FavouriteAssetCellView.Data {
54 | init(asset: Asset) {
55 | self.init(id: asset.id, title: asset.name, color: asset.color, value: "...")
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/ChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartView.swift
3 | // KISS Views
4 | //
5 |
6 | import Charts
7 | import SwiftUI
8 |
9 | struct ChartView: View {
10 | let data: [ChartPoint]
11 | let xAxisName: String
12 | let yAxisName: String
13 |
14 | var body: some View {
15 | Chart(data) {
16 | LineMark(
17 | x: .value(xAxisName, $0.label),
18 | y: .value(yAxisName, $0.value)
19 | )
20 | PointMark(
21 | x: .value(xAxisName, $0.label),
22 | y: .value(yAxisName, $0.value)
23 | )
24 | }
25 | }
26 | }
27 |
28 | extension ChartView {
29 |
30 | /// A helper structure describing a point on a chart.
31 | struct ChartPoint: Identifiable {
32 |
33 | /// A point unique ID.
34 | var id = UUID()
35 |
36 | /// A point label on X axis.
37 | let label: String
38 |
39 | /// A point value.
40 | let value: Double
41 | }
42 |
43 | /// A helper enumeration describing a chart time scopes.
44 | enum Scope: String, CaseIterable {
45 | case week, month, quarter, year
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Edit Asset/EditAssetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditAssetView.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | struct EditAssetView: View where ViewModel: EditAssetViewModel {
10 | @StateObject var viewModel: ViewModel
11 |
12 | @State private var assetName: String?
13 | @State private var assetColor: Color?
14 | @State private var assetPosition: Int?
15 |
16 | var body: some View {
17 |
18 | if let assetData {
19 |
20 | VStack(alignment: .center, spacing: 30) {
21 |
22 | Spacer()
23 |
24 | Text("Editing: \(assetData.id)")
25 | .viewTitle()
26 |
27 | Text("Change asset name, position and color")
28 | .viewDescription()
29 |
30 | Divider()
31 | .padding(.vertical, 30)
32 |
33 | HStack {
34 | Text("Asset name:")
35 | TextField(text: .init(currentValue: $assetName, initialValue: assetData.name), label: {
36 | Text("Enter asset name")
37 | })
38 | .textFieldStyle(.roundedBorder)
39 | }
40 |
41 | ColorPicker(selection: .init(currentValue: $assetColor, initialValue: assetData.color), supportsOpacity: false) {
42 | Text("Choose asset color:")
43 | }
44 |
45 | VStack(alignment: .leading) {
46 | Text("Choose asset position:")
47 | Picker("", selection: .init(currentValue: $assetPosition, initialValue: assetData.position.currentPosition)) {
48 | ForEach(1...assetData.position.numElements, id: \.self) { position in
49 | Text("\(position)")
50 | }
51 | }
52 | .pickerStyle(.segmented)
53 | }
54 |
55 | Spacer()
56 |
57 | // A footer with add assets button:
58 | PrimaryButton(label: "Save changes") {
59 | if let updatedAssetData {
60 | viewModel.saveChanges(assetData: updatedAssetData)
61 | }
62 | }
63 | .disabled(!isChanged)
64 |
65 | }.padding(20)
66 |
67 | } else {
68 | VStack(alignment: .center, spacing: 30) {
69 | Spacer()
70 | Text("Asset not found")
71 | Spacer()
72 | }
73 | }
74 | }
75 | }
76 |
77 | private extension EditAssetView {
78 |
79 | var isChanged: Bool {
80 | assetName != nil || assetColor != nil || assetPosition != nil
81 | }
82 |
83 | var assetData: EditAssetViewData? {
84 | if case let .editAsset(data) = viewModel.viewState {
85 | return data
86 | }
87 | return nil
88 | }
89 |
90 | var notFound: Bool {
91 | if case .assetNotFound = viewModel.viewState {
92 | return true
93 | }
94 | return false
95 | }
96 |
97 | var updatedAssetData: EditAssetViewData? {
98 | guard let assetData else { return nil }
99 |
100 | let position = assetPosition ?? assetData.position.currentPosition
101 | return EditAssetViewData(
102 | id: assetData.id,
103 | name: assetName ?? assetData.name,
104 | position: .init(currentPosition: position, numElements: assetData.position.numElements),
105 | color: assetColor ?? assetData.color
106 | )
107 | }
108 | }
109 |
110 | struct EditAssetView_Previews: PreviewProvider {
111 | static var previews: some View {
112 | // let viewState = EditAssetViewState.assetNotFound
113 | let viewState = EditAssetViewState.editAsset(.init(id: "AU", name: "Gold", position: .init(currentPosition: 2, numElements: 5), color: .white))
114 | let viewModel = PreviewEditAssetViewModel(state: viewState)
115 | return EditAssetView(viewModel: viewModel)
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Edit Asset/View Model/EditAssetViewData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditAssetViewData.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | /// A structure representing an edited asset properties.
9 | struct EditAssetViewData: Hashable, Identifiable {
10 | /// An asset id.
11 | let id: String
12 |
13 | /// An asset name.
14 | let name: String
15 |
16 | /// An asset position on the assets list.
17 | let position: Position
18 |
19 | /// An asset color.
20 | let color: Color
21 | }
22 |
23 | extension EditAssetViewData {
24 |
25 | /// An helper structure representing asset position on favourite assets list.
26 | struct Position: Hashable {
27 | let currentPosition: Int
28 | let numElements: Int
29 | }
30 |
31 | /// A convenience initializer for EditViewAssetData.
32 | ///
33 | /// - Parameters:
34 | /// - asset: an asset to use as a base.
35 | /// - position: an asset position in favourite assets list.
36 | /// - totalAssetCount: a total number of assets.
37 | init(asset: Asset, position: Int, totalAssetCount: Int) {
38 | self.init(
39 | id: asset.id,
40 | name: asset.name,
41 | position: .init(currentPosition: position + 1, numElements: totalAssetCount),
42 | color: asset.color
43 | )
44 | }
45 | }
46 |
47 | extension EditAssetViewData.Position: Identifiable {
48 |
49 | /// An identifiable property of an asset.
50 | var id: String {
51 | "\(currentPosition)\(numElements)"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/Edit Asset/View Model/EditAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// An enumeration describing EditAssetView state.
10 | enum EditAssetViewState {
11 | case editAsset(EditAssetViewData)
12 | case assetNotFound
13 | }
14 |
15 | /// An abstraction describing a View Model for EditAssetView.
16 | protocol EditAssetViewModel: ObservableObject {
17 | /// A view state.
18 | var viewState: EditAssetViewState { get }
19 | var viewStatePublished: Published { get }
20 | var viewStatePublisher: Published.Publisher { get }
21 |
22 | /// Saves current changes.
23 | ///
24 | /// - Parameter assetData: an asset data.
25 | func saveChanges(assetData: EditAssetViewData)
26 | }
27 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/FavouriteAssetCellView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FavouriteAssetCellView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct FavouriteAssetCellView: View {
9 | let data: FavouriteAssetCellView.Data
10 | let onSelectTapped: ((String) -> Void)?
11 | let onEditTapped: ((String) -> Void)?
12 | let onDeleteTapped: ((String) -> Void)?
13 |
14 | var body: some View {
15 | Button(action: {
16 | onSelectTapped?(data.id)
17 | }, label: {
18 | HStack {
19 | Text(data.id)
20 | .padding(.leading, 20)
21 | .frame(minWidth: 60)
22 | .fontWeight(.bold)
23 |
24 | Text(data.title)
25 | .lineLimit(1)
26 | Spacer()
27 | if let value = data.value {
28 | Text(value)
29 | .padding(.trailing, 20)
30 | .fontWeight(.heavy)
31 | }
32 | }
33 | .background(.secondary.opacity(0.0001))
34 | .foregroundColor(data.color)
35 | })
36 | .plain()
37 | .swipeActions {
38 | Button {
39 | onDeleteTapped?(data.id)
40 | } label: {
41 | Image(systemName: "trash")
42 | }
43 | .tint(.red)
44 |
45 | Button {
46 | onEditTapped?(data.id)
47 | } label: {
48 | Image(systemName: "pencil")
49 | }
50 | .tint(.green)
51 | }
52 | }
53 | }
54 |
55 | extension FavouriteAssetCellView {
56 |
57 | struct Data: Identifiable, Hashable, Equatable {
58 | let id: String
59 | let title: String
60 | let color: Color
61 | let value: String?
62 | }
63 | }
64 |
65 | extension FavouriteAssetCellView.Data {
66 |
67 | /// A convenience initializer for FavouriteAssetCellData.
68 | ///
69 | /// - Parameters:
70 | /// - asset: an asset to use as a base.
71 | /// - formattedValue: a current, formatted asset valuation.
72 | init(asset: Asset, formattedValue: String?) {
73 | self.init(
74 | id: asset.id,
75 | title: asset.name,
76 | color: asset.color,
77 | value: formattedValue
78 | )
79 | }
80 | }
81 |
82 | struct FavouriteAssetCellView_Previews: PreviewProvider {
83 | static var previews: some View {
84 | FavouriteAssetCellView(
85 | data: .init(id: "AU", title: "Gold", color: .primary, value: "3.4"),
86 | onSelectTapped: nil,
87 | onEditTapped: nil,
88 | onDeleteTapped: nil
89 | )
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/LoaderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoaderView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension LoaderView {
9 |
10 | /// A configuration data for Loader View.
11 | struct Configuration {
12 |
13 | /// A message to show while loading.
14 | let message: String
15 |
16 | /// A loader width.
17 | let width: Double
18 |
19 | /// A loader height.
20 | let height: Double
21 |
22 | /// A loader background color.
23 | let backgroundColor: Color
24 |
25 | /// A loader background corner radius.
26 | let cornerRadius: Double
27 | }
28 | }
29 |
30 | struct LoaderView: View {
31 | let configuration: Configuration
32 |
33 | var body: some View {
34 | ZStack(alignment: .center) {
35 | ProgressView(configuration.message)
36 | .tint(.primary)
37 | .scaleEffect(2)
38 | .font(.caption)
39 | .foregroundColor(.primary)
40 | }
41 | .frame(width: configuration.width, height: configuration.height)
42 | .background(configuration.backgroundColor)
43 | .cornerRadius(configuration.cornerRadius)
44 | }
45 | }
46 |
47 | struct LoaderView_Previews: PreviewProvider {
48 | static var previews: some View {
49 | LoaderView(configuration: .default)
50 | }
51 | }
52 |
53 | extension LoaderView.Configuration {
54 |
55 | /// A default Loader View configuration.
56 | static var `default`: LoaderView.Configuration {
57 | .init(
58 | message: "Loading...",
59 | width: 200,
60 | height: 150,
61 | backgroundColor: .secondary.opacity(0.2),
62 | cornerRadius: 10
63 | )
64 | }
65 |
66 | /// A Loader View configuration to be used for loading chart data.
67 | static var chartLoader: LoaderView.Configuration {
68 | .init(
69 | message: "Loading...",
70 | width: 800,
71 | height: 150,
72 | backgroundColor: .clear,
73 | cornerRadius: 0
74 | )
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/PrimaryButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrimaryButton.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct PrimaryButton: View {
9 | let label: String
10 | let onTapCallback: (() -> Void)?
11 |
12 | var body: some View {
13 | Button {
14 | onTapCallback?()
15 | } label: {
16 | Text(label)
17 | .primaryButtonLabel()
18 | .frame(maxWidth: .infinity)
19 | }
20 | .primaryButton()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Common/Views/SwiftUISearchBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUISearchBar.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | /// A UIKit Searchbar wrapped in SwiftUI View
10 | /// - SeeAlso: https://axelhodler.medium.com/creating-a-search-bar-for-swiftui-e216fe8c8c7f
11 | struct SwiftUISearchBar: UIViewRepresentable {
12 | @Binding var text: String
13 |
14 | class Coordinator: NSObject, UISearchBarDelegate {
15 | @Binding var text: String
16 |
17 | init(text: Binding) {
18 | _text = text
19 | }
20 |
21 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
22 | text = searchText
23 | }
24 | }
25 |
26 | func makeCoordinator() -> SwiftUISearchBar.Coordinator {
27 | Coordinator(text: $text)
28 | }
29 |
30 | func makeUIView(context: UIViewRepresentableContext) -> UISearchBar {
31 | let searchBar = UISearchBar(frame: .zero)
32 | searchBar.delegate = context.coordinator
33 | searchBar.searchBarStyle = .minimal
34 | return searchBar
35 | }
36 |
37 | func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) {
38 | uiView.text = text
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Home/HomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | // private var dependencyProvider: DependencyProvider?
9 |
10 | struct HomeView: View {
11 | @State private var isSwiftUINaviPresented = false
12 | @State private var isUIKitNaviPresented = false
13 |
14 | var body: some View {
15 | VStack(spacing: 30) {
16 | Spacer()
17 | Text("SwiftUI\nNavigation Showcase")
18 | .font(.title)
19 | .multilineTextAlignment(.center)
20 | .bold()
21 | Text("Tap a link to explore one of the options:")
22 | Spacer()
23 | VStack(spacing: 15) {
24 | Divider()
25 | Button("Nav Stack + Router SwiftUI navigation") {
26 | isSwiftUINaviPresented.toggle()
27 | }
28 | .fullScreenCover(isPresented: $isSwiftUINaviPresented) {
29 | let router = DefaultSwiftUINavigationRouter()
30 | SwiftUIRouterHomeView(
31 | viewModel: DefaultSwiftUIRouterHomeViewModel(
32 | favouriteAssetsManager: DefaultFavouriteAssetsManager(),
33 | router: router
34 | ),
35 | router: router
36 | )
37 | }
38 |
39 | Divider()
40 | Button("UIKit navigation (UINavController + Router)") {
41 | isUIKitNaviPresented.toggle()
42 | }
43 | .fullScreenCover(isPresented: $isUIKitNaviPresented) {
44 | let rootViewController = RootViewController {
45 | isUIKitNaviPresented = false
46 | }
47 | let dependencyProvider = DefaultDependencyProvider(rootAppNavigator: rootViewController)
48 | UIKitRouterHomeView(dependencyProvider: dependencyProvider, rootViewController: rootViewController)
49 | }
50 |
51 | Divider()
52 | }
53 |
54 | Spacer()
55 | }
56 | }
57 | }
58 |
59 | struct HomeView_Previews: PreviewProvider {
60 | static var previews: some View {
61 | HomeView()
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Model/Asset.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Asset.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | /// A simple model representing an asset.
9 | struct Asset: Codable, Equatable, Hashable {
10 | /// An asset id, eg. USD, PLN, etc.
11 | let id: String
12 |
13 | /// An asset full name, eg. "US Dollar".
14 | let name: String
15 |
16 | /// A background color distinguishing an asset.
17 | let colorCode: String?
18 | }
19 |
20 | extension Asset {
21 |
22 | /// A helper value representing a color associated with the asset.
23 | var color: Color {
24 | guard let colorCode else {
25 | return .primary
26 | }
27 | return Color(hex: colorCode)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Model/AssetHistoricalRate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetHistoricalRate.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | struct AssetHistoricalRate: Codable, Equatable, Hashable {
9 | /// An asset id, eg. USD, PLN, etc.
10 | let id: String
11 |
12 | /// A rate snapshot date.
13 | let date: String
14 |
15 | /// An asset price in the moment of snapshot.
16 | let value: Double
17 | }
18 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Model/AssetPerfromance.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetPerfromance.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | struct AssetPerformance: Codable, Equatable, Hashable {
9 | /// An asset id, eg. USD, PLN, etc.
10 | let id: String
11 |
12 | /// An asset full name, eg. "US Dollar"
13 | let name: String
14 |
15 | /// A current asset price.
16 | let price: AssetPrice?
17 | }
18 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Model/AssetPrice.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AssetPrice.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure describing asset price.
9 | struct AssetPrice: Codable, Equatable, Hashable {
10 |
11 | /// An asset price relative to base asset.
12 | let value: Double
13 |
14 | /// A date of the exchange rate.
15 | let date: Date
16 |
17 | /// An asset used as base.
18 | let base: Asset
19 | }
20 |
21 | extension AssetPrice {
22 |
23 | var formattedPrice: String {
24 | String(format: "%.2f %@", 1 / value, base.id)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/Preview Content/PreviewFixtures.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreviewFixtures.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 | import SwiftUI
9 |
10 | final class PreviewSwiftUINavigationRouter: SwiftUINavigationRouter {
11 | @Published var navigationRoute: NavigationRoute?
12 | var navigationPathPublished: Published { _navigationRoute }
13 | var navigationPathPublisher: Published.Publisher { $navigationRoute }
14 | private(set) var navigationStack: [NavigationRoute] = []
15 |
16 | @Published var presentedPopup: PopupRoute?
17 | var presentedPopupPublished: Published { _presentedPopup }
18 | var presentedPopupPublisher: Published.Publisher { $presentedPopup }
19 |
20 | @Published var presentedAlert: AlertRoute?
21 | var presentedAlertPublished: Published { _presentedAlert }
22 | var presentedAlertPublisher: Published.Publisher { $presentedAlert }
23 |
24 | func set(navigationStack: [NavigationRoute]) {}
25 |
26 | func present(popup: PopupRoute) {}
27 | func dismiss() {}
28 |
29 | func push(route: NavigationRoute) {}
30 | func pop() {}
31 | func popAll() {}
32 |
33 | func show(alert: AlertRoute) {}
34 | func hideCurrentAlert() {}
35 | }
36 |
37 | final class PreviewAddAssetViewModel: AddAssetViewModel {
38 | @Published var viewState: AddAssetViewState
39 | var viewStatePublished: Published { _viewState }
40 | var viewStatePublisher: Published.Publisher { $viewState }
41 | @Published var searchPhrase: String = ""
42 | var searchPhrasePublished: Published { _searchPhrase }
43 | var searchPhrasePublisher: Published.Publisher { $searchPhrase }
44 | var selectedAssetsIds: [String] = []
45 |
46 | init(state: AddAssetViewState) {
47 | viewState = state
48 | }
49 |
50 | func onAssetTapped(id: String) {}
51 | func onAssetsSelectionConfirmed() {}
52 | func onPopToRootTapped() {}
53 | }
54 |
55 | final class PreviewAssetsListViewModel: AssetsListViewModel {
56 | @Published var viewState: AssetsListViewState
57 | var viewStatePublished: Published { _viewState }
58 | var viewStatePublisher: Published.Publisher { $viewState }
59 |
60 | init(state: AssetsListViewState) {
61 | viewState = state
62 | }
63 |
64 | func onAddNewAssetTapped() {}
65 | func onAssetSelected(id: String) {}
66 | func removeAssetFromFavourites(id: String) {}
67 | func onAssetSelectedToBeEdited(id: String) {}
68 | func onAssetSelectedForRemoval(id: String) {}
69 | func onRefreshRequested() {}
70 | func onAppInfoTapped() {}
71 | }
72 |
73 | final class PreviewSwiftUIRouterHomeViewModel: SwiftUIRouterHomeViewModel {
74 | let favouriteAssetsManager: FavouriteAssetsManager = DefaultFavouriteAssetsManager()
75 | var canRestoreNavState: Bool = true
76 |
77 | func removeAssetFromFavourites(id: String) {}
78 | func editAssets(id: String) {}
79 | func getRandomFavouriteAsset() -> Asset? { nil }
80 | }
81 |
82 | final class PreviewAssetDetailsViewModel: AssetDetailsViewModel {
83 | @Published var viewState: AssetDetailsViewState
84 | var viewStatePublished: Published { _viewState }
85 | var viewStatePublisher: Published.Publisher { $viewState }
86 | var assetData: AssetDetailsViewData = .init(id: "BTC", name: "Bitcoin")
87 |
88 | init(state: AssetDetailsViewState) {
89 | viewState = state
90 | }
91 |
92 | func edit(asset assetID: String) {}
93 | func reloadChart(scope: ChartView.Scope) {}
94 | func showInitialChart() {}
95 | }
96 |
97 | final class PreviewEditAssetViewModel: EditAssetViewModel {
98 | @Published var viewState: EditAssetViewState
99 | var viewStatePublished: Published { _viewState }
100 | var viewStatePublisher: Published.Publisher { $viewState }
101 | var assetId: String = ""
102 |
103 | init(state: EditAssetViewState) {
104 | viewState = state
105 | }
106 |
107 | func popToRoot() {}
108 | func saveChanges(assetData: EditAssetViewData) {}
109 | }
110 |
111 | final class PreviewAppInfoViewModel: AppInfoViewModel {
112 | @Published var viewState: AppInfoViewState
113 | var viewStatePublished: Published { _viewState }
114 | var viewStatePublisher: Published.Publisher { $viewState }
115 |
116 | init(state: AppInfoViewState) {
117 | viewState = state
118 | }
119 |
120 | func addAssetTapped() {}
121 | func appUpdateTapped() {}
122 | }
123 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/App Flows/Add Asset/SwiftUIRouterAddAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterAddAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// A default AddAssetViewModel implementation for SwiftUI navigation.
10 | final class SwiftUIRouterAddAssetViewModel: AddAssetViewModel {
11 | @Published var viewState = AddAssetViewState.loading
12 | @Published var searchPhrase = ""
13 | private(set) var selectedAssetsIds = [String]()
14 |
15 | private let assetsProvider: AssetsProvider
16 | private let favouriteAssetsManager: FavouriteAssetsManager
17 | private let router: any SwiftUINavigationRouter
18 |
19 | private var cancellables = Set()
20 | private var allAssets = [Asset]()
21 | private var filteredAssets = [Asset]()
22 |
23 | /// A default initializer for AddAssetViewModel.
24 | ///
25 | /// - Parameter assetsProvider: an assets provider.
26 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
27 | /// - Parameter router: a navigation router.
28 | init(
29 | assetsProvider: AssetsProvider,
30 | favouriteAssetsManager: FavouriteAssetsManager,
31 | router: any SwiftUINavigationRouter
32 | ) {
33 | self.assetsProvider = assetsProvider
34 | self.favouriteAssetsManager = favouriteAssetsManager
35 | self.router = router
36 | retrieveSelectedAssets()
37 | loadInitialAssets()
38 | setupAssetFiltering()
39 | }
40 |
41 | /// - SeeAlso: AddAssetViewModel.onAssetSelected(id:)
42 | func onAssetTapped(id: String) {
43 | if selectedAssetsIds.contains(id) {
44 | selectedAssetsIds.removeAll { $0 == id }
45 | } else {
46 | selectedAssetsIds.append(id)
47 | }
48 | composeViewState()
49 | }
50 |
51 | /// - SeeAlso: AddAssetViewModel.onPopToRootTapped()
52 | func onPopToRootTapped() {
53 | router.popAll()
54 | router.dismiss()
55 | }
56 |
57 | /// - SeeAlso: AddAssetViewModel.onAssetsSelectionConfirmed()
58 | func onAssetsSelectionConfirmed() {
59 | // Discussion: We want to retain all the modifications users made to favourite assets...
60 | // ... so we can't just replace the assets stored in the manager with the selected ones.
61 | let favouriteAssets = favouriteAssetsManager.retrieveFavouriteAssets()
62 | let favouriteAssetsIds = Set(favouriteAssets.map { $0.id })
63 | let selectedAssetsIds = Set(selectedAssetsIds)
64 |
65 | // Combining assets to retain and to add in a single collection to store:
66 | let retainedAssetsIds = selectedAssetsIds.intersection(favouriteAssetsIds)
67 | let newAssetsIds = selectedAssetsIds.subtracting(retainedAssetsIds)
68 | let assetsToAdd = Set(allAssets.filter { newAssetsIds.contains($0.id) })
69 | let assetsToRetain = Set(favouriteAssets.filter { retainedAssetsIds.contains($0.id) })
70 | let assetsToStore = Array(assetsToRetain) + Array(assetsToAdd)
71 |
72 | favouriteAssetsManager.store(favouriteAssets: assetsToStore)
73 | router.dismiss()
74 | }
75 | }
76 |
77 | private extension SwiftUIRouterAddAssetViewModel {
78 |
79 | enum Const {
80 | static let searchLatency = 0.3
81 | }
82 |
83 | func loadInitialAssets() {
84 | Task { @MainActor [weak self] in
85 | let assets = await self?.assetsProvider.getAllAssets() ?? []
86 | self?.allAssets = assets
87 | self?.filteredAssets = assets.sorted {
88 | $0.id < $1.id
89 | }
90 | self?.composeViewState()
91 | }
92 | }
93 |
94 | func retrieveSelectedAssets() {
95 | selectedAssetsIds = favouriteAssetsManager
96 | .retrieveFavouriteAssets()
97 | .map {
98 | $0.id
99 | }
100 | }
101 |
102 | func setupAssetFiltering() {
103 | $searchPhrase
104 | .debounce(for: .seconds(Const.searchLatency), scheduler: RunLoop.main)
105 | .removeDuplicates()
106 | .dropFirst()
107 | .sink { [weak self] phrase in
108 | self?.filterAssets(phrase: phrase.lowercased())
109 | self?.composeViewState()
110 | }
111 | .store(in: &cancellables)
112 | }
113 |
114 | func filterAssets(phrase: String) {
115 | guard !phrase.isEmpty else {
116 | filteredAssets = allAssets
117 | return
118 | }
119 |
120 | filteredAssets = allAssets
121 | .filter { asset in
122 | asset.id.lowercased().contains(phrase) || asset.name.lowercased().contains(phrase)
123 | }
124 | }
125 |
126 | func composeViewState() {
127 | guard !allAssets.isEmpty else {
128 | viewState = .noAssets
129 | return
130 | }
131 |
132 | let cellData = filteredAssets
133 | .map {
134 | AssetCellView.Data(id: $0.id, title: $0.name, isSelected: selectedAssetsIds.contains($0.id))
135 | }
136 | viewState = .loaded(cellData)
137 | }
138 | }
139 |
140 | extension SwiftUIRouterAddAssetViewModel {
141 | var searchPhrasePublished: Published { _searchPhrase }
142 | var searchPhrasePublisher: Published.Publisher { $searchPhrase }
143 | var viewStatePublished: Published { _viewState }
144 | var viewStatePublisher: Published.Publisher { $viewState }
145 | }
146 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/App Flows/App Info/SwiftUIRouterAppInfoViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterAppInfoViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// A default AppInfoViewModel implementation for SwiftUI navigation.
10 | final class SwiftUIRouterAppInfoViewModel: AppInfoViewModel {
11 | @Published var viewState = AppInfoViewState.appUpToDate(currentVersion: "0.9")
12 | private let router: any SwiftUINavigationRouter
13 |
14 | /// A default initializer for AppInfoViewModel.
15 | ///
16 | /// - Parameter router: a navigation router.
17 | init(
18 | router: any SwiftUINavigationRouter
19 | ) {
20 | self.router = router
21 | }
22 |
23 | /// - SeeAlso: AppInfoViewModel.onAddAssetTapped()
24 | func addAssetTapped() {
25 | router.present(popup: .addAsset)
26 | }
27 |
28 | /// - SeeAlso: AppInfoViewModel.onAppUpdateTapped()
29 | func appUpdateTapped() {
30 | router.popAll()
31 | router.dismiss()
32 | }
33 | }
34 |
35 | extension SwiftUIRouterAppInfoViewModel {
36 | var viewStatePublished: Published { _viewState }
37 | var viewStatePublisher: Published.Publisher { $viewState }
38 | }
39 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/App Flows/Asset Details/SwiftUIRouterAssetDetailsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterAssetDetailsViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// A default AssetDetailsViewModel implementation.
10 | final class SwiftUIRouterAssetDetailsViewModel: AssetDetailsViewModel {
11 | @Published var viewState = AssetDetailsViewState.loading
12 | let assetData: AssetDetailsViewData
13 |
14 | private let router: any SwiftUINavigationRouter
15 | private let favouriteAssetsManager: FavouriteAssetsManager
16 | private let historicalAssetRatesProvider: HistoricalAssetRatesProvider
17 |
18 | /// A default initializer for AssetDetailsViewModel.
19 | ///
20 | /// - Parameter assetId: an asset id.
21 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
22 | /// - Parameter historicalAssetRatesProvider: a historical asset rates provder.
23 | /// - Parameter router: a navigation router.
24 | init(
25 | assetId: String,
26 | favouriteAssetsManager: FavouriteAssetsManager,
27 | historicalAssetRatesProvider: HistoricalAssetRatesProvider,
28 | router: any SwiftUINavigationRouter
29 | ) {
30 | let asset = favouriteAssetsManager.retrieveFavouriteAssets().filter { $0.id == assetId }.first
31 | assetData = asset?.toAssetDetailsViewData() ?? .empty
32 | self.favouriteAssetsManager = favouriteAssetsManager
33 | self.historicalAssetRatesProvider = historicalAssetRatesProvider
34 | self.router = router
35 | viewState = .loading
36 | }
37 |
38 | /// - SeeAlso: AssetDetailsViewModel.edit(assetID:)
39 | func edit(asset assetID: String) {
40 | router.push(route: .editAsset(assetID))
41 | }
42 |
43 | /// - SeeAlso: AssetDetailsViewModel.reloadChart(scope:)
44 | func reloadChart(scope: ChartView.Scope) async {
45 | await loadAssetChart(scope: scope)
46 | }
47 |
48 | /// - SeeAlso: AssetDetailsViewModel.showInitialChart()
49 | func showInitialChart() async {
50 | await loadAssetChart(scope: .week)
51 | }
52 | }
53 |
54 | private extension SwiftUIRouterAssetDetailsViewModel {
55 |
56 | @MainActor func loadAssetChart(scope: ChartView.Scope) async {
57 | let rates = await historicalAssetRatesProvider.getHistoricalRates(for: assetData.id, range: scope)
58 | let data = rates.map {
59 | ChartView.ChartPoint(label: $0.date, value: $0.value)
60 | }
61 | viewState = .loaded(data)
62 | }
63 | }
64 |
65 | extension SwiftUIRouterAssetDetailsViewModel {
66 | var viewStatePublished: Published { _viewState }
67 | var viewStatePublisher: Published.Publisher { $viewState }
68 | }
69 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/App Flows/Assets List/SwiftUIRouterAssetsListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterAssetsListViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import UIKit
8 |
9 | final class SwiftUIRouterAssetsListViewModel: AssetsListViewModel {
10 | @Published var viewState: AssetsListViewState
11 |
12 | private let router: any SwiftUINavigationRouter
13 | private let favouriteAssetsManager: FavouriteAssetsManager
14 | private let assetsRatesProvider: AssetsRatesProvider
15 | private var favouriteAssets: [Asset]
16 | private var cancellables = Set()
17 |
18 | /// A default initializer for DefaultSwiftUIRouterHomeViewModel.
19 | ///
20 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
21 | /// - Parameter assetsRatesProvider: an assets rates provider.
22 | /// - Parameter router: a navigation router.
23 | init(
24 | favouriteAssetsManager: FavouriteAssetsManager,
25 | assetsRatesProvider: AssetsRatesProvider,
26 | router: any SwiftUINavigationRouter
27 | ) {
28 | self.favouriteAssetsManager = favouriteAssetsManager
29 | self.assetsRatesProvider = assetsRatesProvider
30 | self.router = router
31 | let favouriteAssets = favouriteAssetsManager.retrieveFavouriteAssets()
32 | viewState = SwiftUIRouterAssetsListViewModel.composeViewState(favouriteAssets: favouriteAssets)
33 | self.favouriteAssets = favouriteAssets
34 | subscribeToFavouriteAssetsUpdates()
35 | getAssetRates()
36 | }
37 |
38 | /// - SeeAlso: AssetsListViewModel.onAssetSelected(id:)
39 | func onAssetSelected(id: String) {
40 | router.push(route: .assetDetails(id))
41 | }
42 |
43 | /// - SeeAlso: AssetsListViewModel.onAssetSelectedToBeEdited(id:)
44 | func onAssetSelectedToBeEdited(id: String) {
45 | router.push(route: .editAsset(id))
46 | }
47 |
48 | /// - SeeAlso: AssetsListViewModel.onAddNewAssetTapped()
49 | func onAddNewAssetTapped() {
50 | router.present(popup: .addAsset)
51 | }
52 |
53 | /// - SeeAlso: AssetsListViewModel.onAppInfoTapped()
54 | func onAppInfoTapped() {
55 | router.present(popup: .appInfo)
56 | }
57 |
58 | /// - SeeAlso: AssetsListViewModel.onAssetSelectedForRemoval(id:)
59 | func onAssetSelectedForRemoval(id: String) {
60 | guard let asset = favouriteAssets.filter({ $0.id == id }).first else {
61 | return
62 | }
63 |
64 | router.show(alert: .deleteAsset(assetId: asset.id, assetName: asset.name))
65 | }
66 |
67 | /// - SeeAlso: AssetsListViewModel.onAssetSelectedForFavouriteToggle(id:)
68 | func removeAssetFromFavourites(id: String) {
69 | favouriteAssets.removeAll { $0.id == id }
70 | favouriteAssetsManager.store(favouriteAssets: favouriteAssets)
71 | getAssetRates()
72 | }
73 |
74 | /// - SeeAlso: AssetsListViewModel.onRefreshRequested()
75 | func onRefreshRequested() {
76 | getAssetRates()
77 | }
78 | }
79 |
80 | private extension SwiftUIRouterAssetsListViewModel {
81 |
82 | static func composeViewState(favouriteAssets: [Asset]) -> AssetsListViewState {
83 | favouriteAssets.isEmpty ? .noFavouriteAssets : .loading(favouriteAssets.map { FavouriteAssetCellView.Data(asset: $0) })
84 | }
85 |
86 | func refreshViewState() {
87 | favouriteAssets = favouriteAssetsManager.retrieveFavouriteAssets()
88 | viewState = SwiftUIRouterAssetsListViewModel.composeViewState(favouriteAssets: favouriteAssets)
89 | }
90 |
91 | func subscribeToFavouriteAssetsUpdates() {
92 | favouriteAssetsManager
93 | .didChange
94 | .sink(receiveValue: { [weak self] _ in
95 | self?.refreshViewState()
96 | self?.getAssetRates()
97 | })
98 | .store(in: &cancellables)
99 | }
100 |
101 | func getAssetRates() {
102 | Task { @MainActor [weak self] in
103 | guard let self else { return }
104 |
105 | let dateFormatter = DateFormatter.fullDateFormatter
106 | let rates = await self.assetsRatesProvider.getAssetRates()
107 | guard !rates.isEmpty else { return }
108 |
109 | let lastUpdated = rates.first?.price?.date ?? Date()
110 | let lastUpdatedString = dateFormatter.string(from: lastUpdated)
111 | let data = favouriteAssets.map { favouriteAsset -> FavouriteAssetCellView.Data in
112 | let rate = rates.first { $0.id == favouriteAsset.id }
113 | let formattedValue = rate?.price?.formattedPrice
114 | return FavouriteAssetCellView.Data(asset: favouriteAsset, formattedValue: formattedValue)
115 | }
116 | self.viewState = .loaded(data, lastUpdatedString)
117 | }
118 | }
119 | }
120 |
121 | extension SwiftUIRouterAssetsListViewModel {
122 | var viewStatePublished: Published { _viewState }
123 | var viewStatePublisher: Published.Publisher { $viewState }
124 | }
125 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/App Flows/Edit Asset/SwiftUIRouterEditAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterEditAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// A default EditAssetViewModel implementation.
10 | final class SwiftUIRouterEditAssetViewModel: EditAssetViewModel {
11 | @Published var viewState: EditAssetViewState
12 |
13 | private let router: any SwiftUINavigationRouter
14 | private let favouriteAssetsManager: FavouriteAssetsManager
15 |
16 | /// A default initializer for EditAssetViewModel.
17 | ///
18 | /// - Parameter assetId: an asset id.
19 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
20 | /// - Parameter router: a navigation router.
21 | init(
22 | assetId: String,
23 | favouriteAssetsManager: FavouriteAssetsManager,
24 | router: any SwiftUINavigationRouter
25 | ) {
26 | self.favouriteAssetsManager = favouriteAssetsManager
27 | self.router = router
28 | viewState = SwiftUIRouterEditAssetViewModel.calculateViewState(
29 | favouriteAssetsManager: favouriteAssetsManager,
30 | assetId: assetId
31 | )
32 | }
33 |
34 | /// - SeeAlso: EditAssetViewModel.saveChanges(assetData:)
35 | func saveChanges(assetData: EditAssetViewData) {
36 | let asset = Asset(id: assetData.id, name: assetData.name, colorCode: assetData.color.toHex())
37 | var assets = favouriteAssetsManager.retrieveFavouriteAssets()
38 | assets.removeAll { $0.id == assetData.id }
39 | assets.insert(asset, at: assetData.position.currentPosition - 1)
40 | favouriteAssetsManager.store(favouriteAssets: assets)
41 | router.pop()
42 | }
43 | }
44 |
45 | private extension SwiftUIRouterEditAssetViewModel {
46 |
47 | static func calculateViewState(
48 | favouriteAssetsManager: FavouriteAssetsManager,
49 | assetId: String
50 | ) -> EditAssetViewState {
51 | let assets = favouriteAssetsManager.retrieveFavouriteAssets()
52 | guard let asset = assets.filter({ $0.id == assetId }).first else {
53 | return .assetNotFound
54 | }
55 |
56 | let currentPosition = assets.firstIndex(of: asset) ?? 0
57 | return .editAsset(
58 | EditAssetViewData(asset: asset, position: currentPosition, totalAssetCount: assets.count)
59 | )
60 | }
61 | }
62 |
63 | extension SwiftUIRouterEditAssetViewModel {
64 | var viewStatePublished: Published { _viewState }
65 | var viewStatePublisher: Published.Publisher { $viewState }
66 | }
67 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/Router/AlertRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlertRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure describing a route for an alert.
9 | enum AlertRoute: Hashable, Codable, Identifiable {
10 | case deleteAsset(assetId: String, assetName: String)
11 |
12 | var id: Int {
13 | hashValue
14 | }
15 | }
16 |
17 | /// An abstraction describing a presentable alert route.
18 | protocol AlertRoutePresentable {
19 | var item: any Hashable { get }
20 | var title: String { get }
21 | var message: String? { get }
22 | var confirmationActionText: String { get }
23 | var cancellationActionText: String { get }
24 | }
25 |
26 | extension AlertRoute: AlertRoutePresentable {
27 |
28 | /// - SeeAlso: AlertRoutePresentable.item
29 | var item: any Hashable {
30 | switch self {
31 | case let .deleteAsset(assetId, _):
32 | return assetId
33 | }
34 | }
35 |
36 | /// - SeeAlso: AlertRoutePresentable.title
37 | var title: String {
38 | switch self {
39 | case let .deleteAsset(_, assetName):
40 | return "Do you want to delete \(assetName)?"
41 | }
42 | }
43 |
44 | /// - SeeAlso: AlertRoutePresentable.message
45 | var message: String? {
46 | nil
47 | }
48 |
49 | /// - SeeAlso: AlertRoutePresentable.confirmationActionText
50 | var confirmationActionText: String {
51 | switch self {
52 | case .deleteAsset:
53 | return "Delete"
54 | }
55 | }
56 |
57 | /// - SeeAlso: AlertRoutePresentable.cancellationActionText
58 | var cancellationActionText: String {
59 | "Cancel"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/Router/NavigationRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure describing app navigation route.
9 | enum NavigationRoute: Hashable, Codable, Identifiable {
10 | case embeddedHomeView
11 | case assetDetails(String)
12 | case editAsset(String)
13 |
14 | var id: Int {
15 | hashValue
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/Router/PopupRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PopupRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// A structure describing app popup route.
9 | enum PopupRoute: Hashable, Codable, Identifiable {
10 | case addAsset
11 | case homeView
12 | case appInfo // A placeholder popup screen - mostly for tests.
13 |
14 | var id: Int {
15 | hashValue
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/Router/SwiftUINavigationRouter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUINavigationRouter.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// An abstraction describing a navigation router.
10 | /// It acts similarly to UINavigationController, allowing to push, present, pop and dismiss app views.
11 | /// Requires a bound View able to produce the views to display e.g. `HomeView`
12 | protocol SwiftUINavigationRouter: AnyObject, ObservableObject {
13 |
14 | /// A currently presented popup.
15 | var presentedPopup: PopupRoute? { get set }
16 | var presentedPopupPublished: Published { get }
17 | var presentedPopupPublisher: Published.Publisher { get }
18 |
19 | /// A currently presented alert.
20 | var presentedAlert: AlertRoute? { get set }
21 | var presentedAlertPublished: Published { get }
22 | var presentedAlertPublisher: Published.Publisher { get }
23 |
24 | /// A currently presented navigation route.
25 | var navigationRoute: NavigationRoute? { get }
26 |
27 | /// A complete navigation stack.
28 | /// Contains all navigation routes pushed to navigation stack.
29 | var navigationStack: [NavigationRoute] { get }
30 |
31 | /// Pushes screen to navigation stack.
32 | ///
33 | /// - Parameter route: a screen to be pushed.
34 | func push(route: NavigationRoute)
35 |
36 | /// Removes last view from the navigation stack.
37 | func pop()
38 |
39 | /// Pops navigation stack to root.
40 | func popAll()
41 |
42 | /// Replaces navigation stack.
43 | ///
44 | /// - Parameter navigationStack: a collection of routes to replace the stack with.
45 | func set(navigationStack: [NavigationRoute])
46 |
47 | /// Presents provided popup as sheet.
48 | ///
49 | /// - Parameter popup: a popup to present.
50 | func present(popup: PopupRoute)
51 |
52 | /// Dismisses current popup.
53 | func dismiss()
54 |
55 | /// Shows an alert.
56 | ///
57 | /// - Parameter alert: an alert to show.
58 | func show(alert: AlertRoute)
59 |
60 | /// Removes currently displayed alert from the navigation stack.
61 | func hideCurrentAlert()
62 | }
63 |
64 | /// A default implementation of NavigationRouter.
65 | final class DefaultSwiftUINavigationRouter: SwiftUINavigationRouter {
66 | @Published var presentedPopup: PopupRoute? = nil
67 | var presentedPopupPublished: Published { _presentedPopup }
68 | var presentedPopupPublisher: Published.Publisher { $presentedPopup }
69 |
70 | @Published var presentedAlert: AlertRoute? = nil
71 | var presentedAlertPublished: Published { _presentedAlert }
72 | var presentedAlertPublisher: Published.Publisher { $presentedAlert }
73 |
74 | var navigationRoute: NavigationRoute? {
75 | navigationStack.last
76 | }
77 |
78 | private(set) var navigationStack: [NavigationRoute] = []
79 |
80 | // MARK: - Popups:
81 |
82 | func present(popup: PopupRoute) {
83 | presentedPopup = popup
84 | }
85 |
86 | func dismiss() {
87 | presentedPopup = nil
88 | }
89 |
90 | // MARK: - Inline navigation:
91 |
92 | func push(route: NavigationRoute) {
93 | navigationStack.append(route)
94 | objectWillChange.send()
95 | }
96 |
97 | func pop() {
98 | guard !navigationStack.isEmpty else { return }
99 | navigationStack.removeLast()
100 | objectWillChange.send()
101 | }
102 |
103 | func popAll() {
104 | navigationStack = []
105 | objectWillChange.send()
106 | }
107 |
108 | func set(navigationStack: [NavigationRoute]) {
109 | self.navigationStack = navigationStack
110 | objectWillChange.send()
111 | }
112 |
113 | // MARK: - Alerts:
114 |
115 | func show(alert: AlertRoute) {
116 | presentedAlert = alert
117 | }
118 |
119 | func hideCurrentAlert() {
120 | presentedAlert = nil
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUI Router/SwiftUIRouterHomeViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterHomeViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// An abstraction describing a View Model for .
10 | protocol SwiftUIRouterHomeViewModel: ObservableObject {
11 | var favouriteAssetsManager: FavouriteAssetsManager { get }
12 | var canRestoreNavState: Bool { get }
13 | func removeAssetFromFavourites(id: String)
14 | func getRandomFavouriteAsset() -> Asset?
15 | func editAssets(id: String)
16 | }
17 |
18 | final class DefaultSwiftUIRouterHomeViewModel: SwiftUIRouterHomeViewModel {
19 | let favouriteAssetsManager: FavouriteAssetsManager
20 | var canRestoreNavState: Bool {
21 | !favouriteAssetsManager.retrieveFavouriteAssets().isEmpty
22 | }
23 |
24 | private let router: any SwiftUINavigationRouter
25 |
26 | /// A default initializer for DefaultSwiftUIRouterHomeViewModel.
27 | ///
28 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
29 | /// - Parameter router: a navigation router.
30 | init(
31 | favouriteAssetsManager: FavouriteAssetsManager,
32 | router: any SwiftUINavigationRouter
33 | ) {
34 | self.favouriteAssetsManager = favouriteAssetsManager
35 | self.router = router
36 | }
37 |
38 | func removeAssetFromFavourites(id: String) {
39 | var assets = favouriteAssetsManager.retrieveFavouriteAssets()
40 | assets.removeAll { $0.id == id }
41 | favouriteAssetsManager.store(favouriteAssets: assets)
42 | objectWillChange.send()
43 | }
44 |
45 | func editAssets(id: String) {
46 | router.push(route: .editAsset(id))
47 | }
48 |
49 | func getRandomFavouriteAsset() -> Asset? {
50 | favouriteAssetsManager.retrieveFavouriteAssets().first
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/SwiftUINavigationApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUINavigationApp.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 |
8 | @main
9 | struct SwiftUINavigationApp: App {
10 | var body: some Scene {
11 | WindowGroup {
12 | HomeView()
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Alert/AlertPresenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlertPresenter.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An enumeration describing the possible actions user can perform on an acceptance alert.
9 | enum AcceptanceAlertAction: Equatable {
10 | case yes, no
11 | }
12 |
13 | /// An abstraction describing an object that can present alerts.
14 | protocol AlertPresenter: AnyObject {
15 |
16 | /// Shows a simple information alert.
17 | ///
18 | /// - Parameters:
19 | /// - viewController: a view controller to show the alert on.
20 | /// - title: an alert title.
21 | /// - message: an alert message.
22 | /// - buttonTitle: a title of the confirmation button.
23 | /// - completion: a completion callback.
24 | func showInfoAlert(
25 | on viewController: UIViewController,
26 | title: String,
27 | message: String?,
28 | buttonTitle: String,
29 | completion: (() -> Void)?
30 | )
31 |
32 | /// Shows an acceptance alert.
33 | ///
34 | /// - Parameters:
35 | /// - viewController: a view controller to show the alert on.
36 | /// - title: an alert title.
37 | /// - message: an alert message.
38 | /// - yesActionTitle: a confirmation action button title.
39 | /// - noActionTitle: a denial action button title.
40 | /// - yesActionStyle: a confirmation action button style.
41 | /// - noActionStyle: a denial action button style.
42 | /// - completion: a completion callback.
43 | func showAcceptanceAlert(
44 | on viewController: UIViewController,
45 | title: String,
46 | message: String?,
47 | yesActionTitle: String,
48 | noActionTitle: String,
49 | yesActionStyle: UIAlertAction.Style,
50 | noActionStyle: UIAlertAction.Style,
51 | completion: ((AcceptanceAlertAction) -> Void)?
52 | )
53 | }
54 |
55 | extension AlertPresenter {
56 |
57 | /// A convenience method to show a simple information alert with a default button title.
58 | ///
59 | /// - Parameters:
60 | /// - viewController: a view controller to show the alert on.
61 | /// - title: an alert title.
62 | /// - message: an alert message.
63 | /// - completion: a completion callback.
64 | func showInfoAlert(
65 | on viewController: UIViewController,
66 | title: String,
67 | message: String?,
68 | completion: (() -> Void)?
69 | ) {
70 | showInfoAlert(
71 | on: viewController,
72 | title: title,
73 | message: message,
74 | buttonTitle: "OK",
75 | completion: completion
76 | )
77 | }
78 |
79 | /// A convenience method to show an acceptance alert with default button titles and styles.
80 | ///
81 | /// - Parameters:
82 | /// - viewController: a view controller to show the alert on.
83 | /// - title: an alert title.
84 | /// - message: an alert message.
85 | /// - completion: a completion callback.
86 | func showAcceptanceAlert(
87 | on viewController: UIViewController,
88 | title: String,
89 | message: String?,
90 | completion: ((AcceptanceAlertAction) -> Void)?
91 | ) {
92 | showAcceptanceAlert(
93 | on: viewController,
94 | title: title,
95 | message: message,
96 | yesActionTitle: "Yes",
97 | noActionTitle: "No",
98 | yesActionStyle: .default,
99 | noActionStyle: .default,
100 | completion: completion
101 | )
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Alert/DefaultAlertPresenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DefaultAlertPresenter.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// A default implementation of AlertPresenter.
9 | final class DefaultAlertPresenter: AlertPresenter {
10 |
11 | /// A currently displayed alert controller.
12 | private(set) var alertController: UIAlertController?
13 |
14 | /// - SeeAlso: AlertPresenter.showInfoAlert(on:title:message:buttonTitle:completion:)
15 | func showInfoAlert(
16 | on viewController: UIViewController,
17 | title: String,
18 | message: String?,
19 | buttonTitle: String,
20 | completion: (() -> Void)?
21 | ) {
22 | let okAction = UIAlertAction(title: buttonTitle, style: .default, handler: { _ in
23 | completion?()
24 | })
25 | showAlert(title: title, message: message, on: viewController, actions: [okAction])
26 | }
27 |
28 | /// - SeeAlso: AlertPresenter.showAcceptanceAlert(on:title:message:yesActionTitle:noActionTitle:yesActionStyle:noActionStyle:completion:)
29 | func showAcceptanceAlert(
30 | on viewController: UIViewController,
31 | title: String,
32 | message: String?,
33 | yesActionTitle: String,
34 | noActionTitle: String,
35 | yesActionStyle: UIAlertAction.Style,
36 | noActionStyle: UIAlertAction.Style,
37 | completion: ((AcceptanceAlertAction) -> Void)?
38 | ) {
39 | let yesAction = UIAlertAction(title: yesActionTitle, style: yesActionStyle, handler: { _ in
40 | completion?(.yes)
41 | })
42 | let noAction = UIAlertAction(title: noActionTitle, style: noActionStyle, handler: { _ in
43 | completion?(.no)
44 | })
45 | showAlert(title: title, message: message, on: viewController, actions: [yesAction, noAction])
46 | }
47 | }
48 |
49 | // MARK: - Private Methods
50 |
51 | private extension DefaultAlertPresenter {
52 |
53 | func showAlert(title: String, message: String?, on viewController: UIViewController, actions: [UIAlertAction]) {
54 | let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
55 | actions.forEach { alertController.addAction($0) }
56 | viewController.present(alertController, animated: true, completion: nil)
57 | self.alertController = alertController
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Add Asset Flow/Add Asset/UIKitRouterAddAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitRouterAddAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 | import UIKit
9 |
10 | /// A default AddAssetViewModel implementation for UIKit navigation.
11 | final class UIKitRouterAddAssetViewModel: AddAssetViewModel {
12 | @Published var viewState = AddAssetViewState.loading
13 | @Published var searchPhrase = ""
14 | private(set) var selectedAssetsIds = [String]()
15 |
16 | private let assetsProvider: AssetsProvider
17 | private let favouriteAssetsManager: FavouriteAssetsManager
18 | private let router: UIKitNavigationRouter
19 |
20 | private var cancellables = Set()
21 | private var allAssets = [Asset]()
22 | private var filteredAssets = [Asset]()
23 |
24 | /// A default initializer for AddAssetViewModel.
25 | ///
26 | /// - Parameter assetsProvider: an assets provider.
27 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
28 | /// - Parameter router: a navigation router.
29 | init(
30 | assetsProvider: AssetsProvider,
31 | favouriteAssetsManager: FavouriteAssetsManager,
32 | router: UIKitNavigationRouter
33 | ) {
34 | self.assetsProvider = assetsProvider
35 | self.favouriteAssetsManager = favouriteAssetsManager
36 | self.router = router
37 | retrieveSelectedAssets()
38 | loadInitialAssets()
39 | setupAssetFiltering()
40 | }
41 |
42 | /// - SeeAlso: AddAssetViewModel.onAssetTapped(id:)
43 | func onAssetTapped(id: String) {
44 | if selectedAssetsIds.contains(id) {
45 | selectedAssetsIds.removeAll { $0 == id }
46 | } else {
47 | selectedAssetsIds.append(id)
48 | }
49 | composeViewState()
50 | }
51 |
52 | /// - SeeAlso: AddAssetViewModel.onAssetsSelectionConfirmed()
53 | func onAssetsSelectionConfirmed() {
54 | // Discussion: We want to retain all the modifications users made to favourite assets...
55 | // ... so we can't just replace the assets stored in the manager with the selected ones.
56 | let favouriteAssets = favouriteAssetsManager.retrieveFavouriteAssets()
57 | let favouriteAssetsIds = Set(favouriteAssets.map { $0.id })
58 | let selectedAssetsIds = Set(selectedAssetsIds)
59 |
60 | // Combining assets to retain and to add in a single collection to store:
61 | let retainedAssetsIds = selectedAssetsIds.intersection(favouriteAssetsIds)
62 | let newAssetsIds = selectedAssetsIds.subtracting(retainedAssetsIds)
63 | let assetsToAdd = Set(allAssets.filter { newAssetsIds.contains($0.id) })
64 | let assetsToRetain = Set(favouriteAssets.filter { retainedAssetsIds.contains($0.id) })
65 | let assetsToStore = Array(assetsToRetain) + Array(assetsToAdd)
66 |
67 | favouriteAssetsManager.store(favouriteAssets: assetsToStore)
68 | router.stopCurrentFlow() // TODO: Maybe navigateBack() instead?
69 | }
70 |
71 | /// - SeeAlso: AddAssetViewModel.onPopToRootTapped()
72 | func onPopToRootTapped() {
73 | router.switch(toRoute: MainAppRoute.assetsList, withData: nil)
74 | }
75 | }
76 |
77 | private extension UIKitRouterAddAssetViewModel {
78 |
79 | enum Const {
80 | static let searchLatency = 0.3
81 | }
82 |
83 | func loadInitialAssets() {
84 | Task { @MainActor [weak self] in
85 | let assets = await self?.assetsProvider.getAllAssets() ?? []
86 | self?.allAssets = assets
87 | self?.filteredAssets = assets.sorted {
88 | $0.id < $1.id
89 | }
90 | self?.composeViewState()
91 | }
92 | }
93 |
94 | func retrieveSelectedAssets() {
95 | selectedAssetsIds = favouriteAssetsManager
96 | .retrieveFavouriteAssets()
97 | .map {
98 | $0.id
99 | }
100 | }
101 |
102 | func setupAssetFiltering() {
103 | $searchPhrase
104 | .debounce(for: .seconds(Const.searchLatency), scheduler: RunLoop.main)
105 | .removeDuplicates()
106 | .dropFirst()
107 | .sink { [weak self] phrase in
108 | self?.filterAssets(phrase: phrase.lowercased())
109 | self?.composeViewState()
110 | }
111 | .store(in: &cancellables)
112 | }
113 |
114 | func filterAssets(phrase: String) {
115 | guard !phrase.isEmpty else {
116 | filteredAssets = allAssets
117 | return
118 | }
119 |
120 | filteredAssets = allAssets
121 | .filter { asset in
122 | asset.id.lowercased().contains(phrase) || asset.name.lowercased().contains(phrase)
123 | }
124 | }
125 |
126 | func composeViewState() {
127 | guard !allAssets.isEmpty else {
128 | viewState = .noAssets
129 | return
130 | }
131 |
132 | let cellData = filteredAssets
133 | .map {
134 | AssetCellView.Data(id: $0.id, title: $0.name, isSelected: selectedAssetsIds.contains($0.id))
135 | }
136 | viewState = .loaded(cellData)
137 | }
138 | }
139 |
140 | extension UIKitRouterAddAssetViewModel {
141 | var searchPhrasePublished: Published { _searchPhrase }
142 | var searchPhrasePublisher: Published.Publisher { $searchPhrase }
143 | var viewStatePublished: Published { _viewState }
144 | var viewStatePublisher: Published.Publisher { $viewState }
145 | }
146 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Add Asset Flow/AddAssetFlowCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddAssetFlowCoordinator.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | /// A coordinator handling add asset flow.
10 | final class AddAssetFlowCoordinator: FlowCoordinator {
11 |
12 | /// - SeeAlso: FlowCoordinator.parent
13 | let parent: FlowCoordinator?
14 |
15 | /// - SeeAlso: FlowCoordinator.completionCallback
16 | var completionCallback: (() -> Void)?
17 |
18 | /// - SeeAlso: FlowCoordinator.adaptivePresentationDelegate
19 | let adaptivePresentationDelegate: UIAdaptivePresentationControllerDelegate? = nil
20 |
21 | /// - SeeAlso: FlowCoordinator.child
22 | var child: FlowCoordinator? = nil
23 |
24 | /// - SeeAlso: FlowCoordinator.navigator
25 | let navigator: Navigator
26 |
27 | private let dependencyProvider: DependencyProvider
28 |
29 | /// A default initializer for MainAppFlowCoordinator.
30 | ///
31 | /// - Parameters:
32 | /// - navigator: a navigator.
33 | /// - dependencyProvider: a dependency provider.
34 | /// - parent: a flow coordinator parent.
35 | init(
36 | navigator: Navigator,
37 | dependencyProvider: DependencyProvider,
38 | parent: FlowCoordinator? = nil
39 | ) {
40 | self.navigator = navigator
41 | self.dependencyProvider = dependencyProvider
42 | self.parent = parent
43 | }
44 |
45 | /// - SeeAlso: FlowCoordinator.start(animated:)
46 | func start(animated: Bool) {
47 | let initialRoute = AddAssetRoute.addAsset
48 | let addAsset = makeViewComponents(forRoute: initialRoute, withData: nil)[0]
49 | addAsset.route = initialRoute
50 | navigator.pushViewController(addAsset.viewController, animated: animated)
51 | initialInternalRoute = initialRoute
52 | }
53 |
54 | /// - SeeAlso: FlowCoordinator.stop()
55 | func stop() {
56 | cleanUpNavigationStack()
57 | completionCallback?()
58 | }
59 |
60 | /// - SeeAlso: FlowCoordinator.show(route:withData:)
61 | func canShow(route: any Route) -> Bool {
62 | route as? AddAssetRoute != nil
63 | }
64 |
65 | /// - SeeAlso: FlowCoordinator.makeViewComponents(forRoute:withData:)
66 | func makeViewComponents(forRoute route: any Route, withData: AnyHashable?) -> [ViewComponent] {
67 | guard let route = route as? AddAssetRoute else {
68 | fatalError("Route \(route) is not supported by AddAssetFlowCoordinator")
69 | }
70 |
71 | switch route {
72 | case .addAsset:
73 | return [makeAddAssetViewController()]
74 | }
75 | }
76 |
77 | /// - SeeAlso: FlowCoordinator.makeFlowCoordinator(forRoute:navigator:withData:)
78 | func makeFlowCoordinator(forRoute route: any Route, navigator: Navigator, withData: AnyHashable?) -> FlowCoordinator {
79 | fatalError("makeFlowCoordinator(forRoute:navigator:withData:) has not been implemented")
80 | }
81 | }
82 |
83 | private extension AddAssetFlowCoordinator {
84 |
85 | func makeAddAssetViewController() -> ViewComponent {
86 | let viewModel = UIKitRouterAddAssetViewModel(
87 | assetsProvider: dependencyProvider.assetsProvider,
88 | favouriteAssetsManager: dependencyProvider.favouriteAssetsManager,
89 | router: dependencyProvider.router
90 | )
91 | return AddAssetView(viewModel: viewModel).viewController
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Add Asset Flow/AddAssetRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddAssetRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An enumeration describing a routes in Add Asset flow.
9 | enum AddAssetRoute {
10 |
11 | /// A route to add an asset.
12 | case addAsset
13 | }
14 |
15 | extension AddAssetRoute: Route {
16 |
17 | /// - SeeAlso: Route.path
18 | var name: String {
19 | switch self {
20 | case .addAsset:
21 | return "AddAssetRoute.AddAsset"
22 | }
23 | }
24 |
25 | /// - SeeAlso: Route.isFlow
26 | var isFlow: Bool {
27 | false
28 | }
29 |
30 | /// - SeeAlso: Route.popupPresentationStyle
31 | var popupPresentationStyle: PopupPresentationStyle {
32 | .none
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/App Info Flow/App Info/UIKitRouterAppInfoViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitRouterAppInfoViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import UIKit
8 |
9 | /// A default AppInfoViewModel implementation for UIKit navigation.
10 | final class UIKitRouterAppInfoViewModel: AppInfoViewModel {
11 | @Published var viewState = AppInfoViewState.appUpToDate(currentVersion: "0.9")
12 | // @Published var viewState = AppInfoViewState.appUpdateAvailable(currentVersion: "0.9", availableVersion: "1.0")
13 | private let router: UIKitNavigationRouter
14 |
15 | /// A default initializer for AppInfoViewModel.
16 | ///
17 | /// - Parameter router: a navigation router.
18 | init(
19 | router: UIKitNavigationRouter
20 | ) {
21 | self.router = router
22 | }
23 |
24 | /// - SeeAlso: AppInfoViewModel.onAddAssetTapped()
25 | func addAssetTapped() {
26 | router.switch(toRoute: MainAppRoute.addAsset, withData: nil)
27 | }
28 |
29 | /// - SeeAlso: AppInfoViewModel.onAppUpdateTapped()
30 | func appUpdateTapped() {
31 | router.switch(toRoute: MainAppRoute.assetsList, withData: nil)
32 | }
33 | }
34 |
35 | extension UIKitRouterAppInfoViewModel {
36 | var viewStatePublished: Published { _viewState }
37 | var viewStatePublisher: Published.Publisher { $viewState }
38 | }
39 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/App Info Flow/AppInfoFlowCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppInfoFlowCoordinator.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | /// A coordinator handling add asset flow.
10 | final class AppInfoFlowCoordinator: FlowCoordinator {
11 |
12 | /// - SeeAlso: FlowCoordinator.parent
13 | let parent: FlowCoordinator?
14 |
15 | /// - SeeAlso: FlowCoordinator.completionCallback
16 | var completionCallback: (() -> Void)?
17 |
18 | /// - SeeAlso: FlowCoordinator.adaptivePresentationDelegate
19 | let adaptivePresentationDelegate: UIAdaptivePresentationControllerDelegate? = nil
20 |
21 | /// - SeeAlso: FlowCoordinator.child
22 | var child: FlowCoordinator? = nil
23 |
24 | /// - SeeAlso: FlowCoordinator.navigator
25 | let navigator: Navigator
26 |
27 | private let dependencyProvider: DependencyProvider
28 |
29 | /// A default initializer for MainAppFlowCoordinator.
30 | ///
31 | /// - Parameters:
32 | /// - navigator: a navigator.
33 | /// - dependencyProvider: a dependency provider.
34 | /// - parent: a flow coordinator parent.
35 | init(
36 | navigator: Navigator,
37 | dependencyProvider: DependencyProvider,
38 | parent: FlowCoordinator? = nil
39 | ) {
40 | self.navigator = navigator
41 | self.dependencyProvider = dependencyProvider
42 | self.parent = parent
43 | }
44 |
45 | /// - SeeAlso: FlowCoordinator.start(animated:)
46 | func start(animated: Bool) {
47 | let initialRoute = AppInfoRoute.appInfo
48 | let appInfo = makeViewComponents(forRoute: initialRoute, withData: nil)[0]
49 | appInfo.route = initialRoute
50 | navigator.pushViewController(appInfo.viewController, animated: animated)
51 | initialInternalRoute = initialRoute
52 | }
53 |
54 | /// - SeeAlso: FlowCoordinator.stop()
55 | func stop() {
56 | cleanUpNavigationStack()
57 | completionCallback?()
58 | }
59 |
60 | /// - SeeAlso: FlowCoordinator.show(route:withData:)
61 | func canShow(route: any Route) -> Bool {
62 | route as? AppInfoRoute != nil
63 | }
64 |
65 | /// - SeeAlso: FlowCoordinator.makeViewComponents(forRoute:withData:)
66 | func makeViewComponents(forRoute route: any Route, withData: AnyHashable?) -> [ViewComponent] {
67 | guard let route = route as? AppInfoRoute else {
68 | fatalError("Route \(route) is not supported by AppInfoFlowCoordinator")
69 | }
70 |
71 | switch route {
72 | case .appInfo:
73 | return [makeAppInfoViewController()]
74 | }
75 | }
76 |
77 | /// - SeeAlso: FlowCoordinator.makeFlowCoordinator(forRoute:navigator:withData:)
78 | func makeFlowCoordinator(forRoute route: any Route, navigator: Navigator, withData: AnyHashable?) -> FlowCoordinator {
79 | fatalError("makeFlowCoordinator(forRoute:navigator:withData:) has not been implemented")
80 | }
81 | }
82 |
83 | private extension AppInfoFlowCoordinator {
84 |
85 | func makeAppInfoViewController() -> ViewComponent {
86 | let viewModel = UIKitRouterAppInfoViewModel(
87 | router: dependencyProvider.router
88 | )
89 | return AppInfoView(viewModel: viewModel).viewController
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/App Info Flow/AppInfoRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppInfoRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An enumeration describing a routes in App Info route.
9 | enum AppInfoRoute {
10 |
11 | /// A route presenting app info.
12 | case appInfo
13 | }
14 |
15 | extension AppInfoRoute: Route {
16 |
17 | /// - SeeAlso: Route.path
18 | var name: String {
19 | switch self {
20 | case .appInfo:
21 | return "AppInfoRoute.AppInfo"
22 | }
23 | }
24 |
25 | /// - SeeAlso: Route.isFlow
26 | var isFlow: Bool {
27 | false
28 | }
29 |
30 | /// - SeeAlso: Route.popupPresentationStyle
31 | var popupPresentationStyle: PopupPresentationStyle {
32 | .none
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Main App Flow/Asset Details/UIKitRouterAssetDetailsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitRouterAssetDetailsViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | /// A default AssetDetailsViewModel implementation.
10 | final class UIKitRouterAssetDetailsViewModel: AssetDetailsViewModel {
11 | @Published var viewState = AssetDetailsViewState.loading
12 | let assetData: AssetDetailsViewData
13 |
14 | private let router: UIKitNavigationRouter
15 | private let favouriteAssetsManager: FavouriteAssetsManager
16 | private let historicalAssetRatesProvider: HistoricalAssetRatesProvider
17 |
18 | /// A default initializer for AssetDetailsViewModel.
19 | ///
20 | /// - Parameter assetId: an asset id.
21 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
22 | /// - Parameter historicalAssetRatesProvider: a historical asset rates provder.
23 | /// - Parameter router: a navigation router.
24 | init(
25 | assetId: String,
26 | favouriteAssetsManager: FavouriteAssetsManager,
27 | historicalAssetRatesProvider: HistoricalAssetRatesProvider,
28 | router: UIKitNavigationRouter
29 | ) {
30 | let asset = favouriteAssetsManager.retrieveFavouriteAssets().filter { $0.id == assetId }.first
31 | assetData = asset?.toAssetDetailsViewData() ?? .empty
32 | self.favouriteAssetsManager = favouriteAssetsManager
33 | self.historicalAssetRatesProvider = historicalAssetRatesProvider
34 | self.router = router
35 | viewState = .loading
36 | }
37 |
38 | /// - SeeAlso: AssetDetailsViewModel.edit(assetID:)
39 | func edit(asset assetID: String) {
40 | router.show(route: MainAppRoute.editAsset(assetId: assetID), withData: nil)
41 | }
42 |
43 | /// - SeeAlso: AssetDetailsViewModel.reloadChart(scope:)
44 | func reloadChart(scope: ChartView.Scope) async {
45 | await loadAssetChart(scope: scope)
46 | }
47 |
48 | /// - SeeAlso: AssetDetailsViewModel.showInitialChart()
49 | func showInitialChart() async {
50 | await loadAssetChart(scope: .week)
51 | }
52 | }
53 |
54 | private extension UIKitRouterAssetDetailsViewModel {
55 |
56 | @MainActor func loadAssetChart(scope: ChartView.Scope) async {
57 | let rates = await historicalAssetRatesProvider.getHistoricalRates(for: assetData.id, range: scope)
58 | let data = rates.map {
59 | ChartView.ChartPoint(label: $0.date, value: $0.value)
60 | }
61 | viewState = .loaded(data)
62 | }
63 | }
64 |
65 | extension UIKitRouterAssetDetailsViewModel {
66 | var viewStatePublished: Published { _viewState }
67 | var viewStatePublisher: Published.Publisher { $viewState }
68 | }
69 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Main App Flow/Edit Asset/UIKitRouterEditAssetViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitRouterEditAssetViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | /// A default EditAssetViewModel implementation.
10 | final class UIKitRouterEditAssetViewModel: EditAssetViewModel {
11 | @Published var viewState: EditAssetViewState
12 |
13 | private let router: UIKitNavigationRouter
14 | private let favouriteAssetsManager: FavouriteAssetsManager
15 |
16 | /// A default initializer for EditAssetViewModel.
17 | ///
18 | /// - Parameter assetId: an asset id.
19 | /// - Parameter favouriteAssetsManager: a favourite assets manager.
20 | /// - Parameter router: a navigation router.
21 | init(
22 | assetId: String,
23 | favouriteAssetsManager: FavouriteAssetsManager,
24 | router: UIKitNavigationRouter
25 | ) {
26 | self.favouriteAssetsManager = favouriteAssetsManager
27 | self.router = router
28 | viewState = UIKitRouterEditAssetViewModel.calculateViewState(
29 | favouriteAssetsManager: favouriteAssetsManager,
30 | assetId: assetId
31 | )
32 | }
33 |
34 | /// - SeeAlso: EditAssetViewModel.saveChanges(assetData:)
35 | func saveChanges(assetData: EditAssetViewData) {
36 | let asset = Asset(id: assetData.id, name: assetData.name, colorCode: assetData.color.toHex())
37 | var assets = favouriteAssetsManager.retrieveFavouriteAssets()
38 | assets.removeAll { $0.id == assetData.id }
39 | assets.insert(asset, at: assetData.position.currentPosition - 1)
40 | favouriteAssetsManager.store(favouriteAssets: assets)
41 | router.navigateBack(animated: true)
42 | }
43 | }
44 |
45 | private extension UIKitRouterEditAssetViewModel {
46 |
47 | static func calculateViewState(
48 | favouriteAssetsManager: FavouriteAssetsManager,
49 | assetId: String
50 | ) -> EditAssetViewState {
51 | let assets = favouriteAssetsManager.retrieveFavouriteAssets()
52 | guard let asset = assets.filter({ $0.id == assetId }).first else {
53 | return .assetNotFound
54 | }
55 |
56 | let currentPosition = assets.firstIndex(of: asset) ?? 0
57 | return .editAsset(
58 | EditAssetViewData(asset: asset, position: currentPosition, totalAssetCount: assets.count)
59 | )
60 | }
61 | }
62 |
63 | extension UIKitRouterEditAssetViewModel {
64 | var viewStatePublished: Published { _viewState }
65 | var viewStatePublisher: Published.Publisher { $viewState }
66 | }
67 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/App Flows/Main App Flow/MainAppRoute.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainAppRoute.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An enumeration describing a main app routes.
9 | enum MainAppRoute {
10 |
11 | /// An Asset Lists view:
12 | case assetsList
13 |
14 | /// An Asset Details view:
15 | case assetDetails(assetId: String)
16 |
17 | /// An Edit Asset view:
18 | case editAsset(assetId: String)
19 |
20 | /// A route to add a new asset.
21 | case addAsset
22 |
23 | /// A route to show app info.
24 | case appInfo
25 |
26 | /// A route to show app info as a standalone view.
27 | case appInfoStandalone
28 |
29 | /// An embedded flow (brand new Main App flow embedded in the existing one):
30 | case embeddedMainAppFlow
31 |
32 | /// An embedded flow showing app info.
33 | case appInfoEmbedded
34 |
35 | /// A main app flow presented as a popup:
36 | case popupMainAppFlow
37 |
38 | /// A hypothetical app route representing a drill-down navigation.
39 | /// E.g. showing immediately edit asset screen, but with go-back ability to go to asset details screen.
40 | case restoreNavigation(assetId: String)
41 |
42 | /// A hypothetical app route representing a single popup to be restored.
43 | /// As only one popup can be displayed at once, only the last view controller is restored.
44 | case restorePopupNavigation
45 | }
46 |
47 | extension MainAppRoute: Route {
48 |
49 | /// - SeeAlso: Route.path
50 | var name: String {
51 | switch self {
52 | case .assetsList:
53 | return "MainAppRoute.AssetsList"
54 | case .assetDetails:
55 | return "MainAppRoute.AssetDetails"
56 | case .editAsset:
57 | return "MainAppRoute.EditAsset"
58 | case .addAsset:
59 | return "MainAppRoute.AddAsset"
60 | case .appInfo:
61 | return "MainAppRoute.AppInfo"
62 | case .appInfoStandalone:
63 | return "MainAppRoute.AppInfoStandalone"
64 | case .appInfoEmbedded:
65 | return "MainAppRoute.AppInfoEmbedded"
66 | case .embeddedMainAppFlow:
67 | return "MainAppRoute.EmbeddedMainAppFlow"
68 | case .popupMainAppFlow:
69 | return "MainAppRoute.PopupMainAppFlow"
70 | case .restoreNavigation:
71 | return "MainAppRoute.RestoreNavigation"
72 | case .restorePopupNavigation:
73 | return "MainAppRoute.RestorePopupNavigation"
74 | }
75 | }
76 |
77 | /// - SeeAlso: Route.isFlow
78 | var isFlow: Bool {
79 | switch self {
80 | case .embeddedMainAppFlow, .popupMainAppFlow, .addAsset, .appInfo, .appInfoEmbedded:
81 | return true
82 | default:
83 | return false
84 | }
85 | }
86 |
87 | /// - SeeAlso: Route.popupPresentationStyle
88 | var popupPresentationStyle: PopupPresentationStyle {
89 | switch self {
90 | case .addAsset, .appInfo, .appInfoStandalone, .restorePopupNavigation:
91 | return .modal
92 | case .popupMainAppFlow:
93 | return Bool.random() ? .fullScreen : .modal
94 | default:
95 | return .none
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Component/ViewComponent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewComponent.swift
3 | // KISS Views
4 | //
5 |
6 | import ObjectiveC
7 | import UIKit
8 |
9 | /// An associated object key for a View Component route.
10 | private var ViewComponentRouteKey: UInt8 = 123
11 |
12 | /// An abstraction describing an UIKit visual component (a view that takes up entire screen).
13 | protocol ViewComponent: AnyObject {
14 |
15 | /// A reference to a view controller.
16 | var viewController: UIViewController { get }
17 |
18 | /// A route associated with the component.
19 | var route: any Route { get set }
20 | }
21 |
22 | extension UIViewController: ViewComponent {
23 |
24 | /// - SeeAlso: ViewComponent.viewController
25 | var viewController: UIViewController {
26 | self
27 | }
28 |
29 | /// - SeeAlso: ViewComponent.route
30 | var route: any Route {
31 | get {
32 | objc_getAssociatedObject(self, &ViewComponentRouteKey) as? any Route ?? EmptyRoute()
33 | }
34 | set {
35 | objc_setAssociatedObject(self, &ViewComponentRouteKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Component/ViewComponentFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewComponentFactory.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An abstraction describing a factory producing view components.
9 | protocol ViewComponentFactory {
10 |
11 | /// A method that produces a view components for a given route.
12 | ///
13 | /// - Parameters:
14 | /// - route: a route.
15 | /// - withData: an additional data.
16 | /// - Returns: a view components list.
17 | func makeViewComponents(forRoute route: any Route, withData: AnyHashable?) -> [ViewComponent]
18 | }
19 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/FlowCoordinatorFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FlowCoordinatorFactory.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | /// An abstraction describing a factory producing flow coordinators.
9 | protocol FlowCoordinatorFactory {
10 |
11 | /// A method that produces a flow coordinator for a given route.
12 | ///
13 | /// - Parameters:
14 | /// - route: a route.
15 | /// - navigator: a navigator.
16 | /// - withData: an additional data.
17 | /// - Returns: a flow coordinator.
18 | func makeFlowCoordinator(forRoute route: any Route, navigator: Navigator, withData: AnyHashable?) -> FlowCoordinator
19 | }
20 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Helpers/NavigationStackChangesHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationStackChangesHandler.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An object that handles navigation stack changes - a delegate for UINavigationController.
9 | final class NavigationStackChangesHandler: NSObject, UINavigationControllerDelegate {
10 | private let onRouteShown: ((any Route) -> Void)?
11 |
12 | /// A default initializer for NavigationStackChangesHandler.
13 | ///
14 | /// - Parameter onRouteShown: a callback to be executed on delegate call.
15 | init(onRouteShown: ((any Route) -> Void)?) {
16 | self.onRouteShown = onRouteShown
17 | }
18 |
19 | /// - SeeAlso: UINavigationControllerDelegate.navigationController(_:didShow:animated:)
20 | func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
21 | onRouteShown?(viewController.route)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Helpers/PopupDismissHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PopupDismissHandler.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// A simple handler for popup dismissal.
9 | final class PopupDismissHandler: NSObject, UIAdaptivePresentationControllerDelegate {
10 | private let onDismiss: (() -> Void)?
11 |
12 | /// A default initializer for PopupDismissHandler.
13 | ///
14 | /// - Parameter onDismiss: a callback to be executed on delegate call.
15 | init(onDismiss: (() -> Void)?) {
16 | self.onDismiss = onDismiss
17 | }
18 |
19 | /// - SeeAlso: UIAdaptivePresentationControllerDelegate.presentationControllerDidDismiss(_:)
20 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
21 | onDismiss?()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Navigtor/Navigator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Navigator.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An abstraction describing an object allowing to execute navigation actions.
9 | protocol Navigator: AnyObject {
10 |
11 | /// A navigation stack.
12 | var navigationStack: UINavigationController { get }
13 |
14 | /// A top view controller.
15 | var topViewController: UIViewController? { get }
16 |
17 | /// A visible view controller.
18 | var visibleViewController: UIViewController? { get }
19 |
20 | /// A list of view controllers on the navigation stack.
21 | var viewControllers: [UIViewController] { get }
22 |
23 | /// A flag indicating whether the navigation bar is hidden.
24 | var isNavigationBarHidden: Bool { get }
25 |
26 | /// A currently presented view controller.
27 | var presentedViewController: UIViewController? { get }
28 |
29 | /// A modal view presentation controller.
30 | var presentationController: UIPresentationController? { get }
31 |
32 | /// A delegate object for the navigation controller.
33 | var delegate: UINavigationControllerDelegate? { get set }
34 |
35 | func pushViewController(_ viewController: UIViewController, animated: Bool)
36 | func popViewController(animated: Bool) -> UIViewController?
37 | func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]?
38 | func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?)
39 | func dismiss(animated flag: Bool, completion: (() -> Void)?)
40 | func setViewControllers(_ viewControllers: [UIViewController], animated: Bool)
41 | func setNavigationBarHidden(_ hidden: Bool, animated: Bool)
42 | }
43 |
44 | extension Navigator {
45 |
46 | /// A helper method checking if a navigation stack contains a given route.
47 | ///
48 | /// - Parameter route: a route to check.
49 | /// - Returns: a flag indicating whether a navigation stack contains a given route.
50 | func contains(route: any Route) -> Bool {
51 | viewControllers.filter { $0.route.matches(route) }.isEmpty == false
52 | }
53 |
54 | /// A helper method returning an index of a view controller showing a given route.
55 | ///
56 | /// - Parameter route: a route to check.
57 | /// - Returns: an index of a view controller showing a given route.
58 | func index(for route: any Route) -> Int? {
59 | viewControllers.firstIndex { $0.route.matches(route) }
60 | }
61 | }
62 |
63 | extension UINavigationController: Navigator {
64 |
65 | /// - SeeAlso: `Navigator.navigationStack`
66 | var navigationStack: UINavigationController {
67 | self
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Flow Coordinator/Route/Route.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Route.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An enum describing a popup presentation style.
9 | enum PopupPresentationStyle: Equatable {
10 | /// No popup presentation.
11 | case none
12 |
13 | /// A full screen popup presentation.
14 | case fullScreen
15 |
16 | /// A modal popup presentation.
17 | case modal
18 | }
19 |
20 | extension PopupPresentationStyle {
21 |
22 | /// A convenience property that returns a UIKit modal presentation style.
23 | var modalPresentationStyle: UIModalPresentationStyle {
24 | switch self {
25 | case .none:
26 | return .none
27 | case .fullScreen:
28 | return .fullScreen
29 | case .modal:
30 | return .pageSheet
31 | }
32 | }
33 | }
34 |
35 | /// A navigation route that can be used to navigate to a specific screen or flow.
36 | protocol Route: Equatable {
37 |
38 | /// The name of the route.
39 | var name: String { get }
40 |
41 | /// Whether the route is a separate flow.
42 | var isFlow: Bool { get }
43 |
44 | /// A route popup presentation mode.
45 | var popupPresentationStyle: PopupPresentationStyle { get }
46 | }
47 |
48 | extension Route {
49 |
50 | /// A convenience property that returns whether the route is a popup.
51 | var isPopup: Bool {
52 | switch popupPresentationStyle {
53 | case .none:
54 | return false
55 | case .fullScreen, .modal:
56 | return true
57 | }
58 | }
59 |
60 | /// A convenience method that returns whether the route matches the given route.
61 | func matches(_ route: any Route) -> Bool {
62 | name == route.name && isFlow == route.isFlow && popupPresentationStyle == route.popupPresentationStyle
63 | }
64 | }
65 |
66 | /// An empty implementation of the `Route` protocol.
67 | struct EmptyRoute: Route {
68 |
69 | /// - SeeAlso: `Route.name`
70 | var name: String {
71 | ""
72 | }
73 |
74 | /// - SeeAlso: `Route.isFlow`
75 | var isFlow: Bool {
76 | false
77 | }
78 |
79 | /// - SeeAlso: `Route.popupPresentationStyle`
80 | var popupPresentationStyle: PopupPresentationStyle {
81 | .none
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/RootViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RootViewController.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | /// An abstraction describing a root view controller.
9 | protocol RootView {
10 |
11 | /// Marks the root view for takedown.
12 | /// Call when you want want to remove or replace the root view controller for the app.
13 | func markForTakedown()
14 | }
15 |
16 | /// A UIKit navigation root view controller.
17 | final class RootViewController: UINavigationController {
18 | private let completion: (() -> Void)?
19 |
20 | /// A default initializer for RootViewController.
21 | ///
22 | /// - Parameter completion: a completion block.
23 | init(completion: (() -> Void)?) {
24 | self.completion = completion
25 | super.init(nibName: nil, bundle: nil)
26 | }
27 |
28 | /// - SeeAlso: UIViewController.init(coder:)
29 | required init?(coder: NSCoder) {
30 | fatalError("init(coder:) has not been implemented")
31 | }
32 |
33 | /// - SeeAlso: UIViewController.viewDidLoad()
34 | override func viewDidLoad() {
35 | super.viewDidLoad()
36 | setNavigationBarHidden(false, animated: false)
37 | }
38 | }
39 |
40 | extension RootViewController: RootView {
41 |
42 | func markForTakedown() {
43 | completion?()
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/Router/UIKitNavigationRouter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitNavigationRouter.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | // MARK: - UIKitNavigationRouter
9 |
10 | /// An abstraction describing a UIKit navigation router.
11 | protocol UIKitNavigationRouter: AnyObject {
12 |
13 | /// Provides a currently shown application flow.
14 | var currentFlow: FlowCoordinator? { get }
15 |
16 | /// Shows a route in the flow.
17 | ///
18 | /// - Parameters:
19 | /// - route: a route to show.
20 | /// - withData: an optional data necessary to create a view.
21 | func show(route: any Route, withData: AnyHashable?)
22 |
23 | /// Switches to a route.
24 | func `switch`(toRoute route: any Route, withData: AnyHashable?)
25 |
26 | /// Navigates back one view.
27 | ///
28 | /// - Parameter animated: a flag indicating whether the navigation should be animated.
29 | func navigateBack(animated: Bool)
30 |
31 | /// Stops the current flow.
32 | func stopCurrentFlow()
33 |
34 | /// Navigates back to the root view of the flow.
35 | ///
36 | /// - Parameter animated: a flag indicating whether the navigation should be animated.
37 | func navigateBackToRoot(animated: Bool)
38 |
39 | /// Navigates back to an already shown route.
40 | ///
41 | /// - Parameters:
42 | /// - route: a route to navigate back to.
43 | /// - animated: a flag indicating whether the navigation should be animated.
44 | func navigateBack(toRoute route: any Route, animated: Bool)
45 |
46 | /// Starts the initial flow.
47 | ///
48 | /// - Parameters:
49 | /// - initialFlow: an initial flow to start.
50 | /// - animated: a flag indicating whether the navigation should be animated.
51 | func start(initialFlow: FlowCoordinator, animated: Bool)
52 | }
53 |
54 | // MARK: - DefaultUIKitNavigationRouter
55 |
56 | /// A default implementation of UIKitNavigationRouter.
57 | final class DefaultUIKitNavigationRouter: UIKitNavigationRouter {
58 | private var initialFlow: FlowCoordinator?
59 |
60 | /// - SeeAlso: UIKitNavigationRouter.startInitialFlow(initialFlow:animated:)
61 | func start(initialFlow: FlowCoordinator, animated: Bool) {
62 | self.initialFlow = initialFlow
63 | initialFlow.start(animated: animated)
64 | }
65 |
66 | /// - SeeAlso: UIKitNavigationRouter.show(route:withData:)
67 | func show(route: any Route, withData: AnyHashable?) {
68 | if currentFlow?.canShow(route: route) == true {
69 | currentFlow?.show(route: route, withData: withData)
70 | }
71 | }
72 |
73 | /// - SeeAlso: UIKitNavigationRouter.switch(toRoute:withData:)
74 | func `switch`(toRoute route: any Route, withData: AnyHashable?) {
75 | currentFlow?.switch(toRoute: route, withData: withData)
76 | }
77 |
78 | /// - SeeAlso: UIKitNavigationRouter.navigateBack(animated:)
79 | func navigateBack(animated: Bool) {
80 | currentFlow?.navigateBack(animated: animated)
81 | }
82 |
83 | /// - SeeAlso: UIKitNavigationRouter.navigateBackToRoot(animated:)
84 | func navigateBackToRoot(animated: Bool) {
85 | currentFlow?.navigateBackToRoot(animated: animated, dismissPopup: true)
86 | }
87 |
88 | /// - SeeAlso: UIKitNavigationRouter.navigateBack(toRoute:animated:)
89 | func navigateBack(toRoute route: any Route, animated: Bool) {
90 | currentFlow?.navigateBack(toRoute: route, animated: animated, dismissPopup: true)
91 | }
92 |
93 | /// - SeeAlso: UIKitNavigationRouter.stopCurrentFlow()
94 | func stopCurrentFlow() {
95 | currentFlow?.stop()
96 | }
97 | }
98 |
99 | // MARK: - Helpers
100 |
101 | extension DefaultUIKitNavigationRouter {
102 |
103 | /// Returns the current flow.
104 | var currentFlow: FlowCoordinator? {
105 | getCurrentFlow(base: initialFlow)
106 | }
107 | }
108 |
109 | // MARK: - Private
110 |
111 | private extension DefaultUIKitNavigationRouter {
112 |
113 | func getCurrentFlow(base: FlowCoordinator?) -> FlowCoordinator? {
114 | if let child = base?.child {
115 | return getCurrentFlow(base: child)
116 | }
117 | return base
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI Navigation/UIKit Router/UIKitRouterHomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitRouterHomeView.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | /// A SwiftUI wrapper for UIKit Router navigation.
10 | struct UIKitRouterHomeView: UIViewControllerRepresentable {
11 | typealias UIViewControllerType = RootViewController
12 |
13 | private let dependencyProvider: DependencyProvider
14 | private let rootViewController: RootViewController
15 |
16 | /// A default initializer for UIKitRouterHomeView.
17 | ///
18 | /// - Parameters:
19 | /// - dependencyProvider: a dependency provider.
20 | /// - rootViewController: a root view controller.
21 | init(
22 | dependencyProvider: DependencyProvider,
23 | rootViewController: RootViewController
24 | ) {
25 | self.dependencyProvider = dependencyProvider
26 | self.rootViewController = rootViewController
27 | // TODO: Stop current flow coordinator when the completion is called. It's not leaking memory, but...
28 | }
29 |
30 | func makeUIViewController(context: Context) -> UIViewControllerType {
31 | let initialFlow = MainAppFlowCoordinator(
32 | navigator: dependencyProvider.rootAppNavigator,
33 | dependencyProvider: dependencyProvider
34 | )
35 | router.start(initialFlow: initialFlow, animated: false)
36 | return rootViewController
37 | }
38 |
39 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
40 |
41 | func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Void) {}
42 |
43 | func makeCoordinator() {}
44 |
45 | func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIViewControllerType, context: Context) -> CGSize? {
46 | guard let width = proposal.width, let height = proposal.height else {
47 | return nil
48 | }
49 | return uiViewController.view.sizeThatFits(CGSize(width: width, height: height))
50 | }
51 | }
52 |
53 | private extension UIKitRouterHomeView {
54 |
55 | var router: UIKitNavigationRouter {
56 | dependencyProvider.router
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeAlertPresenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeAlertPresenter.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeAlertPresenter: AlertPresenter {
11 |
12 | func showInfoAlert(on viewController: UIViewController, title: String, message: String?, buttonTitle: String, completion: (() -> Void)?) {}
13 |
14 | func showAcceptanceAlert(on viewController: UIViewController, title: String, message: String?, yesActionTitle: String, noActionTitle: String, yesActionStyle: UIAlertAction.Style, noActionStyle: UIAlertAction.Style, completion: ((AcceptanceAlertAction) -> Void)?) {}
15 | }
16 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeAssetsProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeAssetsProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final actor FakeAssetsProvider: AssetsProvider {
11 | var simulatedAssets: [Asset]?
12 |
13 | func getAllAssets() async -> [Asset] {
14 | simulatedAssets ?? []
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeAssetsRatesProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeAssetsRatesProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final actor FakeAssetsRatesProvider: AssetsRatesProvider {
11 | var simulatedAssetPerformance: [AssetPerformance]?
12 |
13 | func getAssetRates() async -> [AssetPerformance] {
14 | simulatedAssetPerformance ?? []
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeDependencyProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeDependencyProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeDependencyProvider: DependencyProvider {
11 | let fakeRootAppNavigator = FakeNavigator()
12 | let fakeFavouriteAssetsManager = FakeFavouriteAssetsManager()
13 | let fakeAssetsProvider = FakeAssetsProvider()
14 | let fakeUIKitNavigationRouter = FakeUIKitNavigationRouter()
15 | let fakeAssetsRatesProvider = FakeAssetsRatesProvider()
16 | let fakeHistoricalAssetRatesProvider = FakeHistoricalAssetRatesProvider()
17 | let fakeAlertPresenter = FakeAlertPresenter()
18 |
19 | var rootAppNavigator: Navigator {
20 | fakeRootAppNavigator
21 | }
22 |
23 | var favouriteAssetsManager: FavouriteAssetsManager {
24 | fakeFavouriteAssetsManager
25 | }
26 |
27 | var assetsProvider: AssetsProvider {
28 | fakeAssetsProvider
29 | }
30 |
31 | var assetsRatesProvider: AssetsRatesProvider {
32 | fakeAssetsRatesProvider
33 | }
34 |
35 | var historicalAssetRatesProvider: HistoricalAssetRatesProvider {
36 | fakeHistoricalAssetRatesProvider
37 | }
38 |
39 | var router: UIKitNavigationRouter {
40 | fakeUIKitNavigationRouter
41 | }
42 |
43 | var alertPresenter: AlertPresenter {
44 | fakeAlertPresenter
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeFavouriteAssetsManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeFavouriteAssetsManager.swift
3 | // KISS Views
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | @testable import SwiftUI_Navigation
10 |
11 | final class FakeFavouriteAssetsManager: FavouriteAssetsManager {
12 | var simulatedFavouriteAssets: [Asset]?
13 | var didChangeSubject = PassthroughSubject()
14 | var didChange: AnyPublisher {
15 | didChangeSubject.eraseToAnyPublisher()
16 | }
17 |
18 | func retrieveFavouriteAssets() -> [Asset] {
19 | simulatedFavouriteAssets ?? []
20 | }
21 |
22 | func store(favouriteAssets assets: [Asset]) {
23 | simulatedFavouriteAssets = assets
24 | }
25 |
26 | func clear() {
27 | simulatedFavouriteAssets = []
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeFlowCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeFlowCoordinator.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeFlowCoordinator: FlowCoordinator {
11 | var simulatedNavigator: FakeNavigator?
12 | var simulatedParent: FlowCoordinator?
13 | var simulatedCanShow: Bool?
14 |
15 | private(set) var lastSwitchedToRoute: (any Route)?
16 | private(set) var lastSwitchedToRouteData: AnyHashable?
17 | private(set) var lastShownRoute: (any Route)?
18 | private(set) var lastShownRouteData: AnyHashable?
19 | private(set) var lastNavigatedBackAnimated: Bool?
20 | private(set) var lastNavigatedBackToRootAnimated: Bool?
21 | private(set) var lastNavigatedBackToRoute: (any Route)?
22 | private(set) var lastNavigatedBackToRouteAnimated: Bool?
23 | private(set) var lastDidStartAnimated: Bool?
24 | private(set) var lastDidStop: Bool?
25 |
26 | var navigator: Navigator {
27 | simulatedNavigator ?? UINavigationController()
28 | }
29 |
30 | var parent: FlowCoordinator? {
31 | simulatedParent
32 | }
33 |
34 | var child: FlowCoordinator?
35 | var completionCallback: (() -> Void)?
36 |
37 | func start(animated: Bool) {
38 | lastDidStartAnimated = animated
39 | initialInternalRoute = MainAppRoute.assetsList
40 | }
41 |
42 | func stop() {
43 | lastDidStop = true
44 | }
45 |
46 | func canShow(route: any Route) -> Bool {
47 | simulatedCanShow ?? false
48 | }
49 |
50 | func show(route: any Route, withData: AnyHashable?) {
51 | lastShownRoute = route
52 | lastShownRouteData = withData
53 | }
54 |
55 | func `switch`(toRoute route: any Route, withData: AnyHashable?) {
56 | lastSwitchedToRoute = route
57 | lastSwitchedToRouteData = withData
58 | }
59 |
60 | func navigateBack(animated: Bool) {
61 | lastNavigatedBackAnimated = animated
62 | }
63 |
64 | func navigateBackToRoot(animated: Bool, dismissPopup: Bool) {
65 | lastNavigatedBackToRootAnimated = animated
66 | }
67 |
68 | func navigateBack(toRoute route: any Route, animated: Bool, dismissPopup: Bool) {
69 | lastNavigatedBackToRoute = route
70 | lastNavigatedBackToRouteAnimated = animated
71 | }
72 |
73 | func makeViewComponents(forRoute route: any Route, withData: AnyHashable?) -> [ViewComponent] {
74 | []
75 | }
76 |
77 | func makeFlowCoordinator(forRoute route: any Route, navigator: Navigator, withData: AnyHashable?) -> FlowCoordinator {
78 | FakeFlowCoordinator()
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeHistoricalAssetRatesProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeHistoricalAssetRatesProvider.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final actor FakeHistoricalAssetRatesProvider: HistoricalAssetRatesProvider {
11 | var simulatedHistoricalRates: [AssetHistoricalRate]?
12 |
13 | func getHistoricalRates(for assetID: String, range: ChartView.Scope) async -> [AssetHistoricalRate] {
14 | simulatedHistoricalRates ?? []
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeNavigator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeNavigator.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 | import XCTest
8 |
9 | @testable import SwiftUI_Navigation
10 |
11 | final class FakeNavigator: Navigator {
12 | var simulatedNavigationStack: UINavigationController?
13 | var simulatedTopViewController: UIViewController?
14 | var simulatedVisibleViewController: UIViewController?
15 | var simulatedViewControllers: [UIViewController] = []
16 | var simulatedIsNavigationBarHidden: Bool?
17 | var simulatedPresentedViewController: UIViewController?
18 | var simulatedPresentationController: UIPresentationController?
19 | var delegate: UINavigationControllerDelegate?
20 |
21 | private(set) var lastPushedViewController: UIViewController?
22 | private(set) var lastPushedViewControllerAnimation: Bool?
23 | private(set) var lastPoppedToViewControllerAnimation: Bool?
24 | private(set) var lastPoppedToViewController: UIViewController?
25 | private(set) var lastPresentedViewController: UIViewController?
26 | private(set) var lastPresentedViewControllerAnimation: Bool?
27 | private(set) var lastDismissedViewControllerAnimation: Bool?
28 |
29 | var navigationStack: UINavigationController {
30 | simulatedNavigationStack ?? UINavigationController()
31 | }
32 |
33 | var topViewController: UIViewController? {
34 | simulatedTopViewController
35 | }
36 |
37 | var visibleViewController: UIViewController? {
38 | simulatedVisibleViewController
39 | }
40 |
41 | var viewControllers: [UIViewController] {
42 | simulatedViewControllers
43 | }
44 |
45 | var isNavigationBarHidden: Bool {
46 | simulatedIsNavigationBarHidden ?? false
47 | }
48 |
49 | var presentedViewController: UIViewController? {
50 | simulatedPresentedViewController
51 | }
52 |
53 | var presentationController: UIPresentationController? {
54 | simulatedPresentationController
55 | }
56 |
57 | func pushViewController(_ viewController: UIViewController, animated: Bool) {
58 | simulatedViewControllers.append(viewController)
59 | lastPushedViewController = viewController
60 | }
61 |
62 | func popViewController(animated: Bool) -> UIViewController? {
63 | if !simulatedViewControllers.isEmpty {
64 | simulatedViewControllers.removeLast()
65 | }
66 | lastPoppedToViewControllerAnimation = animated
67 | return nil
68 | }
69 |
70 | func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
71 | lastPoppedToViewController = viewController
72 | lastPoppedToViewControllerAnimation = animated
73 | let index = simulatedViewControllers.firstIndex(of: viewController) ?? 0
74 | simulatedViewControllers = Array(simulatedViewControllers[0...index])
75 | return nil
76 | }
77 |
78 | func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) {
79 | simulatedPresentedViewController = viewControllerToPresent
80 | lastPresentedViewController = viewControllerToPresent
81 | lastPresentedViewControllerAnimation = flag
82 | completion?()
83 | }
84 |
85 | func dismiss(animated flag: Bool, completion: (() -> Void)?) {
86 | simulatedPresentedViewController = nil
87 | lastDismissedViewControllerAnimation = flag
88 | completion?()
89 | }
90 |
91 | func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
92 | simulatedViewControllers = viewControllers
93 | }
94 |
95 | func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
96 | simulatedIsNavigationBarHidden = hidden
97 | }
98 | }
99 |
100 | extension FakeNavigator {
101 |
102 | func simulateBackButtonTapped(viewToPopTo view: UIViewController, animated: Bool = false) {
103 | _ = simulatedViewControllers.popLast()
104 | delegate?.navigationController?(navigationStack, didShow: view, animated: animated)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeSwiftUINavigationRouter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeSwiftUINavigationRouter.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeSwiftUINavigationRouter: SwiftUINavigationRouter {
11 | @Published var navigationRoute: NavigationRoute?
12 | @Published var presentedPopup: PopupRoute?
13 | @Published var presentedAlert: AlertRoute?
14 |
15 | private(set) var navigationStack: [NavigationRoute] = []
16 |
17 | func set(navigationStack: [NavigationRoute]) {
18 | self.navigationStack = navigationStack
19 | }
20 | }
21 |
22 | extension FakeSwiftUINavigationRouter {
23 | var navigationPathPublished: Published { _navigationRoute }
24 | var navigationPathPublisher: Published.Publisher { $navigationRoute }
25 | var presentedPopupPublished: Published { _presentedPopup }
26 | var presentedPopupPublisher: Published.Publisher { $presentedPopup }
27 | var presentedAlertPublished: Published { _presentedAlert }
28 | var presentedAlertPublisher: Published.Publisher { $presentedAlert }
29 |
30 | func present(popup: PopupRoute) {}
31 | func dismiss() {}
32 | func push(route: NavigationRoute) {}
33 | func pop() {}
34 | func popAll() {}
35 | func show(alert: AlertRoute) {}
36 | func hideCurrentAlert() {}
37 | }
38 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeSwiftUIRouterHomeViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeSwiftUIRouterHomeViewModel.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeSwiftUIRouterHomeViewModel: SwiftUIRouterHomeViewModel {
11 | var fakeFavouriteAssetsManager = FakeFavouriteAssetsManager()
12 | var favouriteAssetsManager: FavouriteAssetsManager {
13 | fakeFavouriteAssetsManager
14 | }
15 |
16 | var canRestoreNavState = true
17 |
18 | func removeAssetFromFavourites(id: String) {}
19 |
20 | func getRandomFavouriteAsset() -> Asset? {
21 | nil
22 | }
23 |
24 | func editAssets(id: String) {}
25 | }
26 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeUIKitNavigationRouter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeUIKitNavigationRouter.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 |
8 | @testable import SwiftUI_Navigation
9 |
10 | final class FakeUIKitNavigationRouter: UIKitNavigationRouter {
11 |
12 | var currentFlow: FlowCoordinator?
13 |
14 | func show(route: any Route, withData: AnyHashable?) {}
15 |
16 | func `switch`(toRoute route: any Route, withData: AnyHashable?) {}
17 |
18 | func navigateBack(animated: Bool) {}
19 |
20 | func stopCurrentFlow() {}
21 |
22 | func navigateBackToRoot(animated: Bool) {}
23 |
24 | func navigateBack(toRoute route: any Route, animated: Bool) {}
25 |
26 | func start(initialFlow: FlowCoordinator, animated: Bool) {}
27 | }
28 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Fakes and Mocks/FakeUINavigationController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FakeUINavigationController.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | final class FakeUINavigationController: UINavigationController {
9 |
10 | private(set) var lastPresentedViewController: UIViewController?
11 |
12 | override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
13 | lastPresentedViewController = viewControllerToPresent
14 | super.present(viewControllerToPresent, animated: flag, completion: completion)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/NavigationRouterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationRouterTests.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import XCTest
8 |
9 | import Foundation
10 | import XCTest
11 |
12 | @testable import SwiftUI_Navigation
13 |
14 | final class DefaultNavigationRouterTest: XCTestCase {
15 | var sut: DefaultSwiftUINavigationRouter!
16 |
17 | override func setUp() {
18 | sut = DefaultSwiftUINavigationRouter()
19 | }
20 |
21 | func test_whenInitialisingNavigationStack_itShouldStartEmpty() {
22 | XCTAssertEqual(sut.navigationStack.count, 0, "Should start w empty navigation stack")
23 | XCTAssertEqual(sut.presentedPopup, nil, "Should start with no presented popup")
24 | XCTAssertEqual(sut.navigationRoute, nil, "Should start with no presented route")
25 | XCTAssertEqual(sut.presentedAlert, nil, "Should start with no presented alert")
26 | }
27 |
28 | func test_whenPushingAndPoppingViews_shouldUpdateNavigationStack() {
29 | // given:
30 | let fixtureFirstScreen = NavigationRoute.editAsset("abc")
31 | let fixtureSecondScreen = NavigationRoute.editAsset("efg")
32 |
33 | // when:
34 | sut.push(route: fixtureFirstScreen)
35 | sut.push(route: fixtureSecondScreen)
36 |
37 | // then:
38 | XCTAssertEqual(sut.navigationRoute, fixtureSecondScreen, "Should be showing proper screen")
39 | XCTAssertEqual(sut.navigationStack.count, 2, "Should update navigation stack")
40 |
41 | // when:
42 | sut.pop()
43 |
44 | // then:
45 | XCTAssertEqual(sut.navigationRoute, fixtureFirstScreen, "Should pop the screen from navigation stack")
46 | }
47 |
48 | func test_whenPresentingAndDismissingPopup_shouldNotifySubscribers() {
49 | // given:
50 | let fixtureFirstPopup = PopupRoute.addAsset
51 |
52 | // when:
53 | sut.present(popup: fixtureFirstPopup)
54 |
55 | // then:
56 | XCTAssertEqual(sut.presentedPopup, fixtureFirstPopup, "Should be showing second popup")
57 |
58 | // given:
59 | let fixtureSecondPopup = PopupRoute.homeView
60 |
61 | // when:
62 | sut.present(popup: fixtureSecondPopup)
63 |
64 | // then:
65 | XCTAssertEqual(sut.presentedPopup, fixtureSecondPopup, "Should be showing second popup")
66 |
67 | // when:
68 | sut.dismiss()
69 |
70 | // then:
71 | XCTAssertEqual(sut.presentedPopup, nil, "Should be dismiss the popup")
72 | }
73 |
74 | func test_whenShowingAndHiding_shouldNotifySubscribers() {
75 | // given:
76 | let fixtureAlert = AlertRoute.deleteAsset(assetId: "abc", assetName: "ABC")
77 |
78 | // when:
79 | sut.show(alert: fixtureAlert)
80 |
81 | // then:
82 | XCTAssertEqual(sut.presentedAlert, fixtureAlert, "Should be showing an alert")
83 |
84 | // when:
85 | sut.hideCurrentAlert()
86 |
87 | // then:
88 | XCTAssertEqual(sut.presentedAlert, nil, "Should hide current alert")
89 | }
90 |
91 | func test_whenSettingNavigationStack_shouldReplaceCurrentlyDisplayedScreens() {
92 | // given:
93 | let fixtureFirstScreen = NavigationRoute.assetDetails("abc")
94 | let fixtureSecondScreen = NavigationRoute.editAsset("xyz")
95 | sut.push(route: fixtureFirstScreen)
96 |
97 | // when:
98 | sut.set(navigationStack: [fixtureSecondScreen])
99 |
100 | // then:
101 | XCTAssertEqual(sut.navigationRoute, fixtureSecondScreen, "Should set navigation stack accordingly")
102 | XCTAssertEqual(sut.navigationStack.count, 1, "Should replace all previous screens in navigation stack")
103 | }
104 |
105 | func test_whenPoppingAllScreens_shouldClearNavigationStack() {
106 | // given:
107 | let fixtureFirstScreen = NavigationRoute.assetDetails("abc")
108 | let fixtureSecondScreen = NavigationRoute.editAsset("xyz")
109 | sut.push(route: fixtureFirstScreen)
110 | sut.push(route: fixtureFirstScreen)
111 | sut.push(route: fixtureSecondScreen)
112 | sut.push(route: fixtureSecondScreen)
113 | sut.push(route: fixtureSecondScreen) // Testing if we can add multiple instances of the same view.
114 |
115 | // then:
116 | XCTAssertEqual(sut.navigationStack.count, 5, "Should show all the added views")
117 |
118 | // when:
119 | sut.popAll()
120 |
121 | // then:
122 | XCTAssertEqual(sut.navigationRoute, nil, "Should show no screen")
123 | XCTAssertEqual(sut.navigationStack.count, 0, "Should pop all screens from nav stack")
124 | }
125 |
126 | func test_whenSettingNavigationStack_shouldShowWholeCollectionOfViews() {
127 | // given:
128 | let fixtureFirstScreen = NavigationRoute.assetDetails("abc")
129 | let fixtureSecondScreen = NavigationRoute.editAsset("xyz")
130 | sut.set(navigationStack: [fixtureFirstScreen, fixtureSecondScreen])
131 |
132 | // when:
133 | sut.pop()
134 |
135 | // then:
136 | XCTAssertEqual(sut.navigationRoute, fixtureFirstScreen, "Should show first app screen")
137 | XCTAssertEqual(sut.navigationStack.count, 1, "Should have only one screen on the stack")
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/SwiftUIRouterHomeViewTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIRouterHomeViewTests.swift
3 | // KISS Views
4 | //
5 |
6 | import SnapshotTesting
7 | import SwiftUI
8 | import UIKit
9 | import XCTest
10 |
11 | @testable import SwiftUI_Navigation
12 |
13 | final class SwiftUIRouterHomeViewTest: XCTestCase {
14 | var fakeSwiftUIRouterHomeViewModel: FakeSwiftUIRouterHomeViewModel!
15 | var fakeNavigationRouter: FakeSwiftUINavigationRouter!
16 | var sut: SwiftUIRouterHomeView!
17 |
18 | override func setUp() {
19 | fakeSwiftUIRouterHomeViewModel = FakeSwiftUIRouterHomeViewModel()
20 | fakeNavigationRouter = FakeSwiftUINavigationRouter()
21 | sut = SwiftUIRouterHomeView(viewModel: fakeSwiftUIRouterHomeViewModel, router: fakeNavigationRouter)
22 | }
23 |
24 | func test_whenInitialized_shouldShowStartView() {
25 | executeSnapshotTests(forView: sut, named: "SwiftUIRouterNavi_Home_InitialView")
26 | }
27 |
28 | func test_whenNavigationStackIsSet_shouldPushProperView() {
29 | // given:
30 | let fixtureId = "AU"
31 | let fixtureAsset = Asset(id: fixtureId, name: "Gold", colorCode: nil)
32 | let fixtureAsset2 = Asset(id: "BTC", name: "Bitcoin", colorCode: nil)
33 | fakeSwiftUIRouterHomeViewModel.fakeFavouriteAssetsManager.simulatedFavouriteAssets = [fixtureAsset, fixtureAsset2]
34 |
35 | // when:
36 | fakeNavigationRouter.set(navigationStack: [.assetDetails(fixtureId), .editAsset(fixtureId)])
37 |
38 | // then:
39 | executeSnapshotTests(forView: sut, named: "SwiftUIRouterNavi_Home_PushedView")
40 |
41 | // when:
42 | fakeNavigationRouter.set(navigationStack: [.assetDetails(fixtureId)])
43 |
44 | // then:
45 | executeSnapshotTests(forView: sut, named: "SwiftUIRouterNavi_Home_PushedView_Popped")
46 | }
47 |
48 | func test_whenSettingPopup_shouldPresentProperView() throws {
49 | // given:
50 | let vc = UIHostingController(rootView: sut)
51 | fakeNavigationRouter.presentedPopup = .appInfo
52 |
53 | // when:
54 | waitForDisplayListRedraw()
55 | let window = try XCTUnwrap(getAppKeyWindow(withRootViewController: vc), "Should have an access to app key window")
56 | waitForViewHierarchyRedraw(window: window)
57 |
58 | // then:
59 | executeSnapshotTests(appWindow: window, named: "SwiftUIRouterNavi_Home_PresentedView")
60 | }
61 |
62 | func test_whenSettingAlert_shouldPresentProperAlert() throws {
63 | // given:
64 | let vc = UIHostingController(rootView: sut)
65 | fakeNavigationRouter.presentedAlert = .deleteAsset(assetId: "AU", assetName: "Gold")
66 |
67 | // when:
68 | waitForDisplayListRedraw()
69 | let window = try XCTUnwrap(getAppKeyWindow(withRootViewController: vc), "Should have an access to app key window")
70 | waitForViewHierarchyRedraw(window: window)
71 |
72 | // then:
73 | executeSnapshotTests(appWindow: window, named: "SwiftUIRouterNavi_Home_PresentedAlert")
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_InitialView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_InitialView.png
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PresentedAlert.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PresentedAlert.png
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PresentedView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PresentedView.png
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PushedView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PushedView.png
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PushedView_Popped.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pkozielecki/ios-swiftui-navigation/99756ca3f0df0c9691371adb052fc62a02124e81/SwiftUI Navigation/SwiftUI NavigationTests/SwiftUI Router/__Snapshots__/SwiftUIRouterHomeViewTests/iPhone12.SwiftUIRouterNavi_Home_PushedView_Popped.png
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/SnapshottingExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshottingExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import SnapshotTesting
7 | import SwiftUI
8 | import UIKit
9 | import XCTest
10 |
11 | // Discussion: An extension to SnapshotTesting library allowing to make a snapshot of an entire key app Window.
12 | extension Snapshotting where Value: UIWindow, Format == UIImage {
13 | static func makeAppWindow(precision: Float) -> Snapshotting {
14 | Snapshotting.image(precision: precision, perceptualPrecision: 0.98).asyncPullback { window in
15 | Async { callback in
16 | UIView.setAnimationsEnabled(false)
17 | DispatchQueue.main.async {
18 | let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
19 | window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
20 | }
21 | callback(image)
22 | UIView.setAnimationsEnabled(true)
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/UIAlertControllerExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIAlertControllerExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | extension UIAlertController {
9 |
10 | typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
11 |
12 | func tapButton(atIndex index: Int) {
13 | if let block = actions[index].value(forKey: "handler") {
14 | let blockPtr = UnsafeRawPointer(Unmanaged.passUnretained(block as AnyObject).toOpaque())
15 | let handler = unsafeBitCast(blockPtr, to: AlertHandler.self)
16 | handler(actions[index])
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/UIApplicationExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplicationExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | extension UIApplication {
9 |
10 | var keySceneWindow: UIWindow? {
11 | let connectedScenes = UIApplication.shared.connectedScenes
12 | for scene in connectedScenes {
13 | if let windowScene = scene as? UIWindowScene {
14 | for window in windowScene.windows {
15 | if window.isKeyWindow {
16 | return window
17 | }
18 | }
19 | }
20 | }
21 | return nil
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/UIViewControllerExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIViewControllerExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | extension UIViewController {
9 |
10 | func forceLightMode() {
11 | overrideUserInterfaceStyle = .light
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/UIViewExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIViewExtension.swift
3 | // KISS Views
4 | //
5 |
6 | import UIKit
7 |
8 | extension UIView {
9 |
10 | func forceLightMode() {
11 | overrideUserInterfaceStyle = .light
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/Test Helpers/XCTestCaseSnapshotExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // XCTestCaseSnapshotExtensions.swift
3 | // KISS Views
4 | //
5 |
6 | import SnapshotTesting
7 | import SwiftUI
8 | import UIKit
9 | import XCTest
10 |
11 | extension XCTestCase {
12 |
13 | // Executes a snapshot tests for a provided SwiftUI view:
14 | func executeSnapshotTests(
15 | forView view: any View,
16 | named name: String,
17 | precision: Float = 0.995,
18 | isRecording: Bool = false,
19 | file: StaticString = #file,
20 | functionName: String = #function,
21 | line: UInt = #line
22 | ) {
23 | executeSnapshotTests(
24 | forViewController: view.viewController,
25 | named: name,
26 | precision: precision,
27 | isRecording: isRecording,
28 | file: file,
29 | functionName: functionName,
30 | line: line
31 | )
32 | }
33 |
34 | // Executes a snapshot tests for a provided UIViewController:
35 | func executeSnapshotTests(
36 | forViewController viewController: UIViewController,
37 | named name: String,
38 | precision: Float = 0.995,
39 | isRecording: Bool = false,
40 | file: StaticString = #file,
41 | functionName: String = #function,
42 | line: UInt = #line
43 | ) {
44 | viewController.loadViewIfNeeded()
45 | viewController.forceLightMode()
46 | assertSnapshot(
47 | matching: viewController,
48 | as: .image(on: .iPhone12, precision: precision, perceptualPrecision: 0.98),
49 | named: name,
50 | record: isRecording,
51 | file: file,
52 | testName: "iPhone12",
53 | line: line
54 | )
55 | }
56 |
57 | // Executes a snapshot tests for a provided app window (entire screen):
58 | func executeSnapshotTests(
59 | appWindow window: UIWindow,
60 | named name: String,
61 | precision: Float = 0.995,
62 | isRecording: Bool = false,
63 | file: StaticString = #file,
64 | functionName: String = #function,
65 | line: UInt = #line
66 | ) {
67 | assertSnapshot(
68 | matching: window,
69 | as: .makeAppWindow(precision: precision),
70 | named: name,
71 | record: isRecording,
72 | file: file,
73 | testName: "iPhone12",
74 | line: line
75 | )
76 | }
77 |
78 | // Discussion: Introducing slight delay to allow display list to redraw before making a snapshot"
79 | func waitForDisplayListRedraw(delay: Double = 1) {
80 | let expectation = expectation(description: "waitForDisplayListRedraw")
81 |
82 | // when:
83 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
84 | expectation.fulfill()
85 | }
86 |
87 | // then:
88 | wait(for: [expectation])
89 | }
90 |
91 | // Discussion: Introducing longer delay to allow view hierarchy on the main app window to redraw:
92 | func waitForViewHierarchyRedraw(window: UIWindow, delay: Double = 3) {
93 | let expectation = expectation(description: "waitForViewHierarchyRedraw")
94 | UIView.setAnimationsEnabled(false)
95 |
96 | DispatchQueue.main.async {
97 | window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
98 |
99 | // Discussion: Although `UIView.setAnimationsEnabled(false)` works for most UIKit animations,
100 | // it does not work for all of them, including presenting an alert and a popup,
101 | // so, we need to wait for the animation to play out...
102 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
103 | expectation.fulfill()
104 | }
105 |
106 | UIView.setAnimationsEnabled(true)
107 | }
108 |
109 | wait(for: [expectation])
110 | }
111 |
112 | // Discussion: Retrieves app key window (if exists) and attaches provided view as its root:
113 | func getAppKeyWindow(withRootViewController viewController: UIViewController) -> UIWindow? {
114 | guard let window = UIApplication.shared.keySceneWindow else {
115 | return nil
116 | }
117 |
118 | window.overrideUserInterfaceStyle = .light
119 | window.rootViewController = viewController
120 | viewController.loadViewIfNeeded()
121 | return window
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/UIKit Router/RouteTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RouteTests.swift
3 | // KISS Views
4 | //
5 |
6 | import Foundation
7 | import XCTest
8 |
9 | @testable import SwiftUI_Navigation
10 |
11 | final class RouteTest: XCTestCase {
12 |
13 | func testMainAppRoute() {
14 | verify(
15 | route: MainAppRoute.assetsList,
16 | expectedName: "MainAppRoute.AssetsList",
17 | expectedIsPopup: false,
18 | expectedPopupPresentationStyle: .none
19 | )
20 | verify(
21 | route: MainAppRoute.addAsset,
22 | expectedName: "MainAppRoute.AddAsset",
23 | expectedIsPopup: true,
24 | expectedPopupPresentationStyle: .modal
25 | )
26 | verify(
27 | route: MainAppRoute.appInfo,
28 | expectedName: "MainAppRoute.AppInfo",
29 | expectedIsPopup: true,
30 | expectedPopupPresentationStyle: .modal
31 | )
32 |
33 | // TODO: Add more tests for MainAppRoute cases (but is it really needed... ?)
34 | }
35 | }
36 |
37 | private extension RouteTest {
38 |
39 | func verify(route: any Route, expectedName: String, expectedIsPopup: Bool, expectedPopupPresentationStyle: PopupPresentationStyle) {
40 | XCTAssertEqual(route.name, expectedName, "\(route) should have proper name")
41 | XCTAssertEqual(route.isPopup, expectedIsPopup, "\(route) should have proper isPopup")
42 | XCTAssertEqual(route.popupPresentationStyle, expectedPopupPresentationStyle, "\(route) should have proper popup presentation style")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/SwiftUI Navigation/SwiftUI NavigationTests/UIKit Router/UIKitNavigationRouterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitNavigationRouterTests.swift
3 | // KISS Views
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 | import XCTest
9 |
10 | @testable import SwiftUI_Navigation
11 |
12 | final class UIKitNavigationRouterTest: XCTestCase {
13 | var fakeInitialFlowCoordinator: FakeFlowCoordinator!
14 | var sut: DefaultUIKitNavigationRouter!
15 |
16 | override func setUp() {
17 | fakeInitialFlowCoordinator = FakeFlowCoordinator()
18 | sut = DefaultUIKitNavigationRouter()
19 | sut.start(initialFlow: fakeInitialFlowCoordinator, animated: false)
20 | }
21 |
22 | func test_startingInitialFlow() {
23 | XCTAssertEqual(fakeInitialFlowCoordinator.lastDidStartAnimated, false, "Should start initial flow without animation")
24 | }
25 |
26 | func test_calculatingCurrentFlow() {
27 | // initially:
28 | XCTAssertEqual(sut.currentFlow === fakeInitialFlowCoordinator, true, "Should start a provided initial flow")
29 |
30 | // when:
31 | let fakeChildFlowCoordinator = FakeFlowCoordinator()
32 | let fakeChildFlowCoordinator2 = FakeFlowCoordinator()
33 | fakeChildFlowCoordinator.child = fakeChildFlowCoordinator2
34 | currentFlow?.child = fakeChildFlowCoordinator
35 |
36 | // then:
37 | XCTAssertEqual(sut.currentFlow === fakeChildFlowCoordinator2, true, "Should always return the last child flow coordinator as a current flow")
38 | }
39 |
40 | func test_whenShowingRoute_shouldForwardCallToCurrentFlowCoordinator() {
41 | // given:
42 | let fixtureRoute = MainAppRoute.editAsset(assetId: "")
43 | let fixtureData = "fixtureData"
44 |
45 | // when:
46 | sut.show(route: fixtureRoute, withData: fixtureData)
47 |
48 | // then:
49 | XCTAssertNil(currentFlow?.lastShownRoute, "When route is not supported by current flow, it should not be executed")
50 |
51 | // given:
52 | fakeInitialFlowCoordinator.simulatedCanShow = true
53 |
54 | // when:
55 | sut.show(route: fixtureRoute, withData: fixtureData)
56 |
57 | // then:
58 | XCTAssertEqual(currentFlow?.lastShownRoute?.matches(fixtureRoute), true, "Should execute desired route on current flow")
59 | XCTAssertEqual(currentFlow?.lastShownRouteData, fixtureData, "Should pass proper data to current flow")
60 | }
61 |
62 | func test_whenSwitchingToRoute_shouldForwardCallToCurrentFlowCoordinator() {
63 | // given:
64 | let fixtureRoute = MainAppRoute.addAsset
65 | let fixtureData = "fixtureData2"
66 |
67 | // when:
68 | sut.switch(toRoute: fixtureRoute, withData: fixtureData)
69 |
70 | // then:
71 | XCTAssertEqual(currentFlow?.lastSwitchedToRoute?.matches(fixtureRoute), true, "Should switch to a desired route on current flow")
72 | XCTAssertEqual(currentFlow?.lastSwitchedToRouteData, fixtureData, "Should pass proper data to current flow")
73 | }
74 |
75 | func test_whenNavigatingBack_shouldForwardCallToCurrentFlowCoordinator() {
76 | // given:
77 | let fixtureAnimated = false
78 |
79 | // when:
80 | sut.navigateBack(animated: fixtureAnimated)
81 |
82 | // then:
83 | XCTAssertEqual(currentFlow?.lastNavigatedBackAnimated, fixtureAnimated, "Should navigate back on current flow")
84 | }
85 |
86 | func test_whenNavigatingBackToRoot_shouldForwardCallToCurrentFlowCoordinator() {
87 | // given:
88 | let fixtureAnimated = false
89 |
90 | // when:
91 | sut.navigateBackToRoot(animated: fixtureAnimated)
92 |
93 | // then:
94 | XCTAssertEqual(currentFlow?.lastNavigatedBackToRootAnimated, fixtureAnimated, "Should navigate back to root on current flow")
95 | }
96 |
97 | func test_whenNavigatingBackToParticularRoute_shouldForwardCallToCurrentFlowCoordinator() {
98 | // given:
99 | let fixtureAnimated = false
100 | let fixtureRoute = MainAppRoute.addAsset
101 |
102 | // when:
103 | sut.navigateBack(toRoute: fixtureRoute, animated: fixtureAnimated)
104 |
105 | // then:
106 | XCTAssertEqual(currentFlow?.lastNavigatedBackToRoute?.matches(fixtureRoute), true, "Should navigate back to route on current flow")
107 | XCTAssertEqual(currentFlow?.lastNavigatedBackToRouteAnimated, fixtureAnimated, "Should navigate back to route on current flow ")
108 | }
109 |
110 | func test_whenRequestingStoppingFlow_shouldForwardCallToCurrentFlowCoordinator() {
111 | // when:
112 | sut.stopCurrentFlow()
113 |
114 | // then:
115 | XCTAssertEqual(currentFlow?.lastDidStop, true, "Should stop current flow")
116 | }
117 | }
118 |
119 | extension UIKitNavigationRouterTest {
120 |
121 | var currentFlow: FakeFlowCoordinator? {
122 | sut.currentFlow as? FakeFlowCoordinator
123 | }
124 | }
125 |
--------------------------------------------------------------------------------