diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 919db1e3ae..0c8f32aaf1 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -206,6 +206,10 @@ 2A7A7BAE234770C900451078 /* DWCaptureSessionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BAD234770C900451078 /* DWCaptureSessionManager.m */; }; 2A7A7BC92347E0D700451078 /* DWBaseFormTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BC82347E0D700451078 /* DWBaseFormTableViewCell.m */; }; 2A7A7BD62348CB6600451078 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7BD52348CB6600451078 /* SettingsScreen.swift */; }; + C0FA5E0000000000000000A2 /* AdvancedModeInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FA5E0000000000000000A1 /* AdvancedModeInfoSheet.swift */; }; + C0FA5E0000000000000000A3 /* AdvancedModeInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FA5E0000000000000000A1 /* AdvancedModeInfoSheet.swift */; }; + C0FA5E0000000000000000B2 /* SettingsAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FA5E0000000000000000B1 /* SettingsAlerts.swift */; }; + C0FA5E0000000000000000B3 /* SettingsAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FA5E0000000000000000B1 /* SettingsAlerts.swift */; }; 2A7A7C16234B763600451078 /* DWLocalCurrencyViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7C15234B763600451078 /* DWLocalCurrencyViewController.m */; }; 2A7A7C1D234B771400451078 /* DWLocalCurrencyModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7C1C234B771400451078 /* DWLocalCurrencyModel.m */; }; 2A7A7C20234B79B700451078 /* DWLocalCurrencyTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A7A7C1F234B79B700451078 /* DWLocalCurrencyTableViewCell.m */; }; @@ -450,6 +454,12 @@ 47A514602848F75B005A8E3E /* TxDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A5145F2848F75B005A8E3E /* TxDetailViewController.swift */; }; 47A514622848FEAD005A8E3E /* Tx.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 47A514612848FEAD005A8E3E /* Tx.storyboard */; }; 47A5146428491C60005A8E3E /* TxDetailCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A5146328491C60005A8E3E /* TxDetailCells.swift */; }; + C017200000000000000AA2 /* RequestAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000AA1 /* RequestAmountScreen.swift */; }; + C017200000000000000AA3 /* RequestAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000AA1 /* RequestAmountScreen.swift */; }; + C017200000000000000BA2 /* RequestAmountHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000BA1 /* RequestAmountHostingController.swift */; }; + C017200000000000000BA3 /* RequestAmountHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000BA1 /* RequestAmountHostingController.swift */; }; + C0172000000000000009A2 /* SpecifyAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000009A1 /* SpecifyAmountView.swift */; }; + C0172000000000000009A3 /* SpecifyAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000009A1 /* SpecifyAmountView.swift */; }; 47AC8413297822E000BD1B49 /* SpecifyAmountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AC8412297822E000BD1B49 /* SpecifyAmountViewController.swift */; }; 47AE8B9528BFACA100490F5E /* ExploreDash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8B9428BFACA100490F5E /* ExploreDash.swift */; }; 47AE8B9C28BFAD3600490F5E /* ExplorePointOfUse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AE8B9B28BFAD2800490F5E /* ExplorePointOfUse.swift */; }; @@ -582,8 +592,6 @@ 51629A945A8CEF6EF50F9A4A /* DWIdentityKeyUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AAC8F08DF00D0070EE71C3 /* DWIdentityKeyUpgrader.swift */; }; 51646C723006263C00BE3070 /* Uphold-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 51646C713006263C00BE3070 /* Uphold-Info.plist */; }; 51646C733006263C00BE3070 /* Uphold-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 51646C713006263C00BE3070 /* Uphold-Info.plist */; }; - 516983C22FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516983C12FCAEDEB00BA91A2 /* XmarkIcon.swift */; }; - 516983C32FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516983C12FCAEDEB00BA91A2 /* XmarkIcon.swift */; }; 516C63E52FE9BE0A00DD6BC3 /* AboutDashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516C63E42FE9BE0A00DD6BC3 /* AboutDashView.swift */; }; 516C63E62FE9BE0A00DD6BC3 /* AboutDashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516C63E42FE9BE0A00DD6BC3 /* AboutDashView.swift */; }; 516C63E82FE9BE0A00DD6BC3 /* AboutDashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516C63E72FE9BE0A00DD6BC3 /* AboutDashViewModel.swift */; }; @@ -631,6 +639,28 @@ 56F5C9FD51AE5508BB5E579B /* ChainNetworkToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D788CCB6B35B655BD713129E /* ChainNetworkToggle.swift */; }; 57632D5A61F78ED6E896B1AF /* StorageRecordDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */; }; 58825FFD2913876CC5D4AA21 /* InternalTransferScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62C70E4EBB7458C362B7669 /* InternalTransferScreen.swift */; }; + C0172000000000000003A2 /* PaymentsTabRootController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000003A1 /* PaymentsTabRootController.swift */; }; + C0172000000000000003A3 /* PaymentsTabRootController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000003A1 /* PaymentsTabRootController.swift */; }; + C0172000000000000006A2 /* PaymentsSendCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000006A1 /* PaymentsSendCard.swift */; }; + C0172000000000000006A3 /* PaymentsSendCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000006A1 /* PaymentsSendCard.swift */; }; + C0172000000000000007A2 /* PaymentsReceiveContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000007A1 /* PaymentsReceiveContent.swift */; }; + C0172000000000000007A3 /* PaymentsReceiveContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000007A1 /* PaymentsReceiveContent.swift */; }; + C0172000000000000002A2 /* PaymentsActionCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000002A1 /* PaymentsActionCard.swift */; }; + C0172000000000000002A3 /* PaymentsActionCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000002A1 /* PaymentsActionCard.swift */; }; + C0172000000000000001A2 /* PaymentsTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000001A1 /* PaymentsTabSelector.swift */; }; + C0172000000000000001A3 /* PaymentsTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000001A1 /* PaymentsTabSelector.swift */; }; + C0172000000000000011A2 /* TransferPrivacyTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000011A1 /* TransferPrivacyTip.swift */; }; + C0172000000000000011A3 /* TransferPrivacyTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000011A1 /* TransferPrivacyTip.swift */; }; + C0171F000000000000001A2 /* TransferSourceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001A1 /* TransferSourceRow.swift */; }; + C0171F000000000000001A3 /* TransferSourceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001A1 /* TransferSourceRow.swift */; }; + C0171F000000000000001B2 /* TransferAmountValidationNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001B1 /* TransferAmountValidationNote.swift */; }; + C0171F000000000000001B3 /* TransferAmountValidationNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001B1 /* TransferAmountValidationNote.swift */; }; + C0171F000000000000001C2 /* TransferPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001C1 /* TransferPreview.swift */; }; + C0171F000000000000001C3 /* TransferPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001C1 /* TransferPreview.swift */; }; + C0171F000000000000001D2 /* TransferEndpointCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001D1 /* TransferEndpointCards.swift */; }; + C0171F000000000000001D3 /* TransferEndpointCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0171F000000000000001D1 /* TransferEndpointCards.swift */; }; + C017200000000000000FC2 /* TransferEndpointPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FC1 /* TransferEndpointPicker.swift */; }; + C017200000000000000FC3 /* TransferEndpointPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FC1 /* TransferEndpointPicker.swift */; }; 59A140F3992CD2438ADC84CE /* DWPreviewSeedPhraseModel+Mnemonic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48D8BC6623C81DD3F008A668 /* DWPreviewSeedPhraseModel+Mnemonic.swift */; }; 5A1EC0FE2E29A10000000002 /* ShieldedActivityHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */; }; 5A1EC0FE2E29A10000000003 /* ShieldedActivityHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1EC0FE2E29A10000000001 /* ShieldedActivityHistory.swift */; }; @@ -776,6 +806,8 @@ 5AGOV0002F9A0000000000V2 /* GovernanceMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */; }; 5AGOV0002F9A0000000000V3 /* GovernanceMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */; }; 7566F48A2BB6CAF2005238D2 /* MenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7566F4892BB6CAF2005238D2 /* MenuItem.swift */; }; + AC71E0000000000000000002 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC71E0000000000000000001 /* ActivityView.swift */; }; + AC71E0000000000000000003 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC71E0000000000000000001 /* ActivityView.swift */; }; 7566F48B2BB6CAF2005238D2 /* MenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7566F4892BB6CAF2005238D2 /* MenuItem.swift */; }; 7569D6832DE6DA6800768BFF /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7569D6822DE6DA6800768BFF /* ExploreViewController.swift */; }; 7569D6842DE6DA6800768BFF /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7569D6822DE6DA6800768BFF /* ExploreViewController.swift */; }; @@ -842,6 +874,8 @@ 75B3C1A32E36100100CAFE03 /* ShortcutSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3C1A12E36100100CAFE01 /* ShortcutSelectionView.swift */; }; 75B3C1B22E36100100CAFE05 /* ShortcutCustomizeBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3C1B12E36100100CAFE04 /* ShortcutCustomizeBannerView.swift */; }; 75B3C1B32E36100100CAFE06 /* ShortcutCustomizeBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3C1B12E36100100CAFE04 /* ShortcutCustomizeBannerView.swift */; }; + C0172000000000000008A2 /* TransientToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000008A1 /* TransientToastModifier.swift */; }; + C0172000000000000008A3 /* TransientToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0172000000000000008A1 /* TransientToastModifier.swift */; }; 75BDE7AC2BF3287400556791 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75BDE7AB2BF3287400556791 /* Toast.swift */; }; 75BDE7AD2BF3287400556791 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75BDE7AB2BF3287400556791 /* Toast.swift */; }; 75C3EE002DA7C63C00A4E9C0 /* DashSpendUserAuthScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C3EDFE2DA7C63C00A4E9C0 /* DashSpendUserAuthScreen.swift */; }; @@ -1166,6 +1200,26 @@ 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 */; }; + C017200000000000000CA2 /* ShieldedRecoverySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000CA1 /* ShieldedRecoverySheet.swift */; }; + C017200000000000000CA3 /* ShieldedRecoverySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000CA1 /* ShieldedRecoverySheet.swift */; }; + C017200000000000000DA2 /* ShieldedTransferStepList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000DA1 /* ShieldedTransferStepList.swift */; }; + C017200000000000000DA3 /* ShieldedTransferStepList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000DA1 /* ShieldedTransferStepList.swift */; }; + C017200000000000000EA2 /* ShieldedSubmittedUnconfirmedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000EA1 /* ShieldedSubmittedUnconfirmedView.swift */; }; + C017200000000000000EA3 /* ShieldedSubmittedUnconfirmedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000EA1 /* ShieldedSubmittedUnconfirmedView.swift */; }; + C017200000000000000FA2 /* InternalTransferSummaryFigures.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FA1 /* InternalTransferSummaryFigures.swift */; }; + C017200000000000000FA3 /* InternalTransferSummaryFigures.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FA1 /* InternalTransferSummaryFigures.swift */; }; + C017200000000000001102 /* TransferConfirmSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001101 /* TransferConfirmSummary.swift */; }; + C017200000000000001103 /* TransferConfirmSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001101 /* TransferConfirmSummary.swift */; }; + C017200000000000001142 /* InternalTransferConfirmViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001141 /* InternalTransferConfirmViewModel.swift */; }; + C017200000000000001143 /* InternalTransferConfirmViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001141 /* InternalTransferConfirmViewModel.swift */; }; + C017200000000000001152 /* InternalTransferRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001151 /* InternalTransferRunner.swift */; }; + C017200000000000001153 /* InternalTransferRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001151 /* InternalTransferRunner.swift */; }; + C017200000000000001162 /* InternalTransferToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001161 /* InternalTransferToastModifier.swift */; }; + C017200000000000001163 /* InternalTransferToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001161 /* InternalTransferToastModifier.swift */; }; + C017200000000000001112 /* TransferSummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001111 /* TransferSummaryCard.swift */; }; + C017200000000000001113 /* TransferSummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000001111 /* TransferSummaryCard.swift */; }; + C017200000000000000FB2 /* IdentityWithdrawViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FB1 /* IdentityWithdrawViewModel.swift */; }; + C017200000000000000FB3 /* IdentityWithdrawViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C017200000000000000FB1 /* IdentityWithdrawViewModel.swift */; }; C6344885F1E0A6C04D3D8437 /* InternalTransferConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */; }; C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F454341FADB260861FE05EB4 /* SendScreenViewController.swift */; }; C909614D29EFF7D600002D82 /* WalletKeysOverviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C909614C29EFF7D600002D82 /* WalletKeysOverviewModel.swift */; }; @@ -2024,6 +2078,16 @@ 5ADPR0012E4A0000000000F1 /* DashPayContactAddressReadiness.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayContactAddressReadiness.swift; sourceTree = ""; }; 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 = ""; }; + C017200000000000000CA1 /* ShieldedRecoverySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedRecoverySheet.swift; sourceTree = ""; }; + C017200000000000000DA1 /* ShieldedTransferStepList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedTransferStepList.swift; sourceTree = ""; }; + C017200000000000000EA1 /* ShieldedSubmittedUnconfirmedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldedSubmittedUnconfirmedView.swift; sourceTree = ""; }; + C017200000000000000FA1 /* InternalTransferSummaryFigures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTransferSummaryFigures.swift; sourceTree = ""; }; + C017200000000000001101 /* TransferConfirmSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferConfirmSummary.swift; sourceTree = ""; }; + C017200000000000001141 /* InternalTransferConfirmViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTransferConfirmViewModel.swift; sourceTree = ""; }; + C017200000000000001151 /* InternalTransferRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTransferRunner.swift; sourceTree = ""; }; + C017200000000000001161 /* InternalTransferToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTransferToastModifier.swift; sourceTree = ""; }; + C017200000000000001111 /* TransferSummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferSummaryCard.swift; sourceTree = ""; }; + C017200000000000000FB1 /* IdentityWithdrawViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityWithdrawViewModel.swift; sourceTree = ""; }; 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InternalTransferConfirmSheet.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; }; @@ -2304,6 +2368,8 @@ 2A7A7BC72347E0D700451078 /* DWBaseFormTableViewCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWBaseFormTableViewCell.h; sourceTree = ""; }; 2A7A7BC82347E0D700451078 /* DWBaseFormTableViewCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWBaseFormTableViewCell.m; sourceTree = ""; }; 2A7A7BD52348CB6600451078 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + C0FA5E0000000000000000A1 /* AdvancedModeInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedModeInfoSheet.swift; sourceTree = ""; }; + C0FA5E0000000000000000B1 /* SettingsAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAlerts.swift; sourceTree = ""; }; 2A7A7C14234B763600451078 /* DWLocalCurrencyViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWLocalCurrencyViewController.h; sourceTree = ""; }; 2A7A7C15234B763600451078 /* DWLocalCurrencyViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWLocalCurrencyViewController.m; sourceTree = ""; }; 2A7A7C1B234B771400451078 /* DWLocalCurrencyModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DWLocalCurrencyModel.h; sourceTree = ""; }; @@ -2697,6 +2763,9 @@ 47A5145F2848F75B005A8E3E /* TxDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxDetailViewController.swift; sourceTree = ""; }; 47A514612848FEAD005A8E3E /* Tx.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Tx.storyboard; sourceTree = ""; }; 47A5146328491C60005A8E3E /* TxDetailCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxDetailCells.swift; sourceTree = ""; }; + C017200000000000000AA1 /* RequestAmountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAmountScreen.swift; sourceTree = ""; }; + C017200000000000000BA1 /* RequestAmountHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAmountHostingController.swift; sourceTree = ""; }; + C0172000000000000009A1 /* SpecifyAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecifyAmountView.swift; sourceTree = ""; }; 47AC8412297822E000BD1B49 /* SpecifyAmountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecifyAmountViewController.swift; sourceTree = ""; }; 47AE8B9428BFACA100490F5E /* ExploreDash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreDash.swift; sourceTree = ""; }; 47AE8B9B28BFAD2800490F5E /* ExplorePointOfUse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorePointOfUse.swift; sourceTree = ""; }; @@ -2795,7 +2864,6 @@ 51530E6B2FD6EE1D00175308 /* AllMerchantLocationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMerchantLocationsViewController.swift; sourceTree = ""; }; 51530E6C2FD6EE1D00175308 /* AllMerchantLocationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMerchantLocationsViewModel.swift; sourceTree = ""; }; 51646C713006263C00BE3070 /* Uphold-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Uphold-Info.plist"; sourceTree = ""; }; - 516983C12FCAEDEB00BA91A2 /* XmarkIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XmarkIcon.swift; sourceTree = ""; }; 516C4047DB43CFFBB8ED541B /* Pods-DashWalletScreenshotsUITests.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletScreenshotsUITests.testnet.xcconfig"; path = "Target Support Files/Pods-DashWalletScreenshotsUITests/Pods-DashWalletScreenshotsUITests.testnet.xcconfig"; sourceTree = ""; }; 516C63E42FE9BE0A00DD6BC3 /* AboutDashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDashView.swift; sourceTree = ""; }; 516C63E72FE9BE0A00DD6BC3 /* AboutDashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDashViewModel.swift; sourceTree = ""; }; @@ -2917,6 +2985,7 @@ 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuScreen.swift; sourceTree = ""; }; 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuViewModel.swift; sourceTree = ""; }; 7566F4892BB6CAF2005238D2 /* MenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItem.swift; sourceTree = ""; }; + AC71E0000000000000000001 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; 7569D6822DE6DA6800768BFF /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = ""; }; 7569D6852DE6DB1B00768BFF /* ExploreHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreHeaderView.swift; sourceTree = ""; }; 7569D6882DE6DB6700768BFF /* ExploreContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreContentsView.swift; sourceTree = ""; }; @@ -2959,6 +3028,7 @@ 75AA33D72BFB4A5A00F12465 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 75B3C1A12E36100100CAFE01 /* ShortcutSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutSelectionView.swift; sourceTree = ""; }; 75B3C1B12E36100100CAFE04 /* ShortcutCustomizeBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutCustomizeBannerView.swift; sourceTree = ""; }; + C0172000000000000008A1 /* TransientToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransientToastModifier.swift; sourceTree = ""; }; 75BDE7AB2BF3287400556791 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 75C1F0442AE26AC0006929CA /* CoinJoinLevelsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinLevelsViewModel.swift; sourceTree = ""; }; 75C3EDFD2DA7C63C00A4E9C0 /* DashSpendLoginInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashSpendLoginInfoView.swift; sourceTree = ""; }; @@ -3051,6 +3121,17 @@ A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletWipeSerialExecutorTests.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 = ""; }; + C0172000000000000003A1 /* PaymentsTabRootController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsTabRootController.swift; sourceTree = ""; }; + C0172000000000000006A1 /* PaymentsSendCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsSendCard.swift; sourceTree = ""; }; + C0172000000000000007A1 /* PaymentsReceiveContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsReceiveContent.swift; sourceTree = ""; }; + C0172000000000000002A1 /* PaymentsActionCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsActionCard.swift; sourceTree = ""; }; + C0172000000000000001A1 /* PaymentsTabSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsTabSelector.swift; sourceTree = ""; }; + C0172000000000000011A1 /* TransferPrivacyTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferPrivacyTip.swift; sourceTree = ""; }; + C0171F000000000000001A1 /* TransferSourceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferSourceRow.swift; sourceTree = ""; }; + C0171F000000000000001B1 /* TransferAmountValidationNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferAmountValidationNote.swift; sourceTree = ""; }; + C0171F000000000000001C1 /* TransferPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferPreview.swift; sourceTree = ""; }; + C0171F000000000000001D1 /* TransferEndpointCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferEndpointCards.swift; sourceTree = ""; }; + C017200000000000000FC1 /* TransferEndpointPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferEndpointPicker.swift; sourceTree = ""; }; A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKReceiveAddressReader.swift; sourceTree = ""; }; AA00000028D9A3DB00223B77 /* BuySellPortalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalView.swift; sourceTree = ""; }; AA0000032DUMMYID001234567 /* AddProviderToGiftCardsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProviderToGiftCardsTable.swift; sourceTree = ""; }; @@ -3764,6 +3845,45 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + C0171F000000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + C0171F000000000000001A1 /* TransferSourceRow.swift */, + C0172000000000000011A1 /* TransferPrivacyTip.swift */, + C0171F000000000000001B1 /* TransferAmountValidationNote.swift */, + C0171F000000000000001C1 /* TransferPreview.swift */, + C0171F000000000000001D1 /* TransferEndpointCards.swift */, + C017200000000000000FC1 /* TransferEndpointPicker.swift */, + ); + path = Components; + sourceTree = ""; + }; + C0172000000000000011000 /* Confirm */ = { + isa = PBXGroup; + children = ( + 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */, + C017200000000000000FA1 /* InternalTransferSummaryFigures.swift */, + C017200000000000001101 /* TransferConfirmSummary.swift */, + C017200000000000001141 /* InternalTransferConfirmViewModel.swift */, + C017200000000000001151 /* InternalTransferRunner.swift */, + C017200000000000001161 /* InternalTransferToastModifier.swift */, + C017200000000000001111 /* TransferSummaryCard.swift */, + ); + path = Confirm; + sourceTree = ""; + }; + C0172000000000000010000 /* Shielded */ = { + isa = PBXGroup; + children = ( + C017200000000000000CA1 /* ShieldedRecoverySheet.swift */, + C017200000000000000EA1 /* ShieldedSubmittedUnconfirmedView.swift */, + C017200000000000000DA1 /* ShieldedTransferStepList.swift */, + 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */, + 59C9D64AB542D03FE3704D02 /* ShieldedWithdrawalStore.swift */, + ); + path = Shielded; + sourceTree = ""; + }; 5AEEB0012F9C000000000004 /* Masternodes */ = { isa = PBXGroup; children = ( @@ -3786,13 +3906,14 @@ 0529717581A09721B3115957 /* InternalTransfer */ = { isa = PBXGroup; children = ( + C0172000000000000011000 /* Confirm */, + C0172000000000000010000 /* Shielded */, + C0171F000000000000000000 /* Components */, 67FCD2ED5EDFD68CDE1FE7CC /* TransferTimingSheet.swift */, 7249AC40404B20F2AEE590F1 /* InternalTransferViewModel.swift */, A62C70E4EBB7458C362B7669 /* InternalTransferScreen.swift */, F57DA9DBE5AAF88B3727E148 /* InternalTransferHostingController.swift */, - 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */, - 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */, - 59C9D64AB542D03FE3704D02 /* ShieldedWithdrawalStore.swift */, + C017200000000000000FB1 /* IdentityWithdrawViewModel.swift */, ); path = InternalTransfer; sourceTree = ""; @@ -4797,10 +4918,20 @@ path = Security; sourceTree = ""; }; + C0FA5E0000000000000000A0 /* Components */ = { + isa = PBXGroup; + children = ( + C0FA5E0000000000000000A1 /* AdvancedModeInfoSheet.swift */, + C0FA5E0000000000000000B1 /* SettingsAlerts.swift */, + ); + path = Components; + sourceTree = ""; + }; 2A7A7BD32348CB4F00451078 /* Settings */ = { isa = PBXGroup; children = ( 2A7A7C13234B761700451078 /* LocalCurrency */, + C0FA5E0000000000000000A0 /* Components */, 2A8F420A21BEE69E00858B91 /* About */, 2A7A7BD52348CB6600451078 /* SettingsScreen.swift */, 750CED5F2C94BFD7000FB837 /* SettingsMenuViewModel.swift */, @@ -5484,6 +5615,7 @@ 47A1B00030B5A10000D15A01 /* SendAmountScreen.swift */, 47B30D7F291123D30080C326 /* SendAmountViewController.swift */, 47AC8412297822E000BD1B49 /* SpecifyAmountViewController.swift */, + C0172000000000000009A1 /* SpecifyAmountView.swift */, ); path = Amount; sourceTree = ""; @@ -5506,6 +5638,8 @@ 2ACCD85A231939D600A96B62 /* Views */, 2ACCD8572319399100A96B62 /* DWRequestAmountViewController.h */, 2ACCD8582319399100A96B62 /* DWRequestAmountViewController.m */, + C017200000000000000AA1 /* RequestAmountScreen.swift */, + C017200000000000000BA1 /* RequestAmountHostingController.swift */, ); path = RequestAmount; sourceTree = ""; @@ -6694,7 +6828,6 @@ children = ( 51F0D10330BF98BA00B27E11 /* ArrowDownIcon.swift */, 51F0D10030BF97A900B27E11 /* ExclamationIcon.swift */, - 516983C12FCAEDEB00BA91A2 /* XmarkIcon.swift */, ); path = Icons; sourceTree = ""; @@ -6769,10 +6902,23 @@ path = Codec; sourceTree = ""; }; + C0172000000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + C0172000000000000001A1 /* PaymentsTabSelector.swift */, + C0172000000000000002A1 /* PaymentsActionCard.swift */, + C0172000000000000007A1 /* PaymentsReceiveContent.swift */, + C0172000000000000006A1 /* PaymentsSendCard.swift */, + ); + path = Components; + sourceTree = ""; + }; 6916981A49557073ABA80EAB /* Landing */ = { isa = PBXGroup; children = ( + C0172000000000000000000 /* Components */, E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */, + C0172000000000000003A1 /* PaymentsTabRootController.swift */, FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */, B401E660599FEF6B18087F91 /* PaymentsLandingHostingController.swift */, ); @@ -6875,11 +7021,13 @@ 75EBAA082BB9791B004488E3 /* Icon.swift */, AA0031012FD0000000000011 /* IconName+DashIconSource.swift */, 7566F4892BB6CAF2005238D2 /* MenuItem.swift */, + AC71E0000000000000000001 /* ActivityView.swift */, 75EBAA0B2BB9792F004488E3 /* FeatureTopText.swift */, 754C27CB2CC3C14F00BA7B9F /* FeatureSingleItem.swift */, 75EBAA0E2BB99036004488E3 /* TextIntro.swift */, RC00A0032FA0000000000001 /* TopIntro.swift */, 75BDE7AB2BF3287400556791 /* Toast.swift */, + C0172000000000000008A1 /* TransientToastModifier.swift */, 75AA33CE2BF9D44A00F12465 /* ButtonsGroup.swift */, 75AA33D12BF9E18E00F12465 /* Color+DWStyle.swift */, 75AA33D42BF9E1D400F12465 /* Font+DWStyle.swift */, @@ -9514,6 +9662,8 @@ CNR0000052FF100000000001 /* CrowdNodeBalanceReminderBanner.swift in Sources */, CNR0000112FF100000000001 /* CrowdNodeWithdrawalRouter.swift in Sources */, 2A7A7BD62348CB6600451078 /* SettingsScreen.swift in Sources */, + C0FA5E0000000000000000A2 /* AdvancedModeInfoSheet.swift in Sources */, + C0FA5E0000000000000000B2 /* SettingsAlerts.swift in Sources */, 2A0C69DA2314727F001B8C90 /* UISpringTimingParameters+DWInit.m in Sources */, 47C661AD28F972BD00028A8D /* NumberKeyboardButton.swift in Sources */, 11AE3DD82997C599000856EE /* IsDefaultEmail.swift in Sources */, @@ -9791,6 +9941,9 @@ 759609232C455B2000F3BF04 /* SendIntro.swift in Sources */, 7549BE5A2DEAF50D004F0BAF /* IconBitmap.swift in Sources */, 47AC8413297822E000BD1B49 /* SpecifyAmountViewController.swift in Sources */, + C0172000000000000009A2 /* SpecifyAmountView.swift in Sources */, + C017200000000000000BA2 /* RequestAmountHostingController.swift in Sources */, + C017200000000000000AA2 /* RequestAmountScreen.swift in Sources */, 2AFCB9C423BE76EC00FF59A6 /* DWUpholdTransactionObject+DWView.m in Sources */, 75CDD7862C08D61300F433D2 /* DashAmount.swift in Sources */, 114CFED0296469D9005F421B /* CrowdNodeDepositTx.swift in Sources */, @@ -9895,7 +10048,6 @@ 47AE8C1A28C6A21A00490F5E /* AllMerchantLocationsDataProvider.swift in Sources */, 754565CC2DAA52A000DA4E8E /* CTXSpendAPI.swift in Sources */, 51F0D10430BF98BA00B27E11 /* ArrowDownIcon.swift in Sources */, - 516983C22FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */, 51F0D10130BF97A900B27E11 /* ExclamationIcon.swift in Sources */, 754565CE2DAA52A000DA4E8E /* CTXSpendRepository.swift in Sources */, 754565CF2DAA52A000DA4E8E /* CTXSpendEndpoint.swift in Sources */, @@ -10060,6 +10212,7 @@ 47A2E3A92972B15F0032A63B /* RatesProvider.swift in Sources */, 47A2A2E9293E612900938DB7 /* CBAuth.swift in Sources */, 7566F48A2BB6CAF2005238D2 /* MenuItem.swift in Sources */, + AC71E0000000000000000002 /* ActivityView.swift in Sources */, 0F6EDFD128C896BD000427E7 /* CoinbaseCreateAddressesRequest.swift in Sources */, 471A2605289ACD5C0056B7B2 /* AddressUserInfo.swift in Sources */, 51BA2EA02F0B2E0100A1B201 /* SwapPendingGate.swift in Sources */, @@ -10136,6 +10289,7 @@ 753130972B4944130069C9B7 /* UpholdError.swift in Sources */, 2A913E9823A400D3006A2A59 /* DWContainerViewController.m in Sources */, 75BDE7AC2BF3287400556791 /* Toast.swift in Sources */, + C0172000000000000008A2 /* TransientToastModifier.swift in Sources */, 47B30D7A290D035B0080C326 /* DashTextAttachment.swift in Sources */, BA1A0003C0FFEE00B15A0003 /* BalanceInfoSheet.swift in Sources */, C9F067F229E4576D0022D958 /* HomeBalanceView.swift in Sources */, @@ -10225,8 +10379,29 @@ 0EB4679BC135B464D90CDC8B /* TransferTimingSheet.swift in Sources */, EF053C0D81A007CC015EB3EE /* InternalTransferViewModel.swift in Sources */, 58825FFD2913876CC5D4AA21 /* InternalTransferScreen.swift in Sources */, + C0172000000000000001A2 /* PaymentsTabSelector.swift in Sources */, + C0172000000000000002A2 /* PaymentsActionCard.swift in Sources */, + C0172000000000000007A2 /* PaymentsReceiveContent.swift in Sources */, + C0172000000000000006A2 /* PaymentsSendCard.swift in Sources */, + C0172000000000000003A2 /* PaymentsTabRootController.swift in Sources */, + C0171F000000000000001A2 /* TransferSourceRow.swift in Sources */, + C0172000000000000011A2 /* TransferPrivacyTip.swift in Sources */, + C0171F000000000000001B2 /* TransferAmountValidationNote.swift in Sources */, + C0171F000000000000001C2 /* TransferPreview.swift in Sources */, + C0171F000000000000001D2 /* TransferEndpointCards.swift in Sources */, + C017200000000000000FC2 /* TransferEndpointPicker.swift in Sources */, 7C93319817E9E574070A504D /* InternalTransferHostingController.swift in Sources */, C6344885F1E0A6C04D3D8437 /* InternalTransferConfirmSheet.swift in Sources */, + C017200000000000000FA2 /* InternalTransferSummaryFigures.swift in Sources */, + C017200000000000001102 /* TransferConfirmSummary.swift in Sources */, + C017200000000000001142 /* InternalTransferConfirmViewModel.swift in Sources */, + C017200000000000001152 /* InternalTransferRunner.swift in Sources */, + C017200000000000001162 /* InternalTransferToastModifier.swift in Sources */, + C017200000000000001112 /* TransferSummaryCard.swift in Sources */, + C017200000000000000FB2 /* IdentityWithdrawViewModel.swift in Sources */, + C017200000000000000EA2 /* ShieldedSubmittedUnconfirmedView.swift in Sources */, + C017200000000000000DA2 /* ShieldedTransferStepList.swift in Sources */, + C017200000000000000CA2 /* ShieldedRecoverySheet.swift in Sources */, A15ED0B9E7AAF29C694E4E17 /* DWIdentityAuthorizer.swift in Sources */, 2A6B1257A51E609B8025BF3A /* ShieldedTransferCoordinator.swift in Sources */, 9B40E576972DB41F7EF4D21E /* ShieldedWithdrawalStore.swift in Sources */, @@ -10398,6 +10573,8 @@ CNR0000062FF100000000001 /* CrowdNodeBalanceReminderBanner.swift in Sources */, CNR0000122FF100000000001 /* CrowdNodeWithdrawalRouter.swift in Sources */, C9D2C6B02A320AA000D15901 /* SettingsScreen.swift in Sources */, + C0FA5E0000000000000000A3 /* AdvancedModeInfoSheet.swift in Sources */, + C0FA5E0000000000000000B3 /* SettingsAlerts.swift in Sources */, 751B61C52ADFFD0700D1C2EF /* IntegrationViewController+Uphold.swift in Sources */, C9D2C6B22A320AA000D15901 /* UISpringTimingParameters+DWInit.m in Sources */, C9D2C6B62A320AA000D15901 /* NumberKeyboardButton.swift in Sources */, @@ -10605,7 +10782,6 @@ C9D2C7392A320AA000D15901 /* CoinbaseAmount.swift in Sources */, C9D2C73B2A320AA000D15901 /* SendAmountModel.swift in Sources */, 51F0D10530BF98BA00B27E11 /* ArrowDownIcon.swift in Sources */, - 516983C32FCAEDEB00BA91A2 /* XmarkIcon.swift in Sources */, 51F0D10230BF97A900B27E11 /* ExclamationIcon.swift in Sources */, 75EBAA102BB99036004488E3 /* TextIntro.swift in Sources */, RC00A0022FA0000000000001 /* TopIntro.swift in Sources */, @@ -10644,6 +10820,7 @@ C943B32F2A408CED00AF23C5 /* DWSaveAlertViewController.m in Sources */, C930784C2A6AD59700906E4B /* TitleValueCell.swift in Sources */, 7566F48B2BB6CAF2005238D2 /* MenuItem.swift in Sources */, + AC71E0000000000000000003 /* ActivityView.swift in Sources */, C9D2C74F2A320AA000D15901 /* DWSeedPhraseRow.m in Sources */, 7531308E2B47EC910069C9B7 /* UpholdClient.swift in Sources */, 751C05D92D3BB6AA00475E52 /* CurrentUserProfileModel.swift in Sources */, @@ -10746,6 +10923,7 @@ C9D2C7A32A320AA000D15901 /* DWVersionManager.m in Sources */, C9D2C7A52A320AA000D15901 /* DWModalPresentationController.m in Sources */, 75BDE7AD2BF3287400556791 /* Toast.swift in Sources */, + C0172000000000000008A3 /* TransientToastModifier.swift in Sources */, C9D2C7A72A320AA000D15901 /* ActionButtonViewController.swift in Sources */, C9D2C7A82A320AA000D15901 /* BuySellServiceItemCell.swift in Sources */, C956AF122A5B5949002FAB75 /* PasteboardContentView.swift in Sources */, @@ -10762,6 +10940,9 @@ C9D2C7B12A320AA000D15901 /* CoinbaseBaseIDForCurrencyResponse.swift in Sources */, C9D2C7B22A320AA000D15901 /* BuildFile in Sources */, C9D2C7B32A320AA000D15901 /* SpecifyAmountViewController.swift in Sources */, + C0172000000000000009A3 /* SpecifyAmountView.swift in Sources */, + C017200000000000000BA3 /* RequestAmountHostingController.swift in Sources */, + C017200000000000000AA3 /* RequestAmountScreen.swift in Sources */, 756557B62CE84FFA0060348D /* FeeInfo.swift in Sources */, C943B5922A40ED7B00AF23C5 /* DWTextField.m in Sources */, 75EBAA0D2BB9792F004488E3 /* FeatureTopText.swift in Sources */, @@ -11239,8 +11420,29 @@ 1C2999FF1FC854E7DB138250 /* TransferTimingSheet.swift in Sources */, FF4A426923D0CF755AE981FB /* InternalTransferViewModel.swift in Sources */, EFCF9D5D6630AD9BD0A62E29 /* InternalTransferScreen.swift in Sources */, + C0172000000000000001A3 /* PaymentsTabSelector.swift in Sources */, + C0172000000000000002A3 /* PaymentsActionCard.swift in Sources */, + C0172000000000000007A3 /* PaymentsReceiveContent.swift in Sources */, + C0172000000000000006A3 /* PaymentsSendCard.swift in Sources */, + C0172000000000000003A3 /* PaymentsTabRootController.swift in Sources */, + C0171F000000000000001A3 /* TransferSourceRow.swift in Sources */, + C0172000000000000011A3 /* TransferPrivacyTip.swift in Sources */, + C0171F000000000000001B3 /* TransferAmountValidationNote.swift in Sources */, + C0171F000000000000001C3 /* TransferPreview.swift in Sources */, + C0171F000000000000001D3 /* TransferEndpointCards.swift in Sources */, + C017200000000000000FC3 /* TransferEndpointPicker.swift in Sources */, 428C56F90F533CF0DD6774F1 /* InternalTransferHostingController.swift in Sources */, E2D0F4AD56B92356C871F568 /* InternalTransferConfirmSheet.swift in Sources */, + C017200000000000000FA3 /* InternalTransferSummaryFigures.swift in Sources */, + C017200000000000001103 /* TransferConfirmSummary.swift in Sources */, + C017200000000000001143 /* InternalTransferConfirmViewModel.swift in Sources */, + C017200000000000001153 /* InternalTransferRunner.swift in Sources */, + C017200000000000001163 /* InternalTransferToastModifier.swift in Sources */, + C017200000000000001113 /* TransferSummaryCard.swift in Sources */, + C017200000000000000FB3 /* IdentityWithdrawViewModel.swift in Sources */, + C017200000000000000EA3 /* ShieldedSubmittedUnconfirmedView.swift in Sources */, + C017200000000000000DA3 /* ShieldedTransferStepList.swift in Sources */, + C017200000000000000CA3 /* ShieldedRecoverySheet.swift in Sources */, 17ED79A8B56522CD03A8CD5E /* ShieldedTransferCoordinator.swift in Sources */, 89ED38B6205FE4EEEE8013B9 /* ShieldedWithdrawalStore.swift in Sources */, 4AC8F492DD74101C71A5EA5E /* PlatformSendExecutor.swift in Sources */, @@ -11427,7 +11629,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -11464,7 +11666,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -11500,7 +11702,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -11536,7 +11738,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -11697,7 +11899,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; @@ -11726,7 +11928,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -11818,7 +12020,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -11857,7 +12059,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -11946,7 +12148,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -11978,7 +12180,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12010,12 +12212,12 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -12032,12 +12234,12 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -12052,7 +12254,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 3BA20785E162F1AF2F455D9C /* Pods-WatchApp Extension.debug.xcconfig */; buildSettings = { - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_BITCODE = NO; EXCLUDED_ARCHS = ""; @@ -12062,7 +12264,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp.watchkitextension; PRODUCT_NAME = "${TARGET_NAME}"; SDKROOT = watchos; @@ -12079,7 +12281,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 4457C976B1DB0CF402FF3C5F /* Pods-WatchApp Extension.release.xcconfig */; buildSettings = { - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_BITCODE = NO; EXCLUDED_ARCHS = ""; @@ -12089,7 +12291,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp.watchkitextension; PRODUCT_NAME = "${TARGET_NAME}"; SDKROOT = watchos; @@ -12107,7 +12309,7 @@ baseConfigurationReference = 3B0E86557754B86DD3A389CC /* Pods-TodayExtension.debug.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = dashwallet/dashwalletTodayExtension.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -12121,7 +12323,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.TodayExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -12136,7 +12338,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = dashwallet/dashwalletTodayExtension.entitlements; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -12150,7 +12352,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.TodayExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -12168,7 +12370,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; @@ -12197,7 +12399,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -12289,7 +12491,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12317,7 +12519,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -12409,7 +12611,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12448,7 +12650,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -12539,7 +12741,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12578,7 +12780,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -12732,7 +12934,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12770,7 +12972,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -12859,7 +13061,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = dashwallet/dashwalletTodayExtension.entitlements; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -12873,7 +13075,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.TodayExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -12889,7 +13091,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -12921,12 +13123,12 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -12941,7 +13143,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = EE15E7C0EEFF1E6930A90DA6 /* Pods-WatchApp Extension.testflight.xcconfig */; buildSettings = { - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_BITCODE = NO; EXCLUDED_ARCHS = ""; @@ -12951,7 +13153,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp.watchkitextension; PRODUCT_NAME = "${TARGET_NAME}"; SDKROOT = watchos; @@ -13040,7 +13242,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = dashwallet/dashwallet.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -13068,7 +13270,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -13156,7 +13358,7 @@ baseConfigurationReference = AA29A8515217AC4232F794C2 /* Pods-TodayExtension.testnet.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = dashwallet/dashwalletTodayExtension.entitlements; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; @@ -13170,7 +13372,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.TodayExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -13185,7 +13387,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/dashwallet.app/dashwallet"; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -13217,12 +13419,12 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; EXCLUDED_ARCHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -13237,7 +13439,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = E2A832899A499C24C7A43E47 /* Pods-WatchApp Extension.testnet.xcconfig */; buildSettings = { - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_BITCODE = NO; EXCLUDED_ARCHS = ""; @@ -13247,7 +13449,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 9.0.0; + MARKETING_VERSION = 9.1.0; PRODUCT_BUNDLE_IDENTIFIER = org.dashfoundation.dash.watchkitapp.watchkitextension; PRODUCT_NAME = "${TARGET_NAME}"; SDKROOT = watchos; diff --git a/DashWallet/Sources/Application/App.swift b/DashWallet/Sources/Application/App.swift index 3f12ebd988..d80d7a2fa4 100644 --- a/DashWallet/Sources/Application/App.swift +++ b/DashWallet/Sources/Application/App.swift @@ -131,6 +131,10 @@ private let kFiatCurrencyDidChange = "FiatCurrencyDidChange" extension Notification.Name { static let fiatCurrencyDidChange = Notification.Name(kFiatCurrencyDidChange) + /// `DWGlobalOptions.advancedModeEnabled` was flipped. Posted by whoever + /// writes it, so a screen already on display can rebuild rather than show + /// the value it happened to read when it appeared. + static let advancedModeDidChange = Notification.Name("org.dash.advanced-mode-did-change") static let willRequestOSPermission = Notification.Name("org.dash.will-request-permission-notification") static let didRequestOSPermission = Notification.Name("org.dash.did-request-permission-notification") static let applicationTerminationRequest = Notification.Name("DWApplicationTerminationRequestNotification") diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityAuthorizer.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityAuthorizer.swift index 090491d416..640efd8e22 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityAuthorizer.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityAuthorizer.swift @@ -37,8 +37,34 @@ final class DWIdentityAuthorizer { } } + /// True while a caller has already run this gate for the work about to + /// execute, so the executor's own `authorize()` must not ask a second + /// time. + /// + /// Task-local rather than a stored flag: it covers exactly the work run + /// inside `preauthorized(_:)` and nothing else, so every other entry point + /// (the recovery sheet, the Send screen, a profile top-up) still prompts + /// for itself, and it cannot be left set by a transfer that died. + @TaskLocal static var isPreauthorized = false + + /// Runs `body` with this gate already satisfied. The caller must have + /// awaited `authorize()` successfully first โ€” this only suppresses the + /// SECOND prompt, it never skips authentication. + /// + /// Why anyone would: the gate has to be settled before the screen that + /// asked for it goes away, and the executors raise it from deep inside + /// work that outlives that screen. + static func preauthorized(_ body: () async -> T) async -> T { + await $isPreauthorized.withValue(true, operation: body) + } + @MainActor func authorize() async throws { + if Self.isPreauthorized { + Self.logger.info("๐Ÿชช IDENTITY-AUTH :: already authorized for this operation โ€” not prompting again") + return + } + let biometricEnabled = DWGlobalOptions.sharedInstance().biometricAuthEnabled Self.logger.info("๐Ÿชช IDENTITY-AUTH :: authenticating via AuthenticationGate (biometric=\(biometricEnabled, privacy: .public))") let outcome = await AuthenticationGate.authenticate(biometric: biometricEnabled) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift index 2c020eb2da..5411141da5 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityRegistrationCoordinator.swift @@ -158,13 +158,26 @@ enum PlatformPaymentIdentityFundingPolicy { fundingDuffs: fundingDuffs) } - /// Builds inputs in PlatformAddress/BTreeMap order. Rust's derived order - /// compares the address variant first (P2PKH before P2SH), then its - /// 20-byte hash. - static func makeInputs( - candidates: [Candidate], - targetCredits: UInt64 - ) throws -> [ManagedPlatformWallet.IdentityAddressInput] { + /// Saturating credit total of `candidates`. + private static func totalCredits(of candidates: [Candidate]) -> UInt64 { + candidates.reduce(UInt64(0)) { + let (sum, overflow) = $0.addingReportingOverflow($1.balance) + return overflow ? .max : sum + } + } + + /// The candidates `makeInputs` can actually plan from, in + /// PlatformAddress/BTreeMap order (Rust's derived order compares the + /// address variant first โ€” P2PKH before P2SH โ€” then the 20-byte hash), + /// plus their saturating credit total. + /// + /// Any selected address before the fee source would become BTreeMap + /// input 0 itself, so leading addresses that cannot retain the reserve + /// are skipped and the plan draws exclusively from the viable suffix. + /// `nil` when no address can be the fee source. + private static func usableSelection( + candidates: [Candidate] + ) -> (usable: [Candidate], availableCredits: UInt64)? { let sorted = candidates .filter { $0.balance > 0 } .sorted { lhs, rhs in @@ -173,29 +186,40 @@ enum PlatformPaymentIdentityFundingPolicy { } return lhs.hash.lexicographicallyPrecedes(rhs.hash) } - let totalAvailable = sorted.reduce(UInt64(0)) { - let (sum, overflow) = $0.addingReportingOverflow($1.balance) - return overflow ? .max : sum - } + guard let feeSourceIndex = sorted.firstIndex(where: { + $0.balance > feeHeadroomCredits + }) else { return nil } + let usable = Array(sorted[feeSourceIndex...]) + return (usable, totalCredits(of: usable)) + } + + /// Largest funding amount `makeInputs` can plan from `candidates`, in + /// whole duffs (floored, so the ceiling never overstates what the + /// credit-denominated planner accepts). Input 0 contributes + /// `balance โˆ’ reserve` and every later input its full balance, so the + /// ceiling is the usable total minus the retained fee headroom. 0 when + /// no address qualifies as the fee source. + static func maxFundableDuffs(candidates: [Candidate]) -> UInt64 { + guard let (_, availableCredits) = usableSelection(candidates: candidates), + availableCredits > feeHeadroomCredits + else { return 0 } + return (availableCredits - feeHeadroomCredits) / creditsPerDuff + } + + /// Builds inputs from the usable selection above. + static func makeInputs( + candidates: [Candidate], + targetCredits: UInt64 + ) throws -> [ManagedPlatformWallet.IdentityAddressInput] { let required = { let (sum, overflow) = targetCredits.addingReportingOverflow(feeHeadroomCredits) return overflow ? UInt64.max : sum }() - // Any selected address before the fee source would become BTreeMap - // input 0 itself. Skip leading addresses that cannot retain the - // reserve, then plan exclusively from the viable suffix. - guard let feeSourceIndex = sorted.firstIndex(where: { - $0.balance > feeHeadroomCredits - }) else { + guard let (usable, usableAvailable) = usableSelection(candidates: candidates) else { throw PlanningError.insufficient( required: required, - available: totalAvailable) - } - let usable = Array(sorted[feeSourceIndex...]) - let usableAvailable = usable.reduce(UInt64(0)) { - let (sum, overflow) = $0.addingReportingOverflow($1.balance) - return overflow ? .max : sum + available: totalCredits(of: candidates)) } guard usableAvailable >= required else { throw PlanningError.insufficient( diff --git a/DashWallet/Sources/Models/DWGlobalOptions.h b/DashWallet/Sources/Models/DWGlobalOptions.h index 99bd05c16b..04787bfb47 100644 --- a/DashWallet/Sources/Models/DWGlobalOptions.h +++ b/DashWallet/Sources/Models/DWGlobalOptions.h @@ -47,6 +47,14 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL balanceHidden; @property (nonatomic, assign) BOOL tapToHideBalanceShown; +/// Opt-in to the advanced surfaces across the app. Off by default: the +/// screens it unlocks assume the user knows what a UTXO or a derivation path +/// is, and showing them unasked is how an ordinary wallet stops looking +/// ordinary. Read it anywhere, but observe +/// `Notification.Name.advancedModeDidChange` rather than caching the value โ€” +/// it can be flipped from Settings while any screen is on display. +@property (nonatomic, assign) BOOL advancedModeEnabled; + @property (nonatomic, assign) BOOL shouldDisplayOnboarding; @property (nonatomic, assign) BOOL shouldDisplayReclassifyYourTransactionsFlow; @property (nullable, nonatomic, strong) NSDate *dateReclassifyYourTransactionsFlowActivated; diff --git a/DashWallet/Sources/Models/DWGlobalOptions.m b/DashWallet/Sources/Models/DWGlobalOptions.m index c95d9ac32c..273bff98a0 100644 --- a/DashWallet/Sources/Models/DWGlobalOptions.m +++ b/DashWallet/Sources/Models/DWGlobalOptions.m @@ -46,6 +46,7 @@ @implementation DWGlobalOptions @dynamic autoLockAppInterval; @dynamic shortcuts; @dynamic balanceHidden; +@dynamic advancedModeEnabled; @dynamic tapToHideBalanceShown; @dynamic shouldDisplayOnboarding; @dynamic paymentsScreenCurrentTab; @@ -229,6 +230,7 @@ - (void)restoreToDefaults { self.shortcuts = nil; self.localNotificationsEnabled = YES; self.balanceHidden = NO; + self.advancedModeEnabled = NO; self.tapToHideBalanceShown = NO; self.resyncingWallet = NO; self.selectedPaymentCurrency = DWPaymentCurrencyDash; diff --git a/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift b/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift index 12328308a1..c86a495a84 100644 --- a/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift +++ b/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift @@ -109,10 +109,6 @@ struct TransferAmountView: View { inProgress: viewModel.isProcessing, actionHandler: { viewModel.transfer() } ) - .frame(maxWidth: .infinity, maxHeight: 320) - .background(Color.dash.secondaryBackground) - .clipShape(.rect(cornerRadius: 20)) - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) } } diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift index f3bf18dfd4..ba840123a6 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift @@ -807,7 +807,6 @@ struct PayContactSheet: View { inProgress: isSending, actionHandler: { pay() } ) - .padding(.top, 8) } /// Fiat equivalent of what is typed, for the secondary line. Empty while diff --git a/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift index c9f993caf3..3db9e36796 100644 --- a/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Profile/SDKIdentityProfileSheet.swift @@ -390,6 +390,16 @@ final class IdentityTopUpViewModel: ObservableObject { case platform case transparent + /// The funding source that spends `network`'s balance โ€” the internal + /// transfer screen's bridge from its FROM side to this executor. + init(spending network: ChainNetwork) { + switch network { + case .core: self = .transparent + case .platform: self = .platform + case .shielded: self = .shielded + } + } + var title: String { switch self { case .shielded: return NSLocalizedString("Shielded", comment: "Shielded activity: funds moved into the private shielded balance") @@ -439,10 +449,15 @@ final class IdentityTopUpViewModel: ObservableObject { /// unshield minimum (shielded route only, via the pure /// `estimateShieldedFee` FFI) plus the observed top-up transition fee. /// The transparent route also pays a small Core miner fee on top. - static func estimatedFeeCredits(source: FundingSource) -> UInt64 { + /// `nil` when the shielded route's unshield estimate is unavailable โ€” + /// callers show "no number" rather than only the transition fee. + static func estimatedFeeCredits(source: FundingSource) -> UInt64? { let unshieldFee: UInt64 if source == .shielded { - unshieldFee = (try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2)) ?? 0 + guard let estimate = try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) else { + return nil + } + unshieldFee = estimate } else { unshieldFee = 0 } @@ -828,7 +843,11 @@ struct IdentityTopUpSheet: View { } private var estimatedFeeText: String { - let credits = IdentityTopUpViewModel.estimatedFeeCredits(source: source) + guard let credits = IdentityTopUpViewModel.estimatedFeeCredits(source: source) else { + // Shielded route with the unshield estimate unavailable: show no + // number rather than one that omits the larger fee component. + return "โ€”" + } let duffs = credits / 1000 return String.localizedStringWithFormat( NSLocalizedString("~%@ DASH (โ‰ˆ %@)", comment: "DashPay: estimated network fee โ€” DASH amount, then its local-currency equivalent"), diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift index ecd8143ddc..21041d07c4 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift @@ -140,9 +140,6 @@ struct DashSpendSinglePanel: View { inProgress: inProgress, actionHandler: onAction ) - .frame(maxWidth: .infinity) - .bottomPanelStyle() - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) } } diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift index 74f7faa5ab..a548fe6c66 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift @@ -157,10 +157,6 @@ struct DashSpendUserAuthScreen: View { viewModel.onContinue() } ) - .frame(maxWidth: .infinity) - .frame(height: UIDevice.isIphone5OrLess ? 290 : 320) - .padding(.horizontal, 20) - .padding(.bottom, 20) } else { ZStack(alignment: .center) { DashButton( diff --git a/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift b/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift index 28791a5324..710b0e3c11 100644 --- a/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift +++ b/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift @@ -28,22 +28,25 @@ enum HomeBalanceViewState: Int { // MARK: - HomeBalanceView /// Home header: the combined total (transparent + platform + shielded) -/// as the hero amount, then one row per balance with its fiat value and -/// circular in/out transfer buttons. Every row's in-arrow opens the -/// receive sheet (Receive โ†” Internal) with that balance pinned as the -/// destination; every out-arrow opens the mirrored send sheet -/// (Send โ†” Internal) with that balance pinned as the source. +/// as the hero amount, and โ€” in Advanced mode only โ€” one row per balance +/// with its fiat value. +/// +/// The rows are a readout, not a control surface. They used to carry an +/// in/out arrow pair opening the pinned receive/send sheets; the transfer +/// routes those reached are all on the payments tab, and a second, denser +/// entry to them on the balance header was two taps the header did not need. struct HomeBalanceView: View { @ObservedObject var viewModel: BalanceModel @ObservedObject private var platformSync = PlatformAddressSyncCoordinator.shared @ObservedObject private var shieldedSync = ShieldedSyncMonitor.shared @State private var opacity: Double = 0.3 var onLongPress: () -> Void - var onReceive: (ChainNetwork) -> Void = { _ in } - var onSend: (ChainNetwork) -> Void = { _ in } - /// Tap on a row's body (icon/title/amount โ€” not the arrows): opens the - /// what-is-this-balance info sheet for that balance. + /// Tap on a row: opens the what-is-this-balance info sheet. var onInfo: (ChainNetwork) -> Void = { _ in } + /// The breakdown is one of the three surfaces Advanced mode unlocks โ€” + /// without it the wallet has one balance, and naming its parts invites + /// questions the simple mode is there to avoid. + var showsBreakdown: Bool = true // Header nav-bar (SB-11) inputs, threaded in by HomeView from the same // app-owned identity snapshot the UIKit nav-bar avatar reads. A nil @@ -137,7 +140,7 @@ struct HomeBalanceView: View { onLongPress() } - if !viewModel.isBalanceHidden && platformSync.isRunning { + if showsBreakdown && !viewModel.isBalanceHidden && platformSync.isRunning { breakdownCard .padding(.horizontal, 16) .padding(.top, 12) @@ -205,26 +208,20 @@ struct HomeBalanceView: View { icon: "wallet.pass", title: NSLocalizedString("Transparent", comment: "Balance breakdown"), duffs: viewModel.value, - infoAction: { onInfo(.core) }, - inAction: { onReceive(.core) }, - outAction: { onSend(.core) }) + infoAction: { onInfo(.core) }) rowDivider balanceRow( icon: "cloud", title: NSLocalizedString("Platform", comment: ""), duffs: platformDuffs, - infoAction: { onInfo(.platform) }, - inAction: { onReceive(.platform) }, - outAction: { onSend(.platform) }) + infoAction: { onInfo(.platform) }) rowDivider balanceRow( icon: "shield", title: NSLocalizedString("Shielded", comment: ""), duffs: shieldedDuffs, isSyncing: shieldedSync.isSyncing || platformSync.isShieldedBalanceReconciling, - infoAction: { onInfo(.shielded) }, - inAction: { onReceive(.shielded) }, - outAction: { onSend(.shielded) }) + infoAction: { onInfo(.shielded) }) } .padding(.horizontal, 12) .background(Color.dash.white.opacity(0.12)) @@ -254,13 +251,9 @@ struct HomeBalanceView: View { title: String, duffs: UInt64, isSyncing: Bool = false, - infoAction: @escaping () -> Void, - inAction: @escaping () -> Void, - outAction: @escaping () -> Void + infoAction: @escaping () -> Void ) -> some View { HStack(spacing: 8) { - // The row body is its own tap target (info sheet); keeping it a - // sibling of the arrow buttons means it can't swallow their taps. HStack(spacing: 8) { Image(systemName: icon) .font(.system(size: 15)) @@ -293,32 +286,8 @@ struct HomeBalanceView: View { } .contentShape(Rectangle()) .onTapGesture { infoAction() } - - transferButton(systemName: "arrow.down", - label: NSLocalizedString("Transfer in", comment: "Balance breakdown"), - action: inAction) - transferButton(systemName: "arrow.up", - label: NSLocalizedString("Transfer out", comment: "Balance breakdown"), - action: outAction) } .padding(.vertical, 9) } - private func transferButton( - systemName: String, - label: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - .frame(width: 28, height: 28) - .background(Circle().fill(Color.dash.white.opacity(0.18))) - // Keep the visual small but the tap target comfortable. - .contentShape(Rectangle().inset(by: -8)) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - } } diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index e8e99a7bc0..97667cac08 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -230,6 +230,10 @@ struct HomeViewContent: View { @State private var navigateToClaimInvitation: Bool = false @State private var giftCardTxId: Data? = nil @State private var pendingShieldedRecovery: Transaction? = nil + /// An internal transfer runs past the screen that started it, and the + /// confirm sheet closes the moment it begins โ€” so its outcome is + /// announced here, where the user lands. + @ObservedObject private var internalTransfers = InternalTransferRunner.shared /// Balance whose explainer sheet is up (tap on a breakdown row's body). @State private var balanceInfoNetwork: ChainNetwork? = nil @@ -286,15 +290,10 @@ struct HomeViewContent: View { onLongPress: { performShortcut(ShortcutAction(type: .localCurrency)) }, - onReceive: { network in - delegate?.homeViewShowReceive(network: network) - }, - onSend: { network in - delegate?.homeViewShowSend(network: network) - }, onInfo: { network in balanceInfoNetwork = network }, + showsBreakdown: viewModel.isAdvancedMode, username: navUsername, avatarURL: navAvatarURL, identitySeed: navIdentitySeed, @@ -487,6 +486,7 @@ struct HomeViewContent: View { } } } + .internalTransferToast(runner: internalTransfers) .sheet(item: $selectedTxDataItem) { item in TransactionDetailsSheet(item: item) } diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index e17d32a887..1dbe35f550 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -59,9 +59,12 @@ class HomeViewModel: ObservableObject { private var coinJoinTxSets: [String: CoinJoinMixingTxSet] = [:] // Grouped by date private var coinJoinWithdrawalSet = CoinJoinWithdrawalTxSet() // Single combined "CoinJoin Withdrawals" group (app's sweep tx) private var metadataProviders: [MetadataProvider] = [] - #if DEBUG + /// Set only by the `#if DEBUG` preview initializer, but declared + /// unconditionally: two `guard`s read it from ordinary code paths, so + /// hiding the property behind `DEBUG` left them referring to something + /// that does not exist in any configuration without it. False everywhere + /// else costs a Bool. var isPreviewMode: Bool = false - #endif /// Tracks whether initial data load has completed (Fix #2) private var hasCompletedInitialLoad: Bool = false @@ -195,7 +198,11 @@ class HomeViewModel: ObservableObject { @Published private(set) var showReclassifyTransaction: Transaction? = nil @Published var shouldShowShortcutBanner: Bool = false @Published var giftCardTxId: Data? = nil - + /// Mirrors `DWGlobalOptions.advancedModeEnabled`. The balance breakdown on + /// the header is one of the surfaces the mode unlocks, and it has to appear + /// and disappear with the switch rather than on the next launch. + @Published private(set) var isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + #if DASHPAY var joinDashPayState: JoinDashPayState = .callToAction #endif @@ -300,6 +307,13 @@ class HomeViewModel: ObservableObject { // wallet). The balance-driven reloads don't cover this โ€” the balance // notifications the switch posts can carry the same total, and their // observers don't clear the stale per-hash cache. + NotificationCenter.default.publisher(for: .advancedModeDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + } + .store(in: &cancellableBag) + NotificationCenter.default.publisher(for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in diff --git a/DashWallet/Sources/UI/Main/MainTabbarController.swift b/DashWallet/Sources/UI/Main/MainTabbarController.swift index eb63cc24df..53fa9c0112 100644 --- a/DashWallet/Sources/UI/Main/MainTabbarController.swift +++ b/DashWallet/Sources/UI/Main/MainTabbarController.swift @@ -81,6 +81,17 @@ class MainTabbarController: UITabBarController { private var paymentIsOpened = false weak var homeController: HomeViewController? + /// Root of the payments tab. Held so shortcut entry points can put it on + /// the right sub-tab before selecting it. Rebuilt by + /// `configureControllers()`, which also refreshes `paymentsTabIndex` โ€” + /// the DashPay layout inserts tabs before this one. + private weak var paymentsTabRootController: PaymentsTabRootController? + private var paymentsTabIndex: Int? + #if DASHPAY + /// Nil until an identity exists โ€” the Contacts tab is only built then, and + /// its position moves with the rest of the DashPay layout. + private var contactsTabIndex: Int? + #endif weak var menuNavigationController: MainMenuViewController? #if DASHPAY @@ -175,6 +186,19 @@ class MainTabbarController: UITabBarController { extension MainTabbarController { private func configureControllers() { var viewControllers: [UIViewController] = [] + // The bar is hidden by children โ€” the payments landing does it while it + // is up โ€” and hiding outlives the child that asked for it. A rebuild + // replaces `viewControllers` outright, which can take that child out of + // the hierarchy without a `viewWillDisappear` to undo it, leaving a + // wallet with no tab bar at all. Whoever wants it hidden after this + // will say so again on their next appearance. + setTabBarHidden(false, animated: false) + #if DASHPAY + // Cleared before the rebuild, not after: this pass may be the one that + // drops the Contacts tab (a wallet switch away from an identity), and a + // surviving index would then point at whatever took its place. + contactsTabIndex = nil + #endif // Home var item = UITabBarItem(title: nil, image: MainTabbarTabs.home.icon, selectedImage: MainTabbarTabs.home.selectedIcon) @@ -201,18 +225,26 @@ extension MainTabbarController { let contactsVC = UIHostingController(rootView: ContactsScreen()) contactsVC.tabBarItem = item + contactsTabIndex = viewControllers.count viewControllers.append(contactsVC) } #endif - // Payment (tapping this tab opens the payment modal instead of switching tabs) + // Payment. A real tab, not a modal: the landing is where the send / + // receive / transfer flows start, and the tab bar stays visible on it. + // The screens it pushes set `hidesBottomBarWhenPushed`, so the bar is + // gone from the first step that asks for an amount. let paymentImage = Self.makePaymentTabImage() item = UITabBarItem(title: nil, image: paymentImage, selectedImage: paymentImage) item.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) item.accessibilityIdentifier = "tabbar_payments_button" - let paymentVC = EmptyController() + let paymentsRoot = PaymentsTabRootController() + paymentsTabRootController = paymentsRoot + let paymentVC = BaseNavigationController(rootViewController: paymentsRoot) + paymentVC.isNavigationBarHidden = true paymentVC.tabBarItem = item + paymentsTabIndex = viewControllers.count viewControllers.append(paymentVC) #if DASHPAY @@ -386,6 +418,25 @@ extension MainTabbarController { // MARK: - Public extension MainTabbarController { + /// Switch to the history. Used by flows that finish somewhere else and + /// want the user to land where their transaction shows up. + @objc + public func showHome() { + selectedIndex = MainTabbarTabs.home.rawValue + } + + #if DASHPAY + /// Switch to the contacts tab. Returns false when there is none โ€” the tab + /// exists only alongside a DashPay identity โ€” so the caller can say so + /// rather than appearing to do nothing. + @discardableResult + func showContacts() -> Bool { + guard let contactsTabIndex else { return false } + selectedIndex = contactsTabIndex + return true + } + #endif + @objc public func performScanQRCodeAction() { dismiss(animated: false, completion: nil) @@ -485,14 +536,24 @@ extension MainTabbarController: HomeViewControllerDelegate { func showPaymentsController(withActivePage pageIndex: PaymentsViewControllerState) { switch pageIndex { case .receive: - presentPaymentsLandingScreen(activeTab: .receive) + selectPaymentsTab(activeTab: .receive) case .enterAddress: presentSendToAddressScreen() case .pay, .none: - presentPaymentsLandingScreen(activeTab: .send) + selectPaymentsTab(activeTab: .send) } } + /// Every shortcut and menu entry into payments lands on the payments tab + /// rather than presenting a copy of it. Unwinds whatever that tab was left + /// showing, so an entry point always starts at the destination picker. + private func selectPaymentsTab(activeTab: PaymentsLandingTab) { + guard let paymentsTabIndex, let root = paymentsTabRootController else { return } + root.navigationController?.popToRootViewController(animated: false) + root.select(tab: activeTab) + selectedIndex = paymentsTabIndex + } + /// The "Send to Address" shortcut skips the payments landing: straight /// to the send screen, with the address field prefilled when the /// clipboard holds a valid Core/Platform address. @@ -571,12 +632,7 @@ extension MainTabbarController: HomeViewControllerDelegate { extension MainTabbarController: UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { - if viewController is EmptyController { - showPaymentsController(withActivePage: .none) - return false - } - - return true + true } } diff --git a/DashWallet/Sources/UI/Menu/Main/MainMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Main/MainMenuViewModel.swift index 50d937483f..9cfb29202c 100644 --- a/DashWallet/Sources/UI/Menu/Main/MainMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Main/MainMenuViewModel.swift @@ -55,6 +55,26 @@ class MainMenuViewModel: ObservableObject { weak var delegate: MainMenuViewModelDelegate? + private var cancellableBag = Set() + + /// Wallets and Identities are advanced surfaces: they expose accounts, + /// derivation and Platform identities, which is more than an ordinary user + /// is asked to reason about. They appear only while Advanced mode is on. + private var showsAdvancedRows: Bool { + DWGlobalOptions.sharedInstance().advancedModeEnabled + } + + /// The menu is built once, so without this a row toggled in Settings would + /// not appear until something else rebuilt it. + private func observeAdvancedMode() { + NotificationCenter.default.publisher(for: .advancedModeDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.buildMenuSections() + } + .store(in: &cancellableBag) + } + #if DASHPAY init(dashPayModel: DWDashPayProtocol? = nil, dashPayReady: DWDashPayReadyProtocol? = nil, @@ -63,6 +83,7 @@ class MainMenuViewModel: ObservableObject { self.dashPayReady = dashPayReady self.userProfileModel = userProfileModel buildMenuSections() + observeAdvancedMode() userProfileModel?.$showJoinDashpay .receive(on: DispatchQueue.main) @@ -79,6 +100,7 @@ class MainMenuViewModel: ObservableObject { #else init() { buildMenuSections() + observeAdvancedMode() } #endif @@ -105,23 +127,25 @@ class MainMenuViewModel: ObservableObject { } )) - // Wallets - allItems.append(MenuItemModel( - title: NSLocalizedString("Wallets", comment: ""), - icon: .custom("image.wallets", maxHeight: 30), - action: { [weak self] in - self?.navigationDestination = .wallets - } - )) + if showsAdvancedRows { + // Wallets + allItems.append(MenuItemModel( + title: NSLocalizedString("Wallets", comment: ""), + icon: .custom("image.wallets", maxHeight: 30), + action: { [weak self] in + self?.navigationDestination = .wallets + } + )) - // Identities โ€” the device's Dash Platform identities (under Wallets) - allItems.append(MenuItemModel( - title: NSLocalizedString("Identities", comment: "Identities"), - icon: .system("person.crop.circle"), - action: { [weak self] in - self?.navigationDestination = .identities - } - )) + // Identities โ€” the device's Dash Platform identities (under Wallets) + allItems.append(MenuItemModel( + title: NSLocalizedString("Identities", comment: "Identities"), + icon: .system("person.crop.circle"), + action: { [weak self] in + self?.navigationDestination = .identities + } + )) + } // Security allItems.append(MenuItemModel( diff --git a/DashWallet/Sources/UI/Menu/MenuItemModel.swift b/DashWallet/Sources/UI/Menu/MenuItemModel.swift index 08c420f4e2..5d424e41e7 100644 --- a/DashWallet/Sources/UI/Menu/MenuItemModel.swift +++ b/DashWallet/Sources/UI/Menu/MenuItemModel.swift @@ -28,8 +28,11 @@ class MenuItemModel: Identifiable, Equatable, Hashable { var showToggle: Bool = false var isToggled: Bool = false var action: (() -> Void)? = nil + /// Handler for the info icon, when it should act as its own button + /// rather than decorate the title. + var infoAction: (() -> Void)? = nil - init(title: String, subtitle: String? = nil, details: String? = nil, icon: IconName? = nil, showInfo: Bool = false, showToggle: Bool = false, isToggled: Bool = false, action: (() -> Void)? = nil) { + init(title: String, subtitle: String? = nil, details: String? = nil, icon: IconName? = nil, showInfo: Bool = false, showToggle: Bool = false, isToggled: Bool = false, action: (() -> Void)? = nil, infoAction: (() -> Void)? = nil) { self.title = title self.subtitle = subtitle self.details = details @@ -38,6 +41,7 @@ class MenuItemModel: Identifiable, Equatable, Hashable { self.showToggle = showToggle self.isToggled = isToggled self.action = action + self.infoAction = infoAction } static func == (lhs: MenuItemModel, rhs: MenuItemModel) -> Bool { diff --git a/DashWallet/Sources/UI/Menu/Settings/Components/AdvancedModeInfoSheet.swift b/DashWallet/Sources/UI/Menu/Settings/Components/AdvancedModeInfoSheet.swift new file mode 100644 index 0000000000..5816724a65 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Settings/Components/AdvancedModeInfoSheet.swift @@ -0,0 +1,135 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +// MARK: - AdvancedModeInfoSheetViewModel + +/// What the sheet says, so the sheet only draws it. +/// +/// The content is fixed rather than fetched, which was the argument for leaving +/// it inline โ€” but "All new UI MUST be built in SwiftUI with a ViewModel" does +/// not carve out static content, and the surfaces named here are the ones the +/// mode gates: when that list changes, it changes in one place that is not a +/// view body. +@MainActor +final class AdvancedModeInfoSheetViewModel: ObservableObject { + + struct Feature: Identifiable { + let id = UUID() + let title: String + let description: String + let icon: DashIconSource + let iconColor: Color? + } + + let title = NSLocalizedString("What is advanced mode?", comment: "Settings") + let subtitle = NSLocalizedString( + "It unlocks powerful features for managing your Dash across the network", + comment: "Settings") + + let features: [Feature] = [ + Feature( + title: NSLocalizedString("Shield", comment: "Settings: advanced mode"), + description: NSLocalizedString( + "Move Dash into a private, encrypted balance that can't be traced.", + comment: "Settings: advanced mode"), + icon: .custom("feature-shield-purple", bundle: .dashUIKit), + iconColor: nil), + Feature( + title: NSLocalizedString("Identity", comment: "Settings: advanced mode"), + description: NSLocalizedString( + "Fund your identity to create a username and use Dash Platform", + comment: "Settings: advanced mode"), + icon: .custom("feature-identity", bundle: .dashUIKit), + iconColor: Color.dash.purple), + Feature( + title: NSLocalizedString("Platform", comment: "Settings: advanced mode"), + description: NSLocalizedString( + "Keep credits to pay for on-chain actions and Platform apps", + comment: "Settings: advanced mode"), + icon: .custom("feature-platform-purple", bundle: .dashUIKit), + iconColor: nil), + ] +} + +/// What Advanced mode does, shown when the settings row's info glyph is tapped. +/// +/// Self-sizing on purpose: the copy is expected to grow as the mode gains the +/// surfaces it gates, and `BottomSheet.selfSizing` pairs `fillsHeight: false` +/// with the sizing modifier so the sheet keeps snapping to it without anything +/// here being re-tuned. +struct AdvancedModeInfoSheet: View { + + @StateObject private var viewModel = AdvancedModeInfoSheetViewModel() + + var body: some View { + DashUIKit.BottomSheet.selfSizing( + showBackButton: .constant(false) + ) { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + Text(viewModel.title) + .dashFont(.title1) + .foregroundStyle(Color.dash.primaryText) + + Text(viewModel.subtitle) + .dashFont(.body) + .foregroundStyle(Color.dash.secondaryText) + } + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 16) { + ForEach(viewModel.features) { feature in + if let iconColor = feature.iconColor { + SheetFeature( + title: feature.title, + description: feature.description, + icon: feature.icon, + iconColor: iconColor + ) + } else { + SheetFeature( + title: feature.title, + description: feature.description, + icon: feature.icon + ) + } + } + } + .padding(.top, 10) + } + .padding(.horizontal, 40) + .padding(.vertical, 20) + } + // Named so a UI test can assert it is NOT presented by a tap that lands + // on the row's switch. + .accessibilityIdentifier("advanced_mode_info_sheet") + } +} + +#if DEBUG + +#Preview { + Color.dash.primaryBackground + .sheet(isPresented: .constant(true)) { + AdvancedModeInfoSheet() + } +} + +#endif diff --git a/DashWallet/Sources/UI/Menu/Settings/Components/SettingsAlerts.swift b/DashWallet/Sources/UI/Menu/Settings/Components/SettingsAlerts.swift new file mode 100644 index 0000000000..01608601da --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Settings/Components/SettingsAlerts.swift @@ -0,0 +1,114 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +// MARK: - Network choice + +/// Picks the chain to run against. +/// +/// The work is the caller's: switching networks tears down and rebuilds the +/// wallet runtime, and only the screen knows what it has to refresh afterwards. +private struct NetworkChoiceAlert: ViewModifier { + @Binding var isPresented: Bool + let onMainnet: () -> Void + let onTestnet: () -> Void + + func body(content: Content) -> some View { + content.alert( + NSLocalizedString("Network", comment: ""), + isPresented: $isPresented + ) { + Button(NSLocalizedString("Mainnet", comment: ""), action: onMainnet) + Button(NSLocalizedString("Testnet", comment: ""), action: onTestnet) + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + } + } +} + +// MARK: - CoinJoin sweep + +/// The confirmation for moving leftover mixed coins, and the failure that can +/// follow it. +/// +/// One modifier for both because they are one exchange: the second alert only +/// ever appears as the answer to the first, and `errorMessage` doubles as the +/// presentation flag so a failure cannot be shown without something to say. +private struct CoinJoinSweepAlerts: ViewModifier { + @Binding var isConfirming: Bool + /// Pre-formatted leftover balance โ€” this file does no amount formatting. + let amount: String + let onConfirm: () -> Void + @Binding var errorMessage: String? + + func body(content: Content) -> some View { + content + .alert( + NSLocalizedString("Move CoinJoin Funds", comment: "CoinJoin"), + isPresented: $isConfirming + ) { + Button(NSLocalizedString("Move funds", comment: "CoinJoin"), action: onConfirm) + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + } message: { + Text(String( + format: NSLocalizedString( + "Move your %@ in mixed coins to your spendable balance? CoinJoin is no longer supported.", + comment: "CoinJoin"), + amount)) + } + .alert( + NSLocalizedString("Move CoinJoin Funds", comment: "CoinJoin"), + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ), + presenting: errorMessage + ) { _ in + Button(NSLocalizedString("OK", comment: "")) { errorMessage = nil } + } message: { message in + Text(message) + } + } +} + +// MARK: - Call sites + +extension View { + func networkChoiceAlert( + isPresented: Binding, + onMainnet: @escaping () -> Void, + onTestnet: @escaping () -> Void + ) -> some View { + modifier(NetworkChoiceAlert( + isPresented: isPresented, + onMainnet: onMainnet, + onTestnet: onTestnet)) + } + + func coinJoinSweepAlerts( + isConfirming: Binding, + amount: String, + onConfirm: @escaping () -> Void, + errorMessage: Binding + ) -> some View { + modifier(CoinJoinSweepAlerts( + isConfirming: isConfirming, + amount: amount, + onConfirm: onConfirm, + errorMessage: errorMessage)) + } +} diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift index 4c50d1bc7e..12ad479e36 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsMenuViewModel.swift @@ -32,6 +32,8 @@ class SettingsMenuViewModel: ObservableObject { @Published var items: [MenuItemModel] = [] @Published var navigationDestination: SettingsMenuNavigationDestination? @Published var notificationsEnabled: Bool + @Published var advancedModeEnabled: Bool + @Published var showAdvancedModeInfo = false @Published var showCSVExportActivity = false @Published var csvExportData: (fileName: String, file: URL)? @Published var showCoinJoinSweepConfirmation = false @@ -71,6 +73,7 @@ class SettingsMenuViewModel: ObservableObject { init() { self.notificationsEnabled = DWGlobalOptions.sharedInstance().localNotificationsEnabled + self.advancedModeEnabled = DWGlobalOptions.sharedInstance().advancedModeEnabled refreshMenuItems() setupCoinJoinObservers() setupCurrencyChangeObserver() @@ -169,6 +172,41 @@ class SettingsMenuViewModel: ObservableObject { ) ]) #endif + + // Last: it changes what other screens show rather than doing anything + // here, so it reads as a postscript to the settings above rather than + // one of them. + items.append( + MenuItemModel( + title: NSLocalizedString("Advanced mode", comment: "Settings"), + icon: .custom("image.about", maxHeight: 30), + showInfo: true, + showToggle: true, + isToggled: advancedModeEnabled, + action: { [weak self] in + guard let self = self else { return } + self.setAdvancedMode(!self.advancedModeEnabled) + }, + infoAction: { [weak self] in + self?.showAdvancedModeInfo = true + } + ) + ) + } + + // MARK: - Advanced mode + + /// Write the flag, then announce it. The announcement is the point: the + /// setting reaches far beyond this screen, and a consumer that only read + /// the value when it appeared would keep showing the old state until it + /// was rebuilt for some unrelated reason. + func setAdvancedMode(_ enabled: Bool) { + guard enabled != advancedModeEnabled else { return } + advancedModeEnabled = enabled + DWGlobalOptions.sharedInstance().advancedModeEnabled = enabled + DWLogger.log("Settings: advanced mode \(enabled ? "enabled" : "disabled")") + NotificationCenter.default.post(name: .advancedModeDidChange, object: nil) + refreshMenuItems() } // MARK: - CoinJoin Sweep diff --git a/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift b/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift index f167128d9f..64dea4087f 100644 --- a/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift +++ b/DashWallet/Sources/UI/Menu/Settings/SettingsScreen.swift @@ -52,18 +52,8 @@ struct SettingsScreen: View { // Menu list VStack(spacing: 2) { ForEach(viewModel.items) { item in - MenuItem( - title: item.title, - subtitle: item.subtitle, - details: item.details, - icon: item.icon, - showInfo: item.showInfo, - showChevron: false, - showToggle: item.showToggle, - isToggled: item.isToggled, - action: item.action - ) - .frame(minHeight: 60) + row(for: item) + .frame(minHeight: 60) } } .padding(6) @@ -82,45 +72,30 @@ struct SettingsScreen: View { .onReceive(viewModel.$showCSVExportActivity) { show in showCSVExportActivity = show } - .alert(NSLocalizedString("Network", comment: ""), isPresented: $showNetworkAlert) { - Button(NSLocalizedString("Mainnet", comment: "")) { + .networkChoiceAlert( + isPresented: $showNetworkAlert, + onMainnet: { Task { - let success = await viewModel.switchToMainnet() - if success { + if await viewModel.switchToMainnet() { updateView() } } - } - Button(NSLocalizedString("Testnet", comment: "")) { + }, + onTestnet: { Task { - let success = await viewModel.switchToTestnet() - if success { + if await viewModel.switchToTestnet() { updateView() } } - } - Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } - } - .alert(NSLocalizedString("Move CoinJoin Funds", comment: "CoinJoin"), isPresented: $viewModel.showCoinJoinSweepConfirmation) { - Button(NSLocalizedString("Move funds", comment: "CoinJoin")) { - Task { await viewModel.performCoinJoinSweep() } - } - Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } - } message: { - Text(String(format: NSLocalizedString("Move your %@ in mixed coins to your spendable balance? CoinJoin is no longer supported.", comment: "CoinJoin"), viewModel.coinJoinLeftoverFormatted)) - } - .alert( - NSLocalizedString("Move CoinJoin Funds", comment: "CoinJoin"), - isPresented: Binding( - get: { viewModel.coinJoinSweepErrorMessage != nil }, - set: { if !$0 { viewModel.coinJoinSweepErrorMessage = nil } } - ), - presenting: viewModel.coinJoinSweepErrorMessage - ) { _ in - Button(NSLocalizedString("OK", comment: "")) { viewModel.coinJoinSweepErrorMessage = nil } - } message: { message in - Text(message) + }) + .sheet(isPresented: $viewModel.showAdvancedModeInfo) { + AdvancedModeInfoSheet() } + .coinJoinSweepAlerts( + isConfirming: $viewModel.showCoinJoinSweepConfirmation, + amount: viewModel.coinJoinLeftoverFormatted, + onConfirm: { Task { await viewModel.performCoinJoinSweep() } }, + errorMessage: $viewModel.coinJoinSweepErrorMessage) .sheet(isPresented: $showCSVExportActivity) { if let csvData = viewModel.csvExportData { ActivityView(activityItems: [csvData.file]) @@ -128,6 +103,72 @@ struct SettingsScreen: View { } } + // MARK: - Rows + + /// One settings row, rendered by the design system's `MenuItem`. + /// + /// `DashUIKit.MenuItem` draws the row and nothing else โ€” it carries no tap + /// handler of its own โ€” so what a tap means is decided here, per row: + /// + /// - a switch owns its own gesture, so a plain toggle row is not wrapped; + /// - a row that also has something to explain puts the explanation on the + /// row body, leaving the switch to toggle and the rest to inform (the + /// info glyph is drawn by `MenuItem` but is not itself a button); + /// - everything else is a button that runs the row's action. + @ViewBuilder + private func row(for item: MenuItemModel) -> some View { + let content = DashUIKit.MenuItem( + leadingIcon: item.icon.map(Self.iconSource), + title: item.title, + helpText: item.subtitle, + info: item.showInfo ? .round(color: Color.dash.gray300Alpha70) : nil, + accessory: item.showToggle + ? .toggle(isOn: Self.toggleBinding(item)) + : (item.details.map { .text($0) } ?? .none) + ) + + if item.showToggle { + if let infoAction = item.infoAction { + // The switch owns its own tap, so the rest of the row is free + // to answer the question the info glyph poses. Named so a UI + // test can tap the switch alone and assert the sheet stays shut. + Button(action: infoAction) { content } + .buttonStyle(.plain) + .accessibilityIdentifier("settings_row_\(item.title.lowercased().replacingOccurrences(of: " ", with: "_"))") + } else { + content + } + } else if let action = item.action { + Button(action: action) { content } + .buttonStyle(.plain) + } else { + content + } + } + + /// Writing through the switch runs the row's action, which is what the + /// view model already uses to flip the underlying setting; the getter stays + /// the model's value so a refresh is what moves the switch. + private static func toggleBinding(_ item: MenuItemModel) -> Binding { + Binding( + get: { item.isToggled }, + set: { _ in item.action?() } + ) + } + + /// `IconName` predates `DashIconSource` and says the same things. The one + /// field with no counterpart is `maxHeight`: `MenuItem` sizes its own icon. + private static func iconSource(_ icon: IconName) -> DashIconSource { + switch icon { + case .system(let name): + .system(name) + case .custom(let name, let bundle, _): + .custom(name, bundle: bundle) + case .image(let uiImage, _): + .uiImage(uiImage) + } + } + private func handleNavigation(_ destination: SettingsMenuNavigationDestination?) { switch destination { case .currencySelector: @@ -186,15 +227,6 @@ struct SettingsScreen: View { } -struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - return UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} private final class LocalCurrencyHostingViewController: BaseViewController { private let rootView: LocalCurrencyView diff --git a/DashWallet/Sources/UI/Payment Controller/PaymentController.swift b/DashWallet/Sources/UI/Payment Controller/PaymentController.swift index 145e231f55..c4670d54ed 100644 --- a/DashWallet/Sources/UI/Payment Controller/PaymentController.swift +++ b/DashWallet/Sources/UI/Payment Controller/PaymentController.swift @@ -175,12 +175,18 @@ extension PaymentController: DWPaymentProcessorDelegate { presentationAnchor?.topController().view.dw_hideProgressHUD() let finishBlock = { + // The pop is for the LEGACY amount screen, which the redesigned + // send flow never pushes: it collects the amount on its own step + // and hands the processor an address and a value together. Telling + // the delegate the send finished is not conditional on that screen + // โ€” it used to sit inside this check, so a flow without one sent + // successfully and then showed nothing at all. if let vc = self.presentationAnchor?.navigationController?.topViewController as? AmountProviding { vc.navigationController?.popViewController(animated: true) + } - DispatchQueue.main.async { - self.delegate?.paymentControllerDidFinishTransaction(self, txidWire: txidWire) - } + DispatchQueue.main.async { + self.delegate?.paymentControllerDidFinishTransaction(self, txidWire: txidWire) } } diff --git a/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift index becaf85dcd..6aa405d4cc 100644 --- a/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift @@ -244,8 +244,6 @@ private struct SendAmountKeyboardSection: View { inProgress: isLoading, actionHandler: onSend ) - .padding(.top, 10) - .padding(.bottom, 15) } } } diff --git a/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountView.swift b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountView.swift new file mode 100644 index 0000000000..325ada7c01 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountView.swift @@ -0,0 +1,156 @@ +// +// Created by tkhp +// Copyright ยฉ 2023 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// The amount step of the Receive flow: a title, the dual-currency amount +/// field and the keypad that drives it. +/// +/// Driven entirely by `BaseAmountModel` and three closures โ€” the controller +/// owns navigation, the currency list and what "Receive" means. +struct SpecifyAmountView: View { + @ObservedObject var model: BaseAmountModel + let onBack: () -> Void + let onReceive: () -> Void + let onCurrencyTap: () -> Void + + private func displayAmountString(from formatted: String, locale: Locale) -> String { + let decimalSeparator = locale.decimalSeparator ?? "." + let groupingSeparator = locale.groupingSeparator ?? "," + let allowed = CharacterSet.decimalDigits + .union(CharacterSet(charactersIn: decimalSeparator + groupingSeparator)) + let trimmed = formatted.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = String(trimmed.unicodeScalars.filter { allowed.contains($0) }) + return filtered.isEmpty ? trimmed : filtered + } + + private var primaryAmountNumeric: String { + displayAmountString(from: model.mainAmountString, locale: model.keyboardLocale) + } + + private var secondaryAmountNumeric: String { + displayAmountString(from: model.supplementaryAmountString, locale: model.keyboardLocale) + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(leading: { + NavigationBarElement.back.button { onBack() } + }) + + VStack(alignment: .leading, spacing: 20) { + Text(NSLocalizedString("Specify Amount", comment: "Specify Amount")) + .dashFont(.title1) + .foregroundStyle(Color.dash.primaryText) + + + DashUIKit.EnterAmountView( + primaryAmount: primaryAmountNumeric, + secondaryAmount: secondaryAmountNumeric, + primaryCurrency: .dash, + secondaryCurrency: .fiat(model.localCurrencyCode), + isPrimarySelected: model.currentInputItem.isMain, + isCurrencySelectorHidden: model.isCurrencySelectorHidden, + currencyCodes: model.inputItems.map { $0.currencyCode }, + selectedCurrencyCode: model.currentInputItem.currencyCode, + onMax: nil, + onSwap: { model.amountInputControlDidSwapInputs() }, + onCurrencyTap: onCurrencyTap, + onPaste: model.pasteFromClipboard, + onSelectInputType: { code in + if let index = model.inputItems.firstIndex(where: { $0.currencyCode == code }) { + model.selectInputItem(at: index) + } + } + ) + // A fixed height, not a minimum: `EnterAmountView` ends in + // `frame(maxHeight: .infinity)`, so given only a floor it + // eats the `Spacer` below and centres the amount in the + // gap between the title and the keypad. 110 is the height + // its own preview uses. + .frame(height: 110) + + + Spacer(minLength: 0) + } + .padding(.top, 10) + .padding(.horizontal, 20) + + DashUIKit.NumericKeyboardView( + value: Binding( + get: { model.currentInputString }, + set: { model.updateKeyboardInputString($0) } + ), + showDecimalSeparator: true, + locale: model.keyboardLocale, + actionButtonText: NSLocalizedString("Receive", comment: "Specify Amount"), + actionEnabled: model.isAllowedToContinue, + inProgress: false, + actionHandler: onReceive + ) + + } + .background(Color.dash.primaryBackground) + } +} + +#if DEBUG + +/// `BaseAmountModel()` is what the controller itself builds. Its initializer +/// subscribes to published wallet balance and reads exchange rates, both of +/// which resolve to nothing without a wallet โ€” it does not reach for the FFI, +/// so it stands up in a canvas as-is. +@MainActor +private func specifyAmountSample(typed: String? = nil) -> some View { + let model = BaseAmountModel() + if let typed { + model.updateKeyboardInputString(typed) + } + return SpecifyAmountView( + model: model, + onBack: {}, + onReceive: {}, + onCurrencyTap: {}) +} + +@available(iOS 17, *) +#Preview("Empty") { + specifyAmountSample() +} + +/// With an amount the Receive button enables โ€” and this is the layout to watch: +/// the amount belongs directly under the title, not floating in the middle. +@available(iOS 17, *) +#Preview("Amount entered") { + specifyAmountSample(typed: "1.25") +} + +@available(iOS 17, *) +#Preview("Dark") { + specifyAmountSample(typed: "1.25") + .preferredColorScheme(.dark) +} + +/// The title wraps and the keypad still has to fit above the safe area. +@available(iOS 17, *) +#Preview("Large type") { + specifyAmountSample() + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift index 4d34109e55..f16cbdec47 100644 --- a/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift +++ b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift @@ -48,7 +48,7 @@ final class SpecifyAmountViewController: ActionButtonViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = UIColor(named: "SecondaryBackground", in: .dashUIKit, compatibleWith: .current) + view.backgroundColor = UIColor(named: "PrimaryBackground", in: .dashUIKit, compatibleWith: .current) configureHierarchy() } @@ -79,7 +79,7 @@ final class SpecifyAmountViewController: ActionButtonViewController { ) let hostingController = UIHostingController(rootView: rootView) - hostingController.view.backgroundColor = UIColor(named: "SecondaryBackground", in: .dashUIKit, compatibleWith: .current) + hostingController.view.backgroundColor = UIColor(named: "PrimaryBackground", in: .dashUIKit, compatibleWith: .current) hostingController.view.translatesAutoresizingMaskIntoConstraints = false addChild(hostingController) @@ -118,88 +118,3 @@ extension SpecifyAmountViewController: DWLocalCurrencyViewControllerDelegate { controller.dismiss(animated: true) } } - -private struct SpecifyAmountView: View { - @ObservedObject var model: BaseAmountModel - let onBack: () -> Void - let onReceive: () -> Void - let onCurrencyTap: () -> Void - - private func displayAmountString(from formatted: String, locale: Locale) -> String { - let decimalSeparator = locale.decimalSeparator ?? "." - let groupingSeparator = locale.groupingSeparator ?? "," - let allowed = CharacterSet.decimalDigits - .union(CharacterSet(charactersIn: decimalSeparator + groupingSeparator)) - let trimmed = formatted.trimmingCharacters(in: .whitespacesAndNewlines) - let filtered = String(trimmed.unicodeScalars.filter { allowed.contains($0) }) - return filtered.isEmpty ? trimmed : filtered - } - - private var primaryAmountNumeric: String { - displayAmountString(from: model.mainAmountString, locale: model.keyboardLocale) - } - - private var secondaryAmountNumeric: String { - displayAmountString(from: model.supplementaryAmountString, locale: model.keyboardLocale) - } - - var body: some View { - ZStack(alignment: .top) { - Color.dash.primaryBackground - .ignoresSafeArea(edges: .top) - - VStack(spacing: 0) { - NavigationBar(leading: { - NavigationBarElement.back.button { onBack() } - }) - .background(Color.dash.secondaryBackground) - - VStack(alignment: .leading, spacing: 26) { - Text(NSLocalizedString("Specify Amount", comment: "Specify Amount")) - .font(.largeTitle.weight(.bold)) - .foregroundColor(Color.dash.primaryText) - .padding(.top, 10) - - DashUIKit.EnterAmountView( - primaryAmount: primaryAmountNumeric, - secondaryAmount: secondaryAmountNumeric, - primaryCurrency: .dash, - secondaryCurrency: .fiat(model.localCurrencyCode), - isPrimarySelected: model.currentInputItem.isMain, - isCurrencySelectorHidden: model.isCurrencySelectorHidden, - currencyCodes: model.inputItems.map { $0.currencyCode }, - selectedCurrencyCode: model.currentInputItem.currencyCode, - onMax: nil, - onSwap: { model.amountInputControlDidSwapInputs() }, - onCurrencyTap: onCurrencyTap, - onPaste: model.pasteFromClipboard, - onSelectInputType: { code in - if let index = model.inputItems.firstIndex(where: { $0.currencyCode == code }) { - model.selectInputItem(at: index) - } - } - ) - .frame(minHeight: 90) - - Spacer(minLength: 0) - - DashUIKit.NumericKeyboardView( - value: Binding( - get: { model.currentInputString }, - set: { model.updateKeyboardInputString($0) } - ), - showDecimalSeparator: true, - locale: model.keyboardLocale, - actionButtonText: NSLocalizedString("Receive", comment: "Specify Amount"), - actionEnabled: model.isAllowedToContinue, - inProgress: false, - actionHandler: onReceive - ) - } - .padding(.horizontal, 20) - .padding(.bottom, 20) - .background(Color.dash.secondaryBackground) - } - } - } -} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferAmountValidationNote.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferAmountValidationNote.swift new file mode 100644 index 0000000000..8d7dc9b395 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferAmountValidationNote.swift @@ -0,0 +1,82 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// Warning line under the amount field โ€” an orange triangle and the +/// message. Shared with the Send screen, which validates the same way. +struct TransferAmountValidationNote: View { + let message: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13)) + .foregroundColor(.orange) + Text(message) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#if DEBUG + +/// The real messages, since wrapping is the whole risk here: the triangle is +/// baseline-aligned to the first line and must not drift on a two-line note. +private let shortValidationMessage = "You don't have that much Transparent balance." +private let longValidationMessage = """ + You don't have that much Shielded balance. Transfers from the shielded pool \ + also reserve a fee, so the most you can send right now is 0.78412 DASH. + """ + +@available(iOS 17, *) +#Preview("Short") { + TransferAmountValidationNote(message: shortValidationMessage) + .padding(20) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Wrapping") { + TransferAmountValidationNote(message: longValidationMessage) + .padding(20) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Dark") { + TransferAmountValidationNote(message: longValidationMessage) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.dash.primaryBackground) + .preferredColorScheme(.dark) +} + +@available(iOS 17, *) +#Preview("Large type") { + TransferAmountValidationNote(message: longValidationMessage) + .padding(20) + .background(Color.dash.primaryBackground) + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointCards.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointCards.swift new file mode 100644 index 0000000000..c4b423278a --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointCards.swift @@ -0,0 +1,308 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The From / To endpoint cards, in whichever of the three shapes the host +/// asked for. +/// +/// The screen used to build all three inline, which is why choosing a layout +/// and drawing a balance row lived in the same 200 lines. Which shape applies +/// still follows from `sendFrom` / `receiveInto` alone, so the decision travels +/// with the drawing rather than with the screen. +/// +/// Every shape is the design system's `ConverterCard` over one picker sheet. +/// The pinned variants used to be a hand-rolled row with its own radio list +/// instead โ€” a second visual language and a second way to choose the same +/// endpoint, left behind when the standalone form moved to the card. +struct TransferEndpointCards: View { + @ObservedObject var viewModel: InternalTransferViewModel + + /// Send-sheet variant: fixes the source card at the top and turns the rows + /// below it into the destination picker. Takes precedence over `receiveInto`. + var sendFrom: ChainNetwork? + + /// Receive-sheet variant: fixes the destination card at the bottom and + /// turns the rows above it into the source picker. + var receiveInto: ChainNetwork? + + /// Which endpoint the standalone card is currently picking, if any. + /// Presentation state only โ€” the options and the selection belong to + /// `TransferEndpointPicker` and the view model. + @State private var picker: TransferEndpointSide? + + var body: some View { + layout + // One sheet for all three layouts: what changes between them is + // which endpoints it offers, not that it exists. No detent and no + // drag indicator here โ€” the picker is a `BottomSheet.selfSizing`, + // which sets its own height detent and draws its own grabber. + .sheet(item: $picker) { side in + TransferEndpointPicker( + viewModel: viewModel, + side: side, + options: pickerOptions, + onPicked: { picker = nil }) + } + } + + // MARK: - Layouts + + /// All three shapes are the design system's `ConverterCard` โ€” they differ + /// only in which row carries a tap and whether the seam badge swaps. + @ViewBuilder + private var layout: some View { + if let source = sendFrom { + sendCards(source: source) + } else if let target = receiveInto { + receiveCards(target: target) + } else { + swappableCards + } + } + + /// Send-sheet layout: the source is the fixed top row โ€” the balance row + /// that opened this sheet chose it โ€” and the bottom row picks the + /// destination among the other two balances. `onSwap: nil` leaves the + /// static arrow on the seam: with the source pinned there is nothing to + /// exchange, only a direction to state. + private func sendCards(source: ChainNetwork) -> some View { + DashUIKit.ConverterCard( + fromItem: converterItem(source), + toItem: converterItem( + viewModel.resolvedSendTarget, + onTap: { picker = .destination }), + onSwap: nil) + } + + /// Receive-sheet layout: the mirror โ€” the destination is the fixed bottom + /// row and the top one picks the source. + private func receiveCards(target: ChainNetwork) -> some View { + DashUIKit.ConverterCard( + fromItem: converterItem( + viewModel.resolvedReceiveSource, + onTap: { picker = .source }), + toItem: converterItem(target), + onSwap: nil) + } + + /// Standalone screen: both rows are tappable โ€” each opens the picker for + /// its own side, over every endpoint that side accepts. The badge goes + /// static (`onSwap: nil`) only for the one pair whose reverse does not + /// exist; the view model owns that judgement. + private var swappableCards: some View { + DashUIKit.ConverterCard( + fromItem: fromItem, + toItem: toItem, + onSwap: viewModel.canSwapEndpoints + ? { viewModel.swapStandaloneEndpoints() } + : nil) + } + + /// What the picker offers, which is the one thing the three layouts + /// disagree on. A pinned sheet reaches no identity and never lists the + /// endpoint it has fixed, so it picks a plain balance through its own + /// side's setter. + private var pickerOptions: TransferEndpointPicker.Options { + if let source = sendFrom { + return .balances( + availableTargets(for: source), + selected: viewModel.resolvedSendTarget, + select: viewModel.selectSendTarget) + } + if let target = receiveInto { + return .balances( + availableSources(for: target), + selected: viewModel.resolvedReceiveSource, + select: viewModel.selectReceiveSource) + } + return .standalone + } + + private var fromItem: DashUIKit.ConverterCardItem { + viewModel.isIdentitySource + ? identityConverterItem(onTap: { picker = .source }) + : converterItem(viewModel.source, onTap: { picker = .source }) + } + + private var toItem: DashUIKit.ConverterCardItem { + if viewModel.isIdentityDestination { + return identityConverterItem(onTap: { picker = .destination }) + } + // With the identity on the FROM side the TO side is the withdrawal's + // payout balance, which is a narrower set than `resolvedSendTarget`. + let network = viewModel.isIdentitySource + ? viewModel.resolvedWithdrawalTarget.network + : viewModel.resolvedSendTarget + return converterItem(network, onTap: { picker = .destination }) + } + + /// The identity's own credit balance, rendered in DASH like the balance + /// rows โ€” the same persisted number the profile sheet shows. + private func identityConverterItem(onTap: @escaping () -> Void) -> DashUIKit.ConverterCardItem { + let display = TransferEndpointDisplay.identity(in: viewModel) + return DashUIKit.ConverterCardItem( + id: "identity", + icon: display.icon, + title: display.title, + dashBalance: display.dashBalance, + onTap: onTap) + } + + /// No From / To caption, unlike the picker rows: on a swap the two rows + /// physically exchange places, so a caption riding along with one would + /// read as the wrong side mid-flight. The badge on the seam is what says + /// which way the transfer runs โ€” the same bargain the Coinbase transfer + /// card makes. `onTap` turns the whole row into the button that opens + /// that side's picker. + private func converterItem( + _ network: ChainNetwork, + onTap: (() -> Void)? = nil + ) -> DashUIKit.ConverterCardItem { + let display = TransferEndpointDisplay.network(network, in: viewModel) + return DashUIKit.ConverterCardItem( + id: network, + icon: display.icon, + title: display.title, + dashBalance: display.dashBalance, + onTap: onTap) + } + + // MARK: - Helpers + + /// Pinned-sheet picker lists (the balance-row arrow sheets): the fixed + /// endpoint's balance is left out entirely โ€” only the standalone screen + /// shows all three with same-balance taps rejected. + private func availableTargets(for source: ChainNetwork) -> [ChainNetwork] { + viewModel.availableNetworks.filter { $0 != source } + } + + private func availableSources(for target: ChainNetwork) -> [ChainNetwork] { + viewModel.availableNetworks.filter { $0 != target } + } +} + +#if DEBUG + +@MainActor +private func endpointCardsSample( + sendFrom: ChainNetwork? = nil, + receiveInto: ChainNetwork? = nil, + source: ChainNetwork = .core, + target: ChainNetwork = .platform +) -> some View { + TransferEndpointCards( + viewModel: .makeForPreview( + source: source, + target: target, + sendFrom: sendFrom, + receiveInto: receiveInto), + sendFrom: sendFrom, + receiveInto: receiveInto) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// The standalone screen: each row opens its own picker (tapping one in the +/// canvas presents the real sheet) and the seam badge swaps the two. +@available(iOS 17, *) +#Preview("Standalone ยท collapsed") { + endpointCardsSample() +} + +/// Identity destination: the To row is the identity (its own credit balance +/// in DASH) and the seam badge is the static arrow โ€” nothing to swap into. +/// Both rows still open their pickers; the To one is how you get back to a +/// balance destination. +@available(iOS 17, *) +#Preview("Standalone ยท to Identity") { + TransferEndpointCards( + viewModel: .makeForPreview(identityDestination: true), + sendFrom: nil, + receiveInto: nil) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// Identity source: credits leaving the identity for Transparent. The badge +/// swaps here โ€” the reverse (a top-up from Transparent) exists. +@available(iOS 17, *) +#Preview("Standalone ยท from Identity") { + TransferEndpointCards( + viewModel: .makeForPreview(target: .core, identitySource: true), + sendFrom: nil, + receiveInto: nil) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// The one static-badge pair: Shielded โ†’ Identity cannot be reversed, so the +/// seam shows the plain arrow rather than a swap that would fail. +@available(iOS 17, *) +#Preview("Standalone ยท Shielded โ†’ Identity") { + TransferEndpointCards( + viewModel: .makeForPreview(source: .shielded, identityDestination: true), + sendFrom: nil, + receiveInto: nil) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// Send sheet: the From card is pinned, the To rows are the picker. +@available(iOS 17, *) +#Preview("Send ยท from Transparent") { + endpointCardsSample(sendFrom: .core, source: .core, target: .shielded) +} + +@available(iOS 17, *) +#Preview("Send ยท from Shielded") { + endpointCardsSample(sendFrom: .shielded, source: .shielded, target: .core) +} + +/// Receive sheet: the To card is pinned at the bottom, the From rows above it. +@available(iOS 17, *) +#Preview("Receive ยท into Shielded") { + endpointCardsSample(receiveInto: .shielded, source: .core, target: .shielded) +} + +@available(iOS 17, *) +#Preview("Dark") { + endpointCardsSample(sendFrom: .core, source: .core, target: .shielded) + .preferredColorScheme(.dark) +} + +/// A zero balance still has to render an amount, not an empty trailing slot. +@available(iOS 17, *) +#Preview("Empty balances") { + TransferEndpointCards( + viewModel: .makeForPreview( + coreDuffs: 0, + platformCredits: 0, + shieldedCredits: 0), + sendFrom: nil, + receiveInto: nil) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointPicker.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointPicker.swift new file mode 100644 index 0000000000..f92074f9ab --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferEndpointPicker.swift @@ -0,0 +1,279 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// Which endpoint a picker is choosing. Also the sheet item, so the two +/// pickers share one `.sheet(item:)` โ€” stacked `.sheet(isPresented:)` +/// modifiers on a single view only honour the outermost one. +enum TransferEndpointSide: Int, Identifiable { + case source + case destination + + var id: Int { rawValue } + + var title: String { + switch self { + case .source: + return NSLocalizedString("Transfer from", comment: "Internal transfer source picker title") + case .destination: + return NSLocalizedString("Transfer to", comment: "Internal transfer destination picker title") + } + } +} + +/// The bottom-sheet endpoint picker behind either row of the standalone +/// transfer card. +/// +/// It lives apart from `TransferEndpointCards` because it is a screen of its +/// own โ€” chrome, a list and a selection โ€” rather than part of the card that +/// happens to present it. What the card keeps is the presentation state; what +/// the sheet keeps is which endpoints each side accepts. +/// +/// Which options appear is not symmetric, and that asymmetry is the reason +/// this type exists rather than one generic list: the identity is a valid +/// endpoint on both sides, but with the identity already on the FROM side the +/// TO side narrows to what a single state transition reaches. +struct TransferEndpointPicker: View { + + /// Which endpoints the sheet offers, and how a pick is applied. + /// + /// The two cases are not a style choice: the standalone screen picks a + /// `TransferSource` / `TransferDestination` (identity included) through the + /// standalone setters, while a pinned sheet picks a plain balance through + /// the setter for its own side. One list, two vocabularies. + enum Options { + /// The standalone screen: every endpoint the side accepts. + case standalone + /// A pinned sheet (`sendFrom` / `receiveInto`): the balances that + /// remain once the fixed endpoint is out. No identity โ€” neither pinned + /// variant can reach one. + case balances([ChainNetwork], selected: ChainNetwork, select: (ChainNetwork) -> Void) + } + + @ObservedObject var viewModel: InternalTransferViewModel + + let side: TransferEndpointSide + + var options: Options = .standalone + + /// Called after a pick has been applied, for the host to dismiss. Every + /// pick applies immediately โ€” there is no confirm step in this sheet. + var onPicked: () -> Void + + var body: some View { + // The design system draws the grabber, the titled header and the + // close button. `selfSizing` measures the rows instead of taking a + // detent, so the two-row list is not padded out to the four-row + // height the other side needs. + DashUIKit.BottomSheet.selfSizing( + title: side.title, + showBackButton: .constant(false) + ) { + rows + // No trailing `Spacer` and no `maxHeight`: `selfSizing` + // measures intrinsic height, which a greedy child would blow + // up to whatever the sheet was offered. + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 24) + } + } + + private var rows: some View { + VStack(spacing: 8) { + switch options { + case .standalone: + switch side { + case .source: + ForEach(sourceOptions, id: \.self) { source in + sourceRow(source) + } + case .destination: + ForEach(destinationOptions, id: \.self) { destination in + destinationRow(destination) + } + } + + case let .balances(networks, selected, select): + ForEach(networks, id: \.self) { network in + row( + display: display(network: network), + selected: network == selected, + action: { select(network) }) + } + } + } + .modifier(MenuViewModifier()) + } + + // MARK: - Options + + /// The three balances plus Identity. Picking one that collides with the + /// To side moves THAT side โ€” the view model keeps the endpoints distinct + /// and coherent, so nothing is filtered out here. + private var sourceOptions: [TransferSource] { + viewModel.availableNetworks.map(TransferSource.balance) + + (viewModel.offersIdentityEndpoints ? [.identity] : []) + } + + /// The three balances plus Identity โ€” which the payments landing card + /// could reach but the screen itself could not, before this picker. + /// + /// With the identity as the source it narrows to Transparent and Platform: + /// the two targets one state transition reaches. Shielded is absent + /// because nothing moves credits from an identity into the Orchard pool, + /// and Identity because it cannot fund itself. + /// + /// A wallet with no registered identity still lists Identity โ€” the amount + /// validation is where that gap is named, the same way the landing card + /// offers it. + private var destinationOptions: [TransferDestination] { + if viewModel.isIdentitySource { + return IdentityWithdrawalTarget.allCases.map { .balance($0.network) } + } + return viewModel.availableNetworks.map(TransferDestination.balance) + + (viewModel.offersIdentityEndpoints ? [.identity] : []) + } + + // MARK: - Rows + + private func sourceRow(_ source: TransferSource) -> some View { + row( + display: display(network: source.balanceNetwork), + selected: viewModel.transferSource == source, + action: { viewModel.selectStandaloneSource(source) }) + } + + private func destinationRow(_ destination: TransferDestination) -> some View { + row( + display: display(network: destination.balanceNetwork), + selected: viewModel.destination == destination, + action: { viewModel.selectStandaloneDestination(destination) }) + } + + /// The design system's `MenuItem`, with the blue tick as its trailing + /// accessory. + /// + /// Name and mark only. No From / To caption โ€” the sheet's own title + /// already says which side is being chosen, and repeating it four times + /// reads as noise. No radio circle either: the tick is the selection. And + /// no balance, which the cards behind the sheet are already showing. + private func row( + display: TransferEndpointDisplay, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button { + action() + onPicked() + } label: { + DashUIKit.MenuItem( + leadingIcon: display.icon, + title: display.title, + accessory: .selection(isSelected: selected) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + /// `nil` is the identity row โ€” the one endpoint that is not a balance. + private func display(network: ChainNetwork?) -> TransferEndpointDisplay { + guard let network else { return .identity(in: viewModel) } + return .network(network, in: viewModel) + } +} + +// MARK: - Endpoint โ†’ balance + +private extension TransferSource { + var balanceNetwork: ChainNetwork? { + if case .balance(let network) = self { return network } + return nil + } +} + +private extension TransferDestination { + var balanceNetwork: ChainNetwork? { + if case .balance(let network) = self { return network } + return nil + } +} + +#if DEBUG + +@MainActor +private func endpointPickerSample( + _ side: TransferEndpointSide, + viewModel: InternalTransferViewModel, + options: TransferEndpointPicker.Options = .standalone +) -> some View { + TransferEndpointPicker(viewModel: viewModel, side: side, options: options, onPicked: {}) +} + +/// Four rows: the three balances plus Identity, with the current source +/// selected. +@available(iOS 17, *) +#Preview("From") { + endpointPickerSample(.source, viewModel: .makeForPreview()) +} + +@available(iOS 17, *) +#Preview("To") { + endpointPickerSample(.destination, viewModel: .makeForPreview()) +} + +/// The narrowed To side: with the identity as the source only Transparent +/// and Platform remain, and the sheet sizes down to those two rows. +@available(iOS 17, *) +#Preview("To ยท from Identity") { + endpointPickerSample( + .destination, + viewModel: .makeForPreview(target: .core, identitySource: true)) +} + +/// Every balance at zero still renders an amount, not an empty trailing slot. +@available(iOS 17, *) +#Preview("Empty balances") { + endpointPickerSample( + .source, + viewModel: .makeForPreview( + identityCredits: 0, + coreDuffs: 0, + platformCredits: 0, + shieldedCredits: 0)) +} + +/// The pinned send sheet's To list: two balances, no identity row, and the +/// pinned From balance left out entirely. +@available(iOS 17, *) +#Preview("To ยท pinned send sheet") { + endpointPickerSample( + .destination, + viewModel: .makeForPreview(source: .core, target: .shielded, sendFrom: .core), + options: .balances([.platform, .shielded], selected: .shielded, select: { _ in })) +} + +@available(iOS 17, *) +#Preview("Dark") { + endpointPickerSample(.source, viewModel: .makeForPreview()) + .preferredColorScheme(.dark) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPreview.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPreview.swift new file mode 100644 index 0000000000..80a9059a33 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPreview.swift @@ -0,0 +1,80 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// "You will transfer ~ N" under the form, shown once the amount is valid. +/// +/// Takes the formatted string rather than an amount: the view model owns the +/// formatting, and this draws what it is given. +struct TransferPreview: View { + let amountFormatted: String + + var body: some View { + VStack(spacing: 2) { + Text(NSLocalizedString("You will transfer", comment: "")) + .font(.system(size: 12)) + .foregroundColor(Color.dash.secondaryText) + HStack(spacing: 4) { + Text("~ \(amountFormatted)") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Image("icon_dash_currency") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 13, height: 13) + } + } + .frame(maxWidth: .infinity) + } +} + +#if DEBUG + +private func transferPreviewSample(_ amount: String) -> some View { + TransferPreview(amountFormatted: amount) + .padding() + .frame(maxWidth: .infinity) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview { + transferPreviewSample("0.0125") +} + +/// Full 8-digit precision โ€” the amount and the Dash glyph stay centred as one +/// unit rather than the glyph being pushed off the trailing edge. +@available(iOS 17, *) +#Preview("Full precision") { + transferPreviewSample("123.45678901") +} + +@available(iOS 17, *) +#Preview("Dark") { + transferPreviewSample("0.0125") + .preferredColorScheme(.dark) +} + +@available(iOS 17, *) +#Preview("Large type") { + transferPreviewSample("0.0125") + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPrivacyTip.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPrivacyTip.swift new file mode 100644 index 0000000000..e580a28268 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferPrivacyTip.swift @@ -0,0 +1,216 @@ +// +// TransferPrivacyTip.swift +// DashWallet +// + +import SwiftUI +import DashUIKit + +/// The note under the confirm sheet's summary: what this particular transfer +/// means for privacy, or how long the money takes to become spendable. +/// +/// Context in, copy out โ€” it reads nothing else, which is why it is a view of +/// its own rather than eighty lines inside a sheet that also runs a transfer. +/// Each of the balance routes and the identity funding sources can be looked +/// at on its own in the canvas. +/// +/// The drawing is `DashUIKit.SystemMessageView`; what stays here is the mapping +/// from context to words, which is the app's and not the design system's. +struct TransferPrivacyTip: View { + /// What the confirm sheet is confirming. + enum Context { + /// A balance-to-balance route. `isFullWithdrawal` matters only for + /// `.platformToCore`: the amount is the whole balance, which the + /// copy says out loud. + case route(InternalTransferRoute, isFullWithdrawal: Bool) + /// An identity top-up funded from `from` โ€” the copy carries the + /// linkability consequence of that source, same as the profile + /// sheet's top-up picker. + case identityTopUp(from: ChainNetwork) + /// Credits leaving the identity for `to`. What the copy has to say + /// is the settling time, which differs sharply between the two + /// targets. + case identityWithdrawal(to: IdentityWithdrawalTarget) + } + + let context: Context + + /// Dismissed for this presentation only. The sheet builds a fresh tip every + /// time it opens, so closing it is "not now" rather than "never again" โ€” + /// nothing here writes a preference, and a tip that stayed gone would need + /// one. + @State private var isVisible = true + + @ViewBuilder + var body: some View { + if isVisible { + DashUIKit.SystemMessageView( + title: title, + subtitle: message, + icon: icon, + backgroundColor: Color.dash.blueAlpha5, + onClose: { isVisible = false } + ) + } + } + + /// The tip is context-aware: + /// - to Shielded (either source) and every identity top-up: privacy nudge. + /// - Shielded โ†’ Core (L1 withdraw): up-to-10-minute spend delay. + /// - Shielded โ†’ Platform (unshield) / Core โ†’ Platform: settles fast. + /// - Platform โ†’ Core: full-balance withdrawal + network processing delay. + private var icon: DashIconSource { + switch context { + case .route(.shieldedToCore, _), .route(.platformToCore, _), + .identityWithdrawal(.transparent): + return DashIcon.SystemMessage.timerSmall.source + default: + return DashIcon.SystemMessage.shieldSmall.source + } + } + + private var title: String { + switch context { + case .identityWithdrawal(.transparent): + return NSLocalizedString("Processing time", comment: "Platform โ†’ Transparent withdrawal") + case .identityWithdrawal(.platform): + return NSLocalizedString("Ready right away", comment: "Identity โ†’ Platform credit transfer") + case .route(.shieldedToCore, _): + return NSLocalizedString("Up to 10 minutes to spend", comment: "") + case .route(.platformToCore, let isFullWithdrawal): + return isFullWithdrawal + ? NSLocalizedString("Withdraws the entire balance", comment: "Full-balance Platform โ†’ Transparent withdrawal") + : NSLocalizedString("Processing time", comment: "Platform โ†’ Transparent withdrawal") + default: + return NSLocalizedString("Privacy tip", comment: "") + } + } + + private var message: String { + switch context { + case .route(let route, let isFullWithdrawal): + return Self.routeMessage(route, isFullWithdrawal: isFullWithdrawal) + case .identityWithdrawal(let target): + switch target { + case .transparent: + // Same asynchronous asset-unlock payout the Platform โ†’ Core + // withdrawal describes, so it borrows that route's wording. + return NSLocalizedString( + "The Dash arrives in your Transparent balance once the network processes the withdrawal.", + comment: "") + case .platform: + return NSLocalizedString( + "These credits move to your Platform balance and are ready to spend right away.", + comment: "Identity โ†’ Platform credit transfer") + } + case .identityTopUp(let source): + // Same wording (and localization keys) as the profile sheet's + // top-up source picker โ€” both describe the same funding paths. + switch source { + case .shielded: + return NSLocalizedString( + "Recommended: a two-step transfer through your own Platform address keeps your identity unlinked from your transparent coins.", + comment: "Identity top-up sheet โ€” shielded source note") + case .core, .platform: + return NSLocalizedString( + "Funding from a transparent balance publicly links those coins to your identity on the Dash chain. For privacy, pay from your Shielded balance.", + comment: "Identity top-up sheet โ€” linkability warning for transparent-side sources") + } + } + } + + private static func routeMessage(_ route: InternalTransferRoute, isFullWithdrawal: Bool) -> String { + switch route { + case .coreToShielded, .platformToShielded: + return NSLocalizedString( + "For best privacy, wait at least 2 hours before using these funds.", + comment: "") + case .shieldedToCore: + return NSLocalizedString( + "After this transfer, it can take up to 10 minutes before you can use your Dash. This delay is part of how your privacy is protected.", + comment: "") + case .shieldedToPlatform: + // Unshield to Platform Payment settles quickly. + return NSLocalizedString( + "These funds move to your Platform Payment balance and are ready to spend right away.", + comment: "") + case .coreToPlatform: + return NSLocalizedString( + "These funds move to your Platform balance and are ready to spend as soon as the transfer completes.", + comment: "") + case .platformToCore: + return isFullWithdrawal + ? NSLocalizedString( + "This withdraws your entire Platform balance in one transfer. The Dash arrives in your Transparent balance once the network processes the withdrawal.", + comment: "") + : NSLocalizedString( + "The Dash arrives in your Transparent balance once the network processes the withdrawal.", + comment: "") + } + } +} + +#if DEBUG + +private func privacyTipSample(_ context: TransferPrivacyTip.Context) -> some View { + TransferPrivacyTip(context: context) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +private func privacyTipSample(_ route: InternalTransferRoute, isFullWithdrawal: Bool = false) -> some View { + privacyTipSample(.route(route, isFullWithdrawal: isFullWithdrawal)) +} + +@available(iOS 17, *) +#Preview("Core โ†’ Shielded") { privacyTipSample(.coreToShielded) } + +@available(iOS 17, *) +#Preview("Platform โ†’ Shielded") { privacyTipSample(.platformToShielded) } + +/// The only route with the clock rather than the shield. +@available(iOS 17, *) +#Preview("Shielded โ†’ Core") { privacyTipSample(.shieldedToCore) } + +@available(iOS 17, *) +#Preview("Shielded โ†’ Platform") { privacyTipSample(.shieldedToPlatform) } + +@available(iOS 17, *) +#Preview("Core โ†’ Platform") { privacyTipSample(.coreToPlatform) } + +/// `.platformToCore` is the one route whose copy changes with the flag. +@available(iOS 17, *) +#Preview("Platform โ†’ Core") { privacyTipSample(.platformToCore) } + +@available(iOS 17, *) +#Preview("Platform โ†’ Core ยท full") { privacyTipSample(.platformToCore, isFullWithdrawal: true) } + +/// Identity top-up from the privacy-recommended source. +@available(iOS 17, *) +#Preview("โ†’ Identity ยท from Shielded") { privacyTipSample(.identityTopUp(from: .shielded)) } + +/// Transparent-side funding carries the linkability warning. +@available(iOS 17, *) +#Preview("โ†’ Identity ยท from Transparent") { privacyTipSample(.identityTopUp(from: .core)) } + +/// Out of the identity: the transparent payout waits on the network, so it +/// is the clock rather than the shield. +@available(iOS 17, *) +#Preview("Identity โ†’ ยท Transparent") { privacyTipSample(.identityWithdrawal(to: .transparent)) } + +@available(iOS 17, *) +#Preview("Identity โ†’ ยท Platform") { privacyTipSample(.identityWithdrawal(to: .platform)) } + +@available(iOS 17, *) +#Preview("Dark") { privacyTipSample(.shieldedToCore).preferredColorScheme(.dark) } + +/// The body wraps to several lines already; at accessibility sizes the icon +/// must stay pinned to the first one. +@available(iOS 17, *) +#Preview("Large type") { + privacyTipSample(.platformToCore, isFullWithdrawal: true) + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferSourceRow.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferSourceRow.swift new file mode 100644 index 0000000000..330f0059bf --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Components/TransferSourceRow.swift @@ -0,0 +1,233 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// Everything the screen needs to draw one endpoint โ€” the single source of +/// truth behind every `ConverterCard` row and both endpoint picker sheets. +/// +/// One icon and one balance, because every consumer is now a design-system +/// component: `MenuItem` and `ConverterCard` both draw a flat 30pt catalog +/// asset and format the duffs themselves. The second pair of fields existed +/// for `TransferSourceRow`, which drew an SF Symbol in a tinted circle and +/// took a preformatted string; nothing in this flow uses that row any more. +/// +/// `@MainActor` because every balance it reads is main-actor state on the +/// view model. +@MainActor +struct TransferEndpointDisplay { + let icon: DashIconSource + let title: String + /// Duffs โ€” every consumer formats the amount itself. + let dashBalance: Int64 + + static func network( + _ network: ChainNetwork, + in viewModel: InternalTransferViewModel + ) -> TransferEndpointDisplay { + switch network { + case .core: + return .init( + icon: DashIcon.Menu.dashLogoSquare.source, + title: network.balanceName, + dashBalance: Int64(viewModel.coreBalanceDuffs)) + case .platform: + // Platform and Shielded are held in credits โ€” 1000 per duff. + return .init( + icon: DashIcon.Features.platform.source, + title: network.balanceName, + dashBalance: Int64(viewModel.platformCredits / 1000)) + case .shielded: + return .init( + icon: DashIcon.Features.shield.source, + title: network.balanceName, + dashBalance: Int64(viewModel.shieldedBalance / 1000)) + } + } + + static func identity(in viewModel: InternalTransferViewModel) -> TransferEndpointDisplay { + .init( + icon: DashIcon.Features.identity.source, + title: InternalTransferViewModel.identityBalanceName, + dashBalance: Int64(viewModel.identityBalanceCredits / 1000)) + } +} + +struct TransferSourceRow: View { + let iconSystemName: String + let caption: String + let title: String + let balanceTrailing: AnyView + let selected: Bool + /// False renders a fixed (non-picker) endpoint card: no radio circle + /// and no selection border. + var showsRadio: Bool = true + /// With `showsRadio` false: true renders a tappable collapsed selector + /// (chevron trailing, expands a picker on tap) instead of a fixed card. + var showsChevron: Bool = false + var action: () -> Void + + /// Trailing balance amount + Dash currency glyph, the standard trailing + /// content for these rows. + static func dashBalanceTrailing(_ formatted: String) -> AnyView { + AnyView( + HStack(spacing: 2) { + Text(formatted) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Image("icon_dash_currency") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + }) + } + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: iconSystemName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.blue) + .frame(width: 36, height: 36) + .background(Color.dash.blue.opacity(0.08)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(caption) + .font(.system(size: 11)) + .foregroundColor(Color.dash.secondaryText) + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + + Spacer() + + balanceTrailing + + if showsRadio { + radioIndicator + } else if showsChevron { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color.dash.secondaryText) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color.dash.secondaryBackground) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(showsRadio && selected ? Color.dash.blue : Color.clear, + lineWidth: showsRadio && selected ? 1.5 : 0)) + .cornerRadius(12) + } + .buttonStyle(.plain) + .disabled(!showsRadio && !showsChevron) + } + + private var radioIndicator: some View { + ZStack { + Circle() + .stroke(selected ? Color.dash.blue : Color.dash.gray300.opacity(0.6), lineWidth: 1.5) + .frame(width: 18, height: 18) + if selected { + Circle() + .fill(Color.dash.blue) + .frame(width: 10, height: 10) + } + } + } +} + +#if DEBUG + +/// One row per trailing treatment, on the background the screen actually puts +/// them on โ€” the selection border only reads correctly against it. +private func sourceRowGallery( + balance: String = "2.45", + title: String = "Transparent" +) -> some View { + VStack(spacing: 8) { + TransferSourceRow( + iconSystemName: "d.circle.fill", + caption: "From", + title: title, + balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), + selected: true, + action: {}) + + TransferSourceRow( + iconSystemName: "creditcard.fill", + caption: "From", + title: "Platform", + balanceTrailing: TransferSourceRow.dashBalanceTrailing("1.2"), + selected: false, + action: {}) + + TransferSourceRow( + iconSystemName: "shield.fill", + caption: "To", + title: "Shielded", + balanceTrailing: TransferSourceRow.dashBalanceTrailing("0.785"), + selected: false, + showsRadio: false, + action: {}) + + TransferSourceRow( + iconSystemName: "d.circle.fill", + caption: "From", + title: title, + balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), + selected: false, + showsRadio: false, + showsChevron: true, + action: {}) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Variants") { + sourceRowGallery() +} + +@available(iOS 17, *) +#Preview("Dark") { + sourceRowGallery() + .preferredColorScheme(.dark) +} + +/// A 5-fraction-digit balance next to a long balance name is the widest the +/// row ever gets โ€” the title must shrink or truncate before the amount does. +@available(iOS 17, *) +#Preview("Widest content") { + sourceRowGallery(balance: "123.45678", title: "Transparent balance") +} + +/// The caption/title column stacks at accessibility sizes; the balance and the +/// radio must stay on the same line as the icon. +@available(iOS 17, *) +#Preview("Large type") { + sourceRowGallery() + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmSheet.swift new file mode 100644 index 0000000000..cc3ff311a0 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmSheet.swift @@ -0,0 +1,285 @@ +// +// InternalTransferConfirmSheet.swift +// DashWallet +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK + +/// Confirmation half-sheet shown when the user taps `Continue` on the +/// Internal transfer screen. +/// +/// It confirms and then gets out of the way: Confirm authenticates, hands the +/// transfer to `InternalTransferRunner`, which outlives this sheet, and the +/// host closes it. Progress, success and failure are no longer shown here โ€” +/// waiting on a modal for a transfer that can take minutes was the thing being +/// removed. The runner announces the outcome wherever the user ended up. +/// +/// The PIN prompt is the one thing that is NOT deferred: it is raised and +/// answered over this sheet, before the dismissal. Confirm is what the user +/// authorized, so the prompt belongs to the screen they tapped it on, not to +/// whatever they land on afterwards. +/// +/// Drawing only: what the summary says and which privacy note applies are +/// `InternalTransferConfirmViewModel`'s. +struct InternalTransferConfirmSheet: View { + + @StateObject private var viewModel: InternalTransferConfirmViewModel + + /// Confirm is awaiting the PIN prompt. The prompt covers this sheet while + /// it is up, so this is not about showing progress โ€” it is what stops a + /// second tap from queueing a second gate behind the first. + @State private var isSubmitting = false + + var onCancel: () -> Void + /// Called once the transfer has been authorized and handed off โ€” the host + /// closes the sheet and returns the user to the history. NOT a completion + /// callback: the transfer is still running when this fires. + var onSubmitted: () -> Void + var onPlatformShieldCapacityChanged: (UInt64?, Bool) -> Void + + init( + route: InternalTransferRoute?, + dashDuffs: Int64, + amountDuffsUnsigned: UInt64, + creditsAmount: UInt64, + fiatText: String, + withdrawalFeeCredits: UInt64? = nil, + isFullPlatformWithdrawal: Bool = false, + isFullShieldedSweep: Bool = false, + platformShieldAmountWasMax: Bool = false, + identityTopUp: IdentityTopUpTransfer? = nil, + identityWithdrawal: IdentityWithdrawalTransfer? = nil, + onCancel: @escaping () -> Void, + onSubmitted: @escaping () -> Void, + onPlatformShieldCapacityChanged: @escaping (UInt64?, Bool) -> Void + ) { + _viewModel = StateObject(wrappedValue: InternalTransferConfirmViewModel( + route: route, + identityTopUp: identityTopUp, + identityWithdrawal: identityWithdrawal, + dashDuffs: dashDuffs, + amountDuffsUnsigned: amountDuffsUnsigned, + creditsAmount: creditsAmount, + fiatText: fiatText, + withdrawalFeeCredits: withdrawalFeeCredits, + isFullPlatformWithdrawal: isFullPlatformWithdrawal, + isFullShieldedSweep: isFullShieldedSweep, + platformShieldAmountWasMax: platformShieldAmountWasMax)) + self.onCancel = onCancel + self.onSubmitted = onSubmitted + self.onPlatformShieldCapacityChanged = onPlatformShieldCapacityChanged + } + + var body: some View { + // Grabber, title and background come from the design system rather + // than being drawn here. `fillsHeight: true` because the host asks for + // `selfSizing` measures the content instead of taking a detent: the + // summary card is four fixed rows and the privacy note is a couple of + // lines, so a `.large` sheet was mostly empty space. Nothing below may + // grow greedily โ€” no `Spacer`, no `maxHeight: .infinity` โ€” or the + // measurement expands to whatever the sheet was offered. + DashUIKit.BottomSheet.selfSizing( + title: NSLocalizedString("Confirm", comment: "Payments"), + showBackButton: .constant(false) + ) { + VStack(spacing: 0) { + + VStack(alignment: .leading, spacing: 20) { + // The design system's amount pair: the Dash figure with + // its logo, the converted value under it. + DashUIKit.SwapAmountView( + amount: viewModel.dashAmountText, + secondaryText: viewModel.fiatText, + showDashLogo: true) + .frame(maxWidth: .infinity) + .padding(.top, 14) + + TransferSummaryCard(summary: viewModel.summary) + + privacyTip + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + + HStack(spacing: 20) { + DashUIKit.DashButton( + text: NSLocalizedString("Cancel", comment: "Payments"), + isEnabled: !isSubmitting, + fillsWidth: true, + size: .large, + style: .tintedGray, + action: onCancel + ) + + DashUIKit.DashButton( + text: NSLocalizedString("Confirm", comment: "Payments"), + isLoading: isSubmitting, + fillsWidth: true, + size: .large, + style: .filledBlue, + action: submit + ) + } + .padding(20) + } + } + .onChange(of: viewModel.platformShieldCapacityChange) { _, change in + guard let change else { return } + onPlatformShieldCapacityChanged( + change.maxShieldableCredits, + change.submittedAmountWasMax) + } + } + + /// Authorize, then leave โ€” in that order. + /// + /// The sheet stays up for the PIN prompt and closes only once the gate has + /// resolved. Closing first (which is what the hand-off originally did) put + /// the prompt on the home screen the user had just been dropped onto, + /// asking them to authorize something no longer on screen. Backing out of + /// the prompt is not an error: the summary is simply still there. + private func submit() { + guard !isSubmitting else { return } + isSubmitting = true + Task { + let handedOff = await viewModel.confirm() + isSubmitting = false + if handedOff { onSubmitted() } + } + } + + @ViewBuilder + private var privacyTip: some View { + if let context = viewModel.privacyTipContext { + TransferPrivacyTip(context: context) + } + } +} + +#if DEBUG + +/// The sheet owns its coordinator and `phase` is `private(set)`, so previews +/// can only reach the idle summary. The in-flight, success and failure bodies +/// are previewed through `ShieldedTransferStepList` and +/// `ShieldedSubmittedUnconfirmedView` below. +/// +/// Network-fee and total rows ask the SDK to price the route; without a wallet +/// those return `nil` and the rows render "โ€”". `.coreToPlatform` prices from a +/// constant, so it is the route to use when the fee row itself matters. +private func confirmSheetSample( + route: InternalTransferRoute?, + dash: Decimal = 0.5, + withdrawalFeeCredits: UInt64? = nil, + isFullPlatformWithdrawal: Bool = false, + isFullShieldedSweep: Bool = false, + identityTopUp: IdentityTopUpTransfer? = nil, + identityWithdrawal: IdentityWithdrawalTransfer? = nil +) -> some View { + let duffs = Int64(truncating: NSDecimalNumber(decimal: dash * 100_000_000)) + return InternalTransferConfirmSheet( + route: route, + dashDuffs: duffs, + amountDuffsUnsigned: UInt64(duffs), + creditsAmount: UInt64(duffs) * 1000, + fiatText: "$32.75", + withdrawalFeeCredits: withdrawalFeeCredits, + isFullPlatformWithdrawal: isFullPlatformWithdrawal, + isFullShieldedSweep: isFullShieldedSweep, + platformShieldAmountWasMax: false, + identityTopUp: identityTopUp, + identityWithdrawal: identityWithdrawal, + onCancel: {}, + onSubmitted: {}, + onPlatformShieldCapacityChanged: { _, _ in }) +} + +/// Identity destination funded from `source`. Without a wallet the shielded +/// funding's fee row renders "โ€”"; the transparent-side sources price from +/// the observed transition-fee constant, so their fee row shows a number. +private func identityConfirmSheetSample(source: ChainNetwork) -> some View { + confirmSheetSample( + route: nil, + identityTopUp: IdentityTopUpTransfer( + identityId: Data(repeating: 0x07, count: 32), + source: source)) +} + +@available(iOS 17, *) +#Preview("Core โ†’ Platform") { + confirmSheetSample(route: .coreToPlatform) +} + +@available(iOS 17, *) +#Preview("Core โ†’ Shielded") { + confirmSheetSample(route: .coreToShielded) +} + +@available(iOS 17, *) +#Preview("Shielded โ†’ Core ยท sweep") { + confirmSheetSample(route: .shieldedToCore, isFullShieldedSweep: true) +} + +/// Full-balance withdrawal: the fee is already netted out of the payout, and +/// the preflight fee is the only one the sheet can show. +@available(iOS 17, *) +#Preview("Platform โ†’ Core ยท full") { + confirmSheetSample( + route: .platformToCore, + withdrawalFeeCredits: 1_240_000, + isFullPlatformWithdrawal: true) +} + +/// Identity destination from the Transparent balance โ€” the linkability +/// warning replaces the route privacy tip and To reads "Identity". +@available(iOS 17, *) +#Preview("โ†’ Identity ยท from Transparent") { + identityConfirmSheetSample(source: .core) +} + +@available(iOS 17, *) +#Preview("โ†’ Identity ยท from Shielded") { + identityConfirmSheetSample(source: .shielded) +} + +private func withdrawalConfirmSheetSample(target: IdentityWithdrawalTarget) -> some View { + confirmSheetSample( + route: nil, + identityWithdrawal: IdentityWithdrawalTransfer( + identityId: Data(repeating: 0x07, count: 32), + target: target)) +} + +/// Identity source: From reads "Identity" and the fee row is the em dash โ€” +/// neither withdrawal transition has an SDK fee estimate to print. +@available(iOS 17, *) +#Preview("Identity โ†’ ยท Transparent") { + withdrawalConfirmSheetSample(target: .transparent) +} + +@available(iOS 17, *) +#Preview("Identity โ†’ ยท Platform") { + withdrawalConfirmSheetSample(target: .platform) +} + +@available(iOS 17, *) +#Preview("Dark") { + confirmSheetSample(route: .coreToPlatform) + .preferredColorScheme(.dark) +} + +/// Presented the way the screen presents it โ€” verifies the drag handle and the +/// `.large` detent, which the bare-content previews above can't show. +@available(iOS 17, *) +#Preview("As a sheet") { + Color.dash.primaryBackground + .ignoresSafeArea() + .sheet(isPresented: .constant(true)) { + confirmSheetSample(route: .coreToPlatform) + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmViewModel.swift new file mode 100644 index 0000000000..83fc9759eb --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferConfirmViewModel.swift @@ -0,0 +1,174 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +/// Everything the confirm sheet decides, so the sheet itself only draws. +/// +/// Executing the transfer is `InternalTransferRunner`'s, not this object's โ€” +/// the work has to outlive the sheet, which closes the moment it starts. What +/// is left here is per-presentation: what the summary says and which privacy +/// note applies. +@MainActor +final class InternalTransferConfirmViewModel: ObservableObject { + + /// What is being confirmed. The sheet is handed three mutually exclusive + /// optionals; normalising them here once means every decision below is an + /// exhaustive switch instead of a repeated three-way `if let` chain โ€” a + /// fourth kind of transfer becomes a compiler error rather than a branch + /// somebody has to remember to add. + enum Mode: Equatable { + case transfer(InternalTransferRequest) + /// Nothing to execute. Not reachable from the screen, which only + /// presents the sheet for a transfer it has already validated, but the + /// sheet must still render something rather than force-unwrap: every + /// figure reads as unavailable and Confirm does nothing. + case unavailable + } + + let mode: Mode + let dashDuffs: Int64 + let fiatText: String + + /// Forwarded from the runner: a nested `ObservableObject` does not + /// republish through its owner. The sheet closes as soon as the transfer + /// starts, so this is the only runner state it still needs โ€” a capacity + /// change is the one outcome the SCREEN behind it has to act on. + @Published private(set) var platformShieldCapacityChange: + InternalTransferRunner.PlatformShieldCapacityChange? + + private let runner: InternalTransferRunner + private var cancellables = Set() + + init( + route: InternalTransferRoute?, + identityTopUp: IdentityTopUpTransfer?, + identityWithdrawal: IdentityWithdrawalTransfer?, + dashDuffs: Int64, + amountDuffsUnsigned: UInt64, + creditsAmount: UInt64, + fiatText: String, + withdrawalFeeCredits: UInt64?, + isFullPlatformWithdrawal: Bool, + isFullShieldedSweep: Bool, + platformShieldAmountWasMax: Bool, + runner: InternalTransferRunner = .shared + ) { + // Precedence matches what the view did before: an identity transfer + // wins over a route, which the screen never sets alongside one. + let kind: InternalTransferRequest.Kind? + if let identityTopUp { + kind = .identityTopUp(identityTopUp) + } else if let identityWithdrawal { + kind = .identityWithdrawal(identityWithdrawal) + } else if let route { + kind = .route(route) + } else { + kind = nil + } + + mode = kind.map { kind in + .transfer(InternalTransferRequest( + kind: kind, + amountDuffsUnsigned: amountDuffsUnsigned, + creditsAmount: creditsAmount, + withdrawalFeeCredits: withdrawalFeeCredits, + isFullPlatformWithdrawal: isFullPlatformWithdrawal, + isFullShieldedSweep: isFullShieldedSweep, + platformShieldAmountWasMax: platformShieldAmountWasMax)) + } ?? .unavailable + + self.dashDuffs = dashDuffs + self.fiatText = fiatText + self.runner = runner + + // `dropFirst` because the runner is shared: whatever a previous + // transfer left in this property is already there at subscribe time, + // and forwarding it would close this sheet before the user has + // confirmed anything. Only changes that happen after this sheet + // appeared are its own. + runner.$platformShieldCapacityChange + .dropFirst() + .sink { [weak self] in self?.platformShieldCapacityChange = $0 } + .store(in: &cancellables) + } + + // MARK: - Display + + /// Digits only โ€” `SwapAmountView` draws the Dash logo beside them rather + /// than taking a symbol inside the string. + var dashAmountText: String { + (request?.amountDuffsUnsigned ?? 0).dashAmount.formattedDashAmountWithoutCurrencySymbol + } + + var summary: TransferConfirmSummary { + TransferConfirmSummary( + route: routeOrNil, + identityTopUp: topUpOrNil, + identityWithdrawal: withdrawalOrNil, + dashDuffs: dashDuffs, + amountDuffsUnsigned: request?.amountDuffsUnsigned ?? 0, + withdrawalFeeCredits: request?.withdrawalFeeCredits) + } + + var privacyTipContext: TransferPrivacyTip.Context? { + guard let request else { return nil } + switch request.kind { + case .route(let route): + return .route(route, isFullWithdrawal: request.isFullPlatformWithdrawal) + case .identityTopUp(let topUp): + return .identityTopUp(from: topUp.source) + case .identityWithdrawal(let withdrawal): + return .identityWithdrawal(to: withdrawal.target) + } + } + + // MARK: - Actions + + /// Runs the auth gate, then hands the transfer off. Returns whether the + /// sheet has finished with the user: it stays open when the PIN prompt was + /// backed out of, so Confirm can simply be tapped again โ€” everything else, + /// including a refusal because another transfer is still running, is + /// announced by a toast on the surface behind it. + func confirm() async -> Bool { + guard case .transfer(let request) = mode else { return false } + return await runner.start(request) != .notAuthorized + } + + // MARK: - Request accessors + + private var request: InternalTransferRequest? { + if case .transfer(let request) = mode { return request } + return nil + } + + private var routeOrNil: InternalTransferRoute? { + if case .route(let route)? = request?.kind { return route } + return nil + } + + private var topUpOrNil: IdentityTopUpTransfer? { + if case .identityTopUp(let topUp)? = request?.kind { return topUp } + return nil + } + + private var withdrawalOrNil: IdentityWithdrawalTransfer? { + if case .identityWithdrawal(let withdrawal)? = request?.kind { return withdrawal } + return nil + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferRunner.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferRunner.swift new file mode 100644 index 0000000000..affe05bca2 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferRunner.swift @@ -0,0 +1,338 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +/// One internal transfer, described completely enough to execute without the +/// screen that asked for it. +struct InternalTransferRequest: Equatable { + enum Kind: Equatable { + case route(InternalTransferRoute) + case identityTopUp(IdentityTopUpTransfer) + case identityWithdrawal(IdentityWithdrawalTransfer) + } + + let kind: Kind + let amountDuffsUnsigned: UInt64 + let creditsAmount: UInt64 + /// `.platformToCore` only: the preflighted transition fee. + let withdrawalFeeCredits: UInt64? + /// `.platformToCore` only: run the AUTO all-addresses withdrawal. + let isFullPlatformWithdrawal: Bool + /// Shielded reverse routes only: execute the note-aware Max plan. + let isFullShieldedSweep: Bool + /// Frozen with the submitted amount, so a capacity refresh only rewrites + /// a figure the user derived with Max. + let platformShieldAmountWasMax: Bool +} + +/// Runs an internal transfer and outlives whatever presented it. +/// +/// The executors used to be `@StateObject`s on the confirm sheet, which tied a +/// live transfer to that sheet's lifetime: dismissing it deallocated the +/// coordinator and cancelled the task mid-flight. For a Core-funded route that +/// can strand an asset lock that was already committed on chain โ€” the exact +/// case the retry path exists to recover from. Nothing about executing a +/// transfer belongs to a view, so none of it lives in one any more. +/// +/// A shared instance rather than an injected one (against the usual +/// preference) because the point is precisely to be reachable after the view +/// that started the work is gone. One transfer at a time: the screen offers no +/// way to start a second while the first is in flight. +@MainActor +final class InternalTransferRunner: ObservableObject { + + static let shared = InternalTransferRunner() + + /// The one lifecycle callers render from, whichever executor is running. + /// `submittedUnconfirmed` is route-only โ€” no identity transition lands in + /// that state. + enum Phase: Equatable { + case idle + case inFlight + case success + case submittedUnconfirmed + case failed(String) + } + + /// A Platform shield whose capacity moved under the submitted amount. Not + /// a failure to show but an amount to re-derive. + struct PlatformShieldCapacityChange: Equatable { + let maxShieldableCredits: UInt64? + let submittedAmountWasMax: Bool + } + + /// What `start` settled, for the surface that asked for the transfer. + enum StartOutcome: Equatable { + /// Authorized and handed off. The transfer is running. + case started + /// Another transfer is still in flight; nothing was started. + case busy + /// The user backed out of the PIN prompt, or it failed. Not an error + /// state โ€” the caller returns to what it was showing. + case notAuthorized + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var platformShieldCapacityChange: PlatformShieldCapacityChange? + /// What to tell the user, for whoever is on screen when it happens. + /// + /// The confirm sheet closes as soon as the transfer starts, so the + /// outcome arrives with nothing of the transfer's own left on screen โ€” + /// this is how it reaches the surface the user actually landed on. + /// Cleared by that surface once shown. + @Published var notice: Notice? { + didSet { noticeRaisedAt = notice == nil ? nil : Date() } + } + + /// When the current notice was raised. + /// + /// The toast lives on `HomeView`, and a notice raised while the user is + /// elsewhere stays set until something clears it. Without a timestamp the + /// next appearance of that screen would announce an outcome from an + /// arbitrary time ago as if it had just happened. + private(set) var noticeRaisedAt: Date? + + /// The things worth saying, in the order they can happen. + enum Notice: Equatable { + case started + /// A second transfer asked to start while the first is still running. + /// The runner takes one at a time, and refusing silently would look + /// like Confirm did nothing. + case busy + case succeeded + /// Accepted by the network but not yet confirmed โ€” a shielded spend + /// whose receipt has not landed. + case submitted + case failed(String) + } + + private let coordinator = ShieldedTransferCoordinator() + private let topUpExecutor = IdentityTopUpViewModel() + private let withdrawExecutor = IdentityWithdrawViewModel() + private let authorizer = DWIdentityAuthorizer() + private var cancellables = Set() + private var request: InternalTransferRequest? + /// Held across the authorization gate, which is the window `phase` cannot + /// cover: it is still `.idle` while the PIN prompt is up. + private var isAwaitingAuthorization = false + + private init() { + coordinator.$phase + .sink { [weak self] routePhase in + self?.apply(routePhase) + } + .store(in: &cancellables) + } + + /// Takes the transfer at the moment the user confirms it, not when the + /// sheet is built. + /// + /// Everything here publishes, and an earlier version armed the runner from + /// the confirm view model's `init` โ€” which SwiftUI runs inside + /// `StateObject(wrappedValue:)`, i.e. during a view update. That is the + /// "Publishing changes from within view updates" warning: three of them, + /// since `coordinator.reset()` fed the phase sink as well. A tap is not a + /// view update, so taking the request here removes the whole class of it. + /// + /// Authorization is awaited HERE, before anything is handed to an + /// executor, and that is the whole reason this is `async`: the PIN prompt + /// has to be answered while the confirm sheet is still up. The executors + /// each raise the same gate from inside work that outlives that sheet, so + /// leaving it to them meant the sheet closed first and the user met the + /// prompt on whatever screen they landed on. `preauthorized` below is what + /// keeps that inner gate from asking a second time. + func start(_ request: InternalTransferRequest) async -> StartOutcome { + guard phase != .inFlight, !isAwaitingAuthorization else { + notice = .busy + return .busy + } + + // Claimed BEFORE the gate, not after. `phase` only becomes `.inFlight` + // once an executor is running, and the PIN prompt sits in between โ€” + // three entry points build a transfer screen against this one shared + // runner, so a second Confirm during that window passed the phase guard + // and would have started a second transfer behind the first prompt. + isAwaitingAuthorization = true + defer { isAwaitingAuthorization = false } + + do { + try await authorizer.authorize() + } catch { + // Both backing out and a failed prompt leave the caller where it + // was โ€” the prompt itself has already said whatever there was to + // say, and nothing has been started to report on. + return .notAuthorized + } + + // Clear what the previous transfer left, so this one is not read + // through its outcome. + self.request = request + phase = .idle + platformShieldCapacityChange = nil + coordinator.reset() + + notice = .started + run(request) + return .started + } + + // No retry here. A Core-funded route that fails after committing its asset + // lock is recovered by the home screen: the pending transaction row is + // tappable and opens `ShieldedRecoverySheet`, which resumes that exact + // outpoint. A second entry point would risk building a second lock. + + // MARK: - Execution + + /// Every executor runs inside `preauthorized`, because `start` has already + /// held the gate open for this exact transfer. Without it the user would + /// be asked for the PIN twice: once on the sheet, once again from inside + /// the coordinator. + private func run(_ request: InternalTransferRequest) { + Task { + await DWIdentityAuthorizer.preauthorized { + switch request.kind { + case .route(let route): + await runRoute(route, request) + case .identityTopUp(let topUp): + await runTopUp(topUp, request) + case .identityWithdrawal(let withdrawal): + await runWithdrawal(withdrawal, request) + } + } + } + } + + private func runRoute(_ route: InternalTransferRoute, _ request: InternalTransferRequest) async { + switch route { + case .coreToShielded: + await coordinator.performAssetLock(recipientAmountDuffs: request.amountDuffsUnsigned) + case .platformToShielded: + await coordinator.performShield(amountCredits: request.creditsAmount) + case .shieldedToCore: + await coordinator.performWithdraw( + amountCredits: request.creditsAmount, + sweepAll: request.isFullShieldedSweep) + case .shieldedToPlatform: + await coordinator.performUnshield( + amountCredits: request.creditsAmount, + sweepAll: request.isFullShieldedSweep) + case .coreToPlatform: + await coordinator.performFundPlatform(amountDuffs: request.amountDuffsUnsigned) + case .platformToCore: + await coordinator.performPlatformWithdraw( + amountCredits: request.creditsAmount, + fullBalance: request.isFullPlatformWithdrawal, + feeHeadroomCredits: request.withdrawalFeeCredits) + } + } + + /// Both identity executors report the same three outcomes, so they reduce + /// the same way: a result is success, a message is failure, and a bare + /// nil is neither โ€” the executor declined without anything to say. + private func runTopUp(_ topUp: IdentityTopUpTransfer, _ request: InternalTransferRequest) async { + phase = .inFlight + let newBalance = await topUpExecutor.topUp( + identityId: topUp.identityId, + amountDuffs: request.amountDuffsUnsigned, + source: .init(spending: topUp.source)) + phase = outcome(succeeded: newBalance != nil, message: consume(&topUpExecutor.errorMessage)) + announce(phase) + } + + private func runWithdrawal( + _ withdrawal: IdentityWithdrawalTransfer, + _ request: InternalTransferRequest + ) async { + phase = .inFlight + let succeeded = await withdrawExecutor.withdraw( + identityId: withdrawal.identityId, + amountCredits: request.creditsAmount, + target: withdrawal.target) + phase = outcome(succeeded: succeeded, message: consume(&withdrawExecutor.errorMessage)) + announce(phase) + } + + /// Identity transfers reach their terminal phase by returning rather than + /// through the coordinator's stream, so they say so here. A silent decline + /// (`.idle`) is not worth a notice. + private func announce(_ phase: Phase) { + switch phase { + case .success: notice = .succeeded + case .failed(let message): notice = .failed(message) + case .idle: notice = nil + case .inFlight, .submittedUnconfirmed: break + } + } + + /// A failure with no message is the executor declining without a reason + /// worth showing โ€” nothing happened, so nothing is reported. The gate is + /// no longer one of those cases: `start` settles authorization before an + /// executor is ever reached. + private func outcome(succeeded: Bool, message: String?) -> Phase { + if succeeded { return .success } + guard let message else { return .idle } + return .failed(message) + } + + /// Clears the executor's message as it reads it, so a later retry cannot + /// inherit the previous failure. + private func consume(_ message: inout String?) -> String? { + defer { message = nil } + return message + } + + // MARK: - Route phase + + private func apply(_ routePhase: ShieldedTransferCoordinator.Phase) { + // Identity transfers drive `phase` directly; the coordinator is idle + // then and must not overwrite them. + guard case .route? = request?.kind else { return } + + switch routePhase { + case .signing, .locking, .proving, .broadcasting: + phase = .inFlight + case .success: + phase = .success + notice = .succeeded + case .submittedUnconfirmed: + phase = .submittedUnconfirmed + notice = .submitted + case .failed(let message): + phase = .failed(message) + notice = .failed(message) + notePlatformShieldCapacityChange() + default: + phase = .idle + } + } + + /// Reported once โ€” a retry against a stale figure would only fail the same + /// way. + private func notePlatformShieldCapacityChange() { + guard platformShieldCapacityChange == nil, + let request, + let error = coordinator.lastFailure as? ShieldedTransferCoordinator.CoordinatorError, + case .platformShieldCapacityChanged(let maxShieldableCredits) = error + else { return } + + platformShieldCapacityChange = PlatformShieldCapacityChange( + maxShieldableCredits: maxShieldableCredits, + submittedAmountWasMax: request.platformShieldAmountWasMax) + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferSummaryFigures.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferSummaryFigures.swift new file mode 100644 index 0000000000..98f6cc56ae --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferSummaryFigures.swift @@ -0,0 +1,167 @@ +// +// InternalTransferSummaryFigures.swift +// DashWallet +// + +import Foundation +import SwiftDashSDK + +/// The numbers and names the confirm sheet's summary card shows, derived from +/// the route. +/// +/// Lifted out of the sheet because it is fee math: the SDK is asked to price +/// four of the six routes, and Core โ†’ Shielded reconstructs the executed lock +/// value. A SwiftUI `View` is the wrong place for that โ€” it is also the reason +/// none of it could be exercised without rendering a sheet. +/// +/// Every value fails closed. An unavailable estimate is `nil`, which the sheet +/// renders as "โ€”" rather than a number it cannot stand behind; `canContinue` +/// has already refused the transfer by then. +@MainActor +enum InternalTransferSummaryFigures { + + /// Platform credits per DASH (1e11). + private static let creditsPerDash: Decimal = 100_000_000_000 + + /// Flat fee estimate (credits) for `route`, computed offline by the SDK + /// against the latest protocol version, so it matches the fee the SDK will + /// charge. `nil` when the estimate is unavailable. + /// + /// `withdrawalFeeCredits` is only read for `.platformToCore`, where the + /// preflight has already netted the exact transition fee out of the payout. + static func networkFeeCredits( + route: InternalTransferRoute, + withdrawalFeeCredits: UInt64? + ) -> UInt64? { + switch route { + case .coreToShielded: + // The lock charges the fee rounded UP to a whole duff โ€” report + // that, so Amount + Network fee equals Total exactly. + return CoreToShieldedAmountPolicy.currentPoolFeeDuffs.map { $0 * 1000 } + case .platformToShielded: + // Shield (Type 15): base shielded fee. Real metered storage is + // extra and only knowable on-chain, so this is a lower bound. + return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) + case .shieldedToCore: + return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) + case .shieldedToPlatform: + return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) + case .coreToPlatform: + // Address-funding asset lock: the required processing balance (the + // same 50k-duff base the Rust side reserves for address funding). + // The funding ST's metered fee is extra and only knowable + // on-chain, so this is a lower bound. + return CoreToShieldedAmountPolicy.assetLockBaseCostCredits + case .platformToCore: + return withdrawalFeeCredits + } + } + + /// The fee as fiat (e.g. "~ $0.08"), or `nil` when it cannot be priced. + static func networkFeeFiat( + route: InternalTransferRoute, + withdrawalFeeCredits: UInt64? + ) -> String? { + networkFeeCredits( + route: route, + withdrawalFeeCredits: withdrawalFeeCredits) + .map(feeFiatString(credits:)) + } + + private static func feeFiatString(credits: UInt64) -> String { + let dash = Decimal(credits) / creditsPerDash + return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) + } + + // MARK: - Identity destination + + /// Fee estimate for an identity top-up funded from `source`, as fiat. + /// The number is the top-up policy's own display estimate + /// (`IdentityTopUpViewModel.estimatedFeeCredits`): the observed + /// IdentityTopUp transition fee, plus the unshield minimum when the + /// funding starts in the shielded pool. `nil` when that estimate is + /// unavailable โ€” the row renders an em dash. + static func identityTopUpFeeFiat(source: ChainNetwork) -> String? { + IdentityTopUpViewModel.estimatedFeeCredits( + source: .init(spending: source)) + .map(feeFiatString(credits:)) + } + + /// The total row for an identity top-up: the amount itself, mirroring + /// the sibling routes' totals โ€” like Shielded โ†’ Platform (whose unshield + /// this funding shares), the fee is reported in the fee row rather than + /// inflating the total. + static func identityTopUpTotal(dashDuffs: Int64) -> String { + dashDuffs.formattedDashAmount + } + + /// No fee number for an identity withdrawal. Neither transition it can + /// run โ€” IdentityCreditWithdrawal or the credit transfer โ€” has an SDK fee + /// estimator, the same gap `PlatformPaymentIdentityFundingPolicy` + /// documents. `IdentityWithdrawViewModel.feeHeadroomCredits` is a reserve + /// held back, deliberately several times the observed fee, so printing it + /// here would overstate what the transfer costs. The row shows an em dash + /// instead. + /// + /// TODO(SwiftDashSDK): price this once an identity-transition fee + /// estimate is exposed. + static let identityWithdrawalFeeFiat: String? = nil + + /// The summary label for the identity endpoint, on whichever side it + /// sits โ€” matches the payments landing's row title. + static var identityEndpointName: String { + InternalTransferViewModel.identityBalanceName + } + + /// What actually leaves the source balance, formatted. + /// + /// Core โ†’ Shielded charges the pool fee ON TOP of the amount, so its total + /// is the executed lock value; every other route's total is the amount + /// itself. `nil` when the pool fee is unavailable โ€” the row must never show + /// the un-inflated number. + static func totalLeavingSource( + route: InternalTransferRoute, + dashDuffs: Int64, + amountDuffsUnsigned: UInt64 + ) -> String? { + guard route == .coreToShielded else { + return dashDuffs.formattedDashAmount + } + guard let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits, + let lockDuffs = CoreToShieldedAmountPolicy.lockValueDuffs( + forAmountDuffs: amountDuffsUnsigned, + poolFeeCredits: poolFeeCredits), + let signedLockDuffs = Int64(exactly: lockDuffs) + else { return nil } + + return signedLockDuffs.formattedDashAmount + } + + // MARK: - Endpoints + + static func endpoints( + of route: InternalTransferRoute + ) -> (from: ChainNetwork, to: ChainNetwork) { + switch route { + case .coreToShielded: return (.core, .shielded) + case .platformToShielded: return (.platform, .shielded) + case .shieldedToCore: return (.shielded, .core) + case .shieldedToPlatform: return (.shielded, .platform) + case .coreToPlatform: return (.core, .platform) + case .platformToCore: return (.platform, .core) + } + } + + /// Full balance names for the summary rows โ€” longer than + /// `ChainNetwork.balanceName`, which labels a card rather than a sentence. + static func balanceName(_ network: ChainNetwork) -> String { + switch network { + case .core: + return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") + case .platform: + return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") + case .shielded: + return NSLocalizedString("Shielded balance", comment: "") + } + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferToastModifier.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferToastModifier.swift new file mode 100644 index 0000000000..57284278cb --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/InternalTransferToastModifier.swift @@ -0,0 +1,103 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +extension View { + /// Announces an internal transfer's progress on whichever surface the + /// user is looking at. + /// + /// The confirm sheet no longer waits for the transfer โ€” it hands off and + /// closes โ€” so nothing owned by the transfer is on screen when the outcome + /// arrives. This is where it surfaces instead. + func internalTransferToast(runner: InternalTransferRunner) -> some View { + modifier(InternalTransferToastModifier(runner: runner)) + } +} + +private struct InternalTransferToastModifier: ViewModifier { + @ObservedObject var runner: InternalTransferRunner + + /// Long enough to read a failure reason, which is the only one that says + /// something the history will not. + private static let duration: TimeInterval = 3 + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if let notice = runner.notice { + DashUIKit.Toast(style: style(for: notice), message: message(for: notice)) + .padding(.horizontal, 20) + .padding(.bottom, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: runner.notice) + .task(id: runner.notice) { + guard runner.notice != nil else { return } + + // A notice raised while this screen was away has already spent + // its life unseen โ€” announcing it now would date-stamp an old + // outcome as current. Anything still inside its window is fair + // to show, for the rest of that window. + let age = runner.noticeRaisedAt.map { Date().timeIntervalSince($0) } ?? 0 + guard age < Self.duration else { + runner.notice = nil + return + } + try? await Task.sleep(for: .seconds(Self.duration - age)) + // Cancelled when the notice changes or the view goes away, so + // a later one restarts the countdown instead of being cut + // short by the previous one. + guard !Task.isCancelled else { return } + runner.notice = nil + } + } + + private func style(for notice: InternalTransferRunner.Notice) -> ToastStyle { + switch notice { + case .started: return .loading + case .busy: return .warning + case .succeeded, .submitted: return .success + case .failed: return .error + } + } + + private func message(for notice: InternalTransferRunner.Notice) -> String { + switch notice { + case .started: + return NSLocalizedString( + "Transfer started", + comment: "Internal transfer handed to the background runner") + case .busy: + return NSLocalizedString( + "Another transfer is still in progress", + comment: "A second internal transfer was started before the first finished") + case .succeeded: + return NSLocalizedString( + "Transfer complete", + comment: "") + case .submitted: + return NSLocalizedString( + "Transfer sent โ€” waiting for the network to confirm it", + comment: "Internal transfer accepted but not yet confirmed") + case .failed(let reason): + return reason + } + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferConfirmSummary.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferConfirmSummary.swift new file mode 100644 index 0000000000..cece940d99 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferConfirmSummary.swift @@ -0,0 +1,89 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// The four rows of the confirm sheet's summary card, resolved for whichever +/// of the three transfers is being confirmed. +/// +/// The sheet takes a balance route, an identity top-up or an identity +/// withdrawal, and exactly one is ever set. Each row used to pick between them +/// on its own, so the same three-way branch was written out four times and a +/// fourth transfer kind would have to find all four. Resolving them together +/// puts that decision in one place โ€” and makes the "exactly one" assumption +/// something a reader can check at a glance rather than infer. +/// +/// Every figure fails closed: an unavailable estimate becomes an em dash +/// rather than a number the sheet cannot stand behind. `canContinue` has +/// already refused the transfer by the time that could matter. +@MainActor +struct TransferConfirmSummary { + /// Placeholder for a figure that cannot be produced. + static let unavailable = "โ€”" + + let from: String + let to: String + let networkFee: String + let total: String + + init( + route: InternalTransferRoute?, + identityTopUp: IdentityTopUpTransfer?, + identityWithdrawal: IdentityWithdrawalTransfer?, + dashDuffs: Int64, + amountDuffsUnsigned: UInt64, + withdrawalFeeCredits: UInt64? + ) { + let identityName = InternalTransferSummaryFigures.identityEndpointName + + if let identityTopUp { + from = InternalTransferSummaryFigures.balanceName(identityTopUp.source) + to = identityName + networkFee = InternalTransferSummaryFigures.identityTopUpFeeFiat( + source: identityTopUp.source) ?? Self.unavailable + total = InternalTransferSummaryFigures.identityTopUpTotal(dashDuffs: dashDuffs) + return + } + + if let identityWithdrawal { + from = identityName + to = InternalTransferSummaryFigures.balanceName(identityWithdrawal.target.network) + networkFee = InternalTransferSummaryFigures.identityWithdrawalFeeFiat ?? Self.unavailable + total = InternalTransferSummaryFigures.identityTopUpTotal(dashDuffs: dashDuffs) + return + } + + guard let route else { + from = Self.unavailable + to = Self.unavailable + networkFee = Self.unavailable + total = Self.unavailable + return + } + + let endpoints = InternalTransferSummaryFigures.endpoints(of: route) + from = InternalTransferSummaryFigures.balanceName(endpoints.from) + to = InternalTransferSummaryFigures.balanceName(endpoints.to) + networkFee = InternalTransferSummaryFigures.networkFeeFiat( + route: route, + withdrawalFeeCredits: withdrawalFeeCredits) ?? Self.unavailable + total = InternalTransferSummaryFigures.totalLeavingSource( + route: route, + dashDuffs: dashDuffs, + amountDuffsUnsigned: amountDuffsUnsigned) ?? Self.unavailable + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferSummaryCard.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferSummaryCard.swift new file mode 100644 index 0000000000..f6e3d9afc4 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Confirm/TransferSummaryCard.swift @@ -0,0 +1,113 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The confirm sheet's four-row summary: where the money leaves, where it +/// lands, what the network charges, and what the source is out in total. +/// +/// Strings in, rows out. Which transfer produced them โ€” a balance route or +/// either direction of an identity transfer โ€” is `TransferConfirmSummary`'s +/// problem, so the card can be looked at on its own in the canvas, including +/// the em-dash states that are otherwise only reachable without a wallet. +struct TransferSummaryCard: View { + let from: String + let to: String + let networkFee: String + let total: String + + init(summary: TransferConfirmSummary) { + self.init( + from: summary.from, + to: summary.to, + networkFee: summary.networkFee, + total: summary.total) + } + + init(from: String, to: String, networkFee: String, total: String) { + self.from = from + self.to = to + self.networkFee = networkFee + self.total = total + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + row(label: NSLocalizedString("From", comment: ""), value: from) + row(label: NSLocalizedString("To", comment: ""), value: to) + row(label: NSLocalizedString("Network fee", comment: ""), value: networkFee) + row(label: NSLocalizedString("Total", comment: ""), value: total) + } + .modifier(MenuViewModifier()) + } + + /// `MenuItem` with the value as its text accessory โ€” the same pairing the + /// design system uses for a "Network fee" row. Note this puts the label on + /// the prominent line and the value on the muted one, which is the + /// system's convention and the reverse of what this card drew by hand. + private func row(label: String, value: String) -> some View { + DashUIKit.MenuItem(title: label, accessory: .text(value)) + } +} + +#if DEBUG + +private func summaryCardSample( + from: String = "Transparent balance", + to: String = "Shielded balance", + networkFee: String = "~ $0.08", + total: String = "0.5 DASH" +) -> some View { + TransferSummaryCard(from: from, to: to, networkFee: networkFee, total: total) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Balance route") { + summaryCardSample() +} + +/// Either identity direction: one endpoint is the identity rather than a +/// balance, and the withdrawal has no fee estimate to print. +@available(iOS 17, *) +#Preview("To Identity") { + summaryCardSample(from: "Shielded balance", to: "Identity") +} + +@available(iOS 17, *) +#Preview("From Identity") { + summaryCardSample( + from: "Identity", + to: "Transparent balance", + networkFee: TransferConfirmSummary.unavailable) +} + +/// Every figure unavailable โ€” what the card shows when the SDK cannot price +/// the transfer. +@available(iOS 17, *) +#Preview("Nothing to show") { + summaryCardSample( + from: TransferConfirmSummary.unavailable, + to: TransferConfirmSummary.unavailable, + networkFee: TransferConfirmSummary.unavailable, + total: TransferConfirmSummary.unavailable) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/IdentityWithdrawViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/IdentityWithdrawViewModel.swift new file mode 100644 index 0000000000..85b6679602 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/IdentityWithdrawViewModel.swift @@ -0,0 +1,174 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftDashSDK + +/// Business side of moving credits OUT of the DashPay identity โ€” the mirror +/// of `IdentityTopUpViewModel`: one PIN/biometric gate, then the single state +/// transition the chosen target needs. +/// +/// - `.transparent`: `withdrawCredits` โ€” an IdentityCreditWithdrawal that +/// pays out to the wallet's own next Core receive address. The L1 output +/// arrives asynchronously once the network processes the withdrawal, so +/// this returns before the Dash is spendable. +/// - `.platform`: `transferCreditsToAddresses` โ€” a credit transfer to the +/// wallet's own next Platform receive address. Stays on Platform, so it +/// settles as soon as the transition is accepted. +/// +/// Shielded is absent by construction: `IdentityWithdrawalTarget` has no case +/// for it, because no single transition moves identity credits into the +/// Orchard pool. +@MainActor +final class IdentityWithdrawViewModel: ObservableObject { + + @Published var isProcessing = false + @Published var errorMessage: String? + + private let authorizer = DWIdentityAuthorizer() + + enum WithdrawError: LocalizedError { + case noCoreReceiveAddress + case noPlatformReceiveAddress + + var errorDescription: String? { + switch self { + case .noCoreReceiveAddress: + return NSLocalizedString( + "No receive address is available yet โ€” reopen the wallet and try again.", + comment: "Identity withdrawal โ€” missing Core payout address") + case .noPlatformReceiveAddress: + return NSLocalizedString( + "No Platform receive address is available yet โ€” wait for the Platform sync to finish and try again.", + comment: "Identity top-up sheet โ€” missing unshield destination") + } + } + } + + /// Consensus floor for an IdentityCreditWithdrawal, mirroring + /// `platform_version.system_limits.min_withdrawal_amount` โ€” 1000 duffs + /// since protocol v12 (raised from 190). Below it the resulting Core + /// `TxOut` would be dust and the transition is rejected outright, so the + /// screen refuses the amount before Confirm rather than letting the + /// network do it. + /// + /// Applies to `.transparent` only: a credit transfer produces no L1 + /// output and carries no equivalent protocol floor. + static let minimumWithdrawalCredits: UInt64 = 1_000_000 + + /// Held back from the identity balance so the transition can pay its own + /// fee, which is charged to the identity on top of the amount. + /// + /// The SDK exposes no fee estimator for either of these transitions โ€” the + /// same gap `PlatformPaymentIdentityFundingPolicy` documents for identity + /// funding โ€” so this reuses that policy's reserve rather than repeating + /// its measurement: both bound an identity-signed transition whose + /// observed base fee is ~0.0004 DASH, and only the real fee is deducted. + /// + /// TODO(SwiftDashSDK): replace with the SDK's own estimate once one is + /// exposed for IdentityCreditWithdrawal / identity credit transfer. + static var feeHeadroomCredits: UInt64 { + PlatformPaymentIdentityFundingPolicy.feeHeadroomCredits + } + + /// The largest amount `balanceCredits` can send: everything above the fee + /// reserve. Zero when the balance cannot cover the reserve at all. + static func spendableCredits(balanceCredits: UInt64) -> UInt64 { + balanceCredits > feeHeadroomCredits ? balanceCredits - feeHeadroomCredits : 0 + } + + /// True on success. False = cancelled at the PIN prompt (no + /// `errorMessage`) or failed (`errorMessage` carries the reason) โ€” the + /// same contract as `IdentityTopUpViewModel.topUp`'s nil. + func withdraw( + identityId: Data, + amountCredits: UInt64, + target: IdentityWithdrawalTarget + ) async -> Bool { + guard !isProcessing else { return false } + guard let wallet = SwiftDashSDKHost.shared.wallet, + let modelContainer = SwiftDashSDKHost.shared.modelContainer else { + errorMessage = NSLocalizedString("Wallet is not ready", comment: "DashPay") + return false + } + isProcessing = true + defer { isProcessing = false } + + do { + try await authorizer.authorize() + } catch { + // Backing out of the PIN prompt is not an error state. + return false + } + + do { + let signer = KeychainSigner(modelContainer: modelContainer) + switch target { + case .transparent: + guard let address = SwiftDashSDKReceiveAddressReader.receiveAddress(), + !address.isEmpty else { + throw WithdrawError.noCoreReceiveAddress + } + try await wallet.withdrawCredits( + identityId: identityId, + amount: amountCredits, + toAddress: address, + signer: signer) + + case .platform: + try await transferToOwnPlatformAddress( + identityId: identityId, + amountCredits: amountCredits, + wallet: wallet, + signer: signer) + } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// Credits โ†’ the wallet's own next Platform receive address. The address + /// is parsed the same way the shielded top-up parses its unshield + /// destination: 21 storage bytes, the first being the address type. + private func transferToOwnPlatformAddress( + identityId: Data, + amountCredits: UInt64, + wallet: ManagedPlatformWallet, + signer: KeychainSigner + ) async throws { + guard let destination = PlatformAddressSyncCoordinator.shared + .derivedAddresses.nextReceiveAddress?.address, + !destination.isEmpty, + let storageBytes = AddressTransformer.parseBech32mAddress(destination), + storageBytes.count == 21 else { + // Typed so the generic catch surfaces THIS message instead of + // overwriting it with an unrelated one. + throw WithdrawError.noPlatformReceiveAddress + } + let recipient = PlatformAddressCreditOutput( + addressType: storageBytes[storageBytes.startIndex], + hash: Data(storageBytes.dropFirst()), + credits: amountCredits) + try await wallet.transferCreditsToAddresses( + fromIdentityId: identityId, + recipients: [recipient], + signer: signer) + Task { await PlatformAddressSyncCoordinator.shared.syncNow() } + } +} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift deleted file mode 100644 index 4f27594dae..0000000000 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ /dev/null @@ -1,937 +0,0 @@ -// -// InternalTransferConfirmSheet.swift -// DashWallet -// - -import SwiftUI -import DashUIKit -import SwiftDashSDK - -/// Confirmation half-sheet shown when the user taps `Continue` on the -/// Internal transfer screen. Body swaps based on the embedded -/// `ShieldedTransferCoordinator.phase`: -/// - `.idle` โ†’ summary card + Cancel/Confirm buttons. -/// - in-flight phases โ†’ step checklist (Signing / Locking / Proving / -/// Broadcasting). Drag-dismiss is disabled. -/// - `.success` โ†’ green check + amount + Done. -/// - `.failed(msg)` โ†’ summary card with red error + Try again / Close. -/// -/// Confirm executes `route` via the coordinator: -/// - `.coreToShielded` โ†’ `performAssetLock(recipientAmountDuffs:)` -/// - `.platformToShielded` โ†’ `performShield(amountCredits:)` -/// - `.shieldedToCore` โ†’ `performWithdraw(amountCredits:)` -/// - `.shieldedToPlatform` โ†’ `performUnshield(amountCredits:)` -/// - `.coreToPlatform` โ†’ `performFundPlatform(amountDuffs:)` -/// - `.platformToCore` โ†’ `performPlatformWithdrawAll()` (full balance) -struct InternalTransferConfirmSheet: View { - - let route: InternalTransferRoute - let dashDuffs: Int64 - let amountDuffsUnsigned: UInt64 - let creditsAmount: UInt64 - let fiatText: String - /// Preflighted `AddressCreditWithdrawalTransition` fee โ€” only meaningful - /// for `.platformToCore` (the fee headroom / netting basis). - var withdrawalFeeCredits: UInt64? = nil - /// `.platformToCore` only: the amount equals the full-balance net payout, - /// so Confirm runs the AUTO (all-addresses) withdrawal instead of the - /// single-input partial form. - var isFullPlatformWithdrawal: Bool = false - /// Shielded reverse routes only: execute the note-aware Max plan and - /// revalidate it immediately before proving. - var isFullShieldedSweep: Bool = false - /// Frozen with the submitted amount so a capacity refresh can update only - /// a value the user explicitly derived via Platform Shield Max. - var platformShieldAmountWasMax: Bool = false - var onCancel: () -> Void - var onCompleted: () -> Void - var onPlatformShieldCapacityChanged: (UInt64?, Bool) -> Void - - @StateObject private var coordinator = ShieldedTransferCoordinator() - @State private var handledPlatformShieldCapacityChange = false - - 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) - .onChange(of: coordinator.phase) { phase in - handlePlatformShieldCapacityChange(phase) - } - } - - 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) - - secondaryLine - .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 { - privacyTipCard - .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: - progressChecklist - .padding(.horizontal, 24) - .padding(.bottom, 24) - - case .success, .submittedUnconfirmed: - // Handled by `successBody` / `ShieldedSubmittedUnconfirmedView`. - 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("Transfer complete", comment: "")) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - - DashAmount( - amount: dashDuffs, - font: .title, - dashSymbolFactor: 0.7, - showDirection: false) - - secondaryLine - - 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 secondaryLine: some View { - Text(fiatText) - .font(.subheadline) - .foregroundColor(.dash.secondaryText) - } - - // 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, computed offline by - /// the SDK against the latest protocol version (so it matches the fee the - /// SDK will charge). `nil` if the estimate is unavailable โ†’ the row - /// shows "โ€”". - private var networkFeeCredits: UInt64? { - switch route { - case .coreToShielded: - // The lock charges the fee rounded UP to a whole duff โ€” display - // that, so Amount + Network fee equals Total exactly. - return CoreToShieldedAmountPolicy.currentPoolFeeDuffs.map { $0 * 1000 } - case .platformToShielded: - // Shield (Type 15): base shielded fee. Real metered storage is - // extra and only knowable on-chain, so this is a lower bound. - return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) - case .shieldedToCore: - return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) - case .shieldedToPlatform: - return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) - case .coreToPlatform: - // Address-funding asset lock: the required processing balance - // (the same 50k-duff base the Rust side reserves for address - // funding). The funding ST's metered fee is extra and only - // knowable on-chain, so this is a lower bound. - return CoreToShieldedAmountPolicy.assetLockBaseCostCredits - case .platformToCore: - // The exact transition fee the preflight already netted out of - // the payout amount. - return withdrawalFeeCredits - } - } - - /// Network-fee estimate as fiat (e.g. "~ $0.08"), or "โ€”" if unavailable. - private var networkFeeString: String { - guard let credits = networkFeeCredits else { return "โ€”" } - let dash = Decimal(credits) / Self.creditsPerDash - return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) - } - - /// What actually leaves the source balance. Coreโ†’Shielded charges the - /// pool fee on top of the amount (the executed lock value); every other - /// route's total is the amount itself. "โ€”" when the fee estimate is - /// unavailable โ€” `canContinue` fails closed before that can be confirmed, - /// but the row must never show the un-inflated number. - private var totalString: String { - guard route == .coreToShielded else { - return dashDuffs.formattedDashAmount - } - guard let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits, - let lockDuffs = CoreToShieldedAmountPolicy.lockValueDuffs( - forAmountDuffs: amountDuffsUnsigned, - poolFeeCredits: poolFeeCredits), - let signedLockDuffs = Int64(exactly: lockDuffs) - else { return "โ€”" } - return signedLockDuffs.formattedDashAmount - } - - - // MARK: - Summary card - - private var summaryCard: some View { - VStack(spacing: 0) { - summaryRow( - label: NSLocalizedString("From", comment: ""), - value: fromLabel) - divider - summaryRow( - label: NSLocalizedString("To", comment: ""), - value: toLabel) - divider - summaryRow( - label: NSLocalizedString("Network fee", comment: ""), - value: networkFeeString) - divider - summaryRow( - label: NSLocalizedString("Total", comment: ""), - value: totalString) - } - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - /// From/To endpoint names for the summary card, straight off the route. - private var fromLabel: String { Self.balanceName(routeEndpoints.from) } - private var toLabel: String { Self.balanceName(routeEndpoints.to) } - - private var routeEndpoints: (from: ChainNetwork, to: ChainNetwork) { - switch route { - case .coreToShielded: return (.core, .shielded) - case .platformToShielded: return (.platform, .shielded) - case .shieldedToCore: return (.shielded, .core) - case .shieldedToPlatform: return (.shielded, .platform) - case .coreToPlatform: return (.core, .platform) - case .platformToCore: return (.platform, .core) - } - } - - private static func balanceName(_ network: ChainNetwork) -> String { - switch network { - case .core: - return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") - case .platform: - return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") - case .shielded: - return NSLocalizedString("Shielded balance", comment: "") - } - } - - private func summaryRow(label: String, value: String) -> some View { - summaryRow( - label: label, - valueView: AnyView( - Text(value) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText))) - } - - private func summaryRow(label: String, valueView: AnyView) -> some View { - HStack { - Text(label) - .font(.system(size: 14)) - .foregroundColor(.dash.secondaryText) - Spacer() - valueView - } - .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: - Privacy tip - - private var privacyTipCard: some View { - HStack(alignment: .top, spacing: 12) { - ZStack { - Circle() - .fill(Color.dash.blue) - .frame(width: 30, height: 30) - Image(systemName: privacyTipIcon) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - } - - VStack(alignment: .leading, spacing: 4) { - Text(privacyTipTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Text(privacyTipBody) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - } - .padding(14) - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - /// The tip card is route-aware: - /// - to Shielded (either source): privacy nudge. - /// - Shielded โ†’ Core (L1 withdraw): up-to-10-minute spend delay. - /// - Shielded โ†’ Platform (unshield) / Core โ†’ Platform: settles fast. - /// - Platform โ†’ Core: full-balance withdrawal + network processing delay. - private var privacyTipIcon: String { - switch route { - case .shieldedToCore, .platformToCore: return "clock.fill" - default: return "shield.fill" - } - } - - private var privacyTipTitle: String { - switch route { - case .shieldedToCore: - return NSLocalizedString("Up to 10 minutes to spend", comment: "") - case .platformToCore: - return isFullPlatformWithdrawal - ? NSLocalizedString("Withdraws the entire balance", comment: "Full-balance Platform โ†’ Transparent withdrawal") - : NSLocalizedString("Processing time", comment: "Platform โ†’ Transparent withdrawal") - default: - return NSLocalizedString("Privacy tip", comment: "") - } - } - - private var privacyTipBody: String { - switch route { - case .coreToShielded, .platformToShielded: - return NSLocalizedString( - "For best privacy, wait at least 2 hours before using these funds.", - comment: "") - case .shieldedToCore: - return NSLocalizedString( - "After this transfer, it can take up to 10 minutes before you can use your Dash. This delay is part of how your privacy is protected.", - comment: "") - case .shieldedToPlatform: - // Unshield to Platform Payment settles quickly. - return NSLocalizedString( - "These funds move to your Platform Payment balance and are ready to spend right away.", - comment: "") - case .coreToPlatform: - return NSLocalizedString( - "These funds move to your Platform balance and are ready to spend as soon as the transfer completes.", - comment: "") - case .platformToCore: - return isFullPlatformWithdrawal - ? NSLocalizedString( - "This withdraws your entire Platform balance in one transfer. The Dash arrives in your Transparent balance once the network processes the withdrawal.", - comment: "") - : NSLocalizedString( - "The Dash arrives in your Transparent balance once the network processes the withdrawal.", - comment: "") - } - } - - // MARK: - Progress checklist - - /// Vertical step checklist for the in-flight phases. Only the forward - /// asset-lock route has the `.locking` stage; the transparent shield and - /// the reverse withdraw routes hide it because the FFI doesn't surface that - /// intermediate step. Rendering is delegated to the shared - /// `ShieldedTransferStepList` (also used by `ShieldedRecoverySheet`). - private var progressChecklist: some View { - ShieldedTransferStepList(currentPhase: coordinator.phase, steps: progressSteps) - } - - private var progressSteps: [ShieldedTransferStepList.Step] { - var steps: [ShieldedTransferStepList.Step] = [ - .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing) - ] - // Asset-lock routes have the on-chain locking stage. - if route == .coreToShielded || route == .coreToPlatform { - steps.append(.init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking)) - } - // Only shielded legs build an Orchard proof. - switch route { - case .coreToShielded, .platformToShielded, .shieldedToCore, .shieldedToPlatform: - steps.append(.init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving)) - case .coreToPlatform, .platformToCore: - break - } - steps.append(.init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting)) - return steps - } - - // MARK: - Actions - - private func confirm() { - Task { - switch route { - case .coreToShielded: - await coordinator.performAssetLock(recipientAmountDuffs: amountDuffsUnsigned) - case .platformToShielded: - await coordinator.performShield(amountCredits: creditsAmount) - case .shieldedToCore: - await coordinator.performWithdraw( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep) - case .shieldedToPlatform: - await coordinator.performUnshield( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep) - case .coreToPlatform: - await coordinator.performFundPlatform(amountDuffs: amountDuffsUnsigned) - case .platformToCore: - await coordinator.performPlatformWithdraw( - amountCredits: creditsAmount, - fullBalance: isFullPlatformWithdrawal, - feeHeadroomCredits: withdrawalFeeCredits) - } - } - } - - private func tryAgain() { - // If the just-failed asset-lock attempt (Coreโ†’Shielded or - // Coreโ†’Platform) already committed a lock, RESUME that exact outpoint - // instead of building a second lock (which strands the first). - // Capture before reset() clears it. Every other case (no committed - // lock โ€” auth-cancel / preflight failure โ€” or a non-asset-lock route) - // falls through to a fresh retry. - if let op = coordinator.lastAssetLockOutPoint { - switch route { - case .coreToShielded: - coordinator.reset() - Task { await coordinator.resumeAssetLock(outPointTxidWire: op.txidWire, outPointVout: op.vout) } - return - case .coreToPlatform: - coordinator.reset() - Task { await coordinator.resumeFundPlatform(outPointTxidWire: op.txidWire, outPointVout: op.vout) } - return - default: - break - } - } - coordinator.reset() - confirm() - } - - private func handlePlatformShieldCapacityChange( - _ phase: ShieldedTransferCoordinator.Phase - ) { - guard !handledPlatformShieldCapacityChange, - case .failed = phase, - let error = coordinator.lastFailure as? ShieldedTransferCoordinator.CoordinatorError, - case .platformShieldCapacityChanged(let maxShieldableCredits) = error - else { return } - - handledPlatformShieldCapacityChange = true - onPlatformShieldCapacityChanged( - maxShieldableCredits, - platformShieldAmountWasMax) - } -} - -/// Recovery sheet for a stuck "to Shielded" transfer (Coreโ†’Shielded). The -/// transfer's L1 asset lock is committed on-chain but the shield state -/// transition never landed, so the funds sit on an unconsumed -/// `PersistentAssetLock` โ€” recoverable, not lost. "Finish now" resumes that -/// exact outpoint via `ShieldedTransferCoordinator.resumeAssetLock` -/// (re-auth โ†’ Orchard proof โ†’ ShieldFromAssetLock ST โ†’ consume), rather than -/// building a second lock. -/// -/// Presented from the home tx list when the user taps a row flagged -/// `Transaction.isPendingShieldedTransfer`. Owns its own coordinator so it is -/// independent of any live confirm-sheet flow. -struct ShieldedRecoverySheet: View { - - let transaction: Transaction - var onDismiss: () -> Void - - @StateObject private var coordinator = ShieldedTransferCoordinator() - - /// Set when "Finish now" finds the lock already consumed (a background sync - /// landed the shield since the history row's snapshot was captured) โ€” shows - /// the success state immediately instead of paying for a doomed ~30s proof. - @State private var alreadyComplete = false - - var body: some View { - VStack(spacing: 0) { - dragHandle - .padding(.top, 8) - - Text(NSLocalizedString("Finish shielded transfer", comment: "InternalTransfer recovery")) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - .padding(.top, 20) - - switch coordinator.phase { - case .success: - successBody - case .submittedUnconfirmed: - ShieldedSubmittedUnconfirmedView(onDone: onDismiss) - case .signing, .locking, .proving, .broadcasting: - inFlightBody - default: - if alreadyComplete { - successBody - } else { - idleOrFailedBody - } - } - } - .background(Color.dash.primaryBackground) - .interactiveDismissDisabled(isInFlight) - } - - private var isInFlight: Bool { - switch coordinator.phase { - case .signing, .locking, .proving, .broadcasting: - return true - default: - return false - } - } - - // MARK: - Bodies - - private var idleOrFailedBody: some View { - VStack(spacing: 0) { - DashAmount( - amount: Int64(transaction.dashAmount), - font: .largeTitle, - dashSymbolFactor: 0.7, - showDirection: false) - .padding(.top, 14) - - infoCard - .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) - } - - Spacer(minLength: 12) - - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Finish now", comment: "InternalTransfer recovery"), - positiveButtonAction: finish, - negativeButtonText: NSLocalizedString("Close", comment: ""), - negativeButtonAction: onDismiss) - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } - - private var inFlightBody: some View { - VStack(alignment: .leading, spacing: 16) { - // Same stepped checklist as the original transfer so the user sees - // what's happening during the (~30s+) Orchard proof build. Resume - // skips `.locking` (the lock is already on-chain), so only - // Authorizing โ†’ Generating proof โ†’ Broadcasting are shown. - ShieldedTransferStepList( - currentPhase: coordinator.phase, - steps: [ - .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing), - .init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving), - .init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting), - ]) - - Text(NSLocalizedString( - "Building the privacy proof can take up to a minute. Keep the app open.", - comment: "InternalTransfer recovery")) - .font(.caption) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 12) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 24) - .padding(.bottom, 24) - } - - 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("Transfer complete", comment: "")) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - - DashAmount( - amount: Int64(transaction.dashAmount), - font: .title, - dashSymbolFactor: 0.7, - showDirection: false) - - Spacer(minLength: 12) - - DashButton( - text: NSLocalizedString("Done", comment: ""), - style: .filled, - stretch: true, - action: onDismiss) - .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 infoCard: some View { - HStack(alignment: .top, spacing: 12) { - ZStack { - Circle() - .fill(Color.dash.blue) - .frame(width: 30, height: 30) - Image(systemName: "shield.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - } - - VStack(alignment: .leading, spacing: 4) { - Text(NSLocalizedString("Your Dash is safe", comment: "InternalTransfer recovery")) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Text(NSLocalizedString( - "This transfer's funds were locked on-chain but the private transfer didn't finish. Tap Finish now to complete it.", - comment: "InternalTransfer recovery")) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - } - .padding(14) - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - // MARK: - Action - - private func finish() { - guard let op = transaction.shieldedOutPoint else { - onDismiss() - return - } - Task { @MainActor in - // Re-check live status before paying for a ~30s Orchard proof: a - // background shielded sync may have consumed this lock since the - // history row's snapshot was captured. The resume FFI builds the - // full proof before Platform reports "already consumed", so without - // this guard a just-completed transfer would dead-end after ~30s. - ShieldedTxLookup.shared.refresh() - let displayTxid = op.txidWire.reversed().map { String(format: "%02x", $0) }.joined() - let statusRaw = ShieldedTxLookup.shared.info(forTxidHex: displayTxid)?.statusRaw - if statusRaw == 4 { - // Already consumed by a background sync since the row snapshot was - // captured โ†’ it's done; show success without a doomed ~30s resume. - alreadyComplete = true - return - } - // statusRaw 1...3 (still pending), 5 (Core-final โ€” consumption - // unknown), or nil/0 (status unavailable, e.g. a failed refresh): - // attempt the resume. Both a local Consumed tombstone and a remote - // already-consumed report map to unconfirmed rather than false success. - await coordinator.resumeAssetLock(outPointTxidWire: op.txidWire, outPointVout: op.vout) - // On success the shield ST consumed the lock; refresh the snapshot so - // the history row flips pending โ†’ completed even before the next - // scheduled shielded sync pass lands. - if case .success = coordinator.phase { - ShieldedTxLookup.shared.refresh() - } - } - } -} - -/// Vertical step checklist shared by the shielded transfer confirm sheet and -/// the recovery sheet. Each `Step` maps to the -/// `ShieldedTransferCoordinator.Phase` it represents; the row's -/// done/active/pending state is derived from where the current phase sits in -/// the canonical ordering (signing โ†’ locking โ†’ proving โ†’ broadcasting โ†’ -/// success). A terminal `.idle`/`.failed` phase renders every step pending (the -/// host sheet surfaces the summary/error separately). -struct ShieldedTransferStepList: View { - struct Step: Identifiable { - let label: String - let phase: ShieldedTransferCoordinator.Phase - var id: String { label } - } - - /// Pre-resolved (label, state) rows โ€” both inits reduce to this. - private let rows: [(label: String, state: StepState)] - - /// Phase-based rows: each step's done/active/pending state derives from - /// where `currentPhase` sits in the canonical ordering (see type doc). - init(currentPhase: ShieldedTransferCoordinator.Phase, steps: [Step]) { - rows = steps.map { step -> (label: String, state: StepState) in - (label: step.label, state: Self.state(for: step.phase, current: currentPhase)) - } - } - - /// Positional rows, for flows with stages the coordinator phases can't - /// express (e.g. the CoinJoin โ†’ Shielded flow's sweep leg): rows before - /// `currentIndex` are complete, the row at it active, the rest pending. - /// `nil` renders every row pending (idle/failed โ€” the host sheet surfaces - /// the error separately); an index past the last row renders all complete. - init(labels: [String], currentIndex: Int?) { - rows = labels.enumerated().map { index, label -> (label: String, state: StepState) in - guard let current = currentIndex else { return (label: label, state: .pending) } - if index < current { return (label: label, state: .complete) } - if index == current { return (label: label, state: .active) } - return (label: label, state: .pending) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - ForEach(Array(rows.enumerated()), id: \.element.label) { _, row in - stepRow(label: row.label, state: row.state) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private enum StepState { - case pending - case active - case complete - } - - /// Where `phase` sits relative to `current`. The phase enum is ordered - /// .signing โ†’ .locking โ†’ .proving โ†’ .broadcasting โ†’ .success, so a numeric - /// comparison drives the state. - private static func state( - for phase: ShieldedTransferCoordinator.Phase, - current: ShieldedTransferCoordinator.Phase - ) -> StepState { - guard let currentIdx = Self.phaseIndex(current), - let targetIdx = Self.phaseIndex(phase) else { - return .pending - } - if targetIdx < currentIdx { return .complete } - if targetIdx == currentIdx { return .active } - return .pending - } - - private static func phaseIndex(_ phase: ShieldedTransferCoordinator.Phase) -> Int? { - switch phase { - case .signing: return 0 - case .locking: return 1 - case .proving: return 2 - case .broadcasting: return 3 - case .success: return 4 - case .idle, .failed, .submittedUnconfirmed: return nil - } - } - - private func stepRow(label: String, state: StepState) -> some View { - HStack(spacing: 12) { - stepIndicator(state: state) - Text(label) - .font(.system(size: 15, weight: state == .active ? .semibold : .regular)) - .foregroundColor(state == .pending ? .dash.secondaryText : .dash.primaryText) - Spacer() - if state == .active { - SwiftUI.ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.7) - } - } - } - - @ViewBuilder - private func stepIndicator(state: StepState) -> some View { - switch state { - case .pending: - Circle() - .stroke(Color.dash.gray300.opacity(0.6), lineWidth: 1.5) - .frame(width: 20, height: 20) - case .active: - ZStack { - Circle() - .stroke(Color.dash.blue, lineWidth: 1.5) - .frame(width: 20, height: 20) - Circle() - .fill(Color.dash.blue) - .frame(width: 10, height: 10) - } - case .complete: - ZStack { - Circle() - .fill(Color.dash.blue) - .frame(width: 20, height: 20) - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(Color.dash.whiteText) - } - } - } -} - -/// Terminal "submitted but unconfirmed" state shared by the shielded transfer -/// confirm sheet and the recovery sheet. Shown when the SDK reports -/// `shieldedSpendUnconfirmed` โ€” the broadcast was accepted by relay but its -/// result isn't yet confirmed, so the user must NOT resend; there is -/// deliberately no "Try again". The transfer resolves via the next shielded -/// sync. -struct ShieldedSubmittedUnconfirmedView: View { - var onDone: () -> Void - - var body: some View { - VStack(spacing: 16) { - Image(systemName: "paperplane.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 56, height: 56) - .foregroundColor(.dash.blue) - .padding(.top, 24) - - Text(NSLocalizedString("Submitted โ€” confirming", comment: "InternalTransfer")) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - - Text(NSLocalizedString( - "Your transfer was broadcast and is confirming on the network. Don't resend it โ€” it will appear once the network confirms it.", - comment: "InternalTransfer")) - .font(.callout) - .foregroundColor(.dash.secondaryText) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 24) - - Spacer(minLength: 12) - - DashButton( - text: NSLocalizedString("Done", comment: ""), - style: .filled, - stretch: true, - action: onDone) - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } -} diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift index 96d4f8acae..d783dcfedc 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift @@ -22,23 +22,65 @@ final class InternalTransferHostingController: UIViewController { viewModel.amountText = "\(prefillDashAmount)" } + /// Opens the form with `destination` already selected as the To endpoint โ€” + /// the payments landing's Internal card picks where a transfer is headed + /// before any amount is typed. + /// + /// A balance destination is preselected, not pinned: the view model moves + /// the From side off it if that is where it currently sits, the From row + /// opens the source picker, and the swap badge can still exchange the + /// two. Identity fixes the To card instead โ€” the source side does not + /// offer identity (the app implements identity top-up but not withdrawal + /// yet), so there is nothing to swap into; the From row still picks among + /// all three balances. + convenience init(transferTo destination: TransferDestination) { + self.init(nibName: nil, bundle: nil) + viewModel.selectStandaloneDestination(destination) + } + private lazy var hostingController: UIHostingController = { - let screen = InternalTransferScreen(viewModel: viewModel) { [weak self] in - // After a successful transfer the user taps "Done" inside the - // confirm sheet; the screen forwards that to us so we can - // leave โ€” pop when pushed (landing / readiness flows), dismiss - // if some future presenter shows this standalone as a sheet. - guard let self else { return } - if let navigationController = self.navigationController { - navigationController.popViewController(animated: true) - } else { - self.dismiss(animated: true) - } + var screen = InternalTransferScreen(viewModel: viewModel) { [weak self] in + // Confirm hands the transfer to `InternalTransferRunner`; the + // screen waits for the confirm sheet to finish closing and then + // calls this, so it fires while the transfer is still running. + // Leave for the history, where the outcome shows up: the home tab + // when this was pushed inside the tab bar, otherwise just pop or + // dismiss whatever presented us. + self?.leaveForHistory() + } + // Only when pushed: presented as a sheet there is nothing to go back + // to, and the grabber is the way out. + if navigationController != nil { + screen.onBack = { [weak self] in self?.leave() } } return UIHostingController(rootView: screen) }() - private var previousNavBarHidden: Bool = false + private func leave(completion: (() -> Void)? = nil) { + if let navigationController { + // `popViewController` takes no completion handler, so the pop's own + // CoreAnimation transaction carries one. + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + navigationController.popViewController(animated: true) + CATransaction.commit() + } else { + dismiss(animated: true, completion: completion) + } + } + + /// Back to where the transfer becomes visible. The runner announces the + /// outcome there, and โ€” for the routes that write one โ€” the history row + /// carries it. + /// + /// The tab change waits for the pop: run together, the stack slid back + /// while the tab swapped underneath it, and the two read as one jolt. Held + /// onto beforehand because popping detaches this controller, and + /// `tabBarController` is nil by the time the completion runs. + private func leaveForHistory() { + let tabBarController = tabBarController as? MainTabbarController + leave { tabBarController?.showHome() } + } override func viewDidLoad() { super.viewDidLoad() @@ -48,9 +90,9 @@ final class InternalTransferHostingController: UIViewController { hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.backgroundColor = .clear view.addSubview(hostingController.view) - // Pushed: the navigation bar provides the top spacing. Presented - // as a sheet: there is no bar, so inset the content below the - // grabber instead of letting the title crowd the sheet's edge. + // Pushed: the screen draws its own `NavigationBar`, which carries the + // top spacing. Presented as a sheet: no bar, so inset the content + // below the grabber instead of letting the title crowd the edge. let isSheet = navigationController == nil let topInset: CGFloat = isSheet ? 28.0 : 0.0 NSLayoutConstraint.activate([ @@ -61,15 +103,14 @@ final class InternalTransferHostingController: UIViewController { ]) hostingController.didMove(toParent: self) } +} - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - previousNavBarHidden = navigationController?.isNavigationBarHidden ?? false - navigationController?.setNavigationBarHidden(false, animated: animated) - } +// MARK: - NavigationBarDisplayable - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - navigationController?.setNavigationBarHidden(previousNavBarHidden, animated: animated) - } +/// The screen draws the design system's `NavigationBar` itself, so the UIKit +/// one has to stay down. Without this, `BaseNavigationController`'s `willShow` +/// pass falls back to showing it and the screen gets two back buttons. +extension InternalTransferHostingController: NavigationBarDisplayable { + var isBackButtonHidden: Bool { true } + var isNavigationBarHidden: Bool { true } } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift index 142f4cc0e9..a13f5c96f9 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift @@ -11,7 +11,9 @@ import DashUIKit /// rewrite the amount already presented for confirmation. private struct InternalTransferConfirmation: Identifiable { let id = UUID() - let route: InternalTransferRoute + /// Balance-to-balance route; `nil` exactly when one of the identity + /// transfers below is set. + let route: InternalTransferRoute? let dashDuffs: Int64 let amountDuffsUnsigned: UInt64 let creditsAmount: UInt64 @@ -20,20 +22,32 @@ private struct InternalTransferConfirmation: Identifiable { let isFullPlatformWithdrawal: Bool let isFullShieldedSweep: Bool let platformShieldAmountWasMax: Bool + /// Identity-destination submission: the top-up the confirm sheet runs + /// instead of a route. + let identityTopUp: IdentityTopUpTransfer? + /// Identity-source submission: the withdrawal the confirm sheet runs + /// instead of a route. Mutually exclusive with `identityTopUp`. + let identityWithdrawal: IdentityWithdrawalTransfer? } struct InternalTransferScreen: View { @ObservedObject var viewModel: InternalTransferViewModel - /// Invoked when the user finishes a successful transfer via the - /// confirm sheet's `Done` button. The hosting controller wires it - /// to `navigationController?.popViewController`. + /// Invoked once the confirm sheet has handed the transfer to the runner. + /// The transfer is still running โ€” the hosting controller closes this + /// screen and returns to the history, where the outcome shows up. var onCompleted: () -> Void = {} /// False when embedded under a host that renders its own title /// (the balance-row receive sheet) โ€” hides the built-in header. var showsHeader: Bool = true + /// Draws the design system's navigation bar with a back button above the + /// title. `nil` for the embedded variants, which sit under the host's own + /// chrome. The host hides the UIKit bar and passes the pop through here, + /// so the back button is `DashUIKit`'s rather than the system's. + var onBack: (() -> Void)? = nil + /// Receive-sheet variant: fixes the destination card (the balance being /// received into) at the bottom, turns the rows above it into the /// source picker, and hides the swap badge. `nil` = the standard @@ -48,25 +62,24 @@ struct InternalTransferScreen: View { @State private var confirmation: InternalTransferConfirmation? - /// Standalone screen: which endpoint's balance picker is presented as - /// the bottom sheet. `nil` = none โ€” the form shows just the two - /// selected-endpoint cards, never all six rows at once. Presentation - /// state only โ€” the selection itself lives in the view model. - @State private var endpointPicker: EndpointGroup? - - private enum EndpointGroup: String, Identifiable { - case from - case to - - var id: String { rawValue } - } + /// Set when Confirm handed the transfer off, consumed by the sheet's + /// `onDismiss`. Leaving the screen is what closes it, and doing that in the + /// same turn as `confirmation = nil` ran the sheet's dismissal and the + /// screen's own over each other โ€” which is what read as being yanked out + /// the moment the PIN was accepted. One animation at a time instead. + @State private var leavesAfterConfirmation = false var body: some View { VStack(spacing: 0) { + if let onBack { + DashUIKit.NavigationBar( + leading: { DashUIKit.NavigationBarElement.back.button(action: onBack) }) + } + if showsHeader { header .padding(.horizontal, 20) - .padding(.top, 10) + .padding(.top, onBack == nil ? 10 : 0) } // Scrollable so the keypad and action button stay fully on @@ -75,38 +88,76 @@ struct InternalTransferScreen: View { // eat ~110pt the standalone layout has to spare. When the // content fits (standalone), this behaves like the old // fixed layout: top-aligned content, keypad pinned below. - ScrollView { - VStack(spacing: 16) { - amountRow - .padding(.horizontal, 20) - .padding(.top, showsHeader ? 12 : 0) - - directionCards - .padding(.horizontal, 20) - - if viewModel.isBlockedBySync { - SyncGateNote() + // + // The reader is what gives that content a viewport height to fill, + // so the preview can sit in the middle of the free space below the + // cards instead of hanging off them. + GeometryReader { proxy in + ScrollView { + VStack(spacing: 16) { + amountRow + // Its own height, not a share of the slack. + // `EnterAmountView` draws the figure inside a + // `maxHeight: .infinity` frame, so in a stack that + // has room to give it is flexible โ€” and a `Spacer` + // does not beat it, it splits with it. That is why + // the amount still drifted down the screen with the + // spacer already in place: both were taking half + // each. `fixedSize` proposes its ideal height and + // leaves every remaining point to the spacer. + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 20) - } + .padding(.top, showsHeader ? 10 : 0) - if let message = viewModel.amountValidationMessage { - TransferAmountValidationNote(message: message) - .padding(.horizontal, 20) - } + TransferEndpointCards( + viewModel: viewModel, + sendFrom: sendFrom, + receiveInto: receiveInto + ) + .padding(.horizontal, 20) - if viewModel.canContinue { - transferPreview - .padding(.horizontal, 20) + // Takes every point `.frame(minHeight:)` below hands + // the stack beyond what the content needs โ€” which is + // what keeps the form at the top rather than floating + // in the middle. + Spacer(minLength: 12) + + if viewModel.canContinue { + // The second spacer is what centres the preview: + // the pair splits the slack evenly. Both collapse + // to their minimum once the content already fills + // the viewport, so the tight receive-sheet layout + // is unchanged. + TransferPreview(amountFormatted: viewModel.dashAmountFormatted) + .padding(.horizontal, 20) + + Spacer(minLength: 12) + } } + // Fill the viewport so those spacers have slack to divide. + // Taller content is unaffected: it exceeds the minimum and + // scrolls exactly as before. + .frame(minHeight: proxy.size.height, alignment: .top) } - .padding(.bottom, 12) + .scrollBounceBehavior(.basedOnSize) } - .scrollBounceBehavior(.basedOnSize) keyboardSection } .background(Color.dash.primaryBackground) - .sheet(item: $confirmation) { submission in + // Over the keypad rather than inline above it: the gate is about the + // whole screen being unusable, not about the amount that was typed. + .conditionToast( + isVisible: viewModel.isBlockedBySync, + style: .loading, + message: NSLocalizedString( + "Wait until the chain is fully synced to make transfers", + comment: "Transfer blocked during a restored wallet's initial sync")) + .sheet(item: $confirmation, onDismiss: { + guard leavesAfterConfirmation else { return } + leavesAfterConfirmation = false + onCompleted() + }) { submission in InternalTransferConfirmSheet( route: submission.route, dashDuffs: submission.dashDuffs, @@ -117,10 +168,12 @@ struct InternalTransferScreen: View { isFullPlatformWithdrawal: submission.isFullPlatformWithdrawal, isFullShieldedSweep: submission.isFullShieldedSweep, platformShieldAmountWasMax: submission.platformShieldAmountWasMax, + identityTopUp: submission.identityTopUp, + identityWithdrawal: submission.identityWithdrawal, onCancel: { confirmation = nil }, - onCompleted: { + onSubmitted: { + leavesAfterConfirmation = true confirmation = nil - onCompleted() }, onPlatformShieldCapacityChanged: { maxCredits, amountWasMax in confirmation = nil @@ -128,8 +181,9 @@ struct InternalTransferScreen: View { maxShieldableCredits: maxCredits, submittedAmountWasMax: amountWasMax) }) - .presentationDetents([.large]) - .presentationDragIndicator(.hidden) + // No detent here: the sheet is `BottomSheet.selfSizing`, which + // sets its own from the measured content, and draws its own + // grabber. } } @@ -153,10 +207,22 @@ struct InternalTransferScreen: View { isPrimarySelected: isDashInputSelected, currencyCodes: amountCurrencyCodes, selectedCurrencyCode: selectedAmountCurrencyCode, - onMax: { viewModel.fillMaxFromWallet() }, + // No Max toward Identity: `route` is a stale balance pair while + // the destination overlay is on, so `fillMaxFromWallet` has no + // ceiling to compute โ€” and emptying a whole balance into credits + // is not an action to invite. The profile sheet's top-up offers + // none either. Max FROM the identity is a different matter: the + // fee reserve is a fixed bound, so that branch does resolve. + // + // TODO(identity-max): price a top-up ceiling per funding source + // and offer Max here too. + onMax: viewModel.isIdentityDestination + ? nil + : { viewModel.fillMaxFromWallet() }, onSwap: toggleAmountUnit, onCurrencyTap: toggleAmountUnit, - onSelectInputType: selectAmountCurrency + onSelectInputType: selectAmountCurrency, + errorMessage: viewModel.amountValidationMessage ) } @@ -171,18 +237,17 @@ struct InternalTransferScreen: View { inProgress: false, actionHandler: presentConfirmation ) - .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 presentConfirmation() { guard viewModel.canContinue else { return } + // Either identity side: no route describes it โ€” the sheet runs the + // identity transfer captured here instead. + let identityTopUp = viewModel.identityTopUpTransfer + let identityWithdrawal = viewModel.identityWithdrawalTransfer + let isIdentityTransfer = identityTopUp != nil || identityWithdrawal != nil confirmation = InternalTransferConfirmation( - route: viewModel.route, + route: isIdentityTransfer ? nil : viewModel.route, dashDuffs: viewModel.dashDuffs, amountDuffsUnsigned: viewModel.dashDuffsUnsigned, creditsAmount: viewModel.creditsPreview, @@ -190,229 +255,13 @@ struct InternalTransferScreen: View { withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, isFullShieldedSweep: viewModel.isFullShieldedSweep, - platformShieldAmountWasMax: viewModel.platformShieldAmountWasMax) + platformShieldAmountWasMax: viewModel.platformShieldAmountWasMax, + identityTopUp: identityTopUp, + identityWithdrawal: identityWithdrawal) } // MARK: - From / To cards - @ViewBuilder - private var directionCards: some View { - if let source = sendFrom { - sendCards(source: source) - } else if let target = receiveInto { - receiveCards(target: target) - } else { - swappableCards - } - } - - /// Send-sheet layout: the source stays pinned as the top card; the rows - /// below pick the destination among the other two balances. No swap - /// badge โ€” the source is fixed by the tapped balance row. - @ViewBuilder - private func sendCards(source: ChainNetwork) -> some View { - VStack(spacing: 12) { - pinnedCard(source, caption: NSLocalizedString("From", comment: "")) - selectionGroup( - caption: NSLocalizedString("To", comment: ""), - networks: availableTargets(for: source), - selected: viewModel.resolvedSendTarget, - onSelect: viewModel.selectSendTarget) - } - } - - /// Non-tappable pinned endpoint card (the fixed From of the send sheet). - private func pinnedCard(_ network: ChainNetwork, caption: String) -> some View { - let display = networkDisplay(network) - return sourceRow( - iconSystemName: display.icon, - caption: caption, - title: display.title, - balanceTrailing: dashBalanceTrailing(display.balance), - selected: false, - showsRadio: false, - action: {}) - } - - /// Icon / title / formatted balance for a balance row, one source of - /// truth for the pinned cards and picker rows. - private func networkDisplay(_ network: ChainNetwork) -> (icon: String, title: String, balance: String) { - switch network { - case .core: - return ("d.circle.fill", network.balanceName, viewModel.coreBalanceFormatted) - case .platform: - return ("creditcard.fill", network.balanceName, viewModel.platformCreditsFormatted) - case .shielded: - return ("shield.fill", network.balanceName, viewModel.shieldedBalanceFormatted) - } - } - - private var swappableCards: some View { - VStack(spacing: 12) { - collapsedEndpointCard(viewModel.source, caption: NSLocalizedString("From", comment: "")) { - presentEndpointPicker(.from) - } - collapsedEndpointCard(viewModel.resolvedSendTarget, caption: NSLocalizedString("To", comment: "")) { - presentEndpointPicker(.to) - } - } - .sheet(item: $endpointPicker) { group in - endpointPickerSheet(for: group) - .presentationDetents([.height(400)]) - .presentationDragIndicator(.visible) - } - } - - private func presentEndpointPicker(_ group: EndpointGroup) { - endpointPicker = group - } - - /// Bottom-sheet balance picker for one endpoint, sized to slide over - /// the keypad area. Every pick applies and dismisses โ€” picking the - /// balance already on the opposite side moves that side to its default - /// (the view model keeps the endpoints distinct). - private func endpointPickerSheet(for group: EndpointGroup) -> some View { - let isFrom = group == .from - return VStack(alignment: .leading, spacing: 16) { - Text(isFrom - ? NSLocalizedString("Transfer from", comment: "Internal transfer source picker title") - : NSLocalizedString("Transfer to", comment: "Internal transfer destination picker title")) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.dash.primaryText) - - selectionGroup( - caption: isFrom - ? NSLocalizedString("From", comment: "") - : NSLocalizedString("To", comment: ""), - networks: ChainNetwork.allCases, - selected: isFrom ? viewModel.source : viewModel.resolvedSendTarget, - onSelect: { network in - if isFrom { - viewModel.selectStandaloneSource(network) - } else { - viewModel.selectStandaloneTarget(network) - } - endpointPicker = nil - }) - - Spacer(minLength: 0) - } - .padding(.horizontal, 20) - .padding(.top, 24) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.dash.primaryBackground) - } - - private func collapsedEndpointCard( - _ network: ChainNetwork, - caption: String, - action: @escaping () -> Void - ) -> some View { - let display = networkDisplay(network) - return TransferSourceRow( - iconSystemName: display.icon, - caption: caption, - title: display.title, - balanceTrailing: dashBalanceTrailing(display.balance), - selected: false, - showsRadio: false, - showsChevron: true, - action: action) - } - - /// Receive-sheet layout: the destination stays pinned as the bottom - /// card; the rows above pick the source among the other two balances. - /// No swap badge โ€” the destination is fixed by the tapped balance row. - @ViewBuilder - private func receiveCards(target: ChainNetwork) -> some View { - VStack(spacing: 12) { - selectionGroup( - caption: NSLocalizedString("From", comment: ""), - networks: availableSources(for: target), - selected: viewModel.resolvedReceiveSource, - onSelect: viewModel.selectReceiveSource) - - pinnedCard(target, caption: NSLocalizedString("To", comment: "")) - } - } - - /// Trailing balance amount + Dash currency glyph, shared by every card. - private func dashBalanceTrailing(_ formatted: String) -> AnyView { - TransferSourceRow.dashBalanceTrailing(formatted) - } - - private func selectionGroup( - caption: String, - networks: [ChainNetwork], - selected: ChainNetwork, - onSelect: @escaping (ChainNetwork) -> Void - ) -> some View { - VStack(spacing: 8) { - ForEach(networks, id: \.self) { network in - let display = networkDisplay(network) - sourceRow( - iconSystemName: display.icon, - caption: caption, - title: display.title, - balanceTrailing: dashBalanceTrailing(display.balance), - selected: selected == network, - action: { onSelect(network) }) - } - } - } - - /// Tappable source row with a trailing radio indicator โ€” rendered by the - /// shared `TransferSourceRow` (also used by the Send screen's From picker). - private func sourceRow( - iconSystemName: String, - caption: String, - title: String, - balanceTrailing: AnyView, - selected: Bool, - showsRadio: Bool = true, - action: @escaping () -> Void - ) -> some View { - TransferSourceRow( - iconSystemName: iconSystemName, - caption: caption, - title: title, - balanceTrailing: balanceTrailing, - selected: selected, - showsRadio: showsRadio, - action: action) - } - - /// Pinned-sheet picker lists (the balance-row arrow sheets): the fixed - /// endpoint's balance is left out entirely โ€” only the standalone screen - /// shows all three with same-balance taps rejected. - private func availableTargets(for source: ChainNetwork) -> [ChainNetwork] { - ChainNetwork.allCases.filter { $0 != source } - } - - private func availableSources(for target: ChainNetwork) -> [ChainNetwork] { - ChainNetwork.allCases.filter { $0 != target } - } - - // MARK: - Transfer preview - - private var transferPreview: some View { - VStack(spacing: 2) { - Text(NSLocalizedString("You will transfer", comment: "")) - .font(.system(size: 12)) - .foregroundColor(Color.dash.secondaryText) - HStack(spacing: 4) { - Text("~ \(viewModel.dashAmountFormatted)") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Image("icon_dash_currency") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 13, height: 13) - } - } - .frame(maxWidth: .infinity) - } - // MARK: - Helpers private var isDashInputSelected: Bool { @@ -476,117 +325,136 @@ struct InternalTransferScreen: View { } } -// MARK: - TransferAmountValidationNote +#if DEBUG + +/// Previews run without a wallet, so anything the SDK has to price is +/// unavailable: the pool-fee routes (`.coreToShielded`) and the shielded +/// reverse routes render their "fee unavailable" validation note instead of an +/// enabled Continue. `.core โ†’ .platform` needs no estimate, so that pair is the +/// default here and the one to use when previewing an enabled action button. +@MainActor +private func transferScreenSample( + source: ChainNetwork = .core, + target: ChainNetwork = .platform, + identityDestination: Bool = false, + amountText: String = "0", + sendFrom: ChainNetwork? = nil, + receiveInto: ChainNetwork? = nil, + showsHeader: Bool = true, + isChainSynced: Bool = true, + isResyncingWallet: Bool = false +) -> some View { + InternalTransferScreen( + viewModel: .makeForPreview( + source: source, + target: target, + sendFrom: sendFrom, + receiveInto: receiveInto, + identityDestination: identityDestination, + amountText: amountText, + isChainSynced: isChainSynced, + isResyncingWallet: isResyncingWallet), + showsHeader: showsHeader, + receiveInto: receiveInto, + sendFrom: sendFrom) +} -/// Inline explanation for a route-specific amount that cannot be submitted. -/// Keeping this next to the amount/source controls prevents a protective SDK -/// build-time refusal from becoming the user's first feedback. -struct TransferAmountValidationNote: View { - let message: String +@available(iOS 17, *) +#Preview("Standalone ยท empty") { + transferScreenSample() +} - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 13)) - .foregroundColor(.orange) - Text(message) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - } +/// Amount entered and affordable: the "You will transfer" line appears and +/// Continue is enabled. +@available(iOS 17, *) +#Preview("Standalone ยท valid amount") { + transferScreenSample(amountText: "0.5") } -// MARK: - TransferSourceRow - -/// Tappable balance row with icon, caption ("From"/"To"), title, trailing -/// balance, and a radio indicator โ€” shared by the internal transfer screen's -/// source picker and the Send screen's From picker. -struct TransferSourceRow: View { - let iconSystemName: String - let caption: String - let title: String - let balanceTrailing: AnyView - let selected: Bool - /// False renders a fixed (non-picker) endpoint card: no radio circle - /// and no selection border. - var showsRadio: Bool = true - /// With `showsRadio` false: true renders a tappable collapsed selector - /// (chevron trailing, expands a picker on tap) instead of a fixed card. - var showsChevron: Bool = false - var action: () -> Void - - /// Trailing balance amount + Dash currency glyph, the standard trailing - /// content for these rows. - static func dashBalanceTrailing(_ formatted: String) -> AnyView { - AnyView( - HStack(spacing: 2) { - Text(formatted) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Image("icon_dash_currency") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - }) - } +/// Above the 2.45 DASH preview balance โ€” the inline insufficient-balance note +/// replaces the transfer preview and Continue stays disabled. +@available(iOS 17, *) +#Preview("Standalone ยท over balance") { + transferScreenSample(amountText: "9.5") +} - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: iconSystemName) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.blue) - .frame(width: 36, height: 36) - .background(Color.dash.blue.opacity(0.08)) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 2) { - Text(caption) - .font(.system(size: 11)) - .foregroundColor(Color.dash.secondaryText) - Text(title) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText) - } +/// Identity destination: the To card pins the identity (no swap badge, no +/// Max), and a valid amount enables Continue โ€” the Core source needs no SDK +/// fee estimate. +@available(iOS 17, *) +#Preview("Standalone ยท to Identity") { + transferScreenSample(identityDestination: true, amountText: "0.05") +} - Spacer() +/// Below the top-up executor's 0.01 DASH floor โ€” the inline note names the +/// minimum and Continue stays disabled. +@available(iOS 17, *) +#Preview("To Identity ยท below minimum") { + transferScreenSample(identityDestination: true, amountText: "0.005") +} - balanceTrailing +/// The gate needs BOTH halves โ€” the restore marker and an unfinished sync. +/// With only one it never appears, which is what this preview used to show. +@available(iOS 17, *) +#Preview("Sync gate") { + transferScreenSample( + amountText: "0.5", + isChainSynced: false, + isResyncingWallet: true) +} - if showsRadio { - radioIndicator - } else if showsChevron { - Image(systemName: "chevron.down") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Color.dash.secondaryText) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(Color.dash.secondaryBackground) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(showsRadio && selected ? Color.dash.blue : Color.clear, - lineWidth: showsRadio && selected ? 1.5 : 0)) - .cornerRadius(12) - } - .buttonStyle(.plain) - .disabled(!showsRadio && !showsChevron) - } +/// Same unfinished sync, but the wallet was not restored โ€” nothing is blocked, +/// and Continue stays live. +@available(iOS 17, *) +#Preview("Syncing ยท not restored") { + transferScreenSample(amountText: "0.5", isChainSynced: false) +} - private var radioIndicator: some View { - ZStack { - Circle() - .stroke(selected ? Color.dash.blue : Color.dash.gray300.opacity(0.6), lineWidth: 1.5) - .frame(width: 18, height: 18) - if selected { - Circle() - .fill(Color.dash.blue) - .frame(width: 10, height: 10) - } - } - } +/// The gate covers the Core-funded routes only: a shielded source transfers +/// during the same sync. +@available(iOS 17, *) +#Preview("Sync gate ยท shielded source") { + transferScreenSample( + source: .shielded, + target: .core, + amountText: "0.1", + isChainSynced: false, + isResyncingWallet: true) +} + +/// Send-sheet embedding: source pinned, host draws its own title. +@available(iOS 17, *) +#Preview("Send sheet ยท no header") { + transferScreenSample( + source: .core, + amountText: "0.5", + sendFrom: .core, + showsHeader: false) +} + +/// Receive-sheet embedding: destination pinned at the bottom. +@available(iOS 17, *) +#Preview("Receive sheet ยท no header") { + transferScreenSample( + source: .core, + target: .platform, + amountText: "0.5", + receiveInto: .platform, + showsHeader: false) } + +@available(iOS 17, *) +#Preview("Dark") { + transferScreenSample(amountText: "0.5") + .preferredColorScheme(.dark) +} + +/// The form scrolls above the pinned keypad โ€” at accessibility sizes the +/// action button must stay reachable rather than being pushed off screen. +@available(iOS 17, *) +#Preview("Large type") { + transferScreenSample(amountText: "0.5") + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index a0aecd858d..2ff120e6f1 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -43,6 +43,67 @@ enum InternalTransferRoute: Equatable { } } +/// Where an internal transfer delivers. The FROM side is a `ChainNetwork` +/// balance; the DashPay identity's credit balance is additionally reachable +/// as a destination. Identity credits are the fuel for Platform state +/// transitions, not a fourth spendable balance, so `ChainNetwork` and +/// `InternalTransferRoute` never learn about it โ€” an identity transfer is +/// executed by the identity top-up, not by a balance-to-balance route. +enum TransferDestination: Equatable, Hashable { + case balance(ChainNetwork) + case identity +} + +/// Where an internal transfer draws from. The mirror of `TransferDestination` +/// โ€” the identity's credit balance is spendable as well as fundable, through +/// its own state transition rather than through an `InternalTransferRoute`. +enum TransferSource: Equatable, Hashable { + case balance(ChainNetwork) + case identity +} + +/// Where identity credits can land in ONE state transition. +/// +/// Shielded has no case on purpose: nothing moves credits from an identity +/// straight into the Orchard pool. Reaching it means withdrawing here first +/// and shielding after, which is two user-visible transfers, not this one. +enum IdentityWithdrawalTarget: CaseIterable, Equatable, Hashable { + /// IdentityCreditWithdrawal โ†’ the wallet's own Core receive address. + case transparent + /// Credit transfer โ†’ the wallet's own Platform receive address. + case platform + + /// `nil` for `.shielded`, the balance no single transition reaches. + init?(_ network: ChainNetwork) { + switch network { + case .core: self = .transparent + case .platform: self = .platform + case .shielded: return nil + } + } + + var network: ChainNetwork { + switch self { + case .transparent: return .core + case .platform: return .platform + } + } +} + +/// Confirm-sheet input for the Identity destination: the identity being +/// topped up and the balance the top-up spends. +struct IdentityTopUpTransfer: Equatable { + let identityId: Data + let source: ChainNetwork +} + +/// Confirm-sheet input for the Identity source: the identity being drawn +/// down and where its credits land. +struct IdentityWithdrawalTransfer: Equatable { + let identityId: Data + let target: IdentityWithdrawalTarget +} + /// Shared Type-18 amount boundary for Core โ†’ Shielded transfers. /// /// The pool fee rides ON TOP of the typed amount: the app locks @@ -140,15 +201,13 @@ enum TransferSpendAmountPolicy { return message(balanceName: balanceName, spendableDuffs: spendableDuffs) } + /// Short on purpose: it is shown in the amount row, in the slot the + /// converted figure occupies. Which balance fell short and by how much is + /// already on screen โ€” the From card carries both. private static func message(balanceName: String, spendableDuffs: UInt64) -> String { - let formattedSpendable = - "\(spendableDuffs.formattedDashAmountWithoutCurrencySymbol) DASH" - return String.localizedStringWithFormat( - NSLocalizedString( - "Insufficient %1$@ balance. Available to send: %2$@", - comment: "Transfer amount exceeds the source balance"), - balanceName, - formattedSpendable) + NSLocalizedString( + "Insufficient balance", + comment: "Transfer amount exceeds the source balance") } } @@ -348,6 +407,56 @@ final class InternalTransferViewModel: ObservableObject { didSet { guard oldValue != receiveSource else { return }; routeDidChange() } } + /// True while the standalone destination is the DashPay identity rather + /// than a balance. An overlay on the balance-route state: `source` stays + /// the spendable FROM balance, `route` keeps meaning balance-to-balance + /// and is not consulted while this is set โ€” validation, Continue and + /// execution go through the identity top-up path instead. Standalone + /// only; the send/receive-pinned variants stay balance-to-balance. + @Published private(set) var isIdentityDestination = false { + didSet { guard oldValue != isIdentityDestination else { return }; routeDidChange() } + } + + /// Standalone screen: the FROM side is the identity's credit balance + /// rather than `source`. Mutually exclusive with `isIdentityDestination` + /// โ€” an identity cannot fund itself. While set, `route` is a stale + /// balance pair and only `identityWithdrawalTransfer` describes the + /// transfer, exactly as with the destination overlay. + @Published private(set) var isIdentitySource = false { + didSet { guard oldValue != isIdentitySource else { return }; routeDidChange() } + } + + /// The 32-byte id of the identity a transfer would top up, loaded when + /// the Identity destination is selected. `nil` when the wallet has no + /// registered identity โ€” affordability then fails closed with an + /// explanatory message instead of offering a transfer with nowhere to go. + /// Whether Platform and the identity are reachable endpoints at all. + /// + /// Advanced mode is what admits them; without it a transfer is Transparent + /// to Shielded and back, which is the pair an ordinary user is offered. + /// + /// Published rather than read at each use: the switch lives in Settings and + /// can be flipped while this screen is on display, and a view that read the + /// flag once would keep offering an endpoint the transfer can no longer + /// reach. `advancedModeDidChange` is the announcement it exists for. + @Published private(set) var isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + + @Published private(set) var identityId: Data? + + /// The identity's own credit balance for the destination card (credits, + /// 1000 per duff) โ€” the persisted row's value, same read the profile + /// sheet renders. + @Published private(set) var identityBalanceCredits: UInt64 = 0 + + /// Ceiling (duffs) for an identity top-up funded from the Platform + /// balance, per the executor's own planner + /// (`PlatformPaymentIdentityFundingPolicy.maxFundableDuffs`). `nil` + /// while the candidates are unread โ€” affordability fails closed. Cached + /// because reading candidates is a SwiftData fetch: refreshed on + /// destination changes and Platform balance publications, not per + /// keystroke. + @Published private(set) var platformIdentityFundableDuffs: UInt64? + /// Live result of `preflightWithdrawal()` for the Platform โ†’ Core route: /// whether a full-balance withdrawal can fund, its net payout, and the /// reserved fee. `nil` while unknown (loading/failed) โ€” affordability @@ -403,6 +512,149 @@ final class InternalTransferViewModel: ObservableObject { source = Self.sanitizedSource(into: network, proposed: source) } + /// Destination-typed standalone pick: a balance keeps the pre-existing + /// endpoint behavior; Identity overlays it without moving the source โ€” + /// every balance is a valid FROM for a top-up, so there is nothing to + /// sanitise away from. + func selectStandaloneDestination(_ destination: TransferDestination) { + switch destination { + case .balance(let network): + isIdentityDestination = false + // With the identity on the FROM side, `source` is not in play and + // `selectStandaloneTarget`'s collision sanitising would move a + // balance the transfer never touches. + if isIdentitySource { + sendTarget = network + } else { + selectStandaloneTarget(network) + } + case .identity: + // An identity cannot fund itself: taking the TO side releases + // the FROM side back to a balance. + isIdentitySource = false + isIdentityDestination = true + refreshIdentitySnapshot() + } + } + + /// Source-typed standalone pick, the mirror of + /// `selectStandaloneDestination`. Identity releases the destination back + /// to a balance and moves it off Shielded, which no single transition + /// reaches from an identity. + func selectStandaloneSource(_ source: TransferSource) { + switch source { + case .balance(let network): + isIdentitySource = false + if isIdentityDestination { + // Top-up mode: every balance is a valid funding source, and + // the TO side is the identity, so there is no collision to + // sanitise. + self.source = network + } else { + selectStandaloneSource(network) + } + case .identity: + isIdentityDestination = false + isIdentitySource = true + sendTarget = Self.sanitizedWithdrawalTarget(sendTarget) + refreshIdentitySnapshot() + } + } + + /// The standalone destination as the screen and the confirm flow see it. + /// + /// With the identity as the source it is `resolvedWithdrawalTarget`, not + /// `resolvedSendTarget`: the latter sanitises against `source`, which is + /// a stale balance while the overlay is on, and would name a target the + /// transfer does not use. + var destination: TransferDestination { + if isIdentityDestination { return .identity } + if isIdentitySource { return .balance(resolvedWithdrawalTarget.network) } + return .balance(resolvedSendTarget) + } + + /// The standalone source as the screen and the confirm flow see it. + var transferSource: TransferSource { + isIdentitySource ? .identity : .balance(source) + } + + /// The TO selection while the identity is the source: `sendTarget` + /// guarded onto a balance a withdrawal can actually reach. + var resolvedWithdrawalTarget: IdentityWithdrawalTarget { + IdentityWithdrawalTarget(Self.sanitizedWithdrawalTarget(sendTarget)) ?? .transparent + } + + /// Shielded is unreachable from an identity, so it falls back to + /// Transparent โ€” the target a withdrawal always supports. + private static func sanitizedWithdrawalTarget(_ proposed: ChainNetwork) -> ChainNetwork { + IdentityWithdrawalTarget(proposed) == nil ? .core : proposed + } + + /// Confirm-flow input for the Identity source. `nil` unless the source + /// is Identity AND an identity exists (`canContinue` has already refused + /// the transfer otherwise). + var identityWithdrawalTransfer: IdentityWithdrawalTransfer? { + guard isIdentitySource, let identityId else { return nil } + return IdentityWithdrawalTransfer( + identityId: identityId, + target: resolvedWithdrawalTarget) + } + + /// Confirm-sheet input for the Identity destination. `nil` unless the + /// destination is Identity AND an identity exists (`canContinue` has + /// already refused the transfer otherwise). + var identityTopUpTransfer: IdentityTopUpTransfer? { + guard isIdentityDestination, let identityId else { return nil } + return IdentityTopUpTransfer(identityId: identityId, source: source) + } + + /// Whether the two endpoints can trade places. + /// + /// False for exactly one pair: Shielded โ†’ Identity. Its reverse would be + /// Identity โ†’ Shielded, which no single transition performs, so the + /// screen shows the static arrow instead of a swap the tap could not + /// honour. Every other pair reverses into a transfer that exists. + var canSwapEndpoints: Bool { + if isIdentitySource { return true } + if isIdentityDestination { return IdentityWithdrawalTarget(source) != nil } + return true + } + + /// Standalone screen: exchange the two endpoints. Assigned directly rather + /// than through `selectStandaloneSource`/`Target` โ€” those sanitise the + /// opposite side away from a collision, and a swap can't collide. + func swapStandaloneEndpoints() { + guard canSwapEndpoints else { return } + + if isIdentitySource { + // Withdrawal โ†’ top-up: the target balance becomes the funding + // source. Read the target before clearing the overlay, since + // `resolvedWithdrawalTarget` is only meaningful while it is on. + let fundingSource = resolvedWithdrawalTarget.network + isIdentitySource = false + isIdentityDestination = true + source = fundingSource + refreshIdentitySnapshot() + return + } + + if isIdentityDestination { + // Top-up โ†’ withdrawal: the funding balance becomes the payout + // target. `canSwapEndpoints` has already ruled out Shielded. + let payoutTarget = source + isIdentityDestination = false + isIdentitySource = true + sendTarget = Self.sanitizedWithdrawalTarget(payoutTarget) + refreshIdentitySnapshot() + return + } + + let newSource = resolvedSendTarget + let newTarget = source + source = newSource + sendTarget = newTarget + } + func selectSendTarget(_ network: ChainNetwork) { let from = sendSource ?? source sendTarget = Self.sanitizedDestination(from: from, proposed: network) @@ -444,6 +696,43 @@ final class InternalTransferViewModel: ObservableObject { to: Self.sanitizedDestination(from: source, proposed: sendTarget)) } + /// The balances a transfer may touch right now. + var availableNetworks: [ChainNetwork] { + isAdvancedMode ? ChainNetwork.allCases : [.core, .shielded] + } + + /// The identity is a Platform surface, so it comes and goes with the rest + /// of them rather than having a rule of its own. + var offersIdentityEndpoints: Bool { isAdvancedMode } + + /// Pull every endpoint back inside what the mode now allows. + /// + /// Only ever narrows. Turning the mode ON leaves the current pair alone โ€” + /// it was already legal โ€” while turning it OFF has to move a selection that + /// has just become unreachable, or the screen would sit on a route it is no + /// longer allowed to execute. + /// + /// The pinned variants (`sendSource`, `receiveTarget`) are deliberately not + /// touched: they are entered from a balance row that still exists, and + /// rewriting the endpoint the user tapped would be a different bug. + private func applyAdvancedMode() { + isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + guard !isAdvancedMode else { return } + + isIdentitySource = false + isIdentityDestination = false + + if source == .platform { + source = .core + } + if sendTarget == .platform { + sendTarget = Self.defaultDestination(for: source) + } + if receiveSource == .platform { + receiveSource = .shielded + } + } + private static func defaultDestination(for source: ChainNetwork) -> ChainNetwork { switch source { case .core, .platform: @@ -485,12 +774,25 @@ final class InternalTransferViewModel: ObservableObject { /// Refreshes route-dependent async state. Both Platform-funded routes use /// SDK preflights so their UI amount always matches the builder's real - /// input-selection envelope. + /// input-selection envelope. While the destination is Identity, `route` + /// is a stale balance pair, so the route preflights stay down and the + /// identity ceiling refreshes instead. private func routeDidChange() { clearMaxSelection() refreshShieldedSpendCeiling() + refreshIdentityPlatformCeiling() - if route == .platformToCore { + // With the identity as the source, no balance route is active at all: + // `route` is a stale pair, so every route preflight stays down. + guard !isIdentitySource else { + withdrawalPreflightTask?.cancel() + withdrawalPreflightTask = nil + withdrawalPreflight = nil + cancelPlatformShieldPreflight() + return + } + + if !isIdentityDestination, route == .platformToCore { startWithdrawalPreflightIfNeeded() } else { withdrawalPreflightTask?.cancel() @@ -498,13 +800,49 @@ final class InternalTransferViewModel: ObservableObject { withdrawalPreflight = nil } - if route == .platformToShielded { + if !isIdentityDestination, route == .platformToShielded { refreshPlatformShieldPreflight() } else { cancelPlatformShieldPreflight() } } + /// Loads the identity the Identity destination would top up, plus its + /// displayed credit balance. A missing identity is left `nil` โ€” the + /// amount validation names that state. + private func refreshIdentitySnapshot() { + #if DEBUG + if isPreviewInstance { return } + #endif + identityId = DWCurrentUserIdentityInfo.shared.identityId + if let identityId, let container = SwiftDashSDKHost.shared.modelContainer { + identityBalanceCredits = UsernameMarketplaceService.identityBalanceCredits( + identityId: identityId, + container: container) + } else { + identityBalanceCredits = 0 + } + } + + /// Recomputes `platformIdentityFundableDuffs` from the persisted + /// Platform-address candidates. Only meaningful while the Identity + /// destination spends the Platform balance; `nil` otherwise. + private func refreshIdentityPlatformCeiling() { + #if DEBUG + if isPreviewInstance { return } + #endif + guard isIdentityDestination, source == .platform else { + platformIdentityFundableDuffs = nil + return + } + guard let candidates = try? PlatformPaymentIdentityFundingPolicy.currentCandidates() else { + platformIdentityFundableDuffs = nil + return + } + platformIdentityFundableDuffs = + PlatformPaymentIdentityFundingPolicy.maxFundableDuffs(candidates: candidates) + } + /// Net payout of the full-balance (Max) Platform โ†’ Core withdrawal, in /// duffs (credits / 1000). `nil` until the preflight resolves positively. var platformWithdrawableDuffs: UInt64? { @@ -570,17 +908,42 @@ final class InternalTransferViewModel: ObservableObject { /// Drives the one-time restore gate reactively. A normal catch-up may set /// this to false, but it only blocks while the recovery marker is active. - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// + /// Seeded from the monitor in `init()` rather than here: a property default + /// runs in EVERY initializer, and the preview initializer must not spin up + /// the sync monitor singleton. + @Published private(set) var isChainSynced = false private var cancellables = Set() + #if DEBUG + /// Stands in for `DWGlobalOptions.isResyncingWallet` in a canvas. + private var previewIsResyncingWallet: Bool? + + /// True only for `makeForPreview` instances. They never registered with the + /// sync monitor, so `deinit` must not reach for that singleton to + /// unregister โ€” building it inside a preview process starts reachability + /// and SPV observation this instance never wanted. + private var isPreviewInstance = false + #endif + deinit { + #if DEBUG + if isPreviewInstance { return } + #endif // The monitor holds observers strongly โ€” remove or leak the VM. SyncingActivityMonitor.shared.remove(observer: self) } init() { SyncingActivityMonitor.shared.add(observer: self) + + NotificationCenter.default.publisher(for: .advancedModeDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.applyAdvancedMode() } + .store(in: &cancellables) + + isChainSynced = SyncingActivityMonitor.shared.state == .syncDone coreBalanceDuffs = SwiftDashSDKWalletState.shared.balance?.total ?? 0 coreSpendableDuffs = SwiftDashSDKWalletState.shared.feeAwareMaxSendable() platformCredits = PlatformAddressSyncCoordinator.shared.platformBalance @@ -605,7 +968,11 @@ final class InternalTransferViewModel: ObservableObject { PlatformShieldAmountPolicy.awaitingPlatformResync( current: self.awaitingPlatformShieldResync, after: .balancePublished) - if self.route == .platformToShielded { + if self.isIdentityDestination { + // A fresh Platform snapshot moves the identity top-up's + // planner ceiling, not the route preflights. + self.refreshIdentityPlatformCeiling() + } else if self.route == .platformToShielded { self.refreshPlatformShieldPreflight(after: .balancePublished) } } @@ -619,8 +986,111 @@ final class InternalTransferViewModel: ObservableObject { self?.refreshShieldedSpendCeiling() } .store(in: &cancellables) + + // Eager, not deferred to the first Identity pick: the destination + // picker lists the identity's balance alongside the other three, so + // the number has to be there before the destination is selected. + refreshIdentitySnapshot() + } + + #if DEBUG + /// Lightweight initializer used only by SwiftUI previews. Assigns the + /// balances and the endpoint selection directly and skips the wallet / + /// sync / preflight wiring the real `init()` sets up. + /// + /// Property observers do not fire during initialization, so assigning the + /// endpoints here also skips `routeDidChange()` โ€” no preflight task is + /// started and no SwiftData note read happens. + private init( + previewSource: ChainNetwork, + previewTarget: ChainNetwork, + previewSendFrom: ChainNetwork?, + previewReceiveInto: ChainNetwork?, + previewIdentityDestination: Bool, + previewIdentitySource: Bool, + previewIdentityCredits: UInt64, + previewAmountText: String, + previewCoreDuffs: UInt64, + previewPlatformCredits: UInt64, + previewShieldedCredits: UInt64, + previewIsChainSynced: Bool, + previewIsResyncingWallet: Bool + ) { + isPreviewInstance = true + source = previewSource + sendTarget = previewTarget + receiveSource = previewSource + sendSource = previewSendFrom + receiveTarget = previewReceiveInto + amountText = previewAmountText + coreBalanceDuffs = previewCoreDuffs + // No fee reserve to subtract without a wallet โ€” previews want the + // whole balance to read as spendable so Max and Continue behave. + coreSpendableDuffs = previewCoreDuffs + platformCredits = previewPlatformCredits + shieldedBalance = previewShieldedCredits + isChainSynced = previewIsChainSynced + self.previewIsResyncingWallet = previewIsResyncingWallet + if previewIdentityDestination { + isIdentityDestination = true + identityId = Data(repeating: 0x07, count: 32) + identityBalanceCredits = previewIdentityCredits + // Stands in for the planner ceiling the real instance derives + // from the persisted Platform-address candidates. + platformIdentityFundableDuffs = previewPlatformCredits / 1000 + } + if previewIdentitySource { + isIdentitySource = true + identityId = Data(repeating: 0x07, count: 32) + identityBalanceCredits = previewIdentityCredits + // Shielded is unreachable from an identity, so a preview asking + // for it would render a target the real screen never allows. + sendTarget = Self.sanitizedWithdrawalTarget(previewTarget) + } } + /// Preview view model with stubbed balances. + /// + /// Balances are in their published units: Core in duffs (1e8 per DASH), + /// Platform and Shielded in credits (1e11 per DASH). Defaults are + /// 2.45 / 1.2 / 0.785 DASH. + /// + /// Routes that spend the shielded pool (and `.coreToShielded`) ask the + /// SDK for a fee estimate while rendering; without a wallet those return + /// `nil` and the screen falls back to its "fee unavailable" state. Pick a + /// `.coreToPlatform` pair to preview an enabled Continue button. + static func makeForPreview( + source: ChainNetwork = .core, + target: ChainNetwork = .platform, + sendFrom: ChainNetwork? = nil, + receiveInto: ChainNetwork? = nil, + identityDestination: Bool = false, + identitySource: Bool = false, + identityCredits: UInt64 = 25_000_000_000, + amountText: String = "0", + coreDuffs: UInt64 = 245_000_000, + platformCredits: UInt64 = 120_000_000_000, + shieldedCredits: UInt64 = 78_500_000_000, + isChainSynced: Bool = true, + isResyncingWallet: Bool = false + ) -> InternalTransferViewModel { + InternalTransferViewModel( + previewSource: source, + previewTarget: target, + previewSendFrom: sendFrom, + previewReceiveInto: receiveInto, + previewIdentityDestination: identityDestination, + previewIdentitySource: identitySource, + previewIdentityCredits: identityCredits, + previewAmountText: amountText, + previewCoreDuffs: coreDuffs, + previewPlatformCredits: platformCredits, + previewShieldedCredits: shieldedCredits, + previewIsChainSynced: isChainSynced, + previewIsResyncingWallet: isResyncingWallet) + } + #endif + /// Fee kind for the pool-spending routes; `nil` for every other route. private func shieldedFeeKind(for route: InternalTransferRoute) -> PlatformWalletManager.ShieldedFeeKind? { switch route { @@ -630,10 +1100,21 @@ final class InternalTransferViewModel: ObservableObject { } } + /// Fee kind of the shielded spend the CURRENT selection would execute. + /// The Identity destination's shielded funding starts with an unshield to + /// the wallet's own Platform address, so it prices exactly like the + /// Shielded โ†’ Platform route. + private var activeShieldedFeeKind: PlatformWalletManager.ShieldedFeeKind? { + if isIdentityDestination { + return source == .shielded ? .unshield : nil + } + return shieldedFeeKind(for: route) + } + /// Recomputes `shieldedSpendCeilingCredits` from the current note set. /// Cached rather than computed per keystroke โ€” it reads SwiftData. private func refreshShieldedSpendCeiling() { - guard let feeKind = shieldedFeeKind(for: route) else { + guard let feeKind = activeShieldedFeeKind else { shieldedSpendCeilingCredits = nil return } @@ -666,16 +1147,37 @@ final class InternalTransferViewModel: ObservableObject { /// currently-selected source bucket. Each route has its own balance /// envelope โ€” asset-lock spends BIP44 duffs, transparent shield spends /// DIP-17 credits. - /// Only Core-funded routes during a restored wallet's first sync block. + /// Only Core-funded transfers during a restored wallet's first sync + /// block โ€” including an identity top-up spending the Core balance, + /// which is the same asset-lock L1 spend. var isBlockedBySync: Bool { - switch route { - case .coreToShielded, .coreToPlatform: - return WalletSendService.isBlockedByInitialRestoreSync( - isResyncingWallet: DWGlobalOptions.sharedInstance().isResyncingWallet, - isChainSynced: isChainSynced) - default: - return false + let spendsCore: Bool + if isIdentitySource { + // Credits leave the identity; the payout is produced by the + // network, not by an L1 spend of this wallet's UTXOs, so the + // restore gate has nothing to protect here. + spendsCore = false + } else if isIdentityDestination { + spendsCore = source == .core + } else { + spendsCore = route.source == .core } + guard spendsCore else { return false } + return WalletSendService.isBlockedByInitialRestoreSync( + isResyncingWallet: isResyncingWallet, + isChainSynced: isChainSynced) + } + + /// The restore marker, read live so the gate lifts as soon as it clears. + /// + /// A preview can override it: the real one lives in `NSUserDefaults` and is + /// false in a canvas, so a sync-gate preview could otherwise never show the + /// gate โ€” the gate needs a restored wallet AND an unfinished sync. + private var isResyncingWallet: Bool { + #if DEBUG + if let previewIsResyncingWallet { return previewIsResyncingWallet } + #endif + return DWGlobalOptions.sharedInstance().isResyncingWallet } /// Inline, user-facing explanation for an amount rejected before Confirm. @@ -685,6 +1187,9 @@ final class InternalTransferViewModel: ObservableObject { if let maxNotice { return maxNotice } guard dashDuffsUnsigned > 0 else { return nil } + if isIdentitySource { return identityWithdrawalValidationMessage } + if isIdentityDestination { return identityAmountValidationMessage } + // The Core โ†’ Shielded pool fee rides on top of the amount, so there // is no route minimum โ€” but without the estimate the lock value // cannot be derived, so fail closed before Confirm. @@ -748,31 +1253,7 @@ final class InternalTransferViewModel: ObservableObject { // A Max sweep is planned against the real note set rather than the // amount+reserve envelope, so it is affordable by construction. if isFullShieldedSweep { return nil } - // The ceiling is priced from the notes that would actually be - // spent, so it supersedes the flat reserve โ€” which always charges - // a full-size bundle and would reject amounts a one- or two-note - // spend can afford. - if let ceiling = shieldedSpendCeilingCredits { - if creditsPreview > shieldedBalance { - // Simply more than the wallet holds: name that, rather than - // blaming note fragmentation. - return TransferSpendAmountPolicy.insufficientBalanceMessage( - balanceName: balanceName, - requestedDuffs: creditsPreview / 1000, - spendableDuffs: ceiling / 1000) - } - return creditsPreview > ceiling ? Self.shieldedCeilingMessage(ceiling) : nil - } - // Ceiling unavailable (notes reconciling): fall back to the flat - // worst-case reserve. - guard let reserve = feeReserveCredits else { - return Self.feeEstimateUnavailableMessage - } - return TransferSpendAmountPolicy.insufficientBalanceMessage( - balanceName: balanceName, - requestedCredits: creditsPreview, - balanceCredits: shieldedBalance, - feeReserveCredits: reserve) + return shieldedSpendValidationMessage(reserveCredits: feeReserveCredits) case .platformToCore: // Stay quiet while the preflight is still resolving: Continue is @@ -807,6 +1288,126 @@ final class InternalTransferViewModel: ObservableObject { } } + /// The shielded-pool "doesn't fit" line shared by the Shielded โ†’ balance + /// routes and the Identity destination (whose funding starts with the + /// same unshield): balance overrun first, then the note-priced ceiling, + /// then the flat worst-case reserve as the fallback while notes are + /// reconciling. `nil` when the amount fits. + private func shieldedSpendValidationMessage(reserveCredits: UInt64?) -> String? { + let balanceName = ChainNetwork.shielded.balanceName + // The ceiling is priced from the notes that would actually be + // spent, so it supersedes the flat reserve โ€” which always charges + // a full-size bundle and would reject amounts a one- or two-note + // spend can afford. + if let ceiling = shieldedSpendCeilingCredits { + if creditsPreview > shieldedBalance { + // Simply more than the wallet holds: name that, rather than + // blaming note fragmentation. + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: balanceName, + requestedDuffs: creditsPreview / 1000, + spendableDuffs: ceiling / 1000) + } + return creditsPreview > ceiling ? Self.shieldedCeilingMessage(ceiling) : nil + } + guard let reserve = reserveCredits else { + return Self.feeEstimateUnavailableMessage + } + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: balanceName, + requestedCredits: creditsPreview, + balanceCredits: shieldedBalance, + feeReserveCredits: reserve) + } + + /// Amount-affordability of a shielded-pool spend, mirroring + /// `shieldedSpendValidationMessage` gate for gate. + private func canAffordShieldedSpend(reserveCredits: UInt64?) -> Bool { + if let ceiling = shieldedSpendCeilingCredits { + return creditsPreview <= ceiling + } + guard let reserve = reserveCredits else { return false } + return shieldedBalance >= reserve + && creditsPreview <= shieldedBalance - reserve + } + + /// The Identity destination's inline rejection, mirroring + /// `canContinueToIdentity` gate for gate so a disabled Continue is never + /// unexplained: no identity, below the top-up floor, or over the + /// source's envelope. + private var identityAmountValidationMessage: String? { + guard identityId != nil else { return Self.noIdentityMessage } + if dashDuffsUnsigned < IdentityTopUpViewModel.customMinimumDuffs { + return Self.identityMinimumMessage + } + switch source { + case .core: + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: source.balanceName, + requestedDuffs: dashDuffsUnsigned, + spendableDuffs: coreSpendableDuffs) + case .platform: + guard let ceiling = platformIdentityFundableDuffs else { + return Self.feeEstimateUnavailableMessage + } + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: source.balanceName, + requestedDuffs: dashDuffsUnsigned, + spendableDuffs: ceiling) + case .shielded: + return shieldedSpendValidationMessage(reserveCredits: feeReserveCredits) + } + } + + /// The Identity source's inline rejection, mirroring + /// `canContinueFromIdentity` gate for gate: no identity, below the + /// consensus withdrawal floor, or over what the credit balance can send + /// once the fee reserve is held back. + private var identityWithdrawalValidationMessage: String? { + guard identityId != nil else { return Self.noIdentityToWithdrawFromMessage } + if resolvedWithdrawalTarget == .transparent, + creditsPreview < IdentityWithdrawViewModel.minimumWithdrawalCredits { + return Self.identityWithdrawalMinimumMessage + } + let spendable = IdentityWithdrawViewModel.spendableCredits( + balanceCredits: identityBalanceCredits) + guard creditsPreview > spendable else { return nil } + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: Self.identityBalanceName, + requestedDuffs: creditsPreview / 1000, + spendableDuffs: spendable / 1000) + } + + private static let noIdentityMessage = NSLocalizedString( + "You need a DashPay identity before you can top up its balance.", + comment: "Identity transfer destination without a registered identity") + + private static let noIdentityToWithdrawFromMessage = NSLocalizedString( + "You need a DashPay identity before you can move credits out of it.", + comment: "Identity transfer source without a registered identity") + + /// The consensus floor, not a product choice: below it the withdrawal's + /// Core output would be dust and the network rejects the transition. + private static let identityWithdrawalMinimumMessage = String.localizedStringWithFormat( + NSLocalizedString( + "Enter at least %@ DASH", + comment: "Identity top-up sheet โ€” custom amount below the floor"), + (IdentityWithdrawViewModel.minimumWithdrawalCredits / 1000).dashAmount + .formattedDashAmountWithoutCurrencySymbol) + + /// Card-length name for the identity's credit balance, matching how + /// `ChainNetwork.balanceName` labels the other three. + static let identityBalanceName = NSLocalizedString("Identity", comment: "Payments") + + /// Same floor (and nearly the same wording) as the top-up sheet's custom + /// amount field โ€” both feed `IdentityTopUpViewModel.topUp`. + private static let identityMinimumMessage = String.localizedStringWithFormat( + NSLocalizedString( + "Enter at least %@ DASH", + comment: "Identity top-up sheet โ€” custom amount below the floor"), + IdentityTopUpViewModel.customMinimumDuffs.dashAmount + .formattedDashAmountWithoutCurrencySymbol) + private static let feeEstimateUnavailableMessage = NSLocalizedString( "There was an error, please try again later", comment: "Internal transfer fee estimate unavailable") @@ -817,6 +1418,8 @@ final class InternalTransferViewModel: ObservableObject { // otherwise the credit routes would submit a nonzero amount while the // UI shows 0. guard dashDuffsUnsigned > 0, !isBlockedBySync else { return false } + if isIdentitySource { return canContinueFromIdentity } + if isIdentityDestination { return canContinueToIdentity } switch route { case .coreToShielded: // Fee-on-top: the lock value is amount + pool fee, so the balance @@ -845,13 +1448,8 @@ final class InternalTransferViewModel: ObservableObject { } // Unshield/withdraw: the SDK debits amount + fee from the shielded // pool (recipient receives the full amount), so the balance must - // cover amount + fee. Fail closed if the reserve is unavailable. - if let ceiling = shieldedSpendCeilingCredits { - return creditsPreview <= ceiling - } - guard let reserve = feeReserveCredits else { return false } - return shieldedBalance >= reserve - && creditsPreview <= shieldedBalance - reserve + // cover amount + fee. Fails closed if the reserve is unavailable. + return canAffordShieldedSpend(reserveCredits: feeReserveCredits) case .platformToCore: // Either the exact full-balance net payout (Max โ†’ AUTO path over // every address), or a partial amount within the single-input @@ -863,12 +1461,77 @@ final class InternalTransferViewModel: ObservableObject { } } + /// Continue gate for the Identity destination. Each funding source + /// carries the envelope its executor really enforces: + /// - Core: an asset lock that carves its processing fee from the locked + /// value โ€” bounded by the fee-aware spendable balance, exactly like + /// Core โ†’ Platform. + /// - Platform: the executor's own planner ceiling + /// (`platformIdentityFundableDuffs`); `nil` fails closed. + /// - Shielded: the funding's first step is an unshield with its fee on + /// top, so the Shielded โ†’ Platform envelope applies verbatim. + /// The floor is the top-up executor's own minimum; the executor's plan + /// remains the final authority at Confirm and surfaces its own error. + /// Continue gate for the Identity source: an identity exists, the amount + /// clears the consensus withdrawal floor (transparent payouts only), and + /// it fits under the credit balance less the fee reserve the transition + /// is charged on top. The executor's own failure remains the final + /// authority at Confirm. + private var canContinueFromIdentity: Bool { + guard identityId != nil else { return false } + if resolvedWithdrawalTarget == .transparent, + creditsPreview < IdentityWithdrawViewModel.minimumWithdrawalCredits { + return false + } + return creditsPreview <= IdentityWithdrawViewModel.spendableCredits( + balanceCredits: identityBalanceCredits) + } + + private var canContinueToIdentity: Bool { + guard identityId != nil, + dashDuffsUnsigned >= IdentityTopUpViewModel.customMinimumDuffs + else { return false } + switch source { + case .core: + return dashDuffsUnsigned <= coreSpendableDuffs + case .platform: + guard let ceiling = platformIdentityFundableDuffs else { return false } + return dashDuffsUnsigned <= ceiling + case .shielded: + return canAffordShieldedSpend(reserveCredits: feeReserveCredits) + } + } + /// Fee/selection headroom (credits) the SDK requires ON TOP of the amount /// for the active route, used by `canContinue` and Max. `nil` means the /// requirement is currently unavailable for a fee-reserved route โ†’ callers /// fail closed (block). A literal `0` (the asset-lock route) is NOT `nil` โ€” /// that route reserves nothing from the source balance. private var feeReserveCredits: UInt64? { + if isIdentitySource { + // Charged to the identity on top of the amount, and unpriced by + // the SDK โ€” the conservative reserve is what bounds the spend. + return IdentityWithdrawViewModel.feeHeadroomCredits + } + if isIdentityDestination { + switch source { + case .core: + // Asset lock โ€” the processing fee is carved from the locked + // value, nothing is reserved from the source balance. + return 0 + case .platform: + // Governed by the funding planner's own headroom, already + // folded into `platformIdentityFundableDuffs`. + return nil + case .shielded: + // The funding unshield's fee scales with the spent notes โ€” + // reserve the worst case, same as the Shielded โ†’ Platform + // route below. + return try? PlatformWalletManager.estimateShieldedFee( + kind: .unshield, + numActions: ShieldedActionBudget.maxActionsPerTransition) + } + } switch route { case .coreToShielded, .coreToPlatform: // Coreโ†’Shielded's pool fee is duff-denominated and enforced in @@ -975,6 +1638,14 @@ final class InternalTransferViewModel: ObservableObject { Self.cardBalanceString(duffs: shieldedBalance / 1000) } + /// Formatted identity credit balance as DASH for the destination picker's + /// Identity row (max 5 fraction digits). Reads zero until + /// `refreshIdentitySnapshot` has run, which `init` does eagerly so the row + /// is right before the destination is ever selected. + var identityBalanceFormatted: String { + Self.cardBalanceString(duffs: identityBalanceCredits / 1000) + } + /// Active fiat currency code (e.g. "THB") โ€” the amount row's second /// unit pill label. var fiatCurrencyCode: String { @@ -1002,6 +1673,10 @@ final class InternalTransferViewModel: ObservableObject { /// Source-aware Max fill. Keeps the same unit semantics โ€” DASH or fiat โ€” /// but draws the upper bound from whichever bucket the user picked. func fillMaxFromWallet() { + if isIdentitySource { + fillIdentityWithdrawalMax() + return + } if route == .platformToShielded { fillPlatformShieldMax() return @@ -1095,6 +1770,39 @@ final class InternalTransferViewModel: ObservableObject { applyMaxAmountText(sourceDuffs) } + /// Identity Max: the credit balance less the fee reserve the transition + /// is charged on top. Unlike the balance routes there is no preflight to + /// wait on โ€” the reserve is a fixed bound โ€” so this always resolves. + private func fillIdentityWithdrawalMax() { + clearMaxSelection() + let spendable = IdentityWithdrawViewModel.spendableCredits( + balanceCredits: identityBalanceCredits) + if spendable == 0 { + maxNotice = Self.feeReserveExceedsIdentityBalanceMessage + } else { + // The card shows the whole credit balance, so a Max that lands + // below it reads as a bug unless the reserve is accounted for. + maxNotice = Self.identityHeldBackMessage( + identityBalanceCredits - spendable) + } + + isApplyingMax = true + defer { isApplyingMax = false } + applyMaxAmountText(spendable / 1000) + } + + private static let feeReserveExceedsIdentityBalanceMessage = NSLocalizedString( + "Your Identity balance is too low to cover the transfer fee.", + comment: "Identity withdrawal โ€” balance below the fee reserve") + + private static func identityHeldBackMessage(_ heldBackCredits: UInt64) -> String { + String.localizedStringWithFormat( + NSLocalizedString( + "%@ DASH is held back to cover the transfer fee.", + comment: "Identity withdrawal โ€” Max reserve note"), + (heldBackCredits / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) + } + /// Platform Max is asynchronous because only the Rust wallet knows which /// address suffix its shield builder can select. If that answer is not /// ready, remember the user's intent and leave the current text untouched. diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedRecoverySheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedRecoverySheet.swift new file mode 100644 index 0000000000..8ee588793f --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedRecoverySheet.swift @@ -0,0 +1,374 @@ +// +// ShieldedRecoverySheet.swift +// DashWallet +// + +import Combine +import SwiftUI +import DashUIKit +import SwiftDashSDK + +/// Recovery sheet for a stuck "to Shielded" transfer (Coreโ†’Shielded). The +/// transfer's L1 asset lock is committed on-chain but the shield state +/// transition never landed, so the funds sit on an unconsumed +/// `PersistentAssetLock` โ€” recoverable, not lost. "Finish now" resumes that +/// exact outpoint via `ShieldedTransferCoordinator.resumeAssetLock` +/// (re-auth โ†’ Orchard proof โ†’ ShieldFromAssetLock ST โ†’ consume), rather than +/// building a second lock. +/// +// MARK: - AssetLockStatus + +/// `PersistentAssetLock.statusRaw`, named. +/// +/// The recovery decision turns on these numbers, and spelling them as literals +/// where that decision is made hid what a `4` or a `1...3` meant. The Rust side +/// is the authority (`rs-platform-wallet-ffi/src/asset_lock_persistence.rs`); +/// this mirrors it for the one reader that has to branch on it. +/// +/// TODO(asset-lock-status): `TxDetailModel.lockStatusText` still maps the same +/// raw values by hand โ€” it should read this rather than repeat it. +enum AssetLockStatus: Int { + case built = 0 + case broadcast = 1 + case instantSendLocked = 2 + case chainLocked = 3 + /// The shield transition consumed the lock โ€” the transfer is finished. + case consumed = 4 + /// Recovered from chain after a restore: final on Core, but whether it + /// completed on Platform cannot be authenticated. + case recoveredFromChain = 5 +} + +// MARK: - ShieldedRecoveryViewModel + +/// Everything the recovery sheet does, kept out of the sheet. +/// +/// It holds the coordinator, asks the lookup what the lock's live status is, +/// and runs the resume. The view reads `phase` and calls `finish()`. +/// +/// TODO(shielded-recovery-service): the resume is still tied to this object's +/// lifetime, and this object is tied to the sheet's. Dismissal is refused while +/// a resume is in flight, which is what keeps it alive today, but the ownership +/// `InternalTransferRunner` documents โ€” a service the work outlives its +/// presenter through โ€” is the shape this should end up in. +@MainActor +final class ShieldedRecoveryViewModel: ObservableObject { + + /// The coordinator's phase, republished: a nested `ObservableObject` does + /// not announce through its owner. + @Published private(set) var phase: ShieldedTransferCoordinator.Phase = .idle + + /// "Finish now" found the lock already consumed โ€” a background sync landed + /// the shield after the history row's snapshot was taken. Shows success + /// rather than paying for a proof that cannot land. + @Published private(set) var alreadyComplete = false + + private let outPoint: (txidWire: Data, vout: UInt32)? + private let coordinator = ShieldedTransferCoordinator() + private var cancellables = Set() + + init(transaction: Transaction) { + outPoint = transaction.shieldedOutPoint.map { ($0.txidWire, $0.vout) } + + coordinator.$phase + .receive(on: RunLoop.main) + .sink { [weak self] in self?.phase = $0 } + .store(in: &cancellables) + } + + var isInFlight: Bool { + switch phase { + case .signing, .locking, .proving, .broadcasting: return true + default: return false + } + } + + /// Resume the transfer, unless it turns out to be finished already. + /// + /// The live status is re-read first because the resume FFI builds the whole + /// Orchard proof before Platform reports the lock consumed โ€” without this a + /// transfer that completed in the background would dead-end after ~30s of + /// work. Returns false when there is no outpoint to resume, which the sheet + /// answers by closing. + @discardableResult + func finish() -> Bool { + guard let outPoint else { return false } + + Task { @MainActor in + ShieldedTxLookup.shared.refresh() + + if Self.status(ofOutPoint: outPoint.txidWire) == .consumed { + alreadyComplete = true + return + } + + // Every other status resumes: broadcast through chain-locked is + // still pending, `recoveredFromChain` is Core-final with the + // Platform side unknown, and a missing status means the refresh + // told us nothing. A local tombstone and a remote already-consumed + // report both land on unconfirmed rather than false success. + await coordinator.resumeAssetLock( + outPointTxidWire: outPoint.txidWire, + outPointVout: outPoint.vout) + + // The shield consumed the lock; refresh so the history row flips + // pending โ†’ completed without waiting for the next sync pass. + if case .success = coordinator.phase { + ShieldedTxLookup.shared.refresh() + } + } + return true + } + + /// The lookup is keyed by display-order txid; an outpoint carries wire + /// order, which is the reverse. + private static func status(ofOutPoint txidWire: Data) -> AssetLockStatus? { + let displayTxid = txidWire.reversed().map { String(format: "%02x", $0) }.joined() + guard let raw = ShieldedTxLookup.shared.info(forTxidHex: displayTxid)?.statusRaw else { + return nil + } + return AssetLockStatus(rawValue: raw) + } +} + +/// Presented from the home tx list when the user taps a row flagged +/// `Transaction.isPendingShieldedTransfer`. Drawing only โ€” the coordinator, the +/// status lookup and the resume are `ShieldedRecoveryViewModel`'s, which keeps +/// it independent of any live confirm-sheet flow. +struct ShieldedRecoverySheet: View { + + let transaction: Transaction + var onDismiss: () -> Void + + @StateObject private var viewModel: ShieldedRecoveryViewModel + + init(transaction: Transaction, onDismiss: @escaping () -> Void) { + self.transaction = transaction + self.onDismiss = onDismiss + _viewModel = StateObject(wrappedValue: ShieldedRecoveryViewModel(transaction: transaction)) + } + + var body: some View { + VStack(spacing: 0) { + dragHandle + .padding(.top, 8) + + Text(NSLocalizedString("Finish shielded transfer", comment: "InternalTransfer recovery")) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + .padding(.top, 20) + + switch viewModel.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onDismiss) + case .signing, .locking, .proving, .broadcasting: + inFlightBody + default: + if viewModel.alreadyComplete { + successBody + } else { + idleOrFailedBody + } + } + } + .background(Color.dash.primaryBackground) + .interactiveDismissDisabled(viewModel.isInFlight) + } + + // MARK: - Bodies + + private var idleOrFailedBody: some View { + VStack(spacing: 0) { + DashAmount( + amount: Int64(transaction.dashAmount), + font: .largeTitle, + dashSymbolFactor: 0.7, + showDirection: false) + .padding(.top, 14) + + infoCard + .padding(.horizontal, 20) + .padding(.top, 20) + + if case let .failed(message) = viewModel.phase { + Text(message) + .font(.system(size: 13)) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 12) + } + + Spacer(minLength: 12) + + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Finish now", comment: "InternalTransfer recovery"), + positiveButtonAction: finish, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onDismiss) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + private var inFlightBody: some View { + VStack(alignment: .leading, spacing: 16) { + // Same stepped checklist as the original transfer so the user sees + // what's happening during the (~30s+) Orchard proof build. Resume + // skips `.locking` (the lock is already on-chain), so only + // Authorizing โ†’ Generating proof โ†’ Broadcasting are shown. + ShieldedTransferStepList( + currentPhase: viewModel.phase, + steps: [ + .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing), + .init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving), + .init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting), + ]) + + Text(NSLocalizedString( + "Building the privacy proof can take up to a minute. Keep the app open.", + comment: "InternalTransfer recovery")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 24) + } + + 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("Transfer complete", comment: "")) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + + DashAmount( + amount: Int64(transaction.dashAmount), + font: .title, + dashSymbolFactor: 0.7, + showDirection: false) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Done", comment: ""), + style: .filled, + stretch: true, + action: onDismiss) + .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 infoCard: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(Color.dash.blue) + .frame(width: 30, height: 30) + Image(systemName: "shield.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + } + + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("Your Dash is safe", comment: "InternalTransfer recovery")) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(NSLocalizedString( + "This transfer's funds were locked on-chain but the private transfer didn't finish. Tap Finish now to complete it.", + comment: "InternalTransfer recovery")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(14) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + private func finish() { + // No outpoint means nothing to resume โ€” the row that opened this sheet + // has no tracked lock behind it. + if !viewModel.finish() { + onDismiss() + } + } +} + +#if DEBUG + +/// The sheet owns its coordinator and `phase` is `private(set)`, so a preview +/// can only reach the idle state โ€” the in-flight, success and +/// submitted-unconfirmed bodies are previewed through +/// `ShieldedTransferStepList` and `ShieldedSubmittedUnconfirmedView`. +/// +/// The synthetic initialiser is what makes this previewable at all: a real +/// `Transaction` wraps a `PersistentTransaction` out of SwiftData, and there is +/// none in a canvas. `shieldedOutPoint` is therefore nil here, so "Finish now" +/// would bail โ€” which is the honest preview of a row whose lock is missing. +@MainActor +private func recoverySheetSample(netAmount: Int64 = -125_000_000) -> some View { + ShieldedRecoverySheet( + transaction: Transaction( + syntheticTxid: Data(repeating: 0xAB, count: 32), + directionRaw: 1, + netAmount: netAmount, + fee: 1_000, + contextRaw: 2, + date: Date(timeIntervalSince1970: 1_770_000_000)), + onDismiss: {}) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .background(Color.dash.primaryText.opacity(0.4).ignoresSafeArea()) +} + +@available(iOS 17, *) +#Preview("Idle") { + recoverySheetSample() +} + +@available(iOS 17, *) +#Preview("Dark") { + recoverySheetSample() + .preferredColorScheme(.dark) +} + +/// The amount and the copy both have to survive the largest type sizes โ€” this +/// sheet is mostly explanation, and it is the one the user meets after a +/// transfer already went wrong. +@available(iOS 17, *) +#Preview("Large type") { + recoverySheetSample() + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedSubmittedUnconfirmedView.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedSubmittedUnconfirmedView.swift new file mode 100644 index 0000000000..d983170cc1 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedSubmittedUnconfirmedView.swift @@ -0,0 +1,59 @@ +// +// ShieldedSubmittedUnconfirmedView.swift +// DashWallet +// + +import SwiftUI +import DashUIKit + +/// sync. +struct ShieldedSubmittedUnconfirmedView: View { + var onDone: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "paperplane.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + .foregroundColor(.dash.blue) + .padding(.top, 24) + + Text(NSLocalizedString("Submitted โ€” confirming", comment: "InternalTransfer")) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + + Text(NSLocalizedString( + "Your transfer was broadcast and is confirming on the network. Don't resend it โ€” it will appear once the network confirms it.", + comment: "InternalTransfer")) + .font(.callout) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 24) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Done", comment: ""), + style: .filled, + stretch: true, + action: onDone) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } +} + +#if DEBUG + + +@available(iOS 17, *) +#Preview("Submitted โ€” confirming") { + ShieldedSubmittedUnconfirmedView(onDone: {}) + .background(Color.dash.primaryBackground) +} + +#endif + diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferCoordinator.swift similarity index 100% rename from DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift rename to DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferCoordinator.swift diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferStepList.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferStepList.swift new file mode 100644 index 0000000000..8ceef9a689 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedTransferStepList.swift @@ -0,0 +1,192 @@ +// +// ShieldedTransferStepList.swift +// DashWallet +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK + + +/// Vertical step checklist shared by the shielded transfer confirm sheet and +/// the recovery sheet. Each `Step` maps to the +/// `ShieldedTransferCoordinator.Phase` it represents; the row's +/// done/active/pending state is derived from where the current phase sits in +/// the canonical ordering (signing โ†’ locking โ†’ proving โ†’ broadcasting โ†’ +/// success). A terminal `.idle`/`.failed` phase renders every step pending (the +/// host sheet surfaces the summary/error separately). +struct ShieldedTransferStepList: View { + struct Step: Identifiable { + let label: String + let phase: ShieldedTransferCoordinator.Phase + var id: String { label } + } + + /// Pre-resolved (label, state) rows โ€” both inits reduce to this. + private let rows: [(label: String, state: StepState)] + + /// Phase-based rows: each step's done/active/pending state derives from + /// where `currentPhase` sits in the canonical ordering (see type doc). + init(currentPhase: ShieldedTransferCoordinator.Phase, steps: [Step]) { + rows = steps.map { step -> (label: String, state: StepState) in + (label: step.label, state: Self.state(for: step.phase, current: currentPhase)) + } + } + + /// Positional rows, for flows with stages the coordinator phases can't + /// express (e.g. the CoinJoin โ†’ Shielded flow's sweep leg): rows before + /// `currentIndex` are complete, the row at it active, the rest pending. + /// `nil` renders every row pending (idle/failed โ€” the host sheet surfaces + /// the error separately); an index past the last row renders all complete. + init(labels: [String], currentIndex: Int?) { + rows = labels.enumerated().map { index, label -> (label: String, state: StepState) in + guard let current = currentIndex else { return (label: label, state: .pending) } + if index < current { return (label: label, state: .complete) } + if index == current { return (label: label, state: .active) } + return (label: label, state: .pending) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(Array(rows.enumerated()), id: \.element.label) { _, row in + stepRow(label: row.label, state: row.state) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private enum StepState { + case pending + case active + case complete + } + + /// Where `phase` sits relative to `current`. The phase enum is ordered + /// .signing โ†’ .locking โ†’ .proving โ†’ .broadcasting โ†’ .success, so a numeric + /// comparison drives the state. + private static func state( + for phase: ShieldedTransferCoordinator.Phase, + current: ShieldedTransferCoordinator.Phase + ) -> StepState { + guard let currentIdx = Self.phaseIndex(current), + let targetIdx = Self.phaseIndex(phase) else { + return .pending + } + if targetIdx < currentIdx { return .complete } + if targetIdx == currentIdx { return .active } + return .pending + } + + private static func phaseIndex(_ phase: ShieldedTransferCoordinator.Phase) -> Int? { + switch phase { + case .signing: return 0 + case .locking: return 1 + case .proving: return 2 + case .broadcasting: return 3 + case .success: return 4 + case .idle, .failed, .submittedUnconfirmed: return nil + } + } + + private func stepRow(label: String, state: StepState) -> some View { + HStack(spacing: 12) { + stepIndicator(state: state) + Text(label) + .font(.system(size: 15, weight: state == .active ? .semibold : .regular)) + .foregroundColor(state == .pending ? .dash.secondaryText : .dash.primaryText) + Spacer() + if state == .active { + SwiftUI.ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.7) + } + } + } + + @ViewBuilder + private func stepIndicator(state: StepState) -> some View { + switch state { + case .pending: + Circle() + .stroke(Color.dash.gray300.opacity(0.6), lineWidth: 1.5) + .frame(width: 20, height: 20) + case .active: + ZStack { + Circle() + .stroke(Color.dash.blue, lineWidth: 1.5) + .frame(width: 20, height: 20) + Circle() + .fill(Color.dash.blue) + .frame(width: 10, height: 10) + } + case .complete: + ZStack { + Circle() + .fill(Color.dash.blue) + .frame(width: 20, height: 20) + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(Color.dash.whiteText) + } + } + } +} + +/// Terminal "submitted but unconfirmed" state shared by the shielded transfer +/// confirm sheet and the recovery sheet. Shown when the SDK reports +/// `shieldedSpendUnconfirmed` โ€” the broadcast was accepted by relay but its +/// result isn't yet confirmed, so the user must NOT resend; there is + +#if DEBUG + + +private let shieldedSteps: [ShieldedTransferStepList.Step] = [ + .init(label: "Signing", phase: .signing), + .init(label: "Locking funds", phase: .locking), + .init(label: "Generating proof", phase: .proving), + .init(label: "Broadcasting", phase: .broadcasting), +] + +private func stepListSample(_ phase: ShieldedTransferCoordinator.Phase) -> some View { + ShieldedTransferStepList(currentPhase: phase, steps: shieldedSteps) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// Every position in the ordering โ€” pending, first active, mid-run, and all +/// complete โ€” side by side, since the row states are derived rather than set. +@available(iOS 17, *) +#Preview("Steps ยท idle") { + stepListSample(.idle) +} + +@available(iOS 17, *) +#Preview("Steps ยท signing") { + stepListSample(.signing) +} + +@available(iOS 17, *) +#Preview("Steps ยท proving") { + stepListSample(.proving) +} + +@available(iOS 17, *) +#Preview("Steps ยท success") { + stepListSample(.success) +} + +/// Positional init โ€” the CoinJoin โ†’ Shielded flow's extra sweep leg, whose +/// stages have no coordinator phase. +@available(iOS 17, *) +#Preview("Steps ยท positional") { + ShieldedTransferStepList( + labels: ["Sweeping CoinJoin funds", "Signing", "Generating proof", "Broadcasting"], + currentIndex: 1) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} +#endif + diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedWithdrawalStore.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedWithdrawalStore.swift similarity index 100% rename from DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedWithdrawalStore.swift rename to DashWallet/Sources/UI/Payments/InternalTransfer/Shielded/ShieldedWithdrawalStore.swift diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/TransferTimingSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/TransferTimingSheet.swift index 7a372f0adb..cfd66c7982 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/TransferTimingSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/TransferTimingSheet.swift @@ -6,83 +6,81 @@ import SwiftUI import DashUIKit +/// Why a shielded transfer is instant one way and slow the other, shown once +/// before the first internal transfer. +/// +/// `fillsHeight: false` rather than `BottomSheet.selfSizing`: the host presents +/// this from UIKit, and SwiftUI's `.presentationDetents` does not bridge to a +/// `UIHostingController` shown with `present()`. `PaymentsLandingHostingController` +/// measures the content and sets a matching UIKit detent instead. struct TransferTimingSheet: View { - @Environment(\.dismiss) private var dismiss - var onConfirm: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 24) { - HStack { - Spacer() - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.dash.primaryText) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) - } - } - - Text(NSLocalizedString("Transfers take different times", comment: "")) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.dash.primaryText) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) + DashUIKit.BottomSheet( + showBackButton: .constant(false), + fillsHeight: false + ) { + VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 18) { - timingRow( - iconSystemName: "bolt.fill", - iconColor: .orange, - title: NSLocalizedString("From Dash Wallet to Shielded balance", comment: ""), - subtitle: NSLocalizedString("The transfer is instant", comment: "")) + VStack(alignment: .leading, spacing: 20) { + Text(NSLocalizedString("Transfers take different times", comment: "Payments")) + .dashFont(.title1) + .foregroundStyle(Color.dash.primaryText) - timingRow( - iconSystemName: "clock.fill", - iconColor: .blue, - title: NSLocalizedString("From Shielded balance to Dash Wallet", comment: ""), - subtitle: NSLocalizedString("The transfer could take up to 10 minutes", comment: "")) - } + VStack(alignment: .leading, spacing: 16) { + SheetFeature( + title: NSLocalizedString("From Dash Wallet to Shielded balance", comment: "Payments"), + description: NSLocalizedString("The transfer is instant", comment: "Payments"), + icon: DashIcon.Features.instant.source, + iconColor: .dash.yellow + ) - Spacer(minLength: 8) + SheetFeature( + title: NSLocalizedString("From Shielded balance to Dash Wallet", comment: "Payments"), + description: NSLocalizedString("The transfer could take up to 10 minutes", comment: "Payments"), + icon: DashIcon.Features.timerPurple.source + ) + } + } + .padding(.horizontal, 60) + .padding(.top, 20) + .padding(.bottom, 32) - DashButton( - text: NSLocalizedString("I got it", comment: ""), - style: .filledBlue, - size: .large, - action: { - onConfirm() - }) + DashButton( + text: NSLocalizedString("I got it", comment: "Payments"), + style: .filledBlue, + size: .large, + action: onConfirm + ) + .padding(.horizontal, 60) + .padding(.vertical, 20) + } } - .padding(.horizontal, 24) - .padding(.top, 14) - .padding(.bottom, 24) - .background(Color.dash.primaryBackground) } +} - private func timingRow( - iconSystemName: String, - iconColor: Color, - title: String, - subtitle: String - ) -> some View { - HStack(alignment: .top, spacing: 14) { - Image(systemName: iconSystemName) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(iconColor) - .frame(width: 28, height: 28) +#if DEBUG - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText) - .fixedSize(horizontal: false, vertical: true) - Text(subtitle) - .font(.system(size: 13)) - .foregroundColor(Color.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) +@available(iOS 17, *) +#Preview("Sheet") { + Color.dash.primaryBackground + .ignoresSafeArea() + .sheet(isPresented: .constant(true)) { + TransferTimingSheet(onConfirm: {}) } - } } + +@available(iOS 17, *) +#Preview("Content") { + TransferTimingSheet(onConfirm: {}) +} + +/// The rows wrap and the button must stay reachable at the largest type sizes. +@available(iOS 17, *) +#Preview("Large type") { + TransferTimingSheet(onConfirm: {}) + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsActionCard.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsActionCard.swift new file mode 100644 index 0000000000..5a973b9fdf --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsActionCard.swift @@ -0,0 +1,118 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The card the payments landing's Internal and Send tabs open on: a list of +/// destinations to pick before any amount is entered. +/// +/// The caption belongs inside the card rather than above it, which is why this +/// owns it instead of leaving it to the caller. +struct PaymentsActionCard: View { + /// Section label drawn above the rows, inside the card. `nil` on the Send + /// tab, whose two rows need no heading. + var caption: String? = nil + @ViewBuilder var rows: () -> Rows + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let caption { + Text(caption) + .dashFont(.footnote) + .foregroundColor(Color.dash.secondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + rows() + } + .modifier(MenuViewModifier()) + } +} + +/// One destination inside `PaymentsActionCard`: a tinted glyph and a label. +/// +/// No trailing chevron and no balance โ€” this row picks *where* the flow goes; +/// the amount and the balances belong to the screen it opens. +struct PaymentsActionRow: View { + let iconSystemName: String + let title: String + /// A destination the build cannot route yet renders dimmed and untappable + /// rather than being hidden, so the card matches the designed list. + var isEnabled: Bool = true + var action: () -> Void + + var body: some View { + Button(action: action) { + DashUIKit.MenuItem( + leadingIcon: .custom(iconSystemName, bundle: .dashUIKit), + isEnabled: isEnabled, + title: title, + accessory: .none + ) + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) + } +} + +#if DEBUG + +private func actionCardSample() -> some View { + VStack(spacing: 20) { + PaymentsActionCard(caption: "Internal transfer to/from") { + PaymentsActionRow(iconSystemName: DashIcon.Features.shield.rawValue, title: "Shielded") {} + PaymentsActionRow( + iconSystemName: DashIcon.Features.identity.rawValue, + title: "Identity", + isEnabled: false) {} + PaymentsActionRow(iconSystemName: DashIcon.Features.platform.rawValue, title: "Platform") {} + } + + PaymentsActionCard { + PaymentsActionRow(iconSystemName: "qrcode.viewfinder", title: "Scan QR") {} + PaymentsActionRow(iconSystemName: "paperplane.fill", title: "Send to address") {} + } + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Both cards") { + actionCardSample() +} + +@available(iOS 17, *) +#Preview("Dark") { + actionCardSample() + .preferredColorScheme(.dark) +} + +/// Long labels are the only thing that can wrap a row โ€” the glyph must stay +/// top-aligned with the first line rather than centring against two. +@available(iOS 17, *) +#Preview("Large type") { + actionCardSample() + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsReceiveContent.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsReceiveContent.swift new file mode 100644 index 0000000000..26b3a719a8 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsReceiveContent.swift @@ -0,0 +1,453 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The Receive tab: the balance toggle, the QR for its address, and the two +/// actions on that address. +/// +/// Takes the view model rather than a resolved address, because the toggle +/// writes back to it and the placeholder wording depends on which balance is +/// selected โ€” passing the parts separately would just rebuild the same object. +struct PaymentsReceiveContent: View { + @ObservedObject var viewModel: PaymentsLandingViewModel + + var onCopyAddress: () -> Void + var onShareAddress: () -> Void + var onSpecifyAmount: () -> Void + /// Opens the transaction behind a receipt. Passed through rather than + /// acted on โ€” which controller presents the details is the host's. + var onViewTransaction: (Data) -> Void = { _ in } + /// Closes the whole receive surface from the receipt's Done button. + var onDone: () -> Void = {} + /// `nil` renders the row dimmed and untappable. There is no import flow to + /// route to yet โ€” `ShortcutAction.importPrivateKey` is still a `break` in + /// `HomeViewController+Shortcuts` โ€” and a row that silently does nothing + /// reads as a broken button. + var onImportPrivateKey: (() -> Void)? = nil + + /// Which way the last network change moved along `receiveNetworks`, so the + /// incoming card slides in from the side it came from โ€” the same rule the + /// tab selector above follows. + @State private var slidesForward = true + + private enum Layout { + /// Matches `SegmentedControlLayout`'s own spring, so the pill and the + /// card it selects travel together. + static let slideResponse: Double = 0.3 + static let slideDamping: Double = 0.7 + } + + var body: some View { + VStack(alignment: .center, spacing: 20) { + // The same control the tab selector above it is, so the two rows of + // chrome read as one family rather than as a design-system pill + // sitting on top of a system segmented picker. + // + // Always present: Core and Shielded are both ordinary destinations. + // Advanced mode adds Platform, and `receiveNetworks` is what decides + // that โ€” the control only draws what it is given. + SegmentedControl( + options: viewModel.receiveNetworks, + selection: networkSelection, + label: { $0.title }) + // Negative on purpose. The host already insets this whole tab + // by 20 for the card below; -4 pulls the control back out to + // the 16 the tab selector directly above it uses. Two + // segmented controls stacked at different widths read as a + // mistake, and the top one sets the measure. + .padding(.horizontal, -4) + + if let receipt = viewModel.receipt { + // A payment landed while this screen was being presented. The + // receipt takes the whole surface: the address it was paid to + // is spent attention now, and the next thing the user does is + // finish or receive another. + ReceiveReceiptCard( + receipt: receipt, + canViewTransaction: viewModel.canViewTransaction, + onViewTransaction: onViewTransaction, + onReceiveAnother: viewModel.receiveAnother, + onDone: onDone) + .transition(.scale(scale: 0.94).combined(with: .opacity)) + } else { + addressContent + } + } + } + + // MARK: - Network selection + + /// Everything that changes the network goes through here so the direction + /// of travel is known before the change lands โ€” the card has to enter from + /// whichever side the new segment sits on, and only this knows which. + private var networkSelection: Binding { + Binding( + get: { viewModel.network }, + set: { select($0) }) + } + + private func select(_ network: ChainNetwork) { + let options = viewModel.receiveNetworks + guard let from = options.firstIndex(of: viewModel.network), + let to = options.firstIndex(of: network), + from != to + else { return } + + slidesForward = to > from + withAnimation(.spring(response: Layout.slideResponse, + dampingFraction: Layout.slideDamping)) { + viewModel.network = network + } + } + + /// Matches the direction of travel: moving right, the new card enters from + /// the right and the old one leaves to the left. + private var slide: AnyTransition { + .asymmetric( + insertion: .move(edge: slidesForward ? .trailing : .leading), + removal: .move(edge: slidesForward ? .leading : .trailing)) + } + + /// The QR, the address and the actions โ€” everything the screen shows while + /// it is still waiting to be paid. + private var addressContent: some View { + VStack(alignment: .center, spacing: 20) { + // The card itself holds still โ€” only what is inside it travels. + // ZStack so the outgoing and incoming contents share one slot + // instead of stacking; keyed on the network so a change reads as a + // replacement to animate rather than an in-place edit of the QR and + // the address. + ZStack(alignment: .top) { + cardBody + .id(viewModel.network) + .transition(slide) + } + // Keeps the travelling contents inside the card's edges. Square + // rather than the card's 20pt radius: nothing is drawn near the + // corners (40pt of top padding, 20 at the bottom), so the rounding + // would cost a mask for no visible difference. + .clipped() + .modifier(MenuViewModifier(innerPadding: 0)) + + // import private key - if needed + + if onImportPrivateKey != nil { + Button { + onImportPrivateKey?() + } label: { + DashUIKit.MenuItem( + leadingIcon: .custom(DashIcon.Menu.importPrivateKey.rawValue, bundle: .dashUIKit), + title: NSLocalizedString("Import private key", comment: "Payments"), + accessory: .none + ) + .modifier(MenuViewModifier()) + } + .buttonStyle(.plain) + } + } + } + + /// What the card shows for the selected network: the QR, the address and + /// the actions on it. + private var cardBody: some View { + VStack(alignment: .leading, spacing: 0) { + + VStack(alignment: .center, spacing: 20) { + qrCard + + // 12, not 40: the address needs every point it can get to + // stay on one line, and the gap was spending them on + // nothing. `maxWidth: .infinity` on the text column is what + // keeps the copy button on the trailing edge now that the + // gap no longer pushes it there. + HStack(spacing: 12) { + if let address = viewModel.currentAddress { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Your DASH address", comment: "Payments")) + .dashFont(.footnote) + .foregroundStyle(Color.dash.secondaryText) + + // One line, and cut in the middle when it does + // not fit. A Dash address is a single token the + // user reads across to check; broken over two + // lines it reads as two. Cutting the tail hides + // the half people actually check โ€” the last + // characters are what gets compared against the + // sender's screen โ€” so both ends stay. + Text(address) + .dashFont(.subhead) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + .truncationMode(.middle) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + // The address is the thing the user is reaching + // for; the button beside it is the affordance, not + // the only target. Tapping the text copies too. + .contentShape(Rectangle()) + .onTapGesture { onCopyAddress() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Text(NSLocalizedString("Copy", comment: ""))) + } else { + Text(NSLocalizedString("No address available", comment: "Payments")) + .font(.footnote) + .foregroundColor(Color.dash.secondaryText) + } + + + DashUIKit.DashButton( + leadingIcon: .custom(DashIcon.Icons.copyOutline.rawValue, bundle: .dashUIKit), + isEnabled: hasAddress, + size: .medium, + style: .tintedGray, + action: onCopyAddress + ) + } + .padding(.vertical, 6) + } + // Stretches the QR/address block across the card. Without it + // the block sizes to its content and the leading-aligned card + // pins it left, which is what happened when the row's spacing + // stopped being wide enough to fill the width on its own. + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 40) + .padding(.bottom, 10) + + // `isEnabled:` rather than `.disabled(โ€ฆ)`: DashButton derives + // its own colours from that property and applies `.disabled` + // internally, so an outside modifier blocks the tap but leaves + // the button looking live. + HStack(spacing: 20) { + DashUIKit.DashButton( + text: NSLocalizedString("Share address", comment: "Payments"), + isEnabled: hasAddress, + size: .medium, + style: .tintedGray, + action: onShareAddress + ) + + DashUIKit.DashButton( + text: NSLocalizedString("Specify amount", comment: "Payments"), + isEnabled: hasAddress && viewModel.network == .core, + size: .medium, + style: .tintedGray, + action: onSpecifyAmount + ) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 20) + + watchingForPayment + .padding(.top, 14) + .padding(.bottom, 20) + } + } + + @ViewBuilder + private var watchingForPayment: some View { + if viewModel.isWatchingForReceipt { + ReceiveWatchingIndicator() + } + } + + private var hasAddress: Bool { + viewModel.currentAddress != nil + } + + // MARK: - QR + + @ViewBuilder + private var qrCard: some View { + VStack(spacing: 20) { + if let address = viewModel.currentAddress, + let qr = QRCodeGenerator.image(for: address) { + Image(uiImage: qr) + .interpolation(.none) + .resizable() + .frame(width: 200, height: 200) + // White in both themes, not `secondaryBackground`: this is + // the code's quiet zone, and a scanner needs the contrast + // whatever the app is wearing. Without it the dark card ran + // straight up to the modules. + .padding(10) + .background(Color.dash.white) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } else if viewModel.network == .platform && !viewModel.platformIsReady { + placeholder(NSLocalizedString("Platform sync startingโ€ฆ", comment: "")) + } else if viewModel.network == .shielded { + // Nil only until the shielded sub-wallet binds at startup + // (the view model retries as the platform stack comes up). + placeholder(NSLocalizedString("Shielded wallet startingโ€ฆ", comment: "")) + } else { + placeholder(NSLocalizedString("No address available", comment: "")) + } + } + } + + /// Same 220pt square the QR occupies (200 + 10 padding a side), so + /// switching balances doesn't make the actions below jump. + private func placeholder(_ message: String) -> some View { + VStack(spacing: 8) { + SwiftUI.ProgressView() + Text(message) + .font(.footnote) + .foregroundColor(Color.dash.secondaryText) + } + .frame(width: 220, height: 220) + } + +} + +// MARK: - ReceiveReceiptCard + +/// The payment that just landed, in place of the address it was paid to. +/// +/// Ported from the attended-receive work rather than rebuilt: the header is the +/// same `PaymentSuccessHeader` the send flow finishes on, so a receipt and a +/// sent confirmation read as two sides of one screen instead of two designs. +/// +/// It reports rather than acts โ€” which controller shows the transaction, and +/// what Done closes, are the host's to decide. +private struct ReceiveReceiptCard: View { + let receipt: ReceiveReceipt + let canViewTransaction: Bool + let onViewTransaction: (Data) -> Void + let onReceiveAnother: () -> Void + let onDone: () -> Void + + var body: some View { + VStack(spacing: 16) { + PaymentSuccessHeader( + title: NSLocalizedString("Received", comment: "Receive success title"), + amountDuffs: Int64(clamping: receipt.amountDuffs), + fiatText: CurrencyExchanger.shared.fiatAmountString( + for: receipt.amountDuffs.dashAmount)) + + VStack(spacing: 8) { + row(NSLocalizedString("Balance", comment: "Receive receipt rail"), + receipt.rail.balanceName) + row(NSLocalizedString("Status", comment: "Receive receipt status"), + receipt.statusTitle) + row(NSLocalizedString("Time", comment: "Receive receipt time"), + receipt.receivedAt.formatted(date: .omitted, time: .shortened)) + if let memo = receipt.memo, !memo.isEmpty { + row(NSLocalizedString("Memo", comment: "Receive receipt memo"), memo) + } + } + .padding(14) + .modifier(MenuViewModifier(innerPadding: 0)) + + if canViewTransaction, let transactionId = receipt.transactionId { + Button { + onViewTransaction(transactionId) + } label: { + Text(NSLocalizedString("View transaction", comment: "Receive receipt action")) + .dashFont(.subheadMedium) + .foregroundColor(.dash.blue) + } + .buttonStyle(.plain) + } + + Spacer(minLength: 12) + + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Done", comment: "Receive receipt action"), + positiveButtonAction: onDone, + negativeButtonText: NSLocalizedString("Receive another", comment: "Receive receipt action"), + negativeButtonAction: onReceiveAnother) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func row(_ title: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .dashFont(.caption1) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(value) + .dashFont(.caption1Medium) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } +} + +// MARK: - ReceiveWatchingIndicator + +/// Says the screen is doing something on the user's behalf while an address is +/// on display, so a handoff does not feel like nothing is happening. +/// +/// Shared with the specify-amount sheet, which makes the same promise over a QR +/// that carries the amount โ€” the two must not drift into saying it differently. +struct ReceiveWatchingIndicator: View { + var body: some View { + HStack(spacing: 8) { + // `controlSize`, not `scaleEffect`: scaling resamples the spinner + // after it is drawn, which is what turned it into a crunchy + // asterisk. This asks UIKit for a small indicator and gets it drawn + // at that size. + SwiftUI.ProgressView() + .controlSize(.small) + Text(NSLocalizedString("Watching for a paymentโ€ฆ", comment: "Receive screen activity")) + .dashFont(.caption1) + .foregroundColor(.dash.secondaryText) + } + .frame(maxWidth: .infinity) + .transition(.opacity) + } +} + +#if DEBUG + +@MainActor +private func receiveSample( + network: ChainNetwork = .core, + coreAddress: String? = "XyZ8kFqW3nR5tHmB2vJcL7pQaS4dEuG9wN" +) -> some View { + PaymentsReceiveContent( + viewModel: .makeForPreview(activeTab: .receive, network: network, coreAddress: coreAddress), + onCopyAddress: {}, + onShareAddress: {}, + onSpecifyAmount: {}) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +@available(iOS 17, *) +#Preview("Core address") { + receiveSample() +} + +/// Both action pills disable without an address โ€” the row must not collapse. +@available(iOS 17, *) +#Preview("No address") { + receiveSample(coreAddress: nil) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsSendCard.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsSendCard.swift new file mode 100644 index 0000000000..3318d10626 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsSendCard.swift @@ -0,0 +1,135 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The Send tab's opening step: how to name a recipient. +/// +/// Two cards rather than one list, because the second is not another way to +/// name a Dash recipient โ€” it leaves Dash entirely for another chain, and +/// putting it under the same roof as the three address rows would read as a +/// fourth way to do the same thing. +struct PaymentsSendCard: View { + /// A username can only be sent to from an identity that has one, and the + /// row opens the contact book โ€” which has nothing to show without it. + var showsSendToUsername: Bool = false + var onSendToUsername: () -> Void = {} + var onSendToAddress: () -> Void + var onScanQR: () -> Void + /// Off on testnet and without a SwapKit key โ€” the portal swaps real assets + /// and can do neither. The whole second card goes with it rather than + /// showing a card with one dead row. + var showsSwapToCrypto: Bool = false + var onSwapToCrypto: () -> Void = {} + + private enum Layout { + /// Enough to read as two groups without either drifting away from the + /// selector above them. + static let cardSpacing: CGFloat = 12 + } + + var body: some View { + VStack(spacing: Layout.cardSpacing) { + PaymentsActionCard { + if showsSendToUsername { + PaymentsActionRow( + iconSystemName: DashIcon.Menu.sendAccount.rawValue, + title: NSLocalizedString("Send to username", comment: "Payments"), + action: onSendToUsername + ) + } + + PaymentsActionRow( + iconSystemName: DashIcon.Menu.sendAddress.rawValue, + title: NSLocalizedString("Send to Dash address", comment: "Payments"), + action: onSendToAddress + ) + + PaymentsActionRow( + iconSystemName: DashIcon.Menu.scanQR.rawValue, + title: NSLocalizedString("Scan Dash QR", comment: "Payments"), + action: onScanQR + ) + } + + if showsSwapToCrypto { + PaymentsActionCard { + PaymentsActionRow( + iconSystemName: DashIcon.Menu.swapDashCoin.rawValue, + title: NSLocalizedString("Swap to other crypto", comment: "Payments"), + action: onSwapToCrypto + ) + } + } + } + } +} + +#if DEBUG + +private func sendCardSample( + showsSendToUsername: Bool = false, + showsSwapToCrypto: Bool = true +) -> some View { + PaymentsSendCard( + showsSendToUsername: showsSendToUsername, + onSendToUsername: {}, + onSendToAddress: {}, + onScanQR: {}, + showsSwapToCrypto: showsSwapToCrypto, + onSwapToCrypto: {}) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) +} + +/// No identity: the username row is absent and the first card carries two rows. +@available(iOS 17, *) +#Preview("No username") { + sendCardSample() +} + +/// Testnet, or a build with no SwapKit key: the second card is gone entirely +/// and the tab is the three Dash rows it was before. +@available(iOS 17, *) +#Preview("No swap") { + sendCardSample(showsSendToUsername: true, showsSwapToCrypto: false) +} + +/// The registered-identity presentation โ€” the row this tab gains, and the one +/// that changes the first card's height. +@available(iOS 17, *) +#Preview("With username") { + sendCardSample(showsSendToUsername: true) +} + +@available(iOS 17, *) +#Preview("Dark") { + sendCardSample(showsSendToUsername: true) + .preferredColorScheme(.dark) +} + +/// The two cards must stay legible as two groups when the rows grow โ€” this is +/// where a single merged list would start to look right and be wrong. +@available(iOS 17, *) +#Preview("Large type") { + sendCardSample(showsSendToUsername: true) + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsTabSelector.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsTabSelector.swift new file mode 100644 index 0000000000..4f93fe622c --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsTabSelector.swift @@ -0,0 +1,138 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DashUIKit + +/// The payments landing's Receive / Internal / Send selector: an arrow icon +/// over a label per segment, the selected one raised on a filled pill. +/// +/// The drawing is the app's `SegmentedControl` in its icon variant, not a +/// local lookalike. The landing used to hand-roll the same pill and had already +/// drifted from it โ€” flatter corners, a tighter shadow โ€” while also missing the +/// sliding indicator, drag-to-select and the `.isSelected` accessibility trait +/// the shared control provides. +/// +/// What stays here is the landing's own decision: which tabs to offer, and how +/// a `PaymentsLandingTab` names and illustrates itself. The balance-row sheets +/// narrow the list to two, so the caller passes it in rather than the selector +/// reading `PaymentsLandingViewModel`. +struct PaymentsTabSelector: View { + let tabs: [PaymentsLandingTab] + @Binding var selection: PaymentsLandingTab + + var body: some View { + SegmentedControl( + options: tabs, + selection: $selection, + label: { $0.title }, + icon: { tab, isSelected in tab.icon(isSelected: isSelected) }) + } +} + +/// The arrow a tab draws, in the direction's own colour when it is the selected +/// tab and in grey when it is not. +/// +/// Two files per tab rather than one tinted two ways: the colour lives in the +/// artwork, and a coloured arrow dimmed to the unselected treatment would no +/// longer match the grey label beside it. +/// +/// Here rather than beside `title` on the tab itself: the enum lives in the +/// view model, which has no business importing DashUIKit, and which arrow a tab +/// shows is this selector's decision the same way the title is. +private extension PaymentsLandingTab { + func icon(isSelected: Bool) -> DashIconSource { + switch self { + case .receive: + return (isSelected ? DashIcon.SegmentedControl.receive : .receiveDisabled).source + case .internalTransfer: + return (isSelected ? DashIcon.SegmentedControl.transfer : .transferDisabled).source + case .send: + return (isSelected ? DashIcon.SegmentedControl.send : .sendDisabled).source + } + } +} + +#if DEBUG + +/// Live selection, so the canvas exercises the tap path rather than a frozen +/// snapshot of one state. +private struct PaymentsTabSelectorPreview: View { + let tabs: [PaymentsLandingTab] + @State private var selection: PaymentsLandingTab + + init(tabs: [PaymentsLandingTab] = PaymentsLandingTab.allCases, + selection: PaymentsLandingTab = .internalTransfer) { + self.tabs = tabs + _selection = State(initialValue: selection) + } + + var body: some View { + PaymentsTabSelector(tabs: tabs, selection: $selection) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.dash.primaryBackground) + } +} + +@available(iOS 17, *) +#Preview("Three tabs") { + PaymentsTabSelectorPreview() +} + +/// Each end of the track โ€” the pill's corners sit inside the container's, so +/// the first and last segments are where that reads worst. +@available(iOS 17, *) +#Preview("First selected") { + PaymentsTabSelectorPreview(selection: .receive) +} + +@available(iOS 17, *) +#Preview("Last selected") { + PaymentsTabSelectorPreview(selection: .send) +} + +/// The balance-row receive sheet narrows to two tabs โ€” the segments must +/// redistribute rather than leave a gap where Send was. +@available(iOS 17, *) +#Preview("Two tabs") { + PaymentsTabSelectorPreview( + tabs: [.receive, .internalTransfer], + selection: .receive) +} + +/// One tab: the pill spans the whole track. +@available(iOS 17, *) +#Preview("One tab") { + PaymentsTabSelectorPreview(tabs: [.internalTransfer]) +} + +@available(iOS 17, *) +#Preview("Dark") { + PaymentsTabSelectorPreview() + .preferredColorScheme(.dark) +} + +/// Labels grow but the icons don't, and the labels are the only thing that can +/// wrap โ€” the widest point for the selected pill. +@available(iOS 17, *) +#Preview("Large type") { + PaymentsTabSelectorPreview() + .environment(\.dynamicTypeSize, .accessibility1) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingHostingController.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingHostingController.swift index 6dc5c0e19a..4dc983e115 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingHostingController.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingHostingController.swift @@ -26,6 +26,7 @@ final class PaymentsLandingHostingController: DWBasePayViewController { let screen = PaymentsLandingScreen( viewModel: viewModel, onClose: { [weak self] in self?.dismiss(animated: true) }, + onDone: { [weak self] in self?.finishReceiving() }, onCopyAddress: { [weak self] in self?.copyCurrentAddress() }, onShareAddress: { [weak self] in self?.shareCurrentAddress() }, onSpecifyAmount: { [weak self] in self?.pushSpecifyAmount() }, @@ -42,6 +43,10 @@ final class PaymentsLandingHostingController: DWBasePayViewController { viewModel: self.embeddedSendViewModel, onSendCompleted: { [weak self] in self?.dismiss(animated: true) }) }, + onSendToAddress: { [weak self] in self?.pushSendToAddress() }, + onSendToUsername: { [weak self] in self?.showContactBook() }, + onSwapToCrypto: { [weak self] in self?.presentDashDEX() }, + onCloseLanding: { [weak self] in self?.leaveLanding() }, showsHeader: showsHeader) return UIHostingController(rootView: screen) }() @@ -53,6 +58,22 @@ final class PaymentsLandingHostingController: DWBasePayViewController { private static let shieldedBalanceTimingShownKey = "DWShieldedBalanceTimingShown" private var cancellables = Set() + /// Set while the receive flow pushes one of its OWN steps. + /// + /// Specify Amount is not somewhere else โ€” it is the next screen of the same + /// receive, and the address it is naming an amount for is the one the + /// session was armed on. Without this the landing's `viewWillDisappear` + /// suspends watching the moment that screen is pushed, so nothing is + /// detected while the user is on the very screen they are handing over. + private var isPushingReceiveStep = false + /// The specify-amount sheet while it is up, so a receipt can dismiss it. + private weak var requestAmountController: RequestAmountHostingController? + /// Kept apart from `cancellables`: these live exactly as long as that sheet + /// does, and are dropped with it rather than for the screen's lifetime. + private var requestAmountObservers = Set() + /// Live for as long as a receive step is pushed โ€” the sheet is optional + /// within that, and the return has to happen with or without it. + private var receiveStepObservers = Set() /// The Internal tab was activated before the landing finished appearing /// (e.g. it is the initial tab) โ€” present the timing sheet from /// `viewDidAppear` instead of against a view that isn't on screen yet. @@ -192,6 +213,13 @@ final class PaymentsLandingHostingController: DWBasePayViewController { } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // Also on the way back from a pushed step, which restored the bar on + // its own way out. + applyTabBarVisibility() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Programmatic dismissal does not call @@ -199,6 +227,8 @@ final class PaymentsLandingHostingController: DWBasePayViewController { // here as well so returning from transaction details can resume the // same receive session (or start a fresh "Receive another" session). viewModel.setReceiptWatchingObscured(false) + isPushingReceiveStep = false + receiveStepObservers.removeAll() viewModel.setReceiveSurfaceVisible(true) if timingSheetPendingAppearance { timingSheetPendingAppearance = false @@ -208,16 +238,25 @@ final class PaymentsLandingHostingController: DWBasePayViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + // Stepping deeper into the receive flow is not leaving it. Everything + // else โ€” a tab change, a dismissal, a pop โ€” still puts the session to + // sleep. + guard !isPushingReceiveStep else { return } viewModel.setReceiveSurfaceVisible(false) + // Leaving for another tab, or being dismissed โ€” either way the bar is + // not ours to keep hidden. A pushed step hides it for itself. + if presentingViewController == nil { + tabBarController?.setTabBarHidden(false, animated: false) + } } // MARK: - Actions + /// Copies only. The "Copied" toast is drawn by the SwiftUI screen โ€” this + /// controller has no view of its own to hang it on that isn't behind the + /// tab bar. private func copyCurrentAddress() { viewModel.copyCurrentAddressToPasteboard() - view.dw_showInfoHUD( - withText: NSLocalizedString("Copied", comment: ""), - offsetForNavBar: false) } private func shareCurrentAddress() { @@ -236,7 +275,42 @@ final class PaymentsLandingHostingController: DWBasePayViewController { private func pushSpecifyAmount() { let specify = SpecifyAmountViewController.controller() specify.delegate = self - navigationController?.pushViewController(specify, animated: true) + isPushingReceiveStep = true + observeReceiptWhileOnReceiveStep() + pushWithoutTabBar(specify) + } + + /// Bring the user back to the receipt from wherever in the receive flow + /// they are standing. + /// + /// Armed by the push, not by the sheet: the sheet is optional โ€” Specify + /// Amount can be sat on with nothing over it โ€” and a receipt arriving there + /// used to leave the user one screen short of the thing they were waiting + /// for, with no way to know it had happened. + private func observeReceiptWhileOnReceiveStep() { + receiveStepObservers.removeAll() + + viewModel.$receipt + .compactMap { $0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + self.receiveStepObservers.removeAll() + + guard let sheet = self.requestAmountController else { + self.returnToLandingFromReceiveStep() + return + } + // The sheet first, then the step under it โ€” dismissing and + // popping in the same turn runs the two animations over each + // other. + self.requestAmountObservers.removeAll() + self.requestAmountController = nil + sheet.dismiss(animated: true) { [weak self] in + self?.returnToLandingFromReceiveStep() + } + } + .store(in: &receiveStepObservers) } /// The base class routes a scanned payment straight into the payment @@ -256,6 +330,158 @@ final class PaymentsLandingHostingController: DWBasePayViewController { /// First-ever visit to the free-form Internal tab: explain transfer /// timing before the user composes a transfer. The form is already /// embedded underneath; "I got it" (not the X) acknowledges for good. + /// Puts the landing on `tab` without rebuilding it โ€” the tab-bar entry + /// points select this controller rather than presenting a copy. + func select(tab: PaymentsLandingTab) { + viewModel.activeTab = tab + } + + #if DASHPAY + /// "Send to username" โ†’ the contact book. The same screen the Send-to-a- + /// contact entry opens (`PayableViewController.performPayToDashPayUser`); + /// paying happens from a contact's profile sheet, so this row's whole job + /// is getting the user there. + /// + /// Selects the contacts TAB rather than showing a copy of the screen. + /// + /// `ContactsScreen` is a tab root and only works as one. It runs its banner + /// under the status bar (`ignoresSafeArea(edges: .top)`) and lets the safe + /// area place the title inside it, which collapses in a sheet; and it + /// carries no dismiss control, because a tab root never needs one โ€” pushing + /// it onto this stack left the user with no way back, since the payments + /// navigation controller hides its bar. + /// + /// The tab is there whenever this row is: both appear only with a DashPay + /// identity. The tab bar is the way back, and there stays exactly one + /// contacts screen in the app. + private func showContactBook() { + guard let tabBarController = tabBarController as? MainTabbarController, + tabBarController.showContacts() + else { + // No contacts tab means no identity โ€” which is also the condition + // that hides the row. Reaching here would be a bug, and silently + // doing nothing is how it would stay invisible. + assertionFailure("Send to username offered without a contacts tab") + return + } + } + #else + private func showContactBook() {} + #endif + + /// The X above the Internal form. Dismisses where something presented this + /// landing, and leaves for the history where nothing did โ€” as the payments + /// tab's root, `dismiss` is a no-op, which is the bug the receive receipt's + /// Done button had. + private func leaveLanding() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + (tabBarController as? MainTabbarController)?.showHome() + } + } + + /// The tab bar goes while the landing is up, and the X above the selector + /// takes its place as the way out. + /// + /// The Internal tab forced the question โ€” it embeds the transfer form now, + /// and its keypad and Continue button were sharing the bottom of the screen + /// with the bar, which the payments tab's own rule already forbids: the bar + /// is gone from the first step that asks for an amount. Hiding it on that + /// tab alone, though, would flicker the chrome in and out as the user moved + /// between the three, so it goes for the whole landing. + /// + /// Only meaningful on the tab. The balance-row sheets are presented and + /// have no tab bar to hide. + private func applyTabBarVisibility() { + guard let tabBarController, presentingViewController == nil else { return } + tabBarController.setTabBarHidden(true, animated: true) + } + + /// "Swap to other crypto" โ†’ the Dash DEX portal. + /// + /// Behind the same authentication gate the Home shortcut puts it behind: + /// the portal is a spending surface, and a destination that asks for a PIN + /// from one entry point and not another is not a gate at all. + private func presentDashDEX() { + AuthenticationService.shared.authenticate( + withPrompt: nil, + usingBiometricAuthentication: DWGlobalOptions.sharedInstance().biometricAuthEnabled, + alertIfLockout: true + ) { [weak self] authenticated, _, _ in + guard authenticated, let self else { return } + let controller = SwapKitPortalViewController() + controller.hidesBottomBarWhenPushed = true + let navigationController = BaseNavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .fullScreen + self.present(navigationController, animated: true) + } + } + + /// Send card โ†’ the address-entry form. Pushed rather than embedded: the + /// landing's Send tab is now the destination picker, not the form. + /// Where Done on a receive receipt goes. + /// + /// Presented as a sheet there is something to dismiss; as the payments + /// tab's root there is not โ€” `dismiss` was a no-op, which is why the button + /// appeared dead. Leaving for the history is what finishing means there, + /// and it is where the receipt's transaction shows up. + private func finishReceiving() { + if presentingViewController != nil { + dismiss(animated: true) + } else { + (tabBarController as? MainTabbarController)?.showHome() + } + } + + /// Mirror the session into the specify-amount sheet, and close that sheet + /// the moment a payment lands โ€” the receipt is on the surface behind it, + /// and a QR for an amount just paid would be the stalest thing on screen. + private func observeReceiptWhileRequestingAmount(_ controller: RequestAmountHostingController) { + requestAmountObservers.removeAll() + + viewModel.$isWatchingForReceipt + .receive(on: RunLoop.main) + .sink { [weak controller] watching in + controller?.setWatchingForReceipt(watching) + } + .store(in: &requestAmountObservers) + + } + + /// Pop back to the landing from a pushed receive step, if that is where we + /// are. `isPushingReceiveStep` is the flag for exactly that state, and + /// `viewDidAppear` clears it on arrival. + /// + /// The pop targets the STACK MEMBER holding this controller, not this + /// controller. In the payments tab the landing is a child of + /// `PaymentsTabRootController`, which is what the navigation stack + /// actually contains; presented, it is the navigation controller's own + /// root. Popping to `self` therefore named a controller that is not in the + /// stack at all, and UIKit had nothing to pop to โ€” the sheet closed and + /// nothing else moved. + private func returnToLandingFromReceiveStep() { + guard isPushingReceiveStep, let navigationController else { return } + + let host = navigationController.viewControllers.first { controller in + controller === self || controller.children.contains { $0 === self } + } + guard let host, navigationController.topViewController !== host else { return } + + navigationController.popToViewController(host, animated: true) + } + + private func pushSendToAddress() { + pushWithoutTabBar(SendScreenViewController()) + } + + /// The landing keeps the tab bar; everything it pushes is a step in a + /// flow and takes the whole screen. + private func pushWithoutTabBar(_ controller: UIViewController) { + controller.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(controller, animated: true) + } + private func presentTransferTimingSheetIfNeeded() { guard !UserDefaults.standard.bool(forKey: Self.shieldedBalanceTimingShownKey), presentedViewController == nil @@ -268,9 +494,33 @@ final class PaymentsLandingHostingController: DWBasePayViewController { self?.viewModel.setReceiptWatchingObscured(false) } })) + host.modalPresentationStyle = .pageSheet + // Fill the whole sheet, including the bottom safe-area strip, with the + // sheet background โ€” the detent paints that strip itself. + host.view.backgroundColor = UIColor(Color.dash.primaryBackground) + if let sheet = host.sheetPresentationController { - sheet.detents = [.medium()] - sheet.prefersGrabberVisible = true + // `BottomSheet` draws its own grabber. + sheet.prefersGrabberVisible = false + if #unavailable(iOS 26.0) { + sheet.preferredCornerRadius = 24 + } + // SwiftUI's `.presentationDetents` does not bridge to a + // `UIHostingController` presented with `present()` โ€” UIKit falls + // back to `.large` โ€” so the content is measured here and given a + // matching detent. Mirrors `HomeViewController`'s reminder sheet. + if #available(iOS 16.0, *) { + let width = view.bounds.width + let bottomInset = view.window?.safeAreaInsets.bottom ?? 0 + let contentHeight = host + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + .height + sheet.detents = [.custom { context in + min(contentHeight + bottomInset, context.maximumDetentValue) + }] + } else { + sheet.detents = [.medium()] + } } present(host, animated: true) { [weak self, weak host] in host?.presentationController?.delegate = self @@ -318,29 +568,53 @@ extension PaymentsLandingHostingController: NavigationBarDisplayable { extension PaymentsLandingHostingController: SpecifyAmountViewControllerDelegate { func specifyAmountViewController(_ vc: SpecifyAmountViewController, didInput amount: UInt64) { - let model = DWReceiveModel(amount: amount) - - let requestController = DWRequestAmountViewController(model: model) - requestController.delegate = self - viewModel.setReceiptWatchingObscured(true) - present(requestController, animated: true) { [weak self, weak requestController] in - requestController?.presentationController?.delegate = self - } + // The SwiftUI sheet, not `DWRequestAmountViewController`: this landing + // is the redesigned path, and it sizes its own detent. The ObjC pair is + // still what the legacy `ReceiveViewController` presents. + // + // The session keeps running behind THIS sheet, unlike every other one + // the landing presents. + // + // #1041 suspends watching whenever something covers the receive UI, and + // for the share sheet or transaction details that is right โ€” the user + // is not presenting anything. This sheet is the opposite: a QR with the + // amount already in it, held up for someone to pay. Pausing detection + // on the handoff surface would pause it exactly where the feature is + // for. Nothing is lost either way โ€” the session's baseline survives a + // suspend โ€” but the sheet could not say it was watching, and could not + // get out of the way when the payment landed. + let requestController = RequestAmountHostingController.present( + from: self, + model: DWReceiveModel(amount: amount), + delegate: self) + requestAmountController = requestController + requestController.setWatchingForReceipt(viewModel.isWatchingForReceipt) + observeReceiptWhileRequestingAmount(requestController) } } -// MARK: DWRequestAmountViewControllerDelegate +// MARK: RequestAmountHostingControllerDelegate -extension PaymentsLandingHostingController: DWRequestAmountViewControllerDelegate { - func requestAmountViewController(_ controller: DWRequestAmountViewController, didReceiveAmountWithInfo info: String) { +extension PaymentsLandingHostingController: RequestAmountHostingControllerDelegate { + /// The requested amount arrived, which this sheet notices through its own + /// balance-change observer. Getting out of the way is all that is left to + /// do: the attended receipt on the surface behind already says what landed, + /// with the rail, the status and the transaction. + /// + /// It used to pop the navigation stack and raise an info HUD as well. Both + /// were written for a landing that had been PUSHED โ€” as the payments tab's + /// root there is nothing to pop, and that HUD went onto the navigation + /// controller's view, where it drew underneath the tab bar as an + /// unidentifiable smudge. + func requestAmountHostingController( + _ controller: RequestAmountHostingController, + didReceiveAmountWithInfo info: String + ) { controller.dismiss(animated: true) { - self.viewModel.setReceiptWatchingObscured(false) - self.navigationController?.popViewController(animated: true) - - let popAnimationDuration = 300 - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(popAnimationDuration)) { - self.navigationController?.view.dw_showInfoHUD(withText: info) - } + // No un-obscuring: this sheet never suspended the session. What + // does end here is the sheet's own mirroring of it. + self.requestAmountObservers.removeAll() + self.requestAmountController = nil } } } diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index 1dc7c1d281..a2177b6d27 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -9,12 +9,18 @@ import UIKit struct PaymentsLandingScreen: View { @ObservedObject var viewModel: PaymentsLandingViewModel - @Environment(\.colorScheme) private var colorScheme var onClose: () -> Void + /// Done on a receive receipt. Separate from `onClose`, which dismisses: + /// this landing is also the payments TAB's root, where nothing presented it + /// and dismissing is a no-op โ€” which is why Done did nothing there. + var onDone: () -> Void var onCopyAddress: () -> Void var onShareAddress: () -> Void var onSpecifyAmount: () -> Void + /// Opens the transaction behind a receive receipt. The host resolves the + /// txid and presents the details, and pauses the attended session while + /// they are up. var onViewTransaction: (Data) -> Void var onScanQR: () -> Void /// The Internal tab's embedded transfer form. The full landing shows it @@ -37,298 +43,433 @@ struct PaymentsLandingScreen: View { /// The Send tab's address is valid โ†’ advance to the amount step. The host /// pushes `ExternalSendAmountScreen` onto the landing's navigation stack. var onSendContinue: () -> Void = {} + /// "Send to Dash address" on the Send tab's card โ€” the host pushes the send + /// form. (Its sibling row routes through `onScanQR`.) + var onSendToAddress: () -> Void = {} + /// "Send to username" โ€” the host presents the contact book. Offered only + /// while `viewModel.canSendToUsername`. + var onSendToUsername: () -> Void = {} + /// "Swap to other crypto" โ€” the host presents the Dash DEX portal. This + /// leaves Dash for another chain and is not an internal transfer. + var onSwapToCrypto: () -> Void = {} + /// Leaves the payments tab entirely โ€” the X above the Internal form. The + /// host resolves where that goes; on the tab there is nothing to dismiss. + var onCloseLanding: () -> Void = {} /// False for the balance-row receive/send sheets: their grabber + hero /// selector are the top chrome โ€” no X close button or title row. var showsHeader: Bool = true + /// Raised by the Receive tab's copy button. Lives here rather than in + /// `PaymentsReceiveContent` so the toast floats over the whole screen โ€” + /// inside that subtree it would be pinned above the action buttons. + @State private var showsCopiedToast = false + /// Which way the last tab change moved along `visibleTabs`, so the + /// incoming content slides in from the side it came from. + @State private var slidesForward = true + + private enum Layout { + /// Side margin for the picker cards, so they line up with the tab + /// selector above them instead of running to the screen edges. + static let cardHorizontalPadding: CGFloat = 20 + /// Gap under the (currently unreachable) header. + static let headerBottomPadding: CGFloat = 20 + /// Breathing room above the selector where it is the first thing on + /// screen โ€” the balance-row sheets, under their grabber. + static let selectorTopPadding: CGFloat = 20 + /// Tighter for the balance-row sheets: the form they embed needs the + /// vertical room for its amount row, endpoint cards and keypad. + static let embeddedFormTopPadding: CGFloat = 12 + /// Far enough that a tap that drifts, or a vertical flick, is not read + /// as a tab change. + static let swipeMinimumDistance: CGFloat = 24 + /// Matches the selector pill's own spring (`SegmentedControlLayout`), + /// so the pill and the content travel together. + static let slideResponse: Double = 0.3 + static let slideDamping: Double = 0.7 + } + + /// Who the screen is drawing for. The payments tab shows pickers; the + /// balance-row sheets hand one pinned endpoint to a whole embedded form + /// instead. Resolved once here rather than re-derived per tab, so the + /// spacing and the content can never disagree about which one it is. + private enum Mode { + /// The payments tab: every tab opens on a destination card. + case picker + /// Balance-row send sheet โ€” the tapped balance is the source. + case sendingFrom(ChainNetwork) + /// Balance-row receive sheet โ€” the receive toggle's balance is the + /// destination, kept in lockstep by the hosting controller. + case receivingInto + } + + private var mode: Mode { + if let transferSendFrom { return .sendingFrom(transferSendFrom) } + if transferReceivePinned { return .receivingInto } + return .picker + } + + private var isPickerMode: Bool { + if case .picker = mode { return true } + return false + } + + /// A horizontal swipe changes tab anywhere on the payments tab, including + /// over the Internal form's keypad. + /// + /// It costs nothing there: `embeddedTransferViewModel` is owned by the + /// hosting controller, not by the tab, so a typed amount is still in the + /// field on the way back. Off only in the balance-row sheets, which are one + /// pinned route each and have no tabs to page between. + private var allowsTabSwipe: Bool { + isPickerMode + } + + /// Whether anything is drawn above the tab selector โ€” the back header, or + /// the close bar the payments tab carries now that it has no tab bar. + private var hasTopChrome: Bool { + showsHeader || isPickerMode + } + 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. - let isEmbeddedForm = viewModel.activeTab != .receive - VStack(alignment: .center, spacing: isEmbeddedForm ? 12 : 20) { + VStack(alignment: .center, spacing: 10) { if showsHeader { header + } else if isPickerMode { + closeBar } - tabSelector - .padding(.horizontal, 20) - .padding(.top, showsHeader ? 0 : 12) + VStack(alignment: .center, spacing: 20) { + PaymentsTabSelector( + tabs: viewModel.visibleTabs, + selection: tabSelection + ) + .frame(height: 70) + // Only where the selector is the top of the screen. With chrome + // above it this is a second top margin stacked on the VStack's + // own spacing, and the two read as one oversized gap. + .padding(.top, hasTopChrome ? 0 : Layout.selectorTopPadding) + .padding(.horizontal, 16) - switch viewModel.activeTab { - case .receive: - receiveContent - if viewModel.receipt == nil { - Spacer() + // ZStack, not a plain sibling: during the slide both the + // outgoing and incoming tab exist, and they have to share one + // slot instead of stacking and shoving the layout. + ZStack(alignment: .top) { + tabContent + .id(viewModel.activeTab) + .transition(slide) } - case .internalTransfer: - if let sendFrom = transferSendFrom { - // Send sheet: the From card is pinned by the tapped - // balance; the To rows pick the destination. - InternalTransferScreen( - viewModel: embeddedTransferViewModel, - onCompleted: onTransferCompleted, - showsHeader: false, - sendFrom: sendFrom) - } else if transferReceivePinned { - // The hosting controller keeps the pinned route in - // lockstep with the receive toggle (its `$network` - // subscription), so the fixed To card and the executed - // transfer can never disagree. - InternalTransferScreen( - viewModel: embeddedTransferViewModel, - onCompleted: onTransferCompleted, - showsHeader: false, - receiveInto: viewModel.network) - } else { - // Full landing: the transfer form itself, free From and - // To pickers โ€” no intermediate action-row step. - InternalTransferScreen( - viewModel: embeddedTransferViewModel, - onCompleted: onTransferCompleted, - showsHeader: false) - } - case .send: - SendScreen( - viewModel: embeddedSendViewModel, - onClose: onClose, - onScanQR: onScanQR, - onContinue: onSendContinue, - showsHeader: false) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // A mask rather than `clipped()`, and one that reaches into the + // bottom safe area. The clip is here to stop the outgoing and + // incoming tabs drawing over each other as they slide, which is + // a horizontal job; clipping the bottom as well cut off the + // keypad panel's own run into that strip, leaving it to end in + // mid-air above the home indicator. + .mask(Rectangle().ignoresSafeArea(edges: .bottom)) } } .background(Color.dash.primaryBackground) + // Makes the empty area below the card draggable too, so the swipe + // works on the whole screen rather than only over the content. + .contentShape(Rectangle()) + // `.gesture` (not `highPriority`) so rows and buttons keep winning + // their taps. `including:` is how the gesture is switched off โ€” the + // modifier has no nil overload. + .gesture(tabSwipe, including: allowsTabSwipe ? .all : .subviews) .navigationBarHidden(true) + // Keyed on the receipt's id, not on the receipt: a second payment + // arriving replaces the card rather than cross-fading into itself. .animation(.spring(response: 0.45, dampingFraction: 0.82), value: viewModel.receipt?.id) .sensoryFeedback(.success, trigger: viewModel.receipt?.id) + .transientToast( + isPresented: $showsCopiedToast, + style: .copied, + message: NSLocalizedString("Copied", comment: "")) } - // MARK: - Header + // MARK: - Swipe between tabs - 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)) + /// A horizontal swipe anywhere on the screen moves the selector one tab, + /// the way the segmented control above it implies it should. + /// + /// Off in the balance-row sheets (`including: .subviews` above): there a + /// tab is a whole form with a keypad, and paging away mid-entry would drop + /// what the user typed. The picker tabs have nothing to lose and no scroll + /// view of their own to fight over the gesture. + private var tabSwipe: some Gesture { + DragGesture(minimumDistance: Layout.swipeMinimumDistance) + .onEnded { value in + // Ignore a mostly-vertical drag, so a flick down the screen + // doesn't change tab on its horizontal wobble. + guard abs(value.translation.width) > abs(value.translation.height) else { return } + selectTab(offsetBy: value.translation.width < 0 ? 1 : -1) } - Spacer() - Text(headerTitle) - .font(.headline) - .foregroundColor(.dash.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - .padding(.horizontal, 20) - .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: "") - } - } + /// Moves `offset` tabs along `visibleTabs`, stopping at either end rather + /// than wrapping โ€” the selector shows the whole list, so wrapping would + /// read as the pill teleporting. + private func selectTab(offsetBy offset: Int) { + let tabs = viewModel.visibleTabs + guard let current = tabs.firstIndex(of: viewModel.activeTab) else { return } + let target = current + offset + guard tabs.indices.contains(target) else { return } - // 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) + select(tabs[target]) } - // MARK: - Receive + /// Everything that changes the tab goes through here, including the + /// selector's own taps and drag โ€” the content slides in from whichever + /// side the new tab sits on, and only this knows which side that is. + private var tabSelection: Binding { + Binding( + get: { viewModel.activeTab }, + set: { select($0) }) + } - private var receiveContent: some View { - VStack(alignment: .center, spacing: 20) { - ChainNetworkToggle(selection: $viewModel.network, options: ChainNetwork.allCases) - .padding(.horizontal, 20) + private func select(_ tab: PaymentsLandingTab) { + let tabs = viewModel.visibleTabs + guard let from = tabs.firstIndex(of: viewModel.activeTab), + let to = tabs.firstIndex(of: tab), + from != to + else { return } - if let receipt = viewModel.receipt { - receiptCard(receipt) - .transition(.scale(scale: 0.94).combined(with: .opacity)) - } else { - qrCard + slidesForward = to > from + withAnimation(.spring(response: Layout.slideResponse, + dampingFraction: Layout.slideDamping)) { + viewModel.activeTab = tab + } + } - HStack(spacing: 12) { - Button(action: onShareAddress) { - actionPill(title: NSLocalizedString("Share address", comment: "")) - } - .disabled(viewModel.currentAddress == nil) + /// Matches the direction of travel: going right, the new tab enters from + /// the right and the old one leaves to the left. + private var slide: AnyTransition { + .asymmetric( + insertion: .move(edge: slidesForward ? .trailing : .leading), + removal: .move(edge: slidesForward ? .leading : .trailing)) + } - Button(action: onSpecifyAmount) { - actionPill(title: NSLocalizedString("Specify amount", comment: "")) - } - .disabled(viewModel.currentAddress == nil || viewModel.network != .core) - } - .padding(.horizontal, 20) - - if viewModel.isWatchingForReceipt { - HStack(spacing: 8) { - SwiftUI.ProgressView() - .scaleEffect(0.8) - Text(NSLocalizedString("Watching for a paymentโ€ฆ", comment: "Receive screen activity")) - .font(.caption) - .foregroundColor(.dash.secondaryText) - } - .transition(.opacity) - } - } + /// Wraps a picker card as ONE view: spaced off the selector and pinned to + /// the top. The Spacer has to live inside the branch โ€” as a sibling in the + /// outer stack it would not travel with the card, and the slide would tear + /// the two apart. + private func pickerLayout(@ViewBuilder _ content: () -> Content) -> some View { + VStack(spacing: 0) { + content() + Spacer(minLength: 0) } } - private func receiptCard(_ receipt: ReceiveReceipt) -> some View { - VStack(spacing: 16) { - PaymentSuccessHeader( - title: NSLocalizedString("Received", comment: "Receive success title"), - amountDuffs: Int64(clamping: receipt.amountDuffs), - fiatText: CurrencyExchanger.shared.fiatAmountString( - for: receipt.amountDuffs.dashAmount)) - - VStack(spacing: 8) { - receiptInfoRow( - title: NSLocalizedString("Balance", comment: "Receive receipt rail"), - value: receipt.rail.balanceName) - receiptInfoRow( - title: NSLocalizedString("Status", comment: "Receive receipt status"), - value: receipt.statusTitle) - receiptInfoRow( - title: NSLocalizedString("Time", comment: "Receive receipt time"), - value: receipt.receivedAt.formatted(date: .omitted, time: .shortened)) - if let memo = receipt.memo, !memo.isEmpty { - receiptInfoRow( - title: NSLocalizedString("Memo", comment: "Receive receipt memo"), - value: memo) - } - } - .padding(14) - .background(Color.dash.secondaryBackground) - .cornerRadius(14) - .padding(.horizontal, 20) - - if viewModel.canViewTransaction, let transactionId = receipt.transactionId { - Button { - onViewTransaction(transactionId) - } label: { - Text(NSLocalizedString("View transaction", comment: "Receive receipt action")) - .font(.subheadline.weight(.semibold)) - .foregroundColor(.dash.blue) - } + // MARK: - Tab content + + /// Each branch owns the gap under the selector, because the two kinds of + /// content want different ones โ€” a picker card breathes, an embedded form + /// needs the pixels for its keypad. + @ViewBuilder + private var tabContent: some View { + switch viewModel.activeTab { + case .receive: + pickerLayout { + PaymentsReceiveContent( + viewModel: viewModel, + onCopyAddress: { + onCopyAddress() + showsCopiedToast = true + }, + onShareAddress: onShareAddress, + onSpecifyAmount: onSpecifyAmount, + onViewTransaction: onViewTransaction, + onDone: { + viewModel.finishReceiving() + onDone() + } + ) + .padding(.horizontal, Layout.cardHorizontalPadding) } - Spacer(minLength: 12) + case .internalTransfer: + switch mode { + case .picker: + // No destination card first: the form's own From and To cards + // pick the same endpoints, so the card was a step that asked + // for something the next screen asked for again. + InternalTransferScreen( + viewModel: embeddedTransferViewModel, + onCompleted: onTransferCompleted, + showsHeader: false + ) + .padding(.top, Layout.embeddedFormTopPadding) - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Done", comment: "Receive receipt action"), - positiveButtonAction: onClose, - negativeButtonText: NSLocalizedString("Receive another", comment: "Receive receipt action"), - negativeButtonAction: viewModel.receiveAnother) - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + case let .sendingFrom(source): + InternalTransferScreen( + viewModel: embeddedTransferViewModel, + onCompleted: onTransferCompleted, + showsHeader: false, + sendFrom: source + ) + .padding(.top, Layout.embeddedFormTopPadding) - private func receiptInfoRow(title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(title) - .font(.caption) - .foregroundColor(.dash.secondaryText) - Spacer() - Text(value) - .font(.caption.weight(.medium)) - .foregroundColor(.dash.primaryText) - .multilineTextAlignment(.trailing) - .lineLimit(2) - } - } + case .receivingInto: + InternalTransferScreen( + viewModel: embeddedTransferViewModel, + onCompleted: onTransferCompleted, + showsHeader: false, + receiveInto: viewModel.network + ) + .padding(.top, Layout.embeddedFormTopPadding) + } - private var qrCard: some View { - VStack(spacing: 14) { - if let address = viewModel.currentAddress, - let qr = QRCodeGenerator.image(for: address) { - Image(uiImage: qr) - .interpolation(.none) - .resizable() - .frame(width: 220, height: 220) - .padding(12) - .background(Color.dash.white) - .cornerRadius(16) - - VStack(spacing: 4) { - Text(NSLocalizedString("Your DASH address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Text(address) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .multilineTextAlignment(.center) + case .send: + switch mode { + case .picker, .receivingInto: + pickerLayout { + PaymentsSendCard( + showsSendToUsername: viewModel.canSendToUsername, + onSendToUsername: onSendToUsername, + onSendToAddress: onSendToAddress, + onScanQR: onScanQR, + showsSwapToCrypto: viewModel.canSwapToOtherCrypto, + onSwapToCrypto: onSwapToCrypto + ) + .padding(.horizontal, Layout.cardHorizontalPadding) } - .padding(.horizontal, 20) - .onTapGesture { onCopyAddress() } - } else if viewModel.network == .platform && !viewModel.platformIsReady { - placeholder(NSLocalizedString("Platform sync startingโ€ฆ", comment: "")) - } else if viewModel.network == .shielded { - // Nil only until the shielded sub-wallet binds at startup - // (the view model retries as the platform stack comes up). - placeholder(NSLocalizedString("Shielded wallet startingโ€ฆ", comment: "")) - } else { - placeholder(NSLocalizedString("No address available", comment: "")) + + case .sendingFrom: + SendScreen( + viewModel: embeddedSendViewModel, + onClose: onClose, + onScanQR: onScanQR, + onContinue: onSendContinue, + showsHeader: false + ) + .padding(.top, Layout.embeddedFormTopPadding) } } } - private func placeholder(_ message: String) -> some View { - VStack(spacing: 8) { - SwiftUI.ProgressView() - Text(message) - .font(.footnote) - .foregroundColor(Color.dash.secondaryText) - } - .frame(width: 220, height: 220) + // MARK: - Header + + /// Back only, no title. The landing is where a flow starts, so the tab + /// selector below already names the destination; the screens it pushes + /// carry their own titles. + /// + /// Currently unreachable: every caller passes `showsHeader: false` since + /// the landing became a tab. + private var header: some View { + NavigationBar( + leading: { NavigationBarElement.back.button(action: onClose) }) + .padding(.bottom, Layout.headerBottomPadding) } - private func actionPill(title: String) -> some View { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.dash.primaryText) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.dash.secondaryBackground) - .cornerRadius(12) + /// The way out of the payments tab once the tab bar is gone from under it. + /// + /// On every tab, not only the one with the keypad: chrome that appeared and + /// disappeared as the user moved between the three would read as the screen + /// changing identity rather than the content changing. + /// + /// Close rather than back: this is the payments tab's own root, so there is + /// nothing behind it to go back to โ€” the X leaves the flow. Trailing, where + /// a modal's close sits. + private var closeBar: some View { + NavigationBar( + trailing: { NavigationBarElement.close.button(action: onCloseLanding) }) } } + +#if DEBUG + +/// The full landing draws destination cards, which need no wallet at all. The +/// pinned variants at the bottom embed real forms instead, and previews run +/// without a wallet โ€” those price nothing the SDK owns, so they render the +/// "fee unavailable" state rather than an enabled Continue. +@MainActor +private func landingSample( + activeTab: PaymentsLandingTab = .internalTransfer, + network: ChainNetwork = .core, + visibleTabs: [PaymentsLandingTab] = PaymentsLandingTab.allCases, + coreAddress: String? = "XyZ8kFqW3nR5tHmB2vJcL7pQaS4dEuG9wN", + transferAmount: String = "0", + transferSendFrom: ChainNetwork? = nil, + transferReceivePinned: Bool = false, + showsHeader: Bool = true +) -> some View { + + PaymentsLandingScreen( + viewModel: .makeForPreview( + activeTab: activeTab, + network: network, + visibleTabs: visibleTabs, + coreAddress: coreAddress), + onClose: {}, + onDone: {}, + onCopyAddress: {}, + onShareAddress: {}, + onSpecifyAmount: {}, + onViewTransaction: { _ in }, + onScanQR: {}, + embeddedTransferViewModel: .makeForPreview(amountText: transferAmount), + transferSendFrom: transferSendFrom, + transferReceivePinned: transferReceivePinned, + embeddedSendViewModel: .makeForPreview(pinnedSource: transferSendFrom), + showsHeader: showsHeader) +} + +// MARK: Tabs + +/// The tab selector is the shared chrome โ€” these three show it in each of its +/// selected states, above the content that tab actually renders. +@available(iOS 17, *) +#Preview("Internal tab") { + landingSample(showsHeader: false) +} + +/// The presentation the balance-row receive sheet uses: Send is not offered, +/// so the landing has to hold together on two tabs. `PaymentsTabSelector` has +/// its own previews for the selector's own states. +@available(iOS 17, *) +#Preview("Two tabs ยท receive sheet") { + landingSample( + activeTab: .receive, + visibleTabs: [.receive, .internalTransfer]) +} + +// MARK: Receive tab states + +/// No address resolved yet โ€” the QR card gives way to its placeholder. +@available(iOS 17, *) +#Preview("Receive ยท no address") { + landingSample(activeTab: .receive, coreAddress: nil) +} + +@available(iOS 17, *) +#Preview("Receive ยท shielded placeholder") { + landingSample(activeTab: .receive, network: .shielded) +} + +// MARK: Sheet embeddings + +/// Balance-row send sheet: no header (the sheet's grabber and hero selector are +/// the top chrome) and the transfer form's From card pinned. +@available(iOS 17, *) +#Preview("Send sheet ยท pinned From") { + landingSample( + transferAmount: "0.5", + transferSendFrom: .core, + showsHeader: false) +} + +/// Balance-row receive sheet: the transfer form's To card is pinned to the +/// receive toggle's network. +@available(iOS 17, *) +#Preview("Receive sheet ยท pinned To") { + landingSample( + network: .platform, + visibleTabs: [.receive, .internalTransfer], + transferAmount: "0.5", + transferReceivePinned: true, + showsHeader: false) +} + +#endif + diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift index b9a37515ff..a25320b1e6 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift @@ -22,14 +22,6 @@ enum PaymentsLandingTab: String, CaseIterable, Identifiable { case .send: return NSLocalizedString("Send", comment: "") } } - - var iconSystemName: String { - switch self { - case .receive: return "arrow.down" - case .internalTransfer: return "arrow.up.arrow.down" - case .send: return "arrow.up" - } - } } enum CoreReceiptSettlementStatus: Int, Comparable { @@ -117,6 +109,40 @@ final class PaymentsLandingViewModel: ObservableObject { /// Which hero tabs this presentation offers. The full landing shows all /// three; the balance-row receive sheet narrows to Receive + Internal. let visibleTabs: [PaymentsLandingTab] + /// Mirrors `DWGlobalOptions.advancedModeEnabled` so the Internal card can + /// narrow to Shielded while the mode is off. + /// + /// Its own mirror rather than the transfer model's: that one is handed to + /// the screen as a plain `var`, so observing it here would mean re-rendering + /// the whole landing on every keystroke in the embedded amount field. + @Published private(set) var isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + + /// Which addresses the Receive tab offers. + /// + /// Core and Shielded are both ordinary destinations โ€” a wallet can be paid + /// privately without calling itself advanced. Platform is the one that + /// needs the mode: it holds credits rather than spendable Dash, and + /// offering it by default invites payments the payer cannot spend back. + var receiveNetworks: [ChainNetwork] { + isAdvancedMode ? [.core, .shielded, .platform] : [.core, .shielded] + } + + /// Whether the Send tab offers "Send to username". + /// + /// Both halves are required: the row opens the contact book, which needs an + /// identity to hold contacts, and a wallet that cannot name itself has + /// nothing to send *from* in that flow. False everywhere DashPay is not + /// built. + @Published private(set) var canSendToUsername = false + + /// Whether the Send tab offers "Swap to other crypto". + /// + /// The same gate the Dash DEX shortcut is behind: the portal swaps real + /// assets across real chains, which testnet coins cannot do, and it is + /// useless without the SwapKit key. Fixed for the lifetime of the screen โ€” + /// both inputs need a relaunch to change. + let canSwapToOtherCrypto = !WalletEnvironment.isTestnet && SwapKitConstants.isConfigured + @Published private(set) var coreAddress: String? = nil @Published private(set) var platformAddress: String? = nil @Published private(set) var shieldedAddress: String? = nil @@ -164,6 +190,34 @@ final class PaymentsLandingViewModel: ObservableObject { reloadCoreAddress() reloadShieldedAddress() + NotificationCenter.default.publisher(for: .advancedModeDidChange) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + self.isAdvancedMode = DWGlobalOptions.sharedInstance().advancedModeEnabled + // Platform is the only segment the mode takes away, and it can + // be the selected one at that moment โ€” leaving the tab showing + // a Platform address with no segment left to move off it. + if !self.isAdvancedMode, !self.receiveNetworks.contains(self.network) { + self.network = .core + } + } + .store(in: &cancellables) + + #if DASHPAY + refreshCanSendToUsername() + + // The identity can be adopted while this landing is already up โ€” a + // registration finishing, or a recovery resolving one โ€” and the row + // has to appear without the user leaving the tab. + NotificationCenter.default.publisher(for: .DWDashPayRegistrationStatusUpdated) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshCanSendToUsername() + } + .store(in: &cancellables) + #endif + PlatformAddressSyncCoordinator.shared.$derivedAddresses .receive(on: RunLoop.main) .sink { [weak self] addresses in @@ -226,6 +280,50 @@ final class PaymentsLandingViewModel: ObservableObject { .store(in: &cancellables) } + #if DEBUG + /// Lightweight initializer used only by SwiftUI previews: takes the + /// addresses as literals instead of reading them from the wallet, and + /// subscribes to nothing. + private init( + previewActiveTab: PaymentsLandingTab, + previewNetwork: ChainNetwork, + previewVisibleTabs: [PaymentsLandingTab], + previewCoreAddress: String?, + previewPlatformAddress: String?, + previewShieldedAddress: String? + ) { + activeTab = previewActiveTab + network = previewNetwork + visibleTabs = previewVisibleTabs + coreAddress = previewCoreAddress + platformAddress = previewPlatformAddress + shieldedAddress = previewShieldedAddress + // A preview has no host to route to and no transaction to route with, + // so the receipt's "View transaction" stays hidden rather than drawing + // a control that could not do anything. + allowsTransactionDetails = false + } + + /// Preview view model. Pass `nil` for an address to preview that + /// network's placeholder state instead of the QR card. + static func makeForPreview( + activeTab: PaymentsLandingTab = .internalTransfer, + network: ChainNetwork = .core, + visibleTabs: [PaymentsLandingTab] = PaymentsLandingTab.allCases, + coreAddress: String? = "XyZ8kFqW3nR5tHmB2vJcL7pQaS4dEuG9wN", + platformAddress: String? = "XmQ4rT7bN2vK9sD5xF8jH3kL6pW1aZcYuE", + shieldedAddress: String? = nil + ) -> PaymentsLandingViewModel { + PaymentsLandingViewModel( + previewActiveTab: activeTab, + previewNetwork: network, + previewVisibleTabs: visibleTabs, + previewCoreAddress: coreAddress, + previewPlatformAddress: platformAddress, + previewShieldedAddress: shieldedAddress) + } + #endif + var currentAddress: String? { if session?.rail == network, let displayedAddress { return displayedAddress @@ -272,6 +370,16 @@ final class PaymentsLandingViewModel: ObservableObject { } } + /// The user is finished with this receipt and leaving. Unlike + /// `receiveAnother`, no new session is armed and no address is re-derived โ€” + /// coming back should start from a clean receive screen rather than the + /// receipt of a payment already dealt with. + func finishReceiving() { + receipt = nil + invalidateReceiptSession() + reconcileReceiptWatching() + } + func receiveAnother() { receipt = nil invalidateReceiptSession() @@ -287,6 +395,17 @@ final class PaymentsLandingViewModel: ObservableObject { coreAddress = SwiftDashSDKReceiveAddressReader.receiveAddress() } + #if DASHPAY + /// Reads the network-scoped SDK truth, the same source + /// `JoinDashPayRegistrationPolicy` treats as authoritative. The legacy + /// `DWGlobalOptions` username mirror is global and cleared on every network + /// switch, so it would offer the row on a network with no identity. + private func refreshCanSendToUsername() { + let identity = DWCurrentUserIdentityInfo.shared + canSendToUsername = identity.hasIdentity && identity.username?.isEmpty == false + } + #endif + /// Resolves the wallet's default Orchard payment address and encodes it /// for display. `shieldedDefaultAddress` returns nil until the shielded /// sub-wallet is bound (PlatformAddressSyncCoordinator does that at diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsTabRootController.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsTabRootController.swift new file mode 100644 index 0000000000..b76a3976b6 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsTabRootController.swift @@ -0,0 +1,87 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +/// Root of the payments tab, and the reason the landing is not built at launch. +/// +/// `PaymentsLandingHostingController.init` constructs three view models that +/// read the wallet โ€” balances over the FFI, a derived receive address, the sync +/// monitor. Making the landing the tab's root directly would run all of that +/// during `configureControllers()`, before the user has gone anywhere near +/// payments (and again on every DashPay tab reconfiguration). This container +/// defers it to `viewDidLoad`, which UIKit only runs when the tab is first +/// shown. +final class PaymentsTabRootController: UIViewController { + + private var landingController: PaymentsLandingHostingController? + /// A tab requested before the landing existed โ€” applied on load. Shortcut + /// entry points select this tab and name a sub-tab in the same turn, which + /// can be the very first time the tab is shown. + private var pendingTab: PaymentsLandingTab? + + /// Puts the landing on `tab`, or remembers it if the landing has not been + /// built yet. + func select(tab: PaymentsLandingTab) { + guard let landingController else { + pendingTab = tab + return + } + landingController.select(tab: tab) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .dw_background() + + let landing = PaymentsLandingHostingController( + activeTab: pendingTab ?? .send, + showsHeader: false) + pendingTab = nil + landingController = landing + + addChild(landing) + landing.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(landing.view) + NSLayoutConstraint.activate([ + // Full bounds, not the safe area: the landing hosts SwiftUI, which + // applies the safe-area inset itself. Pinning to the guide here + // would count the status bar twice. + landing.view.topAnchor.constraint(equalTo: view.topAnchor), + landing.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + landing.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + landing.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + landing.didMove(toParent: self) + } +} + +// MARK: - NavigationBarDisplayable + +/// The payments tab has no navigation bar: the landing draws its own tab +/// selector at the top and nothing above it. +/// +/// `MainTabbarController` already sets `isNavigationBarHidden` on the +/// navigation controller, but that only holds until the first appearance: +/// `BaseNavigationController`'s `willShow` pass re-derives it per controller +/// and falls back to SHOWING the bar for anything that does not declare +/// otherwise. The bar it then put back carried no title and no back button, so +/// it read as an unexplained ~44pt gap above the selector rather than as a bar. +extension PaymentsTabRootController: NavigationBarDisplayable { + var isBackButtonHidden: Bool { true } + var isNavigationBarHidden: Bool { true } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Confirm/ConfirmPaymentViewController.swift b/DashWallet/Sources/UI/Payments/Pay/Confirm/ConfirmPaymentViewController.swift index 4837cc0a36..f5923528be 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Confirm/ConfirmPaymentViewController.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Confirm/ConfirmPaymentViewController.swift @@ -15,6 +15,8 @@ // limitations under the License. // +import DashUIKit +import SwiftUI import UIKit // MARK: - ConfirmPaymentViewControllerDelegate @@ -27,6 +29,18 @@ protocol ConfirmPaymentViewControllerDelegate: AnyObject { // MARK: - ConfirmPaymentViewController +/// The last step of an L1 send: the amount, what it costs, and the two buttons. +/// +/// Drawn with `DashUIKit.BottomSheet` rather than the hand-built stack of +/// `BalanceView` + `UITableView` + `ActionButton` it used to be, so it matches +/// the confirmation the internal transfer and the non-Core send routes present. +/// Everything around the drawing is unchanged: it is still a `SheetViewController` +/// (same presentation, same content-measured detent), still reports through +/// `ConfirmPaymentViewControllerDelegate`, and `ConfirmPaymentModel` still owns +/// what the rows say and when the button turns into "Sendingโ€ฆ". +/// +/// The model predates `ObservableObject` and pushes updates through two +/// closures, so `State` below is the thin adapter between them and SwiftUI. class ConfirmPaymentViewController: SheetViewController { public var delegate: ConfirmPaymentViewControllerDelegate? public var isSendingEnabled = true { @@ -35,18 +49,29 @@ class ConfirmPaymentViewController: SheetViewController { model.stopPayment() } - confirmButton.isEnabled = isSendingEnabled + state.isSendingEnabled = isSendingEnabled isModalInPresentation = !isSendingEnabled } } - private var balanceView: BalanceView! - private var tableView: UITableView! - private var confirmButton: ActionButton! - // TODO: setIsAcceptContactRequestCheckboxOn - internal let model: ConfirmPaymentModel + private let state = State() + + /// Rebuilt from the model, so the detent can be re-resolved against it. + private lazy var hostingController: UIHostingController = { + let sheet = DashUIKit.BottomSheet.selfSizing( + title: NSLocalizedString("Confirm", comment: "Payment confirmation"), + showBackButton: .constant(false) + ) { + ConfirmPaymentSheet( + state: self.state, + onCancel: { [weak self] in self?.cancel() }, + onConfirm: { [weak self] in self?.confirm() }) + } + return UIHostingController(rootView: AnyView(sheet)) + }() + convenience init(dataSource: ConfirmPaymentDataSource, fiatCurrency: String) { let model = ConfirmPaymentModel(dataSource: dataSource, fiatCurrency: fiatCurrency) self.init(model: model) @@ -67,13 +92,26 @@ class ConfirmPaymentViewController: SheetViewController { model.update(with: dataSource) } + /// Measured from the SwiftUI content instead of counted in rows: the sheet + /// is `selfSizing`, and the row count is not the only thing that moves its + /// height โ€” a wrapped address or Dynamic Type does too. override func contentViewHeight() -> CGFloat { - 190 + CGFloat(model.items.count)*46 + let width = view.bounds.width > 0 ? view.bounds.width : UIScreen.main.bounds.width + let bottomInset = view.window?.safeAreaInsets.bottom ?? 0 + let measured = hostingController + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + .height + return measured + bottomInset } override func viewDidLoad() { super.viewDidLoad() + // `BottomSheet` draws its own grabber; the base class turns the system + // one on for the hand-built content it was written for. + sheetPresentationController?.prefersGrabberVisible = false + presentationController?.delegate = self + configureModel() configureHierarchy() } @@ -81,118 +119,80 @@ class ConfirmPaymentViewController: SheetViewController { extension ConfirmPaymentViewController { private func configureModel() { - model.actionButtonTitleDidChange = { [weak self] in - guard let self else { return } + reloadState() - self.confirmButton.setTitle(self.model.actionButtonTitle, for: .normal) + model.actionButtonTitleDidChange = { [weak self] in + self?.state.actionTitle = self?.model.actionButtonTitle ?? "" } model.dataSourceDidChange = { [weak self] in - self?.balanceView.reloadData() - self?.tableView.reloadData() + self?.reloadState() } } - private func configureHierarchy() { - view.backgroundColor = .dw_secondaryBackground() - - presentationController?.delegate = self - - balanceView = BalanceView() - balanceView.dataSource = model - balanceView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(balanceView) - - tableView = DWIntrinsicTableView(frame: .zero, style: .insetGrouped) - tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.preservesSuperviewLayoutMargins = true - tableView.isScrollEnabled = false - tableView.separatorStyle = .none - tableView.backgroundColor = UIColor.dw_secondaryBackground() - tableView.delegate = self - tableView.dataSource = self - tableView.layoutMargins = view.layoutMargins - tableView.registerClass(for: TitleValueCell.self) - tableView.tableHeaderView = EmptyUIView(frame: .init(x: 0, y: 0, width: 1, height: CGFloat.leastNonzeroMagnitude)) - tableView.tableFooterView = EmptyUIView(frame: .init(x: 0, y: 0, width: 1, height: CGFloat.leastNonzeroMagnitude)) - view.addSubview(tableView) - - let bottomButtonsStack = UIStackView() - bottomButtonsStack.translatesAutoresizingMaskIntoConstraints = false - bottomButtonsStack.axis = .horizontal - bottomButtonsStack.spacing = 10 - bottomButtonsStack.distribution = .fillEqually - view.addSubview(bottomButtonsStack) - - let cancelButton = GrayButton() - cancelButton.addAction(.touchUpInside) { [weak self] _ in - guard let self else { return } - - self.model.stopPayment() - self.delegate?.confirmPaymentViewControllerDidCancel(self) - self.dismiss(animated: true) - } - - cancelButton.setTitle(NSLocalizedString("Cancel", comment: "Payment confirmation"), for: .normal) - bottomButtonsStack.addArrangedSubview(cancelButton) - - confirmButton = ActionButton() - confirmButton.setTitle(model.actionButtonTitle, for: .normal) - confirmButton.addAction(.touchUpInside) { [weak self] _ in - guard let self else { return } - - self.isSendingEnabled = false - self.model.confirmPayment() - self.delegate?.confirmPaymentViewControllerDidConfirm(self) + /// Pull everything the sheet draws off the model in one pass, and re-resolve + /// the detent โ€” an updated payment output can add or drop a row. + private func reloadState() { + state.items = model.items ?? [] + state.actionTitle = model.actionButtonTitle + // Digits only. `mainAmountString` is `formattedDashAmount`, which spells + // the currency out โ€” "DASH 0.02" โ€” and the component draws the symbol. + state.mainAmount = model.dataSource.amountToDisplay.dashAmount + .formattedDashAmountWithoutCurrencySymbol + state.feeDuffs = model.dataSource.feeDuffs + state.totalDuffs = model.dataSource.totalDuffs + state.supplementaryAmount = model.supplementaryAmountString + + if #available(iOS 16.0, *) { + sheetPresentationController?.animateChanges { + sheetPresentationController?.invalidateDetents() + } } + } - bottomButtonsStack.addArrangedSubview(confirmButton) + private func configureHierarchy() { + view.backgroundColor = UIColor(Color.dash.primaryBackground) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.backgroundColor = .clear + view.addSubview(hostingController.view) NSLayoutConstraint.activate([ - balanceView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - - tableView.topAnchor.constraint(equalTo: balanceView.bottomAnchor, constant: 20), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - bottomButtonsStack.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 30), - bottomButtonsStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -15), - bottomButtonsStack.heightAnchor.constraint(equalToConstant: 46), - bottomButtonsStack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), - bottomButtonsStack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) + hostingController.didMove(toParent: self) } -} - -// MARK: UITableViewDelegate, UITableViewDataSource -extension ConfirmPaymentViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let item = model.items[indexPath.row] - - let cell = tableView.dequeueReusableCell(type: TitleValueCell.self, for: indexPath) - cell.update(with: item) - return cell - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - model.items.count - } - - public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - nil + private func cancel() { + model.stopPayment() + delegate?.confirmPaymentViewControllerDidCancel(self) + dismiss(animated: true) } - public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - nil + private func confirm() { + isSendingEnabled = false + model.confirmPayment() + delegate?.confirmPaymentViewControllerDidConfirm(self) } +} - public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - CGFloat.leastNonzeroMagnitude - } +// MARK: - ConfirmPaymentViewController.State - public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - CGFloat.leastNonzeroMagnitude +extension ConfirmPaymentViewController { + /// What the sheet draws, republished from `ConfirmPaymentModel`'s callbacks. + @MainActor + fileprivate final class State: ObservableObject { + @Published var items: [DWTitleDetailItem] = [] + /// Present on the L1 payment path only; see `ConfirmPaymentDataSource`. + @Published var feeDuffs: UInt64? + @Published var totalDuffs: UInt64? + @Published var actionTitle = "" + @Published var mainAmount = "" + @Published var supplementaryAmount = "" + @Published var isSendingEnabled = true } } @@ -207,3 +207,101 @@ extension ConfirmPaymentViewController: UIAdaptivePresentationControllerDelegate true } } + +// MARK: - ConfirmPaymentSheet + +/// Amount, the rows that price it, and the pair of buttons. +/// +/// Nothing below decides anything: the rows arrive as `DWTitleDetailItem`s the +/// model already assembled, and the confirm title is whatever the model says it +/// is right now โ€” including its "Sending. / .. / ..." animation. +private struct ConfirmPaymentSheet: View { + @ObservedObject var state: ConfirmPaymentViewController.State + + /// Every place a duff can occupy. A Core fee is a few hundred of them, and + /// the component's five-place default renders that as "0" โ€” a confirmation + /// that names the wrong fee is worse than one that names a long number. + private static let dashFractionDigits = 8 + + var onCancel: () -> Void + var onConfirm: () -> Void + + var body: some View { + VStack(spacing: 0) { + // The amount string already carries its Dash symbol โ€” it comes from + // the same `formattedDashAmount` the balance view used โ€” so the + // component's own logo would draw a second one. + DashUIKit.SwapAmountView( + amount: state.mainAmount, + secondaryText: state.supplementaryAmount, + showDashLogo: true) + .frame(maxWidth: .infinity) + .padding(.top, 14) + + VStack(spacing: 0) { + ForEach(Array(state.items.enumerated()), id: \.offset) { _, item in + DashUIKit.MenuItem( + title: item.title ?? "", + accessory: accessory(for: item)) + } + } + .modifier(MenuViewModifier()) + .padding(.top, 20) + + HStack(spacing: 20) { + DashUIKit.DashButton( + text: NSLocalizedString("Cancel", comment: "Payment confirmation"), + isEnabled: state.isSendingEnabled, + fillsWidth: true, + size: .large, + style: .tintedGray, + action: onCancel + ) + + DashUIKit.DashButton( + text: state.actionTitle, + isEnabled: state.isSendingEnabled, + fillsWidth: true, + size: .large, + style: .filledBlue, + action: onConfirm + ) + } + .padding(.top, 24) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + /// How a row's value is drawn. + /// + /// The two money rows go through `.balance`, which renders the amount with + /// the library's own Dash symbol. Their `attributedDetail` carries one too, + /// but as an image attachment โ€” read back as plain text it disappears and + /// the row shows a bare number, which is what it did. + /// + /// Matched by title against the model's own strings: the items arrive as an + /// untyped list, and re-deriving the order here would be a second copy of + /// the assembly `ConfirmPaymentModel` already does. + private func accessory(for item: DWTitleDetailItem) -> DashUIKit.MenuItemAccessory { + if let fee = state.feeDuffs, item.title == NSLocalizedString("Network fee", comment: "") { + return .balance(dash: Int64(clamping: fee), sign: .none, + maximumFractionDigits: Self.dashFractionDigits) + } + if let total = state.totalDuffs, item.title == NSLocalizedString("Total", comment: "") { + return .balance(dash: Int64(clamping: total), sign: .none, + maximumFractionDigits: Self.dashFractionDigits) + } + return .text(detail(of: item)) + } + + /// The row's value as plain text, shortened when the model asked for one + /// truncated line โ€” an address, which otherwise wraps and pushes the whole + /// row out of shape. `MenuItem` takes a string rather than a styled view, so + /// the shortening happens here instead of as a truncation mode. + private func detail(of item: DWTitleDetailItem) -> String { + let value = item.plainDetail ?? item.attributedDetail?.string ?? "" + guard item.style == .truncatedSingleLine, value.count > 24 else { return value } + return "\(value.prefix(12))โ€ฆ\(value.suffix(12))" + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Confirm/Model/ConfirmPaymentModel.swift b/DashWallet/Sources/UI/Payments/Pay/Confirm/Model/ConfirmPaymentModel.swift index eb67ec2bec..15f356d7e8 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Confirm/Model/ConfirmPaymentModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Confirm/Model/ConfirmPaymentModel.swift @@ -34,6 +34,19 @@ public protocol ConfirmPaymentDataSource { @objc(totalWithFont:tintColor:) func total(with font: UIFont, tintColor: UIColor) -> DWTitleDetailItem + + /// The fee and the all-in debit in duffs. + /// + /// The `DWTitleDetailItem`s above carry the same two figures already + /// formatted, but they carry the Dash symbol as an image attachment โ€” read + /// back as plain text it is gone, and the row renders a bare number. A + /// renderer that wants to draw the amount itself needs the number. + /// + /// Optional because only the L1 payment path has duffs to give: the Uphold + /// transfer implements this protocol too, and its amounts are not Core + /// values. Callers fall back to the formatted string. + @objc optional var feeDuffs: UInt64 { get } + @objc optional var totalDuffs: UInt64 { get } } // MARK: - ConfirmPaymentModel diff --git a/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.h b/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.h index eebf8a35ea..f5ae9a984f 100644 --- a/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.h +++ b/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.h @@ -37,6 +37,15 @@ NS_ASSUME_NONNULL_BEGIN - (void)performPayToPasteboardAction; - (void)performNFCReadingAction; - (void)performPayToURL:(NSURL *)url; +/// Pay a send this flow has already fully specified โ€” the address and the +/// amount are both known and there is nothing left to ask for. +/// +/// Deliberately not `performPayToURL:` with a `dash:โ€ฆ?amount=` string: that +/// classifies a URI carrying a valid address as a DEEP LINK, and the processor +/// answers a deep link by pushing the legacy amount screen โ€” prefilled with the +/// number the caller already collected. This routes straight to the +/// confirmation with the real fee instead. +- (void)performPayToAddress:(NSString *)address amount:(uint64_t)amount; @end diff --git a/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.m b/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.m index c6fd92e762..95e1583a4d 100644 --- a/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.m +++ b/DashWallet/Sources/UI/Payments/Pay/DWBasePayViewController.m @@ -93,6 +93,16 @@ - (void)performPayToURL:(NSURL *)url { [self processPaymentInput:paymentInput]; } +- (void)performPayToAddress:(NSString *)address amount:(uint64_t)amount { + DWPaymentInput *paymentInput = [[[DWPaymentInputBuilder alloc] init] payToAddress:address + amount:amount]; + if (!paymentInput) { + return; + } + + [self processPaymentInput:paymentInput]; +} + - (void)processPaymentInput:(DWPaymentInput *)input { [self.paymentController performPaymentWith:input]; } @@ -100,8 +110,31 @@ - (void)processPaymentInput:(DWPaymentInput *)input { #pragma mark - DWTxDetailFullscreenViewControllerDelegate - (void)txDetailViewControllerDidFinishWithController:(SuccessTxDetailViewController *)controller { - // Nothing to do โ€” the success screen dismisses itself; the legacy pay-to-contact - // follow-up that lived here is gone with the contact plumbing (Row #18). + // The success screen has already dismissed itself; what is left underneath + // is the send that produced it โ€” an amount step, a source picker, an + // address field. Handing those back would offer to redo a payment that has + // just happened, so leave for the history, which is where the transaction + // now is. + // + // Presented as a modal there is something to dismiss; inside the payments + // tab there is not, and the way back is the stack plus the tab. The tab + // change waits for the pop โ€” run together they animate over each other. + if (self.presentingViewController) { + [self dismissViewControllerAnimated:YES completion:nil]; + return; + } + + MainTabbarController *tabBarController = + [self.tabBarController isKindOfClass:MainTabbarController.class] + ? (MainTabbarController *)self.tabBarController + : nil; + + [CATransaction begin]; + [CATransaction setCompletionBlock:^{ + [tabBarController showHome]; + }]; + [self.navigationController popToRootViewControllerAnimated:YES]; + [CATransaction commit]; } #pragma mark - DWQRScanModelDelegate diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift index 8dd467a439..60f686a6e3 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift @@ -29,6 +29,10 @@ struct SendScreen: View { /// (the balance-row send sheet) โ€” hides the X + title header. var showsHeader: Bool = true + /// Set when the screen was pushed: the header's leading slot becomes a + /// back button instead of a close one, and the host hides the UIKit bar. + var onBack: (() -> Void)? = nil + @FocusState private var addressFieldFocused: Bool @State private var isEditingAddress = false @@ -42,9 +46,9 @@ struct SendScreen: View { var body: some View { VStack(spacing: 0) { if showsHeader { + // No padding of its own: `NavigationBar` already insets its + // leading/trailing slots by 20 and stands 64 tall. header - .padding(.horizontal, 20) - .padding(.top, 10) } ScrollView { @@ -82,22 +86,23 @@ struct SendScreen: View { // MARK: - Header + /// Back when the screen was pushed, close when it is the root of its own + /// presentation. Both come from the design system's bar rather than the + /// UIKit one, so the glyph and its ring are the `navigationbar-*` assets. private var header: some View { - HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) + DashUIKit.NavigationBar( + leading: { + if let onBack { + DashUIKit.NavigationBarElement.back.button(action: onBack) + } else { + DashUIKit.NavigationBarElement.close.button(action: onClose) + } + }, + central: { + Text(NSLocalizedString("Send", comment: "")) + .dashFont(.subheadMedium) .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 @@ -464,12 +469,6 @@ struct ExternalSendAmountScreen: View { 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() { diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift index 53cb6504d8..25961c6a5b 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift @@ -28,7 +28,7 @@ final class SendScreenViewController: DWBasePayViewController { private let sendViewModel = SendViewModel() private lazy var hostingController: UIHostingController = { - let screen = SendScreen( + var screen = SendScreen( viewModel: sendViewModel, onClose: { [weak self] in self?.dismiss(animated: true) }, onScanQR: { [weak self] in self?.performScanQRCodeAction() }, @@ -38,6 +38,11 @@ final class SendScreenViewController: DWBasePayViewController { viewModel: self.sendViewModel, onSendCompleted: { [weak self] in self?.dismiss(animated: true) }) }) + // Pushed from the payments landing there is somewhere to go back to; + // presented as the "Send to Address" shortcut there is not. + if let navigationController, navigationController.viewControllers.first !== self { + screen.onBack = { [weak self] in self?.navigationController?.popViewController(animated: true) } + } return UIHostingController(rootView: screen) }() @@ -98,14 +103,18 @@ extension DWBasePayViewController { /// 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) + // A plain-address input, not a `dash:` URI through `performPay(to:)`. + // That route classifies a URI carrying a valid address as a DEEP LINK, + // and `DWPaymentProcessor` answers a deep link by asking for an amount + // โ€” pushing the legacy `ProvideAmountViewController` on top of the + // amount step the user has just filled in, prefilled with the same + // number. Two amount screens, and the second one in the old design. + // + // Nothing here is a deep link: this flow already holds the address and + // the amount. `payToAddress:amount:` puts both on a `PlainAddress` + // input, which the processor takes straight to the confirmation with + // the real fee โ€” the same place Platform and Shielded land. + performPay(toAddress: address, amount: amountDuffs) } /// Push the external-send SOURCE step (From picker) onto the current @@ -120,16 +129,12 @@ 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 source lands on the same amount step, Transparent + // included. Core โ†’ Core still finishes in the L1 payment + // processor for the real fee math and its confirm, but it + // gets there from that step carrying the amount, rather than + // being handed off before one is entered. + self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) }) // Every UIHostingController already conforms to NavigationBarDisplayable // (nav bar + back button hidden); the screen draws its own back + title. @@ -159,8 +164,9 @@ extension DWBasePayViewController { // The screen draws its own X + title header. When it is the root of its own // modal (the "Send to Address" shortcut), BaseNavigationController's willShow // pass must not re-show the (empty) navigation bar above it. Pushed from the -// payments landing it keeps the bar's back arrow, as before. +// payments landing the bar stays hidden too โ€” `SendScreen` draws the design +// system's own `NavigationBar`, with back in place of close. extension SendScreenViewController: NavigationBarDisplayable { - var isBackButtonHidden: Bool { navigationController?.viewControllers.first === self } - var isNavigationBarHidden: Bool { navigationController?.viewControllers.first === self } + var isBackButtonHidden: Bool { true } + var isNavigationBarHidden: Bool { true } } diff --git a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift index 9cdc895329..9ffbb54b27 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift @@ -120,10 +120,19 @@ final class SendViewModel: ObservableObject { /// Drives the one-time restore gate reactively. A normal catch-up may set /// this to false, but it only blocks while the recovery marker is active. - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// + /// Seeded from the monitor in `init()` rather than here: a property default + /// runs in EVERY initializer, and the preview initializer must not spin up + /// the sync monitor singleton. + @Published private(set) var isChainSynced = false private var cancellables = Set() + #if DEBUG + /// True only for `makeForPreview` instances โ€” see the guard in `deinit`. + private var isPreviewInstance = false + #endif + /// Set by the balance-row send sheet: the source is fixed to the tapped /// balance instead of being user-pickable, and an address whose type /// that balance can't pay surfaces as a mismatch (`pinnedSourceMismatch`) @@ -131,6 +140,11 @@ final class SendViewModel: ObservableObject { let pinnedSource: ChainNetwork? deinit { + #if DEBUG + // Preview instances never registered โ€” building the monitor here just + // to unregister would start reachability inside the canvas. + if isPreviewInstance { return } + #endif // The monitor holds observers strongly โ€” without this the VM (and // its Combine pipelines) outlive the screen. SyncingActivityMonitor.shared.remove(observer: self) @@ -143,6 +157,7 @@ final class SendViewModel: ObservableObject { } refreshClipboardSuggestion() SyncingActivityMonitor.shared.add(observer: self) + isChainSynced = SyncingActivityMonitor.shared.state == .syncDone NotificationCenter.default.publisher(for: UIPasteboard.changedNotification) .receive(on: RunLoop.main) @@ -181,6 +196,64 @@ final class SendViewModel: ObservableObject { .store(in: &cancellables) } + #if DEBUG + /// Lightweight initializer used only by SwiftUI previews. Sets the balances + /// and the typed destination directly and skips the sync-monitor, + /// pasteboard and wallet-state wiring the real `init()` sets up. + /// + /// Property observers do not fire during initialization, so assigning + /// `addressText` here also skips the destination parse and preflight the + /// real screen would run on every keystroke. + private init( + previewPinnedSource: ChainNetwork?, + previewSource: ChainNetwork, + previewAddressText: String, + previewAmountText: String, + previewCoreDuffs: UInt64, + previewPlatformCredits: UInt64, + previewShieldedCredits: UInt64, + previewIsChainSynced: Bool + ) { + pinnedSource = previewPinnedSource + isPreviewInstance = true + source = previewSource + addressText = previewAddressText + amountText = previewAmountText + coreBalanceDuffs = previewCoreDuffs + platformCredits = previewPlatformCredits + shieldedBalance = previewShieldedCredits + isChainSynced = previewIsChainSynced + } + + /// Preview view model with stubbed balances. Core is in duffs (1e8 per + /// DASH), Platform and Shielded in credits (1e11 per DASH); the defaults + /// are 2.45 / 1.2 / 0.785 DASH. + /// + /// `destination` stays `nil` because the address parse never runs here, so + /// the screen renders its address-entry step rather than a resolved + /// recipient. + static func makeForPreview( + pinnedSource: ChainNetwork? = nil, + source: ChainNetwork = .core, + addressText: String = "", + amountText: String = "0", + coreDuffs: UInt64 = 245_000_000, + platformCredits: UInt64 = 120_000_000_000, + shieldedCredits: UInt64 = 78_500_000_000, + isChainSynced: Bool = true + ) -> SendViewModel { + SendViewModel( + previewPinnedSource: pinnedSource, + previewSource: source, + previewAddressText: addressText, + previewAmountText: amountText, + previewCoreDuffs: coreDuffs, + previewPlatformCredits: platformCredits, + previewShieldedCredits: shieldedCredits, + previewIsChainSynced: isChainSynced) + } + #endif + /// Fee kind for the pool-spending routes; `nil` for every other route. private func shieldedFeeKind(for route: Route?) -> PlatformWalletManager.ShieldedFeeKind? { switch route { diff --git a/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentOutput+DWView.m b/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentOutput+DWView.m index 9ff74f01cd..2e38524475 100644 --- a/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentOutput+DWView.m +++ b/DashWallet/Sources/UI/Payments/PaymentModels/DWPaymentOutput+DWView.m @@ -137,6 +137,16 @@ - (uint64_t)amountToDisplay { return total; } +- (uint64_t)feeDuffs { + return self.fee; +} + +- (uint64_t)totalDuffs { + // Mirrors `totalWithFont:tintColor:` โ€” a fee-on-top BIP70 merchant request + // is the one case where the debit is not the amount already carried. + return self.isMerchantRequest ? (self.amount + self.fee) : self.amount; +} + - (BOOL)copyAddressToPasteboard { NSString *address = self.address; NSParameterAssert(address); diff --git a/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountHostingController.swift b/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountHostingController.swift new file mode 100644 index 0000000000..9a4f3d4da9 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountHostingController.swift @@ -0,0 +1,312 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI +import UIKit + +// MARK: - RequestAmountHostingControllerDelegate + +protocol RequestAmountHostingControllerDelegate: AnyObject { + func requestAmountHostingController( + _ controller: RequestAmountHostingController, + didReceiveAmountWithInfo info: String) +} + +// MARK: - RequestAmountHostingController + +/// SwiftUI port of `DWRequestAmountViewController`, kept as a copy beside it โ€” +/// the ObjC pair is still what the payments landing presents. +/// +/// The chrome is `DashUIKit.BottomSheet` rather than the ObjC +/// `DWBaseModalViewController`: grabber, title and background all come from the +/// design system, so this holds nothing but the model wiring. +/// +/// It still owns the model, because `DWReceiveModelProtocol` pushes updates +/// through a delegate rather than publishing them โ€” `State` below is what turns +/// that into something SwiftUI can observe. +final class RequestAmountHostingController: UIViewController { + + weak var delegate: RequestAmountHostingControllerDelegate? + + private let model: DWReceiveModelProtocol + private let state: State + + /// Bridges the ObjC delegate callback into `@Published` values. + @MainActor + fileprivate final class State: ObservableObject { + @Published var qrCodeImage: UIImage? + @Published var paymentAddress: String? + @Published var username: String? + /// Raised by a copy, cleared by `transientToast`'s own timer. + @Published var copiedToastVisible = false + /// Driven by the presenter's attended receive session, when it has one. + @Published var isWatchingForReceipt = false + } + + /// The content's natural height, as `BottomSheet(fillsHeight: false)` + /// publishes it. Seeded before presentation and updated on every layout โ€” + /// the QR arriving, the username row appearing, Dynamic Type changing. + fileprivate var measuredHeight: CGFloat = 0 + + init(model: DWReceiveModelProtocol) { + self.model = model + self.state = State() + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Presentation + + /// Presents the sheet and keeps it sized to its content. + /// + /// The measuring lives here because it is the awkward part: SwiftUI's + /// `.presentationDetents` โ€” and so `BottomSheet.selfSizing` โ€” does not + /// bridge to a `UIHostingController` shown with `present()`; UIKit falls + /// back to `.large`. What does cross the bridge is the height + /// `BottomSheet(fillsHeight: false)` publishes for that modifier to read, so + /// the same measurement drives a custom UIKit detent here: seeded from + /// `sizeThatFits` before anything renders, then corrected by every + /// `BottomSheetHeightPreferenceKey` update. A one-shot `sizeThatFits` is not + /// enough โ€” the amount's `scaleToFitWidth` reports its height from `State`, + /// which is still zero on that first pass. + @discardableResult + static func present( + from presenter: UIViewController, + model: DWReceiveModelProtocol, + delegate: RequestAmountHostingControllerDelegate? + ) -> RequestAmountHostingController { + let controller = RequestAmountHostingController(model: model) + controller.delegate = delegate + controller.modalPresentationStyle = .pageSheet + + if let sheet = controller.sheetPresentationController { + // `BottomSheet` draws its own grabber. + sheet.prefersGrabberVisible = false + if #unavailable(iOS 26.0) { + sheet.preferredCornerRadius = 24 + } + let width = presenter.view.bounds.width + controller.measuredHeight = controller.sheetHostingController + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + .height + + // The resolver reads the controller rather than a captured number, + // so `invalidateDetents()` re-runs it against the latest + // measurement instead of the one taken before the first render. + sheet.detents = [.custom { [weak controller] context in + guard let controller, controller.measuredHeight > 0 else { + return context.maximumDetentValue + } + // `fillsHeight: false` measures without the home-indicator + // inset, so the strip has to be added back here. + let bottomInset = controller.view.window?.safeAreaInsets.bottom ?? 0 + return min(controller.measuredHeight + bottomInset, context.maximumDetentValue) + }] + } + + presenter.present(controller, animated: true) + return controller + } + + override func viewDidLoad() { + super.viewDidLoad() + // The detent paints the bottom safe-area strip itself, so the sheet + // background has to reach it. + view.backgroundColor = UIColor(Color.dash.primaryBackground) + + setupContent() + model.delegate = self + reloadFromModel() + + // The requested amount arriving is what closes this screen, and the + // only signal for it is the balance changing. + NotificationCenter.default.addObserver( + self, + selector: #selector(checkRequestStatus), + name: SwiftDashSDKWalletState.balanceDidChangeNotification, + object: nil) + } + + // MARK: - Content + + /// Built lazily and held, because `present(from:model:delegate:)` measures + /// it before the view is ever loaded. + fileprivate lazy var sheetHostingController: UIHostingController = { + let screen = RequestAmountScreenContainer( + state: state, + amountDuffs: model.amount, + onCopyAddress: { [weak self] in self?.copyAddress() }, + onCopyQRCode: { [weak self] in self?.copyQRCode() }, + onCopyUsername: { [weak self] in self?.copyUsername() }, + onShare: { [weak self] in self?.share() }) + + let sheet = DashUIKit.BottomSheet( + showBackButton: .constant(false), + fillsHeight: false + ) { + screen + } + // SwiftUI hands preference updates to a `@Sendable` closure, so the hop + // back to the main actor is explicit rather than implied by UIKit. + .onPreferenceChange(DashUIKit.BottomSheetHeightPreferenceKey.self) { height in + Task { @MainActor [weak self] in + self?.contentHeightDidChange(height) + } + } + return UIHostingController(rootView: AnyView(sheet)) + }() + + /// Reflect the presenter's attended receive session, so the sheet can say + /// it is still watching. Left alone by presenters that have no session. + func setWatchingForReceipt(_ watching: Bool) { + state.isWatchingForReceipt = watching + } + + /// Re-resolves the detent against the height that just came out of layout. + /// Sub-point changes are ignored: the resolver rounds to the same detent + /// anyway, and re-entering `animateChanges` on every measurement pass makes + /// the sheet twitch. + private func contentHeightDidChange(_ height: CGFloat) { + guard height > 0, abs(height - measuredHeight) > 0.5 else { return } + measuredHeight = height + + guard let sheet = sheetPresentationController else { return } + sheet.animateChanges { sheet.invalidateDetents() } + } + + private func setupContent() { + let hostingController = sheetHostingController + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.backgroundColor = .clear + addChild(hostingController) + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + hostingController.didMove(toParent: self) + } + + private func reloadFromModel() { + state.qrCodeImage = model.qrCodeImage + state.paymentAddress = model.paymentAddress + #if DASHPAY + state.username = model.username + #endif + } + + // MARK: - Actions + + private func copyAddress() { + model.copyAddressToPasteboard() + showCopied() + } + + private func copyQRCode() { + model.copyQRImageToPasteboard() + showCopied() + } + + private func copyUsername() { + #if DASHPAY + model.copyUsernameToPasteboard() + showCopied() + #endif + } + + /// Only the haptic. The toast is drawn by the SwiftUI sheet through + /// `transientToast`, the same `DashUIKit.Toast` the Receive tab raises โ€” + /// the UIKit `showToast` anchors to this controller's view, which inside a + /// sheet puts it behind the sheet rather than over it. + private func showCopied() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + state.copiedToastVisible = true + } + + /// The helper anchors its iPad popover on a `UIButton`, and the real one + /// lives inside SwiftUI where UIKit cannot reach it. This zero-sized stand-in + /// sits at the centre of the content, which is where the popover would point + /// anyway. + private lazy var shareAnchor: UIButton = { + let button = UIButton(frame: .zero) + button.isUserInteractionEnabled = false + button.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: view.centerXAnchor), + button.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + return button + }() + + private func share() { + dw_shareReceiveInfo(model, sender: shareAnchor) + } + + @objc + private func checkRequestStatus() { + guard let info = model.requestAmountReceivedInfoIfReceived() else { return } + delegate?.requestAmountHostingController(self, didReceiveAmountWithInfo: info) + } +} + +// MARK: - DWReceiveModelDelegate + +extension RequestAmountHostingController: DWReceiveModelDelegate { + func receivingInfoDidUpdate() { + reloadFromModel() + } +} + +// MARK: - Container + +/// Observes the state mirror so the screen itself can stay a plain +/// value-in / closures-out view. +private struct RequestAmountScreenContainer: View { + @ObservedObject var state: RequestAmountHostingController.State + + let amountDuffs: UInt64 + let onCopyAddress: () -> Void + let onCopyQRCode: () -> Void + let onCopyUsername: () -> Void + let onShare: () -> Void + + var body: some View { + RequestAmountScreen( + amountDuffs: amountDuffs, + fiatAmount: CurrencyExchanger.shared.fiatAmountString(for: amountDuffs.dashAmount), + qrCodeImage: state.qrCodeImage, + paymentAddress: state.paymentAddress, + username: state.username, + isWatchingForReceipt: state.isWatchingForReceipt, + onCopyAddress: onCopyAddress, + onCopyQRCode: onCopyQRCode, + onCopyUsername: onCopyUsername, + onShare: onShare) + .transientToast( + isPresented: $state.copiedToastVisible, + style: .copied, + message: NSLocalizedString("Copied", comment: "")) + } +} diff --git a/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountScreen.swift b/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountScreen.swift new file mode 100644 index 0000000000..577781381d --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Receive/RequestAmount/RequestAmountScreen.swift @@ -0,0 +1,306 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +/// SwiftUI port of `DWRequestAmountContentView`: the requested amount over the +/// QR that encodes it, the address under that, and Share. +/// +/// Same contents and same copy-on-tap behaviour as the original, dressed in the +/// redesign: the amount is `SwapAmountView`, the same component the amount step +/// enters it with, and the card below is `PaymentsReceiveContent`'s โ€” same QR +/// size, same address row, same pill actions โ€” so the sheet reads as the +/// Receive tab with an amount on it. The original is still in the tree; nothing +/// routes here yet. +/// +/// Values in, closures out: the model stays with the host, because +/// `DWReceiveModelProtocol` is an ObjC protocol with a delegate that pushes +/// updates rather than publishing them. +struct RequestAmountScreen: View { + let amountDuffs: UInt64 + let fiatAmount: String + let qrCodeImage: UIImage? + let paymentAddress: String? + /// DashPay only; `nil` everywhere else, and the row is dropped. + var username: String? = nil + + /// True while the attended receive session is live behind this sheet. + /// Default false so every other presenter of this screen is unchanged. + var isWatchingForReceipt: Bool = false + + var onCopyAddress: () -> Void + var onCopyQRCode: () -> Void + var onCopyUsername: () -> Void = {} + var onShare: () -> Void + + /// Taken from `PaymentsReceiveContent`, the Receive tab this sheet is the + /// amount-carrying twin of: the same 200pt QR in the same 10pt well, the + /// same address row, the same pill actions, the same `MenuViewModifier` + /// card around all of it. + private enum Layout { + static let contentInset: CGFloat = 20 + /// A fixed square, not a fraction of the width โ€” the Receive tab sizes + /// its QR this way so the rows under it never shift. + static let qrSide: CGFloat = 200 + static let qrPadding: CGFloat = 10 + /// The DashPay avatar, or the Dash "D", at the centre of the code. + static let qrBadgeFraction: CGFloat = 0.18 + } + + private var hasAddress: Bool { paymentAddress != nil } + + var body: some View { + VStack(spacing: 20) { + amount + card + .padding(.horizontal, 20) + + if isWatchingForReceipt { + // This sheet is the handoff surface โ€” a QR with the amount + // already in it, held up for someone to pay โ€” so the session + // keeps running behind it and says so. + ReceiveWatchingIndicator() + } + } + .frame(maxWidth: .infinity) + } + + // MARK: - Amount + + /// The same component the amount step draws with, so the number keeps its + /// size and weight as the user crosses from "Specify amount" into this + /// sheet. It renders the Dash glyph itself, and scales the whole group down + /// to fit rather than truncating the digits โ€” which the fixed-size + /// `DWAmountPreviewView` this replaces could not do. + /// + /// Interaction is all opt-in and stays off here: no paste, no currency + /// chevron, no swap. This amount is a result, not an input. + private var amount: some View { + DashUIKit.SwapAmountView( + amount: amountDuffs.formattedDashAmountWithoutCurrencySymbol, + // An empty secondary renders as "0" inside the component, so the + // "rates have not arrived" case has to pass nil to drop the line. + secondaryText: fiatAmount.isEmpty ? nil : fiatAmount, + showDashLogo: true + ) + .padding(.horizontal, Layout.contentInset) + } + + // MARK: - Card + + /// The Receive tab's card, rebuilt around this screen's contents: the QR + /// and its rows share one block of padding, and the action sits below them + /// with its own, so the button keeps the tab's 20pt frame instead of + /// inheriting the rows' spacing. + private var card: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .center, spacing: 20) { + Text(NSLocalizedString("This QR contains the specified amount", comment: "Receive screen")) + .dashFont(.footnote) + .foregroundStyle(Color.dash.primaryText) + + qrCode + + if let paymentAddress { + addressRow( + caption: NSLocalizedString("Your DASH address", comment: "Receive screen"), + value: paymentAddress, + action: onCopyAddress) + } + + if let username { + addressRow( + caption: NSLocalizedString("Username", comment: "Receive screen"), + value: username, + action: onCopyUsername) + } + + // The host wires `onShare` to the share sheet; without a + // control bound to it the requested payment could be copied + // but never sent to anyone. + DashUIKit.DashButton( + text: NSLocalizedString("Share", comment: "Receive screen"), + isEnabled: hasAddress, + fillsWidth: true, + size: .medium, + style: .tintedGray, + action: onShare + ) + .padding(.bottom, 6) + } + .padding(.horizontal, Layout.contentInset) + .padding(.top, 40) + .padding(.bottom, 10) + } + .modifier(MenuViewModifier(innerPadding: 0)) + } + + // MARK: - QR + + /// The Receive tab's QR: a fixed 200pt square in a 10pt well, drawn + /// straight onto the card rather than into a container of its own. + /// + /// Tapping copies the image, as the original's QR button did. The badge in + /// the middle is the DashPay avatar when there is one, and the Dash "D" + /// otherwise โ€” the tab has no badge because it has no DashPay identity to + /// show, this screen does. + @ViewBuilder + private var qrCode: some View { + if let qrCodeImage { + Button(action: onCopyQRCode) { + Image(uiImage: qrCodeImage) + .interpolation(.none) + .resizable() + .frame(width: Layout.qrSide, height: Layout.qrSide) + .padding(Layout.qrPadding) + .overlay(qrBadge(side: Layout.qrSide * Layout.qrBadgeFraction)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + // The same 220pt square the QR occupies, so the rows below do not + // jump once the address resolves. + SwiftUI.ProgressView() + .frame(width: Layout.qrSide + Layout.qrPadding * 2, + height: Layout.qrSide + Layout.qrPadding * 2) + } + } + + @ViewBuilder + private func qrBadge(side: CGFloat) -> some View { + if let avatarInitial { + Text(avatarInitial) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.white) + .frame(width: side, height: side) + .background(Circle().fill(Color.dash.blue)) + .overlay(Circle().stroke(Color.dash.white, lineWidth: 3)) + } else { + DashIcon.Menu.dashLogoSquare.image + .resizable() + .scaledToFit() + .frame(width: side, height: side) + } + } + + /// Stand-in for Android's avatar image: the profile picture is not on this + /// screen's model, so the username's first letter carries it. + private var avatarInitial: String? { + guard let first = username?.first else { return nil } + return String(first).uppercased() + } + + // MARK: - Copyable rows + + /// The Receive tab's address row: caption over a wrapping value, with a + /// `tintedGray` copy pill on the trailing edge. + /// + /// One deviation from the tab โ€” the text takes the full width. The tab has + /// a single row and can let the pair sit centred; this screen can show two + /// (address and username), and centred rows of different lengths would put + /// their copy pills at different x. + private func addressRow(caption: String, value: String, action: @escaping () -> Void) -> some View { + HStack(spacing: 40) { + VStack(alignment: .leading, spacing: 2) { + Text(caption) + .dashFont(.footnote) + .foregroundStyle(Color.dash.secondaryText) + + Text(value) + .dashFont(.subhead) + .foregroundColor(Color.dash.primaryText) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + + DashUIKit.DashButton( + leadingIcon: .custom(DashIcon.Icons.copyOutline.rawValue, bundle: .dashUIKit), + size: .medium, + style: .tintedGray, + action: action + ) + } + .padding(.vertical, 6) + } +} + +#if DEBUG + +/// The screen as the host actually shows it โ€” inside `DashUIKit.BottomSheet`, +/// over a dimmed backdrop. On its own it has no chrome and no background of its +/// own, so previewing it bare would flatter it. +private func requestAmountSheet( + amountDuffs: UInt64 = 125_000_000, + fiatAmount: String = "$32.75", + address: String? = "XyZ8kFqW3nR5tHmB2vJcL7pQaS4dEuG9wN", + username: String? = nil +) -> some View { + DashUIKit.BottomSheet( + showBackButton: .constant(false), + fillsHeight: false + ) { + RequestAmountScreen( + amountDuffs: amountDuffs, + fiatAmount: fiatAmount, + qrCodeImage: address.flatMap { QRCodeGenerator.image(for: $0) }, + paymentAddress: address, + username: username, + onCopyAddress: {}, + onCopyQRCode: {}, + onCopyUsername: {}, + onShare: {}) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) +} + +@available(iOS 17, *) +#Preview("Address") { + requestAmountSheet() +} + +/// DashPay: the username row joins the address, and the badge in the middle of +/// the QR becomes the avatar instead of the Dash logo. +@available(iOS 17, *) +#Preview("With username") { + requestAmountSheet(username: "Epsilon2") +} + +/// Before the address resolves: a spinner in the QR's square so nothing below +/// moves when it arrives, and Share dimmed rather than sharing nothing. +@available(iOS 17, *) +#Preview("No address") { + requestAmountSheet(address: nil) +} + +/// The address must ellipsize in the middle rather than push the copy button +/// off the row โ€” the failure this layout is most likely to have. +@available(iOS 17, *) +#Preview("Long address ยท username") { + requestAmountSheet( + address: "yTBaseS8fJ4vFrmqhUvBaseLongAddressPaddedOutToItsFullWidth1234", + username: "a-rather-long-dashpay-username") +} + +/// Whole units and a fraction read differently at this size; the fiat line +/// disappears when rates have not arrived. +@available(iOS 17, *) +#Preview("Fractional ยท no rate") { + requestAmountSheet(amountDuffs: 12_345_678, fiatAmount: "") +} + +#endif diff --git a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift index 69faa759af..c482da7cd7 100644 --- a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift @@ -146,11 +146,6 @@ struct BuyEnterAmountView: View { } } ) - .frame(maxWidth: .infinity, maxHeight: 320) - .padding(.horizontal, Layout.hPadding) - .background(Color.dash.secondaryBackground) - .clipShape(.rect(cornerRadius: 20)) - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) } } diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift index e653b20983..5cd373f4d4 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift @@ -163,11 +163,6 @@ struct SwapConvertView: View { inProgress: viewModel.isLoading, actionHandler: onContinue ) - .frame(maxWidth: .infinity, maxHeight: 320) - .padding(.horizontal, Layout.hPadding) - .background(Color.dash.secondaryBackground) - .clipShape(.rect(cornerRadius: 20)) - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) } } diff --git a/DashWallet/Sources/UI/SwiftUI Components/ActivityView.swift b/DashWallet/Sources/UI/SwiftUI Components/ActivityView.swift new file mode 100644 index 0000000000..dea9d87fa4 --- /dev/null +++ b/DashWallet/Sources/UI/SwiftUI Components/ActivityView.swift @@ -0,0 +1,34 @@ +// +// Created by Andrei Ashikhmin +// Copyright ยฉ 2024 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import UIKit + +/// The system share sheet, for SwiftUI. +/// +/// Lived in `SettingsScreen.swift` until the About screen and the Tools menu +/// started using it too โ€” three screens reaching into one screen's file for a +/// component that is about none of them. +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/DashWallet/Sources/UI/SwiftUI Components/Icons/XmarkIcon.swift b/DashWallet/Sources/UI/SwiftUI Components/Icons/XmarkIcon.swift deleted file mode 100644 index 3f60a5ce18..0000000000 --- a/DashWallet/Sources/UI/SwiftUI Components/Icons/XmarkIcon.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// Created by Roman Chornyi -// Copyright ยฉ 2026 Dash Core Group. All rights reserved. -// -// Licensed under the MIT License (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://opensource.org/licenses/MIT -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import SwiftUI -import DashUIKit - -// MARK: - XmarkIcon - -/// A code-drawn "โœ•" (close) icon. Mirrors the source SVG (9ร—9 viewBox, two diagonals -/// inset from 0.75 to 7.75, round caps/joins). Scales cleanly to any `size`. -struct XmarkIcon: View { - var size: CGFloat = 9 - var color: Color = .dash.primaryText - var lineWidth: CGFloat = 1.5 - - var body: some View { - XmarkShape() - .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .frame(width: size, height: size) - } -} - -// MARK: - XmarkShape - -/// Two diagonal strokes forming an "โœ•". Endpoints are normalized from the 9-unit -/// source viewBox (inset 0.75 โ†’ 7.75) so the cross keeps its proportions at any size. -private struct XmarkShape: Shape { - private let insetRatio: CGFloat = 0.75 / 9 - private let extentRatio: CGFloat = 7.75 / 9 - - func path(in rect: CGRect) -> Path { - let minX = rect.minX + rect.width * insetRatio - let maxX = rect.minX + rect.width * extentRatio - let minY = rect.minY + rect.height * insetRatio - let maxY = rect.minY + rect.height * extentRatio - - var path = Path() - path.move(to: CGPoint(x: minX, y: minY)) - path.addLine(to: CGPoint(x: maxX, y: maxY)) - path.move(to: CGPoint(x: minX, y: maxY)) - path.addLine(to: CGPoint(x: maxX, y: minY)) - return path - } -} - -#Preview { - VStack(spacing: 24) { - XmarkIcon() - XmarkIcon(size: 24, color: .white, lineWidth: 2) - .padding(20) - .background(Color.dash.blue, in: .circle) - XmarkIcon(size: 40, color: .red, lineWidth: 3) - } - .padding() -} diff --git a/DashWallet/Sources/UI/SwiftUI Components/SegmentedControl.swift b/DashWallet/Sources/UI/SwiftUI Components/SegmentedControl.swift index 810a56982d..947954067c 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/SegmentedControl.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/SegmentedControl.swift @@ -28,6 +28,15 @@ private enum SegmentedControlLayout { static let shadowY: CGFloat = 5 static let springResponse: Double = 0.3 static let springDamping: Double = 0.7 + + // Icon variant (artwork stacked over the label). It sizes to its content + // instead of `height`, which only fits a single text line. The icons are + // drawn to one height and differing widths โ€” a single arrow is half as + // wide as the double one โ€” so pinning the height is what keeps the labels + // under them on one line. + static let iconHeight: CGFloat = 17 + static let iconLabelSpacing: CGFloat = 4 + static let iconSegmentVerticalPadding: CGFloat = 10 } struct SegmentedControl: View { @@ -36,6 +45,14 @@ struct SegmentedControl: View { let options: [T] @Binding var selection: T let label: (T) -> String + /// Artwork drawn above each label, chosen per selection state. `nil` (the + /// default) keeps the text-only segment every existing caller uses. + /// + /// It takes `isSelected` because a selected segment and an unselected one + /// are usually two different files rather than one file tinted two ways โ€” + /// artwork that carries its own colour cannot be dimmed into the + /// unselected treatment the label gets. + var icon: ((_ option: T, _ isSelected: Bool) -> DashIconSource)? = nil @Environment(\.colorScheme) private var colorScheme @State private var containerWidth: CGFloat = 0 @@ -65,12 +82,12 @@ struct SegmentedControl: View { Button(action: { select(option) }) { - Text(label(option)) - .font(.footnoteMedium) + segmentContent(option) .foregroundColor(selection == option ? .dash.primaryText : .dash.primaryText.opacity(0.4)) - .lineLimit(1) .frame(maxWidth: .infinity) - .padding(.vertical, Layout.segmentVerticalPadding) + .padding(.vertical, icon == nil + ? Layout.segmentVerticalPadding + : Layout.iconSegmentVerticalPadding) .contentShape(Rectangle()) .animation(.spring(response: Layout.springResponse, dampingFraction: Layout.springDamping), value: selection) } @@ -99,11 +116,34 @@ struct SegmentedControl: View { } ) .padding(Layout.containerPadding) - .frame(height: Layout.height) + // The icon variant is two lines tall, so it sizes to its content โ€” + // `height` is the single-line text measurement. + .frame(height: icon == nil ? Layout.height : nil) .background(Color.dash.gray300Alpha20) .clipShape(RoundedRectangle(cornerRadius: Layout.containerCornerRadius)) } + @ViewBuilder + private func segmentContent(_ option: T) -> some View { + if let icon { + VStack(spacing: Layout.iconLabelSpacing) { + Image(dash: icon(option, selection == option)) + .resizable() + .scaledToFit() + .frame(height: Layout.iconHeight) + segmentLabel(option) + } + } else { + segmentLabel(option) + } + } + + private func segmentLabel(_ option: T) -> some View { + Text(label(option)) + .font(.footnoteMedium) + .lineLimit(1) + } + private func updateSelection(at x: CGFloat) { let index = max(0, min(options.count - 1, Int(x / segmentWidth))) select(options[index]) @@ -145,3 +185,40 @@ private struct SegmentedControlPreview: View { .padding() } } + +#Preview("Icon variant") { + SegmentedControlIconPreview() +} + +/// The icon variant next to the text-only one, so the two stay recognisably the +/// same control: same track, same pill, only the segment is two lines tall. Tap +/// through the segments โ€” each swaps between its coloured and grey artwork. +private struct SegmentedControlIconPreview: View { + @State private var withIcons = "Internal" + @State private var textOnly = "Internal" + + private let options = ["Receive", "Internal", "Send"] + private func icon(_ option: String, isSelected: Bool) -> DashIconSource { + switch option { + case "Receive": + return (isSelected ? DashIcon.SegmentedControl.receive : .receiveDisabled).source + case "Send": + return (isSelected ? DashIcon.SegmentedControl.send : .sendDisabled).source + default: + return (isSelected ? DashIcon.SegmentedControl.transfer : .transferDisabled).source + } + } + + var body: some View { + VStack(spacing: 24) { + SegmentedControl( + options: options, + selection: $withIcons, + label: { $0 }, + icon: icon) + + SegmentedControl(options: options, selection: $textOnly) + } + .padding() + } +} diff --git a/DashWallet/Sources/UI/SwiftUI Components/Toast.swift b/DashWallet/Sources/UI/SwiftUI Components/Toast.swift index 75e6225cae..56feba8f79 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/Toast.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/Toast.swift @@ -32,9 +32,14 @@ struct ToastView: View { if let icon = icon { VStack { Icon(name: icon) + // `font` sizes SF Symbols only. A `.custom` asset is + // resizable with no intrinsic cap, so without this + // frame it grows to fill the row and squeezes the + // text onto two lines. + .font(.system(size: 15)) + .frame(maxHeight: 20) .padding(.leading, 8) .padding(.top, 12) - .font(.system(size: 15)) Spacer() } } diff --git a/DashWallet/Sources/UI/SwiftUI Components/TransientToastModifier.swift b/DashWallet/Sources/UI/SwiftUI Components/TransientToastModifier.swift new file mode 100644 index 0000000000..11bf1c28ae --- /dev/null +++ b/DashWallet/Sources/UI/SwiftUI Components/TransientToastModifier.swift @@ -0,0 +1,147 @@ +// +// Created by Roman Chornyi +// Copyright ยฉ 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI + +extension View { + /// Overlays a `DashUIKit.Toast` at the bottom while `isPresented`, then + /// clears the flag after `duration`. + /// + /// The sibling `dexOfflineToast` shows a toast for as long as a condition + /// holds; this one is for a moment that has already passed โ€” a copy, a + /// save โ€” where nothing will turn the flag off but time. + /// + /// One toast at a time: the timer is keyed on `isPresented`, so a second + /// trigger while one is already up does NOT extend it. Setting a `true` + /// flag to `true` is not a change, so there is no event to restart on โ€” the + /// second message rides out whatever is left of the first one's countdown. + /// A caller that needs each message timed in full has to lower the flag + /// between them. + func transientToast( + isPresented: Binding, + style: ToastStyle, + message: String, + duration: TimeInterval = 2 + ) -> some View { + modifier(TransientToastModifier( + isPresented: isPresented, + style: style, + message: message, + duration: duration)) + } +} + +extension View { + /// Overlays a `DashUIKit.Toast` for as long as `isVisible` holds. + /// + /// The sibling above is for a moment that has already passed and needs a + /// timer to clear it; this one is for a condition that clears itself โ€” a + /// sync finishing, a connection returning. + func conditionToast( + isVisible: Bool, + style: ToastStyle, + message: String + ) -> some View { + modifier(ConditionToastModifier(isVisible: isVisible, style: style, message: message)) + } +} + +private struct ConditionToastModifier: ViewModifier { + let isVisible: Bool + let style: ToastStyle + let message: String + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if isVisible { + DashUIKit.Toast(style: style, message: message) + .padding(.horizontal, 20) + .padding(.bottom, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: isVisible) + } +} + +private struct TransientToastModifier: ViewModifier { + @Binding var isPresented: Bool + let style: ToastStyle + let message: String + let duration: TimeInterval + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if isPresented { + // No `onDismiss`: it draws a close button, and the + // `Spacer` beside that button makes the toast greedy โ€” + // it would stretch edge to edge instead of hugging the + // message. Nothing here needs dismissing by hand anyway. + DashUIKit.Toast(style: style, message: message) + .padding(.horizontal, 20) + .padding(.bottom, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: isPresented) + .task(id: isPresented) { + guard isPresented else { return } + try? await Task.sleep(for: .seconds(duration)) + // Cancelled when the flag flips or the view goes away. It does + // not restart on a repeat of the same value โ€” see the note on + // `transientToast`. + guard !Task.isCancelled else { return } + isPresented = false + } + } +} + +#if DEBUG + +private struct TransientToastPreview: View { + @State private var copied = false + + var body: some View { + VStack { + Spacer() + // Qualified: the app has a `DashButton` of its own, with a + // different Style enum. + DashUIKit.DashButton( + text: "Copy", + size: .medium, + style: .tintedGray, + action: { copied = true }) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.dash.primaryBackground) + .transientToast( + isPresented: $copied, + style: .copied, + message: NSLocalizedString("Copied", comment: "")) + } +} + +@available(iOS 17, *) +#Preview("Tap to show") { + TransientToastPreview() +} + +#endif diff --git a/DashWallet/Sources/UI/Views/UIView+DWHUD.m b/DashWallet/Sources/UI/Views/UIView+DWHUD.m index 867ed8f84c..69e744ea10 100644 --- a/DashWallet/Sources/UI/Views/UIView+DWHUD.m +++ b/DashWallet/Sources/UI/Views/UIView+DWHUD.m @@ -29,19 +29,46 @@ @implementation UIView (DWHUD) +/// The progress HUD is held by reference (`dw_progressHUD`) rather than looked +/// up, so show and hide always address the same object. +/// +/// MBProgressHUD's own `HUDForView:` cannot serve here: info HUDs are the same +/// class on the same view, and it returns the TOPMOST one. A flow that reports +/// its result with `dw_showInfoHUD` and then takes its spinner down with +/// `dw_hideProgressHUD` therefore dismissed the confirmation it had just put +/// up, leaving nothing to hide the spinner โ€” which then ran forever. The +/// asset-lock retry in `TxDetailViewController` is exactly that pair, and it +/// span on over a transfer that had already completed. The same lookup made +/// `dw_showProgressHUD` adopt a visible info HUD and rewrite its label instead +/// of raising a spinner at all. +/// +/// Telling the two apart by `mode` would fix those but not this: a HUD stays in +/// the hierarchy for the length of its fade-out with `finished` already set +/// (private, so not readable from here), and a show inside that window would +/// adopt a HUD on its way out โ€” leaving the caller with no spinner. An owned +/// reference, released at hide time, has neither problem. - (void)dw_showProgressHUDWithMessage:(nullable NSString *)message { - MBProgressHUD *hud = [MBProgressHUD HUDForView:self]; + MBProgressHUD *hud = [self dw_progressHUD]; if (!hud) { hud = [MBProgressHUD showHUDAddedTo:self animated:YES]; + hud.removeFromSuperViewOnHide = YES; hud.label.font = [UIFont dw_fontForTextStyle:UIFontTextStyleCaption1]; hud.label.adjustsFontForContentSizeCategory = YES; hud.label.numberOfLines = 0; + [self setDw_progressHUD:hud]; } hud.label.text = message; } - (void)dw_hideProgressHUD { - [MBProgressHUD hideHUDForView:self animated:YES]; + MBProgressHUD *hud = [self dw_progressHUD]; + if (!hud) { + return; + } + // Released before the fade-out finishes, deliberately: a `dw_showโ€ฆ` during + // that window must build a fresh HUD instead of adopting this one. + [self setDw_progressHUD:nil]; + [hud hideAnimated:YES]; } - (void)dw_showInfoHUDWithText:(NSString *)text { @@ -104,6 +131,17 @@ - (void)dw_showInfoHUDWithText:(NSString *)text offsetForNavBar:(BOOL)shouldOffs #pragma mark - Private +- (nullable MBProgressHUD *)dw_progressHUD { + return objc_getAssociatedObject(self, @selector(dw_progressHUD)); +} + +- (void)setDw_progressHUD:(nullable MBProgressHUD *)dw_progressHUD { + objc_setAssociatedObject(self, + @selector(dw_progressHUD), + dw_progressHUD, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + - (nullable NSMutableArray *)dw_infoHUDQueue { return objc_getAssociatedObject(self, @selector(dw_infoHUDQueue)); } diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 15ce96108a..cb46a991de 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -3707,6 +3707,9 @@ /* No comment provided by engineer. */ "Scan QR" = "Scan QR"; +/* Payments */ +"Scan Dash QR" = "Scan Dash QR"; + /* No comment provided by engineer. */ "Scan QR Code" = "Scan QR Code"; @@ -3868,15 +3871,24 @@ /* No comment provided by engineer. */ "Send to a Contact" = "Send to a Contact"; +/* Payments */ +"Swap to other crypto" = "Swap to other crypto"; + /* Translate it as short as possible! (24 symbols max) */ "Send to Address" = "Send to Address"; /* No comment provided by engineer. */ "Send to address" = "Send to address"; +/* Payments */ +"Send to Dash address" = "Send to Dash address"; + /* No comment provided by engineer. */ "Send to contact" = "Send to contact"; +/* Payments */ +"Send to username" = "Send to username"; + /* Translate it as short as possible! (24 symbols max) */ "Send to Contact" = "Send to Contact"; diff --git a/DashWalletScreenshotsUITests/DashWalletScreenshotsUITests.swift b/DashWalletScreenshotsUITests/DashWalletScreenshotsUITests.swift index 6cf0afe4fb..7bf9198fbd 100644 --- a/DashWalletScreenshotsUITests/DashWalletScreenshotsUITests.swift +++ b/DashWalletScreenshotsUITests/DashWalletScreenshotsUITests.swift @@ -81,3 +81,46 @@ class DashWalletScreenshotsUITests: XCTestCase { element.tap() } } + +// MARK: - Advanced mode row + +/// The Advanced mode row is a `Button` that opens the explainer, wrapped around +/// a `MenuItem` whose accessory is an interactive switch. Nesting a control +/// inside a button is where a tap can end up answered twice, so this pins the +/// split: the switch flips the setting and the sheet stays shut. +/// +/// `SwitchView` is not a `Toggle` โ€” it is a custom view carrying `.isButton` +/// and an On/Off accessibility value โ€” which is why the switch is found as a +/// button descendant of the row rather than through `app.switches`. +class AdvancedModeRowUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = XCUIApplication() + } + + func testTappingTheSwitchDoesNotOpenTheExplainer() { + app.launch() + + let row = app.descendants(matching: .any)["settings_row_advanced_mode"] + XCTAssert(row.waitForExistence(timeout: 15), + "Advanced mode row not found โ€” the Settings screen is not on display") + + let toggle = row.buttons.firstMatch + XCTAssert(toggle.waitForExistence(timeout: 3), + "No switch inside the Advanced mode row") + + let before = toggle.value as? String + toggle.tap() + + let sheet = app.descendants(matching: .any)["advanced_mode_info_sheet"] + XCTAssertFalse(sheet.waitForExistence(timeout: 2), + "Tapping the switch opened the explainer โ€” the row's button swallowed it") + + XCTAssertNotEqual(toggle.value as? String, before, + "The switch did not change state, so the tap never reached it") + } +}