diff --git a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj index a04b983c08..fdaf61acb9 100644 --- a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj +++ b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj @@ -2992,6 +2992,14 @@ D3384D142BFDCBE700515664 /* MarketEtfViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D122BFDCBE700515664 /* MarketEtfViewModel.swift */; }; D3384D162BFDEF6800515664 /* Etf.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D152BFDEF6800515664 /* Etf.swift */; }; D3384D172BFDEF6800515664 /* Etf.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D152BFDEF6800515664 /* Etf.swift */; }; + D3384D1A2BFF0CAF00515664 /* MarketMarketCapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D192BFF0CAF00515664 /* MarketMarketCapView.swift */; }; + D3384D1B2BFF0CAF00515664 /* MarketMarketCapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D192BFF0CAF00515664 /* MarketMarketCapView.swift */; }; + D3384D1D2BFF0CB800515664 /* MarketMarketCapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D1C2BFF0CB800515664 /* MarketMarketCapViewModel.swift */; }; + D3384D1E2BFF0CB800515664 /* MarketMarketCapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D1C2BFF0CB800515664 /* MarketMarketCapViewModel.swift */; }; + D3384D212BFF0CCA00515664 /* MarketVolumeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D202BFF0CCA00515664 /* MarketVolumeView.swift */; }; + D3384D222BFF0CCA00515664 /* MarketVolumeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D202BFF0CCA00515664 /* MarketVolumeView.swift */; }; + D3384D242BFF0CD100515664 /* MarketVolumeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D232BFF0CD100515664 /* MarketVolumeViewModel.swift */; }; + D3384D252BFF0CD100515664 /* MarketVolumeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3384D232BFF0CD100515664 /* MarketVolumeViewModel.swift */; }; D339A93D29126D0F00B895BE /* HsCryptoKit in Frameworks */ = {isa = PBXBuildFile; productRef = D339A93C29126D0F00B895BE /* HsCryptoKit */; }; D339A93F29126D2A00B895BE /* HsCryptoKit in Frameworks */ = {isa = PBXBuildFile; productRef = D339A93E29126D2A00B895BE /* HsCryptoKit */; }; D3402AEE2BF5D58B003BF6F8 /* WatchlistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3402AED2BF5D58B003BF6F8 /* WatchlistViewModel.swift */; }; @@ -4925,6 +4933,10 @@ D3384D0F2BFDCBDE00515664 /* MarketEtfView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketEtfView.swift; sourceTree = ""; }; D3384D122BFDCBE700515664 /* MarketEtfViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketEtfViewModel.swift; sourceTree = ""; }; D3384D152BFDEF6800515664 /* Etf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Etf.swift; sourceTree = ""; }; + D3384D192BFF0CAF00515664 /* MarketMarketCapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketMarketCapView.swift; sourceTree = ""; }; + D3384D1C2BFF0CB800515664 /* MarketMarketCapViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketMarketCapViewModel.swift; sourceTree = ""; }; + D3384D202BFF0CCA00515664 /* MarketVolumeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketVolumeView.swift; sourceTree = ""; }; + D3384D232BFF0CD100515664 /* MarketVolumeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketVolumeViewModel.swift; sourceTree = ""; }; D3402AED2BF5D58B003BF6F8 /* WatchlistViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistViewModel.swift; sourceTree = ""; }; D3402AF02BF5D59D003BF6F8 /* WatchlistModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistModifier.swift; sourceTree = ""; }; D3402AF62BF71C11003BF6F8 /* WatchlistManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistManager.swift; sourceTree = ""; }; @@ -7763,6 +7775,8 @@ 58AAA9EB9618EBC895D0B123 /* Market */ = { isa = PBXGroup; children = ( + D3384D1F2BFF0CBD00515664 /* Volume */, + D3384D182BFF0C9900515664 /* MarketCap */, D3384D0E2BFDCBD100515664 /* Etf */, D3833AFA2BF335B800ACECFB /* News */, D3833AF02BF20B7200ACECFB /* Pairs */, @@ -9359,6 +9373,24 @@ path = Etf; sourceTree = ""; }; + D3384D182BFF0C9900515664 /* MarketCap */ = { + isa = PBXGroup; + children = ( + D3384D192BFF0CAF00515664 /* MarketMarketCapView.swift */, + D3384D1C2BFF0CB800515664 /* MarketMarketCapViewModel.swift */, + ); + path = MarketCap; + sourceTree = ""; + }; + D3384D1F2BFF0CBD00515664 /* Volume */ = { + isa = PBXGroup; + children = ( + D3384D202BFF0CCA00515664 /* MarketVolumeView.swift */, + D3384D232BFF0CD100515664 /* MarketVolumeViewModel.swift */, + ); + path = Volume; + sourceTree = ""; + }; D36E50882BF7656E00C361BD /* Watchlist */ = { isa = PBXGroup; children = ( @@ -9958,6 +9990,7 @@ 58AAA34F0F6195DF86596A41 /* ChartConfiguration.swift in Sources */, 58AAA75358DF98C1D7191B81 /* DoubleSpendInfoViewController.swift in Sources */, 58AAA747269D5AE1BBDDA2F7 /* LastBlockInfo.swift in Sources */, + D3384D252BFF0CD100515664 /* MarketVolumeViewModel.swift in Sources */, 58AAA4A4D0D7398E7184E7AB /* UITextView.swift in Sources */, 5039F973269C5A9B004711B8 /* ReleaseNotesViewController.swift in Sources */, 58AAA12167F3BC03D0FA55DF /* LockDelegate.swift in Sources */, @@ -10911,6 +10944,7 @@ 11B35BC602EA104EE1C0540C /* BinanceChainKit.swift in Sources */, 11B35DE5BD5716307300AD2F /* OneInchKit.swift in Sources */, D3833AD82BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */, + D3384D1B2BFF0CAF00515664 /* MarketMarketCapView.swift in Sources */, ABC9ADDC1F55F835C68DB4C7 /* UniswapV3Provider.swift in Sources */, D0F132A22B6B98E100C7310E /* RbfService.swift in Sources */, ABC9A3CC73251E7F83A94181 /* UniswapV3TradeService.swift in Sources */, @@ -10958,6 +10992,7 @@ 11B3562D78E70F5F14B81B3A /* CexWithdrawNetwork.swift in Sources */, 11B354DEFBE83147106A5FFE /* CexAssetRecord.swift in Sources */, 11B35C4A0250F05179488A91 /* CexWithdrawNetworkRaw.swift in Sources */, + D3384D1E2BFF0CB800515664 /* MarketMarketCapViewModel.swift in Sources */, 11B357573D364030813F231C /* CexAssetManager.swift in Sources */, 11B350BFC559991F9BA7A63F /* CexAssetRecordStorage.swift in Sources */, 11B357EE5114E13940C0D631 /* CexAssetResponse.swift in Sources */, @@ -11158,6 +11193,7 @@ ABC9A8AC5E635D9CB1704568 /* BackupDisclaimerView.swift in Sources */, ABC9A437473D0E77F9DBEB42 /* RestoreAppViewModel.swift in Sources */, ABC9AE7DA8EFD812710C7BE4 /* RestorePassphraseViewModel.swift in Sources */, + D3384D222BFF0CCA00515664 /* MarketVolumeView.swift in Sources */, ABC9A93E05AAF5D98C1DF4D6 /* RestorePassphraseService.swift in Sources */, ABC9AA016413C37F4CC95080 /* RestorePassphraseViewController.swift in Sources */, ABC9A453F337BA22A5698DCC /* RestorePassphraseModule.swift in Sources */, @@ -11529,6 +11565,7 @@ 58AAA2100166FDFB110FA6D0 /* ChartConfiguration.swift in Sources */, 58AAA415B26725FEF4A1128D /* DoubleSpendInfoViewController.swift in Sources */, 58AAA39A983D2E97066C3959 /* LastBlockInfo.swift in Sources */, + D3384D242BFF0CD100515664 /* MarketVolumeViewModel.swift in Sources */, 58AAA550B894B6F8FC8DA1B1 /* UITextView.swift in Sources */, 58AAA8E5EA8901CF69DDE43D /* LockDelegate.swift in Sources */, 58AAA6A77A2B953931A1D7FC /* DataStatus.swift in Sources */, @@ -12482,6 +12519,7 @@ 11B35146CA9BE897C858AB73 /* BinanceChainKit.swift in Sources */, 11B352309B81355B88BF6B66 /* OneInchKit.swift in Sources */, D3833AD72BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */, + D3384D1A2BFF0CAF00515664 /* MarketMarketCapView.swift in Sources */, ABC9A7E1F93B0A85976C826D /* UniswapV3Provider.swift in Sources */, ABC9AC900545DC0DD2201DEE /* UniswapV3TradeService.swift in Sources */, ABC9ACCD1ED14FA216AF1E65 /* UniswapV3Service.swift in Sources */, @@ -12529,6 +12567,7 @@ 11B3511098C99D7B7D5A492A /* CexWithdrawNetwork.swift in Sources */, 11B350C214D423CE2DCD6853 /* CexAssetRecord.swift in Sources */, 11B355B56270FCD8A17A49B5 /* CexWithdrawNetworkRaw.swift in Sources */, + D3384D1D2BFF0CB800515664 /* MarketMarketCapViewModel.swift in Sources */, 11B35841E0B353B727DCD9CF /* CexAssetManager.swift in Sources */, 6B2907212AF0CB8A006157D6 /* WalletConnectAppShowModule.swift in Sources */, 11B35C95EA77972246D5F3BD /* CexAssetRecordStorage.swift in Sources */, @@ -12729,6 +12768,7 @@ ABC9A04FAB83D7A8D251DA90 /* BackupPasswordView.swift in Sources */, D0F9F5172B99857700C3190A /* FeeSettings.swift in Sources */, ABC9A0CE0155F89F12350DFC /* BackupListView.swift in Sources */, + D3384D212BFF0CCA00515664 /* MarketVolumeView.swift in Sources */, ABC9A4465982823773CE1B50 /* BackupDisclaimerView.swift in Sources */, ABC9AA18996E714C955E7E13 /* RestoreAppViewModel.swift in Sources */, ABC9AF04946C86FA6DBD4225 /* RestorePassphraseViewModel.swift in Sources */, diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Coin/CoinMarkets/CoinMarketsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Coin/CoinMarkets/CoinMarketsView.swift index ff38f1a8c7..5ca4c4e3e2 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Coin/CoinMarkets/CoinMarketsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Coin/CoinMarkets/CoinMarketsView.swift @@ -47,7 +47,7 @@ struct CoinMarketsView: View { .padding(.vertical, .margin8) ScrollViewReader { proxy in - ThemeList(items: viewItems) { viewItem in + ThemeList(viewItems, bottomSpacing: .margin16) { viewItem in if let tradeUrl = viewItem.tradeUrl { ClickableRow(action: { UrlManager.open(url: tradeUrl) @@ -61,7 +61,6 @@ struct CoinMarketsView: View { } } } - .themeListStyle(.transparent) .onChange(of: viewModel.filterTypeInfo) { _ in withAnimation { proxy.scrollTo(viewItems.first!) diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift index 6255ad1033..a7618a6bda 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift @@ -107,7 +107,7 @@ struct MarketCoinsView: View { } @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { - ThemeList(items: marketInfos) { marketInfo in + ThemeList(marketInfos) { marketInfo in let coin = marketInfo.fullCoin.coin ClickableRow(action: { @@ -124,14 +124,13 @@ struct MarketCoinsView: View { } .watchlistSwipeActions(viewModel: watchlistViewModel, coinUid: coin.uid) } - .themeListStyle(.transparent) .refreshable { await viewModel.refresh() } } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { index in + ThemeList(Array(0 ... 10)) { index in ListRow { itemContent( imageUrl: nil, @@ -144,7 +143,6 @@ struct MarketCoinsView: View { .redacted() } } - .themeListStyle(.transparent) .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Etf/MarketEtfView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Etf/MarketEtfView.swift index 70dc8d5f94..4bb951d131 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Etf/MarketEtfView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Etf/MarketEtfView.swift @@ -21,19 +21,34 @@ struct MarketEtfView: View { ThemeView { switch viewModel.state { case .loading: - VStack(spacing: 0) { + ThemeList(bottomSpacing: .margin16) { header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + chart() - listHeader(disabled: true) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + loadingList() } + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) case let .loaded(etfs): - ThemeLazyList { + ThemeList(bottomSpacing: .margin16) { header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + chart() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + list(etfs: etfs) } - .themeListStyle(.transparent) case .failed: VStack(spacing: 0) { header() @@ -129,7 +144,7 @@ struct MarketEtfView: View { @ViewBuilder private func list(etfs: [Etf]) -> some View { Section { - ThemeLazyListSectionContent(items: etfs) { etf in + ListForEach(etfs) { etf in ListRow { itemContent( imageUrl: nil, @@ -141,25 +156,31 @@ struct MarketEtfView: View { } } } header: { - listHeader().background(Color.themeTyler) + listHeader() + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) } } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { index in - ListRow { - itemContent( - imageUrl: nil, - ticker: "ABCD", - name: "Ticker Name", - totalAssets: 123_345_678, - change: index % 2 == 0 ? 123_456 : -123_456 - ) - .redacted() + Section { + ListForEach(Array(0 ... 10)) { index in + ListRow { + itemContent( + imageUrl: nil, + ticker: "ABCD", + name: "Ticker Name", + totalAssets: 123_345_678, + change: index % 2 == 0 ? 123_456 : -123_456 + ) + .redacted() + } } + } header: { + listHeader() + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) } - .themeListStyle(.transparent) - .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) } @ViewBuilder private func itemContent(imageUrl: URL?, ticker: String, name: String, totalAssets: Decimal?, change: Decimal?) -> some View { diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketCap/MarketMarketCapView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketCap/MarketMarketCapView.swift new file mode 100644 index 0000000000..c580398a2b --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketCap/MarketMarketCapView.swift @@ -0,0 +1,201 @@ +import Kingfisher +import MarketKit +import SwiftUI + +struct MarketMarketCapView: View { + @StateObject var viewModel: MarketMarketCapViewModel + @StateObject var chartViewModel: MetricChartViewModel + @StateObject var watchlistViewModel: WatchlistViewModel + @Binding var isPresented: Bool + + @State private var presentedFullCoin: FullCoin? + + init(isPresented: Binding) { + _viewModel = StateObject(wrappedValue: MarketMarketCapViewModel()) + _chartViewModel = StateObject(wrappedValue: MetricChartViewModel.instance(type: .totalMarketCap)) + _watchlistViewModel = StateObject(wrappedValue: WatchlistViewModel()) + _isPresented = isPresented + } + + var body: some View { + ThemeNavigationView { + ThemeView { + switch viewModel.state { + case .loading: + ThemeList(bottomSpacing: .margin16) { + header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + chart() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + loadingList() + } + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + case let .loaded(marketInfos): + ThemeList(bottomSpacing: .margin16) { + header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + chart() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + list(marketInfos: marketInfos) + } + case .failed: + VStack(spacing: 0) { + header() + chart() + + SyncErrorView { + viewModel.sync() + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("button.close".localized) { + isPresented = false + } + } + } + .sheet(item: $presentedFullCoin) { fullCoin in + CoinPageViewNew(coinUid: fullCoin.coin.uid).ignoresSafeArea() + } + } + } + + @ViewBuilder private func header() -> some View { + HStack(spacing: .margin32) { + VStack(spacing: .margin8) { + Text("market.market_cap.title".localized).themeHeadline1() + Text("market.market_cap.description".localized).themeSubhead2() + } + .padding(.vertical, .margin12) + + KFImage.url(URL(string: "total_mcap".headerImageUrl)) + .resizable() + .frame(width: 76, height: 108) + } + .padding(.leading, .margin16) + } + + @ViewBuilder private func chart() -> some View { + ChartView(viewModel: chartViewModel, configuration: .marketCapChart) + .frame(maxWidth: .infinity) + .onFirstAppear { + chartViewModel.start() + } + } + + @ViewBuilder private func listHeader(disabled: Bool = false) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: { + viewModel.sortOrder.toggle() + }) { + Text("market.market_cap.market_cap".localized) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .custom(image: sortIcon()))) + .disabled(disabled) + } + .padding(.horizontal, .margin16) + .padding(.vertical, .margin8) + } + } + + @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { + Section { + ListForEach(marketInfos) { marketInfo in + let coin = marketInfo.fullCoin.coin + + ClickableRow(action: { + presentedFullCoin = marketInfo.fullCoin + }) { + itemContent( + imageUrl: URL(string: coin.imageUrl), + code: coin.code, + marketCap: marketInfo.marketCap, + price: marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + rank: marketInfo.marketCapRank, + diff: marketInfo.priceChangeValue(timePeriod: HsTimePeriod.day1) + ) + } + .watchlistSwipeActions(viewModel: watchlistViewModel, coinUid: coin.uid) + } + } header: { + listHeader() + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) + } + } + + @ViewBuilder private func loadingList() -> some View { + Section { + ListForEach(Array(0 ... 10)) { index in + ListRow { + itemContent( + imageUrl: nil, + code: "CODE", + marketCap: 123_456, + price: "$123.45", + rank: 12, + diff: index % 2 == 0 ? 12.34 : -12.34 + ) + .redacted() + } + } + } header: { + listHeader(disabled: true) + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) + } + } + + @ViewBuilder private func itemContent(imageUrl: URL?, code: String, marketCap: Decimal?, 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)") + } + + if let marketCap, let formatted = ValueFormatter.instance.formatShort(currency: viewModel.currency, value: marketCap) { + Text(formatted).textSubhead2() + } + } + Spacer() + DiffText(diff) + } + } + } + + private func sortIcon() -> Image { + switch viewModel.sortOrder { + 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/MarketCap/MarketMarketCapViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketCap/MarketMarketCapViewModel.swift new file mode 100644 index 0000000000..b379561b91 --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketCap/MarketMarketCapViewModel.swift @@ -0,0 +1,91 @@ +import Combine +import Foundation +import HsExtensions +import MarketKit + +class MarketMarketCapViewModel: 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 sortOrder: MarketModule.SortOrder = .desc { + didSet { + syncState() + } + } + + init() { + currencyManager.$baseCurrency + .sink { [weak self] _ in + self?.sync() + } + .store(in: &cancellables) + + sync() + } + + private func syncState() { + switch internalState { + case .loading: + state = .loading + case let .loaded(marketInfos): + let sortBy: MarketModule.SortBy + + switch sortOrder { + case .asc: sortBy = .lowestCap + case .desc: sortBy = .highestCap + } + + state = .loaded(marketInfos: marketInfos.sorted(sortBy: sortBy, timePeriod: .day1)) + case let .failed(error): + state = .failed(error: error) + } + } +} + +extension MarketMarketCapViewModel { + var currency: Currency { + currencyManager.baseCurrency + } + + func sync() { + tasks = Set() + + if case .failed = internalState { + internalState = .loading + } + + Task { [weak self, marketKit, currency] in + do { + let marketInfos = try await marketKit.marketInfos(top: MarketModule.Top.top100.rawValue, currencyCode: currency.code) + + await MainActor.run { [weak self] in + self?.internalState = .loaded(marketInfos: marketInfos) + } + } catch { + await MainActor.run { [weak self] in + self?.internalState = .failed(error: error) + } + } + } + .store(in: &tasks) + } +} + +extension MarketMarketCapViewModel { + enum State { + case loading + case loaded(marketInfos: [MarketInfo]) + case failed(error: Error) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketGlobalView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketGlobalView.swift index 35ae771e10..a76711db1c 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketGlobalView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketGlobalView.swift @@ -5,6 +5,8 @@ struct MarketGlobalView: View { @ObservedObject var viewModel: MarketGlobalViewModel @State private var presentedGlobalMarketMetricsType: MarketGlobalModule.MetricsType? + @State private var marketCapPresented = false + @State private var volumePresented = false @State private var etfPresented = false var body: some View { @@ -33,6 +35,12 @@ struct MarketGlobalView: View { .sheet(item: $presentedGlobalMarketMetricsType) { metricsType in MarketGlobalMetricsView(metricsType: metricsType).ignoresSafeArea() } + .sheet(isPresented: $marketCapPresented) { + MarketMarketCapView(isPresented: $marketCapPresented) + } + .sheet(isPresented: $volumePresented) { + MarketVolumeView(isPresented: $volumePresented) + } .sheet(isPresented: $etfPresented) { MarketEtfView(isPresented: $etfPresented) } @@ -60,6 +68,8 @@ struct MarketGlobalView: View { .padding(.vertical, .margin16) .onTapGesture { switch metricsType { + case .totalMarketCap: marketCapPresented = true + case .volume24h: volumePresented = true case .defiCap: etfPresented = true default: presentedGlobalMarketMetricsType = metricsType } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketSearchView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketSearchView.swift index 32cc97026f..19027a4913 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketSearchView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketSearchView.swift @@ -13,23 +13,31 @@ struct MarketSearchView: View { ThemeView { switch viewModel.state { case let .placeholder(recentFullCoins, popularFullCoins): - ThemeLazyList { + ThemeList { if !recentFullCoins.isEmpty { - ThemeLazyListSection(header: "market.search.recent".localized, items: recentFullCoins) { fullCoin in - itemContent(fullCoin: fullCoin) + Section { + ListForEach(recentFullCoins) { fullCoin in + itemContent(fullCoin: fullCoin) + } + } header: { + ThemeListSectionHeader(text: "market.search.recent".localized) } } - ThemeLazyListSection(header: "market.search.popular".localized, items: popularFullCoins) { fullCoin in - itemContent(fullCoin: fullCoin) + Section { + ListForEach(popularFullCoins) { fullCoin in + itemContent(fullCoin: fullCoin) + } + } header: { + ThemeListSectionHeader(text: "market.search.popular".localized) } } - .themeListStyle(.transparent) case let .searchResults(fullCoins): - ThemeList(items: fullCoins) { fullCoin in - itemContent(fullCoin: fullCoin) + ThemeList { + ListForEach(fullCoins) { fullCoin in + itemContent(fullCoin: fullCoin) + } } - .themeListStyle(.transparent) } } .sheet(item: $presentedFullCoin) { fullCoin in diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift index 66d3b36692..ae77a14c94 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Pairs/MarketPairsView.swift @@ -45,7 +45,7 @@ struct MarketPairsView: View { } @ViewBuilder private func list(pairs: [MarketPair]) -> some View { - ThemeList(items: pairs) { pair in + ThemeList(pairs) { pair in ClickableRow(action: { if let tradeUrl = pair.tradeUrl { UrlManager.open(url: tradeUrl) @@ -70,7 +70,7 @@ struct MarketPairsView: View { } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { _ in + ThemeList(Array(0 ... 10)) { _ in ListRow { itemContent( frontImageUrl: nil, diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift index b8f8422c75..15c65d558b 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Platforms/MarketPlatformsView.swift @@ -85,7 +85,7 @@ struct MarketPlatformsView: View { } @ViewBuilder private func list(platforms: [TopPlatform]) -> some View { - ThemeList(items: platforms) { platform in + ThemeList(platforms) { platform in ClickableRow(action: { presentedPlatform = platform }) { @@ -109,7 +109,7 @@ struct MarketPlatformsView: View { } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { index in + ThemeList(Array(0 ... 10)) { index in ListRow { itemContent( imageUrl: nil, diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Volume/MarketVolumeView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Volume/MarketVolumeView.swift new file mode 100644 index 0000000000..bebe473e69 --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Volume/MarketVolumeView.swift @@ -0,0 +1,201 @@ +import Kingfisher +import MarketKit +import SwiftUI + +struct MarketVolumeView: View { + @StateObject var viewModel: MarketVolumeViewModel + @StateObject var chartViewModel: MetricChartViewModel + @StateObject var watchlistViewModel: WatchlistViewModel + @Binding var isPresented: Bool + + @State private var presentedFullCoin: FullCoin? + + init(isPresented: Binding) { + _viewModel = StateObject(wrappedValue: MarketVolumeViewModel()) + _chartViewModel = StateObject(wrappedValue: MetricChartViewModel.instance(type: .volume24h)) + _watchlistViewModel = StateObject(wrappedValue: WatchlistViewModel()) + _isPresented = isPresented + } + + var body: some View { + ThemeNavigationView { + ThemeView { + switch viewModel.state { + case .loading: + ThemeList(bottomSpacing: .margin16) { + header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + chart() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + loadingList() + } + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + case let .loaded(marketInfos): + ThemeList(bottomSpacing: .margin16) { + header() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + chart() + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + + list(marketInfos: marketInfos) + } + case .failed: + VStack(spacing: 0) { + header() + chart() + + SyncErrorView { + viewModel.sync() + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("button.close".localized) { + isPresented = false + } + } + } + .sheet(item: $presentedFullCoin) { fullCoin in + CoinPageViewNew(coinUid: fullCoin.coin.uid).ignoresSafeArea() + } + } + } + + @ViewBuilder private func header() -> some View { + HStack(spacing: .margin32) { + VStack(spacing: .margin8) { + Text("market.volume.title".localized).themeHeadline1() + Text("market.volume.description".localized).themeSubhead2() + } + .padding(.vertical, .margin12) + + KFImage.url(URL(string: "total_volume".headerImageUrl)) + .resizable() + .frame(width: 76, height: 108) + } + .padding(.leading, .margin16) + } + + @ViewBuilder private func chart() -> some View { + ChartView(viewModel: chartViewModel, configuration: .baseChart) + .frame(maxWidth: .infinity) + .onFirstAppear { + chartViewModel.start() + } + } + + @ViewBuilder private func listHeader(disabled: Bool = false) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: { + viewModel.sortOrder.toggle() + }) { + Text("market.volume.volume".localized) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .custom(image: sortIcon()))) + .disabled(disabled) + } + .padding(.horizontal, .margin16) + .padding(.vertical, .margin8) + } + } + + @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { + Section { + ListForEach(marketInfos) { marketInfo in + let coin = marketInfo.fullCoin.coin + + ClickableRow(action: { + presentedFullCoin = marketInfo.fullCoin + }) { + itemContent( + imageUrl: URL(string: coin.imageUrl), + code: coin.code, + volume: marketInfo.totalVolume, + price: marketInfo.price.flatMap { ValueFormatter.instance.formatFull(currency: viewModel.currency, value: $0) } ?? "n/a".localized, + rank: marketInfo.marketCapRank, + diff: marketInfo.priceChangeValue(timePeriod: HsTimePeriod.day1) + ) + } + .watchlistSwipeActions(viewModel: watchlistViewModel, coinUid: coin.uid) + } + } header: { + listHeader() + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) + } + } + + @ViewBuilder private func loadingList() -> some View { + Section { + ListForEach(Array(0 ... 10)) { index in + ListRow { + itemContent( + imageUrl: nil, + code: "CODE", + volume: 123_456, + price: "$123.45", + rank: 12, + diff: index % 2 == 0 ? 12.34 : -12.34 + ) + .redacted() + } + } + } header: { + listHeader(disabled: true) + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) + } + } + + @ViewBuilder private func itemContent(imageUrl: URL?, code: String, volume: Decimal?, 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)") + } + + if let volume, let formatted = ValueFormatter.instance.formatShort(currency: viewModel.currency, value: volume) { + Text(formatted).textSubhead2() + } + } + Spacer() + DiffText(diff) + } + } + } + + private func sortIcon() -> Image { + switch viewModel.sortOrder { + 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/Volume/MarketVolumeViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Volume/MarketVolumeViewModel.swift new file mode 100644 index 0000000000..382f1c49eb --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Volume/MarketVolumeViewModel.swift @@ -0,0 +1,91 @@ +import Combine +import Foundation +import HsExtensions +import MarketKit + +class MarketVolumeViewModel: 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 sortOrder: MarketModule.SortOrder = .desc { + didSet { + syncState() + } + } + + init() { + currencyManager.$baseCurrency + .sink { [weak self] _ in + self?.sync() + } + .store(in: &cancellables) + + sync() + } + + private func syncState() { + switch internalState { + case .loading: + state = .loading + case let .loaded(marketInfos): + let sortBy: MarketModule.SortBy + + switch sortOrder { + case .asc: sortBy = .lowestVolume + case .desc: sortBy = .highestVolume + } + + state = .loaded(marketInfos: marketInfos.sorted(sortBy: sortBy, timePeriod: .day1)) + case let .failed(error): + state = .failed(error: error) + } + } +} + +extension MarketVolumeViewModel { + var currency: Currency { + currencyManager.baseCurrency + } + + func sync() { + tasks = Set() + + if case .failed = internalState { + internalState = .loading + } + + Task { [weak self, marketKit, currency] in + do { + let marketInfos = try await marketKit.marketInfos(top: MarketModule.Top.top100.rawValue, currencyCode: currency.code) + + await MainActor.run { [weak self] in + self?.internalState = .loaded(marketInfos: marketInfos) + } + } catch { + await MainActor.run { [weak self] in + self?.internalState = .failed(error: error) + } + } + } + .store(in: &tasks) + } +} + +extension MarketVolumeViewModel { + enum State { + case loading + case loaded(marketInfos: [MarketInfo]) + case failed(error: Error) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift index 1440e11852..075708ba55 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift @@ -133,7 +133,7 @@ struct MarketWatchlistView: View { @ViewBuilder private func list(marketInfos: [MarketInfo], signals: [String: TechnicalAdvice.Advice]) -> some View { ThemeList( - items: marketInfos, + marketInfos, onMove: viewModel.sortBy == .manual ? { source, destination in viewModel.move(source: source, destination: destination) } : nil @@ -174,7 +174,7 @@ struct MarketWatchlistView: View { } @ViewBuilder private func loadingList() -> some View { - ThemeList(items: Array(0 ... 10)) { index in + ThemeList(Array(0 ... 10)) { index in ListRow { itemContent( imageUrl: nil, diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift index 23cad81d82..c8f852f0e4 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift @@ -61,7 +61,7 @@ class MarketWatchlistViewModel: ObservableObject { private func syncCoinUids() { let coinUids = watchlistManager.coinUids - guard coinUids != self.coinUids else { + if case .loaded = internalState, coinUids == self.coinUids { return } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/MetricChart/MetricChartViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/MetricChart/MetricChartViewModel.swift index 8b2eea1c71..20ca5140e7 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/MetricChart/MetricChartViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/MetricChart/MetricChartViewModel.swift @@ -152,7 +152,7 @@ extension MetricChartViewModel { let factory = MetricChartFactory(currentLocale: LanguageManager.shared.currentLocale) return MetricChartViewModel(service: service, factory: factory) } - + static var etfInstance: MetricChartViewModel { let fetcher = MarketEtfFetcher(marketKit: App.shared.marketKit, currencyManager: App.shared.currencyManager) let service = MetricChartService( diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/MultiSwap/TokenSelect/MultiSwapTokenSelectView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/MultiSwap/TokenSelect/MultiSwapTokenSelectView.swift index 280d03b42b..c6531116ea 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/MultiSwap/TokenSelect/MultiSwapTokenSelectView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/MultiSwap/TokenSelect/MultiSwapTokenSelectView.swift @@ -23,7 +23,7 @@ struct MultiSwapTokenSelectView: View { VStack(spacing: 0) { SearchBar(text: $viewModel.searchText, prompt: "placeholder.search".localized) - ThemeList(items: viewModel.items) { item in + ThemeList(viewModel.items, bottomSpacing: .margin16) { item in ClickableRow(action: { currentToken = item.token isPresented = false @@ -67,7 +67,6 @@ struct MultiSwapTokenSelectView: View { } } } - .themeListStyle(.transparent) } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) diff --git a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ThemeList.swift b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ThemeList.swift index b8f1887492..672eac2b44 100644 --- a/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ThemeList.swift +++ b/UnstoppableWallet/UnstoppableWallet/UserInterface/SwiftUI/ThemeList.swift @@ -1,102 +1,64 @@ import SwiftUI +import UIKit -struct ThemeList: View { - private let items: [Item] - private let onMove: ((IndexSet, Int) -> Void)? - private let itemContent: (Item) -> Content +struct ThemeList: View { + private let content: () -> Content + private let bottomSpacing: CGFloat? - @Environment(\.themeListStyle) var themeListStyle + init(bottomSpacing: CGFloat? = nil, @ViewBuilder _ content: @escaping () -> Content) { + self.bottomSpacing = bottomSpacing + self.content = content + } - init(items: [Item], onMove: ((IndexSet, Int) -> Void)? = nil, @ViewBuilder itemContent: @escaping (Item) -> Content) { - self.items = items - self.onMove = onMove - self.itemContent = itemContent + init(_ items: [Item], bottomSpacing: CGFloat? = nil, onMove: ((IndexSet, Int) -> Void)? = nil, @ViewBuilder itemContent: @escaping (Item) -> ItemContent) where Content == ListForEach { + self.bottomSpacing = bottomSpacing + content = { + ListForEach(items, onMove: onMove, itemContent: itemContent) + } } var body: some View { - switch themeListStyle { - case .lawrence, .bordered, .transparentInline, .borderedLawrence: - Text("todo") - case .transparent: - List { - ForEach(items, id: \.self) { item in - VStack(spacing: 0) { - if items.first == item { - HorizontalDivider() - } - - itemContent(item) - - HorizontalDivider() - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - } - .onMove(perform: onMove) + List { + content() + if let bottomSpacing { Spacer() - .frame(height: .margin16) + .frame(height: bottomSpacing) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) } - .listStyle(.plain) } + .listStyle(.plain) + .themeListStyle(.transparent) } } -struct ThemeLazyList: View { - @ViewBuilder let content: () -> Content - - @Environment(\.themeListStyle) var themeListStyle +struct ThemeListSectionHeader: View { + let text: String var body: some View { - switch themeListStyle { - case .lawrence, .bordered, .transparentInline, .borderedLawrence: - Text("todo") - case .transparent: - ScrollView { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - content() - - Spacer().frame(height: .margin32) - } - } - } + Text(text) + .themeSubhead1(alignment: .leading) + .textCase(.uppercase) + .padding(.horizontal, .margin16) + .frame(height: 44) + .frame(maxWidth: .infinity) + .listRowInsets(EdgeInsets()) + .background(Color.themeTyler) } } -struct ThemeLazyListSection: View { - let header: String - let items: [Item] - @ViewBuilder let itemContent: (Item) -> Content - - @Environment(\.themeListStyle) var themeListStyle +struct ListForEach: View { + private let items: [Item] + private let onMove: ((IndexSet, Int) -> Void)? + private let itemContent: (Item) -> Content - var body: some View { - switch themeListStyle { - case .lawrence, .bordered, .transparentInline, .borderedLawrence: - Text("todo") - case .transparent: - Section { - ThemeLazyListSectionContent(items: items, itemContent: itemContent) - } header: { - Text(header) - .themeSubhead1(alignment: .leading) - .textCase(.uppercase) - .padding(.horizontal, .margin16) - .frame(height: 44) - .frame(maxWidth: .infinity) - .background(Color.themeTyler) - } - } + init(_ items: [Item], onMove: ((IndexSet, Int) -> Void)? = nil, @ViewBuilder itemContent: @escaping (Item) -> Content) { + self.items = items + self.onMove = onMove + self.itemContent = itemContent } -} - -struct ThemeLazyListSectionContent: View { - let items: [Item] - @ViewBuilder let itemContent: (Item) -> Content var body: some View { ForEach(items, id: \.self) { item in @@ -109,6 +71,10 @@ struct ThemeLazyListSectionContent: View { HorizontalDivider() } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) } + .onMove(perform: onMove) } } diff --git a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings index cb2859dedc..3da0979379 100644 --- a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings +++ b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings @@ -913,6 +913,14 @@ "market.etf.sort_by.outflow" = "Outflow"; "market.etf.period.all" = "All"; +"market.market_cap.title" = "Total Market Cap"; +"market.market_cap.description" = "Total market value of all cryptocurrencies"; +"market.market_cap.market_cap" = "Market Cap"; + +"market.volume.title" = "Volume"; +"market.volume.description" = "The 24h trading volume of crypto market"; +"market.volume.volume" = "Volume"; + // Coin Page "coin_page.overview" = "Price";