├── Resources ├── icon.afdesign ├── screen-dark-01.png ├── screen-dark-02.png ├── screen-white-01.png └── screen-white-02.png ├── VirtualCoin ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── Icon-1024.png │ │ ├── Icon-120.png │ │ ├── Icon-121.png │ │ ├── Icon-152.png │ │ ├── Icon-167.png │ │ ├── Icon-180.png │ │ ├── Icon-20.png │ │ ├── Icon-29.png │ │ ├── Icon-40.png │ │ ├── Icon-41.png │ │ ├── Icon-42.png │ │ ├── Icon-58.png │ │ ├── Icon-59.png │ │ ├── Icon-60.png │ │ ├── Icon-76.png │ │ ├── Icon-80.png │ │ ├── Icon-81.png │ │ ├── Icon-87.png │ │ └── Contents.json │ ├── GreenPastel.colorset │ │ └── Contents.json │ ├── RedPastel.colorset │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── BackgroundLabel.colorset │ │ └── Contents.json │ └── WidgetBackground.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── vcoin.xcdatamodeld │ ├── .xccurrentversion │ ├── vcoin-20210604.xcdatamodel │ │ └── contents │ ├── vcoin-20210606.xcdatamodel │ │ └── contents │ ├── vcoin-20190712.xcdatamodel │ │ └── contents │ └── vcoin.xcdatamodel │ │ └── contents ├── ViewModels │ ├── SideBarNavigationItem.swift │ ├── ViewState.swift │ ├── AlertViewModel.swift │ ├── MarketViewModel.swift │ ├── ExchangeViewModel.swift │ └── CoinViewModel.swift ├── VirtualCoin.entitlements ├── Extensions │ ├── DispatchQueue.swift │ ├── View.swift │ └── Color.swift ├── Notifications │ ├── NotificationsError.swift │ ├── PriceAlert.swift │ └── Notifications.swift ├── Views │ ├── ComponentViews │ │ ├── LoadingView.swift │ │ ├── InitialsPlaceholder.swift │ │ ├── ViewControllerResolver.swift │ │ ├── ErrorView.swift │ │ ├── SearchBar.swift │ │ ├── NoDataView.swift │ │ ├── CoinImageView.swift │ │ ├── ExchangeDetailView.swift │ │ ├── AlertDetailView.swift │ │ └── ChartView.swift │ ├── AppViews │ │ ├── AppView.swift │ │ ├── TabsView.swift │ │ └── SideBarsView.swift │ ├── ScreenViews │ │ ├── MarketsView.swift │ │ ├── ThirdPartyView.swift │ │ ├── AddAlertView.swift │ │ ├── AddExchangeView.swift │ │ ├── EditExchangeView.swift │ │ ├── EditAlertView.swift │ │ ├── SettingsView.swift │ │ └── CoinView.swift │ ├── ListRowsViews │ │ ├── MarketRowView.swift │ │ ├── CoinRowView.swift │ │ ├── AlertRowView.swift │ │ └── ExchangeRowView.swift │ └── TabsViews │ │ ├── CoinsView.swift │ │ ├── ExchangesView.swift │ │ ├── AlertsView.swift │ │ └── FavouritesView.swift ├── Services │ ├── ApplicationStateService.swift │ └── PricesService.swift ├── Info.plist ├── Cache │ └── MemoryCache.swift ├── VirtualCoinApp.swift └── Previews │ └── PreviewData.swift ├── VirtualCoinWidget ├── en.lproj │ └── VirtualCoinWidget.strings ├── CoinSymbol.swift ├── WidgetEntry.swift ├── WidgetViewModel.swift ├── Extensions │ ├── CurrentValueLineType+ChartColors.swift │ └── ChartVisualType+ChartColors.swift ├── VirtualCoinWidget.swift ├── VirtualCoinWidgetEntryView.swift ├── Info.plist ├── Provider.swift ├── Base.lproj │ └── VirtualCoinWidget.intentdefinition ├── Views │ ├── SmallWidgetView.swift │ ├── LargeWidgetView.swift │ └── MediumWidgetView.swift ├── PreviewData.swift └── DataFetcher.swift ├── VirtualCoin.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── mczachurski.xcuserdatad │ │ ├── xcschemes │ │ └── xcschememanagement.plist │ │ └── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist └── xcshareddata │ └── xcschemes │ └── VirtualCoin.xcscheme ├── VirtualCoinKit ├── Models │ ├── ResponseArray.swift │ ├── ChartValue.swift │ ├── ChartTimeRange.swift │ ├── CurrencyRate.swift │ ├── Market.swift │ ├── Coin.swift │ └── Currency.swift ├── Extensions │ ├── Date.swift │ ├── NumberFormatter.swift │ └── Double.swift ├── VirtualCoinKit.h ├── Info.plist └── Errors │ └── RestClientError.swift ├── CoreData ├── Entities │ ├── Alert+CoreDataClass.swift │ ├── Favourite+CoreDataClass.swift │ ├── Settings+CoreDataClass.swift │ ├── ExchangeItem+CoreDataClass.swift │ ├── Settings+CoreDataProperties.swift │ ├── Favourite+CoreDataProperties.swift │ ├── ExchangeItem+CoreDataProperties.swift │ └── Alert+CoreDataProperties.swift ├── PropertyWrappers │ └── Setting.swift ├── ExchangeItemsHandler.swift ├── SettingsHandler.swift ├── FavouritesHandler.swift ├── AlertsHandler.swift └── CoreDataHandler.swift ├── VirtualCoinWidgetExtension.entitlements ├── VirtualCoinKitTests ├── Info.plist └── VirtualCoinKitTests.swift ├── VirtualCoinTests ├── Info.plist └── VirtualCoinTests.swift ├── VirtualCoinUITests ├── Info.plist └── VirtualCoinUITests.swift ├── README.md ├── .swiftlint.yml └── .gitignore /Resources/icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/Resources/icon.afdesign -------------------------------------------------------------------------------- /Resources/screen-dark-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/Resources/screen-dark-01.png -------------------------------------------------------------------------------- /Resources/screen-dark-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/Resources/screen-dark-02.png -------------------------------------------------------------------------------- /Resources/screen-white-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/Resources/screen-white-01.png -------------------------------------------------------------------------------- /Resources/screen-white-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/Resources/screen-white-02.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /VirtualCoinWidget/en.lproj/VirtualCoinWidget.strings: -------------------------------------------------------------------------------- 1 | "gpCwrM" = "Configuration"; 2 | 3 | "tVvJ9c" = "Configuration for vCoin app"; 4 | 5 | -------------------------------------------------------------------------------- /VirtualCoin/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-120.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-121.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-121.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-152.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-167.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-180.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-41.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-42.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-59.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-59.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-81.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-81.png -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mczachurski/vcoin/HEAD/VirtualCoin/Assets.xcassets/AppIcon.appiconset/Icon-87.png -------------------------------------------------------------------------------- /VirtualCoin.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /VirtualCoinWidget/CoinSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | struct CoinOrder { 10 | let coinId: String 11 | let order: Int32 12 | } 13 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/ResponseArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public class Response: Decodable { 10 | public var data: T 11 | } 12 | -------------------------------------------------------------------------------- /CoreData/Entities/Alert+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | @objc(Alert) 11 | public class Alert: NSManagedObject { 12 | } 13 | -------------------------------------------------------------------------------- /CoreData/Entities/Favourite+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | @objc(Favourite) 11 | public class Favourite: NSManagedObject { 12 | } 13 | -------------------------------------------------------------------------------- /CoreData/Entities/Settings+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | @objc(Settings) 11 | public class Settings: NSManagedObject { 12 | } 13 | -------------------------------------------------------------------------------- /VirtualCoin/vcoin.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | vcoin-20210606.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /VirtualCoin.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /VirtualCoinWidget/WidgetEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | 9 | struct WidgetEntry: TimelineEntry { 10 | let date: Date 11 | let viewModels: [WidgetViewModel] 12 | } 13 | -------------------------------------------------------------------------------- /CoreData/Entities/ExchangeItem+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | @objc(ExchangeItem) 11 | public class ExchangeItem: NSManagedObject { 12 | } 13 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/ChartValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct ChartValue: Decodable { 10 | public var priceUsd: String 11 | public var time: Int 12 | } 13 | -------------------------------------------------------------------------------- /VirtualCoinKit/Extensions/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | extension Date { 10 | var unixTimestamp: Int64 { 11 | return Int64(self.timeIntervalSince1970 * 1_000) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/SideBarNavigationItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | enum SideBarNavigationItem { 10 | case favourites 11 | case currencies 12 | case exchanges 13 | case alerts 14 | } 15 | -------------------------------------------------------------------------------- /VirtualCoin/VirtualCoin.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.mczachurski.vcoin 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /VirtualCoinWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.mczachurski.vcoin 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/ChartTimeRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum ChartTimeRange: String { 10 | case hour = "Hour", day = "Day", week = "Week", month = "Month", year = "Year" 11 | 12 | public static let allValues = [hour, day, week, month, year] 13 | } 14 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/CurrencyRate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct CurrencyRate: Decodable { 10 | public var id: String 11 | public var symbol: String 12 | public var currencySymbol: String 13 | public var type: String 14 | public var rateUsd: String 15 | } 16 | -------------------------------------------------------------------------------- /CoreData/Entities/Settings+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | public extension Settings { 11 | @nonobjc 12 | class func fetchRequest() -> NSFetchRequest { 13 | return NSFetchRequest(entityName: "Settings") 14 | } 15 | 16 | @NSManaged var currency: String 17 | } 18 | -------------------------------------------------------------------------------- /VirtualCoin/Extensions/DispatchQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public extension DispatchQueue { 10 | static func runOnMain(_ handler: @escaping () -> Void) { 11 | if Thread.isMainThread { 12 | handler() 13 | } else { 14 | DispatchQueue.main.async(execute: handler) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /VirtualCoin/Extensions/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension View { 10 | func animate(using animation: Animation = Animation.easeInOut(duration: 0.5), _ action: @escaping () -> Void) -> some View { 11 | onAppear { 12 | withAnimation(animation) { 13 | action() 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /VirtualCoinWidget/WidgetViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | struct WidgetViewModel: Identifiable, Hashable { 10 | let id: String 11 | let order: Int32 12 | let rank: Int 13 | let symbol: String 14 | let name: String 15 | let priceUsd: Double 16 | let changePercent24Hr: Double 17 | let price: Double 18 | var chart: [Double] 19 | } 20 | -------------------------------------------------------------------------------- /CoreData/Entities/Favourite+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | public extension Favourite { 11 | @nonobjc 12 | class func fetchRequest() -> NSFetchRequest { 13 | return NSFetchRequest(entityName: "Favourite") 14 | } 15 | 16 | @NSManaged var coinId: String 17 | @NSManaged var order: Int32 18 | } 19 | -------------------------------------------------------------------------------- /VirtualCoinKit/Extensions/NumberFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public extension NumberFormatter { 10 | static var amountFormatter: NumberFormatter = { 11 | let formatter = NumberFormatter() 12 | 13 | formatter.numberStyle = .decimal 14 | formatter.maximumFractionDigits = 10 15 | 16 | return formatter 17 | }() 18 | } 19 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/Market.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct Market: Decodable { 10 | public var exchangeId: String 11 | public var baseId: String? 12 | public var quoteId: String? 13 | public var baseSymbol: String? 14 | public var quoteSymbol: String? 15 | public var volumeUsd24Hr: String? 16 | public var priceUsd: String? 17 | public var volumePercent: String? 18 | } 19 | -------------------------------------------------------------------------------- /VirtualCoinKit/VirtualCoinKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // VirtualCoinKit.h 3 | // VirtualCoinKit 4 | // 5 | // Created by Marcin Czachurski on 18/04/2021. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for VirtualCoinKit. 11 | FOUNDATION_EXPORT double VirtualCoinKitVersionNumber; 12 | 13 | //! Project version string for VirtualCoinKit. 14 | FOUNDATION_EXPORT const unsigned char VirtualCoinKitVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /VirtualCoin/Notifications/NotificationsError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum NotificationsError: Error { 10 | case notRecognizedCurrencySymbol 11 | } 12 | 13 | extension NotificationsError: LocalizedError { 14 | public var errorDescription: String? { 15 | switch self { 16 | case .notRecognizedCurrencySymbol: 17 | return NSLocalizedString("Not recognized currency symbol.", comment: "") 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /VirtualCoin/Notifications/PriceAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | enum Processing { 10 | case inQueue, processing, finished 11 | } 12 | 13 | class PriceAlert { 14 | var currency: String 15 | var coinId: String 16 | 17 | var price: Double? 18 | var processing: Processing 19 | 20 | init(currency: String, coinId: String) { 21 | self.currency = currency 22 | self.coinId = coinId 23 | self.processing = Processing.inQueue 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/Coin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct Coin: Decodable { 10 | public var id: String 11 | public var rank: String 12 | public var symbol: String 13 | public var name: String 14 | public var supply: String? 15 | public var maxSupply: String? 16 | public var marketCapUsd: String? 17 | public var volumeUsd24Hr: String? 18 | public var priceUsd: String? 19 | public var changePercent24Hr: String? 20 | public var vwap24Hr: String? 21 | } 22 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Extensions/CurrentValueLineType+ChartColors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import LightChart 9 | 10 | extension CurrentValueLineType { 11 | static var red: CurrentValueLineType { 12 | get { 13 | CurrentValueLineType.dash(color: .redPastel, lineWidth: 1, dash: [5]) 14 | } 15 | } 16 | 17 | static var green: CurrentValueLineType { 18 | get { 19 | CurrentValueLineType.dash(color: .greenPastel, lineWidth: 1, dash: [5]) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CoreData/Entities/ExchangeItem+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | public extension ExchangeItem { 11 | @nonobjc 12 | class func fetchRequest() -> NSFetchRequest { 13 | return NSFetchRequest(entityName: "ExchangeItem") 14 | } 15 | 16 | @NSManaged var amount: Double 17 | @NSManaged var coinId: String 18 | @NSManaged var coinSymbol: String 19 | @NSManaged var currency: String 20 | } 21 | 22 | 23 | extension ExchangeItem: Identifiable { 24 | } 25 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct LoadingView: View { 10 | var body: some View { 11 | VStack { 12 | Spacer() 13 | ProgressView() 14 | .progressViewStyle(CircularProgressViewStyle()) 15 | Spacer() 16 | } 17 | } 18 | } 19 | 20 | struct LoadingView_Previews: PreviewProvider { 21 | static var previews: some View { 22 | LoadingView() 23 | .previewLayout(.fixed(width: 32, height: 32)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CoreData/Entities/Alert+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | public extension Alert { 11 | @nonobjc 12 | class func fetchRequest() -> NSFetchRequest { 13 | return NSFetchRequest(entityName: "Alert") 14 | } 15 | 16 | @NSManaged var alertSentDate: Date? 17 | @NSManaged var coinId: String 18 | @NSManaged var coinSymbol: String 19 | @NSManaged var currency: String 20 | @NSManaged var isEnabled: Bool 21 | @NSManaged var isPriceLower: Bool 22 | @NSManaged var price: Double 23 | } 24 | -------------------------------------------------------------------------------- /VirtualCoinWidget/VirtualCoinWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | 10 | @main 11 | struct VirtualCoinWidget: Widget { 12 | let kind: String = "VirtualCoinWidget" 13 | 14 | var body: some WidgetConfiguration { 15 | StaticConfiguration(kind: kind, provider: Provider()) { entry in 16 | VirtualCoinWidgetEntryView(entry: entry) 17 | } 18 | .configurationDisplayName("vCoin") 19 | .description("Shows coin prices") 20 | .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CoreData/PropertyWrappers/Setting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import SwiftUI 9 | 10 | @propertyWrapper public struct Setting: DynamicProperty { 11 | private let keyPath: KeyPath 12 | 13 | public var wrappedValue: T { 14 | get { 15 | let settingsHandler = SettingsHandler() 16 | let settings = settingsHandler.getDefaultSettings() 17 | 18 | return settings[keyPath: keyPath] 19 | } 20 | } 21 | 22 | public init(_ keyPath: KeyPath) { 23 | self.keyPath = keyPath 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /VirtualCoin/Views/AppViews/AppView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct AppView: View { 11 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 12 | 13 | var body: some View { 14 | if horizontalSizeClass == .compact { 15 | TabsView() 16 | } else { 17 | NavigationView { 18 | SideBarsView() 19 | FavouritesView() 20 | } 21 | } 22 | } 23 | } 24 | 25 | struct AppView_Previews: PreviewProvider { 26 | static var previews: some View { 27 | AppView() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /VirtualCoinWidget/VirtualCoinWidgetEntryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | 10 | struct VirtualCoinWidgetEntryView : View { 11 | @Environment(\.widgetFamily) var family: WidgetFamily 12 | 13 | var entry: Provider.Entry 14 | 15 | @ViewBuilder 16 | var body: some View { 17 | switch family { 18 | case .systemSmall: SmallWidgetView(viewModels: entry.viewModels) 19 | case .systemMedium: MediumWidgetView(viewModels: entry.viewModels) 20 | case .systemLarge: LargeWidgetView(viewModels: entry.viewModels) 21 | default: Text("Define favourites") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/ViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum ViewState: Equatable { 10 | case iddle 11 | case loading 12 | case loaded 13 | case error(Error) 14 | 15 | static public func ==(lhs: ViewState, rhs: ViewState) -> Bool { 16 | switch (lhs, rhs) { 17 | case (.error, .error): 18 | return true 19 | case (.loaded, .loaded): 20 | return true 21 | case (.loading,.loading): 22 | return true 23 | case (.iddle,.iddle): 24 | return true 25 | default: 26 | return false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /VirtualCoinKitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /VirtualCoinTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /VirtualCoinUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /VirtualCoinKit/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /VirtualCoin/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public extension Color { 10 | static var main: Color { 11 | return Color("AccentColor") 12 | } 13 | 14 | static func main(opacity: Double) -> Color { 15 | return Color.main.opacity(opacity) 16 | } 17 | 18 | static var greenPastel: Color { 19 | return Color("GreenPastel") 20 | } 21 | 22 | static var redPastel: Color { 23 | return Color("RedPastel") 24 | } 25 | 26 | static var backgroundLabel: Color { 27 | return Color("BackgroundLabel") 28 | } 29 | 30 | static func backgroundLabel(opacity: Double) -> Color { 31 | return Color.backgroundLabel.opacity(opacity) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/GreenPastel.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "108", 9 | "green" : "169", 10 | "red" : "0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "108", 27 | "green" : "169", 28 | "red" : "0" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/RedPastel.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "53", 9 | "green" : "54", 10 | "red" : "255" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "53", 27 | "green" : "54", 28 | "red" : "255" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x08", 9 | "green" : "0x65", 10 | "red" : "0xF0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.031", 27 | "green" : "0.396", 28 | "red" : "0.941" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/BackgroundLabel.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/AlertViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import VirtualCoinKit 9 | 10 | public class AlertViewModel: Identifiable, ObservableObject { 11 | @Published public var alert: Alert 12 | @Published public var coinViewModel: CoinViewModel 13 | @Published public var currency: Currency 14 | 15 | init(coinViewModel: CoinViewModel, alert: Alert, currency: Currency) { 16 | self.alert = alert 17 | self.coinViewModel = coinViewModel 18 | self.currency = currency 19 | } 20 | 21 | public func setCoinViewModel(_ coinViewModel: CoinViewModel) { 22 | self.coinViewModel = coinViewModel 23 | } 24 | 25 | public func setCurrency(_ currency: Currency) { 26 | self.currency = currency 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /VirtualCoinKit/Models/Currency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct Currency { 10 | public var id: String 11 | public var symbol: String 12 | public var locale: String 13 | public var name: String 14 | 15 | public init(id: String = "", symbol: String = "", locale: String = "", name: String = "") { 16 | self.id = id 17 | self.symbol = symbol 18 | self.locale = locale 19 | self.name = name 20 | } 21 | } 22 | 23 | extension Currency: Hashable { 24 | public static func == (lhs: Currency, rhs: Currency) -> Bool { 25 | return lhs.symbol == rhs.symbol 26 | } 27 | 28 | public func hash(into hasher: inout Hasher) { 29 | hasher.combine(self.symbol) 30 | } 31 | } 32 | 33 | extension Currency: Identifiable { 34 | } 35 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | vCoin 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionPointIdentifier 26 | com.apple.widgetkit-extension 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /VirtualCoinTests/VirtualCoinTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import XCTest 8 | @testable import VirtualCoin 9 | 10 | class VirtualCoinTests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | func testExample() throws { 21 | // This is an example of a functional test case. 22 | // Use XCTAssert and related functions to verify your tests produce the correct results. 23 | } 24 | 25 | func testPerformanceExample() throws { 26 | // This is an example of a performance test case. 27 | self.measure { 28 | // Put the code you want to measure the time of here. 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /VirtualCoinKitTests/VirtualCoinKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import XCTest 8 | @testable import VirtualCoinKit 9 | 10 | class VirtualCoinKitTests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | func testExample() throws { 21 | // This is an example of a functional test case. 22 | // Use XCTAssert and related functions to verify your tests produce the correct results. 23 | } 24 | 25 | func testPerformanceExample() throws { 26 | // This is an example of a performance test case. 27 | self.measure { 28 | // Put the code you want to measure the time of here. 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/InitialsPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import SwiftUI 9 | 10 | struct InitialsPlaceholder: View { 11 | var text: String 12 | 13 | var body: some View { 14 | ZStack { 15 | Circle() 16 | .strokeBorder(Color.main,lineWidth: 2) 17 | .background(Circle().foregroundColor(Color.main(opacity: 0.4))) 18 | .frame(width: 32, height: 32) 19 | Text(String(text.first ?? "?") ) 20 | .foregroundColor(.white) 21 | } 22 | } 23 | } 24 | 25 | struct InitialsPlaceholder_Previews: PreviewProvider { 26 | static var previews: some View { 27 | Group { 28 | InitialsPlaceholder(text: "Bitcoin") 29 | .preferredColorScheme(.dark) 30 | InitialsPlaceholder(text: "DegeCoin") 31 | .preferredColorScheme(.light) 32 | } 33 | .previewLayout(.fixed(width: 32, height: 32)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /VirtualCoin.xcodeproj/xcuserdata/mczachurski.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | VirtualCoin.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | VirtualCoinKit.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 2 16 | 17 | VirtualCoinWidgetExtension.xcscheme_^#shared#^_ 18 | 19 | orderHint 20 | 1 21 | 22 | 23 | SuppressBuildableAutocreation 24 | 25 | F8916835262B38AD007335D7 26 | 27 | primary 28 | 29 | 30 | F891684B262B38AE007335D7 31 | 32 | primary 33 | 34 | 35 | F8916856262B38AE007335D7 36 | 37 | primary 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /CoreData/ExchangeItemsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | class ExchangeItemsHandler { 11 | func createExchangeItemEntity() -> ExchangeItem { 12 | let context = CoreDataHandler.shared.container.viewContext 13 | return ExchangeItem(context: context) 14 | } 15 | 16 | func deleteExchangeItemEntity(exchangeItem: ExchangeItem) { 17 | let context = CoreDataHandler.shared.container.viewContext 18 | context.delete(exchangeItem) 19 | } 20 | 21 | func getExchangeItems() -> [ExchangeItem] { 22 | var exchangeItems: [ExchangeItem] = [] 23 | 24 | let context = CoreDataHandler.shared.container.viewContext 25 | let fetchRequest = NSFetchRequest(entityName: "ExchangeItem") 26 | do { 27 | if let list = try context.fetch(fetchRequest) as? [ExchangeItem] { 28 | exchangeItems = list 29 | } 30 | } catch { 31 | print("Error during fetching ExchangeItem") 32 | } 33 | 34 | return exchangeItems 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/MarketsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct MarketsView: View { 10 | @Environment(\.presentationMode) var presentationMode 11 | public var markets: [MarketViewModel] 12 | 13 | var body: some View { 14 | NavigationView { 15 | List(markets) { market in 16 | MarketRowView(market: market) 17 | } 18 | .navigationBarTitle(Text("Markets"), displayMode: .inline) 19 | .navigationBarItems(trailing: Button(action: { 20 | presentationMode.wrappedValue.dismiss() 21 | }) { 22 | Text("Done").bold() 23 | }) 24 | } 25 | .listStyle(PlainListStyle()) 26 | } 27 | } 28 | 29 | struct MarketsView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | Group { 32 | MarketsView(markets: [PreviewData.getMarketViewModel()]) 33 | .preferredColorScheme(.dark) 34 | 35 | MarketsView(markets: [PreviewData.getMarketViewModel()]) 36 | .preferredColorScheme(.light) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/MarketViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import VirtualCoinKit 9 | 10 | public class MarketViewModel: Identifiable, ObservableObject { 11 | public let id: String 12 | public let baseSymbol: String 13 | public let quoteSymbol: String 14 | public let priceUsd: Double 15 | public let price: Double 16 | 17 | init(market: Market, rateUsd: Double) { 18 | self.id = market.exchangeId 19 | self.baseSymbol = market.baseSymbol ?? "" 20 | self.quoteSymbol = market.quoteSymbol ?? "" 21 | 22 | if let priceUsd = market.priceUsd, let price = Double(priceUsd) { 23 | self.priceUsd = price 24 | self.price = price / rateUsd 25 | } else { 26 | self.priceUsd = 0 27 | self.price = 0 28 | } 29 | } 30 | 31 | init(id: String, baseSymbol: String?, quoteSymbol: String?, priceUsd: Double, price: Double) { 32 | self.id = id 33 | self.baseSymbol = baseSymbol ?? "" 34 | self.quoteSymbol = quoteSymbol ?? "" 35 | self.priceUsd = priceUsd 36 | self.price = price 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /VirtualCoinKit/Errors/RestClientError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum RestClientError: Error { 10 | case badUrl 11 | case serverError 12 | case emptyDataError 13 | case numberBadFormat 14 | case networkFailure(Error) 15 | case badDataFormat(Error) 16 | } 17 | 18 | extension RestClientError: LocalizedError { 19 | public var errorDescription: String? { 20 | switch self { 21 | case .badUrl: 22 | return NSLocalizedString("Bad URL to coincap.io API.", comment: "") 23 | case .serverError: 24 | return NSLocalizedString("Server returns unexpected error.", comment: "") 25 | case .emptyDataError: 26 | return NSLocalizedString("Server returns empty data result.", comment: "") 27 | case .numberBadFormat: 28 | return NSLocalizedString("Server returns number which cannot be deserialized.", comment: "") 29 | case .networkFailure(let error): 30 | return NSLocalizedString(error.localizedDescription, comment: "") 31 | case .badDataFormat(let error): 32 | return NSLocalizedString(error.localizedDescription, comment: "") 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ListRowsViews/MarketRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct MarketRowView: View { 10 | @StateObject var market: MarketViewModel 11 | @Setting(\.currency) private var currencySymbol: String 12 | 13 | var body: some View { 14 | HStack { 15 | VStack(alignment: .leading) { 16 | Text(market.id) 17 | .font(.headline) 18 | Text("\(market.baseSymbol) / \(market.quoteSymbol)") 19 | .font(.footnote) 20 | .foregroundColor(.accentColor) 21 | } 22 | 23 | Spacer() 24 | 25 | Text(market.price.toFormattedPrice(currency: currencySymbol)) 26 | .font(.footnote) 27 | } 28 | } 29 | } 30 | 31 | struct MarketRowView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | Group { 34 | MarketRowView(market: PreviewData.getMarketViewModel()) 35 | .preferredColorScheme(.dark) 36 | 37 | MarketRowView(market: PreviewData.getMarketViewModel()) 38 | .preferredColorScheme(.light) 39 | } 40 | .previewLayout(.fixed(width: 360, height: 70)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CoreData/SettingsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | import UIKit 10 | 11 | class SettingsHandler { 12 | func getDefaultSettings() -> Settings { 13 | var settingsList: [Settings] = [] 14 | 15 | let context = CoreDataHandler.shared.container.viewContext 16 | let fetchRequest = NSFetchRequest(entityName: "Settings") 17 | do { 18 | if let list = try context.fetch(fetchRequest) as? [Settings] { 19 | settingsList = list 20 | } 21 | } catch { 22 | print("Error during fetching favourites") 23 | } 24 | 25 | if let settings = settingsList.first { 26 | if settings.currency.count == 0 { 27 | settings.currency = "USD" 28 | } 29 | 30 | return settings 31 | } else { 32 | let settings = self.createSettingsEntity() 33 | settings.currency = "USD" 34 | CoreDataHandler.shared.save() 35 | 36 | return settings 37 | } 38 | } 39 | 40 | private func createSettingsEntity() -> Settings { 41 | let context = CoreDataHandler.shared.container.viewContext 42 | return Settings(context: context) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/ViewControllerResolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | final class ViewControllerResolver: UIViewControllerRepresentable { 10 | let onResolve: (UIViewController) -> Void 11 | 12 | init(onResolve: @escaping (UIViewController) -> Void) { 13 | self.onResolve = onResolve 14 | } 15 | 16 | func makeUIViewController(context: Context) -> ParentResolverViewController { 17 | ParentResolverViewController(onResolve: onResolve) 18 | } 19 | 20 | func updateUIViewController(_ uiViewController: ParentResolverViewController, context: Context) { } 21 | } 22 | 23 | class ParentResolverViewController: UIViewController { 24 | let onResolve: (UIViewController) -> Void 25 | 26 | init(onResolve: @escaping (UIViewController) -> Void) { 27 | self.onResolve = onResolve 28 | super.init(nibName: nil, bundle: nil) 29 | } 30 | 31 | required init?(coder: NSCoder) { 32 | fatalError("Use init(onResolve:) to instantiate ParentResolverViewController.") 33 | } 34 | 35 | override func didMove(toParent parent: UIViewController?) { 36 | super.didMove(toParent: parent) 37 | 38 | if let parent = parent { 39 | onResolve(parent) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Extensions/ChartVisualType+ChartColors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import LightChart 9 | 10 | extension ChartVisualType { 11 | static var red: ChartVisualType { 12 | get { 13 | ChartVisualType.customFilled(color: .redPastel, 14 | lineWidth: 2, 15 | fillGradient: LinearGradient(gradient: .init(colors: [.redPastel.opacity(0.3), .redPastel.opacity(0.01)]), 16 | startPoint: .init(x: 0.5, y: 1), 17 | endPoint: .init(x: 0.5, y: 0))) 18 | } 19 | } 20 | 21 | static var green: ChartVisualType { 22 | get { 23 | ChartVisualType.customFilled(color: .greenPastel, 24 | lineWidth: 2, 25 | fillGradient: LinearGradient(gradient: .init(colors: [.greenPastel.opacity(0.3), .greenPastel.opacity(0.01)]), 26 | startPoint: .init(x: 0.5, y: 1), 27 | endPoint: .init(x: 0.5, y: 0))) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct ErrorView: View { 10 | public var error: Error 11 | public var refreshAction: (() -> Void)? 12 | 13 | var body: some View { 14 | VStack { 15 | Image(systemName: "exclamationmark.triangle.fill") 16 | .resizable() 17 | .foregroundColor(.accentColor) 18 | .frame(width: 64, height: 64, alignment: .center) 19 | 20 | Text("\(error.localizedDescription)") 21 | .multilineTextAlignment(.center) 22 | if let action = refreshAction { 23 | Button("Refresh", action: action) 24 | .padding(.top, 10) 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct ErrorView_Previews: PreviewProvider { 31 | 32 | enum PreviewError: Error, LocalizedError { 33 | case unknown 34 | 35 | public var errorDescription: String? { 36 | switch self { 37 | case .unknown: 38 | return NSLocalizedString("Bad URL to coincap.io API.", comment: "") 39 | } 40 | } 41 | } 42 | 43 | static var previews: some View { 44 | ErrorView(error: PreviewError.unknown) { 45 | } 46 | .previewLayout(.fixed(width: 360, height: 200)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | class SearchBar: NSObject, ObservableObject { 10 | @Published var text: String = "" 11 | let searchController: UISearchController = UISearchController(searchResultsController: nil) 12 | 13 | override init() { 14 | super.init() 15 | self.searchController.obscuresBackgroundDuringPresentation = false 16 | self.searchController.searchResultsUpdater = self 17 | } 18 | } 19 | 20 | extension SearchBar: UISearchResultsUpdating { 21 | func updateSearchResults(for searchController: UISearchController) { 22 | 23 | // Publish search bar text changes. 24 | if let searchBarText = searchController.searchBar.text { 25 | self.text = searchBarText 26 | } 27 | } 28 | } 29 | 30 | struct SearchBarModifier: ViewModifier { 31 | let searchBar: SearchBar 32 | 33 | func body(content: Content) -> some View { 34 | content 35 | .overlay( 36 | ViewControllerResolver { viewController in 37 | viewController.navigationItem.searchController = self.searchBar.searchController 38 | } 39 | .frame(width: 0, height: 0) 40 | ) 41 | } 42 | } 43 | 44 | extension View { 45 | func add(_ searchBar: SearchBar) -> some View { 46 | return self.modifier(SearchBarModifier(searchBar: searchBar)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Provider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | import Intents 10 | 11 | struct Provider: TimelineProvider { 12 | typealias Entry = WidgetEntry 13 | 14 | func placeholder(in context: Context) -> WidgetEntry { 15 | return WidgetEntry(date: Date(), viewModels: PreviewData.getWidgetViewModels()) 16 | } 17 | 18 | func getSnapshot(in context: Context, completion: @escaping (WidgetEntry) -> ()) { 19 | let entry = WidgetEntry(date: Date(), viewModels: PreviewData.getWidgetViewModels()) 20 | completion(entry) 21 | } 22 | 23 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { 24 | 25 | let dataFetcher = DataFetcher() 26 | dataFetcher.getCoins { widgetViewModelsResult in 27 | var entries: [WidgetEntry] = [] 28 | let currentDate = Date() 29 | 30 | switch widgetViewModelsResult { 31 | case .success(let widgetViewModels): 32 | entries.append(WidgetEntry(date: currentDate, viewModels: widgetViewModels)) 33 | break 34 | case .failure: 35 | break 36 | } 37 | 38 | let nextUpdateDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! 39 | let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate)) 40 | completion(timeline) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /VirtualCoinUITests/VirtualCoinUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import XCTest 8 | 9 | class VirtualCoinUITests: XCTestCase { 10 | 11 | override func setUpWithError() throws { 12 | // Put setup code here. This method is called before the invocation of each test method in the class. 13 | 14 | // In UI tests it is usually best to stop immediately when a failure occurs. 15 | continueAfterFailure = false 16 | 17 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 18 | } 19 | 20 | override func tearDownWithError() throws { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | } 23 | 24 | func testExample() throws { 25 | // UI tests must launch the application that they test. 26 | let app = XCUIApplication() 27 | app.launch() 28 | 29 | // Use recording to get started writing UI tests. 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /VirtualCoin/Services/ApplicationStateService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public class ApplicationStateService: ObservableObject { 10 | public static let shared = ApplicationStateService() 11 | 12 | @Published public var coins: [CoinViewModel] = [] 13 | @Published public var markets: [MarketViewModel] = [] 14 | @Published public var favourites: [CoinViewModel] = [] 15 | @Published public var currencyRateUsd: Double = 1.0 16 | 17 | public var selectedExchangeViewModel: ExchangeViewModel? 18 | public var selectedAlertViewModel: AlertViewModel? 19 | } 20 | 21 | extension ApplicationStateService { 22 | public func removeFromFavourites(coinViewModel: CoinViewModel) { 23 | self.favourites = self.favourites.filter { $0 !== coinViewModel } 24 | } 25 | 26 | public func addToFavourites(coinViewModel: CoinViewModel) { 27 | self.favourites.append(coinViewModel) 28 | 29 | self.favourites = self.favourites.sorted(by: { lhs, rhs in 30 | lhs.rank < rhs.rank 31 | }) 32 | } 33 | } 34 | 35 | extension ApplicationStateService { 36 | public static var preview: ApplicationStateService = { 37 | let applicationStateService = ApplicationStateService() 38 | 39 | applicationStateService.coins = PreviewData.getCoinsViewModel() 40 | applicationStateService.favourites = PreviewData.getCoinsViewModel() 41 | applicationStateService.markets = PreviewData.getMarketsViewModel() 42 | 43 | return applicationStateService 44 | }() 45 | } 46 | -------------------------------------------------------------------------------- /VirtualCoin/Views/AppViews/TabsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct TabsView: View { 11 | 12 | var body: some View { 13 | TabView { 14 | 15 | // Favourites view. 16 | NavigationView { 17 | FavouritesView() 18 | } 19 | .tabItem { 20 | Image(systemName: "star.fill") 21 | Text("Favourites") 22 | } 23 | 24 | // All currencies view. 25 | NavigationView { 26 | CoinsView() 27 | } 28 | .tabItem { 29 | Image(systemName: "bitcoinsign.circle.fill") 30 | Text("All currencies") 31 | } 32 | 33 | // Exchanges view. 34 | NavigationView { 35 | ExchangesView() 36 | } 37 | .tabItem { 38 | Image(systemName: "arrow.triangle.2.circlepath.circle.fill") 39 | Text("Exchanges") 40 | } 41 | 42 | // Alerts view. 43 | NavigationView { 44 | AlertsView() 45 | } 46 | .tabItem { 47 | Image(systemName: "bell.fill") 48 | Text("Alerts") 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct TabsView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | TabsView() 57 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /VirtualCoinKit/Extensions/Double.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public extension Double { 10 | func toFormattedAmount() -> String { 11 | return NumberFormatter.amountFormatter.string(from: NSNumber(value: self)) ?? "" 12 | } 13 | 14 | func toFormattedPrice(currency: String) -> String { 15 | return self.toFormattedPrice(currency: currency, maximumFractionDigits: 4) 16 | } 17 | 18 | func toFormattedPrice(currency: String, maximumFractionDigits: Int) -> String { 19 | let currencyFormatter = NumberFormatter() 20 | currencyFormatter.usesGroupingSeparator = true 21 | currencyFormatter.maximumFractionDigits = maximumFractionDigits 22 | currencyFormatter.numberStyle = NumberFormatter.Style.currency 23 | 24 | var locale = "en-US" 25 | if let currency = Currencies.allCurrenciesDictionary[currency] { 26 | locale = currency.locale 27 | } 28 | 29 | currencyFormatter.locale = Locale(identifier: locale) 30 | let priceString = currencyFormatter.string(from: NSNumber(value: self)) 31 | return priceString ?? "" 32 | } 33 | 34 | func toFormattedPercent() -> String { 35 | return String(self.rounded(toPlaces: 2)) + "%" 36 | } 37 | 38 | var absoluteValue: Double { 39 | if self > 0.0 { 40 | return self 41 | } else { 42 | return -1 * self 43 | } 44 | } 45 | 46 | func rounded(toPlaces places: Int) -> Double { 47 | let divisor = pow(10.0, Double(places)) 48 | return (self * divisor).rounded() / divisor 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ListRowsViews/CoinRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct CoinRowView: View { 11 | @ObservedObject var coin: CoinViewModel 12 | @Setting(\.currency) private var currencySymbol: String 13 | 14 | var body: some View { 15 | 16 | HStack { 17 | CoinImageView(coin: coin) 18 | 19 | VStack(alignment: .leading) { 20 | Text(coin.name) 21 | .font(.headline) 22 | Text(coin.symbol) 23 | .font(.subheadline) 24 | .foregroundColor(.accentColor) 25 | } 26 | 27 | Spacer() 28 | 29 | VStack(alignment: .trailing) { 30 | Text(coin.price.toFormattedPrice(currency: currencySymbol)) 31 | .font(.subheadline) 32 | .foregroundColor(coin.changePercent24Hr > 0 ?.greenPastel : .redPastel) 33 | 34 | Text(coin.changePercent24Hr.toFormattedPercent()) 35 | .font(.caption) 36 | .foregroundColor(.gray) 37 | } 38 | } 39 | } 40 | } 41 | 42 | struct CoinRowView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | Group { 45 | CoinRowView(coin: PreviewData.getCoinViewModel()) 46 | .preferredColorScheme(.dark) 47 | 48 | CoinRowView(coin: PreviewData.getCoinViewModel()) 49 | .preferredColorScheme(.light) 50 | } 51 | .previewLayout(.fixed(width: 360, height: 70)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CoreData/FavouritesHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | class FavouritesHandler { 11 | func createFavouriteEntity() -> Favourite { 12 | let context = CoreDataHandler.shared.container.viewContext 13 | return Favourite(context: context) 14 | } 15 | 16 | func deleteFavouriteEntity(coinId: String) { 17 | let favourites = self.getFavourites() 18 | let filtered = favourites.filter { favourite -> Bool in 19 | return favourite.coinId == coinId 20 | } 21 | 22 | let context = CoreDataHandler.shared.container.viewContext 23 | for favourite in filtered { 24 | context.delete(favourite) 25 | } 26 | } 27 | 28 | func isFavourite(coinId: String) -> Bool { 29 | let favourites = self.getFavourites() 30 | 31 | let exists = favourites.contains { favourite -> Bool in 32 | return favourite.coinId == coinId 33 | } 34 | 35 | return exists 36 | } 37 | 38 | func getFavourites() -> [Favourite] { 39 | var favourites: [Favourite] = [] 40 | 41 | let context = CoreDataHandler.shared.container.viewContext 42 | let fetchRequest = NSFetchRequest(entityName: "Favourite") 43 | let sortDescriptor = NSSortDescriptor(key: "order", ascending: true) 44 | fetchRequest.sortDescriptors = [ sortDescriptor ] 45 | 46 | do { 47 | if let list = try context.fetch(fetchRequest) as? [Favourite] { 48 | favourites = list 49 | } 50 | } catch { 51 | print("Error during fetching favourites") 52 | } 53 | 54 | return favourites 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/NoDataView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | 8 | import SwiftUI 9 | 10 | struct NoDataView: View { 11 | var title: String 12 | var subtitle: String 13 | var action: (() -> Void)? 14 | 15 | @State var opacity = 0.0 16 | 17 | public init(title: String, subtitle: String) { 18 | self.title = title 19 | self.subtitle = subtitle 20 | } 21 | 22 | public init(title: String, subtitle: String, action: @escaping () -> Void) { 23 | self.title = title 24 | self.subtitle = subtitle 25 | self.action = action 26 | } 27 | 28 | var body: some View { 29 | VStack { 30 | Text(title) 31 | .font(.title) 32 | .foregroundColor(.gray) 33 | 34 | if let action = self.action { 35 | Button(action: action, label: { 36 | Text(subtitle) 37 | .foregroundColor(Color.white) 38 | .padding() 39 | }) 40 | .frame(height: 40) 41 | .background( 42 | RoundedRectangle(cornerRadius: 20, style: .continuous) 43 | .fill(Color.main) 44 | ) 45 | } else { 46 | Text(subtitle) 47 | .foregroundColor(.gray) 48 | } 49 | } 50 | .opacity(self.opacity) 51 | .animate { 52 | self.opacity = 1 53 | } 54 | } 55 | } 56 | 57 | struct NoDataView_Previews: PreviewProvider { 58 | static var previews: some View { 59 | NoDataView(title: "No data", subtitle: "Please add new favourite") { 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/CoinImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import URLImage 9 | 10 | struct CoinImageView: View { 11 | var coin: CoinViewModel 12 | 13 | var body: some View { 14 | if let imageUrl = URL(string: coin.imageUrl) { 15 | URLImage(imageUrl, 16 | empty: { 17 | InitialsPlaceholder(text: coin.name) 18 | .frame(width: 32, height: 32, alignment: .center) 19 | }, 20 | inProgress: { progress in 21 | InitialsPlaceholder(text: coin.name) 22 | .frame(width: 32, height: 32, alignment: .center) 23 | }, 24 | failure: { error, retry in 25 | InitialsPlaceholder(text: coin.name) 26 | .frame(width: 32, height: 32, alignment: .center) 27 | }, 28 | content: { image in 29 | image 30 | .resizable() 31 | .frame(width: 32, height: 32, alignment: .center) 32 | } 33 | ) 34 | } else { 35 | InitialsPlaceholder(text: coin.name) 36 | .frame(width: 32, height: 32, alignment: .center) 37 | } 38 | } 39 | } 40 | 41 | struct CoinImageView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | Group { 44 | CoinImageView(coin: PreviewData.getCoinViewModel()) 45 | .preferredColorScheme(.dark) 46 | 47 | CoinImageView(coin: PreviewData.getCoinViewModel()) 48 | .preferredColorScheme(.light) 49 | } 50 | .previewLayout(.fixed(width: 32, height: 32)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Base.lproj/VirtualCoinWidget.intentdefinition: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INEnums 6 | 7 | INIntentDefinitionModelVersion 8 | 1.2 9 | INIntentDefinitionNamespace 10 | 88xZPY 11 | INIntentDefinitionSystemVersion 12 | 21A5284e 13 | INIntentDefinitionToolsBuildVersion 14 | 13A5192j 15 | INIntentDefinitionToolsVersion 16 | 13.0 17 | INIntents 18 | 19 | 20 | INIntentCategory 21 | information 22 | INIntentDescription 23 | Configuration for vCoin app 24 | INIntentDescriptionID 25 | tVvJ9c 26 | INIntentEligibleForWidgets 27 | 28 | INIntentIneligibleForSuggestions 29 | 30 | INIntentName 31 | Configuration 32 | INIntentResponse 33 | 34 | INIntentResponseCodes 35 | 36 | 37 | INIntentResponseCodeName 38 | success 39 | INIntentResponseCodeSuccess 40 | 41 | 42 | 43 | INIntentResponseCodeName 44 | failure 45 | 46 | 47 | 48 | INIntentTitle 49 | Configuration 50 | INIntentTitleID 51 | gpCwrM 52 | INIntentType 53 | Custom 54 | INIntentVerb 55 | View 56 | 57 | 58 | INTypes 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/ExchangeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import VirtualCoinKit 9 | 10 | public class ExchangeViewModel: Identifiable, ObservableObject { 11 | @Published public var priceUsd = 0.0 12 | @Published public var price = 0.0 13 | @Published public var exchangeItem: ExchangeItem 14 | @Published public var coinViewModel: CoinViewModel 15 | @Published public var currency: Currency 16 | 17 | init(coinViewModel: CoinViewModel, exchangeItem: ExchangeItem, currency: Currency) { 18 | self.exchangeItem = exchangeItem 19 | self.coinViewModel = coinViewModel 20 | self.currency = currency 21 | 22 | self.recalculatePrice() 23 | } 24 | 25 | public func setCoinViewModel(_ coinViewModel: CoinViewModel) { 26 | self.coinViewModel = coinViewModel 27 | self.recalculatePrice() 28 | } 29 | 30 | public func setCurrency(_ currency: Currency) { 31 | self.currency = currency 32 | self.recalculatePrice() 33 | } 34 | 35 | private func recalculatePrice() { 36 | self.priceUsd = self.coinViewModel.priceUsd * self.exchangeItem.amount 37 | 38 | let coinCapClient = CoinCapClient() 39 | coinCapClient.getCurrencyRate(for: self.currency.id) { result in 40 | switch result { 41 | case .success(let currencyRate): 42 | let currencyRateUsd = Double(currencyRate.rateUsd) ?? 1.0 43 | 44 | DispatchQueue.runOnMain { 45 | self.price = self.priceUsd / currencyRateUsd 46 | } 47 | break 48 | case .failure(let error): 49 | // TODO: Show something in UI. 50 | print(error) 51 | break 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/ThirdPartyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct ThirdPartyView: View { 10 | var body: some View { 11 | List { 12 | VStack(alignment: .leading) { 13 | Link("https://coincap.io", 14 | destination: URL(string: "https://coincap.io")!) 15 | Spacer() 16 | Text("API for cryptocurrency pricing.") 17 | .font(.footnote) 18 | } 19 | 20 | VStack(alignment: .leading) { 21 | Link("https://github.com/pichukov/LightChart", 22 | destination: URL(string: "https://github.com/pichukov/LightChart")!) 23 | Spacer() 24 | Text("Lightweight SwiftUI package with line charts implementation.") 25 | .font(.footnote) 26 | } 27 | 28 | VStack(alignment: .leading) { 29 | Link("https://github.com/dmytro-anokhin/url-image", 30 | destination: URL(string: "https://github.com/dmytro-anokhin/url-image")!) 31 | Spacer() 32 | Text("SwiftUI view that displays an image downloaded from provided URL. ") 33 | .font(.footnote) 34 | } 35 | } 36 | .navigationBarTitle(Text("Third party"), displayMode: .inline) 37 | } 38 | } 39 | 40 | struct ThirdPartyView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | Group { 43 | NavigationView { 44 | ThirdPartyView() 45 | } 46 | .preferredColorScheme(.dark) 47 | 48 | NavigationView { 49 | ThirdPartyView() 50 | } 51 | .preferredColorScheme(.light) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /VirtualCoin/Views/AppViews/SideBarsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct SideBarsView: View { 11 | @State private var selectedItem: SideBarNavigationItem? = .favourites 12 | 13 | var body: some View { 14 | List { 15 | 16 | // Favourites view. 17 | NavigationLink( 18 | destination:FavouritesView(), 19 | tag: SideBarNavigationItem.favourites, 20 | selection: $selectedItem 21 | ) { 22 | Label("Favourites", systemImage: "star.fill") 23 | } 24 | 25 | // All currencies view. 26 | NavigationLink( 27 | destination: CoinsView(), 28 | tag: SideBarNavigationItem.currencies, 29 | selection: $selectedItem 30 | ) { 31 | Label("All currencies", systemImage: "bitcoinsign.circle.fill") 32 | } 33 | 34 | // Exchanges view. 35 | NavigationLink( 36 | destination: ExchangesView(), 37 | tag: SideBarNavigationItem.exchanges, 38 | selection: $selectedItem 39 | ) { 40 | Label("Exchanges", systemImage: "arrow.triangle.2.circlepath.circle.fill") 41 | } 42 | 43 | // Alerts view. 44 | NavigationLink( 45 | destination: AlertsView(), 46 | tag: SideBarNavigationItem.alerts, 47 | selection: $selectedItem 48 | ) { 49 | Label("Alerts", systemImage: "bell.fill") 50 | } 51 | 52 | } 53 | .listStyle(SidebarListStyle()) 54 | .navigationTitle("vCoin") 55 | } 56 | } 57 | 58 | struct SideBarsView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | SideBarsView() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vCoin (Virtual Coin) 2 | 3 | **vCoin** is lightweight application for keeping track of cryptocurrency prices. 4 | 5 | Main features: 6 | - more then 1000 cryptocurrencies 7 | - 5 dynamic charts (hour, day, week, month, year) 8 | - prices displayed in more then 100 currencies (USD, EUR, GBP, CHF, etc.) 9 | - minimal design 10 | - dark/white themes 11 | - widget presenting current prices (taking into account user favorite currencies) 12 | 13 | 14 | 15 | You can downlad app from AppStore. 16 | 17 | **vCoin** uses [https://coincap.io](https://coincap.io) API. All endpoints are described here: [https://docs.coincap.io/](https://docs.coincap.io/). 18 | 19 | ## Screenshots for App Store 20 | 21 | After running simulator run one of the following command to change status bar icons. 22 | 23 | **iPhone 6.5" Display** 24 | 25 | ```bash 26 | xcrun simctl status_bar "iPhone 11 Pro Max" override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 27 | ``` 28 | 29 | **iPhone 5.5" Display** 30 | 31 | ```bash 32 | xcrun simctl status_bar "iPhone 8 Plus" override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 33 | ``` 34 | 35 | **iPad Pro (3rd Gen) 12.9" Display** 36 | 37 | ```bash 38 | xcrun simctl status_bar "iPad Pro (12.9-inch) (5th generation)" override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 39 | ``` 40 | 41 | ## Contributing 42 | You can fork and clone repository. Execute 'carthage update' and build. Do your changes and pull a request. Enjoy! 43 | -------------------------------------------------------------------------------- /VirtualCoin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BGTaskSchedulerPermittedIdentifiers 6 | 7 | dev.mczachurski.vcoin.fetch 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleDisplayName 12 | vCoin 13 | CFBundleExecutable 14 | $(EXECUTABLE_NAME) 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | $(PRODUCT_NAME) 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | $(MARKETING_VERSION) 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | LSRequiresIPhoneOS 28 | 29 | NSUserActivityTypes 30 | 31 | ConfigurationIntent 32 | 33 | UIApplicationSceneManifest 34 | 35 | UIApplicationSupportsMultipleScenes 36 | 37 | 38 | UIApplicationSupportsIndirectInputEvents 39 | 40 | UIBackgroundModes 41 | 42 | fetch 43 | 44 | UILaunchScreen 45 | 46 | UIRequiredDeviceCapabilities 47 | 48 | armv7 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UISupportedInterfaceOrientations~ipad 57 | 58 | UIInterfaceOrientationPortrait 59 | UIInterfaceOrientationPortraitUpsideDown 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /VirtualCoin/vcoin.xcdatamodeld/vcoin-20210604.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Views/SmallWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | import VirtualCoinKit 10 | 11 | struct SmallWidgetView: View { 12 | var viewModels: [WidgetViewModel] 13 | 14 | @Setting(\.currency) private var currencySymbol: String 15 | 16 | var body: some View { 17 | VStack { 18 | ForEach(viewModels.prefix(3).indices, id: \.self) { index in 19 | VStack { 20 | HStack { 21 | VStack(alignment: .leading) { 22 | Text("\(viewModels[index].name)") 23 | .font(.caption2) 24 | Text("\(viewModels[index].symbol)") 25 | .foregroundColor(Color.gray) 26 | .font(.caption2) 27 | } 28 | 29 | Spacer() 30 | 31 | VStack(alignment: .trailing){ 32 | Text("\(viewModels[index].price.toFormattedPrice(currency: currencySymbol))") 33 | .font(.caption2) 34 | Text("\(viewModels[index].changePercent24Hr.toFormattedPercent())") 35 | .foregroundColor(viewModels[index].changePercent24Hr > 0 ?.greenPastel : .redPastel) 36 | .font(.caption2) 37 | } 38 | } 39 | 40 | if index + 1 < (viewModels.capacity > 3 ? 3 : viewModels.capacity) { 41 | Divider() 42 | } 43 | } 44 | } 45 | } 46 | .padding() 47 | } 48 | } 49 | 50 | struct SmallWidgetView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | Group { 53 | VirtualCoinWidgetEntryView(entry: WidgetEntry(date: Date(), viewModels: PreviewData.getWidgetViewModels())) 54 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 55 | .environment(\.colorScheme, .light) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/AddAlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct AddAlertView: View { 11 | @Environment(\.presentationMode) var presentationMode 12 | 13 | @State private var price: NSNumber? 14 | @State private var isPriceLower: Bool = true 15 | @State private var selectedCurrency = Currencies.getDefaultCurrency() 16 | @State private var selectedCoin = CoinViewModel(symbol: "BTC") 17 | 18 | var body: some View { 19 | NavigationView { 20 | AlertDetailView(price: $price, isPriceLower: $isPriceLower, currency: $selectedCurrency, coin: $selectedCoin) 21 | .navigationBarTitle(Text("Exchange"), displayMode: .inline) 22 | .navigationBarItems( 23 | leading: Button(action: { 24 | presentationMode.wrappedValue.dismiss() 25 | }) { 26 | Text("Cancel").bold() 27 | }, 28 | trailing: Button(action: { 29 | self.saveSettings() 30 | presentationMode.wrappedValue.dismiss() 31 | }) { 32 | Text("Done").bold() 33 | }) 34 | } 35 | } 36 | 37 | private func saveSettings() { 38 | let alertsHandler = AlertsHandler() 39 | let alert = alertsHandler.createAlertEntity() 40 | 41 | alert.currency = self.selectedCurrency.symbol 42 | alert.coinId = self.selectedCoin.id 43 | alert.coinSymbol = self.selectedCoin.symbol 44 | alert.price = self.price?.doubleValue ?? 0 45 | alert.isEnabled = true 46 | alert.isPriceLower = self.isPriceLower 47 | 48 | CoreDataHandler.shared.save() 49 | } 50 | } 51 | 52 | struct AddAlertView_Previews: PreviewProvider { 53 | static var previews: some View { 54 | Group { 55 | AddAlertView() 56 | .environmentObject(ApplicationStateService.preview) 57 | .preferredColorScheme(.dark) 58 | 59 | AddAlertView() 60 | .environmentObject(ApplicationStateService.preview) 61 | .preferredColorScheme(.light) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: # paths to include during linting. `--path` is ignored if present. 2 | - CoreData 3 | - VirtualCoin 4 | - VirtualCoinKit 5 | analyzer_rules: 6 | - unused_import 7 | - unused_private_declaration 8 | opt_in_rules: 9 | - anyobject_protocol 10 | - array_init 11 | - attributes 12 | - class_delegate_protocol 13 | - closure_end_indentation 14 | - closure_body_length 15 | - closure_spacing 16 | - collection_alignment 17 | - contains_over_first_not_nil 18 | - empty_count 19 | - empty_string 20 | - empty_xctest_method 21 | - explicit_init 22 | - extension_access_modifier 23 | - fallthrough 24 | - fatal_error_message 25 | - file_name 26 | - first_where 27 | - force_unwrapping 28 | - identical_operands 29 | - joined_default_parameter 30 | - let_var_whitespace 31 | - last_where 32 | - literal_expression_end_indentation 33 | - lower_acl_than_parent 34 | - mark 35 | - modifier_order 36 | - multiline_arguments 37 | - multiline_literal_brackets 38 | - multiline_parameters 39 | - nimble_operator 40 | - number_separator 41 | - operator_usage_whitespace 42 | - overridden_super_call 43 | - override_in_extension 44 | - pattern_matching_keywords 45 | - private_action 46 | - private_outlet 47 | - prohibited_super_call 48 | - quick_discouraged_call 49 | - quick_discouraged_focused_test 50 | - quick_discouraged_pending_test 51 | - redundant_nil_coalescing 52 | - redundant_type_annotation 53 | - single_test_class 54 | - sorted_first_last 55 | - sorted_imports 56 | - static_operator 57 | - unavailable_function 58 | - unneeded_parentheses_in_closure_argument 59 | - vertical_parameter_alignment_on_call 60 | - vertical_whitespace_closing_braces 61 | - vertical_whitespace_opening_braces 62 | - xct_specific_matcher 63 | - yoda_condition 64 | line_length: 180 65 | file_header: 66 | required_pattern: | 67 | \/\/ 68 | \/\/ .*?\.swift 69 | \/\/ VCoin 70 | \/\/ 71 | \/\/ Created by .*? on \d{1,2}\/\d{1,2}\/\d{2}\. 72 | \/\/ Copyright © \d{4} Marcin Czachurski\. All rights reserved\. 73 | \/\/ 74 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji) 75 | identifier_name: 76 | excluded: # excluded via string array 77 | - id 78 | - Id 79 | - x 80 | - y 81 | -------------------------------------------------------------------------------- /VirtualCoin/vcoin.xcdatamodeld/vcoin-20210606.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/AddExchangeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import NumericText 10 | 11 | struct AddExchangeView: View { 12 | @Environment(\.presentationMode) var presentationMode 13 | 14 | @State private var amount: NSNumber? 15 | @State private var selectedCurrency = Currencies.getDefaultCurrency() 16 | @State private var selectedCoin = CoinViewModel(symbol: "BTC") 17 | 18 | var body: some View { 19 | NavigationView { 20 | ExchangeDetailView(amount: self.$amount, currency: self.$selectedCurrency, coin: self.$selectedCoin) 21 | .navigationBarTitle(Text("Exchange"), displayMode: .inline) 22 | .navigationBarItems( 23 | leading: Button(action: { 24 | presentationMode.wrappedValue.dismiss() 25 | }) { 26 | Text("Cancel").bold() 27 | }, 28 | trailing: Button(action: { 29 | self.saveSettings() 30 | presentationMode.wrappedValue.dismiss() 31 | }) { 32 | Text("Done").bold() 33 | }) 34 | } 35 | } 36 | 37 | private func saveSettings() { 38 | let exchangeItemHandler = ExchangeItemsHandler() 39 | let exchangeItem = exchangeItemHandler.createExchangeItemEntity() 40 | 41 | exchangeItem.currency = self.selectedCurrency.symbol 42 | exchangeItem.coinId = self.selectedCoin.id 43 | exchangeItem.coinSymbol = self.selectedCoin.symbol 44 | exchangeItem.amount = self.amount?.doubleValue ?? 0 45 | 46 | CoreDataHandler.shared.save() 47 | } 48 | } 49 | 50 | struct AddExchangeView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | Group { 53 | EditExchangeView(exchangeViewModel: PreviewData.getExchangeViewModel()) 54 | .environmentObject(ApplicationStateService.preview) 55 | .preferredColorScheme(.dark) 56 | 57 | EditExchangeView(exchangeViewModel: PreviewData.getExchangeViewModel()) 58 | .environmentObject(ApplicationStateService.preview) 59 | .preferredColorScheme(.light) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ListRowsViews/AlertRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct AlertRowView: View { 11 | @ObservedObject public var alertViewModel: AlertViewModel 12 | 13 | @State var isEnabled: Bool = true 14 | let onDetail: () -> Void 15 | 16 | var body: some View { 17 | Button(action: onDetail) { 18 | HStack { 19 | CoinImageView(coin: alertViewModel.coinViewModel) 20 | VStack(alignment: .leading) { 21 | Text("\(alertViewModel.coinViewModel.name)") 22 | .font(.headline) 23 | Text(alertViewModel.alert.coinSymbol) 24 | .font(.subheadline) 25 | .foregroundColor(.accentColor) 26 | HStack { 27 | Text(alertViewModel.alert.isPriceLower ? "Lower than" : "Greather than") 28 | .foregroundColor(.gray) 29 | .font(.subheadline) 30 | Text("\(alertViewModel.alert.price.toFormattedPrice(currency: alertViewModel.alert.currency))") 31 | .font(.subheadline) 32 | Text(alertViewModel.alert.currency) 33 | .font(.subheadline) 34 | .foregroundColor(.accentColor) 35 | } 36 | } 37 | 38 | Spacer() 39 | 40 | VStack(alignment: .trailing) { 41 | Toggle("", isOn: $isEnabled) 42 | .toggleStyle(SwitchToggleStyle(tint: .accentColor)) 43 | .labelsHidden() 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | struct AlertRowView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | Group { 53 | AlertRowView(alertViewModel: PreviewData.getAlertViewModel()) { 54 | } 55 | .preferredColorScheme(.dark) 56 | 57 | AlertRowView(alertViewModel: PreviewData.getAlertViewModel()) { 58 | } 59 | .preferredColorScheme(.light) 60 | } 61 | .previewLayout(.fixed(width: 360, height: 70)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CoreData/AlertsHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | 10 | class AlertsHandler { 11 | private let timeInterval: TimeInterval = -1 * 24 * 60 * 60 12 | 13 | func createAlertEntity() -> Alert { 14 | let context = CoreDataHandler.shared.container.viewContext 15 | return Alert(context: context) 16 | } 17 | 18 | func deleteAlertEntity(alert: Alert) { 19 | let context = CoreDataHandler.shared.container.viewContext 20 | context.delete(alert) 21 | } 22 | 23 | func getActiveAlerts() -> [Alert] { 24 | var alerts: [Alert] = [] 25 | 26 | let context = CoreDataHandler.shared.container.viewContext 27 | let fetchRequest = NSFetchRequest(entityName: "Alert") 28 | 29 | let calendar = Calendar(identifier: Calendar.Identifier.gregorian) 30 | 31 | var date = Date() 32 | date = date.addingTimeInterval(self.timeInterval) 33 | let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) 34 | 35 | guard let minimumAlertDate = calendar.date(from: components) else { 36 | return alerts 37 | } 38 | 39 | let predicate = NSPredicate(format: "isEnabled == YES && (alertSentDate == nil || alertSentDate < %@)", 40 | argumentArray: [minimumAlertDate] 41 | ) 42 | 43 | fetchRequest.predicate = predicate 44 | 45 | do { 46 | if let list = try context.fetch(fetchRequest) as? [Alert] { 47 | alerts = list 48 | } 49 | } catch { 50 | print("Error during fetching Alert") 51 | } 52 | 53 | return alerts 54 | } 55 | 56 | func getAlerts(coinSymbol: String) -> [Alert] { 57 | var alerts: [Alert] = [] 58 | 59 | let context = CoreDataHandler.shared.container.viewContext 60 | 61 | let fetchRequest = NSFetchRequest(entityName: "Alert") 62 | let predicate = NSPredicate(format: "coinSymbol == %@", coinSymbol) 63 | fetchRequest.predicate = predicate 64 | 65 | do { 66 | if let list = try context.fetch(fetchRequest) as? [Alert] { 67 | alerts = list 68 | } 69 | } catch { 70 | print("Error during fetching Alert") 71 | } 72 | 73 | return alerts 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ListRowsViews/ExchangeRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct ExchangeRowView: View { 11 | @ObservedObject public var exchangeViewModel: ExchangeViewModel 12 | 13 | let onDetail: () -> Void 14 | 15 | var body: some View { 16 | Button(action: onDetail) { 17 | ZStack { 18 | HStack { 19 | CoinImageView(coin: exchangeViewModel.coinViewModel) 20 | VStack(alignment: .leading) { 21 | Text("\(exchangeViewModel.coinViewModel.name)") 22 | .font(.headline) 23 | Text(exchangeViewModel.exchangeItem.coinSymbol) 24 | .font(.subheadline) 25 | .foregroundColor(.accentColor) 26 | Text("\(exchangeViewModel.exchangeItem.amount.toFormattedAmount())") 27 | .font(.subheadline) 28 | } 29 | 30 | Spacer() 31 | 32 | VStack(alignment: .trailing) { 33 | Text("\(exchangeViewModel.currency.name)") 34 | .font(.headline) 35 | Text(exchangeViewModel.exchangeItem.currency) 36 | .font(.subheadline) 37 | .foregroundColor(.accentColor) 38 | Text(exchangeViewModel.price.toFormattedPrice(currency: exchangeViewModel.exchangeItem.currency)) 39 | .font(.subheadline) 40 | } 41 | } 42 | HStack { 43 | Spacer() 44 | Image(systemName: "arrow.left.arrow.right") 45 | Spacer() 46 | } 47 | } 48 | } 49 | } 50 | } 51 | 52 | struct ExchangeRowView_Previews: PreviewProvider { 53 | static var previews: some View { 54 | Group { 55 | ExchangeRowView(exchangeViewModel: PreviewData.getExchangeViewModel()) { 56 | } 57 | .preferredColorScheme(.dark) 58 | 59 | ExchangeRowView(exchangeViewModel: PreviewData.getExchangeViewModel()) { 60 | } 61 | .preferredColorScheme(.light) 62 | } 63 | .previewLayout(.fixed(width: 360, height: 70)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /VirtualCoin/vcoin.xcdatamodeld/vcoin-20190712.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /VirtualCoin/vcoin.xcdatamodeld/vcoin.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /VirtualCoin/Cache/MemoryCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Memory cache based on article: https://www.swiftbysundell.com/articles/caching-in-swift/ 10 | final class MemoryCache { 11 | private let wrapped = NSCache() 12 | private let dateProvider: () -> Date 13 | private let entryLifetime: TimeInterval 14 | 15 | init(dateProvider: @escaping () -> Date = Date.init, 16 | entryLifetime: TimeInterval = 12 * 60 * 60) { 17 | self.dateProvider = dateProvider 18 | self.entryLifetime = entryLifetime 19 | } 20 | 21 | func insert(_ value: Value, forKey key: Key) { 22 | let date = dateProvider().addingTimeInterval(entryLifetime) 23 | let entry = Entry(value: value, expirationDate: date) 24 | wrapped.setObject(entry, forKey: WrappedKey(key)) 25 | } 26 | 27 | func value(forKey key: Key) -> Value? { 28 | guard let entry = wrapped.object(forKey: WrappedKey(key)) else { 29 | return nil 30 | } 31 | 32 | guard dateProvider() < entry.expirationDate else { 33 | // Discard values that have expired 34 | removeValue(forKey: key) 35 | return nil 36 | } 37 | 38 | return entry.value 39 | } 40 | 41 | func removeValue(forKey key: Key) { 42 | wrapped.removeObject(forKey: WrappedKey(key)) 43 | } 44 | } 45 | 46 | private extension MemoryCache { 47 | final class WrappedKey: NSObject { 48 | let key: Key 49 | 50 | init(_ key: Key) { self.key = key } 51 | 52 | override var hash: Int { return key.hashValue } 53 | 54 | override func isEqual(_ object: Any?) -> Bool { 55 | guard let value = object as? WrappedKey else { 56 | return false 57 | } 58 | 59 | return value.key == key 60 | } 61 | } 62 | } 63 | 64 | private extension MemoryCache { 65 | final class Entry { 66 | let value: Value 67 | let expirationDate: Date 68 | 69 | init(value: Value, expirationDate: Date) { 70 | self.value = value 71 | self.expirationDate = expirationDate 72 | } 73 | } 74 | } 75 | 76 | extension MemoryCache { 77 | subscript(key: Key) -> Value? { 78 | get { return value(forKey: key) } 79 | set { 80 | guard let value = newValue else { 81 | // If nil was assigned using our subscript, 82 | // then we remove any value for that key: 83 | removeValue(forKey: key) 84 | return 85 | } 86 | 87 | insert(value, forKey: key) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /VirtualCoin/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon-60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-121.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-41.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-59.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-42.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-81.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "Icon-1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,swift,xcode,windows 3 | 4 | ### Carthage ### 5 | # Carthage 6 | # 7 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 8 | Carthage/Checkouts 9 | Carthage/Build 10 | Cartfile.resolved 11 | 12 | 13 | ### OSX ### 14 | *.DS_Store 15 | .AppleDouble 16 | .LSOverride 17 | 18 | # Icon must end with two \r 19 | Icon 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # Files that might appear in the root of a volume 25 | .DocumentRevisions-V100 26 | .fseventsd 27 | .Spotlight-V100 28 | .TemporaryItems 29 | .Trashes 30 | .VolumeIcon.icns 31 | .com.apple.timemachine.donotpresent 32 | 33 | # Directories potentially created on remote AFP share 34 | .AppleDB 35 | .AppleDesktop 36 | Network Trash Folder 37 | Temporary Items 38 | .apdisk 39 | 40 | ### Swift ### 41 | # Xcode 42 | # 43 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 44 | 45 | ## Build generated 46 | build/ 47 | DerivedData/ 48 | 49 | ## Various settings 50 | *.pbxuser 51 | !default.pbxuser 52 | *.mode1v3 53 | !default.mode1v3 54 | *.mode2v3 55 | !default.mode2v3 56 | *.perspectivev3 57 | !default.perspectivev3 58 | xcuserdata/ 59 | 60 | ## Other 61 | *.moved-aside 62 | *.xccheckout 63 | *.xcscmblueprint 64 | 65 | ## Obj-C/Swift specific 66 | *.hmap 67 | *.ipa 68 | *.dSYM.zip 69 | *.dSYM 70 | 71 | ## Playgrounds 72 | timeline.xctimeline 73 | playground.xcworkspace 74 | 75 | # Swift Package Manager 76 | # 77 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 78 | # Packages/ 79 | # Package.pins 80 | .build/ 81 | 82 | 83 | # fastlane 84 | # 85 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 86 | # screenshots whenever they are needed. 87 | # For more information about the recommended setup visit: 88 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 89 | 90 | fastlane/report.xml 91 | fastlane/Preview.html 92 | fastlane/screenshots 93 | fastlane/test_output 94 | 95 | ### Windows ### 96 | # Windows thumbnail cache files 97 | Thumbs.db 98 | ehthumbs.db 99 | ehthumbs_vista.db 100 | 101 | # Folder config file 102 | Desktop.ini 103 | 104 | # Recycle Bin used on file shares 105 | $RECYCLE.BIN/ 106 | 107 | # Windows Installer files 108 | *.cab 109 | *.msi 110 | *.msm 111 | *.msp 112 | 113 | # Windows shortcuts 114 | *.lnk 115 | 116 | ### Xcode ### 117 | # Xcode 118 | # 119 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 120 | 121 | ## Build generated 122 | 123 | ## Various settings 124 | 125 | ## Other 126 | 127 | ### Xcode Patch ### 128 | *.xcodeproj/* 129 | !*.xcodeproj/project.pbxproj 130 | !*.xcodeproj/xcshareddata/ 131 | !*.xcworkspace/contents.xcworkspacedata 132 | /*.gcno 133 | 134 | # End of https://www.gitignore.io/api/osx,swift,xcode,windows -------------------------------------------------------------------------------- /VirtualCoin/VirtualCoinApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VirtualCoinApp.swift 3 | // VirtualCoin 4 | // 5 | // Created by Marcin Czachurski on 17/04/2021. 6 | // Copyright © 2021 Marcin Czachurski. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import VirtualCoinKit 11 | import BackgroundTasks 12 | import URLImage 13 | import URLImageStore 14 | 15 | @main 16 | struct VirtualCoinApp: App { 17 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 18 | 19 | let urlImageService = URLImageService(inMemoryStore: URLImageInMemoryStore()) 20 | 21 | var body: some Scene { 22 | WindowGroup { 23 | AppView() 24 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in 25 | PricesService.shared.stop() 26 | appDelegate.submitBackgroundTasks() 27 | } 28 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in 29 | PricesService.shared.start() 30 | } 31 | .environmentObject(ApplicationStateService.shared) 32 | .environmentObject(PricesService.shared) 33 | .environmentObject(CoinsService.shared) 34 | .environment(\.managedObjectContext, CoreDataHandler.shared.container.viewContext) 35 | .environment(\.urlImageService, urlImageService) 36 | } 37 | } 38 | } 39 | 40 | class AppDelegate: NSObject, UIApplicationDelegate { 41 | let backgroundTaskId = "dev.mczachurski.vcoin.fetch" 42 | 43 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 44 | self.registerBackgroundFetch() 45 | return true 46 | } 47 | 48 | private func registerBackgroundFetch() { 49 | BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskId, using: nil) { task in 50 | 51 | // Task expired. 52 | task.expirationHandler = { 53 | task.setTaskCompleted(success: false) 54 | } 55 | 56 | let notifications = Notifications() 57 | notifications.sendNotification { result in 58 | switch result { 59 | case .success: 60 | task.setTaskCompleted(success: true) 61 | case .failure(_): 62 | task.setTaskCompleted(success: false) 63 | } 64 | } 65 | } 66 | } 67 | 68 | func submitBackgroundTasks() { 69 | do { 70 | let request = BGAppRefreshTaskRequest(identifier: backgroundTaskId) 71 | request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // Refresh after 1 hour. 72 | try BGTaskScheduler.shared.submit(request) 73 | } catch { 74 | print("Could not schedule app refresh task \(error.localizedDescription)") 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/ExchangeDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import NumericText 10 | 11 | struct ExchangeDetailView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | 14 | @Binding private var amount: NSNumber? 15 | @Binding private var selectedCurrency: Currency 16 | @Binding private var selectedCoin: CoinViewModel 17 | 18 | init(amount: Binding, currency: Binding, coin: Binding) { 19 | _amount = amount 20 | _selectedCurrency = currency 21 | _selectedCoin = coin 22 | } 23 | 24 | var body: some View { 25 | List { 26 | Section(header: Text("VALUES")) { 27 | HStack { 28 | Text("Amount") 29 | NumericTextField("Amount", 30 | number: $amount, 31 | isDecimalAllowed: true, 32 | numberFormatter: NumberFormatter.amountFormatter) 33 | .multilineTextAlignment(.trailing) 34 | } 35 | 36 | Picker(selection: $selectedCoin, label: Text("Coin")) { 37 | ForEach(applicationStateService.coins, id: \.self) { coin in 38 | HStack { 39 | Text(coin.name) 40 | .font(.body) 41 | Text("(\(coin.symbol))") 42 | .font(.footnote) 43 | .foregroundColor(.accentColor) 44 | }.tag(coin) 45 | } 46 | } 47 | 48 | Picker(selection: $selectedCurrency, label: Text("Currency")) { 49 | ForEach(Currencies.allCurrenciesList, id: \.self) { currency in 50 | HStack { 51 | Text(currency.name) 52 | .font(.body) 53 | Text("(\(currency.symbol))") 54 | .font(.footnote) 55 | .foregroundColor(.accentColor) 56 | }.tag(currency) 57 | } 58 | } 59 | } 60 | } 61 | .listStyle(GroupedListStyle()) 62 | } 63 | } 64 | 65 | struct ExchangeDetailView_Previews: PreviewProvider { 66 | @State private static var amount: NSNumber? = 12.32 67 | @State private static var currency = PreviewData.getCurrency() 68 | @State private static var coin = PreviewData.getCoinViewModel() 69 | 70 | static var previews: some View { 71 | ExchangeDetailView(amount: $amount, currency: $currency, coin: $coin) 72 | .environmentObject(ApplicationStateService.preview) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Views/LargeWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | import LightChart 10 | import VirtualCoinKit 11 | 12 | struct LargeWidgetView: View { 13 | var viewModels: [WidgetViewModel] 14 | 15 | @Setting(\.currency) private var currencySymbol: String 16 | 17 | var body: some View { 18 | VStack { 19 | ForEach(viewModels.prefix(6).indices, id: \.self) { index in 20 | VStack { 21 | HStack { 22 | VStack(alignment: .leading) { 23 | Text("\(viewModels[index].name)") 24 | .font(.caption2) 25 | Text("\(viewModels[index].symbol)") 26 | .foregroundColor(Color.gray) 27 | .font(.caption2) 28 | } 29 | 30 | Spacer() 31 | 32 | LightChartView(data: viewModels[index].chart, 33 | type: .curved, 34 | visualType: viewModels[index].changePercent24Hr > 0 ? .green : .red, 35 | currentValueLineType: viewModels[index].changePercent24Hr > 0 ? .green : .red) 36 | .frame(maxWidth: 80, maxHeight: .infinity) 37 | .padding(.top, 2) 38 | .padding(.bottom, 2) 39 | 40 | HStack { 41 | Spacer() 42 | VStack(alignment: .trailing){ 43 | Text("\(viewModels[index].price.toFormattedPrice(currency: currencySymbol))") 44 | .font(.caption2) 45 | Text("\(viewModels[index].changePercent24Hr.toFormattedPercent())") 46 | .foregroundColor(viewModels[index].changePercent24Hr > 0 ?.greenPastel : .redPastel) 47 | .font(.caption2) 48 | } 49 | } 50 | .frame(minWidth: 100, maxWidth: 100) 51 | } 52 | 53 | if index + 1 < (viewModels.capacity > 6 ? 6 : viewModels.capacity) { 54 | Divider() 55 | } 56 | } 57 | } 58 | } 59 | .padding() 60 | } 61 | } 62 | 63 | struct LargeWidgetView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | Group { 66 | VirtualCoinWidgetEntryView(entry: WidgetEntry(date: Date(), viewModels: PreviewData.getWidgetViewModels())) 67 | .previewContext(WidgetPreviewContext(family: .systemLarge)) 68 | .environment(\.colorScheme, .light) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /VirtualCoin/ViewModels/CoinViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import VirtualCoinKit 9 | 10 | public class CoinViewModel: Identifiable, ObservableObject { 11 | public let id: String 12 | public let rank: Int 13 | public let symbol: String 14 | public let name: String 15 | public let imageUrl: String 16 | public let orginalPriceUsd: Double 17 | 18 | @Published public var price: Double = 0.0 19 | @Published public var isFavourite = false 20 | @Published public var priceUsd: Double 21 | @Published public var changePercent24Hr: Double 22 | 23 | init(coin: Coin) { 24 | self.id = coin.id 25 | self.symbol = coin.symbol 26 | self.name = coin.name 27 | 28 | if let rank = Int(coin.rank) { 29 | self.rank = rank 30 | } else { 31 | self.rank = 0 32 | } 33 | 34 | var internalPriceUsd = 0.0 35 | if let priceUsd = coin.priceUsd, let price = Double(priceUsd) { 36 | internalPriceUsd = price 37 | } 38 | 39 | var internalChangePercent24Hr = 0.0 40 | if let changePercent24Hr = coin.changePercent24Hr, let price = Double(changePercent24Hr) { 41 | internalChangePercent24Hr = price 42 | } 43 | 44 | self.priceUsd = internalPriceUsd 45 | self.changePercent24Hr = internalChangePercent24Hr 46 | self.orginalPriceUsd = internalPriceUsd / ((internalChangePercent24Hr / 100) + 1) 47 | self.imageUrl = "https://static.coincap.io/assets/icons/\(symbol.lowercased())@2x.png" 48 | } 49 | 50 | init(id: String = "bitcoin", rank: Int = 1, symbol: String = "BTC", name: String = "Bitcoin", priceUsd: Double = 0.0, changePercent24Hr: Double = 0.0) { 51 | self.id = id 52 | self.rank = rank 53 | self.symbol = symbol 54 | self.name = name 55 | self.priceUsd = priceUsd 56 | self.orginalPriceUsd = priceUsd 57 | self.price = priceUsd 58 | self.changePercent24Hr = changePercent24Hr 59 | self.imageUrl = "https://static.coincap.io/assets/icons/\(symbol.lowercased())@2x.png" 60 | } 61 | } 62 | 63 | extension CoinViewModel { 64 | public func refresh(priceUsd: String?, changePercent24Hr: String?, withRateUsd rateUsd: Double) { 65 | if let priceUsd = priceUsd, let price = Double(priceUsd) { 66 | self.priceUsd = price 67 | self.price = price / rateUsd 68 | } 69 | 70 | if let changePercent24Hr = changePercent24Hr, let price = Double(changePercent24Hr) { 71 | self.changePercent24Hr = price 72 | } 73 | } 74 | } 75 | 76 | extension CoinViewModel: Hashable { 77 | public static func == (lhs: CoinViewModel, rhs: CoinViewModel) -> Bool { 78 | return lhs.id == rhs.id 79 | } 80 | 81 | public func hash(into hasher: inout Hasher) { 82 | hasher.combine(self.id) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /VirtualCoinWidget/Views/MediumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import WidgetKit 8 | import SwiftUI 9 | import LightChart 10 | import VirtualCoinKit 11 | 12 | struct MediumWidgetView: View { 13 | var viewModels: [WidgetViewModel] 14 | 15 | @Setting(\.currency) private var currencySymbol: String 16 | 17 | var body: some View { 18 | VStack { 19 | ForEach(viewModels.prefix(3).indices, id: \.self) { index in 20 | VStack { 21 | HStack { 22 | VStack(alignment: .leading) { 23 | Text("\(viewModels[index].name)") 24 | .font(.caption2) 25 | Text("\(viewModels[index].symbol)") 26 | .foregroundColor(Color.gray) 27 | .font(.caption2) 28 | } 29 | 30 | Spacer() 31 | 32 | LightChartView(data: viewModels[index].chart, 33 | type: .curved, 34 | visualType: viewModels[index].changePercent24Hr > 0 ? .green : .red, 35 | currentValueLineType: viewModels[index].changePercent24Hr > 0 ? .green : .red) 36 | .frame(maxWidth: 80, maxHeight: .infinity) 37 | .padding(.top, 2) 38 | .padding(.bottom, 2) 39 | 40 | HStack { 41 | Spacer() 42 | VStack(alignment: .trailing){ 43 | Text("\(viewModels[index].price.toFormattedPrice(currency: currencySymbol))") 44 | .font(.caption2) 45 | Text("\(viewModels[index].changePercent24Hr.toFormattedPercent())") 46 | .foregroundColor(viewModels[index].changePercent24Hr > 0 ?.greenPastel : .redPastel) 47 | .font(.caption2) 48 | } 49 | } 50 | .frame(minWidth: 100, maxWidth: 100) 51 | } 52 | 53 | if index + 1 < (viewModels.capacity > 3 ? 3 : viewModels.capacity) { 54 | Divider() 55 | } 56 | } 57 | } 58 | } 59 | .padding() 60 | } 61 | } 62 | 63 | struct MediumWidgetView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | Group { 66 | VirtualCoinWidgetEntryView(entry: WidgetEntry(date: Date(), viewModels: PreviewData.getWidgetViewModels())) 67 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 68 | .environment(\.colorScheme, .light) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /VirtualCoinWidget/PreviewData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | struct PreviewData { 10 | 11 | static func getWidgetViewModels() -> [WidgetViewModel] { 12 | return [ 13 | WidgetViewModel(id: "bitcoin", 14 | order: 1, 15 | rank: 1, 16 | symbol: "BTC", 17 | name: "Bitcoin", 18 | priceUsd: 36135.11, 19 | changePercent24Hr: -4.72, 20 | price: 36135.11, 21 | chart: getChartData()), 22 | 23 | WidgetViewModel(id: "ethereum", 24 | order: 2, 25 | rank: 2, 26 | symbol: "ETH", 27 | name: "Ethereum", 28 | priceUsd: 2662.72, 29 | changePercent24Hr: -4.43, 30 | price: 2662.72, 31 | chart: getChartData()), 32 | 33 | WidgetViewModel(id: "dogecoin", 34 | order: 3, 35 | rank: 3, 36 | symbol: "DOGE", 37 | name: "Dogecoin", 38 | priceUsd: 0.37526429, 39 | changePercent24Hr: -5.18, 40 | price: 0.37526429, 41 | chart: getChartData()), 42 | 43 | WidgetViewModel(id: "cardano", 44 | order: 4, 45 | rank: 4, 46 | symbol: "USDT", 47 | name: "Cardano", 48 | priceUsd: 1.69, 49 | changePercent24Hr: -5.18, 50 | price: 1.69, 51 | chart: getChartData()), 52 | 53 | WidgetViewModel(id: "tether", 54 | order: 5, 55 | rank: 5, 56 | symbol: "USDT", 57 | name: "Tether", 58 | priceUsd: 1.00, 59 | changePercent24Hr: 0.03, 60 | price: 1.00, 61 | chart: getChartData()), 62 | 63 | WidgetViewModel(id: "xrp", 64 | order: 6, 65 | rank: 6, 66 | symbol: "XRP", 67 | name: "XRP", 68 | priceUsd: 0.94153174, 69 | changePercent24Hr: -5.35, 70 | price: 0.94153174, 71 | chart: getChartData()) 72 | ] 73 | } 74 | 75 | private static func getChartData() -> [Double] { 76 | return [34, 34, 23, 34, 45, 65, 34, 32, 23, 33, 65, 57, 65, 34, 65, 67] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/AlertDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import NumericText 10 | 11 | struct AlertDetailView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | 14 | @Binding private var price: NSNumber? 15 | @Binding private var isPriceLower: Bool 16 | @Binding private var selectedCurrency: Currency 17 | @Binding private var selectedCoin: CoinViewModel 18 | 19 | init(price: Binding, isPriceLower: Binding, currency: Binding, coin: Binding) { 20 | _price = price 21 | _isPriceLower = isPriceLower 22 | _selectedCurrency = currency 23 | _selectedCoin = coin 24 | } 25 | 26 | var body: some View { 27 | List { 28 | Section(header: Text("VALUES")) { 29 | HStack { 30 | Text("Notify when") 31 | Picker("", selection: $isPriceLower) { 32 | Text("lower than").tag(true) 33 | Text("higher than").tag(false) 34 | } 35 | .pickerStyle(SegmentedPickerStyle()) 36 | } 37 | 38 | HStack { 39 | Text("Price") 40 | NumericTextField("Price", number: $price, isDecimalAllowed: true) 41 | .multilineTextAlignment(.trailing) 42 | } 43 | 44 | Picker(selection: $selectedCurrency, label: Text("Currency")) { 45 | ForEach(Currencies.allCurrenciesList, id: \.self) { currency in 46 | HStack { 47 | Text(currency.name) 48 | .font(.body) 49 | Text("(\(currency.symbol))") 50 | .font(.footnote) 51 | .foregroundColor(.accentColor) 52 | }.tag(currency) 53 | } 54 | } 55 | 56 | Picker(selection: $selectedCoin, label: Text("Coin")) { 57 | ForEach(applicationStateService.coins, id: \.self) { coin in 58 | HStack { 59 | Text(coin.name) 60 | .font(.body) 61 | Text("(\(coin.symbol))") 62 | .font(.footnote) 63 | .foregroundColor(.accentColor) 64 | }.tag(coin) 65 | } 66 | } 67 | } 68 | } 69 | .listStyle(GroupedListStyle()) 70 | } 71 | } 72 | 73 | struct AlertDetailView_Previews: PreviewProvider { 74 | @State private static var price: NSNumber? = 21.2 75 | @State private static var isPriceLower: Bool = true 76 | @State private static var currency = PreviewData.getCurrency() 77 | @State private static var coin = PreviewData.getCoinViewModel() 78 | 79 | static var previews: some View { 80 | AlertDetailView(price: $price, isPriceLower: $isPriceLower, currency: $currency, coin: $coin) 81 | .environmentObject(ApplicationStateService.preview) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/EditExchangeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import NumericText 10 | 11 | struct EditExchangeView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | @Environment(\.presentationMode) var presentationMode 14 | 15 | @State private var amount: NSNumber? 16 | @State private var selectedCurrency: Currency 17 | @State private var selectedCoin: CoinViewModel 18 | 19 | @ObservedObject private var exchangeViewModel: ExchangeViewModel 20 | 21 | init(exchangeViewModel: ExchangeViewModel) { 22 | self.exchangeViewModel = exchangeViewModel 23 | 24 | self._amount = State(initialValue: NSNumber(value: exchangeViewModel.exchangeItem.amount)) 25 | self._selectedCurrency = State(initialValue: Currency(symbol: exchangeViewModel.exchangeItem.currency)) 26 | self._selectedCoin = State(initialValue: CoinViewModel(id: exchangeViewModel.exchangeItem.coinId)) 27 | } 28 | 29 | var body: some View { 30 | NavigationView { 31 | ExchangeDetailView(amount: self.$amount, currency: self.$selectedCurrency, coin: self.$selectedCoin) 32 | .navigationBarTitle(Text("Exchange"), displayMode: .inline) 33 | .navigationBarItems( 34 | leading: Button(action: { 35 | presentationMode.wrappedValue.dismiss() 36 | }) { 37 | Text("Cancel").bold() 38 | }, 39 | trailing: Button(action: { 40 | self.saveSettings() 41 | presentationMode.wrappedValue.dismiss() 42 | }) { 43 | Text("Done").bold() 44 | }) 45 | } 46 | } 47 | 48 | private func saveSettings() { 49 | self.exchangeViewModel.exchangeItem.currency = self.selectedCurrency.symbol 50 | self.exchangeViewModel.exchangeItem.coinId = self.selectedCoin.id 51 | self.exchangeViewModel.exchangeItem.coinSymbol = self.selectedCoin.symbol 52 | self.exchangeViewModel.exchangeItem.amount = self.amount?.doubleValue ?? 0 53 | 54 | CoreDataHandler.shared.save() 55 | 56 | if let coinViewModel = applicationStateService.coins.first(where: { coinViewModel in 57 | coinViewModel.id == self.exchangeViewModel.exchangeItem.coinId 58 | }) { 59 | self.exchangeViewModel.setCoinViewModel(coinViewModel) 60 | } 61 | 62 | if let currency = Currencies.allCurrenciesDictionary[self.exchangeViewModel.exchangeItem.currency] { 63 | self.exchangeViewModel.setCurrency(currency) 64 | } 65 | } 66 | } 67 | 68 | struct EditExchangeView_Previews: PreviewProvider { 69 | static var previews: some View { 70 | Group { 71 | EditExchangeView(exchangeViewModel: PreviewData.getExchangeViewModel()) 72 | .environmentObject(ApplicationStateService.preview) 73 | .preferredColorScheme(.dark) 74 | 75 | EditExchangeView(exchangeViewModel: PreviewData.getExchangeViewModel()) 76 | .environmentObject(ApplicationStateService.preview) 77 | .preferredColorScheme(.light) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/EditAlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import NumericText 10 | 11 | struct EditAlertView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | @Environment(\.presentationMode) var presentationMode 14 | 15 | @State private var price: NSNumber? 16 | @State private var isPriceLower: Bool = true 17 | @State private var selectedCurrency: Currency 18 | @State private var selectedCoin: CoinViewModel 19 | 20 | @ObservedObject private var alertViewModel: AlertViewModel 21 | 22 | init(alertViewModel: AlertViewModel) { 23 | self.alertViewModel = alertViewModel 24 | 25 | self._price = State(initialValue: NSNumber(value: alertViewModel.alert.price)) 26 | self._isPriceLower = State(initialValue: alertViewModel.alert.isPriceLower) 27 | self._selectedCurrency = State(initialValue: Currency(symbol: alertViewModel.alert.currency)) 28 | self._selectedCoin = State(initialValue: CoinViewModel(id: alertViewModel.alert.coinId)) 29 | } 30 | 31 | var body: some View { 32 | NavigationView { 33 | AlertDetailView(price: self.$price, isPriceLower: $isPriceLower, currency: self.$selectedCurrency, coin: self.$selectedCoin) 34 | .navigationBarTitle(Text("Alert"), displayMode: .inline) 35 | .navigationBarItems( 36 | leading: Button(action: { 37 | presentationMode.wrappedValue.dismiss() 38 | }) { 39 | Text("Cancel").bold() 40 | }, 41 | trailing: Button(action: { 42 | self.saveSettings() 43 | presentationMode.wrappedValue.dismiss() 44 | }) { 45 | Text("Done").bold() 46 | }) 47 | } 48 | } 49 | 50 | private func saveSettings() { 51 | self.alertViewModel.alert.currency = self.selectedCurrency.symbol 52 | self.alertViewModel.alert.coinId = self.selectedCoin.id 53 | self.alertViewModel.alert.coinSymbol = self.selectedCoin.symbol 54 | self.alertViewModel.alert.price = self.price?.doubleValue ?? 0 55 | self.alertViewModel.alert.isPriceLower = self.isPriceLower 56 | 57 | CoreDataHandler.shared.save() 58 | 59 | if let coinViewModel = applicationStateService.coins.first(where: { coinViewModel in 60 | coinViewModel.id == self.alertViewModel.alert.coinId 61 | }) { 62 | self.alertViewModel.setCoinViewModel(coinViewModel) 63 | } 64 | 65 | if let currency = Currencies.allCurrenciesDictionary[self.alertViewModel.alert.currency] { 66 | self.alertViewModel.setCurrency(currency) 67 | } 68 | } 69 | } 70 | 71 | struct EEditAlertView_Previews: PreviewProvider { 72 | static var previews: some View { 73 | Group { 74 | EditAlertView(alertViewModel: PreviewData.getAlertViewModel()) 75 | .environmentObject(ApplicationStateService.preview) 76 | .preferredColorScheme(.dark) 77 | 78 | EditAlertView(alertViewModel: PreviewData.getAlertViewModel()) 79 | .environmentObject(ApplicationStateService.preview) 80 | .preferredColorScheme(.light) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /VirtualCoin.xcodeproj/xcuserdata/mczachurski.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 36 | 37 | 51 | 52 | 53 | 54 | 55 | 57 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /VirtualCoin/Services/PricesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | 9 | public class PricesService: ObservableObject { 10 | public static let shared = PricesService() 11 | 12 | var urlSession: URLSession? 13 | var webSocketTask: URLSessionWebSocketTask? 14 | var pricesServiceConnection = PricesServiceConnection() 15 | 16 | deinit { 17 | self.stop() 18 | } 19 | 20 | func start() { 21 | guard self.pricesServiceConnection.isConnected == false else { 22 | return 23 | } 24 | 25 | let favouritesHandler = FavouritesHandler() 26 | let favourites = favouritesHandler.getFavourites() 27 | 28 | guard favourites.isEmpty == false else { 29 | return 30 | } 31 | 32 | let queryParam = favourites.map { favourite in favourite.coinId }.joined(separator: ",") 33 | 34 | self.urlSession = URLSession(configuration: .default, delegate: self.pricesServiceConnection, delegateQueue: OperationQueue()) 35 | self.webSocketTask = self.urlSession?.webSocketTask(with: URL(string: "wss://ws.coincap.io/prices?assets=\(queryParam)")!) 36 | 37 | self.webSocketTask?.resume() 38 | self.receiveMessages() 39 | } 40 | 41 | func stop() { 42 | self.webSocketTask?.cancel(with: .goingAway, reason: nil) 43 | self.webSocketTask = nil 44 | self.urlSession = nil 45 | } 46 | 47 | private func receiveMessages() { 48 | webSocketTask?.receive { result in 49 | switch result { 50 | case .failure(let error): 51 | print("Error in receiving message: \(error)") 52 | case .success(let message): 53 | switch message { 54 | case .string(let text): 55 | self.updatePrice(message: text) 56 | case .data(_): 57 | print("Received unsupported message") 58 | @unknown default: 59 | print("Received unknown") 60 | } 61 | 62 | self.receiveMessages() 63 | } 64 | } 65 | } 66 | 67 | private func updatePrice(message: String) { 68 | guard let data = message.data(using: .utf8) else { 69 | return 70 | } 71 | 72 | guard let prices = try? JSONDecoder().decode(([String: String]).self, from: data) else { 73 | return 74 | } 75 | 76 | for price in prices { 77 | guard let coin = ApplicationStateService.shared.coins.first(where: { coinViewModel in coinViewModel.id == price.key }) else { 78 | continue 79 | } 80 | 81 | guard let priceUsd = Double(price.value) else { 82 | continue 83 | } 84 | 85 | let difference = priceUsd - coin.orginalPriceUsd 86 | let changePercent24Hr = (difference / coin.orginalPriceUsd) * 100 87 | 88 | DispatchQueue.runOnMain { 89 | coin.changePercent24Hr = changePercent24Hr 90 | coin.price = priceUsd / ApplicationStateService.shared.currencyRateUsd 91 | coin.priceUsd = priceUsd 92 | } 93 | } 94 | } 95 | } 96 | 97 | class PricesServiceConnection: NSObject, URLSessionWebSocketDelegate { 98 | var isConnected = false 99 | var connectionError = false 100 | 101 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { 102 | self.isConnected = true 103 | } 104 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { 105 | self.isConnected = false 106 | } 107 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError: Error?) { 108 | self.connectionError = true 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ComponentViews/ChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | import LightChart 10 | 11 | struct ChartView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | @EnvironmentObject private var coinsService: CoinsService 14 | 15 | @State private var state: ViewState = .iddle 16 | @State private var chartData: [Double] = [] 17 | 18 | public var chartTimeRange: ChartTimeRange 19 | public var coin: CoinViewModel 20 | 21 | @Setting(\.currency) private var currencySymbol: String 22 | 23 | var body: some View { 24 | switch state { 25 | case .iddle: 26 | Text("").onAppear { 27 | self.load() 28 | } 29 | case .loading: 30 | LoadingView() 31 | case .loaded: 32 | ZStack(alignment: .topTrailing) { 33 | LightChartView(data: self.chartData, 34 | type: .curved, 35 | visualType: .customFilled(color: .main, 36 | lineWidth: 2, 37 | fillGradient: LinearGradient( 38 | gradient: .init(colors: [.main(opacity: 0.5), .main(opacity: 0.1)]), 39 | startPoint: .init(x: 0.5, y: 1), 40 | endPoint: .init(x: 0.5, y: 0) 41 | )), 42 | currentValueLineType: .dash(color: .main(opacity: 0.3), lineWidth: 1, dash: [5])) 43 | VStack { 44 | self.getLabelView(value: self.getMaxValue()) 45 | .offset(x: -4, y: 4) 46 | Spacer() 47 | self.getLabelView(value: self.getMinValue()) 48 | .offset(x: -4, y: -4) 49 | } 50 | } 51 | case .error(let error): 52 | ErrorView(error: error) 53 | .padding() 54 | } 55 | } 56 | 57 | private func getLabelView(value: Double) -> some View { 58 | Text(value.toFormattedPrice(currency: currencySymbol)) 59 | .font(.footnote) 60 | .padding(2) 61 | .background(Color.backgroundLabel(opacity: 0.4)) 62 | .foregroundColor(.main) 63 | .cornerRadius(5) 64 | } 65 | 66 | private func getMaxValue() -> Double { 67 | return (self.chartData.max() ?? 0) / self.applicationStateService.currencyRateUsd 68 | } 69 | 70 | private func getMinValue() -> Double { 71 | return (self.chartData.min() ?? 0) / self.applicationStateService.currencyRateUsd 72 | } 73 | 74 | private func load() { 75 | state = .loading 76 | 77 | coinsService.getChartData(coin: coin, chartTimeRange: chartTimeRange) { result in 78 | switch result { 79 | case .success(let chartData): 80 | self.chartData = chartData 81 | self.state = .loaded 82 | break; 83 | case .failure(let error): 84 | self.state = .error(error) 85 | break; 86 | } 87 | } 88 | } 89 | } 90 | 91 | struct ChartView_Previews: PreviewProvider { 92 | static var previews: some View { 93 | Group { 94 | ChartView(chartTimeRange: .hour, coin: PreviewData.getCoinViewModel()) 95 | .environmentObject(CoinsService.preview) 96 | .preferredColorScheme(.dark) 97 | 98 | ChartView(chartTimeRange: .hour, coin: PreviewData.getCoinViewModel()) 99 | .environmentObject(CoinsService.preview) 100 | .preferredColorScheme(.light) 101 | } 102 | .previewLayout(.fixed(width: 360, height: 360)) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /VirtualCoin/Views/TabsViews/CoinsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import CoreData 9 | import VirtualCoinKit 10 | 11 | struct CoinsView: View { 12 | 13 | @EnvironmentObject private var coinsService: CoinsService 14 | @EnvironmentObject private var applicationStateService: ApplicationStateService 15 | 16 | @ObservedObject private var searchBar: SearchBar = SearchBar() 17 | @State private var showingSettingsView = false 18 | @State private var state: ViewState = .iddle 19 | 20 | var body: some View { 21 | self.mainBody() 22 | .navigationTitle("All currencies") 23 | .toolbar { 24 | ToolbarItem(placement: .navigationBarLeading) { 25 | Button(action: { 26 | showingSettingsView.toggle() 27 | }) { 28 | Image(systemName: "switch.2") 29 | } 30 | } 31 | } 32 | .sheet(isPresented: $showingSettingsView) { 33 | SettingsView() 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | private func mainBody() -> some View { 39 | switch state { 40 | case .iddle: 41 | self.skeletonView() 42 | .onAppear { 43 | self.load() 44 | } 45 | case .loading: 46 | self.skeletonView() 47 | case .loaded: 48 | if applicationStateService.coins.count > 0 { 49 | List { 50 | ForEach(applicationStateService.coins.filter { 51 | searchBar.text.isEmpty || $0.name.localizedStandardContains(searchBar.text) 52 | }) { coin in 53 | NavigationLink(destination: CoinView(coin: coin)) { 54 | CoinRowView(coin: coin) 55 | } 56 | } 57 | } 58 | .add(self.searchBar) 59 | .listStyle(PlainListStyle()) 60 | } else { 61 | NoDataView(title: "No Coins", subtitle: "Coins has not been downloaded") 62 | } 63 | case .error(let error): 64 | ErrorView(error: error) { 65 | self.load() 66 | } 67 | .padding() 68 | } 69 | } 70 | 71 | private func skeletonView() -> some View { 72 | List(PreviewData.getCoinsViewModel(), id: \.id) { coin in 73 | NavigationLink(destination: CoinView(coin: coin)) { 74 | CoinRowView(coin: coin) 75 | } 76 | } 77 | .listStyle(PlainListStyle()) 78 | .redacted(reason: .placeholder) 79 | } 80 | 81 | private func load() { 82 | if applicationStateService.coins.isEmpty == false { 83 | self.state = .loaded 84 | return 85 | } 86 | 87 | state = .loading 88 | 89 | coinsService.loadCoins(into: applicationStateService) { result in 90 | DispatchQueue.runOnMain { 91 | switch result { 92 | case .success: 93 | self.state = .loaded 94 | break; 95 | case .failure(let error): 96 | self.state = .error(error) 97 | break; 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | struct CoinsView_Previews: PreviewProvider { 105 | static var previews: some View { 106 | Group { 107 | NavigationView { 108 | CoinsView() 109 | .environmentObject(ApplicationStateService.preview) 110 | .environmentObject(CoinsService.preview) 111 | } 112 | .preferredColorScheme(.dark) 113 | 114 | NavigationView { 115 | CoinsView() 116 | .environmentObject(ApplicationStateService.preview) 117 | .environmentObject(CoinsService.preview) 118 | } 119 | .preferredColorScheme(.light) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct SettingsView: View { 11 | @EnvironmentObject private var coinsService: CoinsService 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | @Environment(\.presentationMode) var presentationMode 14 | 15 | @State var matchSystem: Bool = true 16 | @State var isDarkMode: Bool = true 17 | @State private var selectedCurrency = Currencies.getDefaultCurrency() 18 | 19 | var body: some View { 20 | NavigationView { 21 | List { 22 | 23 | Section(header: Text("MAIN")) { 24 | 25 | Picker(selection: $selectedCurrency, label: Text("Currency")) { 26 | ForEach(Currencies.allCurrenciesList, id: \.self) { currency in 27 | HStack { 28 | Text(currency.name) 29 | .font(.body) 30 | Text("(\(currency.symbol))") 31 | .font(.footnote) 32 | .foregroundColor(.accentColor) 33 | }.tag(currency) 34 | } 35 | } 36 | } 37 | 38 | Section(header: Text("OTHER")) { 39 | NavigationLink(destination: ThirdPartyView()) { 40 | Text("Third party") 41 | } 42 | 43 | HStack { 44 | Text("Source code") 45 | Spacer() 46 | Link("GitHub", 47 | destination: URL(string: "https://github.com/mczachurski/vcoin")!) 48 | } 49 | 50 | HStack { 51 | Text("Report a bug") 52 | Spacer() 53 | Link("Issues on Github", 54 | destination: URL(string: "https://github.com/mczachurski/vcoin/issues")!) 55 | } 56 | 57 | HStack { 58 | Text("Follow me on Twitter") 59 | Spacer() 60 | Link("@mczachurski", 61 | destination: URL(string: "https://twitter.com/@mczachurski")!) 62 | } 63 | 64 | } 65 | 66 | Section { 67 | HStack { 68 | Text("Version") 69 | Spacer() 70 | Text("2.0.0") 71 | } 72 | } 73 | } 74 | .listStyle(GroupedListStyle()) 75 | .navigationBarTitle(Text("Settings"), displayMode: .inline) 76 | .navigationBarItems(trailing: Button(action: { 77 | self.saveSettings() 78 | presentationMode.wrappedValue.dismiss() 79 | }) { 80 | Text("Done").bold() 81 | }) 82 | }.onAppear { 83 | self.loadDefaultCurrency() 84 | } 85 | } 86 | 87 | private func loadDefaultCurrency() { 88 | let settingsHandler = SettingsHandler() 89 | let defaultSettings = settingsHandler.getDefaultSettings() 90 | 91 | if defaultSettings.currency != "" { 92 | self.selectedCurrency = Currency(symbol: defaultSettings.currency) 93 | } 94 | } 95 | 96 | private func saveSettings() { 97 | let settingsHandler = SettingsHandler() 98 | let defaultSettings = settingsHandler.getDefaultSettings() 99 | defaultSettings.currency = self.selectedCurrency.symbol 100 | 101 | CoreDataHandler.shared.save() 102 | coinsService.loadCoins(into: applicationStateService) { _ in } 103 | } 104 | } 105 | 106 | struct SettingsView_Previews: PreviewProvider { 107 | static var previews: some View { 108 | Group { 109 | SettingsView() 110 | .preferredColorScheme(.dark) 111 | 112 | SettingsView() 113 | .preferredColorScheme(.light) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /CoreData/CoreDataHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import CoreData 8 | import Foundation 9 | import UIKit 10 | 11 | public class CoreDataHandler { 12 | public static let shared = CoreDataHandler() 13 | 14 | public let container: NSPersistentContainer 15 | 16 | init(inMemory: Bool = false) { 17 | container = NSPersistentContainer(name: "vcoin") 18 | 19 | if inMemory { 20 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 21 | } else { 22 | guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.dev.mczachurski.vcoin") else { 23 | fatalError("Container URL for application cannot be retrieved") 24 | } 25 | 26 | let dbUrl = url.appendingPathComponent("Data.sqlite") 27 | container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: dbUrl)] 28 | } 29 | 30 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 31 | if let error = error as NSError? { 32 | // Replace this implementation with code to handle the error appropriately. 33 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 34 | 35 | /* 36 | Typical reasons for an error here include: 37 | * The parent directory does not exist, cannot be created, or disallows writing. 38 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 39 | * The device is out of space. 40 | * The store could not be migrated to the current model version. 41 | Check the error message to determine what the actual problem was. 42 | */ 43 | fatalError("Unresolved error \(error), \(error.userInfo)") 44 | } 45 | }) 46 | } 47 | 48 | public func save() { 49 | let context = self.container.viewContext 50 | if context.hasChanges { 51 | do { 52 | try context.save() 53 | } catch { 54 | // Replace this implementation with code to handle the error appropriately. 55 | // fatalError() causes the application to generate a crash log and terminate. 56 | // You should not use this function in a shipping application, although it may be useful during development. 57 | 58 | let nserror = error as NSError 59 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 60 | } 61 | } 62 | } 63 | } 64 | 65 | extension CoreDataHandler { 66 | public static var preview: CoreDataHandler = { 67 | let result = CoreDataHandler(inMemory: true) 68 | let viewContext = result.container.viewContext 69 | 70 | let favouriteItem1 = Favourite(context: viewContext) 71 | favouriteItem1.coinId = "ethereum" 72 | 73 | let favouriteItem2 = Favourite(context: viewContext) 74 | favouriteItem2.coinId = "bitcoin" 75 | 76 | let favouriteItem3 = Favourite(context: viewContext) 77 | favouriteItem3.coinId = "dogecoin" 78 | 79 | let exchangeItem1 = ExchangeItem(context: viewContext) 80 | exchangeItem1.coinId = "bitcoin" 81 | exchangeItem1.amount = 2 82 | exchangeItem1.currency = "USD" 83 | 84 | let alertItem1 = Alert(context: viewContext) 85 | alertItem1.coinId = "bitcoin" 86 | alertItem1.currency = "USD" 87 | alertItem1.isEnabled = true 88 | alertItem1.isPriceLower = true 89 | alertItem1.price = 63.33 90 | 91 | do { 92 | try viewContext.save() 93 | } catch { 94 | // Replace this implementation with code to handle the error appropriately. 95 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 96 | let nsError = error as NSError 97 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 98 | } 99 | 100 | return result 101 | }() 102 | } 103 | -------------------------------------------------------------------------------- /VirtualCoin.xcodeproj/xcshareddata/xcschemes/VirtualCoin.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 53 | 59 | 60 | 61 | 62 | 63 | 73 | 75 | 81 | 82 | 83 | 84 | 90 | 92 | 98 | 99 | 100 | 101 | 103 | 104 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /VirtualCoin/Views/TabsViews/ExchangesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct ExchangesView: View { 11 | @EnvironmentObject private var applicationStateService: ApplicationStateService 12 | 13 | @State private var showingSettingsView = false 14 | @State private var showingExchangeDetailsView = false 15 | 16 | @FetchRequest( 17 | sortDescriptors: [NSSortDescriptor(keyPath: \ExchangeItem.coinId, ascending: true)], 18 | animation: .default 19 | ) 20 | private var exchanges: FetchedResults 21 | 22 | var body: some View { 23 | self.mainBody() 24 | .navigationTitle("Exchanges") 25 | .toolbar { 26 | ToolbarItem(placement: .navigationBarLeading) { 27 | Button(action: { 28 | showingSettingsView.toggle() 29 | }) { 30 | Image(systemName: "switch.2") 31 | } 32 | } 33 | ToolbarItem(placement: .navigationBarTrailing) { 34 | Button(action: { 35 | applicationStateService.selectedExchangeViewModel = nil 36 | self.showingExchangeDetailsView = true 37 | }) { 38 | Image(systemName: "plus") 39 | } 40 | } 41 | } 42 | .sheet(isPresented: $showingSettingsView) { 43 | SettingsView() 44 | } 45 | .sheet(isPresented: $showingExchangeDetailsView) { 46 | if let selectedExchangeViewModel = applicationStateService.selectedExchangeViewModel { 47 | EditExchangeView(exchangeViewModel: selectedExchangeViewModel) 48 | } else { 49 | AddExchangeView() 50 | } 51 | } 52 | } 53 | 54 | @ViewBuilder 55 | private func mainBody() -> some View { 56 | if exchanges.count > 0 { 57 | List { 58 | ForEach(exchanges, id: \.self) { exchange in 59 | let coinViewModelFromApi = applicationStateService.coins.first(where: { coinViewModel in 60 | coinViewModel.id == exchange.coinId 61 | }) 62 | 63 | let coinViewModel = coinViewModelFromApi ?? CoinViewModel() 64 | 65 | let currency = Currencies.allCurrenciesDictionary[exchange.currency] ?? Currency() 66 | 67 | let exchangeViewModel = ExchangeViewModel(coinViewModel: coinViewModel, 68 | exchangeItem: exchange, 69 | currency: currency) 70 | 71 | ExchangeRowView(exchangeViewModel: exchangeViewModel) { 72 | applicationStateService.selectedExchangeViewModel = exchangeViewModel 73 | self.showingExchangeDetailsView = true 74 | } 75 | }.onDelete(perform: self.deleteItem) 76 | } 77 | .listStyle(PlainListStyle()) 78 | } else { 79 | NoDataView(title: "No Exchanges", subtitle: "Add exchange") { 80 | applicationStateService.selectedExchangeViewModel = nil 81 | self.showingExchangeDetailsView = true 82 | } 83 | } 84 | } 85 | 86 | private func deleteItem(at indexSet: IndexSet) { 87 | let exchangeItemsHandler = ExchangeItemsHandler() 88 | 89 | for index in indexSet { 90 | let exchangeItem = self.exchanges[index] 91 | exchangeItemsHandler.deleteExchangeItemEntity(exchangeItem: exchangeItem) 92 | } 93 | 94 | CoreDataHandler.shared.save() 95 | } 96 | } 97 | 98 | struct ExchangesView_Previews: PreviewProvider { 99 | static var previews: some View { 100 | Group { 101 | NavigationView { 102 | ExchangesView() 103 | .environmentObject(ApplicationStateService.preview) 104 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 105 | } 106 | .preferredColorScheme(.dark) 107 | 108 | NavigationView { 109 | ExchangesView() 110 | .environmentObject(ApplicationStateService.preview) 111 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 112 | } 113 | .preferredColorScheme(.light) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /VirtualCoin/Previews/PreviewData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | public struct PreviewData { 11 | 12 | static func getCoinsViewModel() -> [CoinViewModel] { 13 | return [ 14 | CoinViewModel(id: "bitcoin", rank: 1, symbol: "BTC", name: "Bitcoin", priceUsd: 36135.11, changePercent24Hr: -4.72), 15 | CoinViewModel(id: "ethereum", rank: 2, symbol: "ETH", name: "Ethereum", priceUsd: 2662.72, changePercent24Hr: -4.43), 16 | CoinViewModel(id: "dogecoin", rank: 3, symbol: "DOGE", name: "Dogecoin", priceUsd: 0.37526429, changePercent24Hr: -5.18), 17 | CoinViewModel(id: "cardano", rank: 4, symbol: "USDT", name: "Cardano", priceUsd: 1.69, changePercent24Hr: -5.18), 18 | CoinViewModel(id: "tether", rank: 5, symbol: "USDT", name: "Tether", priceUsd: 1.00, changePercent24Hr: 0.03), 19 | CoinViewModel(id: "xrp", rank: 6, symbol: "XRP", name: "XRP", priceUsd: 0.94153174, changePercent24Hr: -5.35), 20 | CoinViewModel(id: "uniswap", rank: 7, symbol: "UNI", name: "Uniswap", priceUsd: 25.98, changePercent24Hr: -5.28), 21 | CoinViewModel(id: "litecoin", rank: 8, symbol: "LTC", name: "Litecoin", priceUsd: 175.35, changePercent24Hr: -3.78), 22 | CoinViewModel(id: "chainlink", rank: 9, symbol: "LINK", name: "Chainlink", priceUsd: 27.65, changePercent24Hr: -7.53), 23 | CoinViewModel(id: "binance-usd", rank: 10, symbol: "BUSD", name: "Binance USD", priceUsd: 1.00, changePercent24Hr: 0.04), 24 | CoinViewModel(id: "stellar", rank: 11, symbol: "XLM", name: "Stellar", priceUsd: 0.38132001, changePercent24Hr: -4.78), 25 | CoinViewModel(id: "vechain", rank: 12, symbol: "VET", name: "VeChain", priceUsd: 0.12897915, changePercent24Hr: -5.77), 26 | CoinViewModel(id: "matic-network", rank: 13, symbol: "MATIC", name: "Matic Network", priceUsd: 1.57, changePercent24Hr: -8.23), 27 | CoinViewModel(id: "ethereum-classic", rank: 14, symbol: "MATIC", name: "Ethereum Classic", priceUsd: 63.85, changePercent24Hr: -4.17) 28 | ] 29 | } 30 | 31 | static func getCoinViewModel() -> CoinViewModel { 32 | CoinViewModel(id: "bitcoin", 33 | rank: 1, 34 | symbol: "BTC", 35 | name: "Bitcoin", 36 | priceUsd: 6929.821775, 37 | changePercent24Hr: -0.81014) 38 | } 39 | 40 | static func getMarketsViewModel() -> [MarketViewModel] { 41 | return [ 42 | MarketViewModel(id: "Kraken", 43 | baseSymbol: "BTC", 44 | quoteSymbol: "EUR", 45 | priceUsd: 67211.23, 46 | price: 12321.33), 47 | MarketViewModel(id: "BitShop", 48 | baseSymbol: "BTC", 49 | quoteSymbol: "EUR", 50 | priceUsd: 65211.23, 51 | price: 12341.22), 52 | ] 53 | } 54 | 55 | static func getMarketViewModel() -> MarketViewModel { 56 | MarketViewModel(id: "Kraken", 57 | baseSymbol: "BTC", 58 | quoteSymbol: "EUR", 59 | priceUsd: 67211.23, 60 | price: 12321.33) 61 | } 62 | 63 | static func getAlert() -> Alert { 64 | let alert = Alert(context: CoreDataHandler.preview.container.viewContext) 65 | alert.coinId = "bitcoin" 66 | alert.currency = "USD" 67 | alert.isEnabled = true 68 | alert.isPriceLower = true 69 | alert.price = 3212.21 70 | 71 | return alert 72 | } 73 | 74 | static func getExchangeItem() -> ExchangeItem { 75 | let exchangeItem = ExchangeItem(context: CoreDataHandler.preview.container.viewContext) 76 | exchangeItem.coinId = "bitcoin" 77 | exchangeItem.amount = 2 78 | exchangeItem.currency = "USD" 79 | 80 | return exchangeItem 81 | } 82 | 83 | static func getCurrency() -> Currency { 84 | Currency(id: "united-states-dollar", symbol: "USD", locale: "en-US", name: "US Dollar") 85 | } 86 | 87 | static func getExchangeViewModel() -> ExchangeViewModel { 88 | ExchangeViewModel(coinViewModel: getCoinViewModel(), 89 | exchangeItem: getExchangeItem(), 90 | currency: getCurrency()) 91 | } 92 | 93 | static func getAlertViewModel() -> AlertViewModel { 94 | AlertViewModel(coinViewModel: getCoinViewModel(), 95 | alert: getAlert(), 96 | currency: getCurrency()) 97 | } 98 | 99 | static func getChartData() -> [Double] { 100 | return [34, 34, 23, 34, 45, 65, 34, 32, 23, 33, 65, 57, 65, 34, 65, 67] 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /VirtualCoin/Views/TabsViews/AlertsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import UserNotifications 9 | import VirtualCoinKit 10 | 11 | struct AlertsView: View { 12 | @EnvironmentObject private var applicationStateService: ApplicationStateService 13 | 14 | @State private var showingSettingsView = false 15 | @State private var showingAlertView = false 16 | 17 | @FetchRequest( 18 | sortDescriptors: [NSSortDescriptor(keyPath: \Alert.coinId, ascending: true)], 19 | animation: .default 20 | ) 21 | private var alerts: FetchedResults 22 | 23 | var body: some View { 24 | self.mainBody() 25 | .navigationTitle("Alerts") 26 | .toolbar { 27 | ToolbarItem(placement: .navigationBarLeading) { 28 | Button(action: { 29 | showingSettingsView.toggle() 30 | }) { 31 | Image(systemName: "switch.2") 32 | } 33 | } 34 | ToolbarItem(placement: .navigationBarTrailing) { 35 | Button(action: { 36 | applicationStateService.selectedAlertViewModel = nil 37 | self.showingAlertView = true 38 | }) { 39 | Image(systemName: "plus") 40 | } 41 | } 42 | } 43 | .sheet(isPresented: $showingSettingsView) { 44 | SettingsView() 45 | } 46 | .sheet(isPresented: $showingAlertView) { 47 | if let selectedAlertViewModel = applicationStateService.selectedAlertViewModel { 48 | EditAlertView(alertViewModel: selectedAlertViewModel).onDisappear { 49 | self.grantNotificationPermission() 50 | } 51 | } else { 52 | AddAlertView() 53 | } 54 | } 55 | } 56 | 57 | @ViewBuilder 58 | private func mainBody() -> some View { 59 | if alerts.count > 0 { 60 | List { 61 | ForEach(alerts, id: \.self) { alert in 62 | let coinViewModelFromApi = applicationStateService.coins.first(where: { coinViewModel in 63 | coinViewModel.id == alert.coinId 64 | }) 65 | 66 | let coinViewModel = coinViewModelFromApi ?? CoinViewModel(id: "", rank: 1, symbol: "", name: "", priceUsd: 0, changePercent24Hr: 0) 67 | 68 | let currency = Currencies.allCurrenciesDictionary[alert.currency] ?? Currency() 69 | 70 | let alertViewModel = AlertViewModel(coinViewModel: coinViewModel, 71 | alert: alert, 72 | currency: currency) 73 | 74 | AlertRowView(alertViewModel: alertViewModel) { 75 | applicationStateService.selectedAlertViewModel = alertViewModel 76 | self.showingAlertView = true 77 | } 78 | }.onDelete(perform: self.deleteItem) 79 | } 80 | .listStyle(PlainListStyle()) 81 | } else { 82 | NoDataView(title: "No Alerts", subtitle: "Add alert") { 83 | applicationStateService.selectedAlertViewModel = nil 84 | self.showingAlertView = true 85 | } 86 | } 87 | } 88 | 89 | private func deleteItem(at indexSet: IndexSet) { 90 | let alertsHandler = AlertsHandler() 91 | 92 | for index in indexSet { 93 | let alert = self.alerts[index] 94 | alertsHandler.deleteAlertEntity(alert: alert) 95 | } 96 | 97 | CoreDataHandler.shared.save() 98 | } 99 | 100 | private func grantNotificationPermission() { 101 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in 102 | if success { 103 | print("Authorization was granted: \(success)") 104 | } else if let error = error { 105 | print(error.localizedDescription) 106 | } 107 | } 108 | } 109 | } 110 | 111 | struct AlertsView_Previews: PreviewProvider { 112 | static var previews: some View { 113 | Group { 114 | NavigationView { 115 | AlertsView() 116 | .environmentObject(ApplicationStateService.preview) 117 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 118 | } 119 | .preferredColorScheme(.dark) 120 | 121 | NavigationView { 122 | AlertsView() 123 | .environmentObject(ApplicationStateService.preview) 124 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 125 | } 126 | .preferredColorScheme(.light) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /VirtualCoin/Views/TabsViews/FavouritesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | 9 | struct FavouritesView: View { 10 | @EnvironmentObject private var applicationStateService: ApplicationStateService 11 | @EnvironmentObject private var coinsService: CoinsService 12 | 13 | @State private var showingSettingsView = false 14 | @State private var state: ViewState = .iddle 15 | 16 | var body: some View { 17 | self.mainBody() 18 | .navigationTitle("Favourites") 19 | .toolbar { 20 | ToolbarItem(placement: .navigationBarLeading) { 21 | Button(action: { 22 | showingSettingsView.toggle() 23 | }) { 24 | Image(systemName: "switch.2") 25 | } 26 | } 27 | } 28 | .sheet(isPresented: $showingSettingsView) { 29 | SettingsView() 30 | } 31 | } 32 | 33 | @ViewBuilder 34 | private func mainBody() -> some View { 35 | switch state { 36 | case .iddle: 37 | self.skeletonView() 38 | .onAppear { 39 | self.load() 40 | } 41 | 42 | case .loading: 43 | self.skeletonView() 44 | case .loaded: 45 | if applicationStateService.favourites.count > 0 { 46 | List { 47 | ForEach(applicationStateService.favourites) { coin in 48 | NavigationLink(destination: CoinView(coin: coin)) { 49 | CoinRowView(coin: coin) 50 | } 51 | }.onMove(perform: self.move) 52 | } 53 | .toolbar { 54 | EditButton() 55 | } 56 | .listStyle(PlainListStyle()) 57 | } else { 58 | NoDataView(title: "No Favourites", subtitle: "Select your favourite coins") 59 | } 60 | case .error(let error): 61 | ErrorView(error: error) { 62 | self.load() 63 | } 64 | .padding() 65 | } 66 | } 67 | 68 | private func move(from source: IndexSet, to destination: Int) { 69 | // Get favourites form core data. 70 | let favouritesHandler = FavouritesHandler() 71 | var favourites: [Favourite] = favouritesHandler.getFavourites() 72 | 73 | // Change order in core data array. 74 | favourites.move(fromOffsets: source, toOffset: destination) 75 | 76 | // Update the userOrder attribute in revisedItems to persist the new order. 77 | // This is done in reverse order to minimize changes to the indices. 78 | for reverseIndex in stride(from: favourites.count - 1, through: 0, by: -1 ) { 79 | favourites[reverseIndex].order = Int32(reverseIndex) 80 | } 81 | 82 | // Save new order. 83 | CoreDataHandler.shared.save() 84 | 85 | // Change also order in array stored in application state. 86 | self.applicationStateService.favourites.move(fromOffsets: source, toOffset: destination) 87 | } 88 | 89 | private func skeletonView() -> some View { 90 | List(PreviewData.getCoinsViewModel(), id: \.id) { coin in 91 | NavigationLink(destination: CoinView(coin: coin)) { 92 | CoinRowView(coin: coin) 93 | } 94 | } 95 | .listStyle(PlainListStyle()) 96 | .redacted(reason: .placeholder) 97 | } 98 | 99 | private func load() { 100 | if applicationStateService.coins.isEmpty == false { 101 | self.state = .loaded 102 | return 103 | } 104 | 105 | state = .loading 106 | 107 | coinsService.loadCoins(into: applicationStateService) { result in 108 | DispatchQueue.runOnMain { 109 | switch result { 110 | case .success: 111 | self.state = .loaded 112 | break; 113 | case .failure(let error): 114 | self.state = .error(error) 115 | break; 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | struct FavouritesView_Previews: PreviewProvider { 123 | static var previews: some View { 124 | Group { 125 | NavigationView { 126 | FavouritesView() 127 | .environmentObject(ApplicationStateService.preview) 128 | .environmentObject(CoinsService.preview) 129 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 130 | } 131 | .preferredColorScheme(.dark) 132 | 133 | NavigationView { 134 | FavouritesView() 135 | .environmentObject(ApplicationStateService.preview) 136 | .environmentObject(CoinsService.preview) 137 | .environment(\.managedObjectContext, CoreDataHandler.preview.container.viewContext) 138 | } 139 | .preferredColorScheme(.light) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /VirtualCoin/Notifications/Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import UserNotifications 9 | import VirtualCoinKit 10 | 11 | class Notifications { 12 | private let alertsHandler = AlertsHandler() 13 | private let coinCapClient = CoinCapClient() 14 | private var priceAlerts: [String: PriceAlert] = [:] 15 | 16 | func sendNotification(completionHandler: @escaping (Result) -> Void) { 17 | let alerts = self.alertsHandler.getActiveAlerts() 18 | 19 | for alert in alerts { 20 | let alertKey = self.getKey(alert: alert) 21 | if priceAlerts[alertKey] == nil { 22 | priceAlerts[alertKey] = PriceAlert(currency: alert.currency, 23 | coinId: alert.coinId) 24 | } 25 | } 26 | 27 | let notificationsGroup = DispatchGroup() 28 | 29 | for priceAlert in priceAlerts { 30 | notificationsGroup.enter() 31 | priceAlert.value.processing = Processing.processing 32 | 33 | guard let currency = Currencies.allCurrenciesDictionary[priceAlert.value.currency] else { 34 | completionHandler(Result.failure(NotificationsError.notRecognizedCurrencySymbol)) 35 | return 36 | } 37 | 38 | self.coinCapClient.getCoinPriceAsync(for: priceAlert.value.coinId, currencyId: currency.id) { result in 39 | switch result { 40 | case .success(let price): 41 | priceAlert.value.price = price 42 | priceAlert.value.processing = Processing.finished 43 | case .failure(let error): 44 | print(error.localizedDescription) 45 | } 46 | 47 | notificationsGroup.leave() 48 | } 49 | } 50 | 51 | notificationsGroup.notify(queue: .main) { 52 | self.processAlerts(alerts: alerts) 53 | completionHandler(Result.success(())) 54 | } 55 | } 56 | 57 | private func processAlerts(alerts: [Alert]) { 58 | for alert in alerts { 59 | let alertKey = self.getKey(alert: alert) 60 | if let priceAlert = priceAlerts[alertKey] { 61 | self.processAlert(alert: alert, price: priceAlert.price) 62 | } 63 | } 64 | } 65 | 66 | private func processAlert(alert: Alert, price: Double?) { 67 | guard let price = price else { 68 | return 69 | } 70 | 71 | if alert.isPriceLower && price < alert.price { 72 | let center = UNUserNotificationCenter.current() 73 | center.getNotificationSettings { settings in 74 | self.notifyAboutLowerPrices(settings: settings, center: center, alert: alert, price: price) 75 | } 76 | } else if !alert.isPriceLower && price > alert.price { 77 | let center = UNUserNotificationCenter.current() 78 | center.getNotificationSettings { settings in 79 | self.notifyAboutHigherPrices(settings: settings, center: center, alert: alert, price: price) 80 | } 81 | } 82 | } 83 | 84 | private func notifyAboutLowerPrices(settings: UNNotificationSettings, center: UNUserNotificationCenter, alert: Alert, price: Double) { 85 | if settings.authorizationStatus == .authorized { 86 | let body = "Currency price is \(price.toFormattedPrice(currency: alert.currency)) lower than: \(alert.price.toFormattedPrice(currency: alert.currency))" 87 | 88 | self.sendNotification(center: center, 89 | title: alert.coinSymbol, 90 | body: body) 91 | 92 | alert.alertSentDate = Date() 93 | CoreDataHandler.shared.save() 94 | } 95 | } 96 | 97 | private func notifyAboutHigherPrices(settings: UNNotificationSettings, center: UNUserNotificationCenter, alert: Alert, price: Double) { 98 | if settings.authorizationStatus == .authorized { 99 | let body = "Currency price is \(price.toFormattedPrice(currency: alert.currency)) higher than: \(alert.price.toFormattedPrice(currency: alert.currency))" 100 | 101 | self.sendNotification(center: center, 102 | title: alert.coinSymbol, 103 | body: body) 104 | 105 | alert.alertSentDate = Date() 106 | CoreDataHandler.shared.save() 107 | } 108 | } 109 | 110 | private func sendNotification(center: UNUserNotificationCenter, title: String, body: String) { 111 | let content = UNMutableNotificationContent() 112 | content.title = title 113 | content.body = body 114 | content.sound = UNNotificationSound.default 115 | 116 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) 117 | 118 | let identifier = "VCoinLocalNotification" 119 | let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) 120 | 121 | center.add(request, withCompletionHandler: { error in 122 | if let error = error { 123 | print("Error during sending notification \(error)") 124 | } 125 | }) 126 | } 127 | 128 | private func getKey(alert: Alert) -> String { 129 | return self.getKey(currency: alert.currency, coinId: alert.coinId) 130 | } 131 | 132 | private func getKey(currency: String, coinId: String) -> String { 133 | return "\(currency)|\(coinId)" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /VirtualCoinWidget/DataFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import Foundation 8 | import VirtualCoinKit 9 | 10 | class DataFetcher { 11 | func getCoins(completionHandler: @escaping (Result<[WidgetViewModel], RestClientError>) -> Void) { 12 | let settingsHandler = SettingsHandler() 13 | let defaultSettings = settingsHandler.getDefaultSettings() 14 | 15 | let coinCapClient = CoinCapClient() 16 | guard let currency = Currencies.allCurrenciesDictionary[defaultSettings.currency] else { 17 | completionHandler(.success([])) 18 | return 19 | } 20 | 21 | coinCapClient.getCurrencyRate(for: currency.id) { currencyResult in 22 | switch currencyResult { 23 | case .success(let currencyRate): 24 | let currencyRateUsd = Double(currencyRate.rateUsd) ?? 1.0 25 | self.downloadCoins(currencyRateUsd: currencyRateUsd, completionHandler: completionHandler) 26 | break 27 | case .failure(let error): 28 | completionHandler(.failure(error)) 29 | break 30 | } 31 | } 32 | 33 | } 34 | 35 | private func downloadCoins(currencyRateUsd: Double, completionHandler: @escaping (Result<[WidgetViewModel], RestClientError>) -> Void) { 36 | var results: [WidgetViewModel] = [] 37 | let coinOrders = self.getCoinOrders() 38 | let coinCapClient = CoinCapClient() 39 | let downloadCoinsGroup = DispatchGroup() 40 | 41 | for coinOrder in coinOrders { 42 | downloadCoinsGroup.enter() 43 | 44 | coinCapClient.getCoinAsync(for: coinOrder.coinId) { coinResult in 45 | switch coinResult { 46 | case .success(let coin): 47 | 48 | let priceUsd = Double(coin.priceUsd ?? "") ?? 0.0 49 | let changePercent24Hr = Double(coin.changePercent24Hr ?? "") ?? 0.0 50 | 51 | results.append(WidgetViewModel(id: coinOrder.coinId, 52 | order: coinOrder.order, 53 | rank: Int(coin.rank) ?? 0, 54 | symbol: coin.symbol, 55 | name: coin.name, 56 | priceUsd: priceUsd, 57 | changePercent24Hr: changePercent24Hr, 58 | price: priceUsd / currencyRateUsd, 59 | chart: [])) 60 | 61 | break 62 | case .failure(let error): 63 | completionHandler(.failure(error)) 64 | break 65 | } 66 | 67 | downloadCoinsGroup.leave() 68 | } 69 | } 70 | 71 | downloadCoinsGroup.notify(queue: .main) { 72 | self.downloadCharts(results: results, completionHandler: completionHandler) 73 | } 74 | } 75 | 76 | private func downloadCharts(results: [WidgetViewModel], completionHandler: @escaping (Result<[WidgetViewModel], RestClientError>) -> Void) { 77 | var resultsMutating = results 78 | let downloadChartsGroup = DispatchGroup() 79 | let coinCapClient = CoinCapClient() 80 | 81 | for resultIndex in resultsMutating.indices { 82 | 83 | downloadChartsGroup.enter() 84 | coinCapClient.getChartValuesAsync(for: resultsMutating[resultIndex].id, withRange: .hour) { chartResult in 85 | switch chartResult { 86 | case .success(let chartValues): 87 | 88 | var dataResult: [Double] = [] 89 | for chartValue in chartValues { 90 | if let value = Double(chartValue.priceUsd) { 91 | dataResult.append(value) 92 | } 93 | } 94 | 95 | resultsMutating[resultIndex].chart = dataResult 96 | 97 | break 98 | case .failure(let error): 99 | completionHandler(.failure(error)) 100 | break 101 | } 102 | 103 | downloadChartsGroup.leave() 104 | } 105 | } 106 | 107 | downloadChartsGroup.notify(queue: .main) { 108 | let resultOrdered = resultsMutating.sorted(by: { lhs, rhs in 109 | lhs.order < rhs.order 110 | }) 111 | 112 | completionHandler(.success(resultOrdered)) 113 | } 114 | } 115 | 116 | private func getCoinOrders() -> [CoinOrder] { 117 | let favouritesHandler = FavouritesHandler() 118 | let favourites = favouritesHandler.getFavourites() 119 | 120 | // Get coins from favourites. 121 | var coinOrders = favourites.map { favourite in 122 | CoinOrder(coinId: favourite.coinId, order: favourite.order) 123 | } 124 | 125 | var maxOrder = coinOrders.map { coinOrder in coinOrder.order }.max() ?? 0 126 | 127 | // Add other higher rank coins for nice widget. 128 | for widgetViewModel in PreviewData.getWidgetViewModels() { 129 | if coinOrders.contains(where: { coinOrder in coinOrder.coinId == widgetViewModel.id }) { 130 | continue 131 | } 132 | 133 | coinOrders.append(CoinOrder(coinId: widgetViewModel.id, order: maxOrder)) 134 | maxOrder = maxOrder + 1 135 | } 136 | 137 | return coinOrders 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /VirtualCoin/Views/ScreenViews/CoinView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // https://mczachurski.dev 3 | // Copyright © 2021 Marcin Czachurski and the repository contributors. 4 | // Licensed under the MIT License. 5 | // 6 | 7 | import SwiftUI 8 | import VirtualCoinKit 9 | 10 | struct CoinView: View { 11 | @EnvironmentObject var applicationStateService: ApplicationStateService 12 | @EnvironmentObject var coinsService: CoinsService 13 | 14 | @ObservedObject public var coin: CoinViewModel 15 | 16 | @State private var state: ViewState = .iddle 17 | @State private var selectedTab: ChartTimeRange = .hour 18 | @State private var isShowingMarketsView = false 19 | 20 | @Setting(\.currency) private var currencySymbol: String 21 | 22 | var body: some View { 23 | VStack { 24 | HStack { 25 | CoinImageView(coin: coin) 26 | Text(coin.name) 27 | .font(.largeTitle) 28 | .fontWeight(.thin) 29 | }.padding(.trailing, 32) 30 | 31 | Text(coin.symbol) 32 | .font(.title2) 33 | .fontWeight(.light) 34 | .foregroundColor(.gray) 35 | 36 | Text(coin.price.toFormattedPrice(currency: currencySymbol)) 37 | .fontWeight(.light) 38 | .font(.title) 39 | 40 | Text(coin.changePercent24Hr.toFormattedPercent()) 41 | .font(.body) 42 | .foregroundColor(coin.changePercent24Hr > 0 ? .greenPastel : .redPastel) 43 | 44 | VStack { 45 | Picker("", selection: $selectedTab) { 46 | Text(ChartTimeRange.hour.rawValue).tag(ChartTimeRange.hour) 47 | Text(ChartTimeRange.day.rawValue).tag(ChartTimeRange.day) 48 | Text(ChartTimeRange.week.rawValue).tag(ChartTimeRange.week) 49 | Text(ChartTimeRange.month.rawValue).tag(ChartTimeRange.month) 50 | Text(ChartTimeRange.year.rawValue).tag(ChartTimeRange.year) 51 | } 52 | .pickerStyle(SegmentedPickerStyle()) 53 | .padding() 54 | 55 | switch(selectedTab) { 56 | case .hour: 57 | ChartView(chartTimeRange: .hour, coin: coin) 58 | .frame(maxWidth: .infinity, maxHeight: .infinity) 59 | case .day: 60 | ChartView(chartTimeRange: .day, coin: coin) 61 | .frame(maxWidth: .infinity, maxHeight: .infinity) 62 | case .week: 63 | ChartView(chartTimeRange: .week, coin: coin) 64 | .frame(maxWidth: .infinity, maxHeight: .infinity) 65 | case .month: 66 | ChartView(chartTimeRange: .month, coin: coin) 67 | .frame(maxWidth: .infinity, maxHeight: .infinity) 68 | case .year: 69 | ChartView(chartTimeRange: .year, coin: coin) 70 | .frame(maxWidth: .infinity, maxHeight: .infinity) 71 | } 72 | } 73 | } 74 | .toolbar { 75 | ToolbarItemGroup(placement: .navigationBarTrailing) { 76 | Button(action: { 77 | isShowingMarketsView.toggle() 78 | }) { 79 | Image(systemName: "globe") 80 | } 81 | .disabled(self.state != .loaded) 82 | 83 | Button(action: { 84 | self.toggleFavourite(); 85 | }) { 86 | Image(systemName: coin.isFavourite ? "star.fill" : "star") 87 | } 88 | } 89 | } 90 | .sheet(isPresented: $isShowingMarketsView) { 91 | MarketsView(markets: self.applicationStateService.markets) 92 | } 93 | .onAppear { 94 | self.load() 95 | } 96 | } 97 | 98 | private func load() { 99 | state = .loading 100 | 101 | coinsService.loadMarketValues(into: applicationStateService, 102 | coin: coin) { result in 103 | DispatchQueue.runOnMain { 104 | switch result { 105 | case .success: 106 | self.state = .loaded 107 | break; 108 | case .failure(let error): 109 | self.state = .error(error) 110 | break; 111 | } 112 | } 113 | } 114 | } 115 | 116 | private func toggleFavourite() { 117 | let favouritesHandler = FavouritesHandler() 118 | 119 | if favouritesHandler.isFavourite(coinId: coin.id) { 120 | self.coin.isFavourite = false 121 | self.applicationStateService.removeFromFavourites(coinViewModel: coin) 122 | 123 | favouritesHandler.deleteFavouriteEntity(coinId: coin.id) 124 | } else { 125 | self.coin.isFavourite = true 126 | self.applicationStateService.addToFavourites(coinViewModel: coin) 127 | 128 | let favouriteEntity = favouritesHandler.createFavouriteEntity() 129 | favouriteEntity.coinId = coin.id 130 | } 131 | 132 | CoreDataHandler.shared.save() 133 | } 134 | } 135 | 136 | struct CoinView_Previews: PreviewProvider { 137 | static var previews: some View { 138 | Group { 139 | NavigationView { 140 | CoinView(coin: PreviewData.getCoinViewModel()) 141 | .environmentObject(ApplicationStateService.preview) 142 | .environmentObject(CoinsService.preview) 143 | } 144 | .preferredColorScheme(.dark) 145 | 146 | NavigationView { 147 | CoinView(coin: PreviewData.getCoinViewModel()) 148 | .environmentObject(ApplicationStateService.preview) 149 | .environmentObject(CoinsService.preview) 150 | } 151 | .preferredColorScheme(.light) 152 | } 153 | } 154 | } 155 | --------------------------------------------------------------------------------