diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index f9b219bc7..15d601824 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 00B3E9708BF84A08409B431B /* BIP70PaymentOutputFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5822AAB9B5EA605FF82B009A /* BIP70PaymentOutputFactory.swift */; }; 026B0383557D0181E034B64E /* SDKIdentityProfileSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */; }; - 5ACTMYQR020000000000QR02 /* MyDashPayUserQRSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */; }; 02EDCC7635BCD84768554D9C /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */; }; @@ -113,7 +112,6 @@ 1C2999FF1FC854E7DB138250 /* TransferTimingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67FCD2ED5EDFD68CDE1FE7CC /* TransferTimingSheet.swift */; }; 1C2D3E4F5A6B7C8D9E0F1A2B /* TransferAmountHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C9D0E1F2A3B4C5D6E7F809 /* TransferAmountHostingController.swift */; }; 1CDA90D00719D6249F2761AC /* ContactItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */; }; - 9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */; }; 1F3FF21B0295D5A326F08B74 /* OrderPreviewFeeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */; }; 1FAFEA3F516E6CBDD2B49010 /* SwiftDashSDKContactsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02357928871603392E797C9A /* SwiftDashSDKContactsService.swift */; }; 1FED991A9D2D236582CE3C99 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; @@ -121,11 +119,9 @@ 2284B63F1C7BD28F0012ECDF /* BRAWKeypad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F45A0A1C30EAB700B07A15 /* BRAWKeypad.swift */; }; 22B03841025A55A573432CC9 /* WalletAccountsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285B961142AB3AFF2442F4EB /* WalletAccountsScreen.swift */; }; 22E89B6C00B546D294D7F331 /* SelfSizingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB7E093E994D2391D36DEC /* SelfSizingSheet.swift */; }; - 5ACPHD0002F9A0000000000S2 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; 2556F3C5C72D70A771C6C61A /* PaymentProtocolWireFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B030D4B574621DA2C5816 /* PaymentProtocolWireFormat.swift */; }; 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - 25C88271468A4CD23F34A6E9 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1F21A7592E99814A800518 /* SendScreen.swift */; }; 279C16F9AF0575C540E312E5 /* SwapConvertCardRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99130732DE8F2E95F635F6B /* SwapConvertCardRow.swift */; }; 2991DE4863B0BF4FA8628C3C /* DWProfileUpdateBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA91E4239626E28125CC0E2 /* DWProfileUpdateBridge.swift */; }; 2A0C699C23104588001B8C90 /* DWPaymentInput.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C699B23104588001B8C90 /* DWPaymentInput.m */; }; @@ -358,7 +354,6 @@ 4539AA3FC5C209480BA279AA /* PaymentProtocolMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4833BE986D59F69A555C5F /* PaymentProtocolMessages.swift */; }; 45EAD8E50593EE5158E92E9B /* WalletAccountsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285B961142AB3AFF2442F4EB /* WalletAccountsScreen.swift */; }; 4636DCF2B17C1DC56E9CDD84 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; - 5ACPHD0002F9A0000000000S3 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; 470617D6299A671900DCC667 /* CrowdNodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470617D5299A671900DCC667 /* CrowdNodeCell.swift */; }; 47081197298CF20C003FCA3D /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47081196298CF20B003FCA3D /* Transaction.swift */; }; 4708119F2990F56F003FCA3D /* TransactionDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708119E2990F56F003FCA3D /* TransactionDataItem.swift */; }; @@ -647,6 +642,29 @@ 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; 5AACV0002F9A0000000000S2 /* AddContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */; }; 5AACV0002F9A0000000000S3 /* AddContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */; }; + 5ACPHD0002F9A0000000000S2 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; + 5ACPHD0002F9A0000000000S3 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; + 5ACSH0002F9A0000000000S2 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; + 5ACSH0002F9A0000000000S3 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; + 5ACTCARD0200000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; + 5ACTCARD0300000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; + 5ACTCAVM0200000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; + 5ACTCAVM0300000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; + 5ACTCEVR020000000000EV02 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; + 5ACTCEVR030000000000EV03 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; + 5ACTCROW0200000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; + 5ACTCROW0300000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; + 5ACTCSPR020000000000SP02 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; + 5ACTCSPR030000000000SP03 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; + 5ACTCSTC020000000000SC02 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; + 5ACTCSTC030000000000SC03 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; + 5ACTCSTV020000000000SV02 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; + 5ACTCSTV030000000000SV03 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; + 5ACTDAYG020000000000DG02 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; + 5ACTDAYG030000000000DG03 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; + 5ACTMYQR020000000000QR02 /* MyDashPayUserQRSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */; }; + 5ACTTFB0020000000000FB02 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; + 5ACTTFB0030000000000FB03 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 5AGOV0002F9A0000000000S2 /* GovernanceMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */; }; 5AGOV0002F9A0000000000S3 /* GovernanceMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */; }; 5AGOV0002F9A0000000000V2 /* GovernanceMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */; }; @@ -657,6 +675,20 @@ 5ANVM0002F9A0000000000S3 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ANVM0002F9A0000000000S1 /* NotificationsViewModel.swift */; }; 5APCV0002F9A0000000000S2 /* PayContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */; }; 5APCV0002F9A0000000000S3 /* PayContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */; }; + 5ASB00100000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; + 5ASB00200000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; + 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; + 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; + 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; + 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; 5BBF50F1EFB3EC99854ECE69 /* SwiftDashSDKTransactionSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */; }; 5D8D3B4397A957BA6DD6FC0C /* DWAboutModel+MasternodeSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1D2D397047E72188C90FAE /* DWAboutModel+MasternodeSync.swift */; }; 5EAD111230A0000000A1B201 /* MayaAmountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAD111130A0000000A1B201 /* MayaAmountFormatter.swift */; }; @@ -666,15 +698,6 @@ 6181C570B638E07D8B369CDB /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; 61E0BCA4B860A2578137B28F /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 62FDC661BC3BDD5C2EA676BA /* ContactAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */; }; - 5ACSH0002F9A0000000000S2 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; - 5ACTCSTV020000000000SV02 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; - 5ACTCSTC020000000000SC02 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; - 5ACTCSPR020000000000SP02 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; - 5ACTCARD0200000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; - 5ACTCAVM0200000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; - 5ACTDAYG020000000000DG02 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; - 5ACTCROW0200000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; - 5ACTCEVR020000000000EV02 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; 630227C0414D5C74BA95BB8F /* BIP70Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94606903417F75BE99D4B31C /* BIP70Error.swift */; }; 63869E343191C55BDBBEED72 /* SwiftDashSDKInsightClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7BE8E4519203200CDFB460 /* SwiftDashSDKInsightClient.swift */; }; 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; @@ -683,6 +706,7 @@ 6715F4CB33E5F472FEC43B7E /* DWParsedPaymentURI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DC91C11E382E22D1C17F05 /* DWParsedPaymentURI.swift */; }; 6C4AA7FD67FEE5A9E5CF9781 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76AB0835FF44EA88F36991E0 /* WalletsViewModel.swift */; }; 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 5APLTSB10000000000000000 /* PaymentsLandingTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */; }; 6FAE60AB81526ADAE67D1BE7 /* BIP70PaymentService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9155233A128831C0ABF18A3 /* BIP70PaymentService+App.swift */; }; 6FD1512E7774A997C1F8466B /* SwapMenuStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286D4D1CADBBD51F09BE8E8 /* SwapMenuStyles.swift */; }; 6FFC566DBB2CFAA3DB3CEFB4 /* SwiftDashSDKPhraseRepairer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CA14DC8416012B813B5E2C /* SwiftDashSDKPhraseRepairer.swift */; }; @@ -886,9 +910,7 @@ 75D5D5342CC928630049ED7B /* UIHostingController+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D5D5322CC9285B0049ED7B /* UIHostingController+DashWallet.swift */; }; 75D5F3CE191EC270004AB296 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 75D5F3CD191EC270004AB296 /* main.m */; }; 75D657672DF579F300ACE570 /* TransactionFilterDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */; }; - 5ACTTFB0020000000000FB02 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 75D657682DF579F300ACE570 /* TransactionFilterDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */; }; - 5ACTTFB0030000000000FB03 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 75D6D8E42D96A6DB00E40A6D /* StubTransactionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */; }; 75D6D8E52D96A6DB00E40A6D /* StubTransactionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */; }; 75D9EBB82DE5CC3A009416A2 /* AddGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D9EBB72DE5CC3A009416A2 /* AddGiftCardsTable.swift */; }; @@ -960,6 +982,7 @@ 8A7607606A594C6E99A2FAC7 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; 8AF94168DE3494EAF0811D77 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 5APLTSB20000000000000000 /* PaymentsLandingTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */; }; 8DD6758D2AC6F5BB23D5504D /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 8DF3D24EE3B247B79AE9CAB5 /* BuySellPortalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */; }; 8E3AF21ED19AEC0FD89A63FC /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; @@ -989,6 +1012,7 @@ 9B54AF317E7728C18646F702 /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */; }; 9B627F0FCC93D887BAB3E9A9 /* BIP70PaymentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D58056D713E4B50DAE0F24B /* BIP70PaymentService.swift */; }; 9E39B6CDE39A9CA4E74ADF3B /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; + 9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */; }; 9E8A618E9D824AFFEDAC4F0A /* BIP70InteractiveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36BCF99A4D30077C9C9B0EB /* BIP70InteractiveCoordinator.swift */; }; A0795C9369A1402498C00F69 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; A1060800B100000000000001 /* ExplorePointOfUse+PreviewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1060800F100000000000001 /* ExplorePointOfUse+PreviewMock.swift */; }; @@ -1002,6 +1026,8 @@ A15ED0B9E7AAF29C694E4E16 /* DWIdentityAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33FBA94DA84A9EE303FF48B4 /* DWIdentityAuthorizer.swift */; }; A15ED0B9E7AAF29C694E4E17 /* DWIdentityAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33FBA94DA84A9EE303FF48B4 /* DWIdentityAuthorizer.swift */; }; A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */; }; + A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; + A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; A22906F4DFABDDE03929141C /* AcceptPillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC166485A7B75F489382900A /* AcceptPillButton.swift */; }; A2BBDCD2F99F98CF729EB4B3 /* ContactsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA7FBAB805983E9BDA442D6 /* ContactsCard.swift */; }; A361573C34CF9107E6B8A221 /* SwiftDashSDKWalletCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697D56A525CBF142F4401FE /* SwiftDashSDKWalletCreator.swift */; }; @@ -1195,7 +1221,6 @@ C3DAD268246AA6F10001624F /* DWScreenshotWarningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C3DAD267246AA6F10001624F /* DWScreenshotWarningViewController.m */; }; C3DAD2CF247585C10001624F /* NSPredicate+DWFullTextSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = C3DAD2CE247585C10001624F /* NSPredicate+DWFullTextSearch.m */; }; C5745FA476F615D2C1E378F3 /* SwiftDashSDKReceiveAddressReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */; }; - C5906424C3D379AF0CB83370 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1F21A7592E99814A800518 /* SendScreen.swift */; }; C6138156317BAF449AA76861 /* PaymentsLandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */; }; C6344885F1E0A6C04D3D8437 /* InternalTransferConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */; }; C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F454341FADB260861FE05EB4 /* SendScreenViewController.swift */; }; @@ -1915,15 +1940,6 @@ EE0000042DUMMYID001234567 /* PercentageFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000052DUMMYID001234567 /* PercentageFormatter.swift */; }; EE0000062DUMMYID001234567 /* PercentageFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000052DUMMYID001234567 /* PercentageFormatter.swift */; }; EE4D60EF9BDBB7CC4F7924C7 /* ContactAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */; }; - 5ACSH0002F9A0000000000S3 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; - 5ACTCSTV030000000000SV03 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; - 5ACTCSTC030000000000SC03 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; - 5ACTCSPR030000000000SP03 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; - 5ACTCARD0300000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; - 5ACTCAVM0300000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; - 5ACTDAYG030000000000DG03 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; - 5ACTCROW0300000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; - 5ACTCEVR030000000000EV03 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; EE5F3D5C458E92B69D14BFD8 /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6615F1246B5935365B05BD /* NavigationBar.swift */; }; EF053C0D81A007CC015EB3EE /* InternalTransferViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7249AC40404B20F2AEE590F1 /* InternalTransferViewModel.swift */; }; EF8FE439EB96236940DFBDE7 /* SwiftDashSDKTransactionSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */; }; @@ -1966,27 +1982,6 @@ RC00A0052FA0000000000001 /* ExploreMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */; }; RC00C0012FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; RC00C0022FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; - BCB10EF569912077058577E4 /* ContestedNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1426BD13E794C788646055 /* ContestedNamesService.swift */; }; - EB88594208EAD0E9E1FA752F /* MasternodeVoterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */; }; - F29CC1CE29F3544F9E2F9BE7 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; - 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - D0C564276BD8B9770F0E0499 /* ContestDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */; }; - 1FED991A9D2D236582CE3C99 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; - 8F0D34E5B93E7D2EB9062394 /* ContestedNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1426BD13E794C788646055 /* ContestedNamesService.swift */; }; - F5D223F887ABBFCEB478816F /* MasternodeVoterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */; }; - 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; - 12118430106B8A5EBB2FBC60 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - F34816414D829A45E0762757 /* ContestDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */; }; - AD24D88215A4E51186112B34 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; - 6181C570B638E07D8B369CDB /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; - E503EDEA94E656CAE70E5A11 /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; - D19A81C4F6103AC513D737AA /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; - 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; - A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; - A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; - 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; - 7062FCF792F292D84A0B819B /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; - 553696A24F315D361BC86231 /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2069,7 +2064,6 @@ 0286D4D1CADBBD51F09BE8E8 /* SwapMenuStyles.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapMenuStyles.swift; sourceTree = ""; }; 0448A6769BCF20987BE9960C /* DiagnosticLogExporter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiagnosticLogExporter.swift; sourceTree = ""; }; 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InternalTransferConfirmSheet.swift; sourceTree = ""; }; - 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactPreviewHeader.swift; sourceTree = ""; }; 067B8024942F1481CD9B9CC1 /* PaymentNetworkResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentNetworkResolver.swift; sourceTree = ""; }; 0755F840BCC1E0754D807B04 /* libPods-TodayExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-TodayExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 080B9ECCDC82A7B7FF4039C6 /* ScriptAddressCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ScriptAddressCodec.swift; sourceTree = ""; }; @@ -2615,7 +2609,6 @@ 2AFF01DA243F4559003718DC /* DWDPRegistrationStatus.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWDPRegistrationStatus.m; sourceTree = ""; }; 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletSendService.swift; sourceTree = ""; }; 2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactItem.swift; sourceTree = ""; }; - 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayUserLink.swift; sourceTree = ""; }; 2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageRecordDetailViews.swift; sourceTree = ""; }; 2F134B2E67D68A97892FD760 /* WalletsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletsScreen.swift; sourceTree = ""; }; 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKTransactionSender.swift; sourceTree = ""; }; @@ -2825,18 +2818,10 @@ 48D8BC6623C81DD3F008A668 /* DWPreviewSeedPhraseModel+Mnemonic.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "DWPreviewSeedPhraseModel+Mnemonic.swift"; sourceTree = ""; }; 4BA7FBAB805983E9BDA442D6 /* ContactsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactsCard.swift; sourceTree = ""; }; 4C4833BE986D59F69A555C5F /* PaymentProtocolMessages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentProtocolMessages.swift; sourceTree = ""; }; + 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayUserLink.swift; sourceTree = ""; }; 4E55DD28C4EBE049216291A2 /* HomeUsernameRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeUsernameRow.swift; sourceTree = ""; }; 4E9C4B39B015CD9D8AD4741D /* SwiftDashSDKWalletRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKWalletRuntime.swift; sourceTree = ""; }; 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactAvatarView.swift; sourceTree = ""; }; - 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheet.swift; sourceTree = ""; }; - 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; - 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsCard.swift; sourceTree = ""; }; - 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheetPresenter.swift; sourceTree = ""; }; - 5ACTCARD0100000000000000 /* ContactActivityCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityCard.swift; sourceTree = ""; }; - 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityViewModel.swift; sourceTree = ""; }; - 5ACTDAYG010000000000DG01 /* DayGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DayGrouping.swift; sourceTree = ""; }; - 5ACTCROW0100000000000000 /* ContactActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityRow.swift; sourceTree = ""; }; - 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityEventRow.swift; sourceTree = ""; }; 514AEA122FB324FA00F9B54C /* SwapConvertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConvertView.swift; sourceTree = ""; }; 514AEA4A2FB33B4300F9B54C /* SwapConvertHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConvertHostingController.swift; sourceTree = ""; }; 514AEAD42FB34F8600F9B54C /* OrderPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderPreviewView.swift; sourceTree = ""; }; @@ -2888,15 +2873,33 @@ 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveFundsSheet.swift; sourceTree = ""; }; 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformSyncStatusScreen.swift; sourceTree = ""; }; 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddContactViewModel.swift; sourceTree = ""; }; + 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactPreviewHeader.swift; sourceTree = ""; }; + 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheet.swift; sourceTree = ""; }; + 5ACTCARD0100000000000000 /* ContactActivityCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityCard.swift; sourceTree = ""; }; + 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityViewModel.swift; sourceTree = ""; }; + 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityEventRow.swift; sourceTree = ""; }; + 5ACTCROW0100000000000000 /* ContactActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityRow.swift; sourceTree = ""; }; + 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheetPresenter.swift; sourceTree = ""; }; + 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsCard.swift; sourceTree = ""; }; + 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; + 5ACTDAYG010000000000DG01 /* DayGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DayGrouping.swift; sourceTree = ""; }; + 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MyDashPayUserQRSheet.swift; sourceTree = ""; }; + 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionFilterButton.swift; sourceTree = ""; }; 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuScreen.swift; sourceTree = ""; }; 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuViewModel.swift; sourceTree = ""; }; 5AMMS0002F9A0000000000S1 /* MainMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuScreen.swift; sourceTree = ""; }; 5ANVM0002F9A0000000000S1 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = ""; }; 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayContactViewModel.swift; sourceTree = ""; }; + 5ASF00100000000000000000 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; + 5ASF01100000000000000000 /* SendSourceScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendSourceScreen.swift; sourceTree = ""; }; + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalSendAmountScreen.swift; sourceTree = ""; }; + 5ASF03100000000000000000 /* SendConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendConfirmSheet.swift; sourceTree = ""; }; + 5ASF04100000000000000000 /* SendAddressSummary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendAddressSummary.swift; sourceTree = ""; }; + 5ASF05100000000000000000 /* SendChainStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendChainStyle.swift; sourceTree = ""; }; + 5ASF06100000000000000000 /* SyncGateNote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncGateNote.swift; sourceTree = ""; }; 5B30DA60B985C3CC3ED05B64 /* DWCurrentUserIdentityInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWCurrentUserIdentityInfo.swift; sourceTree = ""; }; 5C3839568283FDC3A6933824 /* DWRegistrationPhaseAdapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWRegistrationPhaseAdapter.swift; sourceTree = ""; }; 5CBC0F417B9E8DE631F9DE2A /* Pods-DashWalletTests.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletTests.testnet.xcconfig"; path = "Target Support Files/Pods-DashWalletTests/Pods-DashWalletTests.testnet.xcconfig"; sourceTree = ""; }; - 5D1F21A7592E99814A800518 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; 5E1461688D08658B938A1066 /* MasternodeAPYCalculator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeAPYCalculator.swift; sourceTree = ""; }; 5EAD111130A0000000A1B201 /* MayaAmountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaAmountFormatter.swift; sourceTree = ""; }; 5EAD222130A0000000A1B202 /* DashAmountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashAmountFormatterTests.swift; sourceTree = ""; }; @@ -3042,7 +3045,6 @@ 75D5F3CF191EC270004AB296 /* DashWallet-Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "DashWallet-Prefix.pch"; sourceTree = ""; }; 75D5F3E5191EC270004AB296 /* DashWalletTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashWalletTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionFilterDialog.swift; sourceTree = ""; }; - 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionFilterButton.swift; sourceTree = ""; }; 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubTransactionSource.swift; sourceTree = ""; }; 75D9EBB72DE5CC3A009416A2 /* AddGiftCardsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGiftCardsTable.swift; sourceTree = ""; }; 75D9EBBB2DE5CD8B009416A2 /* GiftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftCard.swift; sourceTree = ""; }; @@ -3089,7 +3091,6 @@ 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletScreenshotsUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalScreen.swift; sourceTree = ""; }; 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKIdentityProfileSheet.swift; sourceTree = ""; }; - 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MyDashPayUserQRSheet.swift; sourceTree = ""; }; 7FA744C4A6023E26A3B5B860 /* DWLogger.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = DWLogger.h; sourceTree = ""; }; 82926C69F3BB675BEF1A1FFD /* DWProfileUpdateCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWProfileUpdateCoordinator.swift; sourceTree = ""; }; 8571065059CF5168F45C86E4 /* Pods-dashpay.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dashpay.testflight.xcconfig"; path = "Target Support Files/Pods-dashpay/Pods-dashpay.testflight.xcconfig"; sourceTree = ""; }; @@ -3112,6 +3113,7 @@ A11E57A70003C0DE0003B003 /* ClaimInvitationScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClaimInvitationScreen.swift; sourceTree = ""; }; A11E57A70009C0DE0009B009 /* SyncingAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncingAlertView.swift; sourceTree = ""; }; A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletWipeSerialExecutorTests.swift; sourceTree = ""; }; + A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = ""; }; A59D5720437F8A4137605940 /* PaymentRequestVerifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentRequestVerifier.swift; sourceTree = ""; }; A62C70E4EBB7458C362B7669 /* InternalTransferScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InternalTransferScreen.swift; sourceTree = ""; }; A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKReceiveAddressReader.swift; sourceTree = ""; }; @@ -3608,6 +3610,7 @@ E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformAddressSyncCoordinator.swift; sourceTree = ""; }; E7333F2D05D0DD2D4373FF9B /* StorageExplorerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageExplorerView.swift; sourceTree = ""; }; E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingScreen.swift; sourceTree = ""; }; + 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingTabSelector.swift; sourceTree = ""; }; E83D8CEAFF69B6D666C02116 /* Pods-WatchApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchApp.release.xcconfig"; path = "Target Support Files/Pods-WatchApp/Pods-WatchApp.release.xcconfig"; sourceTree = ""; }; E90D495F8DF1E8EE8A84C8E8 /* SwiftDashSDKSPVStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKSPVStatusScreen.swift; sourceTree = ""; }; E950113F9E258B2F1CC0D50C /* DWContestedNameStatusService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWContestedNameStatusService.swift; sourceTree = ""; }; @@ -3670,16 +3673,6 @@ RC00A0032FA0000000000001 /* TopIntro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopIntro.swift; sourceTree = ""; }; RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreMenuScreen.swift; sourceTree = ""; }; RC00C0032FA0000000000001 /* CSVExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExportSheet.swift; sourceTree = ""; }; - FC1426BD13E794C788646055 /* ContestedNamesService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestedNamesService.swift; sourceTree = ""; }; - 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoterRegistry.swift; sourceTree = ""; }; - 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoteCaster.swift; sourceTree = ""; }; - 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameVotingScreen.swift; sourceTree = ""; }; - 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestDetailScreen.swift; sourceTree = ""; }; - FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = ""; }; - ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameRequestStatusScreen.swift; sourceTree = ""; }; - C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BulkVoteSheet.swift; sourceTree = ""; }; - A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = ""; }; - 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteHistoryDAO.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -4968,9 +4961,8 @@ C9F42F9E29DA82E5001BC549 /* PayableViewController.swift */, D788CCB6B35B655BD713129E /* ChainNetworkToggle.swift */, 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */, - 5D1F21A7592E99814A800518 /* SendScreen.swift */, + 5ASGRPS10000000000000000 /* Send */, F454341FADB260861FE05EB4 /* SendScreenViewController.swift */, - 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, ); path = Pay; sourceTree = ""; @@ -6765,6 +6757,35 @@ path = PlatformAddress; sourceTree = ""; }; + 5ACTGRPA0100000000000000 /* Activity */ = { + isa = PBXGroup; + children = ( + 5ACTGRPA0200000000000000 /* Components */, + 5ACTCARD0100000000000000 /* ContactActivityCard.swift */, + 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */, + 5ACTDAYG010000000000DG01 /* DayGrouping.swift */, + ); + path = Activity; + sourceTree = ""; + }; + 5ACTGRPA0200000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5ACTCROW0100000000000000 /* ContactActivityRow.swift */, + 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5ACTGSET010000000000GS01 /* Settings */ = { + isa = PBXGroup; + children = ( + 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */, + 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */, + ); + path = Settings; + sourceTree = ""; + }; 5AGOV0002F9A0000000000G0 /* Governance */ = { isa = PBXGroup; children = ( @@ -6774,6 +6795,29 @@ path = Governance; sourceTree = ""; }; + 5ASGRPC10000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5ASF04100000000000000000 /* SendAddressSummary.swift */, + 5ASF05100000000000000000 /* SendChainStyle.swift */, + 5ASF06100000000000000000 /* SyncGateNote.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5ASGRPS10000000000000000 /* Send */ = { + isa = PBXGroup; + children = ( + 5ASGRPC10000000000000000 /* Components */, + 5ASF00100000000000000000 /* SendScreen.swift */, + 5ASF01100000000000000000 /* SendSourceScreen.swift */, + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */, + 5ASF03100000000000000000 /* SendConfirmSheet.swift */, + 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, + ); + path = Send; + sourceTree = ""; + }; 622476CB6BA5E7F2315CC2AF /* Contacts */ = { isa = PBXGroup; children = ( @@ -6793,9 +6837,18 @@ path = Codec; sourceTree = ""; }; + 5APLTSGRP000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */, + ); + path = Components; + sourceTree = ""; + }; 6916981A49557073ABA80EAB /* Landing */ = { isa = PBXGroup; children = ( + 5APLTSGRP000000000000000 /* Components */, E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */, FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */, B401E660599FEF6B18087F91 /* PaymentsLandingHostingController.swift */, @@ -7801,35 +7854,6 @@ path = DashPay; sourceTree = ""; }; - 5ACTGRPA0100000000000000 /* Activity */ = { - isa = PBXGroup; - children = ( - 5ACTGRPA0200000000000000 /* Components */, - 5ACTCARD0100000000000000 /* ContactActivityCard.swift */, - 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */, - 5ACTDAYG010000000000DG01 /* DayGrouping.swift */, - ); - path = Activity; - sourceTree = ""; - }; - 5ACTGRPA0200000000000000 /* Components */ = { - isa = PBXGroup; - children = ( - 5ACTCROW0100000000000000 /* ContactActivityRow.swift */, - 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */, - ); - path = Components; - sourceTree = ""; - }; - 5ACTGSET010000000000GS01 /* Settings */ = { - isa = PBXGroup; - children = ( - 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */, - 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */, - ); - path = Settings; - sourceTree = ""; - }; C943B3502A40A54500AF23C5 /* Contacts */ = { isa = PBXGroup; children = ( @@ -10121,10 +10145,17 @@ E16E2BA180551E3B6661B9F4 /* PlatformCreditsFormatter.swift in Sources */, F1F870569B75C5B1452C5D65 /* ChainNetworkToggle.swift in Sources */, A72BF1D1A1F3F067215CE04C /* QRCodeGenerator.swift in Sources */, - 25C88271468A4CD23F34A6E9 /* SendScreen.swift in Sources */, C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */, 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */, + 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */, + 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */, + 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */, + 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */, + 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */, + 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */, + 5ASB00100000000000000000 /* SendScreen.swift in Sources */, 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */, + 5APLTSB10000000000000000 /* PaymentsLandingTabSelector.swift in Sources */, 12DE15416F9F7A0B8749CAF1 /* PaymentsLandingViewModel.swift in Sources */, 908AB7EFCC69D3828BBD70A8 /* PaymentsLandingHostingController.swift in Sources */, 0EB4679BC135B464D90CDC8B /* TransferTimingSheet.swift in Sources */, @@ -11145,10 +11176,17 @@ 3705C73F15BCD91AF70940B9 /* PlatformCreditsFormatter.swift in Sources */, 56F5C9FD51AE5508BB5E579B /* ChainNetworkToggle.swift in Sources */, 9B54AF317E7728C18646F702 /* QRCodeGenerator.swift in Sources */, - C5906424C3D379AF0CB83370 /* SendScreen.swift in Sources */, 7C8C17249DC2DD0ECB3F2720 /* SendScreenViewController.swift in Sources */, A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */, + 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */, + 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */, + 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */, + 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */, + 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */, + 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */, + 5ASB00200000000000000000 /* SendScreen.swift in Sources */, 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */, + 5APLTSB20000000000000000 /* PaymentsLandingTabSelector.swift in Sources */, C6138156317BAF449AA76861 /* PaymentsLandingViewModel.swift in Sources */, 72F0556F329AE232966A8B4A /* PaymentsLandingHostingController.swift in Sources */, 1C2999FF1FC854E7DB138250 /* TransferTimingSheet.swift in Sources */, @@ -13304,7 +13342,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dashpay/DashUIKit"; requirement = { - branch = master; + branch = "feat/bottom-sheet-background"; kind = branch; }; }; diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json new file mode 100644 index 000000000..d7b09bcee --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-arrow-down.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg new file mode 100644 index 000000000..a5b1fb320 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json new file mode 100644 index 000000000..59bdabb88 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-arrow-up.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg new file mode 100644 index 000000000..630a4d675 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json new file mode 100644 index 000000000..b867bd34b --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-transfer.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg new file mode 100644 index 000000000..65e37bdb2 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index d1d5b0cbe..629d29a2e 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -56,6 +56,19 @@ final class PreparedStandardSend: NSObject { /// BEFORE the broadcast (`ensureOnlineAction`) leave a retryable object. private let claimLock = NSLock() private var broadcastState = BroadcastState.ready + private var broadcastActionInvoked = false + + /// `true` once `broadcastAction` has been invoked, whatever it returned or + /// threw. The captured `FinalizedCoreTransaction` is single-shot, so from + /// that point on this object can only surface the SDK's already-consumed + /// error — a caller offering "try again" must re-prepare instead of + /// re-broadcasting it. Failures raised BEFORE the broadcast (the online + /// precheck) leave this `false`, and the object rebroadcastable. + var isBroadcastConsumed: Bool { + claimLock.lock() + defer { claimLock.unlock() } + return broadcastActionInvoked + } init( txData: Data, @@ -124,6 +137,9 @@ final class PreparedStandardSend: NSObject { // was prepared. Claiming the state first ensures a stored unknown // result is returned without consulting changing network state. try ensureOnlineAction() + claimLock.lock() + broadcastActionInvoked = true + claimLock.unlock() outcome = try broadcastAction() } catch { claimLock.lock() diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift new file mode 100644 index 000000000..d06e35976 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift @@ -0,0 +1,130 @@ +// +// PaymentsLandingTabSelector.swift +// DashWallet +// +// The Receive / Internal transfer / Send switcher at the top of the payments +// landing. +// + +import SwiftUI +import DashUIKit + +/// A row of pills, one per tab. The active one carries the tab's colour and +/// its label and takes the space that is left; the others shrink to their +/// icon. +/// +/// Takes the tabs and a binding rather than the view model: which tabs are +/// offered is the presentation's decision — the full landing shows all three, +/// the balance-row receive sheet narrows to two — and this view has no reason +/// to know why. +struct PaymentsLandingTabSelector: View { + + private enum Layout { + static let spacing: CGFloat = 8 + static let contentGap: CGFloat = 8 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 10 + /// The inactive pill holds its icon and nothing else, so it is sized + /// rather than left to hug — otherwise the three widths would shift + /// with each glyph. + static let collapsedWidth: CGFloat = 60 + /// Height of the box the glyph is centred in, whatever its own size. + static let glyphBoxHeight: CGFloat = 20 + } + + let tabs: [PaymentsLandingTab] + @Binding var selection: PaymentsLandingTab + + var body: some View { + HStack(spacing: Layout.spacing) { + ForEach(tabs) { tab in + Button { selection = tab } label: { + pill(for: tab) + } + .buttonStyle(.plain) + } + } + // Scoped to the pills rather than wrapping the binding write in + // `withAnimation`: that would animate whatever else observes the + // selection — the whole tab's content — which is the host's call to + // make, not this view's. + .animation(.snappy(duration: 0.25), value: selection) + } + + @ViewBuilder + private func pill(for tab: PaymentsLandingTab) -> some View { + let isSelected = selection == tab + + HStack(spacing: Layout.contentGap) { + icon(for: tab) + + if isSelected { + Text(tab.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.dash.whiteText) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + // Fades rather than appearing at full width: the pill is + // still growing underneath it, and a hard cut reads as a + // second, competing change. + .transition(.opacity) + } + } + .padding(.horizontal, Layout.horizontalPadding) + .padding(.vertical, Layout.verticalPadding) + // Selected takes what's left; the rest keep the fixed icon width. + .frame(maxWidth: isSelected ? .infinity : Layout.collapsedWidth) + .background(isSelected ? tab.accent : Color.dash.gray300Alpha20) + .clipShape(Capsule()) + } + + private func icon(for tab: PaymentsLandingTab) -> some View { + // Both dimensions are set from the design: the three glyphs have + // different aspect ratios, and a single square frame would squash + // the arrows. + Image(tab.icon.name) + .resizable() + .frame(width: tab.icon.width, height: tab.icon.height) + .foregroundStyle(selection == tab ? Color.dash.whiteText : Color.dash.secondaryText) + .frame(height: Layout.glyphBoxHeight) + } +} + +#if DEBUG + +private struct TabSelectorHost: View { + let tabs: [PaymentsLandingTab] + @State var selection: PaymentsLandingTab + + var body: some View { + PaymentsLandingTabSelector(tabs: tabs, selection: $selection) + .padding() + .background(Color.dash.primaryBackground) + } +} + +/// One per state — the active pill's fill is the tab's own colour, so the +/// three look materially different. +#Preview("Receive") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .receive) +} + +#Preview("Internal transfer") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .internalTransfer) +} + +#Preview("Send") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send) +} + +/// The balance-row receive sheet offers two, so the active pill grows wider. +#Preview("Narrowed") { + TabSelectorHost(tabs: [.receive, .internalTransfer], selection: .receive) +} + +#Preview("Dark") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send) + .preferredColorScheme(.dark) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index c98ca2d82..0980382d6 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -9,7 +9,6 @@ import UIKit struct PaymentsLandingScreen: View { @ObservedObject var viewModel: PaymentsLandingViewModel - @Environment(\.colorScheme) private var colorScheme var onClose: () -> Void var onCopyAddress: () -> Void @@ -39,18 +38,41 @@ struct PaymentsLandingScreen: View { var body: some View { // Tighter chrome when a tab embeds a full form (transfer or send) — - // its amount + cards + keypad need most of the sheet. + // its amount + cards + keypad need most of the sheet. It shortens the + // gap BELOW the selector only: sizing the whole stack by it moved the + // header and the pills too, so switching tabs jumped them 8pt. let isEmbeddedForm = (viewModel.activeTab == .internalTransfer && embeddedTransferViewModel != nil) || viewModel.activeTab == .send - VStack(alignment: .center, spacing: isEmbeddedForm ? 12 : 20) { + VStack(alignment: .center, spacing: 0) { if showsHeader { header } - tabSelector + PaymentsLandingTabSelector( + tabs: viewModel.visibleTabs, + selection: $viewModel.activeTab) .padding(.horizontal, 20) - .padding(.top, showsHeader ? 0 : 12) + // The pills carry no container of their own, so they need the + // gap the boxed control used to get from its own padding and + // edge — under the header most of all, where the close button + // sits right above them. Fixed, so it reads the same on every + // tab. + .padding(.top, showsHeader ? 20 : 12) + .padding(.bottom, isEmbeddedForm ? 12 : 20) + tabContent + } + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + } + + /// Cross-faded rather than swapped outright — the pills animate their own + /// morph, and an instant content change beside that reads as a glitch. + /// Deliberately short: two of these tabs carry a keypad and a scroll view, + /// and a long fade on those looks slow rather than smooth. + @ViewBuilder + private var tabContent: some View { + Group { switch viewModel.activeTab { case .receive: receiveContent @@ -92,8 +114,7 @@ struct PaymentsLandingScreen: View { showsHeader: false) } } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) + .animation(.easeInOut(duration: 0.18), value: viewModel.activeTab) } // MARK: - Header @@ -118,50 +139,12 @@ struct PaymentsLandingScreen: View { .padding(.top, 10) } - private var headerTitle: String { - switch viewModel.activeTab { - case .receive: return NSLocalizedString("Receive", comment: "") - case .internalTransfer: return NSLocalizedString("Internal transfer", comment: "") - case .send: return NSLocalizedString("Send", comment: "") - } - } + /// The tab's own label — the selector shows the same words on the active + /// pill, and two copies would be free to drift. + private var headerTitle: String { viewModel.activeTab.title } // MARK: - Tab selector - private var tabSelector: some View { - HStack(spacing: 4) { - ForEach(viewModel.visibleTabs) { tab in - Button(action: { viewModel.activeTab = tab }) { - VStack(spacing: 4) { - Image(systemName: tab.iconSystemName) - .font(.system(size: 16, weight: .semibold)) - Text(tab.title) - .font(.system(size: 13, weight: .medium)) - } - .foregroundColor(viewModel.activeTab == tab ? Color.dash.primaryText : Color.dash.secondaryText) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 8) - // Selected pill: a solid white raised card in light mode; a - // translucent light fill in dark so the primaryText label stays - // legible (pure white would be invisible on the dark selector). - // Mirrors the app's SegmentedControl selected-fill treatment. - .fill(viewModel.activeTab == tab - ? (colorScheme == .dark ? Color.dash.whiteAlpha20 : Color.dash.white) - : Color.clear) - .shadow( - color: viewModel.activeTab == tab - ? Color.dash.shadow : .clear, - radius: 2, x: 0, y: 1)) - } - } - } - .padding(4) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - } - // MARK: - Receive private var receiveContent: some View { diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift index 29aa865c9..31fa974cd 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift @@ -6,6 +6,8 @@ import Combine import Foundation import SwiftDashSDK +import SwiftUI +import DashUIKit import UIKit enum PaymentsLandingTab: String, CaseIterable, Identifiable { @@ -18,16 +20,27 @@ enum PaymentsLandingTab: String, CaseIterable, Identifiable { var title: String { switch self { case .receive: return NSLocalizedString("Receive", comment: "") - case .internalTransfer: return NSLocalizedString("Internal", comment: "") + case .internalTransfer: return NSLocalizedString("Internal transfer", comment: "") case .send: return NSLocalizedString("Send", comment: "") } } - var iconSystemName: String { + /// Asset name and the size the glyph is drawn at — the three differ, and + /// one shared size would distort them. + var icon: (name: String, width: CGFloat, height: CGFloat) { switch self { - case .receive: return "arrow.down" - case .internalTransfer: return "arrow.up.arrow.down" - case .send: return "arrow.up" + case .receive: return ("payments-option-arrow-down", 8.881, 14) + case .internalTransfer: return ("payments-option-transfer", 15.297, 16) + case .send: return ("payments-option-arrow-up", 7.771, 14) + } + } + + /// Fill of the pill while this tab is the active one. + var accent: Color { + switch self { + case .receive: return Color.dash.green + case .internalTransfer: return Color.dash.lightBlue + case .send: return Color.dash.blue } } } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift new file mode 100644 index 000000000..b83cabcd2 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift @@ -0,0 +1,150 @@ +// +// SendAddressSummary.swift +// DashWallet +// +// Where the money is going, as read-only context on the steps after the +// address has been decided. +// + +import SwiftUI +import DashUIKit + +/// Destination context for the source and amount steps. +/// +/// Not a field: by this point the address is settled, and the boxed control +/// with a pencil this replaced offered an edit the step could not make — +/// tapping it only went back, which the navigation bar's own back button +/// already does. So it reads as a heading, the way the merchant header does +/// on the gift-card screens. +struct SendAddressSummary: View { + + /// Mirrors `MerchantCellRow`'s card metrics — this is the same idea in a + /// different flow, so it should not be a second set of numbers. + private enum Layout { + static let textSpacing: CGFloat = 4 + static let contentPadding: CGFloat = 20 + static let cornerRadius: CGFloat = 16 + } + + @ObservedObject var viewModel: SendViewModel + /// Show which balance funds the send, and how much is in it. Off on the + /// source step, where the cards below already say both and the choice is + /// still open; on for the amount step, where the source is settled and + /// the number is what the amount is being measured against. + var showsSource: Bool = false + + /// Mirrors the app-wide flag the home balance uses, so hiding in one + /// place hides everywhere. `@State` seeded from it, since a plain read + /// would not re-render on toggle. + @State private var isBalanceHidden = DWGlobalOptions.sharedInstance().balanceHidden + + var body: some View { + VStack(alignment: .leading, spacing: Layout.textSpacing) { + Text(NSLocalizedString("Send", comment: "Send screen")) + .dashFont(.title1) + .foregroundStyle(Color.dash.primaryText) + + Text(String(format: NSLocalizedString("to %@", comment: "Send screen"), viewModel.trimmedAddress)) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + .lineLimit(1) + .truncationMode(.middle) + + if let destination = viewModel.destination { + Text(String(format: NSLocalizedString("to %@", comment: "Send screen"), destinationTitle(destination))) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + } + + if showsSource { + HStack(spacing: 0) { + Text(String( + format: NSLocalizedString("from %@ ", comment: "Send screen — the balance being spent"), + sourceTitle(viewModel.source))) + + + sourceBalanceLine + } + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var sourceBalanceLine: some View { + HStack(spacing: 6) { + + if isBalanceHidden { + Text("••••••••") + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + } else { + DashUIKit.DashAmount( + amount: Int64(clamping: viewModel.selectedSourceBalanceDuffs), + fontSize: 15, + sign: DashAmountSign.none + ) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + } + + Button { + isBalanceHidden.toggle() + DWGlobalOptions.sharedInstance().balanceHidden = isBalanceHidden + } label: { + Image(systemName: isBalanceHidden ? "eye.slash.fill" : "eye.fill") + .font(.system(size: 12)) + .foregroundStyle(Color.dash.secondaryText) + .frame(width: 28, height: 28) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel(isBalanceHidden + ? NSLocalizedString("Show balance", comment: "Send screen") + : NSLocalizedString("Hide balance", comment: "Send screen")) + } + } +} + +#if DEBUG + +/// The source step: destination only — the From cards below carry the choice. +#Preview("Destination only") { + VStack(alignment: .leading, spacing: 16) { + SendAddressSummary(viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core)) + + SendAddressSummary(viewModel: .preview( + address: "dashs1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + destination: .shielded(raw43: Data(repeating: 0x2a, count: 43)))) + } + .padding() + .background(Color.dash.primaryBackground) +} + +/// The amount step: the source is settled, so it says which balance is being +/// spent and how much is in it. Whether the number is masked follows the +/// app-wide flag, so this preview shows it as the device last left it. +#Preview("With source") { + VStack(alignment: .leading, spacing: 16) { + SendAddressSummary( + viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core, + coreBalanceDuffs: 158_998_000), + showsSource: true) + + SendAddressSummary( + viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core, + source: .shielded), + showsSource: true) + } + .padding() + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift new file mode 100644 index 000000000..15f71d3db --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift @@ -0,0 +1,107 @@ +// +// SendChainStyle.swift +// DashWallet +// +// Icons, titles and badges for a chain network or destination kind. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Shared helpers + +func sourceIconName(_ network: ChainNetwork) -> String { + switch network { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } +} + +func sourceTitle(_ network: ChainNetwork) -> String { + switch network { + case .core: return NSLocalizedString("Transparent", comment: "Balance breakdown") + case .platform: return NSLocalizedString("Platform", comment: "Dash Platform chain") + case .shielded: return NSLocalizedString("Shielded", comment: "") + } +} + +func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { + HStack(spacing: 4) { + Image(systemName: destinationIconName(destination)) + .font(.system(size: 10, weight: .semibold)) + Text(destinationTitle(destination)) + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.dashBlue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dashBlue.opacity(0.1)) + .clipShape(Capsule()) +} + +func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } +} + +func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") + case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") + case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") + } +} + +/// Middle-truncated address display shared by the screen's clipboard chip +/// and the confirm sheet's To row. +func truncateMiddle(_ s: String, visible: Int = 8) -> String { + guard s.count > visible * 2 + 3 else { return s } + let head = s.prefix(visible) + let tail = s.suffix(visible) + return "\(head)…\(tail)" +} + +#if DEBUG + +/// Every chain and destination the send flow can label, side by side — this +/// file exists so the two lists cannot drift apart. +#Preview("Chain and destination styling") { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Sources").font(.caption).foregroundColor(.dash.tertiaryText) + ForEach([ChainNetwork.core, .platform, .shielded], id: \.self) { network in + HStack(spacing: 8) { + Image(systemName: sourceIconName(network)) + .foregroundColor(.dashBlue) + Text(sourceTitle(network)) + } + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Destinations").font(.caption).foregroundColor(.dash.tertiaryText) + destinationBadge(.core) + destinationBadge(.platform) + destinationBadge(.shielded(raw43: Data(repeating: 0x2a, count: 43))) + } + + Divider() + + Text(truncateMiddle("yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY")) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.secondaryText) + } + .padding() + .background(Color.dash.primaryBackground) +} + +#endif + diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift new file mode 100644 index 000000000..03f180985 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift @@ -0,0 +1,45 @@ +// +// SyncGateNote.swift +// DashWallet +// +// The note shown while the chain is still syncing. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +/// Inline explanation for a Continue disabled by the chain-sync gate: +/// Core-funded sends stay off until `SyncingActivityMonitor` reports +/// `.syncDone` (a stale UTXO set can't safely fund a spend). Shared by +/// the Send and Internal transfer screens. +struct SyncGateNote: View { + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 13)) + .foregroundColor(.orange) + Text(NSLocalizedString( + "Your wallet is still syncing. Sending from your Transparent balance will be available once syncing completes.", + comment: "Core send blocked until chain sync completes")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.orange.opacity(0.08)) + .cornerRadius(10) + } +} + +#if DEBUG + +#Preview { + SyncGateNote() + .padding() + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift new file mode 100644 index 000000000..0b4a2e3ca --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -0,0 +1,276 @@ +// +// ExternalSendAmountScreen.swift +// DashWallet +// +// Step three: the amount keypad, and the route it commits to. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 3: amount + +/// The final step: the address and source are settled and shown read-only, +/// leaving the amount keypad and the balance/affordability validation. +/// +/// Every route, Transparent → Transparent included, confirms in +/// `SendConfirmSheet` — Core → Core prepares (auth + build/sign, showing the +/// real fee) and broadcasts through `WalletSendService` there instead of +/// `ShieldedTransferCoordinator`. +struct ExternalSendAmountScreen: View { + @ObservedObject var viewModel: SendViewModel + /// Pop back to the source step. + var onBack: () -> Void + /// A route finished successfully (confirm sheet's Done). + var onSendCompleted: () -> Void + + @State private var showConfirm = false + /// Core route: built and signed here, before the sheet is presented. The + /// auth gate inside it puts up the PIN, and UIKit refuses to present that + /// over a sheet that is still animating in — doing it from the sheet's own + /// `.task` failed the send on the first attempt every time. + @StateObject private var coreSend = CoreSendConfirmController() + @State private var isPreparingCoreSend = false + + var body: some View { + VStack(spacing: 0) { + // DashUIKit's bar carries its own height and horizontal + // insets, so the manual padding the hand-rolled header needed + // goes with it. + DashUIKit.NavigationBar(leading: { + NavigationBarElement.back.button { onBack() } + }) + + VStack(spacing: 20) { + SendAddressSummary(viewModel: viewModel, showsSource: true) + + amountRow + + // A signature that could not be produced (offline, chain not + // synced) keeps the user here — say why, or Continue would + // just look broken. + if let message = coreSend.failureMessage { + TransferAmountValidationNote(message: message) + .padding(.horizontal, 20) + } else if let message = viewModel.amountValidationMessage { + TransferAmountValidationNote(message: message) + .padding(.horizontal, 20) + } + } + .padding(.horizontal, 20) + .padding(.top, 10) + + Spacer() + + keyboardSection + } + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + .sheet(isPresented: $showConfirm) { + if let route = viewModel.route { + SendConfirmSheet( + route: route, + destinationAddress: viewModel.trimmedAddress, + destinationRaw43: shieldedRecipientRaw43, + dashDuffs: viewModel.dashDuffs, + creditsAmount: viewModel.creditsPreview, + fiatText: viewModel.fiatAmountString, + withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, + isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, + isFullShieldedSweep: viewModel.isFullShieldedSweep, + coreSend: coreSend, + onCancel: { showConfirm = false }, + onCompleted: { + showConfirm = false + onSendCompleted() + }) + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } + } + } + + private var shieldedRecipientRaw43: Data? { + if case .shielded(let raw43) = viewModel.destination { return raw43 } + return nil + } + + // MARK: - Amount + + private var amountRow: some View { + EnterAmountView( + primaryAmount: dashAmountText, + secondaryAmount: fiatAmountText, + primaryCurrency: .dash, + secondaryCurrency: .fiat(viewModel.fiatCurrencyCode), + isPrimarySelected: isDashInputSelected, + currencyCodes: amountCurrencyCodes, + selectedCurrencyCode: selectedAmountCurrencyCode, + onMax: { viewModel.fillMaxFromWallet() }, + onSwap: toggleAmountUnit, + onCurrencyTap: toggleAmountUnit, + onSelectInputType: selectAmountCurrency + ) + } + + // MARK: - Keyboard + + private var keyboardSection: some View { + NumericKeyboardView( + value: keypadBinding, + showDecimalSeparator: true, + actionButtonText: NSLocalizedString("Continue", comment: ""), + actionEnabled: viewModel.canContinue, + inProgress: isPreparingCoreSend, + actionHandler: continueAction + ) + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .background(Color.dash.secondaryBackground) + .clipShape(.rect(cornerRadius: 20)) + .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) + } + + private func continueAction() { + guard let route = viewModel.route else { return } + guard route == .coreToCore else { + showConfirm = true + return + } + // Sign first, then present: the sheet opens already knowing the real + // fee, and the PIN goes up over this screen rather than over a sheet + // in mid-presentation. Same order the legacy payment processor uses. + Task { + isPreparingCoreSend = true + defer { isPreparingCoreSend = false } + await coreSend.prepare( + address: viewModel.trimmedAddress, + amountDuffs: viewModel.dashDuffsUnsigned) + // A refused or cancelled signature keeps the user here with the + // reason, rather than opening a confirm sheet that has nothing to + // confirm. + guard coreSend.isReady else { return } + showConfirm = true + } + } + + /// The source picked on the previous step, read-only. Tapping goes back. + private var fromSummary: some View { + Button(action: onBack) { + HStack(spacing: 10) { + Image(systemName: sourceIconName(viewModel.source)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dashBlue) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("From", comment: "")) + .font(.caption) + .foregroundColor(.secondary) + Text(sourceTitle(viewModel.source)) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.secondaryBackground) + .cornerRadius(10) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + } + + // MARK: - Amount helpers + + private var isDashInputSelected: Bool { + viewModel.unit == .dash + } + + private var dashAmountText: String { + switch viewModel.unit { + case .dash: + return keypadBinding.wrappedValue + case .fiat: + guard viewModel.parsedDashAmount > 0 else { return "" } + return InternalTransferViewModel.formatTyped( + viewModel.parsedDashAmount, + fractionDigits: 8) + } + } + + private var fiatAmountText: String { + switch viewModel.unit { + case .dash: + guard viewModel.parsedDashAmount > 0, + let fiatAmount = try? CurrencyExchanger.shared.convertDash( + amount: viewModel.parsedDashAmount, + to: viewModel.fiatCurrencyCode) + else { return "" } + return InternalTransferViewModel.formatTyped( + fiatAmount, + fractionDigits: 2) + case .fiat: + return keypadBinding.wrappedValue + } + } + + private var amountCurrencyCodes: [String] { + ["DASH", viewModel.fiatCurrencyCode] + } + + private var selectedAmountCurrencyCode: String { + isDashInputSelected ? "DASH" : viewModel.fiatCurrencyCode + } + + private func toggleAmountUnit() { + viewModel.unit = isDashInputSelected ? .fiat : .dash + } + + private func selectAmountCurrency(_ currencyCode: String) { + viewModel.unit = + currencyCode.caseInsensitiveCompare("DASH") == .orderedSame + ? .dash + : .fiat + } + + private var keypadBinding: Binding { + Binding( + get: { viewModel.amountText == "0" ? "" : viewModel.amountText }, + set: { newValue in + if newValue.isEmpty { + viewModel.amountText = "0" + } else { + viewModel.amountText = newValue + } + }) + } +} + +#if DEBUG + +#Preview("Amount") { + ExternalSendAmountScreen( + viewModel: .preview(address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core), + onBack: {}, onSendCompleted: {}) + .background(Color.dash.primaryBackground) +} + +/// Shielded source: confirms in `SendConfirmSheet`, same as every other route. +#Preview("From Shielded") { + ExternalSendAmountScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core, + source: .shielded), + onBack: {}, onSendCompleted: {}) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift new file mode 100644 index 000000000..2d9832bb9 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift @@ -0,0 +1,695 @@ +// +// SendConfirmSheet.swift +// DashWallet +// +// Confirmation half-sheet for every external send route. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - CoreSendConfirmController + +/// Drives the Core → Core leg of `SendConfirmSheet`. Every other route runs +/// through `ShieldedTransferCoordinator`; a plain L1 send has no shielding/ +/// asset-lock/platform stages, so it gets its own minimal state machine +/// instead of borrowing a coordinator phase that doesn't apply to it. +/// +/// Shape: `prepareStandardSendForConfirmation` runs the auth gate and +/// builds + signs the transaction WITHOUT broadcasting — that happens on +/// appear, so the sheet can show the real fee off the signed transaction +/// instead of an estimate. `.ready` is the only phase with a live Confirm +/// button; tapping it calls `PreparedStandardSend.broadcast()`, the explicit +/// user-confirm step. +@MainActor +final class CoreSendConfirmController: ObservableObject { + + enum Phase: Equatable { + /// Nothing attempted yet, or the user backed out of the PIN — the + /// same resting state, because a cancelled prompt is a decision and + /// has nothing to report. + case idle + /// Auth gate + build/sign in flight (`prepareStandardSendForConfirmation`). + case signing + /// Prepared: `feeDuffs` holds the real fee off the signed transaction. + case ready + /// `PreparedStandardSend.broadcast()` in flight. + case broadcasting + case success + /// Broadcast accepted by relay but its result couldn't be confirmed — + /// terminal + non-retryable, mirrors + /// `ShieldedTransferCoordinator.Phase.submittedUnconfirmed`. + case submittedUnconfirmed + case failed(String) + } + + @Published private(set) var phase: Phase = .idle + /// The exact fee (duffs) off the signed transaction. `nil` until + /// `.ready` — the summary row shows "—" rather than a guess until then. + @Published private(set) var feeDuffs: UInt64? + + /// Signed and waiting for the user's confirmation. + var isReady: Bool { phase == .ready } + + /// Why the signature could not be produced, for the step that asked. + var failureMessage: String? { + if case let .failed(message) = phase { return message } + return nil + } + + private var prepared: PreparedStandardSend? + + /// Build + sign, without broadcasting. Run by the amount step before it + /// presents the confirm sheet — the PIN this puts up cannot be presented + /// over a sheet that is still appearing. Safe to call again from `retry` + /// after a prepare-time failure (auth cancelled, offline, chain not + /// synced, ...). + func prepare(address: String, amountDuffs: UInt64) async { + phase = .signing + prepared = nil + feeDuffs = nil + do { + let result = try await WalletSendService.shared.prepareStandardSendForConfirmation( + address: address, amount: amountDuffs) + prepared = result + feeDuffs = result.fee + phase = .ready + } catch { + phase = WalletSendService.isAuthenticationCancelledError(error as NSError) + ? .idle + : .failed(Self.message(for: error)) + } + } + + /// Explicit user-confirm step: broadcasts the already-prepared, already- + /// signed transaction. No-op unless a prepare succeeded. + func confirmBroadcast() async { + guard case .ready = phase else { return } + await broadcastPrepared() + } + + /// `Try again` from `.failed`. Re-broadcasts the SAME signed transaction + /// only while it is still intact — a failure raised before the broadcast + /// reached the SDK (the online precheck). Once the broadcast ran, the + /// captured transaction is consumed and `broadcastPrepared` has dropped + /// it, so this re-prepares (auth + build + sign) instead of replaying an + /// object that could only report the SDK's already-consumed error. A + /// prepare-time failure (nothing was ever signed) re-prepares too. + /// Unknown outcomes are terminal and never reach `.failed`. + func retry(address: String, amountDuffs: UInt64) async { + guard case .failed = phase else { return } + if prepared != nil { + await broadcastPrepared() + } else { + await prepare(address: address, amountDuffs: amountDuffs) + } + } + + private func broadcastPrepared() async { + guard let prepared else { return } + phase = .broadcasting + do { + try prepared.broadcast() + phase = .success + } catch { + if WalletSendService.isBroadcastUnknownError(error as NSError) { + phase = .submittedUnconfirmed + } else { + // A rejection (or any local failure raised once the broadcast + // reached the SDK) consumed the single-shot transaction. Drop + // it — and the fee read off it — so `Try again` re-signs + // rather than replaying a consumed object. A pre-broadcast + // failure keeps it, and retry re-broadcasts as before. + if prepared.isBroadcastConsumed { + self.prepared = nil + feeDuffs = nil + } + phase = .failed(Self.message(for: error)) + } + } + } + + private static func message(for error: Error) -> String { + (error as NSError).localizedDescription + } +} + +// MARK: - SendConfirmSheet + +/// Confirmation half-sheet for every external send route. Same skeleton as +/// `InternalTransferConfirmSheet` (summary → progress checklist → success); +/// the To row is the recipient's address. Every route except Core → Core +/// executes via `ShieldedTransferCoordinator`'s external-destination legs; +/// Core → Core executes via `CoreSendConfirmController` / +/// `WalletSendService` instead — see that type's doc for why it doesn't +/// share the coordinator. +struct SendConfirmSheet: View { + + let route: SendViewModel.Route + let destinationAddress: String + /// The recipient's raw 43-byte Orchard payload — required for + /// `.shieldedToShielded`, nil otherwise. + let destinationRaw43: Data? + let dashDuffs: Int64 + let creditsAmount: UInt64 + let fiatText: String + /// Preflighted withdrawal fee — only meaningful for `.platformToCore`. + var withdrawalFeeCredits: UInt64? = nil + var isFullPlatformWithdrawal: Bool = false + var isFullShieldedSweep: Bool = false + /// Core route only: prepared by the amount step BEFORE this sheet is + /// presented. Preparing from here raced the sheet's own presentation — + /// `PinPromptPresenter` cannot put the PIN over a sheet that is still + /// animating in, UIKit rejects it silently, and its watchdog resolved the + /// send as an authentication failure. + @ObservedObject var coreSend: CoreSendConfirmController + + var onCancel: () -> Void + var onCompleted: () -> Void + + @StateObject private var coordinator = ShieldedTransferCoordinator() + /// Core → Core only — see `CoreSendConfirmController`. + + var body: some View { + VStack(spacing: 0) { + dragHandle + .padding(.top, 8) + + Text(NSLocalizedString("Confirm", comment: "")) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + .padding(.top, 20) + + if route == .coreToCore { + switch coreSend.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onCompleted) + default: + detailsBody + } + } else { + switch coordinator.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onCompleted) + default: + detailsBody + } + } + } + .background(Color.dash.primaryBackground) + .interactiveDismissDisabled(isInFlight) + + } + + private var isInFlight: Bool { + if route == .coreToCore { + switch coreSend.phase { + case .signing, .broadcasting: + return true + default: + return false + } + } + switch coordinator.phase { + case .signing, .locking, .proving, .broadcasting: + return true + default: + return false + } + } + + // MARK: - Idle / in-flight / failed body + + private var detailsBody: some View { + VStack(spacing: 0) { + DashAmount( + amount: dashDuffs, + font: .largeTitle, + dashSymbolFactor: 0.7, + showDirection: false) + .padding(.top, 14) + + Text(fiatText) + .font(.subheadline) + .foregroundColor(.dash.secondaryText) + .padding(.top, 6) + + summaryCard + .padding(.horizontal, 20) + .padding(.top, 20) + + if let failureMessage { + Text(failureMessage) + .font(.system(size: 13)) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 12) + } else { + infoCard + .padding(.horizontal, 20) + .padding(.top, 12) + } + + Spacer(minLength: 12) + + actionSection + } + } + + private var failureMessage: String? { + if route == .coreToCore { + if case let .failed(message) = coreSend.phase { return message } + return nil + } + if case let .failed(message) = coordinator.phase { return message } + return nil + } + + @ViewBuilder + private var actionSection: some View { + if route == .coreToCore { + switch coreSend.phase { + case .ready: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Confirm", comment: ""), + positiveButtonAction: confirm, + negativeButtonText: NSLocalizedString("Cancel", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .failed: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Try again", comment: ""), + positiveButtonAction: tryAgain, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .signing, .broadcasting: + ShieldedTransferStepList(labels: coreProgressLabels, currentIndex: coreProgressIndex) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + case .success, .submittedUnconfirmed: + EmptyView() + + // Unreachable: the amount step only presents this sheet once the + // transaction is signed, and a cancelled PIN keeps it there. + case .idle: + EmptyView() + } + } else { + switch coordinator.phase { + case .idle: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Confirm", comment: ""), + positiveButtonAction: confirm, + negativeButtonText: NSLocalizedString("Cancel", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .failed: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Try again", comment: ""), + positiveButtonAction: tryAgain, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .signing, .locking, .proving, .broadcasting: + ShieldedTransferStepList(currentPhase: coordinator.phase, steps: progressSteps) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + case .success, .submittedUnconfirmed: + EmptyView() + } + } + } + + /// Core → Core's two-stage checklist (`CoreSendConfirmController.Phase` + /// has no `.locking`/`.proving` — a plain L1 send doesn't pass through + /// them), rendered with `ShieldedTransferStepList`'s positional + /// initializer since its phase-based one is typed to + /// `ShieldedTransferCoordinator.Phase`. + private var coreProgressLabels: [String] { + [NSLocalizedString("Authorizing", comment: ""), NSLocalizedString("Broadcasting", comment: "")] + } + + private var coreProgressIndex: Int? { + switch coreSend.phase { + case .signing: return 0 + case .broadcasting: return 1 + default: return nil + } + } + + // MARK: - Success body + + private var successBody: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .foregroundColor(.green) + .padding(.top, 24) + + Text(NSLocalizedString("Sent", comment: "Send confirm sheet")) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + + DashAmount( + amount: dashDuffs, + font: .title, + dashSymbolFactor: 0.7, + showDirection: false) + + Text(fiatText) + .font(.subheadline) + .foregroundColor(.dash.secondaryText) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Done", comment: ""), + style: .filled, + stretch: true, + action: onCompleted) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + // MARK: - Pieces + + private var dragHandle: some View { + Rectangle() + .fill(Color.dash.grabberFill) + .frame(width: 36, height: 5) + .cornerRadius(2.5) + } + + private var summaryCard: some View { + VStack(spacing: 0) { + summaryRow( + label: NSLocalizedString("From", comment: ""), + value: fromLabel) + divider + HStack { + Text(NSLocalizedString("To", comment: "")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(truncateMiddle(destinationAddress)) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + divider + summaryRow( + label: NSLocalizedString("Network fee", comment: ""), + value: networkFeeString) + divider + summaryRow( + label: NSLocalizedString("Total", comment: ""), + value: totalString) + } + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + /// The wallet debit. Core → Core charges the network fee ON TOP of what + /// the recipient receives, so its Total is the amount plus the exact fee + /// off the signed transaction — the all-in figure the legacy confirm + /// screen showed (`DWPaymentProcessor` passed `amount + preparedSend.fee`). + /// Until that fee is known, and on every other route (whose fee row is an + /// estimate, as in `InternalTransferConfirmSheet`), Total is the amount. + private var totalString: String { + guard route == .coreToCore, let feeDuffs = coreSend.feeDuffs else { + return dashDuffs.formattedDashAmount + } + return (dashDuffs + Int64(clamping: feeDuffs)).formattedDashAmount + } + + private var fromLabel: String { + switch route { + case .platformToPlatform, .platformToCore: + return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") + case .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: + return NSLocalizedString("Shielded balance", comment: "") + case .coreToCore, .coreToShielded: + return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") + } + } + + private func summaryRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(value) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + } + + private var divider: some View { + Rectangle() + .fill(Color.dash.gray300.opacity(0.3)) + .frame(height: 1) + .padding(.horizontal, 14) + } + + // MARK: - Network fee estimate + + /// Platform credits per DASH (1e11). + private static let creditsPerDash: Decimal = 100_000_000_000 + + /// Flat fee estimate (credits) for the active route — same estimators as + /// the internal transfer's confirm sheet. `nil` → the row shows "—". + private var networkFeeCredits: UInt64? { + switch route { + case .coreToShielded: + return CoreToShieldedAmountPolicy.poolFeeCredits + case .platformToPlatform: + // Credit transfer: the metered transition fee. The executor + // states ~0.001 DASH as the conservative max. + return 100_000_000 + case .platformToCore: + return withdrawalFeeCredits + case .shieldedToCore: + return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) + case .shieldedToPlatform: + return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) + case .shieldedToShielded: + return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) + case .coreToCore: + // The one route with a real (not projected) fee: off the signed + // transaction, once `CoreSendConfirmController` finishes + // preparing. `nil` while unknown — the row shows "—", not a guess. + guard let feeDuffs = coreSend.feeDuffs else { return nil } + return feeDuffs * 1000 + } + } + + private var networkFeeString: String { + guard let credits = networkFeeCredits else { return "—" } + let dash = Decimal(credits) / Self.creditsPerDash + let formatted = CurrencyExchanger.shared.fiatAmountString(for: dash) + // Every other route estimates; Core → Core's fee is the exact amount + // off the signed transaction, so it doesn't get the "~" prefix. + return route == .coreToCore ? formatted : "~ " + formatted + } + + // MARK: - Info card + + private var infoCard: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(Color.dash.blue) + .frame(width: 30, height: 30) + Image(systemName: infoIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + } + + VStack(alignment: .leading, spacing: 4) { + Text(infoTitle) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(infoBody) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(14) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + private var infoIcon: String { + switch route { + case .platformToCore, .shieldedToCore: return "clock.fill" + default: return "exclamationmark.shield.fill" + } + } + + private var infoTitle: String { + switch route { + case .platformToCore, .shieldedToCore: + return NSLocalizedString("Processing time", comment: "") + default: + return NSLocalizedString("Double-check the address", comment: "Send confirm sheet") + } + } + + private var infoBody: String { + switch route { + case .shieldedToCore: + return NSLocalizedString( + "The Dash arrives at the recipient's address after the network processes the withdrawal — this can take up to 10 minutes.", + comment: "Send confirm sheet") + case .platformToCore: + return isFullPlatformWithdrawal + ? NSLocalizedString( + "This withdraws your entire Platform balance in one transfer. The Dash arrives at the recipient's address once the network processes the withdrawal.", + comment: "Send confirm sheet") + : NSLocalizedString( + "The Dash arrives at the recipient's address once the network processes the withdrawal.", + comment: "Send confirm sheet") + default: + return NSLocalizedString( + "Dash sent to a wrong address can't be recovered. Make sure the address is exactly the one you intend to pay.", + comment: "Send confirm sheet") + } + } + + // MARK: - Progress checklist + + private var progressSteps: [ShieldedTransferStepList.Step] { + var steps: [ShieldedTransferStepList.Step] = [ + .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing) + ] + // Only the asset-lock route has the on-chain locking stage. + if route == .coreToShielded { + steps.append(.init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking)) + } + // Only shielded legs build an Orchard proof. + switch route { + case .coreToShielded, .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: + steps.append(.init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving)) + case .platformToPlatform, .platformToCore, .coreToCore: + break + } + steps.append(.init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting)) + return steps + } + + // MARK: - Actions + + private func confirm() { + Task { + switch route { + case .coreToShielded: + guard let destinationRaw43 else { + coordinator.reset() + return + } + await coordinator.performAssetLock( + amountDuffs: UInt64(dashDuffs), + recipientRaw43: destinationRaw43) + case .platformToPlatform: + await coordinator.performPlatformSend( + destination: destinationAddress, + amountCredits: creditsAmount) + case .platformToCore: + await coordinator.performPlatformWithdraw( + amountCredits: creditsAmount, + fullBalance: isFullPlatformWithdrawal, + feeHeadroomCredits: withdrawalFeeCredits, + toCoreAddress: destinationAddress) + case .shieldedToCore: + await coordinator.performWithdraw( + amountCredits: creditsAmount, + sweepAll: isFullShieldedSweep, + toCoreAddress: destinationAddress) + case .shieldedToPlatform: + await coordinator.performUnshield( + amountCredits: creditsAmount, + sweepAll: isFullShieldedSweep, + toPlatformAddress: destinationAddress) + case .shieldedToShielded: + guard let destinationRaw43 else { + coordinator.reset() + return + } + await coordinator.performShieldedTransfer( + amountCredits: creditsAmount, + recipientRaw43: destinationRaw43) + case .coreToCore: + // Already prepared (auth + build/sign) by the amount step + // before this sheet was presented — Confirm only broadcasts + // the signed transaction. + await coreSend.confirmBroadcast() + } + } + } + + private func tryAgain() { + if route == .coreToCore { + Task { + await coreSend.retry(address: destinationAddress, amountDuffs: UInt64(dashDuffs)) + } + return + } + // If the just-failed Core → Shielded attempt already committed its + // asset lock, RESUME that exact outpoint with the same recipient + // instead of building a second lock (which strands the first) — + // mirrors the internal transfer's retry. + if route == .coreToShielded, + let op = coordinator.lastAssetLockOutPoint, + let destinationRaw43 { + coordinator.reset() + Task { + await coordinator.resumeAssetLock( + outPointTxidWire: op.txidWire, + outPointVout: op.vout, + recipientRaw43: destinationRaw43) + } + return + } + coordinator.reset() + confirm() + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift new file mode 100644 index 000000000..30027e8ed --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -0,0 +1,184 @@ +// +// SendScreen.swift +// DashWallet +// +// Step one of the external send: the recipient address, whose form decides the destination type. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 1: address + +struct SendScreen: View { + @ObservedObject var viewModel: SendViewModel + var onClose: () -> Void + var onScanQR: () -> Void + /// The entered address is valid → advance to the amount step. The host + /// pushes `ExternalSendAmountScreen` onto the same navigation stack. + var onContinue: () -> Void + /// False when embedded under a host that renders its own chrome + /// (the balance-row send sheet) — hides the X + title header. + var showsHeader: Bool = true + + @FocusState private var addressFieldFocused: Bool + + var body: some View { + VStack(spacing: 20) { + addressField + + Spacer() + + DashButton( + text: NSLocalizedString("Continue", comment: ""), + style: .filled, + stretch: true, + isEnabled: viewModel.canAdvanceToAmount, + action: { + addressFieldFocused = false + onContinue() + } + ) + .padding(.horizontal, 40) + } + .padding(.top, 10) + .padding(.horizontal, 20) + .padding(.bottom, 20) + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + } + +// // MARK: - Header +// +// private var header: some View { +// HStack { +// Button(action: onClose) { +// Image(systemName: "xmark") +// .font(.system(size: 16, weight: .medium)) +// .foregroundColor(Color.dash.primaryText) +// .frame(width: 36, height: 36) +// .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) +// } +// Spacer() +// Text(NSLocalizedString("Send", comment: "")) +// .font(.headline) +// .foregroundColor(.dash.primaryText) +// Spacer() +// Color.clear.frame(width: 36, height: 36) +// } +// } + + // MARK: - Address + + private var addressField: some View { + DashUIKit.AddressFieldView( + text: $viewModel.addressText, + label: NSLocalizedString("Address", comment: ""), + placeholder: NSLocalizedString("Dash address", comment: "Send screen address placeholder"), + hasError: addressErrorText != nil, + errorText: addressErrorText, + onScanQR: onScanQR, + // Shown by the component only while the field is empty, which is + // exactly when it is useful. + onPaste: { viewModel.pasteFromClipboard() } + ) { + if let destination = viewModel.destination { + destinationBadge(destination) + } + } + .focused($addressFieldFocused) + .onChange(of: viewModel.destination) { _, destination in + // The address just became valid (a paste, or the final typed + // character) → drop the keyboard so Continue is reachable without + // a second tap. Software-keyboard-less setups (a simulator with a + // hardware keyboard) never lose focus on their own, so don't wait + // for that. + if destination != nil { + addressFieldFocused = false + } + } + } + + /// Whichever refusal applies, in the one slot the field has for it. + private var addressErrorText: String? { + if viewModel.showsInvalidAddress { + return NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen") + } + if viewModel.pinnedSourceMismatch { + return String( + format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), + viewModel.pinnedSourceTitle) + } + return nil + } + + private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { + HStack(spacing: 4) { + Image(systemName: destinationIconName(destination)) + .font(.system(size: 10, weight: .semibold)) + Text(destinationTitle(destination)) + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.dash.blue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dash.blue.opacity(0.1)) + .clipShape(Capsule()) + } + + private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } + } + + private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") + case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") + case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") + } + } + +} + +#if DEBUG + +/// Seeded model, nothing wired — see `SendViewModel.preview`. The production +/// initializer subscribes to the sync monitor and two balance publishers, all +/// live singletons a canvas must not start. +#Preview("Empty") { + SendScreen(viewModel: .preview(), onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// A decoded address collapses the field into the summary card. +#Preview("Transparent address") { + SendScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core), + onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +#Preview("Still syncing") { + SendScreen( + viewModel: .preview(isChainSynced: false), + onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// Embedded under a host that draws its own chrome — the balance-row sheet. +#Preview("No header") { + SendScreen( + viewModel: .preview(), onClose: {}, onScanQR: {}, onContinue: {}, + showsHeader: false) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift new file mode 100644 index 000000000..3eace0bc7 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -0,0 +1,121 @@ +// +// SendSourceScreen.swift +// DashWallet +// +// Step two: which balance the send is funded from. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 2: amount + +/// The source step: which balance funds the send. The address is settled by +/// now and shown read-only above the cards; this screen adds the sync gate. +/// Continue always advances to the amount step — no route skips it. +struct SendSourceScreen: View { + @ObservedObject var viewModel: SendViewModel + /// Pop back to the address step. + var onBack: () -> Void + /// A valid source is chosen → advance to the amount step. + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + DashUIKit.NavigationBar(leading: { + NavigationBarElement.back.button { onBack() } + }) + + // Content + + VStack(alignment: .leading, spacing: 20) { + SendAddressSummary(viewModel: viewModel) + + sourceCards + + if viewModel.isBlockedBySync { + SyncGateNote() + .padding(.horizontal, 20) + } + + Spacer() + + DashButton( + text: NSLocalizedString("Continue", comment: ""), + style: .filled, + stretch: true, + isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, + action: onContinue + ) + .padding(.horizontal, 40) + } + .padding(.top, 10) + .padding(.bottom, 20) + .padding(.horizontal, 20) + } + .background(Color.primaryBackground) + .navigationBarHidden(true) + } + + // MARK: - From picker + + @ViewBuilder + private var sourceCards: some View { + if let pinned = viewModel.pinnedSource { + sourceRow(pinned, showsRadio: false) + } else { + VStack(spacing: 8) { + ForEach(viewModel.validSources, id: \.self) { network in + sourceRow(network) + } + } + } + } + + private func sourceRow(_ network: ChainNetwork, showsRadio: Bool = true) -> some View { + let balance: String + switch network { + case .core: balance = viewModel.coreBalanceFormatted + case .platform: balance = viewModel.platformCreditsFormatted + case .shielded: balance = viewModel.shieldedBalanceFormatted + } + + return TransferSourceRow( + iconSystemName: sourceIconName(network), + caption: NSLocalizedString("From", comment: ""), + title: sourceTitle(network), + balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), + selected: viewModel.source == network, + showsRadio: showsRadio, + action: { + viewModel.source = network + } + ) + } +} + +#if DEBUG + +#Preview("Pick a source") { + SendSourceScreen( + viewModel: .preview(address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core), + onBack: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// Pinned by the caller (the balance row's own send button) — the picker +/// shows the choice already made rather than re-offering it. +#Preview("Pinned to Shielded") { + SendSourceScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core, + source: .shielded, + pinnedSource: .shielded), + onBack: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift similarity index 84% rename from DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift rename to DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index d4178fc83..d4f6406f1 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -29,8 +29,9 @@ final class SendViewModel: ObservableObject { } /// Every (source balance → destination address type) leg the Send screen - /// can execute. Core → Core rides the classic L1 payment processor; the - /// rest run through `ShieldedTransferCoordinator` / the Platform seam. + /// can execute — all confirmed and executed from `SendConfirmSheet`. + /// Core → Core runs through `WalletSendService` directly; the rest run + /// through `ShieldedTransferCoordinator` / the Platform seam. enum Route: Equatable { case coreToCore /// BIP44 UTXOs → asset lock → Type 18 shield note for the @@ -90,7 +91,10 @@ final class SendViewModel: ObservableObject { /// `.syncDone`). Core-funded routes can't Continue before that — the /// UTXO set may be stale (see `WalletSendService.ensureChainSynced`, /// the boundary backstop behind this UI gate). - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// Seeded in `init` rather than here: a property initialiser runs whatever + /// the `wired` flag says, and this one would wake the monitor from a + /// preview canvas. + @Published private(set) var isChainSynced = false private var cancellables = Set() @@ -100,17 +104,32 @@ final class SendViewModel: ObservableObject { /// rather than silently re-picking the source. let pinnedSource: ChainNetwork? + /// False for the preview seam below, which never registered anywhere. + private let isWired: Bool + deinit { // The monitor holds observers strongly — without this the VM (and - // its Combine pipelines) outlive the screen. - SyncingActivityMonitor.shared.remove(observer: self) + // its Combine pipelines) outlive the screen. Skipped when the model + // was never wired: removing an observer that never registered would + // reach the live singleton from a canvas. + if isWired { + SyncingActivityMonitor.shared.remove(observer: self) + } } - init(pinnedSource: ChainNetwork? = nil) { + /// `wired: false` builds an inert model for previews: no observer on + /// `SyncingActivityMonitor`, no balance subscriptions, no clipboard read. + /// Every one of those reaches a live singleton that does real work, which + /// a canvas must not start — and the screens only read published state, + /// so an unwired model renders them exactly as a wired one would. + init(pinnedSource: ChainNetwork? = nil, wired: Bool = true) { self.pinnedSource = pinnedSource + self.isWired = wired if let pinnedSource { source = pinnedSource } + guard wired else { return } + isChainSynced = SyncingActivityMonitor.shared.state == .syncDone refreshClipboardSuggestion() SyncingActivityMonitor.shared.add(observer: self) @@ -283,11 +302,22 @@ final class SendViewModel: ObservableObject { clipboardSuggestion = Self.detect(in: raw) } + /// Apply the DETECTED address — used by the "Send to Address" shortcut, + /// which only opens the screen prefilled when the clipboard holds one. func useClipboardSuggestion() { guard let suggestion = clipboardSuggestion else { return } addressText = suggestion.address } + /// Paste whatever is on the clipboard, valid or not. The field's own + /// validation says what is wrong with it — a Paste button that silently + /// does nothing because the content didn't parse is worse than one that + /// pastes and lets the error explain. + func pasteFromClipboard() { + guard let raw = UIPasteboard.general.string else { return } + addressText = raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func detect(in raw: String) -> ClipboardSuggestion? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } @@ -377,6 +407,24 @@ final class SendViewModel: ObservableObject { InternalTransferViewModel.cardBalanceString(duffs: shieldedBalance / 1000) } + /// What a Core-funded send can actually spend: confirmed, mature funds + /// with the send fee already set aside — the exact number Max fills in. + /// + /// `coreBalanceDuffs` is the wallet TOTAL, so it counts unconfirmed and + /// immature coins and leaves nothing for the fee. Gating on it let the + /// user type an amount Max would never offer, arm the button, and only + /// find out at the payment processor's error. + var coreSpendableAfterFeeDuffs: UInt64 { + SwiftDashSDKWalletState.shared.feeAwareMaxSendable() + } + + /// The selected source's balance, in duffs — what the amount step shows + /// under "from", so the user can see what they are spending out of + /// without going back a step. + var selectedSourceBalanceDuffs: UInt64 { + balanceDuffs(of: source) + } + /// A source's balance normalised to duffs, for the "first source with /// funds" auto-pick. private func balanceDuffs(of network: ChainNetwork) -> UInt64 { @@ -404,10 +452,11 @@ final class SendViewModel: ObservableObject { private var feeReserveCredits: UInt64? { switch route { case .coreToCore, .coreToShielded, .platformToCore, nil: - // L1 send fees are handled by the payment processor; the - // asset-lock shield carves its pool fee from the locked value - // (nothing reserved from the Core balance, mirroring the - // internal transfer); the full-balance platform withdrawal + // Core → Core's fee headroom is netted by `coreSpendableAfterFeeDuffs` + // (`SwiftDashSDKWalletState.feeAwareMaxSendable()`), not a credits + // reserve; the asset-lock shield carves its pool fee from the + // locked value (nothing reserved from the Core balance, mirroring + // the internal transfer); the full-balance platform withdrawal // nets its fee out of the preflighted payout. return 0 case .platformToPlatform: @@ -484,6 +533,16 @@ final class SendViewModel: ObservableObject { if let shieldedMaxNotice { return shieldedMaxNotice } guard dashDuffsUnsigned > 0, let route else { return nil } + // Below the dust threshold the network drops the output, whatever the + // balance is — say so rather than leave Continue dead. + if route == .coreToCore, dashDuffsUnsigned < CoreTxConstants.minOutputAmount { + return String.localizedStringWithFormat( + NSLocalizedString( + "The minimum amount you can send is %@", + comment: "External send — dust threshold"), + "\(CoreTxConstants.minOutputAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + } + // Route minimum first: below the Type-18 pool fee the SDK refuses the // asset lock however much Core balance backs it. if route == .coreToShielded { @@ -513,10 +572,13 @@ final class SendViewModel: ObservableObject { switch route { case .coreToCore, .coreToShielded: + // The same envelope `canContinue` gates on — quoting the wallet + // total here would report an amount as affordable while the + // button stayed dead. return TransferSpendAmountPolicy.insufficientBalanceMessage( balanceName: balanceName, requestedDuffs: dashDuffsUnsigned, - spendableDuffs: coreBalanceDuffs) + spendableDuffs: coreSpendableAfterFeeDuffs) case .platformToPlatform: guard let reserve = feeReserveCredits else { @@ -588,9 +650,11 @@ final class SendViewModel: ObservableObject { guard dashDuffsUnsigned > 0, let route, !isBlockedBySync else { return false } switch route { case .coreToCore: - // The L1 fee rides on top; the payment processor rejects an - // unfundable send with its own error, so gate on the balance only. - return dashDuffsUnsigned <= coreBalanceDuffs + // Below the dust threshold the network won't relay the output at + // all — the same floor `BaseAmountModel` enforces on the classic + // amount screen, which this step now replaces for this route. + guard dashDuffsUnsigned >= CoreTxConstants.minOutputAmount else { return false } + return dashDuffsUnsigned <= coreSpendableAfterFeeDuffs case .coreToShielded: // Asset-lock route: the pool fee is carved from the locked value // and the Rust side rejects an undersized lock. Enforce the same @@ -599,7 +663,7 @@ final class SendViewModel: ObservableObject { guard let minimumDuffs = coreToShieldedMinimumAmountDuffs, dashDuffsUnsigned >= minimumDuffs else { return false } - return dashDuffsUnsigned <= coreBalanceDuffs + return dashDuffsUnsigned <= coreSpendableAfterFeeDuffs case .platformToPlatform: guard let reserve = feeReserveCredits else { return false } return platformCredits >= reserve @@ -737,3 +801,28 @@ extension SendViewModel: SyncingActivityMonitorObserver { } } } + +#if DEBUG + +extension SendViewModel { + /// Preview seed. Nothing is wired, so no singleton is touched; the state + /// the screens render is set directly. + static func preview( + address: String = "", + destination: DestinationKind? = nil, + source: ChainNetwork = .core, + pinnedSource: ChainNetwork? = nil, + coreBalanceDuffs: UInt64 = 96_999_737, + isChainSynced: Bool = true + ) -> SendViewModel { + let model = SendViewModel(pinnedSource: pinnedSource, wired: false) + model.addressText = address + model.destination = destination + model.source = source + model.coreBalanceDuffs = coreBalanceDuffs + model.isChainSynced = isChainSynced + return model + } +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift deleted file mode 100644 index 8e4dfc2a2..000000000 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ /dev/null @@ -1,1156 +0,0 @@ -// -// SendScreen.swift -// DashWallet -// -// External-send flow, split across two steps. Step one (`SendScreen`) is -// address only: the recipient address (typed / pasted / scanned) decides the -// destination type (Transparent L1 / Platform / Shielded); Continue advances -// to step two. Step two (`ExternalSendAmountScreen`) is the From picker plus -// the amount keypad — Core → Core continues into the classic payment -// processor (real fee math + its own confirm); every other route confirms in -// `SendConfirmSheet` and executes via `ShieldedTransferCoordinator`. -// - -import SwiftUI -import DashUIKit -import SwiftDashSDK -import UIKit - -// MARK: - Step 1: address - -struct SendScreen: View { - @ObservedObject var viewModel: SendViewModel - var onClose: () -> Void - var onScanQR: () -> Void - /// The entered address is valid → advance to the amount step. The host - /// pushes `ExternalSendAmountScreen` onto the same navigation stack. - var onContinue: () -> Void - /// False when embedded under a host that renders its own chrome - /// (the balance-row send sheet) — hides the X + title header. - var showsHeader: Bool = true - - @FocusState private var addressFieldFocused: Bool - @State private var isEditingAddress = false - - /// The address is "locked" (shown as a truncated card) once a valid - /// destination is decoded and focus has left the field; tapping it reopens - /// the editable field. - private var isAddressLocked: Bool { - viewModel.destination != nil && !isEditingAddress && !addressFieldFocused - } - - var body: some View { - VStack(spacing: 0) { - if showsHeader { - header - .padding(.horizontal, 20) - .padding(.top, 10) - } - - ScrollView { - VStack(spacing: 14) { - addressField - .padding(.top, 12) - - if let suggestion = viewModel.clipboardSuggestion, - suggestion.address != viewModel.trimmedAddress { - clipboardChip(for: suggestion) - } - - scanRow - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - .scrollDismissesKeyboard(.interactively) - - DashButton( - text: NSLocalizedString("Continue", comment: ""), - style: .filled, - stretch: true, - isEnabled: viewModel.canAdvanceToAmount, - action: { - addressFieldFocused = false - onContinue() - }) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) - } - - // MARK: - Header - - private var header: some View { - HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(Color.dash.primaryText) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.dash.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } - - // MARK: - Address - - private var addressField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - if isAddressLocked { - lockedAddressCard - } else { - TextField( - NSLocalizedString("Dash address", comment: "Send screen address placeholder"), - text: $viewModel.addressText, - axis: .vertical) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .padding(12) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.asciiCapable) - .lineLimit(2...4) - .focused($addressFieldFocused) - .onAppear { - // Rendered after tapping the locked card → put the - // cursor straight back into the field. - if isEditingAddress { - addressFieldFocused = true - } - } - .onChange(of: addressFieldFocused) { _, focused in - // Focus left the field → re-lock (when valid). - if !focused { - isEditingAddress = false - } - } - .onChange(of: viewModel.destination) { _, destination in - // The address just became valid (a paste, or the - // final typed character) → lock in right away. - // Software-keyboard-less setups (simulator with a - // hardware keyboard) never drop focus on their own, - // so don't wait for that. - if destination != nil { - addressFieldFocused = false - isEditingAddress = false - } - } - } - - if viewModel.showsInvalidAddress { - Text(NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen")) - .font(.caption) - .foregroundColor(.red) - } else if viewModel.pinnedSourceMismatch { - Text(String( - format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), - viewModel.pinnedSourceTitle)) - .font(.caption) - .foregroundColor(.red) - } - } - .padding(.horizontal, 20) - } - - /// The locked-in address: middle-truncated single line + pencil. - /// Tapping reopens the editable field with the cursor in place. - private var lockedAddressCard: some View { - Button(action: { isEditingAddress = true }) { - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Color.dash.secondaryText) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - } - .buttonStyle(.plain) - } - - private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { - HStack(spacing: 4) { - Image(systemName: destinationIconName(destination)) - .font(.system(size: 10, weight: .semibold)) - Text(destinationTitle(destination)) - .font(.caption2.weight(.semibold)) - } - .foregroundColor(.dash.blue) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dash.blue.opacity(0.1)) - .clipShape(Capsule()) - } - - private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } - } - - private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") - case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") - case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") - } - } - - private func clipboardChip(for suggestion: SendViewModel.ClipboardSuggestion) -> some View { - Button(action: { viewModel.useClipboardSuggestion() }) { - HStack(spacing: 10) { - Image(systemName: "doc.on.doc.fill") - .foregroundColor(.blue) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("Send to copied address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Text(truncateMiddle(suggestion.address)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Text(destinationTitle(suggestion.kind)) - .font(.caption2) - .foregroundColor(Color.dash.secondaryText) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dash.gray300.opacity(0.3)) - .cornerRadius(8) - } - .padding(12) - .background(Color.dash.blue.opacity(0.08)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } - - private var scanRow: some View { - Button(action: onScanQR) { - HStack(spacing: 6) { - Image(systemName: "qrcode.viewfinder") - Text(NSLocalizedString("Scan QR", comment: "")) - } - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.dash.blue.opacity(0.12)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } -} - -// MARK: - Step 2: amount - -/// The amount step for the split external-send flow. The address is already -/// chosen (locked, read-only) — this screen carries the From picker, the -/// sync gate, and the amount keypad, plus the balance/affordability -/// validations. Continue routes exactly as the old single-screen form did: -/// Core → Core into the L1 payment processor, everything else into -/// `SendConfirmSheet`. -struct SendSourceScreen: View { - @ObservedObject var viewModel: SendViewModel - /// Pop back to the address step. - var onBack: () -> Void - /// A valid source is chosen → advance to the amount step. - var onContinue: () -> Void - - var body: some View { - VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) - - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) - .padding(.top, 12) - - sourceCards - - if viewModel.isBlockedBySync { - SyncGateNote() - .padding(.horizontal, 20) - } - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - - DashButton( - text: NSLocalizedString("Continue", comment: ""), - style: .filled, - stretch: true, - isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, - action: onContinue) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - .background(Color.primaryBackground) - .navigationBarHidden(true) - } - - // MARK: - From picker - - @ViewBuilder - private var sourceCards: some View { - if let pinned = viewModel.pinnedSource { - // Balance-row send sheet: the source is the tapped balance, - // rendered as a fixed card (no radio, not tappable). - sourceRow(pinned, showsRadio: false) - .padding(.horizontal, 20) - } else { - VStack(spacing: 8) { - ForEach(viewModel.validSources, id: \.self) { network in - sourceRow(network) - } - } - .padding(.horizontal, 20) - } - } - - private func sourceRow(_ network: ChainNetwork, showsRadio: Bool = true) -> some View { - let balance: String - switch network { - case .core: balance = viewModel.coreBalanceFormatted - case .platform: balance = viewModel.platformCreditsFormatted - case .shielded: balance = viewModel.shieldedBalanceFormatted - } - return TransferSourceRow( - iconSystemName: sourceIconName(network), - caption: NSLocalizedString("From", comment: ""), - title: sourceTitle(network), - balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), - selected: viewModel.source == network, - showsRadio: showsRadio, - action: { viewModel.source = network }) - } -} - -// MARK: - Step 3: amount - -/// The final step of the split external-send flow: the address and source are -/// already chosen (both shown read-only), leaving only the amount keypad and -/// the balance/affordability validation. Continue routes exactly as the old -/// single-screen form did — Core → Core into the L1 payment processor, -/// everything else into `SendConfirmSheet`. -struct ExternalSendAmountScreen: View { - @ObservedObject var viewModel: SendViewModel - /// Pop back to the source step. - var onBack: () -> Void - /// Core → Core: hand (address, amount in duffs) to the hosting - /// controller, which routes through the L1 payment processor. - var onContinueCore: (String, UInt64) -> Void - /// A non-core route finished successfully (confirm sheet's Done). - var onSendCompleted: () -> Void - - @State private var showConfirm = false - - var body: some View { - VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) - - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) - .padding(.top, 12) - - fromSummary - - amountRow - .padding(.horizontal, 20) - .padding(.top, 6) - - if let message = viewModel.amountValidationMessage { - TransferAmountValidationNote(message: message) - .padding(.horizontal, 20) - } - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - - keyboardSection - } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) - .sheet(isPresented: $showConfirm) { - if let route = viewModel.route, route != .coreToCore { - SendConfirmSheet( - route: route, - destinationAddress: viewModel.trimmedAddress, - destinationRaw43: shieldedRecipientRaw43, - dashDuffs: viewModel.dashDuffs, - creditsAmount: viewModel.creditsPreview, - fiatText: viewModel.fiatAmountString, - withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, - isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, - isFullShieldedSweep: viewModel.isFullShieldedSweep, - onCancel: { showConfirm = false }, - onCompleted: { - showConfirm = false - onSendCompleted() - }) - .presentationDetents([.large]) - .presentationDragIndicator(.hidden) - } - } - } - - private var shieldedRecipientRaw43: Data? { - if case .shielded(let raw43) = viewModel.destination { return raw43 } - return nil - } - - // MARK: - Amount - - private var amountRow: some View { - EnterAmountView( - primaryAmount: dashAmountText, - secondaryAmount: fiatAmountText, - primaryCurrency: .dash, - secondaryCurrency: .fiat(viewModel.fiatCurrencyCode), - isPrimarySelected: isDashInputSelected, - currencyCodes: amountCurrencyCodes, - selectedCurrencyCode: selectedAmountCurrencyCode, - onMax: { viewModel.fillMaxFromWallet() }, - onSwap: toggleAmountUnit, - onCurrencyTap: toggleAmountUnit, - onSelectInputType: selectAmountCurrency - ) - } - - // MARK: - Keyboard - - private var keyboardSection: some View { - NumericKeyboardView( - value: keypadBinding, - showDecimalSeparator: true, - actionButtonText: NSLocalizedString("Continue", comment: ""), - actionEnabled: viewModel.canContinue, - inProgress: false, - actionHandler: continueAction - ) - .padding(.top, 12) - .padding(.horizontal, 16) - .padding(.bottom, 12) - .background(Color.dash.secondaryBackground) - .clipShape(.rect(cornerRadius: 20)) - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) - } - - private func continueAction() { - guard let route = viewModel.route else { return } - if route == .coreToCore { - onContinueCore(viewModel.trimmedAddress, viewModel.dashDuffsUnsigned) - } else { - showConfirm = true - } - } - - /// The source picked on the previous step, read-only. Tapping goes back. - private var fromSummary: some View { - Button(action: onBack) { - HStack(spacing: 10) { - Image(systemName: sourceIconName(viewModel.source)) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dashBlue) - .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("From", comment: "")) - .font(.caption) - .foregroundColor(.secondary) - Text(sourceTitle(viewModel.source)) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(.primaryText) - } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.secondaryBackground) - .cornerRadius(10) - } - .buttonStyle(.plain) - .padding(.horizontal, 20) - } - - // MARK: - Amount helpers - - private var isDashInputSelected: Bool { - viewModel.unit == .dash - } - - private var dashAmountText: String { - switch viewModel.unit { - case .dash: - return keypadBinding.wrappedValue - case .fiat: - guard viewModel.parsedDashAmount > 0 else { return "" } - return InternalTransferViewModel.formatTyped( - viewModel.parsedDashAmount, - fractionDigits: 8) - } - } - - private var fiatAmountText: String { - switch viewModel.unit { - case .dash: - guard viewModel.parsedDashAmount > 0, - let fiatAmount = try? CurrencyExchanger.shared.convertDash( - amount: viewModel.parsedDashAmount, - to: viewModel.fiatCurrencyCode) - else { return "" } - return InternalTransferViewModel.formatTyped( - fiatAmount, - fractionDigits: 2) - case .fiat: - return keypadBinding.wrappedValue - } - } - - private var amountCurrencyCodes: [String] { - ["DASH", viewModel.fiatCurrencyCode] - } - - private var selectedAmountCurrencyCode: String { - isDashInputSelected ? "DASH" : viewModel.fiatCurrencyCode - } - - private func toggleAmountUnit() { - viewModel.unit = isDashInputSelected ? .fiat : .dash - } - - private func selectAmountCurrency(_ currencyCode: String) { - viewModel.unit = - currencyCode.caseInsensitiveCompare("DASH") == .orderedSame - ? .dash - : .fiat - } - - private var keypadBinding: Binding { - Binding( - get: { viewModel.amountText == "0" ? "" : viewModel.amountText }, - set: { newValue in - if newValue.isEmpty { - viewModel.amountText = "0" - } else { - viewModel.amountText = newValue - } - }) - } -} - -// MARK: - Shared step chrome - -/// Back-chevron + "Send" title, shared by the source and amount steps. -private struct SendStepHeader: View { - var onBack: () -> Void - - var body: some View { - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } -} - -/// The chosen destination address, read-only. Tapping (`onEdit`) pops back to -/// the address step. Shared by the source and amount steps. -private struct SendAddressSummary: View { - @ObservedObject var viewModel: SendViewModel - var onEdit: () -> Void - - var body: some View { - Button(action: onEdit) { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.secondaryBackground) - .cornerRadius(10) - } - } - .buttonStyle(.plain) - .padding(.horizontal, 20) - } -} - -// MARK: - Shared helpers - -private func sourceIconName(_ network: ChainNetwork) -> String { - switch network { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } -} - -private func sourceTitle(_ network: ChainNetwork) -> String { - switch network { - case .core: return NSLocalizedString("Transparent", comment: "Balance breakdown") - case .platform: return NSLocalizedString("Platform", comment: "Dash Platform chain") - case .shielded: return NSLocalizedString("Shielded", comment: "") - } -} - -private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { - HStack(spacing: 4) { - Image(systemName: destinationIconName(destination)) - .font(.system(size: 10, weight: .semibold)) - Text(destinationTitle(destination)) - .font(.caption2.weight(.semibold)) - } - .foregroundColor(.dashBlue) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dashBlue.opacity(0.1)) - .clipShape(Capsule()) -} - -private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } -} - -private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") - case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") - case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") - } -} - -/// Middle-truncated address display shared by the screen's clipboard chip -/// and the confirm sheet's To row. -private func truncateMiddle(_ s: String, visible: Int = 8) -> String { - guard s.count > visible * 2 + 3 else { return s } - let head = s.prefix(visible) - let tail = s.suffix(visible) - return "\(head)…\(tail)" -} - -// MARK: - SendConfirmSheet - -/// Confirmation half-sheet for the non-core external send routes. Same -/// skeleton as `InternalTransferConfirmSheet` (summary → progress checklist → -/// success), but the To row is the recipient's address and execution runs the -/// coordinator's external-destination legs. Core → Core never reaches this -/// sheet — it rides the classic payment processor. -struct SendConfirmSheet: View { - - let route: SendViewModel.Route - let destinationAddress: String - /// The recipient's raw 43-byte Orchard payload — required for - /// `.shieldedToShielded`, nil otherwise. - let destinationRaw43: Data? - let dashDuffs: Int64 - let creditsAmount: UInt64 - let fiatText: String - /// Preflighted withdrawal fee — only meaningful for `.platformToCore`. - var withdrawalFeeCredits: UInt64? = nil - var isFullPlatformWithdrawal: Bool = false - var isFullShieldedSweep: Bool = false - var onCancel: () -> Void - var onCompleted: () -> Void - - @StateObject private var coordinator = ShieldedTransferCoordinator() - - var body: some View { - VStack(spacing: 0) { - dragHandle - .padding(.top, 8) - - Text(NSLocalizedString("Confirm", comment: "")) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - .padding(.top, 20) - - switch coordinator.phase { - case .success: - successBody - case .submittedUnconfirmed: - ShieldedSubmittedUnconfirmedView(onDone: onCompleted) - default: - detailsBody - } - } - .background(Color.dash.primaryBackground) - .interactiveDismissDisabled(isInFlight) - } - - private var isInFlight: Bool { - switch coordinator.phase { - case .signing, .locking, .proving, .broadcasting: - return true - default: - return false - } - } - - // MARK: - Idle / in-flight / failed body - - private var detailsBody: some View { - VStack(spacing: 0) { - DashAmount( - amount: dashDuffs, - font: .largeTitle, - dashSymbolFactor: 0.7, - showDirection: false) - .padding(.top, 14) - - Text(fiatText) - .font(.subheadline) - .foregroundColor(.dash.secondaryText) - .padding(.top, 6) - - summaryCard - .padding(.horizontal, 20) - .padding(.top, 20) - - if case let .failed(message) = coordinator.phase { - Text(message) - .font(.system(size: 13)) - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - .padding(.top, 12) - } else { - infoCard - .padding(.horizontal, 20) - .padding(.top, 12) - } - - Spacer(minLength: 12) - - switch coordinator.phase { - case .idle: - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Confirm", comment: ""), - positiveButtonAction: confirm, - negativeButtonText: NSLocalizedString("Cancel", comment: ""), - negativeButtonAction: onCancel) - .padding(.horizontal, 16) - .padding(.bottom, 16) - - case .failed: - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Try again", comment: ""), - positiveButtonAction: tryAgain, - negativeButtonText: NSLocalizedString("Close", comment: ""), - negativeButtonAction: onCancel) - .padding(.horizontal, 16) - .padding(.bottom, 16) - - case .signing, .locking, .proving, .broadcasting: - ShieldedTransferStepList(currentPhase: coordinator.phase, steps: progressSteps) - .padding(.horizontal, 24) - .padding(.bottom, 24) - - case .success, .submittedUnconfirmed: - EmptyView() - } - } - } - - // MARK: - Success body - - private var successBody: some View { - VStack(spacing: 16) { - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 64, height: 64) - .foregroundColor(.green) - .padding(.top, 24) - - Text(NSLocalizedString("Sent", comment: "Send confirm sheet")) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - - DashAmount( - amount: dashDuffs, - font: .title, - dashSymbolFactor: 0.7, - showDirection: false) - - Text(fiatText) - .font(.subheadline) - .foregroundColor(.dash.secondaryText) - - Spacer(minLength: 12) - - DashButton( - text: NSLocalizedString("Done", comment: ""), - style: .filled, - stretch: true, - action: onCompleted) - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } - - // MARK: - Pieces - - private var dragHandle: some View { - Rectangle() - .fill(Color.dash.grabberFill) - .frame(width: 36, height: 5) - .cornerRadius(2.5) - } - - private var summaryCard: some View { - VStack(spacing: 0) { - summaryRow( - label: NSLocalizedString("From", comment: ""), - value: fromLabel) - divider - HStack { - Text(NSLocalizedString("To", comment: "")) - .font(.system(size: 14)) - .foregroundColor(.dash.secondaryText) - Spacer() - Text(truncateMiddle(destinationAddress)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - divider - summaryRow( - label: NSLocalizedString("Network fee", comment: ""), - value: networkFeeString) - divider - summaryRow( - label: NSLocalizedString("Total", comment: ""), - value: dashDuffs.formattedDashAmount) - } - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - private var fromLabel: String { - switch route { - case .platformToPlatform, .platformToCore: - return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") - case .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: - return NSLocalizedString("Shielded balance", comment: "") - case .coreToCore, .coreToShielded: - return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") - } - } - - private func summaryRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.system(size: 14)) - .foregroundColor(.dash.secondaryText) - Spacer() - Text(value) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText) - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - } - - private var divider: some View { - Rectangle() - .fill(Color.dash.gray300.opacity(0.3)) - .frame(height: 1) - .padding(.horizontal, 14) - } - - // MARK: - Network fee estimate - - /// Platform credits per DASH (1e11). - private static let creditsPerDash: Decimal = 100_000_000_000 - - /// Flat fee estimate (credits) for the active route — same estimators as - /// the internal transfer's confirm sheet. `nil` → the row shows "—". - private var networkFeeCredits: UInt64? { - switch route { - case .coreToShielded: - return CoreToShieldedAmountPolicy.poolFeeCredits - case .platformToPlatform: - // Credit transfer: the metered transition fee. The executor - // states ~0.001 DASH as the conservative max. - return 100_000_000 - case .platformToCore: - return withdrawalFeeCredits - case .shieldedToCore: - return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) - case .shieldedToPlatform: - return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) - case .shieldedToShielded: - return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) - case .coreToCore: - // Never presented here — the L1 processor shows the real fee. - return nil - } - } - - private var networkFeeString: String { - guard let credits = networkFeeCredits else { return "—" } - let dash = Decimal(credits) / Self.creditsPerDash - return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) - } - - // MARK: - Info card - - private var infoCard: some View { - HStack(alignment: .top, spacing: 12) { - ZStack { - Circle() - .fill(Color.dash.blue) - .frame(width: 30, height: 30) - Image(systemName: infoIcon) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - } - - VStack(alignment: .leading, spacing: 4) { - Text(infoTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Text(infoBody) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - } - .padding(14) - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - private var infoIcon: String { - switch route { - case .platformToCore, .shieldedToCore: return "clock.fill" - default: return "exclamationmark.shield.fill" - } - } - - private var infoTitle: String { - switch route { - case .platformToCore, .shieldedToCore: - return NSLocalizedString("Processing time", comment: "") - default: - return NSLocalizedString("Double-check the address", comment: "Send confirm sheet") - } - } - - private var infoBody: String { - switch route { - case .shieldedToCore: - return NSLocalizedString( - "The Dash arrives at the recipient's address after the network processes the withdrawal — this can take up to 10 minutes.", - comment: "Send confirm sheet") - case .platformToCore: - return isFullPlatformWithdrawal - ? NSLocalizedString( - "This withdraws your entire Platform balance in one transfer. The Dash arrives at the recipient's address once the network processes the withdrawal.", - comment: "Send confirm sheet") - : NSLocalizedString( - "The Dash arrives at the recipient's address once the network processes the withdrawal.", - comment: "Send confirm sheet") - default: - return NSLocalizedString( - "Dash sent to a wrong address can't be recovered. Make sure the address is exactly the one you intend to pay.", - comment: "Send confirm sheet") - } - } - - // MARK: - Progress checklist - - private var progressSteps: [ShieldedTransferStepList.Step] { - var steps: [ShieldedTransferStepList.Step] = [ - .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing) - ] - // Only the asset-lock route has the on-chain locking stage. - if route == .coreToShielded { - steps.append(.init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking)) - } - // Only shielded legs build an Orchard proof. - switch route { - case .coreToShielded, .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: - steps.append(.init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving)) - case .platformToPlatform, .platformToCore, .coreToCore: - break - } - steps.append(.init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting)) - return steps - } - - // MARK: - Actions - - private func confirm() { - Task { - switch route { - case .coreToShielded: - guard let destinationRaw43 else { - coordinator.reset() - return - } - await coordinator.performAssetLock( - amountDuffs: UInt64(dashDuffs), - recipientRaw43: destinationRaw43) - case .platformToPlatform: - await coordinator.performPlatformSend( - destination: destinationAddress, - amountCredits: creditsAmount) - case .platformToCore: - await coordinator.performPlatformWithdraw( - amountCredits: creditsAmount, - fullBalance: isFullPlatformWithdrawal, - feeHeadroomCredits: withdrawalFeeCredits, - toCoreAddress: destinationAddress) - case .shieldedToCore: - await coordinator.performWithdraw( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep, - toCoreAddress: destinationAddress) - case .shieldedToPlatform: - await coordinator.performUnshield( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep, - toPlatformAddress: destinationAddress) - case .shieldedToShielded: - guard let destinationRaw43 else { - coordinator.reset() - return - } - await coordinator.performShieldedTransfer( - amountCredits: creditsAmount, - recipientRaw43: destinationRaw43) - case .coreToCore: - // Unreachable: the screen routes Core → Core through the L1 - // payment processor and never presents this sheet. - break - } - } - } - - private func tryAgain() { - // If the just-failed Core → Shielded attempt already committed its - // asset lock, RESUME that exact outpoint with the same recipient - // instead of building a second lock (which strands the first) — - // mirrors the internal transfer's retry. - if route == .coreToShielded, - let op = coordinator.lastAssetLockOutPoint, - let destinationRaw43 { - coordinator.reset() - Task { - await coordinator.resumeAssetLock( - outPointTxidWire: op.txidWire, - outPointVout: op.vout, - recipientRaw43: destinationRaw43) - } - return - } - coordinator.reset() - confirm() - } -} - - -/// Inline explanation for a Continue disabled by the chain-sync gate: -/// Core-funded sends stay off until `SyncingActivityMonitor` reports -/// `.syncDone` (a stale UTXO set can't safely fund a spend). Shared by -/// the Send and Internal transfer screens. -struct SyncGateNote: View { - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 13)) - .foregroundColor(.orange) - Text(NSLocalizedString( - "Your wallet is still syncing. Sending from your Transparent balance will be available once syncing completes.", - comment: "Core send blocked until chain sync completes")) - .font(.caption) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color.orange.opacity(0.08)) - .cornerRadius(10) - } -} diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift index 53cb6504d..1fc20d237 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift @@ -88,29 +88,12 @@ final class SendScreenViewController: DWBasePayViewController { } } -// MARK: - Core → Core routing +// MARK: - External send navigation extension DWBasePayViewController { - /// Core L1 send with a fixed amount: a BIP21 `dash:` URI carries the - /// amount into the classic payment processor, which goes straight to - /// its confirm (real fee math) — the amount screen is skipped because - /// the intent already has an amount. Shared by the Send screen and the - /// balance-row send sheet. - func continueCore(address: String, amountDuffs: UInt64) { - guard address.isValidDashAddressForCurrentNetwork else { return } - var uriString = "dash:\(address)" - if amountDuffs > 0 { - let dashAmount = InternalTransferViewModel.formatTyped( - amountDuffs.dashAmount, fractionDigits: 8) - uriString += "?amount=\(dashAmount)" - } - guard let url = URL(string: uriString) else { return } - performPay(to: url) - } - /// Push the external-send SOURCE step (From picker) onto the current /// navigation stack, sharing the address step's view model. Continue there - /// pushes the amount step. Shared by the Send screen and the balance-row + /// pushes the amount step, whatever the route. Shared by the Send screen and the balance-row /// send sheet (both `DWBasePayViewController` hosts inside a nav /// controller). func pushExternalSendSource(viewModel: SendViewModel, @@ -120,16 +103,9 @@ extension DWBasePayViewController { onBack: { [weak self] in self?.navigationController?.popViewController(animated: true) }, onContinue: { [weak self] in guard let self else { return } - if viewModel.route == .coreToCore { - // Transparent → Transparent: skip our amount step and use - // the classic L1 amount screen (real fee math + its own - // confirm) reached through the payment processor. - self.continueCore(address: viewModel.trimmedAddress, amountDuffs: 0) - } else { - // Legs the L1 amount screen can't fund (asset-lock shield, - // platform/shielded sources): our amount step + confirm. - self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) - } + // Every route takes the same amount step, Transparent → + // Transparent included, and finishes in `SendConfirmSheet`. + self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) }) // Every UIHostingController already conforms to NavigationBarDisplayable // (nav bar + back button hidden); the screen draws its own back + title. @@ -137,17 +113,13 @@ extension DWBasePayViewController { navigationController?.pushViewController(host, animated: true) } - /// Push the external-send AMOUNT step (final). Core → Core rides - /// `continueCore` (the L1 payment processor); every other route confirms in - /// `SendConfirmSheet`. + /// Push the external-send AMOUNT step (final). Every route, Transparent → + /// Transparent included, confirms and executes in `SendConfirmSheet`. func pushExternalSendAmount(viewModel: SendViewModel, onSendCompleted: @escaping () -> Void) { let screen = ExternalSendAmountScreen( viewModel: viewModel, onBack: { [weak self] in self?.navigationController?.popViewController(animated: true) }, - onContinueCore: { [weak self] address, amountDuffs in - self?.continueCore(address: address, amountDuffs: amountDuffs) - }, onSendCompleted: onSendCompleted) let host = UIHostingController(rootView: screen) navigationController?.pushViewController(host, animated: true) diff --git a/DashWalletTests/PaymentProtocolTests.swift b/DashWalletTests/PaymentProtocolTests.swift index f0e2e1058..5cbb4c0dc 100644 --- a/DashWalletTests/PaymentProtocolTests.swift +++ b/DashWalletTests/PaymentProtocolTests.swift @@ -452,6 +452,34 @@ final class PreparedStandardSendBroadcastTests: XCTestCase { XCTAssertEqual(calls, 2) } + /// The claim state returning to `.ready` does not mean the captured + /// transaction survived: only a failure raised BEFORE the broadcast leaves + /// it intact. `CoreSendConfirmController` reads this to decide between + /// re-broadcasting and re-preparing. + func testBroadcastIsConsumedOnlyOnceTheBroadcastRan() { + let offline = NSError( + domain: "test.network", code: 1, + userInfo: [NSLocalizedDescriptionKey: "offline"]) + let blocked = prepared( + hashByte: 0x17, + ensureOnline: { throw offline }, + action: { .accepted(txid: String(repeating: "17", count: 32)) }) + + XCTAssertFalse(blocked.isBroadcastConsumed) + XCTAssertThrowsError(try blocked.broadcast()) + XCTAssertFalse(blocked.isBroadcastConsumed) + + let rejected = prepared(hashByte: 0x28) { + .rejected( + txid: String(repeating: "28", count: 32), + reason: "broadcast was not started") + } + + XCTAssertFalse(rejected.isBroadcastConsumed) + XCTAssertThrowsError(try rejected.broadcast()) + XCTAssertTrue(rejected.isBroadcastConsumed) + } + func testUnknownRemainsTerminalWhenNetworkStateChanges() { let offline = NSError( domain: "test.network", code: 2,