diff --git a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj index e221e7c092..f69530d785 100644 --- a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj +++ b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj @@ -3078,6 +3078,16 @@ D3833AEB2BEE4CAA00ACECFB /* TopPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AE92BEE4CAA00ACECFB /* TopPlatform.swift */; }; D3833AEE2BF1F0C400ACECFB /* MarketPlatformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AED2BF1F0C400ACECFB /* MarketPlatformView.swift */; }; D3833AEF2BF1F0C400ACECFB /* MarketPlatformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AED2BF1F0C400ACECFB /* MarketPlatformView.swift */; }; + D3833AF22BF20B8600ACECFB /* MarketPairsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF12BF20B8600ACECFB /* MarketPairsView.swift */; }; + D3833AF32BF20B8600ACECFB /* MarketPairsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF12BF20B8600ACECFB /* MarketPairsView.swift */; }; + D3833AF52BF20B8D00ACECFB /* MarketPairsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF42BF20B8D00ACECFB /* MarketPairsViewModel.swift */; }; + D3833AF62BF20B8D00ACECFB /* MarketPairsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF42BF20B8D00ACECFB /* MarketPairsViewModel.swift */; }; + D3833AF82BF2181800ACECFB /* MarketPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF72BF2181800ACECFB /* MarketPair.swift */; }; + D3833AF92BF2181800ACECFB /* MarketPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AF72BF2181800ACECFB /* MarketPair.swift */; }; + D3833AFC2BF335C700ACECFB /* MarketNewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AFB2BF335C700ACECFB /* MarketNewsView.swift */; }; + D3833AFD2BF335C700ACECFB /* MarketNewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AFB2BF335C700ACECFB /* MarketNewsView.swift */; }; + D3833AFF2BF335D100ACECFB /* MarketNewsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AFE2BF335D100ACECFB /* MarketNewsViewModel.swift */; }; + D3833B002BF335D100ACECFB /* MarketNewsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AFE2BF335D100ACECFB /* MarketNewsViewModel.swift */; }; D38404E4218317DF007D50AD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3285F4520BD158E00644076 /* AppDelegate.swift */; }; D38404E8218317DF007D50AD /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B62A8820CA40DC005A9F80 /* MainViewController.swift */; }; D38404F9218317DF007D50AD /* MainModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35B96D2BC5994AC8EC794 /* MainModule.swift */; }; @@ -4907,6 +4917,11 @@ D3833AE02BEE3FE800ACECFB /* MarketPlatformsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketPlatformsViewModel.swift; sourceTree = ""; }; D3833AE92BEE4CAA00ACECFB /* TopPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopPlatform.swift; sourceTree = ""; }; D3833AED2BF1F0C400ACECFB /* MarketPlatformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketPlatformView.swift; sourceTree = ""; }; + D3833AF12BF20B8600ACECFB /* MarketPairsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketPairsView.swift; sourceTree = ""; }; + D3833AF42BF20B8D00ACECFB /* MarketPairsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketPairsViewModel.swift; sourceTree = ""; }; + D3833AF72BF2181800ACECFB /* MarketPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketPair.swift; sourceTree = ""; }; + D3833AFB2BF335C700ACECFB /* MarketNewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketNewsView.swift; sourceTree = ""; }; + D3833AFE2BF335D100ACECFB /* MarketNewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketNewsViewModel.swift; sourceTree = ""; }; D38405CE218317DF007D50AD /* Unstoppable D.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Unstoppable D.app"; sourceTree = BUILT_PRODUCTS_DIR; }; D38406BE21831B3D007D50AD /* Unstoppable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Unstoppable.app; sourceTree = BUILT_PRODUCTS_DIR; }; D38406C3218327B1007D50AD /* AppIcon.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppIcon.xcassets; sourceTree = ""; }; @@ -5480,6 +5495,7 @@ 11B35ED0A8819AB7EA27D368 /* StatExtensions.swift */, D3DB51B12BD912A00091BBDB /* MarketInfo.swift */, D3833AE92BEE4CAA00ACECFB /* TopPlatform.swift */, + D3833AF72BF2181800ACECFB /* MarketPair.swift */, ); path = Extensions; sourceTree = ""; @@ -7689,6 +7705,8 @@ 58AAA9EB9618EBC895D0B123 /* Market */ = { isa = PBXGroup; children = ( + D3833AFA2BF335B800ACECFB /* News */, + D3833AF02BF20B7200ACECFB /* Pairs */, D3833AEC2BF1F0AC00ACECFB /* Platform */, D3833ADC2BEE3FC200ACECFB /* Platforms */, D3833AD52BEE1A2900ACECFB /* Watchlist */, @@ -9276,6 +9294,24 @@ path = Platform; sourceTree = ""; }; + D3833AF02BF20B7200ACECFB /* Pairs */ = { + isa = PBXGroup; + children = ( + D3833AF12BF20B8600ACECFB /* MarketPairsView.swift */, + D3833AF42BF20B8D00ACECFB /* MarketPairsViewModel.swift */, + ); + path = Pairs; + sourceTree = ""; + }; + D3833AFA2BF335B800ACECFB /* News */ = { + isa = PBXGroup; + children = ( + D3833AFB2BF335C700ACECFB /* MarketNewsView.swift */, + D3833AFE2BF335D100ACECFB /* MarketNewsViewModel.swift */, + ); + path = News; + sourceTree = ""; + }; D3948EF52ADA846400FAE566 /* Widget */ = { isa = PBXGroup; children = ( @@ -9785,6 +9821,7 @@ D02A67BD272A7460009B2C1C /* TweetCell.swift in Sources */, 11B3527D20636D21F0F45C80 /* CurrentDateProvider.swift in Sources */, 11B35DA5492B0C4EC7130A19 /* AppConfig.swift in Sources */, + D3833AF32BF20B8600ACECFB /* MarketPairsView.swift in Sources */, 11B350B22CCCFCD466BEB808 /* FeeRateProvider.swift in Sources */, 6BCD53172A161F4800993F20 /* BackupViewController.swift in Sources */, 11B35B3F384758B223A7218C /* MainSettingsFooterCell.swift in Sources */, @@ -10015,6 +10052,7 @@ 1A564D209D3AFA40F808C8FB /* PerformanceContentCollectionViewCell.swift in Sources */, 1A564E8CA0CFBE8B1E232B60 /* PerformanceTableViewCell.swift in Sources */, 1A564BAB51E12A8F37D870B5 /* PerformanceSideCollectionViewCell.swift in Sources */, + D3833B002BF335D100ACECFB /* MarketNewsViewModel.swift in Sources */, 58AAAA71882CB345D56BBA00 /* CoinChartFactory.swift in Sources */, 11B35DD9C17FDD3ED40BA321 /* CoinInvestorsModule.swift in Sources */, 11B358D913A404C1DA7D4E0E /* CoinInvestorsViewModel.swift in Sources */, @@ -10049,6 +10087,7 @@ 11B350860CB79E9C5F032166 /* ManageAccountViewModel.swift in Sources */, 11B3563E71C4AC16DFE8AB76 /* ActiveAccount.swift in Sources */, 6BCD530D2A161F4100993F20 /* ICloudBackupNameViewModel.swift in Sources */, + D3833AF92BF2181800ACECFB /* MarketPair.swift in Sources */, 11B35056B69A06C8CFF3CBB6 /* BackupModule.swift in Sources */, D349031B2BE8DF5F005F147B /* BinancePreSendHandler.swift in Sources */, D0A980AA2B5E3C0900127AF4 /* StepChangeButtonsView.swift in Sources */, @@ -10121,6 +10160,7 @@ D00267BD2A57E72700D6B2D5 /* ResendPasteInputView.swift in Sources */, 58AAA3FE5DE72D3CEFFE4399 /* MarketPostService.swift in Sources */, 1A564CFD8F22A2F5FDB346EA /* JailbreakService.swift in Sources */, + D3833AFD2BF335C700ACECFB /* MarketNewsView.swift in Sources */, D05E96A72A2627E5002CCD71 /* TronOutgoingTransactionRecord.swift in Sources */, 58AAA097F8417B30693547FE /* CoinPageMarkdownParser.swift in Sources */, 1A564BB34D0EAA2E8BE8B498 /* DeepLinkManager.swift in Sources */, @@ -10826,6 +10866,7 @@ ABC9AD27E074CF3FA292C647 /* IndicatorAdviceView.swift in Sources */, ABC9A305CBB28F2B19EB00D2 /* CoinDetailAdviceViewController.swift in Sources */, ABC9AADE6C4251FCC9222125 /* ManageBarButtonView.swift in Sources */, + D3833AF62BF20B8D00ACECFB /* MarketPairsViewModel.swift in Sources */, ABC9AB11FDD018A96BB86557 /* BottomGradientHolder.swift in Sources */, 11B35311CEEC40EA3089293D /* SubscriptionInfoViewController.swift in Sources */, 11B35A5DA8197D193B7CF8D9 /* AmountData.swift in Sources */, @@ -11333,6 +11374,7 @@ D0532CBE2B149DEE0015DF40 /* WatchViewModel.swift in Sources */, 11B3540F182F3EDE74245EC7 /* MainSettingsFooterCell.swift in Sources */, 6BCD53162A161F4800993F20 /* BackupViewController.swift in Sources */, + D3833AF22BF20B8600ACECFB /* MarketPairsView.swift in Sources */, 58AAAD33B32694AFA2E954D6 /* GradientLayer.swift in Sources */, 58AAAE430A2184D5A12202EA /* DebugModule.swift in Sources */, 58AAA54CF2169C04A4A38817 /* DebugRouter.swift in Sources */, @@ -11563,6 +11605,7 @@ 1A564D81725888B31D56F389 /* PerformanceContentCollectionViewCell.swift in Sources */, 1A5648B6D95710939690F22A /* PerformanceTableViewCell.swift in Sources */, 1A564E09FB049006167E033B /* PerformanceSideCollectionViewCell.swift in Sources */, + D3833AFF2BF335D100ACECFB /* MarketNewsViewModel.swift in Sources */, D36DE0D8272FD887000BC916 /* OneInchDataSource.swift in Sources */, 58AAA2EBAFC1C443C48BA857 /* CoinChartFactory.swift in Sources */, 11B355EE734C8CAC81BA1BF9 /* CoinInvestorsModule.swift in Sources */, @@ -11597,6 +11640,7 @@ 11B356C6E9FC6594917B3FF6 /* ActiveAccount.swift in Sources */, D02A67C8272A7460009B2C1C /* CoinTweetsViewController.swift in Sources */, D02A67C2272A7460009B2C1C /* TwitterText.swift in Sources */, + D3833AF82BF2181800ACECFB /* MarketPair.swift in Sources */, 6BE8A0812ADE2FAB0012DE7F /* CurrencyManager.swift in Sources */, D349031A2BE8DF5F005F147B /* BinancePreSendHandler.swift in Sources */, 11B359F73F1D626BF832977F /* BackupModule.swift in Sources */, @@ -11669,6 +11713,7 @@ D00267BC2A57E72700D6B2D5 /* ResendPasteInputView.swift in Sources */, 1A56464440899E3299F79D32 /* JailbreakService.swift in Sources */, 58AAAE5341084CFA30D4832C /* CoinPageMarkdownParser.swift in Sources */, + D3833AFC2BF335C700ACECFB /* MarketNewsView.swift in Sources */, D05E96A62A2627E5002CCD71 /* TronOutgoingTransactionRecord.swift in Sources */, 1A5640D6FDF86EDB54213F9B /* DeepLinkManager.swift in Sources */, D36DE0EC272FD89B000BC916 /* SwapSettingsModule.swift in Sources */, @@ -12374,6 +12419,7 @@ ABC9A74F192AB94CFD1D1649 /* IndicatorAdviceCell.swift in Sources */, ABC9AFE47A405844612EB01A /* IndicatorAdviceView.swift in Sources */, ABC9A0866C672D2D560DA23C /* CoinDetailAdviceViewController.swift in Sources */, + D3833AF52BF20B8D00ACECFB /* MarketPairsViewModel.swift in Sources */, ABC9AD3001AAA0570B503876 /* ManageBarButtonView.swift in Sources */, ABC9A54FFFFBFC3C7B23F0B8 /* BottomGradientHolder.swift in Sources */, 11B354AC828E53D98222EE71 /* SubscriptionInfoViewController.swift in Sources */, @@ -13675,7 +13721,7 @@ repositoryURL = "https://github.com/horizontalsystems/MarketKit.Swift"; requirement = { kind = exactVersion; - version = 3.0.3; + version = 3.0.4; }; }; D3604E7D28F03C1D0066C366 /* XCRemoteSwiftPackageReference "Chart" */ = { diff --git a/UnstoppableWallet/UnstoppableWallet/Extensions/Coin.swift b/UnstoppableWallet/UnstoppableWallet/Extensions/Coin.swift index e7881e63b4..0802be7c3f 100644 --- a/UnstoppableWallet/UnstoppableWallet/Extensions/Coin.swift +++ b/UnstoppableWallet/UnstoppableWallet/Extensions/Coin.swift @@ -6,4 +6,9 @@ extension Coin { let scale = Int(UIScreen.main.scale) return "https://cdn.blocksdecoded.com/coin-icons/32px/\(uid)@\(scale)x.png" } + + static func imageUrl(uid: String) -> String { + let scale = Int(UIScreen.main.scale) + return "https://cdn.blocksdecoded.com/coin-icons/32px/\(uid)@\(scale)x.png" + } } diff --git a/UnstoppableWallet/UnstoppableWallet/Extensions/MarketPair.swift b/UnstoppableWallet/UnstoppableWallet/Extensions/MarketPair.swift new file mode 100644 index 0000000000..57d95859da --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Extensions/MarketPair.swift @@ -0,0 +1,13 @@ +import MarketKit + +extension MarketPair: Hashable { + public static func == (lhs: MarketPair, rhs: MarketPair) -> Bool { + lhs.base == rhs.base && lhs.target == rhs.target && lhs.marketName == rhs.marketName + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(base) + hasher.combine(target) + hasher.combine(marketName) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift index e16475cd0b..51d5b98b95 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift @@ -15,7 +15,10 @@ struct MarketCoinsView: View { ThemeView { switch viewModel.state { case .loading: - loadingList() + VStack(spacing: 0) { + header(disabled: true) + loadingList() + } case let .loaded(marketInfos): VStack(spacing: 0) { header() @@ -34,7 +37,7 @@ struct MarketCoinsView: View { } } - @ViewBuilder private func header() -> some View { + @ViewBuilder private func header(disabled: Bool = false) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack { Button(action: { @@ -43,6 +46,7 @@ struct MarketCoinsView: View { Text(viewModel.sortBy.title) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) Button(action: { topSelectorPresented = true @@ -50,6 +54,7 @@ struct MarketCoinsView: View { Text(viewModel.top.title) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) Button(action: { timePeriodSelectorPresented = true @@ -57,6 +62,7 @@ struct MarketCoinsView: View { Text(viewModel.timePeriod.shortTitle) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) } .padding(.horizontal, .margin16) .padding(.vertical, .margin8) @@ -100,73 +106,71 @@ struct MarketCoinsView: View { } @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { - ScrollViewReader { _ in - ThemeList(items: marketInfos) { marketInfo in - ClickableRow(action: { - presentedFullCoin = marketInfo.fullCoin - }) { - let coin = marketInfo.fullCoin.coin - - KFImage.url(URL(string: coin.imageUrl)) - .resizable() - .placeholder { Circle().fill(Color.themeSteel20) } - .frame(width: .iconSize32, height: .iconSize32) - - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text(coin.code).textBody() - Spacer() - Text(marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized).textBody() - } - - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - if let rank = marketInfo.marketCapRank { - BadgeViewNew(text: "\(rank)") - } - - Text(coin.name).textSubhead2() - } - Spacer() - DiffText(marketInfo.priceChangeValue(timePeriod: viewModel.timePeriod)) - } - } - } - } - .themeListStyle(.transparent) - .refreshable { - await viewModel.refresh() + ThemeList(items: marketInfos) { marketInfo in + ClickableRow(action: { + presentedFullCoin = marketInfo.fullCoin + }) { + let coin = marketInfo.fullCoin.coin + + itemContent( + imageUrl: URL(string: coin.imageUrl), + code: coin.code, + name: coin.name, + price: marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + rank: marketInfo.marketCapRank, + diff: marketInfo.priceChangeValue(timePeriod: viewModel.timePeriod) + ) } } + .themeListStyle(.transparent) + .refreshable { + await viewModel.refresh() + } } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { _ in + ThemeList(items: Array(0 ... 10)) { index in ListRow { - Circle() - .fill(Color.themeSteel20) - .frame(width: .iconSize32, height: .iconSize32) - .shimmering() - - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text("USDT").textBody().redacted(value: nil) - Spacer() - Text("$12345").textBody().redacted(value: nil) - } + itemContent( + imageUrl: nil, + code: "CODE", + name: "Coin Name", + price: "$123.45", + rank: 12, + diff: index % 2 == 0 ? 12.34 : -12.34 + ) + .redacted() + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - Text("12").textBody().redacted(value: nil) - Text("Bitcoin").textSubhead2().redacted(value: nil) - } - Spacer() - DiffText(12.34).redacted(value: nil) + @ViewBuilder private func itemContent(imageUrl: URL?, code: String, name: String, price: String, rank: Int?, diff: Decimal?) -> some View { + KFImage.url(imageUrl) + .resizable() + .placeholder { Circle().fill(Color.themeSteel20) } + .clipShape(Circle()) + .frame(width: .iconSize32, height: .iconSize32) + + VStack(spacing: 1) { + HStack(spacing: .margin8) { + Text(code).textBody() + Spacer() + Text(price).textBody() + } + + HStack(spacing: .margin8) { + HStack(spacing: .margin4) { + if let rank { + BadgeViewNew(text: "\(rank)") } + + Text(name).textSubhead2() } + Spacer() + DiffText(diff) } } - .themeListStyle(.transparent) - .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift index da8c86d176..20b848a982 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift @@ -163,6 +163,18 @@ extension MarketModule { } } + enum SortOrder { + case asc + case desc + + mutating func toggle() { + switch self { + case .asc: self = .desc + case .desc: self = .asc + } + } + } + enum Top: Int, CaseIterable { case top100 = 100 case top200 = 200 diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift index a20f897dea..3f2c81e97c 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift @@ -4,15 +4,19 @@ import ThemeKit struct MarketTabView: View { @StateObject var coinsViewModel: MarketCoinsViewModel @StateObject var watchlistViewModel: MarketWatchlistViewModel + @StateObject var newsViewModel: MarketNewsViewModel @StateObject var platformsViewModel: MarketPlatformsViewModel + @StateObject var pairsViewModel: MarketPairsViewModel - @State private var currentTabIndex: Int = Tab.coins.rawValue + @State private var currentTabIndex: Int = Tab.pairs.rawValue @State private var loadedTabs = [Tab]() init() { _coinsViewModel = StateObject(wrappedValue: MarketCoinsViewModel()) _watchlistViewModel = StateObject(wrappedValue: MarketWatchlistViewModel()) + _newsViewModel = StateObject(wrappedValue: MarketNewsViewModel()) _platformsViewModel = StateObject(wrappedValue: MarketPlatformsViewModel()) + _pairsViewModel = StateObject(wrappedValue: MarketPairsViewModel()) } var body: some View { @@ -22,28 +26,23 @@ struct MarketTabView: View { currentTabIndex: $currentTabIndex ) - TabView(selection: $currentTabIndex) { - MarketCoinsView(viewModel: coinsViewModel) - .tag(Tab.coins.rawValue) - - MarketWatchlistView(viewModel: watchlistViewModel) - .tag(Tab.watchlist.rawValue) - - Text("News Content").tag(Tab.news.rawValue) - - MarketPlatformsView(viewModel: platformsViewModel) - .tag(Tab.platforms.rawValue) - - Text("Pairs Content").tag(Tab.pairs.rawValue) - Text("Sectors Content").tag(Tab.sectors.rawValue) - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) - .onChange(of: currentTabIndex) { index in - loadTab(index: index) - } - .onFirstAppear { - loadTab(index: currentTabIndex) + if let tab = Tab(rawValue: currentTabIndex) { + VStack { + switch tab { + case .coins: MarketCoinsView(viewModel: coinsViewModel) + case .watchlist: MarketWatchlistView(viewModel: watchlistViewModel) + case .news: MarketNewsView(viewModel: newsViewModel) + case .platforms: MarketPlatformsView(viewModel: platformsViewModel) + case .pairs: MarketPairsView(viewModel: pairsViewModel) + } + } + .frame(maxHeight: .infinity) + .onChange(of: currentTabIndex) { index in + loadTab(index: index) + } + .onFirstAppear { + loadTab(index: currentTabIndex) + } } } } @@ -62,10 +61,9 @@ struct MarketTabView: View { switch tab { case .coins: coinsViewModel.load() case .watchlist: watchlistViewModel.load() - case .news: () + case .news: newsViewModel.load() case .platforms: platformsViewModel.load() - case .pairs: () - case .sectors: () + case .pairs: pairsViewModel.load() } } } @@ -77,7 +75,7 @@ extension MarketTabView { case news case platforms case pairs - case sectors + // case sectors var title: String { switch self { @@ -86,7 +84,7 @@ extension MarketTabView { case .news: return "market.tab.news".localized case .platforms: return "market.tab.platforms".localized case .pairs: return "market.tab.pairs".localized - case .sectors: return "market.tab.sectors".localized + // case .sectors: return "market.tab.sectors".localized } } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsView.swift new file mode 100644 index 0000000000..9f107138ff --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsView.swift @@ -0,0 +1,114 @@ +import MarketKit +import SwiftUI + +struct MarketNewsView: View { + @ObservedObject var viewModel: MarketNewsViewModel + + var body: some View { + ThemeView { + switch viewModel.state { + case .loading: + loadingList() + case let .loaded(posts): + list(posts: posts) + case .failed: + SyncErrorView { + Task { + await viewModel.refresh() + } + } + } + } + } + + @ViewBuilder private func list(posts: [Post]) -> some View { + ScrollView { + LazyVStack(spacing: .margin12) { + ForEach(posts.indices, id: \.self) { index in + let post = posts[index] + + ListSection { + ClickableRow( + padding: EdgeInsets(top: .margin16, leading: .margin16, bottom: .margin16, trailing: .margin16), + action: { + UrlManager.open(url: post.url) + } + ) { + itemContent( + source: post.source, + title: post.title, + body: post.body, + ago: timeAgo(interval: Date().timeIntervalSince1970 - post.timestamp) + ) + } + } + } + } + .padding(EdgeInsets(top: .margin12, leading: .margin16, bottom: .margin32, trailing: .margin16)) + } + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder private func loadingList() -> some View { + ScrollView { + LazyVStack(spacing: .margin12) { + ForEach(0 ... 5, id: \.self) { _ in + ListSection { + ListRow(padding: EdgeInsets(top: .margin16, leading: .margin16, bottom: .margin16, trailing: .margin16)) { + itemContent( + source: "Post Source", + title: "Post title post title post title post title post title post title", + body: "Post body post body post body post body post body post body post body post body post body post body post body post body post body post body post body post body", + ago: "1h ago" + ) + .redacted() + } + } + } + } + .padding(EdgeInsets(top: .margin12, leading: .margin16, bottom: .margin32, trailing: .margin16)) + } + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } + + @ViewBuilder private func itemContent(source: String, title: String, body: String, ago: String) -> some View { + VStack(alignment: .leading, spacing: .margin12) { + VStack(alignment: .leading, spacing: .margin8) { + Text(source).themeCaptionSB() + + VStack(alignment: .leading, spacing: .margin6) { + Text(title) + .themeHeadline2() + .lineLimit(3) + + Text(body) + .themeSubhead2() + .lineLimit(2) + } + } + + Text(ago).themeMicro(color: .themeGray50) + } + } + + private func timeAgo(interval: TimeInterval) -> String { + var interval = Int(interval) / 60 + + // interval from post in minutes + if interval < 60 { + return "timestamp.min_ago".localized(max(1, interval)) + } + + // interval in hours + interval /= 60 + if interval < 24 { + return "timestamp.hours_ago".localized(interval) + } + + // interval in days + interval /= 24 + return "timestamp.days_ago".localized(interval) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsViewModel.swift new file mode 100644 index 0000000000..3e2ea8a97d --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/News/MarketNewsViewModel.swift @@ -0,0 +1,58 @@ +import Combine +import Foundation +import HsExtensions +import MarketKit + +class MarketNewsViewModel: ObservableObject { + private let marketKit = App.shared.marketKit + + private var tasks = Set() + + @Published var state: State = .loading + + private func sync() { + tasks = Set() + + Task { [weak self] in + await self?._sync() + }.store(in: &tasks) + } + + private func _sync() async { + if case .failed = state { + await MainActor.run { [weak self] in + self?.state = .loading + } + } + + do { + let posts = try await marketKit.posts() + + await MainActor.run { [weak self] in + self?.state = .loaded(posts: posts) + } + } catch { + await MainActor.run { [weak self] in + self?.state = .failed(error: error) + } + } + } +} + +extension MarketNewsViewModel { + func load() { + sync() + } + + func refresh() async { + await _sync() + } +} + +extension MarketNewsViewModel { + enum State { + case loading + case loaded(posts: [Post]) + case failed(error: Error) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift new file mode 100644 index 0000000000..66d3b36692 --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift @@ -0,0 +1,147 @@ +import Kingfisher +import MarketKit +import SwiftUI + +struct MarketPairsView: View { + @ObservedObject var viewModel: MarketPairsViewModel + + var body: some View { + ThemeView { + switch viewModel.state { + case .loading: + VStack(spacing: 0) { + header(disabled: true) + loadingList() + } + case let .loaded(pairs): + VStack(spacing: 0) { + header() + list(pairs: pairs) + } + case .failed: + SyncErrorView { + Task { + await viewModel.refresh() + } + } + } + } + } + + @ViewBuilder private func header(disabled: Bool = false) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: { + viewModel.volumeSortOrder.toggle() + }) { + Text("market.pairs.volume".localized) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .custom(image: volumeSortIcon()))) + .disabled(disabled) + } + .padding(.horizontal, .margin16) + .padding(.vertical, .margin8) + } + } + + @ViewBuilder private func list(pairs: [MarketPair]) -> some View { + ThemeList(items: pairs) { pair in + ClickableRow(action: { + if let tradeUrl = pair.tradeUrl { + UrlManager.open(url: tradeUrl) + } + }) { + itemContent( + frontImageUrl: pair.baseCoinUid.flatMap { URL(string: Coin.imageUrl(uid: $0)) }, + backImageUrl: pair.targetCoinUid.flatMap { URL(string: Coin.imageUrl(uid: $0)) }, + base: pair.base, + target: pair.target, + volume: pair.volume.flatMap { ValueFormatter.instance.formatShort(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + marketName: pair.marketName, + rank: pair.rank, + price: pair.price.flatMap { ValueFormatter.instance.formatShort(value: $0, decimalCount: 8, symbol: pair.target) } ?? "n/a".localized + ) + } + } + .themeListStyle(.transparent) + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder private func loadingList() -> some View { + ThemeList(items: Array(0 ... 10)) { _ in + ListRow { + itemContent( + frontImageUrl: nil, + backImageUrl: nil, + base: "CODE", + target: "CODE", + volume: "$123.4 B", + marketName: "Market Name", + rank: 12, + price: "123 CODE" + ) + .redacted() + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } + + @ViewBuilder private func itemContent(frontImageUrl: URL?, backImageUrl: URL?, base: String, target: String, volume: String, marketName: String, rank: Int, price: String) -> some View { + ZStack(alignment: .leading) { + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.themeTyler) + .frame(width: .iconSize32, height: .iconSize32) + + KFImage.url(backImageUrl) + .resizable() + .placeholder { Circle().fill(Color.themeSteel20) } + .clipShape(Circle()) + .frame(width: .iconSize32, height: .iconSize32) + } + } + + ZStack { + Circle() + .fill(Color.themeTyler) + .frame(width: .iconSize32, height: .iconSize32) + + KFImage.url(frontImageUrl) + .resizable() + .placeholder { Circle().fill(Color.themeSteel20) } + .clipShape(Circle()) + .frame(width: .iconSize32, height: .iconSize32) + } + } + .frame(width: 52) + + VStack(spacing: 1) { + HStack(spacing: .margin8) { + Text("\(base) / \(target)").textBody() + Spacer() + Text(volume).textBody() + } + + HStack(spacing: .margin8) { + HStack(spacing: .margin4) { + BadgeViewNew(text: "\(rank)") + Text(marketName).textSubhead2() + } + Spacer() + Text(price).textSubhead2() + } + } + } + + private func volumeSortIcon() -> Image { + switch viewModel.volumeSortOrder { + case .asc: return Image("arrow_medium_2_up_20") + case .desc: return Image("arrow_medium_2_down_20") + } + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsViewModel.swift new file mode 100644 index 0000000000..d46f9f488a --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsViewModel.swift @@ -0,0 +1,107 @@ +import Combine +import Foundation +import HsExtensions +import MarketKit + +class MarketPairsViewModel: ObservableObject { + private let marketKit = App.shared.marketKit + private let currencyManager = App.shared.currencyManager + + private var cancellables = Set() + private var tasks = Set() + + private var internalState: State = .loading { + didSet { + syncState() + } + } + + @Published var state: State = .loading + + var volumeSortOrder: MarketModule.SortOrder = .desc { + didSet { + syncState() + } + } + + private func sync() { + tasks = Set() + + Task { [weak self] in + await self?._sync() + }.store(in: &tasks) + } + + private func _sync() async { + if case .failed = state { + await MainActor.run { [weak self] in + self?.internalState = .loading + } + } + + do { + let pairs = try await marketKit.topPairs(currencyCode: currency.code) + + await MainActor.run { [weak self] in + self?.internalState = .loaded(pairs: pairs) + } + } catch { + await MainActor.run { [weak self] in + self?.internalState = .failed(error: error) + } + } + } + + private func syncState() { + switch internalState { + case .loading: + state = .loading + case let .loaded(pairs): + state = .loaded(pairs: pairs.sorted(volumeSortOrder: volumeSortOrder)) + case let .failed(error): + state = .failed(error: error) + } + } +} + +extension MarketPairsViewModel { + var currency: Currency { + currencyManager.baseCurrency + } + + func load() { + currencyManager.$baseCurrency + .sink { [weak self] _ in + self?.sync() + } + .store(in: &cancellables) + + sync() + } + + func refresh() async { + await _sync() + } +} + +extension MarketPairsViewModel { + enum State { + case loading + case loaded(pairs: [MarketPair]) + case failed(error: Error) + } +} + +extension [MarketPair] { + func sorted(volumeSortOrder: MarketModule.SortOrder) -> [MarketPair] { + sorted { lhsPair, rhsPair in + let lhsVolume = lhsPair.volume ?? 0 + let rhsVolume = rhsPair.volume ?? 0 + + switch volumeSortOrder { + case .asc: return lhsVolume < rhsVolume + case .desc: return lhsVolume > rhsVolume + } + } + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift index 8503e2f229..b8f8422c75 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift @@ -14,7 +14,10 @@ struct MarketPlatformsView: View { ThemeView { switch viewModel.state { case .loading: - loadingList() + VStack(spacing: 0) { + header(disabled: true) + loadingList() + } case let .loaded(platforms): VStack(spacing: 0) { header() @@ -33,7 +36,7 @@ struct MarketPlatformsView: View { } } - @ViewBuilder private func header() -> some View { + @ViewBuilder private func header(disabled: Bool = false) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack { Button(action: { @@ -42,6 +45,7 @@ struct MarketPlatformsView: View { Text(viewModel.sortBy.title) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) Button(action: { timePeriodSelectorPresented = true @@ -49,6 +53,7 @@ struct MarketPlatformsView: View { Text(viewModel.timePeriod.shortTitle) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) } .padding(.horizontal, .margin16) .padding(.vertical, .margin8) @@ -80,75 +85,75 @@ struct MarketPlatformsView: View { } @ViewBuilder private func list(platforms: [TopPlatform]) -> some View { - ScrollViewReader { _ in - ThemeList(items: platforms) { platform in - ClickableRow(action: { - presentedPlatform = platform - }) { - let blockchain = platform.blockchain - - KFImage.url(URL(string: blockchain.type.imageUrl)) - .resizable() - .placeholder { RoundedRectangle(cornerRadius: .cornerRadius4).fill(Color.themeSteel20) } - .frame(width: .iconSize32, height: .iconSize32) - - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text(blockchain.name).textBody() - Spacer() - Text(platform.marketCap.flatMap { ValueFormatter.instance.formatShort(currency: viewModel.currency, value: $0) } ?? "n/a".localized).textBody() - } - - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - if let rank = platform.rank { - BadgeViewNew(text: "\(rank)", change: platform.ranks[viewModel.timePeriod].map { $0 - rank }) - } - - if let protocolsCount = platform.protocolsCount { - Text("market.top.protocols".localized(String(protocolsCount))).textSubhead2() - } - } - Spacer() - DiffText(platform.changes[viewModel.timePeriod]) - } - } - } - } - .themeListStyle(.transparent) - .refreshable { - await viewModel.refresh() + ThemeList(items: platforms) { platform in + ClickableRow(action: { + presentedPlatform = platform + }) { + let blockchain = platform.blockchain + + itemContent( + imageUrl: URL(string: blockchain.type.imageUrl), + name: blockchain.name, + marketCap: platform.marketCap.flatMap { ValueFormatter.instance.formatShort(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + protocolsCount: platform.protocolsCount, + rank: platform.rank, + rankChange: platform.rank.flatMap { rank in platform.ranks[viewModel.timePeriod].map { $0 - rank } }, + diff: platform.changes[viewModel.timePeriod] + ) } } + .themeListStyle(.transparent) + .refreshable { + await viewModel.refresh() + } } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { _ in + ThemeList(items: Array(0 ... 10)) { index in ListRow { - RoundedRectangle(cornerRadius: .cornerRadius4) - .fill(Color.themeSteel20) - .frame(width: .iconSize32, height: .iconSize32) - .shimmering() + itemContent( + imageUrl: nil, + name: "Blockchain", + marketCap: "$123.4 B", + protocolsCount: 123, + rank: 12, + rankChange: nil, + diff: index % 2 == 0 ? 12.34 : -12.34 + ) + .redacted() + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } + + @ViewBuilder private func itemContent(imageUrl: URL?, name: String, marketCap: String, protocolsCount: Int?, rank: Int?, rankChange: Int?, diff: Decimal?) -> some View { + KFImage.url(imageUrl) + .resizable() + .placeholder { RoundedRectangle(cornerRadius: .cornerRadius8).fill(Color.themeSteel20) } + .clipShape(RoundedRectangle(cornerRadius: .cornerRadius8)) + .frame(width: .iconSize32, height: .iconSize32) + + VStack(spacing: 1) { + HStack(spacing: .margin8) { + Text(name).textBody() + Spacer() + Text(marketCap).textBody() + } - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text("Ethereum").textBody().redacted(value: nil) - Spacer() - Text("$123.4 B").textBody().redacted(value: nil) + HStack(spacing: .margin8) { + HStack(spacing: .margin4) { + if let rank { + BadgeViewNew(text: "\(rank)", change: rankChange) } - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - Text("12").textBody().redacted(value: nil) - Text("Protocols: 123").textSubhead2().redacted(value: nil) - } - Spacer() - DiffText(12.34).redacted(value: nil) + if let protocolsCount { + Text("market.top.protocols".localized(String(protocolsCount))).textSubhead2() } } + Spacer() + DiffText(diff) } } - .themeListStyle(.transparent) - .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift index 2e6140f1e9..0a98d456c0 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift @@ -14,14 +14,17 @@ struct MarketWatchlistView: View { ThemeView { switch viewModel.state { case .loading: - loadingList() - case let .loaded(marketInfos): + VStack(spacing: 0) { + header(disabled: true) + loadingList() + } + case let .loaded(marketInfos, signals): if marketInfos.isEmpty { PlaceholderViewNew(image: Image("rate_48"), text: "market.watchlist.empty".localized) } else { VStack(spacing: 0) { header() - list(marketInfos: marketInfos) + list(marketInfos: marketInfos, signals: signals) } } case .failed: @@ -37,7 +40,7 @@ struct MarketWatchlistView: View { } } - @ViewBuilder private func header() -> some View { + @ViewBuilder private func header(disabled: Bool = false) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack { Button(action: { @@ -46,6 +49,7 @@ struct MarketWatchlistView: View { Text(viewModel.sortBy.title) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) Button(action: { timePeriodSelectorPresented = true @@ -53,11 +57,16 @@ struct MarketWatchlistView: View { Text(viewModel.timePeriod.shortTitle) } .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + .disabled(disabled) if viewModel.showSignals { - signalsButton().buttonStyle(SecondaryActiveButtonStyle()) + signalsButton() + .buttonStyle(SecondaryActiveButtonStyle()) + .disabled(disabled) } else { - signalsButton().buttonStyle(SecondaryButtonStyle()) + signalsButton() + .buttonStyle(SecondaryButtonStyle()) + .disabled(disabled) } } .padding(.horizontal, .margin16) @@ -97,74 +106,96 @@ struct MarketWatchlistView: View { } } - @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { - ScrollViewReader { _ in - ThemeList(items: marketInfos) { marketInfo in - ClickableRow(action: { - presentedFullCoin = marketInfo.fullCoin - }) { - let coin = marketInfo.fullCoin.coin - - KFImage.url(URL(string: coin.imageUrl)) - .resizable() - .placeholder { Circle().fill(Color.themeSteel20) } - .frame(width: .iconSize32, height: .iconSize32) - - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text(coin.code).textBody() - Spacer() - Text(marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized).textBody() - } - - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - if let rank = marketInfo.marketCapRank { - BadgeViewNew(text: "\(rank)") - } - - Text(coin.name).textSubhead2() - } - Spacer() - DiffText(marketInfo.priceChangeValue(timePeriod: viewModel.timePeriod)) - } - } - } - } - .themeListStyle(.transparent) - .refreshable { - await viewModel.refresh() + @ViewBuilder private func list(marketInfos: [MarketInfo], signals: [String: TechnicalAdvice.Advice]) -> some View { + ThemeList(items: marketInfos) { marketInfo in + ClickableRow(action: { + presentedFullCoin = marketInfo.fullCoin + }) { + let coin = marketInfo.fullCoin.coin + + itemContent( + imageUrl: URL(string: coin.imageUrl), + code: coin.code, + name: coin.name, + price: marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + rank: marketInfo.marketCapRank, + diff: marketInfo.priceChangeValue(timePeriod: viewModel.timePeriod), + signal: viewModel.showSignals ? signals[coin.uid] : nil + ) } } + .themeListStyle(.transparent) + .refreshable { + await viewModel.refresh() + } } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { _ in + ThemeList(items: Array(0 ... 10)) { index in ListRow { - Circle() - .fill(Color.themeSteel20) - .frame(width: .iconSize32, height: .iconSize32) - .shimmering() - - VStack(spacing: 1) { - HStack(spacing: .margin8) { - Text("USDT").textBody().redacted(value: nil) - Spacer() - Text("$12345").textBody().redacted(value: nil) + itemContent( + imageUrl: nil, + code: "CODE", + name: "Coin Name", + price: "$123.45", + rank: 12, + diff: index % 2 == 0 ? 12.34 : -12.34, + signal: nil + ) + .redacted() + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } + + @ViewBuilder private func itemContent(imageUrl: URL?, code: String, name: String, price: String, rank: Int?, diff: Decimal?, signal: TechnicalAdvice.Advice?) -> some View { + KFImage.url(imageUrl) + .resizable() + .placeholder { Circle().fill(Color.themeSteel20) } + .clipShape(Circle()) + .frame(width: .iconSize32, height: .iconSize32) + + VStack(spacing: 1) { + HStack(spacing: .margin8) { + HStack(spacing: .margin12) { + Text(code).textBody() + + if let signal { + Text(signal.title) + .font(.themeMicroSB) + .foregroundColor(.themeTyler) + .padding(.horizontal, .margin4) + .padding(.vertical, .margin2) + .background(RoundedRectangle(cornerRadius: .cornerRadius4, style: .continuous).fill(color(signal: signal))) + .clipShape(RoundedRectangle(cornerRadius: .cornerRadius4, style: .continuous)) } + } - HStack(spacing: .margin8) { - HStack(spacing: .margin4) { - Text("12").textBody().redacted(value: nil) - Text("Bitcoin").textSubhead2().redacted(value: nil) - } - Spacer() - DiffText(12.34).redacted(value: nil) + Spacer() + Text(price).textBody() + } + + HStack(spacing: .margin8) { + HStack(spacing: .margin4) { + if let rank { + BadgeViewNew(text: "\(rank)") } + + Text(name).textSubhead2() } + Spacer() + DiffText(diff) } } - .themeListStyle(.transparent) - .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } + + private func color(signal: TechnicalAdvice.Advice) -> Color { + switch signal { + case .oversold, .overbought: return .themeLucian + case .strongSell, .strongBuy: return .themeRemus + case .sell, .buy: return .themeStronbuy + case .neutral: return .themeLeah + } } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift index b100cdea42..fbeaaefcd8 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift @@ -44,13 +44,13 @@ class MarketWatchlistViewModel: ObservableObject { private func syncCoinUids() { coinUids = favoritesManager.allCoinUids - if case let .loaded(marketInfos) = internalState { + if case let .loaded(marketInfos, signals) = internalState { let newMarketInfos = marketInfos.filter { marketInfo in coinUids.contains(marketInfo.fullCoin.coin.uid) } if newMarketInfos.count == coinUids.count { - internalState = .loaded(marketInfos: newMarketInfos) + internalState = .loaded(marketInfos: newMarketInfos, signals: signals) return } } @@ -69,7 +69,7 @@ class MarketWatchlistViewModel: ObservableObject { private func _syncMarketInfos() async { if coinUids.isEmpty { await MainActor.run { [weak self] in - self?.internalState = .loaded(marketInfos: []) + self?.internalState = .loaded(marketInfos: [], signals: [:]) } return } @@ -81,10 +81,13 @@ class MarketWatchlistViewModel: ObservableObject { } do { - let marketInfos = try await marketKit.marketInfos(coinUids: coinUids, currencyCode: currency.code) + async let _marketInfos = try marketKit.marketInfos(coinUids: coinUids, currencyCode: currency.code) + async let _signals = try marketKit.signals(coinUids: coinUids) + + let (marketInfos, signals) = try await (_marketInfos, _signals) await MainActor.run { [weak self] in - self?.internalState = .loaded(marketInfos: marketInfos) + self?.internalState = .loaded(marketInfos: marketInfos, signals: signals) } } catch { await MainActor.run { [weak self] in @@ -97,8 +100,8 @@ class MarketWatchlistViewModel: ObservableObject { switch internalState { case .loading: state = .loading - case let .loaded(marketInfos): - state = .loaded(marketInfos: marketInfos.sorted(sortBy: sortBy, timePeriod: timePeriod)) + case let .loaded(marketInfos, signals): + state = .loaded(marketInfos: marketInfos.sorted(sortBy: sortBy, timePeriod: timePeriod), signals: signals) case let .failed(error): state = .failed(error: error) } @@ -138,7 +141,7 @@ extension MarketWatchlistViewModel { extension MarketWatchlistViewModel { enum State { case loading - case loaded(marketInfos: [MarketInfo]) + case loaded(marketInfos: [MarketInfo], signals: [String: TechnicalAdvice.Advice]) case failed(error: Error) } } diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ClickableRow.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ClickableRow.swift index ce789ee79b..7bd7620d99 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ClickableRow.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ClickableRow.swift @@ -1,11 +1,13 @@ import SwiftUI struct ClickableRow: View { + private let padding: EdgeInsets private let spacing: CGFloat private let action: () -> Void @ViewBuilder private let content: Content - init(spacing: CGFloat = .margin16, action: @escaping () -> Void, @ViewBuilder content: () -> Content) { + init(padding: EdgeInsets = EdgeInsets(top: .margin12, leading: .margin16, bottom: .margin12, trailing: .margin16), spacing: CGFloat = .margin16, action: @escaping () -> Void, @ViewBuilder content: () -> Content) { + self.padding = padding self.spacing = spacing self.action = action self.content = content() @@ -13,7 +15,7 @@ struct ClickableRow: View { var body: some View { Button(action: action, label: { - ListRow(spacing: spacing) { + ListRow(padding: padding, spacing: spacing) { content } }) diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/Extensions/Text.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/Extensions/Text.swift index 7e58ca17a1..d16d1fa6cf 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/Extensions/Text.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/Extensions/Text.swift @@ -18,10 +18,18 @@ extension View { foregroundColor(color).font(.themeCaption) } + func textCaptionSB(color: Color = .themeGray) -> some View { + foregroundColor(color).font(.themeCaptionSB) + } + func textHeadline1(color: Color = .themeLeah) -> some View { foregroundColor(color).font(.themeHeadline1) } + func textHeadline2(color: Color = .themeLeah) -> some View { + foregroundColor(color).font(.themeHeadline2) + } + func textMicro(color: Color = .themeGray) -> some View { foregroundColor(color).font(.themeMicro) } @@ -43,9 +51,7 @@ extension View { } func themeCaptionSB(color: Color = .themeGray, alignment: Alignment = .leading) -> some View { - frame(maxWidth: .infinity, alignment: alignment) - .foregroundColor(color) - .font(.themeCaptionSB) + textCaptionSB(color: color).frame(maxWidth: .infinity, alignment: alignment) } func themeHeadline1(color: Color = .themeLeah, alignment: Alignment = .leading) -> some View { @@ -53,8 +59,10 @@ extension View { } func themeHeadline2(color: Color = .themeLeah, alignment: Alignment = .leading) -> some View { - frame(maxWidth: .infinity, alignment: alignment) - .foregroundColor(color) - .font(.themeHeadline2) + textHeadline2(color: color).frame(maxWidth: .infinity, alignment: alignment) + } + + func themeMicro(color: Color = .themeGray, alignment: Alignment = .leading) -> some View { + textMicro(color: color).frame(maxWidth: .infinity, alignment: alignment) } } diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/RedactedModifier.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/RedactedModifier.swift index 82dce5a1e6..c3b19b96e8 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/RedactedModifier.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/RedactedModifier.swift @@ -11,7 +11,7 @@ struct RedactedModifier: ViewModifier { } extension View { - func redacted(value: Any?) -> some View { + func redacted(value: Any? = nil) -> some View { modifier(RedactedModifier(value: value)) } } diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ScrollableTabHeaderView.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ScrollableTabHeaderView.swift index 0bee4c7780..5728900c66 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ScrollableTabHeaderView.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ScrollableTabHeaderView.swift @@ -27,11 +27,14 @@ struct ScrollableTabHeaderView: View { } .padding(.horizontal, .margin12) } - .onChange(of: currentTabIndex) { _ in + .onChange(of: currentTabIndex) { index in withAnimation(.spring().speed(1.5)) { - proxy.scrollTo(currentTabIndex, anchor: .center) + proxy.scrollTo(index, anchor: .center) } } + .onFirstAppear { + proxy.scrollTo(currentTabIndex, anchor: .center) + } } } .animation(.spring().speed(1.5), value: currentTabIndex) diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/SecondaryButtonStyle.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/SecondaryButtonStyle.swift index f47638f03c..5e96ad9267 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/SecondaryButtonStyle.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/SecondaryButtonStyle.swift @@ -34,8 +34,8 @@ struct SecondaryButtonStyle: ButtonStyle { } @ViewBuilder func accessoryView(accessory: Accessory, configuration: Configuration) -> some View { - if let icon = accessory.icon { - Image(icon) + if let image = accessory.image { + image .renderingMode(.template) .foregroundColor(accessory.foregroundColor(isEnabled: isEnabled, isPressed: configuration.isPressed)) } else { @@ -69,14 +69,14 @@ struct SecondaryButtonStyle: ButtonStyle { case none case dropDown case info - case custom(icon: String, pressedColor: Color = Self.pressedColor, activeColor: Color = Self.enabledColor, disabledColor: Color = Self.disabledColor) + case custom(image: Image, pressedColor: Color = Self.pressedColor, activeColor: Color = Self.enabledColor, disabledColor: Color = Self.disabledColor) - var icon: String? { + var image: Image? { switch self { case .none: return nil - case .dropDown: return "arrow_small_down_20" - case .info: return "circle_information_20" - case let .custom(icon, _, _, _): return icon + case .dropDown: return Image("arrow_small_down_20") + case .info: return Image("circle_information_20") + case let .custom(image, _, _, _): return image } } diff --git a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings index 1564195c96..efb895e744 100644 --- a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings +++ b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings @@ -772,6 +772,7 @@ "market.top.top_platforms" = "Top Platforms"; "market.top.protocols" = "Protocols: %@"; +"market.pairs.volume" = "Volume"; "top_pairs.title" = "Top Market Pairs"; "top_pairs.description" = "Top trading pairs by volume in every exchanges";