From 5ab861dc12ed874f7b42533e91448011a2d36f94 Mon Sep 17 00:00:00 2001 From: Andrei Ashikhmin Date: Mon, 16 Sep 2024 13:38:42 +0200 Subject: [PATCH] feat: coinjoin wallet integration & mixing info --- DashSyncCurrentCommit | 2 +- DashWallet.xcodeproj/project.pbxproj | 26 ++ .../Models/CoinJoin/CoinJoinService.swift | 298 ++++++++++++++++++ .../CoinJoinLevelsViewController.swift | 26 +- .../DashPay/CoinJoin/CoinJoinViewModel.swift | 56 ++-- .../Views/Cells/CoinJoinProgressView.swift | 98 ++++++ .../Sources/UI/Home/Views/HomeView.swift | 18 +- .../Sources/UI/Home/Views/HomeViewModel.swift | 74 ++++- .../Sources/UI/Menu/MenuItemModel.swift | 20 +- .../Settings/SettingsMenuViewController.swift | 154 +++------ .../UI/Menu/Settings/SettingsViewModel.swift | 134 ++++++++ .../UI/SwiftUI Components/MenuItem.swift | 4 +- DashWallet/dashwallet-Bridging-Header.h | 4 +- 13 files changed, 762 insertions(+), 152 deletions(-) create mode 100644 DashWallet/Sources/Models/CoinJoin/CoinJoinService.swift create mode 100644 DashWallet/Sources/UI/Home/Views/Cells/CoinJoinProgressView.swift create mode 100644 DashWallet/Sources/UI/Menu/Settings/SettingsViewModel.swift diff --git a/DashSyncCurrentCommit b/DashSyncCurrentCommit index 530482057..8de791cf2 100644 --- a/DashSyncCurrentCommit +++ b/DashSyncCurrentCommit @@ -1 +1 @@ -01e0c602fdb67517b9bb562fabbf8344f3aca636 +315879b4d157a026fc760d3742f6a55a8883fde5 diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 8e8f52308..3ca502a2e 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -520,6 +520,12 @@ 47FA3AFF29350929008D58DC /* SyncingActivityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FA3AFE29350929008D58DC /* SyncingActivityMonitor.swift */; }; 47FA3B0229364991008D58DC /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FA3B0129364991008D58DC /* HTTPClient.swift */; }; 7502A4872AE401EF00ACDDD3 /* UsernameVotingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7502A4862AE401EF00ACDDD3 /* UsernameVotingViewController.swift */; }; + 7503643A2C89CFB70029EC0D /* CoinJoinProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750364392C89CFB70029EC0D /* CoinJoinProgressView.swift */; }; + 7503643B2C89CFB70029EC0D /* CoinJoinProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750364392C89CFB70029EC0D /* CoinJoinProgressView.swift */; }; + 7503643E2C89D49A0029EC0D /* CoinJoinService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7503643D2C89D49A0029EC0D /* CoinJoinService.swift */; }; + 7503643F2C89D49A0029EC0D /* CoinJoinService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7503643D2C89D49A0029EC0D /* CoinJoinService.swift */; }; + 750CED602C94BFD7000FB837 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CED5F2C94BFD7000FB837 /* SettingsViewModel.swift */; }; + 750CED612C94BFD7000FB837 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CED5F2C94BFD7000FB837 /* SettingsViewModel.swift */; }; 7513DA882AB175E0005D55F6 /* TopperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527720E2AA9F58E0066557E /* TopperViewModel.swift */; }; 7513DA892AB17606005D55F6 /* Topper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E2F3C92AA4D1B900C3B458 /* Topper.swift */; }; 7513DA8A2AB17666005D55F6 /* SupportedTopperAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527720C2AA9B2630066557E /* SupportedTopperAssets.swift */; }; @@ -2405,9 +2411,12 @@ 5FD4C91FB8EAB529E8E41227 /* Pods-dashwallet no watch.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dashwallet no watch.testnet.xcconfig"; path = "Pods/Target Support Files/Pods-dashwallet no watch/Pods-dashwallet no watch.testnet.xcconfig"; sourceTree = ""; }; 6FBBFC90577C940D8C04E0B1 /* Pods-DashWalletScreenshotsUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletScreenshotsUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-DashWalletScreenshotsUITests/Pods-DashWalletScreenshotsUITests.debug.xcconfig"; sourceTree = ""; }; 7502A4862AE401EF00ACDDD3 /* UsernameVotingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameVotingViewController.swift; sourceTree = ""; }; + 750364392C89CFB70029EC0D /* CoinJoinProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinProgressView.swift; sourceTree = ""; }; + 7503643D2C89D49A0029EC0D /* CoinJoinService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinService.swift; sourceTree = ""; }; 7509C10E1AF3076100D03FD5 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7509C1121AF3720100D03FD5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 750C6CC01B5C8EB60038AAE9 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; + 750CED5F2C94BFD7000FB837 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 7511E8CB1AE5FF240025F1B3 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; lineEnding = 0; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.simpleColoring; }; 7511E8CF1AE5FF2D0025F1B3 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7511E8D31AE5FF390025F1B3 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; @@ -3933,6 +3942,7 @@ 2A44313D22CF632F009BAF7F /* Models */ = { isa = PBXGroup; children = ( + 7503643C2C89D4890029EC0D /* CoinJoin */, 75A8C1612AE571E30042256E /* Voting */, 47081194298CF1E3003FCA3D /* Transactions */, 11BD737F28E7354200A34022 /* CrowdNode */, @@ -4033,6 +4043,7 @@ isa = PBXGroup; children = ( C9F451F22A0C933700825057 /* SyncingHeaderView.swift */, + 750364392C89CFB70029EC0D /* CoinJoinProgressView.swift */, ); path = Cells; sourceTree = ""; @@ -4193,6 +4204,7 @@ 2A7A7BD52348CB6600451078 /* SettingsMenuViewController.swift */, 2A7A7BD72348CB7300451078 /* DWSettingsMenuModel.h */, 2A7A7BD82348CB7300451078 /* DWSettingsMenuModel.m */, + 750CED5F2C94BFD7000FB837 /* SettingsViewModel.swift */, ); path = Settings; sourceTree = ""; @@ -5997,6 +6009,14 @@ path = Voting; sourceTree = ""; }; + 7503643C2C89D4890029EC0D /* CoinJoin */ = { + isa = PBXGroup; + children = ( + 7503643D2C89D49A0029EC0D /* CoinJoinService.swift */, + ); + path = CoinJoin; + sourceTree = ""; + }; 7531308F2B47EE480069C9B7 /* Model */ = { isa = PBXGroup; children = ( @@ -8482,6 +8502,7 @@ 75FFD6BB2BF48DF80032879E /* HomeViewController+JailbreakCheck.swift in Sources */, 477F50102950A55A003C7508 /* Coinbase+Error.swift in Sources */, 2A63003F2327B4BB00827825 /* DWPaymentOutput+DWView.m in Sources */, + 750CED602C94BFD7000FB837 /* SettingsViewModel.swift in Sources */, 2ACD53EE234C9D8E00650AD3 /* UIView+DWRecursiveSubview.m in Sources */, 11860923297598B400279FCC /* AddressStatus.swift in Sources */, C91E919729FBACE6003E7883 /* ExtendedPublicKeysModel.swift in Sources */, @@ -8814,6 +8835,7 @@ 472D13E3299E23B7006903F1 /* BalanceNotifier.swift in Sources */, 2AD1CE6422D9127600C99324 /* DWSeedWordModel.m in Sources */, 7592AA7C2B9B08C000417F9E /* SupportedTopperPaymentMethods.swift in Sources */, + 7503643E2C89D49A0029EC0D /* CoinJoinService.swift in Sources */, 75AA33CC2BF9C82700F12465 /* ModalDialog.swift in Sources */, 2A44314022CF642C009BAF7F /* DWRootModel.m in Sources */, 47C661AF28FDAA3400028A8D /* BaseAmountViewController.swift in Sources */, @@ -8901,6 +8923,7 @@ C909615B29F6535300002D82 /* DerivationPathKeysHeaderView.swift in Sources */, 4751CAD02970224D00F63AC4 /* ConvertCryptoOrderPreviewModel.swift in Sources */, 47A2E3A92972B15F0032A63B /* RatesProvider.swift in Sources */, + 7503643A2C89CFB70029EC0D /* CoinJoinProgressView.swift in Sources */, 47A2A2E9293E612900938DB7 /* CBAuth.swift in Sources */, 7566F48A2BB6CAF2005238D2 /* MenuItem.swift in Sources */, 0F6EDFD128C896BD000427E7 /* CoinbaseCreateAddressesRequest.swift in Sources */, @@ -9046,6 +9069,7 @@ C9D2C69A2A320AA000D15901 /* CrowdNodeAPI.swift in Sources */, C9D2C69B2A320AA000D15901 /* DWStartModel.m in Sources */, 754495DD2AE91B6300492817 /* GroupedRequestCell.swift in Sources */, + 7503643B2C89CFB70029EC0D /* CoinJoinProgressView.swift in Sources */, C9D2C69C2A320AA000D15901 /* DWAdvancedSecurityModelStub.m in Sources */, C9D2C69D2A320AA000D15901 /* Foundation+Bitcoin.swift in Sources */, C9D2C69E2A320AA000D15901 /* AtmDetailsView.swift in Sources */, @@ -9213,6 +9237,7 @@ C9D2C7152A320AA000D15901 /* DWBasePayViewController.m in Sources */, C943B4AC2A40A54600AF23C5 /* DWContactsViewController.m in Sources */, C9D2C7162A320AA000D15901 /* CrowdNodeTransferModel.swift in Sources */, + 750CED612C94BFD7000FB837 /* SettingsViewModel.swift in Sources */, C9D2C7172A320AA000D15901 /* DWSetPinViewController.m in Sources */, C943B3322A408CED00AF23C5 /* DWProfileDisplayNameCellModel.m in Sources */, C9D2C7182A320AA000D15901 /* ConfirmOrderController.swift in Sources */, @@ -9469,6 +9494,7 @@ C9D2C7D12A320AA000D15901 /* DWMainMenuContentView.m in Sources */, C9D2C7D32A320AA000D15901 /* CrowdNodeAPIConfirmationTx.swift in Sources */, C9D2C7D42A320AA000D15901 /* UIViewController+DWEmbedding.m in Sources */, + 7503643F2C89D49A0029EC0D /* CoinJoinService.swift in Sources */, C9D2C7D62A320AA000D15901 /* CALayer+DWShadow.m in Sources */, C9D2C7D72A320AA000D15901 /* MerchantDAO.swift in Sources */, C9D2C7D82A320AA000D15901 /* ExploreDatabaseSyncManager.swift in Sources */, diff --git a/DashWallet/Sources/Models/CoinJoin/CoinJoinService.swift b/DashWallet/Sources/Models/CoinJoin/CoinJoinService.swift new file mode 100644 index 000000000..500d1376b --- /dev/null +++ b/DashWallet/Sources/Models/CoinJoin/CoinJoinService.swift @@ -0,0 +1,298 @@ +// +// Created by Andrei Ashikhmin +// Copyright © 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +enum MixingStatus: Int { + case notStarted + case mixing + case paused + case finished + case error + + var isInProgress: Bool { + get { + return self == .mixing || self == .paused || self == .error + } + } + + var localizedValue: String { + get { + switch self { + case .notStarted: + NSLocalizedString("Not started", comment: "CoinJoin") + case .mixing: + NSLocalizedString("Mixing ·", comment: "CoinJoin") + case .paused: + NSLocalizedString("Mixing Paused ·", comment: "CoinJoin") + case .finished: + NSLocalizedString("Fully mixed", comment: "CoinJoin") + case .error: + NSLocalizedString("Error ·", comment: "CoinJoin") + } + } + } +} + +enum CoinJoinMode { + case none + case intermediate + case advanced +} + +private let kDefaultMultisession = false // for stability, need to investigate +private let kDefaultRounds: Int32 = 1 //4 TODO +private let kDefaultSessions: Int32 = 1 //6 TODO +private let kDefaultDenominationGoal: Int32 = 50 +private let kDefaultDenominationHardcap: Int32 = 300 +private let kCoinJoinMode = "coinJoinModeKey" + +class CoinJoinService: NSObject { + static let shared: CoinJoinService = { + return CoinJoinService() + }() + + private var cancellableBag = Set() + private let updateMutex = NSLock() + private let updateMixingStateMutex = NSLock() + private var coinJoinManager: DSCoinJoinManager? = nil + private var hasAnonymizableBalance: Bool = false + private var networkStatus: NetworkStatus = .online + + @Published private(set) var mode: CoinJoinMode = .none + @Published var mixingState: MixingStatus = .notStarted + @Published private(set) var progress: Double = 0.0 + @Published private(set) var totalBalance: UInt64 = 0 + @Published private(set) var coinJoinBalance: UInt64 = 0 + @Published private(set) var activeSessions: Int = 0 + + override init() { + super.init() + NotificationCenter.default.publisher(for: NSNotification.Name.DSWalletBalanceDidChange) + .sink { [weak self] _ in self?.updateBalance(balance: DWEnvironment.sharedInstance().currentAccount.balance) } + .store(in: &cancellableBag) + } + + func updateMode(mode: CoinJoinMode) { + self.coinJoinManager?.updateOptions(withEnabled: mode != .none) + let account = DWEnvironment.sharedInstance().currentAccount + let balance = account.balance + + if (mode != .none && self.mode == .none) { + configureMixing(amount: balance) + } + + updateBalance(balance: balance) + // TODO: timeskew + updateState(balance: balance, mode: mode, timeSkew: TimeInterval(0), hasAnonymizableBalance: self.hasAnonymizableBalance, networkStatus: self.networkStatus, chain: DWEnvironment.sharedInstance().currentChain) + } + + private func prepareMixing() { + guard let coinJoinManager = self.coinJoinManager ?? createCoinJoinManager() else { return } + + coinJoinManager.setStopOnNothingToDo(true) + coinJoinManager.start() + } + + private func startMixing() { + guard let coinJoinManager = self.coinJoinManager else { return } + + if !coinJoinManager.startMixing() { + print("[SW] CoinJoin: Mixing has been started already.") + } else { + coinJoinManager.refreshUnusedKeys() + coinJoinManager.initMasternodeGroup() + coinJoinManager.doAutomaticDenominating() + + DSLogger.log("[SW] CoinJoin: Mixing \(coinJoinManager.startMixing() ? "started successfully" : "start failed, will retry")") // TODO: failed statuses: \(coinJoinManager.statuses) + } + } + + private func configureMixing(amount: UInt64) { + guard let coinJoinManager = self.coinJoinManager ?? createCoinJoinManager() else { return } + + let rounds: Int32 + switch mode { + case .none: + return + case .intermediate: + rounds = kDefaultRounds + case .advanced: + rounds = kDefaultRounds * 2 + } + + coinJoinManager.configureMixing(withAmount: amount, rounds: rounds, sessions: kDefaultSessions, withMultisession: kDefaultMultisession, denominationGoal: kDefaultDenominationGoal, denominationHardCap: kDefaultDenominationHardcap) + } + + private func updateProgress() { + guard let coinJoinManager = self.coinJoinManager else { return } + self.progress = coinJoinManager.getMixingProgress() + let coinJoinBalance = coinJoinManager.getBalance() + self.totalBalance = coinJoinBalance.myTrusted + self.coinJoinBalance = coinJoinBalance.anonymized + } + + private func createCoinJoinManager() -> DSCoinJoinManager? { + self.coinJoinManager = DSCoinJoinManager.sharedInstance(for: DWEnvironment().currentChain) + coinJoinManager?.managerDelegate = self + return self.coinJoinManager + } + + private func synchronized(_ lock: NSLock, closure: () -> Void) { + lock.lock() + defer { lock.unlock() } + closure() + } + + private func updateBalance(balance: UInt64) { + guard let coinJoinManager = self.coinJoinManager else { return } + + coinJoinManager.updateOptions(withAmount: balance) + DSLogger.log("[SW] CoinJoin: total balance: \(balance)") + let canDenominate = coinJoinManager.doAutomaticDenominating(withDryRun: true) + + let coinJoinBalance = coinJoinManager.getBalance() + DSLogger.log("[SW] CoinJoin: mixed balance: \(coinJoinBalance.anonymized)") + + let anonBalance = coinJoinManager.getAnonymizableBalance(withSkipDenominated: false, skipUnconfirmed: false) + DSLogger.log("[SW] CoinJoin: anonymizable balance \(anonBalance)") + + let smallestDenomination = coinJoinManager.getSmallestDenomination() + let hasPartiallyMixedCoins = (coinJoinBalance.denominatedTrusted - coinJoinBalance.anonymized) > 0 + let hasAnonymizableBalance = anonBalance > smallestDenomination + let hasBalanceLeftToMix: Bool + + if hasPartiallyMixedCoins { + hasBalanceLeftToMix = true + } else if hasAnonymizableBalance && canDenominate { + hasBalanceLeftToMix = true + } else { + hasBalanceLeftToMix = false + } + + DSLogger.log("[SW] CoinJoin: can mix balance: \(hasBalanceLeftToMix) = balance: (\(anonBalance > smallestDenomination) && canDenominate: \(canDenominate)) || partially-mixed: \(hasPartiallyMixedCoins)") + + updateState( + balance: balance, + mode: self.mode, + timeSkew: TimeInterval(0), // TODO + hasAnonymizableBalance: hasBalanceLeftToMix, + networkStatus: self.networkStatus, + chain: DWEnvironment.sharedInstance().currentChain + ) + } + + private func stopMixing() { + self.coinJoinManager?.managerDelegate = nil + self.coinJoinManager?.stop() + } + + private func updateState( + balance: UInt64, + mode: CoinJoinMode, + timeSkew: TimeInterval, + hasAnonymizableBalance: Bool, + networkStatus: NetworkStatus, + chain: DSChain + ) { + synchronized(self.updateMutex) { + DSLogger.log("[SW] CoinJoin: \(mode), \(timeSkew) ms, \(hasAnonymizableBalance), \(networkStatus), synced: \(chain.chainManager!.isSynced)") + + self.networkStatus = networkStatus + self.hasAnonymizableBalance = hasAnonymizableBalance + self.mode = mode + // self.timeSkew = timeSkew + + if mode == .none /*|| !isInsideTimeSkewBounds(timeSkew) || blockchainState.replaying*/ { // TODO + updateMixingState(state: .notStarted) + } else { + configureMixing(amount: balance) + + if hasAnonymizableBalance { + if networkStatus == .online && chain.chainManager!.isSynced { + updateMixingState(state: .mixing) + } else { + updateMixingState(state: .paused) + } + } else { + updateMixingState(state: .finished) + } + } + + updateProgress() + } + } + + private func updateMixingState(state: MixingStatus) { + synchronized(self.updateMixingStateMutex) { + let previousMixingStatus = self.mixingState + DSLogger.log("[SW] CoinJoin: \(previousMixingStatus) -> \(state)") + + if previousMixingStatus == .paused && state != .paused { + DSLogger.log("[SW] CoinJoin: moving from paused to \(state)") + } + + self.mixingState = state + + if state == .mixing && previousMixingStatus != .mixing { + // start mixing + prepareMixing() + startMixing() + } else if previousMixingStatus == .mixing && state != .mixing { + // finish mixing + stopMixing() + } + } + } +} + +extension CoinJoinService: DSCoinJoinManagerDelegate { + func sessionStarted(withId baseId: Int32, clientSessionId clientId: UInt256, denomination denom: UInt32, poolState state: PoolState, poolMessage message: PoolMessage, ipAddress address: UInt128, isJoined joined: Bool) { + updateActiveSessions() + } + + func sessionComplete(withId baseId: Int32, clientSessionId clientId: UInt256, denomination denom: UInt32, poolState state: PoolState, poolMessage message: PoolMessage, ipAddress address: UInt128, isJoined joined: Bool) { + updateActiveSessions() + } + + func mixingStarted() { } + + func mixingComplete(_ withError: Bool) { + if withError { + DSLogger.log("[SW] CoinJoin: Mixing Error. \(progress)% mixed") + } else { + DSLogger.log("[SW] CoinJoin: Mixing Complete. \(progress)% mixed") + } + + self.updateMixingState(state: withError ? .error : .finished) // TODO: paused? + } + + func transactionProcessed(withId txId: UInt256, type: CoinJoinTransactionType) { + self.updateProgress() + } + + private func updateActiveSessions() { + guard let coinJoinManager = self.coinJoinManager else { return } + + let activeSessions = coinJoinManager.getActiveSessionCount() + self.activeSessions = Int(activeSessions) + + DSLogger.log("[SW] CoinJoin: Active sessions: \(activeSessions)") + } +} + diff --git a/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinLevelsViewController.swift b/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinLevelsViewController.swift index b4827eeb8..a095163d0 100644 --- a/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinLevelsViewController.swift +++ b/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinLevelsViewController.swift @@ -32,8 +32,6 @@ class CoinJoinLevelsViewController: UIViewController { @IBOutlet private var advancedTime: UILabel! @IBOutlet private var continueButton: ActionButton! - @Published private(set) var selectedMode: CoinJoinMode = .none - @objc static func controller() -> CoinJoinLevelsViewController { vc(CoinJoinLevelsViewController.self, from: sb("CoinJoin")) @@ -47,9 +45,9 @@ class CoinJoinLevelsViewController: UIViewController { @IBAction func continueButtonAction() { - if viewModel.status == .notStarted { + if viewModel.mixingState == .notStarted { self.navigationController?.popViewController(animated: true) - viewModel.startMixing(mode: selectedMode) + viewModel.startMixing() } else { let alert = UIAlertController(title: NSLocalizedString("Are you sure you want to stop mixing?", comment: "CoinJoin"), message: NSLocalizedString("Any funds that have been mixed will be combined with your un mixed funds", comment: "CoinJoin"), preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("Stop Mixing", comment: "CoinJoin"), style: .destructive, handler: { [weak self] _ in @@ -64,8 +62,6 @@ class CoinJoinLevelsViewController: UIViewController { extension CoinJoinLevelsViewController { private func configureHierarchy() { - selectedMode = viewModel.mode - titleLabel.text = NSLocalizedString("Select mixing level", comment: "CoinJoin") intermediateTitle.text = NSLocalizedString("Intermediate", comment: "CoinJoin") intermediateDescription.text = NSLocalizedString("Advanced users who have a very high level of technical expertise can determine your transaction history", comment: "Coinbase") @@ -92,32 +88,32 @@ extension CoinJoinLevelsViewController { @objc private func selectIntermediate() { - if selectedMode == .intermediate { + if viewModel.selectedMode == .intermediate { return } - if viewModel.status == .mixing { + if viewModel.mixingState == .mixing { confirmFor(.intermediate) } else { - selectedMode = .intermediate + viewModel.selectedMode = .intermediate } } @objc private func selectAdvanced() { - if selectedMode == .advanced { + if viewModel.selectedMode == .advanced { return } - if viewModel.status == .mixing { + if viewModel.mixingState == .mixing { confirmFor(.advanced) } else { - selectedMode = .advanced + viewModel.selectedMode = .advanced } } private func configureObservers() { - $selectedMode + viewModel.$selectedMode .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] mode in guard let self = self else { return } @@ -135,7 +131,7 @@ extension CoinJoinLevelsViewController { }) .store(in: &cancellableBag) - viewModel.$status + viewModel.$mixingState .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] status in guard let self = self else { return } @@ -179,7 +175,7 @@ extension CoinJoinLevelsViewController { let alert = UIAlertController(title: "", message: NSLocalizedString("Are you sure you want to change the privacy level?", comment: "CoinJoin"), preferredStyle: .alert) alert.addAction(UIAlertAction(title: title, style: .default, handler: { [weak self] _ in - self?.selectedMode = mode + self?.viewModel.selectedMode = mode })) let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) alert.addAction(cancelAction) diff --git a/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinViewModel.swift b/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinViewModel.swift index 7a1958fb5..db84313f3 100644 --- a/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinViewModel.swift +++ b/DashWallet/Sources/UI/DashPay/CoinJoin/CoinJoinViewModel.swift @@ -16,6 +16,7 @@ // import Foundation +import Combine @objc public class CoinJoinObjcWrapper: NSObject { @@ -25,28 +26,18 @@ public class CoinJoinObjcWrapper: NSObject { } } - -enum CoinJoinMode { - case none - case intermediate - case advanced -} - -enum MixingStatus { - case notStarted - case mixing - case paused - case finished - case error -} - private let kInfoShown = "coinJoinInfoShownKey" -class CoinJoinViewModel { +class CoinJoinViewModel: ObservableObject { static let shared = CoinJoinViewModel() + private var cancellableBag = Set() + private let coinJoinService = CoinJoinService.shared - private(set) var mode: CoinJoinMode = .none - @Published private(set) var status: MixingStatus = .notStarted + @Published var selectedMode: CoinJoinMode = .none + @Published private(set) var mixingState: MixingStatus = .notStarted + @Published private(set) var progress: Double = 0.0 + @Published private(set) var totalBalance: UInt64 = 0 + @Published private(set) var coinJoinBalance: UInt64 = 0 private var _infoShown: Bool? = nil var infoShown: Bool { @@ -57,12 +48,33 @@ class CoinJoinViewModel { } } - func startMixing(mode: CoinJoinMode) { - self.mode = mode - status = .mixing + init() { + coinJoinService.$mixingState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.mixingState = state + } + .store(in: &cancellableBag) + + coinJoinService.$progress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + self.progress = progress + self.totalBalance = coinJoinService.totalBalance + self.coinJoinBalance = coinJoinService.coinJoinBalance + } + .store(in: &cancellableBag) + } + + func startMixing() { + if self.selectedMode != .none { + coinJoinService.updateMode(mode: self.selectedMode) + } } func stopMixing() { - status = .notStarted + selectedMode = .none + coinJoinService.updateMode(mode: .none) } } diff --git a/DashWallet/Sources/UI/Home/Views/Cells/CoinJoinProgressView.swift b/DashWallet/Sources/UI/Home/Views/Cells/CoinJoinProgressView.swift new file mode 100644 index 000000000..00f174120 --- /dev/null +++ b/DashWallet/Sources/UI/Home/Views/Cells/CoinJoinProgressView.swift @@ -0,0 +1,98 @@ +// +// Created by Andrei Ashikhmin +// Copyright © 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct CoinJoinProgressView: View { + @State var state: MixingStatus + @State var progress: Double + @State var mixed: Double + @State var total: Double + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.dashBlue.opacity(0.1)) + .frame(width: 38, height: 38) + + Image("image.coinjoin.menu") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + VStack(alignment: .leading, spacing: 4) { + CoinJoinProgressInfo(state: state, progress: progress, mixed: mixed, total: total, textColor: .primaryText, font: .subheadline) + .padding(.leading, -6) + SwiftUI.ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle(tint: .dashBlue)) + .frame(height: 6) + .padding(.top, 2) + } + } + .padding(12) + .background(Color.secondaryBackground) + .cornerRadius(8) + } +} + +struct CoinJoinProgressInfo: View { + @State var state: MixingStatus + @State var progress: Double + @State var mixed: Double + @State var total: Double + var textColor: Color + var font: Font + + var body: some View { + HStack(spacing: 0) { + if state == .mixing { + SwiftUI.ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .dashBlue)) + .scaleEffect(0.5) + } + + Text(state.localizedValue) + .foregroundColor(textColor) + .font(font) + .padding(.leading, state == .mixing ? 2 : 5) + + if state.isInProgress { + Text(progress.formatted(.percent.precision(.fractionLength(0...2)))) + .foregroundColor(textColor) + .font(font) + .padding(.leading, 4) + } + + Spacer() + Text("\(mixed, format: .number.precision(.fractionLength(0...3))) of \(total, format: .number.precision(.fractionLength(0...3)))") // TODO + .foregroundColor(textColor) + .font(font) + Image("icon_dash_currency") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: font.pointSize, height: font.pointSize) + .padding(.leading, 2) + .foregroundColor(textColor) + } + } +} + +#Preview { + CoinJoinProgressView(state: .mixing, progress: 0.45, mixed: 0.123, total: 0.321) +} diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 95b028243..b2eb3f33b 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -192,6 +192,7 @@ final class HomeView: UIView, DWHomeModelUpdatesObserver, DWDPRegistrationErrorR private func setIdentity(dpInfoHidden: Bool, model: DWHomeProtocol) { headerView.isDPWelcomeViewHidden = dpInfoHidden headerView.isVotingViewHidden = true + viewModel.showJoinDashpay = !headerView.isDPWelcomeViewHidden let status = model.dashPayModel.registrationStatus let completed = model.dashPayModel.registrationCompleted @@ -212,6 +213,7 @@ final class HomeView: UIView, DWHomeModelUpdatesObserver, DWDPRegistrationErrorR let now = Date().timeIntervalSince1970 headerView.isVotingViewHidden = dpInfoHidden || wasClosed || now < VotingConstants.votingEndTime headerView.isDPWelcomeViewHidden = true + viewModel.showJoinDashpay = !headerView.isDPWelcomeViewHidden let dao = UsernameRequestsDAOImpl.shared Task { @@ -287,7 +289,18 @@ struct TransactionList: View { LazyVStack(pinnedViews: [.sectionHeaders]) { balanceHeader() - .frame(height: viewModel.hasNetwork ? 250 : 335) + .frame(height: viewModel.balanceHeaderHeight) + + if viewModel.coinJoinItem.isOn { + CoinJoinProgressView( + state: viewModel.coinJoinItem.state, + progress: viewModel.coinJoinItem.progress, + mixed: viewModel.coinJoinItem.mixed, + total: viewModel.coinJoinItem.total + ) + .padding(.horizontal, 15) + .id(viewModel.coinJoinItem.id) + } syncingHeader() .frame(height: 50) @@ -322,6 +335,9 @@ struct TransactionList: View { .sheet(item: $selectedTxDataItem) { item in TransactionDetailsSheet(item: item) } + .onChange(of: viewModel.coinJoinItem) { new in + DSLogger.log("[SW] CoinJoin: on change of coinJoinItem: \(viewModel.coinJoinItem.description)") + } } @ViewBuilder diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index f6b3df166..4ad97c71c 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -16,17 +16,31 @@ // import Foundation +import Combine + +let kBaseBalanceHeaderHeight: CGFloat = 250 class HomeViewModel: ObservableObject { + private var cancellableBag = Set() + private let coinJoinService = CoinJoinService.shared + @Published var txItems: Array<(DateKey, [TransactionListDataItem])> = [] - @Published var hasNetwork: Bool = true + @Published var balanceHeaderHeight: CGFloat = kBaseBalanceHeaderHeight // TDOO: move back to HomeView when fully transitioned to SwiftUI + @Published var coinJoinItem = CoinJoinMenuItemModel(title: NSLocalizedString("Mixing", comment: "CoinJoin"), isOn: false, state: .notStarted, progress: 0.0, mixed: 0.0, total: 0.0) + private var model: SyncModel = SyncModelImpl() + var showJoinDashpay: Bool = false { + didSet { + self.recalculateHeight() + } + } init() { model.networkStatusDidChange = { status in - self.hasNetwork = status == .online + self.recalculateHeight() } - self.hasNetwork = model.networkStatus == .online + self.recalculateHeight() + self.observeCoinJoin() } func updateItems(transactions: [DSTransaction]) { @@ -60,6 +74,60 @@ class HomeViewModel: ObservableObject { } } } + + private func recalculateHeight() { + var height = kBaseBalanceHeaderHeight + let hasNetwork = model.networkStatus == .online + + if !hasNetwork { + height += 85 + } + + if showJoinDashpay { + height += 50 + } + + self.balanceHeaderHeight = height + } +} + +extension HomeViewModel { + private func observeCoinJoin() { + coinJoinService.$progress + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshCoinJoinItem() + } + .store(in: &cancellableBag) + + coinJoinService.$mode + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshCoinJoinItem() + } + .store(in: &cancellableBag) + + coinJoinService.$mixingState + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshCoinJoinItem() + } + .store(in: &cancellableBag) + } + + private func refreshCoinJoinItem() { + self.coinJoinItem = CoinJoinMenuItemModel( + title: NSLocalizedString("Mixing", comment: "CoinJoin"), + isOn: coinJoinService.mixingState.isInProgress, + state: coinJoinService.mixingState, + progress: coinJoinService.progress, + mixed: Double(coinJoinService.coinJoinBalance) / Double(DUFFS), + total: Double(coinJoinService.totalBalance) / Double(DUFFS) + ) + } } // MARK: - TransactionListDataItem diff --git a/DashWallet/Sources/UI/Menu/MenuItemModel.swift b/DashWallet/Sources/UI/Menu/MenuItemModel.swift index fcf3993ba..e4ca2a82e 100644 --- a/DashWallet/Sources/UI/Menu/MenuItemModel.swift +++ b/DashWallet/Sources/UI/Menu/MenuItemModel.swift @@ -48,12 +48,22 @@ class MenuItemModel: Identifiable, Equatable { } class CoinJoinMenuItemModel: MenuItemModel { - var mixingPercentage: String - var dashAmount: String + @State var isOn: Bool + @State var state: MixingStatus + @State var progress: Double + @State var mixed: Double + @State var total: Double - init(title: String, mixingPercentage: String, dashAmount: String, action: (() -> Void)? = nil) { - self.mixingPercentage = mixingPercentage - self.dashAmount = dashAmount + init(title: String, isOn: Bool, state: MixingStatus, progress: Double, mixed: Double, total: Double, action: (() -> Void)? = nil) { + self.isOn = isOn + self.state = state + self.progress = progress + self.mixed = mixed + self.total = total super.init(title: title, action: action) } + + var description: String { + return "CoinJoinMenuItemModel(title: \(title), isOn: \(isOn), state: \(state), progress: \(progress), mixed: \(mixed), total: \(total))" + } } diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewController.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewController.swift index 33ab4c986..d97dbbf76 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewController.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewController.swift @@ -17,6 +17,7 @@ import UIKit import SwiftUI +import Combine @objc(DWSettingsMenuViewControllerDelegate) protocol SettingsMenuViewControllerDelegate: AnyObject { @@ -29,6 +30,8 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle @objc weak var delegate: SettingsMenuViewControllerDelegate? private lazy var model: DWSettingsMenuModel = DWSettingsMenuModel() + private lazy var viewModel: SettingsViewModel = SettingsViewModel(model: model) + private var cancellables = Set() init() { super.init(nibName: nil, bundle: nil) @@ -46,7 +49,7 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle view.backgroundColor = .dw_secondaryBackground() let content = SettingsMenuContent( - items: menuItems(), + viewModel: self.viewModel, onLocalCurrencyChange: { [weak self] in self?.showCurrencySelector() }, @@ -60,6 +63,29 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle let swiftUIController = UIHostingController(rootView: content) swiftUIController.view.backgroundColor = .dw_secondaryBackground() dw_embedChild(swiftUIController) + setupNavigationObserver() + } + + private func setupNavigationObserver() { + viewModel.$navigationDestination + .receive(on: DispatchQueue.main) + .sink { [weak self] dest in + switch dest { + case .coinjoin: + self?.showCoinJoinController() + case .currencySelector: + self?.showCurrencySelector() + case .network: + self?.showChangeNetwork() + case .rescan: + self?.showWarningAboutReclassifiedTransactions() + case .about: + self?.showAboutController() + default: + break + } + } + .store(in: &cancellables) } override var preferredStatusBarStyle: UIStatusBarStyle { @@ -78,72 +104,6 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle // MARK: - Private - private func menuItems() -> [MenuItemModel] { - var items: [MenuItemModel] = [ - MenuItemModel( - title: NSLocalizedString("Local Currency", comment: ""), - subtitle: model.localCurrencyCode, - showChevron: true, - action: { [weak self] in - self?.showCurrencySelector() - } - ), - MenuItemModel( - title: NSLocalizedString("Enable Receive Notifications", comment: ""), - showToggle: true, - isToggled: self.model.notificationsEnabled, - action: { [weak self] in - self?.model.notificationsEnabled.toggle() - } - ), - MenuItemModel( - title: NSLocalizedString("Network", comment: ""), - subtitle: model.networkName, - showChevron: true, - action: { [weak self] in - self?.showChangeNetwork() - } - ), - MenuItemModel( - title: NSLocalizedString("Rescan Blockchain", comment: ""), - showChevron: true, - action: { [weak self] in - self?.showWarningAboutReclassifiedTransactions() - } - ), - MenuItemModel( - title: NSLocalizedString("About", comment: ""), - showChevron: true, - action: { [weak self] in - self?.showAboutController() - } - ) - ] - - #if DASHPAY - items.append(contentsOf: [ - CoinJoinMenuItemModel( - title: NSLocalizedString("CoinJoin", comment: ""), - mixingPercentage: "70%", - dashAmount: "0.085 of 0.199", - action: { [weak self] in - self?.showCoinJoinController() - } - ), - MenuItemModel( - title: "Enable Voting", - showToggle: true, - isToggled: VotingPrefs.shared.votingEnabled, - action: { - VotingPrefs.shared.votingEnabled.toggle() - } - ) - ]) - #endif - - return items - } - private func showCurrencySelector() { let controller = DWLocalCurrencyViewController(navigationAppearance: .default, presentationMode: .screen, currencyCode: nil) controller.delegate = self @@ -155,18 +115,6 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle navigationController?.pushViewController(aboutViewController, animated: true) } - private func showCoinJoinController() { - let vc: UIViewController - - if CoinJoinViewModel.shared.infoShown { - vc = CoinJoinLevelsViewController.controller() - } else { - vc = CoinJoinInfoViewController.controller() - } - - navigationController?.pushViewController(vc, animated: true) - } - private func showChangeNetwork() { let actionSheet = UIAlertController(title: NSLocalizedString("Network", comment: ""), message: nil, preferredStyle: .actionSheet) @@ -263,19 +211,35 @@ class SettingsMenuViewController: UIViewController, DWLocalCurrencyViewControlle } } +// MARK: - CoinJoin + +extension SettingsMenuViewController { + private func showCoinJoinController() { + let vc: UIViewController + + if CoinJoinViewModel.shared.infoShown { + vc = CoinJoinLevelsViewController.controller() + } else { + vc = CoinJoinInfoViewController.controller() + } + + navigationController?.pushViewController(vc, animated: true) + } +} + struct SettingsMenuContent: View { - var items: [MenuItemModel] + @StateObject var viewModel: SettingsViewModel var onLocalCurrencyChange: () -> Void var onNetworkChange: () -> Void var onRescanBlockchain: () -> Void var body: some View { - List(items) { item in + List(viewModel.items) { item in Group { if let cjItem = item as? CoinJoinMenuItemModel { MenuItem( title: cjItem.title, - subtitleView: AnyView(CoinJoinSubtitle(cjItem.mixingPercentage, cjItem.dashAmount)), + subtitleView: AnyView(CoinJoinSubtitle(cjItem)), icon: .custom("image.coinjoin.menu"), action: cjItem.action ) @@ -304,29 +268,15 @@ struct SettingsMenuContent: View { } @ViewBuilder - private func CoinJoinSubtitle(_ mixingPercentage: String, _ dashAmount: String) -> some View { - HStack(spacing: 0) { - SwiftUI.ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .dashBlue)) - .scaleEffect(0.5) - Text(NSLocalizedString("Mixing ·", comment: "CoinJoin")) - .font(.caption) - .foregroundColor(.tertiaryText) - .padding(.leading, 2) - Text(mixingPercentage) + private func CoinJoinSubtitle(_ cjItem: CoinJoinMenuItemModel) -> some View { + if cjItem.isOn { + CoinJoinProgressInfo(state: cjItem.state, progress: cjItem.progress, mixed: cjItem.mixed, total: cjItem.total, textColor: .tertiaryText, font: .caption) + } else { + Text(NSLocalizedString("Turned off", comment: "CoinJoin")) .font(.caption) .foregroundColor(.tertiaryText) .padding(.leading, 4) - Spacer() - Text(dashAmount) - .font(.caption) - .foregroundColor(.tertiaryText) - Image("icon_dash_currency") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12) - .padding(.leading, 2) - .foregroundColor(.tertiaryText) + .padding(.top, 2) } } } diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsViewModel.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..40da83171 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsViewModel.swift @@ -0,0 +1,134 @@ +// +// Created by Andrei Ashikhmin +// Copyright © 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +enum SettingsNavDest { + case coinjoin + case currencySelector + case network + case rescan + case about + case none +} + +class SettingsViewModel: ObservableObject { + private var cancellableBag = Set() + private let coinJoinService = CoinJoinService.shared + private var model: DWSettingsMenuModel + @Published var items: [MenuItemModel] = [] + @Published private(set) var navigationDestination: SettingsNavDest = .none + + init(model: DWSettingsMenuModel) { + self.model = model + refreshMenuItems() + setupCoinJoinObservers() + } + + private func setupCoinJoinObservers() { + coinJoinService.$progress + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshMenuItems() + } + .store(in: &cancellableBag) + + coinJoinService.$mode + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshMenuItems() + } + .store(in: &cancellableBag) + + coinJoinService.$mixingState + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshMenuItems() + } + .store(in: &cancellableBag) + } + + private func refreshMenuItems() { + self.items = [ + MenuItemModel( + title: NSLocalizedString("Local Currency", comment: ""), + subtitle: model.localCurrencyCode, + showChevron: true, + action: { [weak self] in + self?.navigationDestination = .currencySelector + } + ), + MenuItemModel( + title: NSLocalizedString("Enable Receive Notifications", comment: ""), + showToggle: true, + isToggled: model.notificationsEnabled, + action: { [weak self] in + self?.model.notificationsEnabled.toggle() + } + ), + MenuItemModel( + title: NSLocalizedString("Network", comment: ""), + subtitle: model.networkName, + showChevron: true, + action: { [weak self] in + self?.navigationDestination = .network + } + ), + MenuItemModel( + title: NSLocalizedString("Rescan Blockchain", comment: ""), + showChevron: true, + action: { [weak self] in + self?.navigationDestination = .rescan + } + ), + MenuItemModel( + title: NSLocalizedString("About", comment: ""), + showChevron: true, + action: { [weak self] in + self?.navigationDestination = .about + } + ) + ] + + #if DASHPAY + items.append(contentsOf: [ + CoinJoinMenuItemModel( + title: NSLocalizedString("CoinJoin", comment: "CoinJoin"), + isOn: coinJoinService.mode != .none, + state: coinJoinService.mixingState, + progress: coinJoinService.progress, + mixed: Double(coinJoinService.coinJoinBalance) / Double(DUFFS), + total: Double(coinJoinService.totalBalance) / Double(DUFFS), + action: { [weak self] in + self?.navigationDestination = .coinjoin + } + ), + MenuItemModel( + title: "Enable Voting", + showToggle: true, + isToggled: VotingPrefs.shared.votingEnabled, + action: { + VotingPrefs.shared.votingEnabled.toggle() + } + ) + ]) + #endif + } +} diff --git a/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift b/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift index 136d0b5f5..10bcdf6cb 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift @@ -21,7 +21,7 @@ typealias TransactionPreview = MenuItem struct MenuItem: View { var title: String - var subtitleView: AnyView? = nil + @State var subtitleView: AnyView? = nil var details: String? = nil var topText: String? = nil var icon: IconName? = nil @@ -89,7 +89,7 @@ struct MenuItem: View { action: (() -> Void)? = nil ) { self.title = title - self.subtitleView = subtitleView + self._subtitleView = State(initialValue: subtitleView) self.details = details self.topText = topText self.icon = icon diff --git a/DashWallet/dashwallet-Bridging-Header.h b/DashWallet/dashwallet-Bridging-Header.h index 95602335d..60966195a 100644 --- a/DashWallet/dashwallet-Bridging-Header.h +++ b/DashWallet/dashwallet-Bridging-Header.h @@ -113,7 +113,6 @@ static const bool _SNAPSHOT = 0; #import "UIView+DWEmbedding.h" #import "DWBasePressableControl.h" - #if DASHPAY #import "DWInvitationSetupState.h" #import "DPAlertViewController.h" @@ -168,3 +167,6 @@ static const bool _SNAPSHOT = 0; //MARK: Onboarding #import "DWTransactionStub.h" + +//MARK: CoinJoin +#import "DSCoinJoinManager.h"