diff --git a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj index 5ac1259b63..4849dcb5d3 100644 --- a/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj +++ b/UnstoppableWallet/UnstoppableWallet.xcodeproj/project.pbxproj @@ -246,7 +246,7 @@ 11B3526AA8E758606BC0CE38 /* CexWithdrawService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3548F0E1223B08D3B7F0C /* CexWithdrawService.swift */; }; 11B3526D1747C11291F2D998 /* CoinTreasuriesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3580ECB328146E94D4359 /* CoinTreasuriesService.swift */; }; 11B3527103D25C72BC849651 /* MarketOverviewTopPairsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B350465C489A233625E8F2 /* MarketOverviewTopPairsDataSource.swift */; }; - 11B352712EC6F2C7F6965443 /* MarketWatchlistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3566FE007887C3528583C /* MarketWatchlistViewModel.swift */; }; + 11B352712EC6F2C7F6965443 /* MarketWatchlistViewModelOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3566FE007887C3528583C /* MarketWatchlistViewModelOld.swift */; }; 11B3527C3BD088DCCA6959C3 /* ModuleUnlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35FF02BBEDAEF446D0610 /* ModuleUnlockView.swift */; }; 11B3527D20636D21F0F45C80 /* CurrentDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35779E6353B98B298FF29 /* CurrentDateProvider.swift */; }; 11B3527F2E2D46DC307E6D3D /* RestoreSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35E343901BA7DE01181CB /* RestoreSettingsViewModel.swift */; }; @@ -1533,7 +1533,7 @@ 11B35FF1D956D812F815FA86 /* NftImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3522A9A7774977CF39A1D /* NftImageView.swift */; }; 11B35FF65FCE441A69822E1C /* InputPrefixWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35743158763BD8E336770 /* InputPrefixWrapperView.swift */; }; 11B35FF681C01782693B3C4A /* SendEvmConfirmationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B354950B1534AD045FDA3A /* SendEvmConfirmationViewController.swift */; }; - 11B35FF6D36153F372C16C32 /* MarketWatchlistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3566FE007887C3528583C /* MarketWatchlistViewModel.swift */; }; + 11B35FF6D36153F372C16C32 /* MarketWatchlistViewModelOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3566FE007887C3528583C /* MarketWatchlistViewModelOld.swift */; }; 11B35FF84A61FFBEC01CE15E /* BlockchainTokensViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B350B29B000CD809F81228 /* BlockchainTokensViewModel.swift */; }; 11B35FFC8C3E4CF638397650 /* UnlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B35D36E5D47264AE07D729 /* UnlockView.swift */; }; 11B35FFD159D864F6D914F08 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B357511F8F17D8221B64E2 /* AppearanceView.swift */; }; @@ -3066,6 +3066,10 @@ D36DE100272FD92F000BC916 /* SwapSelectProviderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36DE0F5272FD92F000BC916 /* SwapSelectProviderViewModel.swift */; }; D36E0C2A28D084AB00B622B9 /* CollectionViewCenteredFlowLayout in Frameworks */ = {isa = PBXBuildFile; productRef = D36E0C2928D084AB00B622B9 /* CollectionViewCenteredFlowLayout */; }; D36E0C2C28D084CB00B622B9 /* CollectionViewCenteredFlowLayout in Frameworks */ = {isa = PBXBuildFile; productRef = D36E0C2B28D084CB00B622B9 /* CollectionViewCenteredFlowLayout */; }; + D3833AD72BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AD62BEE1A7900ACECFB /* MarketWatchlistView.swift */; }; + D3833AD82BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AD62BEE1A7900ACECFB /* MarketWatchlistView.swift */; }; + D3833ADA2BEE1A8300ACECFB /* MarketWatchlistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AD92BEE1A8300ACECFB /* MarketWatchlistViewModel.swift */; }; + D3833ADB2BEE1A8300ACECFB /* MarketWatchlistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3833AD92BEE1A8300ACECFB /* MarketWatchlistViewModel.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 */; }; @@ -3635,7 +3639,7 @@ 11B356671FA76C7DEDA50B94 /* SwapApproveConfirmationModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapApproveConfirmationModule.swift; sourceTree = ""; }; 11B3566B18FBFBA85D98D824 /* EnabledWalletCacheManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnabledWalletCacheManager.swift; sourceTree = ""; }; 11B3566DC3A97A5CC3E2C729 /* BalancePrimaryValueManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalancePrimaryValueManager.swift; sourceTree = ""; }; - 11B3566FE007887C3528583C /* MarketWatchlistViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarketWatchlistViewModel.swift; sourceTree = ""; }; + 11B3566FE007887C3528583C /* MarketWatchlistViewModelOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarketWatchlistViewModelOld.swift; sourceTree = ""; }; 11B3567314F1A1DF8D1B2910 /* TransactionFilterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionFilterView.swift; sourceTree = ""; }; 11B356861F703A5A5C6630B6 /* LitecoinAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LitecoinAdapter.swift; sourceTree = ""; }; 11B3568F6FAF721301DEC188 /* FormCautionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FormCautionView.swift; sourceTree = ""; }; @@ -4889,6 +4893,8 @@ D36DE0F3272FD92E000BC916 /* SwapSelectProviderModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapSelectProviderModule.swift; sourceTree = ""; }; D36DE0F4272FD92F000BC916 /* SwapSelectProviderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapSelectProviderService.swift; sourceTree = ""; }; D36DE0F5272FD92F000BC916 /* SwapSelectProviderViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwapSelectProviderViewModel.swift; sourceTree = ""; }; + D3833AD62BEE1A7900ACECFB /* MarketWatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketWatchlistView.swift; sourceTree = ""; }; + D3833AD92BEE1A8300ACECFB /* MarketWatchlistViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketWatchlistViewModel.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 = ""; }; @@ -7670,6 +7676,7 @@ 58AAA9EB9618EBC895D0B123 /* Market */ = { isa = PBXGroup; children = ( + D3833AD52BEE1A2900ACECFB /* Watchlist */, D3DB51AD2BD7A9740091BBDB /* Coins */, 11B35AAFE626B8D1806D8960 /* MarketList */, 58AAA5C5DA041F3A46A6B241 /* MarketOverview */, @@ -7732,7 +7739,7 @@ children = ( 58AAA7D27615D192FBC5486E /* MarketWatchlistModule.swift */, 58AAABDFE887324FC10AC290 /* MarketWatchlistService.swift */, - 11B3566FE007887C3528583C /* MarketWatchlistViewModel.swift */, + 11B3566FE007887C3528583C /* MarketWatchlistViewModelOld.swift */, 58AAAA1B62A6A1A278BE06AA /* MarketWatchlistViewController.swift */, ABC9A8B6A5C590B23C6F83C3 /* MarketWatchlistDecorator.swift */, ); @@ -9228,6 +9235,15 @@ path = UnstoppableWallet; sourceTree = ""; }; + D3833AD52BEE1A2900ACECFB /* Watchlist */ = { + isa = PBXGroup; + children = ( + D3833AD62BEE1A7900ACECFB /* MarketWatchlistView.swift */, + D3833AD92BEE1A8300ACECFB /* MarketWatchlistViewModel.swift */, + ); + path = Watchlist; + sourceTree = ""; + }; D3948EF52ADA846400FAE566 /* Widget */ = { isa = PBXGroup; children = ( @@ -10200,7 +10216,7 @@ 6B2907262AF0CB8A006157D6 /* WalletConnectAppShowView.swift in Sources */, 11B35A5A820C1BCC1A92E944 /* MarketTopViewController.swift in Sources */, 11B35A8BB87C68ACF4594C99 /* MarketTopModule.swift in Sources */, - 11B35FF6D36153F372C16C32 /* MarketWatchlistViewModel.swift in Sources */, + 11B35FF6D36153F372C16C32 /* MarketWatchlistViewModelOld.swift in Sources */, 58AAA488935A7DE6CF7C592D /* MarketGlobalMetricService.swift in Sources */, 58AAA7B0CC093B05F7487496 /* MarketGlobalMetricModule.swift in Sources */, 58AAA937A06DD40BD9A64C71 /* MarketGlobalMetricViewController.swift in Sources */, @@ -10612,6 +10628,7 @@ 2FA5D61F4FA6E818D25C4A96 /* EvmSendSettingsViewModel.swift in Sources */, 2FA5D201AED8FB83968A5220 /* StepperAmountInputView.swift in Sources */, 2FA5D069D16C6119C970EDF1 /* StepperAmountInputCell.swift in Sources */, + D3833ADB2BEE1A8300ACECFB /* MarketWatchlistViewModel.swift in Sources */, 2FA5D4341C017BB619D745A2 /* EvmCommonGasDataService.swift in Sources */, 2FA5DDDE9097C0CD9C0F5BD5 /* EvmFeeModule.swift in Sources */, 2FA5DF34B11401B29082BBD2 /* EvmFeeService.swift in Sources */, @@ -10701,6 +10718,7 @@ 11B35EA628D9401F5C3A9CB8 /* NftKit.swift in Sources */, 11B35BC602EA104EE1C0540C /* BinanceChainKit.swift in Sources */, 11B35DE5BD5716307300AD2F /* OneInchKit.swift in Sources */, + D3833AD82BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */, ABC9ADDC1F55F835C68DB4C7 /* UniswapV3Provider.swift in Sources */, D0F132A22B6B98E100C7310E /* RbfService.swift in Sources */, ABC9A3CC73251E7F83A94181 /* UniswapV3TradeService.swift in Sources */, @@ -11746,7 +11764,7 @@ 11B3504029EB87A32DB63666 /* MarketTopService.swift in Sources */, 11B35A426FD3D729DEB89DEA /* MarketTopViewController.swift in Sources */, 11B357405174FF9F9BEB3704 /* MarketTopModule.swift in Sources */, - 11B352712EC6F2C7F6965443 /* MarketWatchlistViewModel.swift in Sources */, + 11B352712EC6F2C7F6965443 /* MarketWatchlistViewModelOld.swift in Sources */, 58AAA2960B54658E2614D72E /* MarketGlobalMetricService.swift in Sources */, 58AAA996622FCD647B51A3C5 /* MarketGlobalMetricModule.swift in Sources */, 58AAA5C000029E9EB74C46C4 /* MarketGlobalMetricViewController.swift in Sources */, @@ -12154,6 +12172,7 @@ 2FA5DBD32AA258A75A2FDD95 /* EvmSendSettingsService.swift in Sources */, 2FA5D7C86A2A55B906953B76 /* EvmSendSettingsViewController.swift in Sources */, 2FA5DE6911CB4127CAB11F35 /* EvmSendSettingsViewModel.swift in Sources */, + D3833ADA2BEE1A8300ACECFB /* MarketWatchlistViewModel.swift in Sources */, 2FA5DF3D135C616A61B37292 /* StepperAmountInputView.swift in Sources */, 2FA5D9A3C943976E38293621 /* StepperAmountInputCell.swift in Sources */, 2FA5D6CF72BEC8CF1AEE3F9D /* EvmCommonGasDataService.swift in Sources */, @@ -12243,6 +12262,7 @@ 11B355734D16C412220BBEBD /* NftKit.swift in Sources */, 11B35146CA9BE897C858AB73 /* BinanceChainKit.swift in Sources */, 11B352309B81355B88BF6B66 /* OneInchKit.swift in Sources */, + D3833AD72BEE1A7900ACECFB /* MarketWatchlistView.swift in Sources */, ABC9A7E1F93B0A85976C826D /* UniswapV3Provider.swift in Sources */, ABC9AC900545DC0DD2201DEE /* UniswapV3TradeService.swift in Sources */, ABC9ACCD1ED14FA216AF1E65 /* UniswapV3Service.swift in Sources */, diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift index 33fa8e812a..ad8074c142 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsView.swift @@ -13,83 +13,20 @@ struct MarketCoinsView: View { var body: some View { ThemeView { - VStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Button(action: { - sortBySelectorPresented = true - }) { - Text(viewModel.sortBy.title) - } - .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) - - Button(action: { - topSelectorPresented = true - }) { - Text(viewModel.top.title) - } - .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) - - Button(action: { - priceChangePeriodSelectorPresented = true - }) { - Text(viewModel.priceChangePeriod.shortTitle) - } - .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) - } - .padding(.horizontal, .margin16) - .padding(.vertical, .margin8) + switch viewModel.state { + case .loading: + loadingList() + case let .loaded(marketInfos): + VStack(spacing: 0) { + header() + list(marketInfos: marketInfos) } - .alert( - isPresented: $sortBySelectorPresented, - title: "market.sort_by.title".localized, - viewItems: MarketModule.SortBy.allCases.map { .init(text: $0.title, selected: viewModel.sortBy == $0) }, - onTap: { index in - guard let index else { - return - } - - viewModel.sortBy = MarketModule.SortBy.allCases[index] - } - ) - .alert( - isPresented: $topSelectorPresented, - title: "market.top_coins.title".localized, - viewItems: viewModel.tops.map { .init(text: $0.title, selected: viewModel.top == $0) }, - onTap: { index in - guard let index else { - return - } - - viewModel.top = viewModel.tops[index] - } - ) - .alert( - isPresented: $priceChangePeriodSelectorPresented, - title: "market.price_change_period.title".localized, - viewItems: viewModel.priceChangePeriods.map { .init(text: $0.shortTitle, selected: viewModel.priceChangePeriod == $0) }, - onTap: { index in - guard let index else { - return - } - - viewModel.priceChangePeriod = viewModel.priceChangePeriods[index] - } - ) - - ZStack { - switch viewModel.state { - case .loading: - ProgressView() - case let .loaded(marketInfos): - list(marketInfos: marketInfos) - case .failed: - SyncErrorView { - // viewModel.onRetry() - } + case .failed: + SyncErrorView { + Task { + await viewModel.refresh() } } - .frame(maxHeight: .infinity) } } .sheet(item: $presentedFullCoin) { fullCoin in @@ -97,6 +34,71 @@ struct MarketCoinsView: View { } } + @ViewBuilder private func header() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: { + sortBySelectorPresented = true + }) { + Text(viewModel.sortBy.title) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + + Button(action: { + topSelectorPresented = true + }) { + Text(viewModel.top.title) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + + Button(action: { + priceChangePeriodSelectorPresented = true + }) { + Text(viewModel.priceChangePeriod.shortTitle) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + } + .padding(.horizontal, .margin16) + .padding(.vertical, .margin8) + } + .alert( + isPresented: $sortBySelectorPresented, + title: "market.sort_by.title".localized, + viewItems: viewModel.sortBys.map { .init(text: $0.title, selected: viewModel.sortBy == $0) }, + onTap: { index in + guard let index else { + return + } + + viewModel.sortBy = viewModel.sortBys[index] + } + ) + .alert( + isPresented: $topSelectorPresented, + title: "market.top_coins.title".localized, + viewItems: viewModel.tops.map { .init(text: $0.title, selected: viewModel.top == $0) }, + onTap: { index in + guard let index else { + return + } + + viewModel.top = viewModel.tops[index] + } + ) + .alert( + isPresented: $priceChangePeriodSelectorPresented, + title: "market.price_change_period.title".localized, + viewItems: viewModel.priceChangePeriods.map { .init(text: $0.shortTitle, selected: viewModel.priceChangePeriod == $0) }, + onTap: { index in + guard let index else { + return + } + + viewModel.priceChangePeriod = viewModel.priceChangePeriods[index] + } + ) + } + @ViewBuilder private func list(marketInfos: [MarketInfo]) -> some View { ScrollViewReader { _ in ThemeList(items: marketInfos) { marketInfo in @@ -137,4 +139,34 @@ struct MarketCoinsView: View { } } } + + @ViewBuilder private func loadingList() -> some View { + ThemeList(items: Array(0 ... 10)) { _ 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) + } + + 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) + } + } + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsViewModel.swift index aa6639ed4f..279c03c058 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Coins/MarketCoinsViewModel.swift @@ -35,8 +35,6 @@ class MarketCoinsViewModel: ObservableObject { } } - init() {} - private func syncMarketInfos() { tasks = Set() @@ -83,6 +81,10 @@ extension MarketCoinsViewModel { currencyManager.baseCurrency } + var sortBys: [MarketModule.SortBy] { + [.highestCap, .lowestCap, .gainers, .losers, .highestVolume, .lowestVolume] + } + var tops: [MarketModule.Top] { [.top100, .top200, .top300, .top500] } @@ -91,11 +93,8 @@ extension MarketCoinsViewModel { [.hour24, .week1, .month1, .month3] } - func sync() { - switch state { - case .failed, .loading: syncMarketInfos() - default: () - } + func load() { + syncMarketInfos() } func refresh() async { diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift index 820fff320a..c2b4439927 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketModule.swift @@ -142,6 +142,7 @@ enum MarketModule { extension MarketModule { enum SortBy: String, CaseIterable { + case manual case highestCap case lowestCap case gainers @@ -151,6 +152,7 @@ extension MarketModule { var title: String { switch self { + case .manual: return "market.sort_by.manual".localized case .highestCap: return "market.sort_by.highest_cap".localized case .lowestCap: return "market.sort_by.lowest_cap".localized case .gainers: return "market.sort_by.gainers".localized @@ -385,6 +387,7 @@ extension [MarketKit.MarketInfo] { } return sortBy == .gainers ? lhsPriceChange > rhsPriceChange : lhsPriceChange < rhsPriceChange + default: return true } } } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift index a99cd3fb9a..1d0360c865 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketTabView.swift @@ -3,11 +3,14 @@ import ThemeKit struct MarketTabView: View { @StateObject var coinsViewModel: MarketCoinsViewModel + @StateObject var watchlistViewModel: MarketWatchlistViewModel @State private var currentTabIndex: Int = Tab.coins.rawValue + @State private var loadedTabs = [Tab]() init() { _coinsViewModel = StateObject(wrappedValue: MarketCoinsViewModel()) + _watchlistViewModel = StateObject(wrappedValue: MarketWatchlistViewModel()) } var body: some View { @@ -18,12 +21,13 @@ struct MarketTabView: View { ) TabView(selection: $currentTabIndex) { - Text("News Content").tag(Tab.news.rawValue) - MarketCoinsView(viewModel: coinsViewModel) .tag(Tab.coins.rawValue) - Text("Watchlist Content").tag(Tab.watchlist.rawValue) + MarketWatchlistView(viewModel: watchlistViewModel) + .tag(Tab.watchlist.rawValue) + + Text("News Content").tag(Tab.news.rawValue) Text("Platforms Content").tag(Tab.platforms.rawValue) Text("Pairs Content").tag(Tab.pairs.rawValue) Text("Sectors Content").tag(Tab.sectors.rawValue) @@ -44,10 +48,16 @@ struct MarketTabView: View { return } + guard !loadedTabs.contains(tab) else { + return + } + + loadedTabs.append(tab) + switch tab { + case .coins: coinsViewModel.load() + case .watchlist: watchlistViewModel.load() case .news: () - case .coins: coinsViewModel.sync() - case .watchlist: () case .platforms: () case .pairs: () case .sectors: () @@ -57,18 +67,18 @@ struct MarketTabView: View { extension MarketTabView { enum Tab: Int, CaseIterable { - case news case coins case watchlist + case news case platforms case pairs case sectors var title: String { switch self { - case .news: return "market.tab.news".localized case .coins: return "market.tab.coins".localized case .watchlist: return "market.tab.watchlist".localized + 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 diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistModule.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistModule.swift index 20227bfaec..0a499f1565 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistModule.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistModule.swift @@ -16,7 +16,7 @@ enum MarketWatchlistModule { ) let decorator = MarketWatchlistDecorator(service: service) - let viewModel = MarketWatchlistViewModel(service: service) + let viewModel = MarketWatchlistViewModelOld(service: service) let headerViewModel = MarketSingleSortHeaderViewModel(service: service, decorator: decorator) let listViewModel = MarketListWatchViewModel(service: service, watchlistToggleService: watchlistToggleService, decorator: decorator) diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewController.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewController.swift index cf646b41c0..7e8341ef48 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewController.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewController.swift @@ -6,7 +6,7 @@ import UIKit class MarketWatchlistViewController: MarketListViewController { weak var parentNavigationController: UINavigationController? - private let viewModel: MarketWatchlistViewModel + private let viewModel: MarketWatchlistViewModelOld private let singleSortHeaderView: MarketSingleSortHeaderView private let placeholderView = PlaceholderView() @@ -15,7 +15,7 @@ class MarketWatchlistViewController: MarketListViewController { override var headerView: UITableViewHeaderFooterView? { singleSortHeaderView } override var emptyView: UIView? { placeholderView } - init(viewModel: MarketWatchlistViewModel, listViewModel: IMarketListViewModel, headerViewModel: MarketSingleSortHeaderViewModel) { + init(viewModel: MarketWatchlistViewModelOld, listViewModel: IMarketListViewModel, headerViewModel: MarketSingleSortHeaderViewModel) { self.viewModel = viewModel singleSortHeaderView = MarketSingleSortHeaderView(viewModel: headerViewModel, hasTopSeparator: false) diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModelOld.swift similarity index 70% rename from UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModel.swift rename to UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModelOld.swift index f609521b6b..5f319d5086 100644 --- a/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModel.swift +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/MarketWatchlist/MarketWatchlistViewModelOld.swift @@ -1,4 +1,4 @@ -class MarketWatchlistViewModel { +class MarketWatchlistViewModelOld { private let service: MarketWatchlistService init(service: MarketWatchlistService) { @@ -6,7 +6,7 @@ class MarketWatchlistViewModel { } } -extension MarketWatchlistViewModel { +extension MarketWatchlistViewModelOld { func onLoad() { service.load() } diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift new file mode 100644 index 0000000000..a5bb6c72bf --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistView.swift @@ -0,0 +1,170 @@ +import Kingfisher +import MarketKit +import SwiftUI + +struct MarketWatchlistView: View { + @ObservedObject var viewModel: MarketWatchlistViewModel + + @State private var sortBySelectorPresented = false + @State private var priceChangePeriodSelectorPresented = false + + @State private var presentedFullCoin: FullCoin? + + var body: some View { + ThemeView { + switch viewModel.state { + case .loading: + loadingList() + case let .loaded(marketInfos): + if marketInfos.isEmpty { + PlaceholderViewNew(image: Image("rate_48"), text: "market.watchlist.empty".localized) + } else { + VStack(spacing: 0) { + header() + list(marketInfos: marketInfos) + } + } + case .failed: + SyncErrorView { + Task { + await viewModel.refresh() + } + } + } + } + .sheet(item: $presentedFullCoin) { fullCoin in + CoinPageViewNew(coinUid: fullCoin.coin.uid) + } + } + + @ViewBuilder private func header() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: { + sortBySelectorPresented = true + }) { + Text(viewModel.sortBy.title) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + + Button(action: { + priceChangePeriodSelectorPresented = true + }) { + Text(viewModel.priceChangePeriod.shortTitle) + } + .buttonStyle(SecondaryButtonStyle(style: .default, rightAccessory: .dropDown)) + + if viewModel.showSignals { + signalsButton().buttonStyle(SecondaryActiveButtonStyle()) + } else { + signalsButton().buttonStyle(SecondaryButtonStyle()) + } + } + .padding(.horizontal, .margin16) + .padding(.vertical, .margin8) + } + .alert( + isPresented: $sortBySelectorPresented, + title: "market.sort_by.title".localized, + viewItems: viewModel.sortBys.map { .init(text: $0.title, selected: viewModel.sortBy == $0) }, + onTap: { index in + guard let index else { + return + } + + viewModel.sortBy = viewModel.sortBys[index] + } + ) + .alert( + isPresented: $priceChangePeriodSelectorPresented, + title: "market.price_change_period.title".localized, + viewItems: viewModel.priceChangePeriods.map { .init(text: $0.shortTitle, selected: viewModel.priceChangePeriod == $0) }, + onTap: { index in + guard let index else { + return + } + + viewModel.priceChangePeriod = viewModel.priceChangePeriods[index] + } + ) + } + + @ViewBuilder private func signalsButton() -> some View { + Button(action: { + viewModel.showSignals.toggle() + }) { + Text("market.watchlist.signals".localized) + } + } + + @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(period: viewModel.priceChangePeriod)) + } + } + } + } + .themeListStyle(.transparent) + .refreshable { + await viewModel.refresh() + } + } + } + + @ViewBuilder private func loadingList() -> some View { + ThemeList(items: Array(0 ... 10)) { _ 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) + } + + 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) + } + } + } + } + .themeListStyle(.transparent) + .simultaneousGesture(DragGesture(minimumDistance: 0), including: .all) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift new file mode 100644 index 0000000000..6736115cde --- /dev/null +++ b/UnstoppableWallet/UnstoppableWallet/Modules/Market/Watchlist/MarketWatchlistViewModel.swift @@ -0,0 +1,144 @@ +import Combine +import Foundation +import HsExtensions +import MarketKit +import RxSwift + +class MarketWatchlistViewModel: ObservableObject { + private let marketKit = App.shared.marketKit + private let currencyManager = App.shared.currencyManager + private let favoritesManager = App.shared.favoritesManager + + private let disposeBag = DisposeBag() + private var cancellables = Set() + private var tasks = Set() + + private var coinUids = [String]() + + private var internalState: State = .loading { + didSet { + syncState() + } + } + + @Published var state: State = .loading + + var sortBy: MarketModule.SortBy = .gainers { + didSet { + syncState() + } + } + + var priceChangePeriod: MarketModule.PriceChangePeriod = .hour24 { + didSet { + syncState() + } + } + + var showSignals: Bool = false { + didSet { + syncState() + } + } + + private func syncCoinUids() { + coinUids = favoritesManager.allCoinUids + + if case let .loaded(marketInfos) = internalState { + let newMarketInfos = marketInfos.filter { marketInfo in + coinUids.contains(marketInfo.fullCoin.coin.uid) + } + + if newMarketInfos.count == coinUids.count { + internalState = .loaded(marketInfos: newMarketInfos) + return + } + } + + syncMarketInfos() + } + + private func syncMarketInfos() { + tasks = Set() + + Task { [weak self] in + await self?._syncMarketInfos() + }.store(in: &tasks) + } + + private func _syncMarketInfos() async { + if coinUids.isEmpty { + await MainActor.run { [weak self] in + self?.internalState = .loaded(marketInfos: []) + } + return + } + + if case .failed = internalState { + await MainActor.run { [weak self] in + self?.internalState = .loading + } + } + + do { + let marketInfos = try await marketKit.marketInfos(coinUids: coinUids, 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) + } + } + } + + private func syncState() { + switch internalState { + case .loading: + state = .loading + case let .loaded(marketInfos): + state = .loaded(marketInfos: marketInfos.sorted(sortBy: sortBy, priceChangePeriod: priceChangePeriod)) + case let .failed(error): + state = .failed(error: error) + } + } +} + +extension MarketWatchlistViewModel { + var currency: Currency { + currencyManager.baseCurrency + } + + var sortBys: [MarketModule.SortBy] { + [.manual, .highestCap, .lowestCap, .gainers, .losers, .highestVolume, .lowestVolume] + } + + var priceChangePeriods: [MarketModule.PriceChangePeriod] { + [.hour24, .week1, .month1, .month3] + } + + func load() { + currencyManager.$baseCurrency + .sink { [weak self] _ in + self?.syncMarketInfos() + } + .store(in: &cancellables) + + subscribe(disposeBag, favoritesManager.coinUidsUpdatedObservable) { [weak self] in self?.syncCoinUids() } + + syncCoinUids() + } + + func refresh() async { + await _syncMarketInfos() + } +} + +extension MarketWatchlistViewModel { + enum State { + case loading + case loaded(marketInfos: [MarketInfo]) + case failed(error: Error) + } +} diff --git a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings index e0452f7fb2..338e302ea2 100644 --- a/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings +++ b/UnstoppableWallet/UnstoppableWallet/en.lproj/Localizable.strings @@ -705,6 +705,7 @@ "market.tab.sectors" = "Sectors"; "market.sort_by.title" = "Sort By"; +"market.sort_by.manual" = "Manual"; "market.sort_by.highest_cap" = "Highest Cap"; "market.sort_by.lowest_cap" = "Lowest Cap"; "market.sort_by.gainers" = "Gainers"; @@ -786,6 +787,8 @@ "market_discovery.not_found" = "No results found"; "market_watchlist.empty.caption" = "Your watchlist is empty."; +"market.watchlist.signals" = "Signals"; +"market.watchlist.empty" = "Your watchlist is empty"; "market.advanced_search.title" = "Filters"; "market.advanced_search.show_results" = "Show Results";