├── .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 | --------------------------------------------------------------------------------