From 264a874e906e5380f58e576161614eff3166ec49 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:39:54 +0300 Subject: [PATCH 01/19] refactor(send): split the send flow out of one 1156-line file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendScreen.swift held every step of the external send — the address screen, the source picker, the amount keypad, the confirm sheet — plus their shared chrome and helpers. Each now has its own file under Send/, with the pieces two or more steps share in Send/Components/. Nothing changed but location. The helpers and the two chrome views were file-scope `private`, which the split cannot preserve, so they are internal now; SendViewModel moves in beside the screens it drives. Co-Authored-By: Claude Opus 5 --- DashWallet.xcodeproj/project.pbxproj | 64 +- .../Pay/Send/Components/SendChainStyle.swift | 68 + .../Pay/Send/Components/SendStepChrome.swift | 75 ++ .../Pay/Send/Components/SyncGateNote.swift | 35 + .../Pay/Send/ExternalSendAmountScreen.swift | 230 ++++ .../Payments/Pay/Send/SendConfirmSheet.swift | 443 +++++++ .../UI/Payments/Pay/Send/SendScreen.swift | 266 ++++ .../Payments/Pay/Send/SendSourceScreen.swift | 98 ++ .../Pay/{ => Send}/SendViewModel.swift | 0 .../Sources/UI/Payments/Pay/SendScreen.swift | 1156 ----------------- 10 files changed, 1274 insertions(+), 1161 deletions(-) create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift create mode 100644 DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift rename DashWallet/Sources/UI/Payments/Pay/{ => Send}/SendViewModel.swift (100%) delete mode 100644 DashWallet/Sources/UI/Payments/Pay/SendScreen.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index f9b219bc7..51f7e03f9 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -101,6 +101,13 @@ 12BD10DCCEFF4142EA3BA0CA /* SwiftDashSDKWalletSending.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4BAA92B00D4109567DF870F /* SwiftDashSDKWalletSending.swift */; }; 12DE15416F9F7A0B8749CAF1 /* PaymentsLandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */; }; 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; + 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; + 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB04100000000000000000 /* SendStepChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendStepChrome.swift */; }; + 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB00100000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; 16AA1B384A9758C1DA97676A /* SwiftDashSDKHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */; }; 17ED79A8B56522CD03A8CD5E /* ShieldedTransferCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */; }; 197F19679BBAABA2E99503B8 /* DWAvatarUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A461F75E1E170FFE3294337 /* DWAvatarUploadClient.swift */; }; @@ -125,7 +132,6 @@ 2556F3C5C72D70A771C6C61A /* PaymentProtocolWireFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B030D4B574621DA2C5816 /* PaymentProtocolWireFormat.swift */; }; 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - 25C88271468A4CD23F34A6E9 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1F21A7592E99814A800518 /* SendScreen.swift */; }; 279C16F9AF0575C540E312E5 /* SwapConvertCardRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99130732DE8F2E95F635F6B /* SwapConvertCardRow.swift */; }; 2991DE4863B0BF4FA8628C3C /* DWProfileUpdateBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA91E4239626E28125CC0E2 /* DWProfileUpdateBridge.swift */; }; 2A0C699C23104588001B8C90 /* DWPaymentInput.m in Sources */ = {isa = PBXBuildFile; fileRef = 2A0C699B23104588001B8C90 /* DWPaymentInput.m */; }; @@ -1008,6 +1014,13 @@ A365C2F95472285830E6CDEB /* HomeUsernameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E55DD28C4EBE049216291A2 /* HomeUsernameRow.swift */; }; A3ED42ED80F32AA5CA530A40 /* DamerauLevenshtein.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CE60A95629A2883710A75C /* DamerauLevenshtein.swift */; }; A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; + 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; + 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB04200000000000000000 /* SendStepChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendStepChrome.swift */; }; + 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB00200000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; A6AAB271A1ADA69C272DA1D4 /* PinPromptPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D385FC174E5B887CF3398CCD /* PinPromptPresenter.swift */; }; A6DB8889C5594B1988BDE0CE /* SwapCoinIconLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F0351CAD174EEB8E64012A /* SwapCoinIconLoader.swift */; }; A72BF1D1A1F3F067215CE04C /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */; }; @@ -1195,7 +1208,6 @@ C3DAD268246AA6F10001624F /* DWScreenshotWarningViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C3DAD267246AA6F10001624F /* DWScreenshotWarningViewController.m */; }; C3DAD2CF247585C10001624F /* NSPredicate+DWFullTextSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = C3DAD2CE247585C10001624F /* NSPredicate+DWFullTextSearch.m */; }; C5745FA476F615D2C1E378F3 /* SwiftDashSDKReceiveAddressReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */; }; - C5906424C3D379AF0CB83370 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1F21A7592E99814A800518 /* SendScreen.swift */; }; C6138156317BAF449AA76861 /* PaymentsLandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */; }; C6344885F1E0A6C04D3D8437 /* InternalTransferConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */; }; C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F454341FADB260861FE05EB4 /* SendScreenViewController.swift */; }; @@ -2896,7 +2908,6 @@ 5B30DA60B985C3CC3ED05B64 /* DWCurrentUserIdentityInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWCurrentUserIdentityInfo.swift; sourceTree = ""; }; 5C3839568283FDC3A6933824 /* DWRegistrationPhaseAdapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWRegistrationPhaseAdapter.swift; sourceTree = ""; }; 5CBC0F417B9E8DE631F9DE2A /* Pods-DashWalletTests.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletTests.testnet.xcconfig"; path = "Target Support Files/Pods-DashWalletTests/Pods-DashWalletTests.testnet.xcconfig"; sourceTree = ""; }; - 5D1F21A7592E99814A800518 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; 5E1461688D08658B938A1066 /* MasternodeAPYCalculator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeAPYCalculator.swift; sourceTree = ""; }; 5EAD111130A0000000A1B201 /* MayaAmountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MayaAmountFormatter.swift; sourceTree = ""; }; 5EAD222130A0000000A1B202 /* DashAmountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashAmountFormatterTests.swift; sourceTree = ""; }; @@ -3096,6 +3107,13 @@ 8C25F44380CDB841AF55D3FF /* DarkCoinMessageFraming.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DarkCoinMessageFraming.swift; sourceTree = ""; }; 8C7313C7337036FEC54729E4 /* BIP70URI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BIP70URI.swift; sourceTree = ""; }; 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendViewModel.swift; sourceTree = ""; }; + 5ASF06100000000000000000 /* SyncGateNote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncGateNote.swift; sourceTree = ""; }; + 5ASF05100000000000000000 /* SendChainStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendChainStyle.swift; sourceTree = ""; }; + 5ASF04100000000000000000 /* SendStepChrome.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendStepChrome.swift; sourceTree = ""; }; + 5ASF03100000000000000000 /* SendConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendConfirmSheet.swift; sourceTree = ""; }; + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalSendAmountScreen.swift; sourceTree = ""; }; + 5ASF01100000000000000000 /* SendSourceScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendSourceScreen.swift; sourceTree = ""; }; + 5ASF00100000000000000000 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; 91F0B8B82F3FAB7C00E713AE /* KeysOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeysOverviewView.swift; sourceTree = ""; }; 94606903417F75BE99D4B31C /* BIP70Error.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BIP70Error.swift; sourceTree = ""; }; 96CB7E093E994D2391D36DEC /* SelfSizingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfSizingSheet.swift; sourceTree = ""; }; @@ -4955,6 +4973,29 @@ path = ControllerCollection; sourceTree = ""; }; + 5ASGRPS10000000000000000 /* Send */ = { + isa = PBXGroup; + children = ( + 5ASGRPC10000000000000000 /* Components */, + 5ASF00100000000000000000 /* SendScreen.swift */, + 5ASF01100000000000000000 /* SendSourceScreen.swift */, + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */, + 5ASF03100000000000000000 /* SendConfirmSheet.swift */, + 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, + ); + path = Send; + sourceTree = ""; + }; + 5ASGRPC10000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5ASF04100000000000000000 /* SendStepChrome.swift */, + 5ASF05100000000000000000 /* SendChainStyle.swift */, + 5ASF06100000000000000000 /* SyncGateNote.swift */, + ); + path = Components; + sourceTree = ""; + }; 2A8B9E5722FF6F2900FF8653 /* Pay */ = { isa = PBXGroup; children = ( @@ -4968,9 +5009,8 @@ C9F42F9E29DA82E5001BC549 /* PayableViewController.swift */, D788CCB6B35B655BD713129E /* ChainNetworkToggle.swift */, 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */, - 5D1F21A7592E99814A800518 /* SendScreen.swift */, + 5ASGRPS10000000000000000 /* Send */, F454341FADB260861FE05EB4 /* SendScreenViewController.swift */, - 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, ); path = Pay; sourceTree = ""; @@ -10124,6 +10164,13 @@ 25C88271468A4CD23F34A6E9 /* SendScreen.swift in Sources */, C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */, 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */, + 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */, + 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */, + 5ASB04100000000000000000 /* SendStepChrome.swift in Sources */, + 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */, + 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */, + 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */, + 5ASB00100000000000000000 /* SendScreen.swift in Sources */, 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */, 12DE15416F9F7A0B8749CAF1 /* PaymentsLandingViewModel.swift in Sources */, 908AB7EFCC69D3828BBD70A8 /* PaymentsLandingHostingController.swift in Sources */, @@ -11148,6 +11195,13 @@ C5906424C3D379AF0CB83370 /* SendScreen.swift in Sources */, 7C8C17249DC2DD0ECB3F2720 /* SendScreenViewController.swift in Sources */, A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */, + 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */, + 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */, + 5ASB04200000000000000000 /* SendStepChrome.swift in Sources */, + 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */, + 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */, + 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */, + 5ASB00200000000000000000 /* SendScreen.swift in Sources */, 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */, C6138156317BAF449AA76861 /* PaymentsLandingViewModel.swift in Sources */, 72F0556F329AE232966A8B4A /* PaymentsLandingHostingController.swift in Sources */, diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift new file mode 100644 index 000000000..77ab781f0 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift @@ -0,0 +1,68 @@ +// +// SendChainStyle.swift +// DashWallet +// +// Icons, titles and badges for a chain network or destination kind. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Shared helpers + +func sourceIconName(_ network: ChainNetwork) -> String { + switch network { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } +} + +func sourceTitle(_ network: ChainNetwork) -> String { + switch network { + case .core: return NSLocalizedString("Transparent", comment: "Balance breakdown") + case .platform: return NSLocalizedString("Platform", comment: "Dash Platform chain") + case .shielded: return NSLocalizedString("Shielded", comment: "") + } +} + +func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { + HStack(spacing: 4) { + Image(systemName: destinationIconName(destination)) + .font(.system(size: 10, weight: .semibold)) + Text(destinationTitle(destination)) + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.dashBlue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dashBlue.opacity(0.1)) + .clipShape(Capsule()) +} + +func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } +} + +func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") + case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") + case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") + } +} + +/// Middle-truncated address display shared by the screen's clipboard chip +/// and the confirm sheet's To row. +func truncateMiddle(_ s: String, visible: Int = 8) -> String { + guard s.count > visible * 2 + 3 else { return s } + let head = s.prefix(visible) + let tail = s.suffix(visible) + return "\(head)…\(tail)" +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift new file mode 100644 index 000000000..3553f4443 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift @@ -0,0 +1,75 @@ +// +// SendStepChrome.swift +// DashWallet +// +// Header and address summary shared by the send steps. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Shared step chrome + +/// Back-chevron + "Send" title, shared by the source and amount steps. +struct SendStepHeader: View { + var onBack: () -> Void + + var body: some View { + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) + } + Spacer() + Text(NSLocalizedString("Send", comment: "")) + .font(.headline) + .foregroundColor(.primaryText) + Spacer() + Color.clear.frame(width: 36, height: 36) + } + } +} + +/// The chosen destination address, read-only. Tapping (`onEdit`) pops back to +/// the address step. Shared by the source and amount steps. +struct SendAddressSummary: View { + @ObservedObject var viewModel: SendViewModel + var onEdit: () -> Void + + var body: some View { + Button(action: onEdit) { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(NSLocalizedString("Address", comment: "")) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + if let destination = viewModel.destination { + destinationBadge(destination) + } + } + HStack(spacing: 8) { + Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.primaryText) + .lineLimit(1) + Spacer() + Image(systemName: "pencil") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.secondaryBackground) + .cornerRadius(10) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift new file mode 100644 index 000000000..431bd1a08 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift @@ -0,0 +1,35 @@ +// +// SyncGateNote.swift +// DashWallet +// +// The note shown while the chain is still syncing. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +/// Inline explanation for a Continue disabled by the chain-sync gate: +/// Core-funded sends stay off until `SyncingActivityMonitor` reports +/// `.syncDone` (a stale UTXO set can't safely fund a spend). Shared by +/// the Send and Internal transfer screens. +struct SyncGateNote: View { + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 13)) + .foregroundColor(.orange) + Text(NSLocalizedString( + "Your wallet is still syncing. Sending from your Transparent balance will be available once syncing completes.", + comment: "Core send blocked until chain sync completes")) + .font(.caption) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.orange.opacity(0.08)) + .cornerRadius(10) + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift new file mode 100644 index 000000000..7641501e6 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -0,0 +1,230 @@ +// +// ExternalSendAmountScreen.swift +// DashWallet +// +// Step three: the amount keypad, and the route it commits to. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 3: amount + +/// The final step of the split external-send flow: the address and source are +/// already chosen (both shown read-only), leaving only the amount keypad and +/// the balance/affordability validation. Continue routes exactly as the old +/// single-screen form did — Core → Core into the L1 payment processor, +/// everything else into `SendConfirmSheet`. +struct ExternalSendAmountScreen: View { + @ObservedObject var viewModel: SendViewModel + /// Pop back to the source step. + var onBack: () -> Void + /// Core → Core: hand (address, amount in duffs) to the hosting + /// controller, which routes through the L1 payment processor. + var onContinueCore: (String, UInt64) -> Void + /// A non-core route finished successfully (confirm sheet's Done). + var onSendCompleted: () -> Void + + @State private var showConfirm = false + + var body: some View { + VStack(spacing: 0) { + SendStepHeader(onBack: onBack) + .padding(.horizontal, 20) + .padding(.top, 10) + + ScrollView { + VStack(spacing: 14) { + SendAddressSummary(viewModel: viewModel, onEdit: onBack) + .padding(.top, 12) + + fromSummary + + amountRow + .padding(.horizontal, 20) + .padding(.top, 6) + + if let message = viewModel.amountValidationMessage { + TransferAmountValidationNote(message: message) + .padding(.horizontal, 20) + } + } + .padding(.bottom, 8) + } + .scrollBounceBehavior(.basedOnSize) + + keyboardSection + } + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + .sheet(isPresented: $showConfirm) { + if let route = viewModel.route, route != .coreToCore { + SendConfirmSheet( + route: route, + destinationAddress: viewModel.trimmedAddress, + destinationRaw43: shieldedRecipientRaw43, + dashDuffs: viewModel.dashDuffs, + creditsAmount: viewModel.creditsPreview, + fiatText: viewModel.fiatAmountString, + withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, + isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, + isFullShieldedSweep: viewModel.isFullShieldedSweep, + onCancel: { showConfirm = false }, + onCompleted: { + showConfirm = false + onSendCompleted() + }) + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } + } + } + + private var shieldedRecipientRaw43: Data? { + if case .shielded(let raw43) = viewModel.destination { return raw43 } + return nil + } + + // MARK: - Amount + + private var amountRow: some View { + EnterAmountView( + primaryAmount: dashAmountText, + secondaryAmount: fiatAmountText, + primaryCurrency: .dash, + secondaryCurrency: .fiat(viewModel.fiatCurrencyCode), + isPrimarySelected: isDashInputSelected, + currencyCodes: amountCurrencyCodes, + selectedCurrencyCode: selectedAmountCurrencyCode, + onMax: { viewModel.fillMaxFromWallet() }, + onSwap: toggleAmountUnit, + onCurrencyTap: toggleAmountUnit, + onSelectInputType: selectAmountCurrency + ) + } + + // MARK: - Keyboard + + private var keyboardSection: some View { + NumericKeyboardView( + value: keypadBinding, + showDecimalSeparator: true, + actionButtonText: NSLocalizedString("Continue", comment: ""), + actionEnabled: viewModel.canContinue, + inProgress: false, + actionHandler: continueAction + ) + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .background(Color.dash.secondaryBackground) + .clipShape(.rect(cornerRadius: 20)) + .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) + } + + private func continueAction() { + guard let route = viewModel.route else { return } + if route == .coreToCore { + onContinueCore(viewModel.trimmedAddress, viewModel.dashDuffsUnsigned) + } else { + showConfirm = true + } + } + + /// The source picked on the previous step, read-only. Tapping goes back. + private var fromSummary: some View { + Button(action: onBack) { + HStack(spacing: 10) { + Image(systemName: sourceIconName(viewModel.source)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dashBlue) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("From", comment: "")) + .font(.caption) + .foregroundColor(.secondary) + Text(sourceTitle(viewModel.source)) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primaryText) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + } + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.secondaryBackground) + .cornerRadius(10) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + } + + // MARK: - Amount helpers + + private var isDashInputSelected: Bool { + viewModel.unit == .dash + } + + private var dashAmountText: String { + switch viewModel.unit { + case .dash: + return keypadBinding.wrappedValue + case .fiat: + guard viewModel.parsedDashAmount > 0 else { return "" } + return InternalTransferViewModel.formatTyped( + viewModel.parsedDashAmount, + fractionDigits: 8) + } + } + + private var fiatAmountText: String { + switch viewModel.unit { + case .dash: + guard viewModel.parsedDashAmount > 0, + let fiatAmount = try? CurrencyExchanger.shared.convertDash( + amount: viewModel.parsedDashAmount, + to: viewModel.fiatCurrencyCode) + else { return "" } + return InternalTransferViewModel.formatTyped( + fiatAmount, + fractionDigits: 2) + case .fiat: + return keypadBinding.wrappedValue + } + } + + private var amountCurrencyCodes: [String] { + ["DASH", viewModel.fiatCurrencyCode] + } + + private var selectedAmountCurrencyCode: String { + isDashInputSelected ? "DASH" : viewModel.fiatCurrencyCode + } + + private func toggleAmountUnit() { + viewModel.unit = isDashInputSelected ? .fiat : .dash + } + + private func selectAmountCurrency(_ currencyCode: String) { + viewModel.unit = + currencyCode.caseInsensitiveCompare("DASH") == .orderedSame + ? .dash + : .fiat + } + + private var keypadBinding: Binding { + Binding( + get: { viewModel.amountText == "0" ? "" : viewModel.amountText }, + set: { newValue in + if newValue.isEmpty { + viewModel.amountText = "0" + } else { + viewModel.amountText = newValue + } + }) + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift new file mode 100644 index 000000000..60b228989 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift @@ -0,0 +1,443 @@ +// +// SendConfirmSheet.swift +// DashWallet +// +// Confirmation half-sheet for the non-core external send routes. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - SendConfirmSheet + +/// Confirmation half-sheet for the non-core external send routes. Same +/// skeleton as `InternalTransferConfirmSheet` (summary → progress checklist → +/// success), but the To row is the recipient's address and execution runs the +/// coordinator's external-destination legs. Core → Core never reaches this +/// sheet — it rides the classic payment processor. +struct SendConfirmSheet: View { + + let route: SendViewModel.Route + let destinationAddress: String + /// The recipient's raw 43-byte Orchard payload — required for + /// `.shieldedToShielded`, nil otherwise. + let destinationRaw43: Data? + let dashDuffs: Int64 + let creditsAmount: UInt64 + let fiatText: String + /// Preflighted withdrawal fee — only meaningful for `.platformToCore`. + var withdrawalFeeCredits: UInt64? = nil + var isFullPlatformWithdrawal: Bool = false + var isFullShieldedSweep: Bool = false + var onCancel: () -> Void + var onCompleted: () -> Void + + @StateObject private var coordinator = ShieldedTransferCoordinator() + + var body: some View { + VStack(spacing: 0) { + dragHandle + .padding(.top, 8) + + Text(NSLocalizedString("Confirm", comment: "")) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + .padding(.top, 20) + + switch coordinator.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onCompleted) + default: + detailsBody + } + } + .background(Color.dash.primaryBackground) + .interactiveDismissDisabled(isInFlight) + } + + private var isInFlight: Bool { + switch coordinator.phase { + case .signing, .locking, .proving, .broadcasting: + return true + default: + return false + } + } + + // MARK: - Idle / in-flight / failed body + + private var detailsBody: some View { + VStack(spacing: 0) { + DashAmount( + amount: dashDuffs, + font: .largeTitle, + dashSymbolFactor: 0.7, + showDirection: false) + .padding(.top, 14) + + Text(fiatText) + .font(.subheadline) + .foregroundColor(.dash.secondaryText) + .padding(.top, 6) + + summaryCard + .padding(.horizontal, 20) + .padding(.top, 20) + + if case let .failed(message) = coordinator.phase { + Text(message) + .font(.system(size: 13)) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 12) + } else { + infoCard + .padding(.horizontal, 20) + .padding(.top, 12) + } + + Spacer(minLength: 12) + + switch coordinator.phase { + case .idle: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Confirm", comment: ""), + positiveButtonAction: confirm, + negativeButtonText: NSLocalizedString("Cancel", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .failed: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Try again", comment: ""), + positiveButtonAction: tryAgain, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .signing, .locking, .proving, .broadcasting: + ShieldedTransferStepList(currentPhase: coordinator.phase, steps: progressSteps) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + case .success, .submittedUnconfirmed: + EmptyView() + } + } + } + + // MARK: - Success body + + private var successBody: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .foregroundColor(.green) + .padding(.top, 24) + + Text(NSLocalizedString("Sent", comment: "Send confirm sheet")) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.dash.primaryText) + + DashAmount( + amount: dashDuffs, + font: .title, + dashSymbolFactor: 0.7, + showDirection: false) + + Text(fiatText) + .font(.subheadline) + .foregroundColor(.dash.secondaryText) + + Spacer(minLength: 12) + + DashButton( + text: NSLocalizedString("Done", comment: ""), + style: .filled, + stretch: true, + action: onCompleted) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + // MARK: - Pieces + + private var dragHandle: some View { + Rectangle() + .fill(Color.dash.grabberFill) + .frame(width: 36, height: 5) + .cornerRadius(2.5) + } + + private var summaryCard: some View { + VStack(spacing: 0) { + summaryRow( + label: NSLocalizedString("From", comment: ""), + value: fromLabel) + divider + HStack { + Text(NSLocalizedString("To", comment: "")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(truncateMiddle(destinationAddress)) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + divider + summaryRow( + label: NSLocalizedString("Network fee", comment: ""), + value: networkFeeString) + divider + summaryRow( + label: NSLocalizedString("Total", comment: ""), + value: dashDuffs.formattedDashAmount) + } + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + private var fromLabel: String { + switch route { + case .platformToPlatform, .platformToCore: + return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") + case .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: + return NSLocalizedString("Shielded balance", comment: "") + case .coreToCore, .coreToShielded: + return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") + } + } + + private func summaryRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(value) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + } + + private var divider: some View { + Rectangle() + .fill(Color.dash.gray300.opacity(0.3)) + .frame(height: 1) + .padding(.horizontal, 14) + } + + // MARK: - Network fee estimate + + /// Platform credits per DASH (1e11). + private static let creditsPerDash: Decimal = 100_000_000_000 + + /// Flat fee estimate (credits) for the active route — same estimators as + /// the internal transfer's confirm sheet. `nil` → the row shows "—". + private var networkFeeCredits: UInt64? { + switch route { + case .coreToShielded: + return CoreToShieldedAmountPolicy.poolFeeCredits + case .platformToPlatform: + // Credit transfer: the metered transition fee. The executor + // states ~0.001 DASH as the conservative max. + return 100_000_000 + case .platformToCore: + return withdrawalFeeCredits + case .shieldedToCore: + return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) + case .shieldedToPlatform: + return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) + case .shieldedToShielded: + return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) + case .coreToCore: + // Never presented here — the L1 processor shows the real fee. + return nil + } + } + + private var networkFeeString: String { + guard let credits = networkFeeCredits else { return "—" } + let dash = Decimal(credits) / Self.creditsPerDash + return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) + } + + // MARK: - Info card + + private var infoCard: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .fill(Color.dash.blue) + .frame(width: 30, height: 30) + Image(systemName: infoIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + } + + VStack(alignment: .leading, spacing: 4) { + Text(infoTitle) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(infoBody) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(14) + .background(Color.dash.secondaryBackground) + .cornerRadius(12) + } + + private var infoIcon: String { + switch route { + case .platformToCore, .shieldedToCore: return "clock.fill" + default: return "exclamationmark.shield.fill" + } + } + + private var infoTitle: String { + switch route { + case .platformToCore, .shieldedToCore: + return NSLocalizedString("Processing time", comment: "") + default: + return NSLocalizedString("Double-check the address", comment: "Send confirm sheet") + } + } + + private var infoBody: String { + switch route { + case .shieldedToCore: + return NSLocalizedString( + "The Dash arrives at the recipient's address after the network processes the withdrawal — this can take up to 10 minutes.", + comment: "Send confirm sheet") + case .platformToCore: + return isFullPlatformWithdrawal + ? NSLocalizedString( + "This withdraws your entire Platform balance in one transfer. The Dash arrives at the recipient's address once the network processes the withdrawal.", + comment: "Send confirm sheet") + : NSLocalizedString( + "The Dash arrives at the recipient's address once the network processes the withdrawal.", + comment: "Send confirm sheet") + default: + return NSLocalizedString( + "Dash sent to a wrong address can't be recovered. Make sure the address is exactly the one you intend to pay.", + comment: "Send confirm sheet") + } + } + + // MARK: - Progress checklist + + private var progressSteps: [ShieldedTransferStepList.Step] { + var steps: [ShieldedTransferStepList.Step] = [ + .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing) + ] + // Only the asset-lock route has the on-chain locking stage. + if route == .coreToShielded { + steps.append(.init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking)) + } + // Only shielded legs build an Orchard proof. + switch route { + case .coreToShielded, .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: + steps.append(.init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving)) + case .platformToPlatform, .platformToCore, .coreToCore: + break + } + steps.append(.init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting)) + return steps + } + + // MARK: - Actions + + private func confirm() { + Task { + switch route { + case .coreToShielded: + guard let destinationRaw43 else { + coordinator.reset() + return + } + await coordinator.performAssetLock( + amountDuffs: UInt64(dashDuffs), + recipientRaw43: destinationRaw43) + case .platformToPlatform: + await coordinator.performPlatformSend( + destination: destinationAddress, + amountCredits: creditsAmount) + case .platformToCore: + await coordinator.performPlatformWithdraw( + amountCredits: creditsAmount, + fullBalance: isFullPlatformWithdrawal, + feeHeadroomCredits: withdrawalFeeCredits, + toCoreAddress: destinationAddress) + case .shieldedToCore: + await coordinator.performWithdraw( + amountCredits: creditsAmount, + sweepAll: isFullShieldedSweep, + toCoreAddress: destinationAddress) + case .shieldedToPlatform: + await coordinator.performUnshield( + amountCredits: creditsAmount, + sweepAll: isFullShieldedSweep, + toPlatformAddress: destinationAddress) + case .shieldedToShielded: + guard let destinationRaw43 else { + coordinator.reset() + return + } + await coordinator.performShieldedTransfer( + amountCredits: creditsAmount, + recipientRaw43: destinationRaw43) + case .coreToCore: + // Unreachable: the screen routes Core → Core through the L1 + // payment processor and never presents this sheet. + break + } + } + } + + private func tryAgain() { + // If the just-failed Core → Shielded attempt already committed its + // asset lock, RESUME that exact outpoint with the same recipient + // instead of building a second lock (which strands the first) — + // mirrors the internal transfer's retry. + if route == .coreToShielded, + let op = coordinator.lastAssetLockOutPoint, + let destinationRaw43 { + coordinator.reset() + Task { + await coordinator.resumeAssetLock( + outPointTxidWire: op.txidWire, + outPointVout: op.vout, + recipientRaw43: destinationRaw43) + } + return + } + coordinator.reset() + confirm() + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift new file mode 100644 index 000000000..3638a28ec --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -0,0 +1,266 @@ +// +// SendScreen.swift +// DashWallet +// +// Step one of the external send: the recipient address, whose form decides the destination type. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 1: address + +struct SendScreen: View { + @ObservedObject var viewModel: SendViewModel + var onClose: () -> Void + var onScanQR: () -> Void + /// The entered address is valid → advance to the amount step. The host + /// pushes `ExternalSendAmountScreen` onto the same navigation stack. + var onContinue: () -> Void + /// False when embedded under a host that renders its own chrome + /// (the balance-row send sheet) — hides the X + title header. + var showsHeader: Bool = true + + @FocusState private var addressFieldFocused: Bool + @State private var isEditingAddress = false + + /// The address is "locked" (shown as a truncated card) once a valid + /// destination is decoded and focus has left the field; tapping it reopens + /// the editable field. + private var isAddressLocked: Bool { + viewModel.destination != nil && !isEditingAddress && !addressFieldFocused + } + + var body: some View { + VStack(spacing: 0) { + if showsHeader { + header + .padding(.horizontal, 20) + .padding(.top, 10) + } + + ScrollView { + VStack(spacing: 14) { + addressField + .padding(.top, 12) + + if let suggestion = viewModel.clipboardSuggestion, + suggestion.address != viewModel.trimmedAddress { + clipboardChip(for: suggestion) + } + + scanRow + } + .padding(.bottom, 8) + } + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + + DashButton( + text: NSLocalizedString("Continue", comment: ""), + style: .filled, + stretch: true, + isEnabled: viewModel.canAdvanceToAmount, + action: { + addressFieldFocused = false + onContinue() + }) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + } + + // MARK: - Header + + private var header: some View { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.dash.primaryText) + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) + } + Spacer() + Text(NSLocalizedString("Send", comment: "")) + .font(.headline) + .foregroundColor(.dash.primaryText) + Spacer() + Color.clear.frame(width: 36, height: 36) + } + } + + // MARK: - Address + + private var addressField: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(NSLocalizedString("Address", comment: "")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + Spacer() + if let destination = viewModel.destination { + destinationBadge(destination) + } + } + if isAddressLocked { + lockedAddressCard + } else { + TextField( + NSLocalizedString("Dash address", comment: "Send screen address placeholder"), + text: $viewModel.addressText, + axis: .vertical) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .padding(12) + .background(Color.dash.secondaryBackground) + .cornerRadius(10) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .lineLimit(2...4) + .focused($addressFieldFocused) + .onAppear { + // Rendered after tapping the locked card → put the + // cursor straight back into the field. + if isEditingAddress { + addressFieldFocused = true + } + } + .onChange(of: addressFieldFocused) { _, focused in + // Focus left the field → re-lock (when valid). + if !focused { + isEditingAddress = false + } + } + .onChange(of: viewModel.destination) { _, destination in + // The address just became valid (a paste, or the + // final typed character) → lock in right away. + // Software-keyboard-less setups (simulator with a + // hardware keyboard) never drop focus on their own, + // so don't wait for that. + if destination != nil { + addressFieldFocused = false + isEditingAddress = false + } + } + } + + if viewModel.showsInvalidAddress { + Text(NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen")) + .font(.caption) + .foregroundColor(.red) + } else if viewModel.pinnedSourceMismatch { + Text(String( + format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), + viewModel.pinnedSourceTitle)) + .font(.caption) + .foregroundColor(.red) + } + } + .padding(.horizontal, 20) + } + + /// The locked-in address: middle-truncated single line + pencil. + /// Tapping reopens the editable field with the cursor in place. + private var lockedAddressCard: some View { + Button(action: { isEditingAddress = true }) { + HStack(spacing: 8) { + Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + Spacer() + Image(systemName: "pencil") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.dash.secondaryText) + } + .padding(12) + .frame(maxWidth: .infinity) + .background(Color.dash.secondaryBackground) + .cornerRadius(10) + } + .buttonStyle(.plain) + } + + private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { + HStack(spacing: 4) { + Image(systemName: destinationIconName(destination)) + .font(.system(size: 10, weight: .semibold)) + Text(destinationTitle(destination)) + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.dash.blue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dash.blue.opacity(0.1)) + .clipShape(Capsule()) + } + + private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return "d.circle.fill" + case .platform: return "creditcard.fill" + case .shielded: return "shield.fill" + } + } + + private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { + switch destination { + case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") + case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") + case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") + } + } + + private func clipboardChip(for suggestion: SendViewModel.ClipboardSuggestion) -> some View { + Button(action: { viewModel.useClipboardSuggestion() }) { + HStack(spacing: 10) { + Image(systemName: "doc.on.doc.fill") + .foregroundColor(.blue) + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Send to copied address", comment: "")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + Text(truncateMiddle(suggestion.address)) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(destinationTitle(suggestion.kind)) + .font(.caption2) + .foregroundColor(Color.dash.secondaryText) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dash.gray300.opacity(0.3)) + .cornerRadius(8) + } + .padding(12) + .background(Color.dash.blue.opacity(0.08)) + .cornerRadius(10) + } + .padding(.horizontal, 20) + } + + private var scanRow: some View { + Button(action: onScanQR) { + HStack(spacing: 6) { + Image(systemName: "qrcode.viewfinder") + Text(NSLocalizedString("Scan QR", comment: "")) + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.dash.blue.opacity(0.12)) + .cornerRadius(10) + } + .padding(.horizontal, 20) + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift new file mode 100644 index 000000000..cda3b6b29 --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -0,0 +1,98 @@ +// +// SendSourceScreen.swift +// DashWallet +// +// Step two: which balance the send is funded from. +// + +import SwiftUI +import DashUIKit +import SwiftDashSDK +import UIKit + +// MARK: - Step 2: amount + +/// The amount step for the split external-send flow. The address is already +/// chosen (locked, read-only) — this screen carries the From picker, the +/// sync gate, and the amount keypad, plus the balance/affordability +/// validations. Continue routes exactly as the old single-screen form did: +/// Core → Core into the L1 payment processor, everything else into +/// `SendConfirmSheet`. +struct SendSourceScreen: View { + @ObservedObject var viewModel: SendViewModel + /// Pop back to the address step. + var onBack: () -> Void + /// A valid source is chosen → advance to the amount step. + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + SendStepHeader(onBack: onBack) + .padding(.horizontal, 20) + .padding(.top, 10) + + ScrollView { + VStack(spacing: 14) { + SendAddressSummary(viewModel: viewModel, onEdit: onBack) + .padding(.top, 12) + + sourceCards + + if viewModel.isBlockedBySync { + SyncGateNote() + .padding(.horizontal, 20) + } + } + .padding(.bottom, 8) + } + .scrollBounceBehavior(.basedOnSize) + + DashButton( + text: NSLocalizedString("Continue", comment: ""), + style: .filled, + stretch: true, + isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, + action: onContinue) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .background(Color.primaryBackground) + .navigationBarHidden(true) + } + + // MARK: - From picker + + @ViewBuilder + private var sourceCards: some View { + if let pinned = viewModel.pinnedSource { + // Balance-row send sheet: the source is the tapped balance, + // rendered as a fixed card (no radio, not tappable). + sourceRow(pinned, showsRadio: false) + .padding(.horizontal, 20) + } else { + VStack(spacing: 8) { + ForEach(viewModel.validSources, id: \.self) { network in + sourceRow(network) + } + } + .padding(.horizontal, 20) + } + } + + private func sourceRow(_ network: ChainNetwork, showsRadio: Bool = true) -> some View { + let balance: String + switch network { + case .core: balance = viewModel.coreBalanceFormatted + case .platform: balance = viewModel.platformCreditsFormatted + case .shielded: balance = viewModel.shieldedBalanceFormatted + } + return TransferSourceRow( + iconSystemName: sourceIconName(network), + caption: NSLocalizedString("From", comment: ""), + title: sourceTitle(network), + balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), + selected: viewModel.source == network, + showsRadio: showsRadio, + action: { viewModel.source = network }) + } +} diff --git a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift similarity index 100% rename from DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift rename to DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift deleted file mode 100644 index 8e4dfc2a2..000000000 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ /dev/null @@ -1,1156 +0,0 @@ -// -// SendScreen.swift -// DashWallet -// -// External-send flow, split across two steps. Step one (`SendScreen`) is -// address only: the recipient address (typed / pasted / scanned) decides the -// destination type (Transparent L1 / Platform / Shielded); Continue advances -// to step two. Step two (`ExternalSendAmountScreen`) is the From picker plus -// the amount keypad — Core → Core continues into the classic payment -// processor (real fee math + its own confirm); every other route confirms in -// `SendConfirmSheet` and executes via `ShieldedTransferCoordinator`. -// - -import SwiftUI -import DashUIKit -import SwiftDashSDK -import UIKit - -// MARK: - Step 1: address - -struct SendScreen: View { - @ObservedObject var viewModel: SendViewModel - var onClose: () -> Void - var onScanQR: () -> Void - /// The entered address is valid → advance to the amount step. The host - /// pushes `ExternalSendAmountScreen` onto the same navigation stack. - var onContinue: () -> Void - /// False when embedded under a host that renders its own chrome - /// (the balance-row send sheet) — hides the X + title header. - var showsHeader: Bool = true - - @FocusState private var addressFieldFocused: Bool - @State private var isEditingAddress = false - - /// The address is "locked" (shown as a truncated card) once a valid - /// destination is decoded and focus has left the field; tapping it reopens - /// the editable field. - private var isAddressLocked: Bool { - viewModel.destination != nil && !isEditingAddress && !addressFieldFocused - } - - var body: some View { - VStack(spacing: 0) { - if showsHeader { - header - .padding(.horizontal, 20) - .padding(.top, 10) - } - - ScrollView { - VStack(spacing: 14) { - addressField - .padding(.top, 12) - - if let suggestion = viewModel.clipboardSuggestion, - suggestion.address != viewModel.trimmedAddress { - clipboardChip(for: suggestion) - } - - scanRow - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - .scrollDismissesKeyboard(.interactively) - - DashButton( - text: NSLocalizedString("Continue", comment: ""), - style: .filled, - stretch: true, - isEnabled: viewModel.canAdvanceToAmount, - action: { - addressFieldFocused = false - onContinue() - }) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) - } - - // MARK: - Header - - private var header: some View { - HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(Color.dash.primaryText) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.dash.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } - - // MARK: - Address - - private var addressField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - if isAddressLocked { - lockedAddressCard - } else { - TextField( - NSLocalizedString("Dash address", comment: "Send screen address placeholder"), - text: $viewModel.addressText, - axis: .vertical) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .padding(12) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.asciiCapable) - .lineLimit(2...4) - .focused($addressFieldFocused) - .onAppear { - // Rendered after tapping the locked card → put the - // cursor straight back into the field. - if isEditingAddress { - addressFieldFocused = true - } - } - .onChange(of: addressFieldFocused) { _, focused in - // Focus left the field → re-lock (when valid). - if !focused { - isEditingAddress = false - } - } - .onChange(of: viewModel.destination) { _, destination in - // The address just became valid (a paste, or the - // final typed character) → lock in right away. - // Software-keyboard-less setups (simulator with a - // hardware keyboard) never drop focus on their own, - // so don't wait for that. - if destination != nil { - addressFieldFocused = false - isEditingAddress = false - } - } - } - - if viewModel.showsInvalidAddress { - Text(NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen")) - .font(.caption) - .foregroundColor(.red) - } else if viewModel.pinnedSourceMismatch { - Text(String( - format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), - viewModel.pinnedSourceTitle)) - .font(.caption) - .foregroundColor(.red) - } - } - .padding(.horizontal, 20) - } - - /// The locked-in address: middle-truncated single line + pencil. - /// Tapping reopens the editable field with the cursor in place. - private var lockedAddressCard: some View { - Button(action: { isEditingAddress = true }) { - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Color.dash.secondaryText) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - } - .buttonStyle(.plain) - } - - private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { - HStack(spacing: 4) { - Image(systemName: destinationIconName(destination)) - .font(.system(size: 10, weight: .semibold)) - Text(destinationTitle(destination)) - .font(.caption2.weight(.semibold)) - } - .foregroundColor(.dash.blue) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dash.blue.opacity(0.1)) - .clipShape(Capsule()) - } - - private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } - } - - private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") - case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") - case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") - } - } - - private func clipboardChip(for suggestion: SendViewModel.ClipboardSuggestion) -> some View { - Button(action: { viewModel.useClipboardSuggestion() }) { - HStack(spacing: 10) { - Image(systemName: "doc.on.doc.fill") - .foregroundColor(.blue) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("Send to copied address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Text(truncateMiddle(suggestion.address)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Text(destinationTitle(suggestion.kind)) - .font(.caption2) - .foregroundColor(Color.dash.secondaryText) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dash.gray300.opacity(0.3)) - .cornerRadius(8) - } - .padding(12) - .background(Color.dash.blue.opacity(0.08)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } - - private var scanRow: some View { - Button(action: onScanQR) { - HStack(spacing: 6) { - Image(systemName: "qrcode.viewfinder") - Text(NSLocalizedString("Scan QR", comment: "")) - } - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.dash.blue.opacity(0.12)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } -} - -// MARK: - Step 2: amount - -/// The amount step for the split external-send flow. The address is already -/// chosen (locked, read-only) — this screen carries the From picker, the -/// sync gate, and the amount keypad, plus the balance/affordability -/// validations. Continue routes exactly as the old single-screen form did: -/// Core → Core into the L1 payment processor, everything else into -/// `SendConfirmSheet`. -struct SendSourceScreen: View { - @ObservedObject var viewModel: SendViewModel - /// Pop back to the address step. - var onBack: () -> Void - /// A valid source is chosen → advance to the amount step. - var onContinue: () -> Void - - var body: some View { - VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) - - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) - .padding(.top, 12) - - sourceCards - - if viewModel.isBlockedBySync { - SyncGateNote() - .padding(.horizontal, 20) - } - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - - DashButton( - text: NSLocalizedString("Continue", comment: ""), - style: .filled, - stretch: true, - isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, - action: onContinue) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - .background(Color.primaryBackground) - .navigationBarHidden(true) - } - - // MARK: - From picker - - @ViewBuilder - private var sourceCards: some View { - if let pinned = viewModel.pinnedSource { - // Balance-row send sheet: the source is the tapped balance, - // rendered as a fixed card (no radio, not tappable). - sourceRow(pinned, showsRadio: false) - .padding(.horizontal, 20) - } else { - VStack(spacing: 8) { - ForEach(viewModel.validSources, id: \.self) { network in - sourceRow(network) - } - } - .padding(.horizontal, 20) - } - } - - private func sourceRow(_ network: ChainNetwork, showsRadio: Bool = true) -> some View { - let balance: String - switch network { - case .core: balance = viewModel.coreBalanceFormatted - case .platform: balance = viewModel.platformCreditsFormatted - case .shielded: balance = viewModel.shieldedBalanceFormatted - } - return TransferSourceRow( - iconSystemName: sourceIconName(network), - caption: NSLocalizedString("From", comment: ""), - title: sourceTitle(network), - balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), - selected: viewModel.source == network, - showsRadio: showsRadio, - action: { viewModel.source = network }) - } -} - -// MARK: - Step 3: amount - -/// The final step of the split external-send flow: the address and source are -/// already chosen (both shown read-only), leaving only the amount keypad and -/// the balance/affordability validation. Continue routes exactly as the old -/// single-screen form did — Core → Core into the L1 payment processor, -/// everything else into `SendConfirmSheet`. -struct ExternalSendAmountScreen: View { - @ObservedObject var viewModel: SendViewModel - /// Pop back to the source step. - var onBack: () -> Void - /// Core → Core: hand (address, amount in duffs) to the hosting - /// controller, which routes through the L1 payment processor. - var onContinueCore: (String, UInt64) -> Void - /// A non-core route finished successfully (confirm sheet's Done). - var onSendCompleted: () -> Void - - @State private var showConfirm = false - - var body: some View { - VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) - - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) - .padding(.top, 12) - - fromSummary - - amountRow - .padding(.horizontal, 20) - .padding(.top, 6) - - if let message = viewModel.amountValidationMessage { - TransferAmountValidationNote(message: message) - .padding(.horizontal, 20) - } - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - - keyboardSection - } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) - .sheet(isPresented: $showConfirm) { - if let route = viewModel.route, route != .coreToCore { - SendConfirmSheet( - route: route, - destinationAddress: viewModel.trimmedAddress, - destinationRaw43: shieldedRecipientRaw43, - dashDuffs: viewModel.dashDuffs, - creditsAmount: viewModel.creditsPreview, - fiatText: viewModel.fiatAmountString, - withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, - isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, - isFullShieldedSweep: viewModel.isFullShieldedSweep, - onCancel: { showConfirm = false }, - onCompleted: { - showConfirm = false - onSendCompleted() - }) - .presentationDetents([.large]) - .presentationDragIndicator(.hidden) - } - } - } - - private var shieldedRecipientRaw43: Data? { - if case .shielded(let raw43) = viewModel.destination { return raw43 } - return nil - } - - // MARK: - Amount - - private var amountRow: some View { - EnterAmountView( - primaryAmount: dashAmountText, - secondaryAmount: fiatAmountText, - primaryCurrency: .dash, - secondaryCurrency: .fiat(viewModel.fiatCurrencyCode), - isPrimarySelected: isDashInputSelected, - currencyCodes: amountCurrencyCodes, - selectedCurrencyCode: selectedAmountCurrencyCode, - onMax: { viewModel.fillMaxFromWallet() }, - onSwap: toggleAmountUnit, - onCurrencyTap: toggleAmountUnit, - onSelectInputType: selectAmountCurrency - ) - } - - // MARK: - Keyboard - - private var keyboardSection: some View { - NumericKeyboardView( - value: keypadBinding, - showDecimalSeparator: true, - actionButtonText: NSLocalizedString("Continue", comment: ""), - actionEnabled: viewModel.canContinue, - inProgress: false, - actionHandler: continueAction - ) - .padding(.top, 12) - .padding(.horizontal, 16) - .padding(.bottom, 12) - .background(Color.dash.secondaryBackground) - .clipShape(.rect(cornerRadius: 20)) - .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) - } - - private func continueAction() { - guard let route = viewModel.route else { return } - if route == .coreToCore { - onContinueCore(viewModel.trimmedAddress, viewModel.dashDuffsUnsigned) - } else { - showConfirm = true - } - } - - /// The source picked on the previous step, read-only. Tapping goes back. - private var fromSummary: some View { - Button(action: onBack) { - HStack(spacing: 10) { - Image(systemName: sourceIconName(viewModel.source)) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dashBlue) - .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("From", comment: "")) - .font(.caption) - .foregroundColor(.secondary) - Text(sourceTitle(viewModel.source)) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(.primaryText) - } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.secondaryBackground) - .cornerRadius(10) - } - .buttonStyle(.plain) - .padding(.horizontal, 20) - } - - // MARK: - Amount helpers - - private var isDashInputSelected: Bool { - viewModel.unit == .dash - } - - private var dashAmountText: String { - switch viewModel.unit { - case .dash: - return keypadBinding.wrappedValue - case .fiat: - guard viewModel.parsedDashAmount > 0 else { return "" } - return InternalTransferViewModel.formatTyped( - viewModel.parsedDashAmount, - fractionDigits: 8) - } - } - - private var fiatAmountText: String { - switch viewModel.unit { - case .dash: - guard viewModel.parsedDashAmount > 0, - let fiatAmount = try? CurrencyExchanger.shared.convertDash( - amount: viewModel.parsedDashAmount, - to: viewModel.fiatCurrencyCode) - else { return "" } - return InternalTransferViewModel.formatTyped( - fiatAmount, - fractionDigits: 2) - case .fiat: - return keypadBinding.wrappedValue - } - } - - private var amountCurrencyCodes: [String] { - ["DASH", viewModel.fiatCurrencyCode] - } - - private var selectedAmountCurrencyCode: String { - isDashInputSelected ? "DASH" : viewModel.fiatCurrencyCode - } - - private func toggleAmountUnit() { - viewModel.unit = isDashInputSelected ? .fiat : .dash - } - - private func selectAmountCurrency(_ currencyCode: String) { - viewModel.unit = - currencyCode.caseInsensitiveCompare("DASH") == .orderedSame - ? .dash - : .fiat - } - - private var keypadBinding: Binding { - Binding( - get: { viewModel.amountText == "0" ? "" : viewModel.amountText }, - set: { newValue in - if newValue.isEmpty { - viewModel.amountText = "0" - } else { - viewModel.amountText = newValue - } - }) - } -} - -// MARK: - Shared step chrome - -/// Back-chevron + "Send" title, shared by the source and amount steps. -private struct SendStepHeader: View { - var onBack: () -> Void - - var body: some View { - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } -} - -/// The chosen destination address, read-only. Tapping (`onEdit`) pops back to -/// the address step. Shared by the source and amount steps. -private struct SendAddressSummary: View { - @ObservedObject var viewModel: SendViewModel - var onEdit: () -> Void - - var body: some View { - Button(action: onEdit) { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.secondaryBackground) - .cornerRadius(10) - } - } - .buttonStyle(.plain) - .padding(.horizontal, 20) - } -} - -// MARK: - Shared helpers - -private func sourceIconName(_ network: ChainNetwork) -> String { - switch network { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } -} - -private func sourceTitle(_ network: ChainNetwork) -> String { - switch network { - case .core: return NSLocalizedString("Transparent", comment: "Balance breakdown") - case .platform: return NSLocalizedString("Platform", comment: "Dash Platform chain") - case .shielded: return NSLocalizedString("Shielded", comment: "") - } -} - -private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { - HStack(spacing: 4) { - Image(systemName: destinationIconName(destination)) - .font(.system(size: 10, weight: .semibold)) - Text(destinationTitle(destination)) - .font(.caption2.weight(.semibold)) - } - .foregroundColor(.dashBlue) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dashBlue.opacity(0.1)) - .clipShape(Capsule()) -} - -private func destinationIconName(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return "d.circle.fill" - case .platform: return "creditcard.fill" - case .shielded: return "shield.fill" - } -} - -private func destinationTitle(_ destination: SendViewModel.DestinationKind) -> String { - switch destination { - case .core: return NSLocalizedString("Transparent address", comment: "Send screen destination type") - case .platform: return NSLocalizedString("Platform address", comment: "Send screen destination type") - case .shielded: return NSLocalizedString("Shielded address", comment: "Send screen destination type") - } -} - -/// Middle-truncated address display shared by the screen's clipboard chip -/// and the confirm sheet's To row. -private func truncateMiddle(_ s: String, visible: Int = 8) -> String { - guard s.count > visible * 2 + 3 else { return s } - let head = s.prefix(visible) - let tail = s.suffix(visible) - return "\(head)…\(tail)" -} - -// MARK: - SendConfirmSheet - -/// Confirmation half-sheet for the non-core external send routes. Same -/// skeleton as `InternalTransferConfirmSheet` (summary → progress checklist → -/// success), but the To row is the recipient's address and execution runs the -/// coordinator's external-destination legs. Core → Core never reaches this -/// sheet — it rides the classic payment processor. -struct SendConfirmSheet: View { - - let route: SendViewModel.Route - let destinationAddress: String - /// The recipient's raw 43-byte Orchard payload — required for - /// `.shieldedToShielded`, nil otherwise. - let destinationRaw43: Data? - let dashDuffs: Int64 - let creditsAmount: UInt64 - let fiatText: String - /// Preflighted withdrawal fee — only meaningful for `.platformToCore`. - var withdrawalFeeCredits: UInt64? = nil - var isFullPlatformWithdrawal: Bool = false - var isFullShieldedSweep: Bool = false - var onCancel: () -> Void - var onCompleted: () -> Void - - @StateObject private var coordinator = ShieldedTransferCoordinator() - - var body: some View { - VStack(spacing: 0) { - dragHandle - .padding(.top, 8) - - Text(NSLocalizedString("Confirm", comment: "")) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - .padding(.top, 20) - - switch coordinator.phase { - case .success: - successBody - case .submittedUnconfirmed: - ShieldedSubmittedUnconfirmedView(onDone: onCompleted) - default: - detailsBody - } - } - .background(Color.dash.primaryBackground) - .interactiveDismissDisabled(isInFlight) - } - - private var isInFlight: Bool { - switch coordinator.phase { - case .signing, .locking, .proving, .broadcasting: - return true - default: - return false - } - } - - // MARK: - Idle / in-flight / failed body - - private var detailsBody: some View { - VStack(spacing: 0) { - DashAmount( - amount: dashDuffs, - font: .largeTitle, - dashSymbolFactor: 0.7, - showDirection: false) - .padding(.top, 14) - - Text(fiatText) - .font(.subheadline) - .foregroundColor(.dash.secondaryText) - .padding(.top, 6) - - summaryCard - .padding(.horizontal, 20) - .padding(.top, 20) - - if case let .failed(message) = coordinator.phase { - Text(message) - .font(.system(size: 13)) - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - .padding(.top, 12) - } else { - infoCard - .padding(.horizontal, 20) - .padding(.top, 12) - } - - Spacer(minLength: 12) - - switch coordinator.phase { - case .idle: - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Confirm", comment: ""), - positiveButtonAction: confirm, - negativeButtonText: NSLocalizedString("Cancel", comment: ""), - negativeButtonAction: onCancel) - .padding(.horizontal, 16) - .padding(.bottom, 16) - - case .failed: - ButtonsGroup( - orientation: .horizontal, - size: .large, - positiveButtonText: NSLocalizedString("Try again", comment: ""), - positiveButtonAction: tryAgain, - negativeButtonText: NSLocalizedString("Close", comment: ""), - negativeButtonAction: onCancel) - .padding(.horizontal, 16) - .padding(.bottom, 16) - - case .signing, .locking, .proving, .broadcasting: - ShieldedTransferStepList(currentPhase: coordinator.phase, steps: progressSteps) - .padding(.horizontal, 24) - .padding(.bottom, 24) - - case .success, .submittedUnconfirmed: - EmptyView() - } - } - } - - // MARK: - Success body - - private var successBody: some View { - VStack(spacing: 16) { - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 64, height: 64) - .foregroundColor(.green) - .padding(.top, 24) - - Text(NSLocalizedString("Sent", comment: "Send confirm sheet")) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.dash.primaryText) - - DashAmount( - amount: dashDuffs, - font: .title, - dashSymbolFactor: 0.7, - showDirection: false) - - Text(fiatText) - .font(.subheadline) - .foregroundColor(.dash.secondaryText) - - Spacer(minLength: 12) - - DashButton( - text: NSLocalizedString("Done", comment: ""), - style: .filled, - stretch: true, - action: onCompleted) - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } - - // MARK: - Pieces - - private var dragHandle: some View { - Rectangle() - .fill(Color.dash.grabberFill) - .frame(width: 36, height: 5) - .cornerRadius(2.5) - } - - private var summaryCard: some View { - VStack(spacing: 0) { - summaryRow( - label: NSLocalizedString("From", comment: ""), - value: fromLabel) - divider - HStack { - Text(NSLocalizedString("To", comment: "")) - .font(.system(size: 14)) - .foregroundColor(.dash.secondaryText) - Spacer() - Text(truncateMiddle(destinationAddress)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - divider - summaryRow( - label: NSLocalizedString("Network fee", comment: ""), - value: networkFeeString) - divider - summaryRow( - label: NSLocalizedString("Total", comment: ""), - value: dashDuffs.formattedDashAmount) - } - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - private var fromLabel: String { - switch route { - case .platformToPlatform, .platformToCore: - return NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance") - case .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: - return NSLocalizedString("Shielded balance", comment: "") - case .coreToCore, .coreToShielded: - return NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") - } - } - - private func summaryRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.system(size: 14)) - .foregroundColor(.dash.secondaryText) - Spacer() - Text(value) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.dash.primaryText) - } - .padding(.horizontal, 14) - .padding(.vertical, 14) - } - - private var divider: some View { - Rectangle() - .fill(Color.dash.gray300.opacity(0.3)) - .frame(height: 1) - .padding(.horizontal, 14) - } - - // MARK: - Network fee estimate - - /// Platform credits per DASH (1e11). - private static let creditsPerDash: Decimal = 100_000_000_000 - - /// Flat fee estimate (credits) for the active route — same estimators as - /// the internal transfer's confirm sheet. `nil` → the row shows "—". - private var networkFeeCredits: UInt64? { - switch route { - case .coreToShielded: - return CoreToShieldedAmountPolicy.poolFeeCredits - case .platformToPlatform: - // Credit transfer: the metered transition fee. The executor - // states ~0.001 DASH as the conservative max. - return 100_000_000 - case .platformToCore: - return withdrawalFeeCredits - case .shieldedToCore: - return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 2) - case .shieldedToPlatform: - return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2) - case .shieldedToShielded: - return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) - case .coreToCore: - // Never presented here — the L1 processor shows the real fee. - return nil - } - } - - private var networkFeeString: String { - guard let credits = networkFeeCredits else { return "—" } - let dash = Decimal(credits) / Self.creditsPerDash - return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) - } - - // MARK: - Info card - - private var infoCard: some View { - HStack(alignment: .top, spacing: 12) { - ZStack { - Circle() - .fill(Color.dash.blue) - .frame(width: 30, height: 30) - Image(systemName: infoIcon) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color.dash.whiteText) - } - - VStack(alignment: .leading, spacing: 4) { - Text(infoTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.dash.primaryText) - Text(infoBody) - .font(.system(size: 13)) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - } - .padding(14) - .background(Color.dash.secondaryBackground) - .cornerRadius(12) - } - - private var infoIcon: String { - switch route { - case .platformToCore, .shieldedToCore: return "clock.fill" - default: return "exclamationmark.shield.fill" - } - } - - private var infoTitle: String { - switch route { - case .platformToCore, .shieldedToCore: - return NSLocalizedString("Processing time", comment: "") - default: - return NSLocalizedString("Double-check the address", comment: "Send confirm sheet") - } - } - - private var infoBody: String { - switch route { - case .shieldedToCore: - return NSLocalizedString( - "The Dash arrives at the recipient's address after the network processes the withdrawal — this can take up to 10 minutes.", - comment: "Send confirm sheet") - case .platformToCore: - return isFullPlatformWithdrawal - ? NSLocalizedString( - "This withdraws your entire Platform balance in one transfer. The Dash arrives at the recipient's address once the network processes the withdrawal.", - comment: "Send confirm sheet") - : NSLocalizedString( - "The Dash arrives at the recipient's address once the network processes the withdrawal.", - comment: "Send confirm sheet") - default: - return NSLocalizedString( - "Dash sent to a wrong address can't be recovered. Make sure the address is exactly the one you intend to pay.", - comment: "Send confirm sheet") - } - } - - // MARK: - Progress checklist - - private var progressSteps: [ShieldedTransferStepList.Step] { - var steps: [ShieldedTransferStepList.Step] = [ - .init(label: NSLocalizedString("Authorizing", comment: ""), phase: .signing) - ] - // Only the asset-lock route has the on-chain locking stage. - if route == .coreToShielded { - steps.append(.init(label: NSLocalizedString("Locking funds", comment: ""), phase: .locking)) - } - // Only shielded legs build an Orchard proof. - switch route { - case .coreToShielded, .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: - steps.append(.init(label: NSLocalizedString("Generating proof", comment: ""), phase: .proving)) - case .platformToPlatform, .platformToCore, .coreToCore: - break - } - steps.append(.init(label: NSLocalizedString("Broadcasting", comment: ""), phase: .broadcasting)) - return steps - } - - // MARK: - Actions - - private func confirm() { - Task { - switch route { - case .coreToShielded: - guard let destinationRaw43 else { - coordinator.reset() - return - } - await coordinator.performAssetLock( - amountDuffs: UInt64(dashDuffs), - recipientRaw43: destinationRaw43) - case .platformToPlatform: - await coordinator.performPlatformSend( - destination: destinationAddress, - amountCredits: creditsAmount) - case .platformToCore: - await coordinator.performPlatformWithdraw( - amountCredits: creditsAmount, - fullBalance: isFullPlatformWithdrawal, - feeHeadroomCredits: withdrawalFeeCredits, - toCoreAddress: destinationAddress) - case .shieldedToCore: - await coordinator.performWithdraw( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep, - toCoreAddress: destinationAddress) - case .shieldedToPlatform: - await coordinator.performUnshield( - amountCredits: creditsAmount, - sweepAll: isFullShieldedSweep, - toPlatformAddress: destinationAddress) - case .shieldedToShielded: - guard let destinationRaw43 else { - coordinator.reset() - return - } - await coordinator.performShieldedTransfer( - amountCredits: creditsAmount, - recipientRaw43: destinationRaw43) - case .coreToCore: - // Unreachable: the screen routes Core → Core through the L1 - // payment processor and never presents this sheet. - break - } - } - } - - private func tryAgain() { - // If the just-failed Core → Shielded attempt already committed its - // asset lock, RESUME that exact outpoint with the same recipient - // instead of building a second lock (which strands the first) — - // mirrors the internal transfer's retry. - if route == .coreToShielded, - let op = coordinator.lastAssetLockOutPoint, - let destinationRaw43 { - coordinator.reset() - Task { - await coordinator.resumeAssetLock( - outPointTxidWire: op.txidWire, - outPointVout: op.vout, - recipientRaw43: destinationRaw43) - } - return - } - coordinator.reset() - confirm() - } -} - - -/// Inline explanation for a Continue disabled by the chain-sync gate: -/// Core-funded sends stay off until `SyncingActivityMonitor` reports -/// `.syncDone` (a stale UTXO set can't safely fund a spend). Shared by -/// the Send and Internal transfer screens. -struct SyncGateNote: View { - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 13)) - .foregroundColor(.orange) - Text(NSLocalizedString( - "Your wallet is still syncing. Sending from your Transparent balance will be available once syncing completes.", - comment: "Core send blocked until chain sync completes")) - .font(.caption) - .foregroundColor(.dash.secondaryText) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color.orange.opacity(0.08)) - .cornerRadius(10) - } -} From 0ba58de3f1ba74ba0670b2e05d88f65584a73c2d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:52:20 +0300 Subject: [PATCH 02/19] test(send): previewable view model, and previews for each send step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendViewModel could not be built in a canvas: its initializer registers an observer on SyncingActivityMonitor and subscribes to two balance publishers, and isChainSynced read the monitor from a property initializer, which runs before any flag could stop it. That read moves into init, and an unwired initializer skips the registrations entirely — the screens only render published state, so an unwired model draws them exactly as a live one does. Previews cover what each step actually looks like: an empty address field and a decoded one, the sync gate, the header-less embed, a pinned source, and a shielded source whose route leaves the classic payment processor. Co-Authored-By: Claude Opus 5 --- .../Pay/Send/Components/SyncGateNote.swift | 10 ++++ .../Pay/Send/ExternalSendAmountScreen.swift | 23 +++++++++ .../UI/Payments/Pay/Send/SendScreen.swift | 37 ++++++++++++++ .../Payments/Pay/Send/SendSourceScreen.swift | 24 +++++++++ .../UI/Payments/Pay/Send/SendViewModel.swift | 51 +++++++++++++++++-- 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift index 431bd1a08..03f180985 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SyncGateNote.swift @@ -33,3 +33,13 @@ struct SyncGateNote: View { .cornerRadius(10) } } + +#if DEBUG + +#Preview { + SyncGateNote() + .padding() + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index 7641501e6..1d27b35f7 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -228,3 +228,26 @@ struct ExternalSendAmountScreen: View { }) } } + +#if DEBUG + +#Preview("Amount") { + ExternalSendAmountScreen( + viewModel: .preview(address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core), + onBack: {}, onContinueCore: { _, _ in }, onSendCompleted: {}) + .background(Color.dash.primaryBackground) +} + +/// Shielded source: the route leaves the classic payment processor, so the +/// screen confirms in `SendConfirmSheet` instead of continuing to it. +#Preview("From Shielded") { + ExternalSendAmountScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core, + source: .shielded), + onBack: {}, onContinueCore: { _, _ in }, onSendCompleted: {}) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift index 3638a28ec..bc73883bd 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -264,3 +264,40 @@ struct SendScreen: View { .padding(.horizontal, 20) } } + +#if DEBUG + +/// Seeded model, nothing wired — see `SendViewModel.preview`. The production +/// initializer subscribes to the sync monitor and two balance publishers, all +/// live singletons a canvas must not start. +#Preview("Empty") { + SendScreen(viewModel: .preview(), onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// A decoded address collapses the field into the summary card. +#Preview("Transparent address") { + SendScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core), + onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +#Preview("Still syncing") { + SendScreen( + viewModel: .preview(isChainSynced: false), + onClose: {}, onScanQR: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// Embedded under a host that draws its own chrome — the balance-row sheet. +#Preview("No header") { + SendScreen( + viewModel: .preview(), onClose: {}, onScanQR: {}, onContinue: {}, + showsHeader: false) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift index cda3b6b29..68434d97b 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -96,3 +96,27 @@ struct SendSourceScreen: View { action: { viewModel.source = network }) } } + +#if DEBUG + +#Preview("Pick a source") { + SendSourceScreen( + viewModel: .preview(address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core), + onBack: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +/// Pinned by the caller (the balance row's own send button) — the picker +/// shows the choice already made rather than re-offering it. +#Preview("Pinned to Shielded") { + SendSourceScreen( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core, + source: .shielded, + pinnedSource: .shielded), + onBack: {}, onContinue: {}) + .background(Color.dash.primaryBackground) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index d4178fc83..e60833b58 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -90,7 +90,10 @@ final class SendViewModel: ObservableObject { /// `.syncDone`). Core-funded routes can't Continue before that — the /// UTXO set may be stale (see `WalletSendService.ensureChainSynced`, /// the boundary backstop behind this UI gate). - @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone + /// Seeded in `init` rather than here: a property initialiser runs whatever + /// the `wired` flag says, and this one would wake the monitor from a + /// preview canvas. + @Published private(set) var isChainSynced = false private var cancellables = Set() @@ -100,17 +103,32 @@ final class SendViewModel: ObservableObject { /// rather than silently re-picking the source. let pinnedSource: ChainNetwork? + /// False for the preview seam below, which never registered anywhere. + private let isWired: Bool + deinit { // The monitor holds observers strongly — without this the VM (and - // its Combine pipelines) outlive the screen. - SyncingActivityMonitor.shared.remove(observer: self) + // its Combine pipelines) outlive the screen. Skipped when the model + // was never wired: removing an observer that never registered would + // reach the live singleton from a canvas. + if isWired { + SyncingActivityMonitor.shared.remove(observer: self) + } } - init(pinnedSource: ChainNetwork? = nil) { + /// `wired: false` builds an inert model for previews: no observer on + /// `SyncingActivityMonitor`, no balance subscriptions, no clipboard read. + /// Every one of those reaches a live singleton that does real work, which + /// a canvas must not start — and the screens only read published state, + /// so an unwired model renders them exactly as a wired one would. + init(pinnedSource: ChainNetwork? = nil, wired: Bool = true) { self.pinnedSource = pinnedSource + self.isWired = wired if let pinnedSource { source = pinnedSource } + guard wired else { return } + isChainSynced = SyncingActivityMonitor.shared.state == .syncDone refreshClipboardSuggestion() SyncingActivityMonitor.shared.add(observer: self) @@ -737,3 +755,28 @@ extension SendViewModel: SyncingActivityMonitorObserver { } } } + +#if DEBUG + +extension SendViewModel { + /// Preview seed. Nothing is wired, so no singleton is touched; the state + /// the screens render is set directly. + static func preview( + address: String = "", + destination: DestinationKind? = nil, + source: ChainNetwork = .core, + pinnedSource: ChainNetwork? = nil, + coreBalanceDuffs: UInt64 = 96_999_737, + isChainSynced: Bool = true + ) -> SendViewModel { + let model = SendViewModel(pinnedSource: pinnedSource, wired: false) + model.addressText = address + model.destination = destination + model.source = source + model.coreBalanceDuffs = coreBalanceDuffs + model.isChainSynced = isChainSynced + return model + } +} + +#endif From 9aa1270ae52c7814da31fc6588ded97d0edab67c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:55:23 +0300 Subject: [PATCH 03/19] test(send): previews for the shared send components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step header, the address summary in each of its three destination forms, and the chain-styling helpers rendered side by side — that last one exists so the source and destination label sets cannot drift apart unnoticed, which is only visible when they are shown together. Co-Authored-By: Claude Opus 5 --- .../Pay/Send/Components/SendChainStyle.swift | 39 +++++++++++++++++++ .../Pay/Send/Components/SendStepChrome.swift | 37 ++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift index 77ab781f0..15f71d3db 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendChainStyle.swift @@ -66,3 +66,42 @@ func truncateMiddle(_ s: String, visible: Int = 8) -> String { let tail = s.suffix(visible) return "\(head)…\(tail)" } + +#if DEBUG + +/// Every chain and destination the send flow can label, side by side — this +/// file exists so the two lists cannot drift apart. +#Preview("Chain and destination styling") { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Sources").font(.caption).foregroundColor(.dash.tertiaryText) + ForEach([ChainNetwork.core, .platform, .shielded], id: \.self) { network in + HStack(spacing: 8) { + Image(systemName: sourceIconName(network)) + .foregroundColor(.dashBlue) + Text(sourceTitle(network)) + } + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Destinations").font(.caption).foregroundColor(.dash.tertiaryText) + destinationBadge(.core) + destinationBadge(.platform) + destinationBadge(.shielded(raw43: Data(repeating: 0x2a, count: 43))) + } + + Divider() + + Text(truncateMiddle("yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY")) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.dash.secondaryText) + } + .padding() + .background(Color.dash.primaryBackground) +} + +#endif + diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift index 3553f4443..5bc75c235 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift @@ -73,3 +73,40 @@ struct SendAddressSummary: View { .padding(.horizontal, 20) } } + +#if DEBUG + +#Preview("Step header") { + SendStepHeader(onBack: {}) + .padding(.vertical) + .background(Color.dash.primaryBackground) +} + +/// The card the address field collapses into once a destination decodes — +/// one preview per destination kind, since the badge is what differs. +#Preview("Address summary") { + VStack(spacing: 12) { + SendAddressSummary( + viewModel: .preview( + address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", + destination: .core), + onEdit: {}) + + SendAddressSummary( + viewModel: .preview( + address: "dash:8xKq2mVn4pLrTyWvBcDfGhJkMnPqRsTuVwXyZ", + destination: .platform), + onEdit: {}) + + SendAddressSummary( + viewModel: .preview( + address: "dashs1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + destination: .shielded(raw43: Data(repeating: 0x2a, count: 43))), + onEdit: {}) + } + .padding() + .background(Color.dash.primaryBackground) +} + +#endif + From 6e5b270a96f46b482c0e07aed66d21a27b036626 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:00:08 +0300 Subject: [PATCH 04/19] refactor(send): use DashUIKit's NavigationBar for the send steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendStepHeader drew its own back circle and centred title with hardcoded colours and metrics — a second implementation of the bar the rest of the redesign already uses. The source and amount steps now take DashUIKit.NavigationBar with the standard back element, which carries its own height and insets, so the manual padding goes with it. The file that held it kept only SendAddressSummary, so it is named after that. Co-Authored-By: Claude Opus 5 --- DashWallet.xcodeproj/project.pbxproj | 12 +++---- ...pChrome.swift => SendAddressSummary.swift} | 32 +------------------ .../Pay/Send/ExternalSendAmountScreen.swift | 13 ++++++-- .../Payments/Pay/Send/SendSourceScreen.swift | 13 ++++++-- 4 files changed, 27 insertions(+), 43 deletions(-) rename DashWallet/Sources/UI/Payments/Pay/Send/Components/{SendStepChrome.swift => SendAddressSummary.swift} (70%) diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 51f7e03f9..8f22d3b72 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -103,7 +103,7 @@ 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; - 5ASB04100000000000000000 /* SendStepChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendStepChrome.swift */; }; + 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; @@ -1016,7 +1016,7 @@ A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; - 5ASB04200000000000000000 /* SendStepChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendStepChrome.swift */; }; + 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; @@ -3109,7 +3109,7 @@ 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendViewModel.swift; sourceTree = ""; }; 5ASF06100000000000000000 /* SyncGateNote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncGateNote.swift; sourceTree = ""; }; 5ASF05100000000000000000 /* SendChainStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendChainStyle.swift; sourceTree = ""; }; - 5ASF04100000000000000000 /* SendStepChrome.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendStepChrome.swift; sourceTree = ""; }; + 5ASF04100000000000000000 /* SendAddressSummary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendAddressSummary.swift; sourceTree = ""; }; 5ASF03100000000000000000 /* SendConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendConfirmSheet.swift; sourceTree = ""; }; 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalSendAmountScreen.swift; sourceTree = ""; }; 5ASF01100000000000000000 /* SendSourceScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendSourceScreen.swift; sourceTree = ""; }; @@ -4989,7 +4989,7 @@ 5ASGRPC10000000000000000 /* Components */ = { isa = PBXGroup; children = ( - 5ASF04100000000000000000 /* SendStepChrome.swift */, + 5ASF04100000000000000000 /* SendAddressSummary.swift */, 5ASF05100000000000000000 /* SendChainStyle.swift */, 5ASF06100000000000000000 /* SyncGateNote.swift */, ); @@ -10166,7 +10166,7 @@ 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */, 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */, 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */, - 5ASB04100000000000000000 /* SendStepChrome.swift in Sources */, + 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */, 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */, 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */, 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */, @@ -11197,7 +11197,7 @@ A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */, 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */, 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */, - 5ASB04200000000000000000 /* SendStepChrome.swift in Sources */, + 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */, 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */, 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */, 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */, diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift similarity index 70% rename from DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift rename to DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift index 5bc75c235..b8b7ce757 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendStepChrome.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift @@ -2,7 +2,7 @@ // SendStepChrome.swift // DashWallet // -// Header and address summary shared by the send steps. +// The card the address field collapses into once a destination decodes. // import SwiftUI @@ -13,30 +13,6 @@ import UIKit // MARK: - Shared step chrome /// Back-chevron + "Send" title, shared by the source and amount steps. -struct SendStepHeader: View { - var onBack: () -> Void - - var body: some View { - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } -} - -/// The chosen destination address, read-only. Tapping (`onEdit`) pops back to -/// the address step. Shared by the source and amount steps. struct SendAddressSummary: View { @ObservedObject var viewModel: SendViewModel var onEdit: () -> Void @@ -76,12 +52,6 @@ struct SendAddressSummary: View { #if DEBUG -#Preview("Step header") { - SendStepHeader(onBack: {}) - .padding(.vertical) - .background(Color.dash.primaryBackground) -} - /// The card the address field collapses into once a destination decodes — /// one preview per destination kind, since the badge is what differs. #Preview("Address summary") { diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index 1d27b35f7..45d56374f 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -31,9 +31,16 @@ struct ExternalSendAmountScreen: View { var body: some View { VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) + // DashUIKit's bar carries its own height and horizontal + // insets, so the manual padding the hand-rolled header needed + // goes with it. + DashUIKit.NavigationBar(leading: { + NavigationBarElement.back.button { onBack() } + }, central: { + Text(NSLocalizedString("Send", comment: "")) + .dashFont(.subheadMedium) + .foregroundColor(.dash.primaryText) + }) ScrollView { VStack(spacing: 14) { diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift index 68434d97b..a5e93cf5b 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -27,9 +27,16 @@ struct SendSourceScreen: View { var body: some View { VStack(spacing: 0) { - SendStepHeader(onBack: onBack) - .padding(.horizontal, 20) - .padding(.top, 10) + // DashUIKit's bar carries its own height and horizontal + // insets, so the manual padding the hand-rolled header needed + // goes with it. + DashUIKit.NavigationBar(leading: { + NavigationBarElement.back.button { onBack() } + }, central: { + Text(NSLocalizedString("Send", comment: "")) + .dashFont(.subheadMedium) + .foregroundColor(.dash.primaryText) + }) ScrollView { VStack(spacing: 14) { From 578df39f44f6726bbcd09e5630aea57761811a04 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:36:14 +0300 Subject: [PATCH 05/19] refactor(send): use DashUIKit's AddressFieldView for the address entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled field carried its own label row, TextField styling, error copy and a separate full-width "Scan QR" button. AddressFieldView already has all of it, including the scan control inside the field — so the button below goes, and the two mutually exclusive error messages collapse into the one error slot the component offers. The badge naming the decoded destination moves into the component's new label accessory, so the label is drawn once instead of the screen printing its own row above the field. Two things stay here. The collapsed card a decoded address folds into has no counterpart in the component, so the field keeps both branches. And the clipboard chip stays instead of the component's Paste button: it shows which address it would paste and what kind it is, and only appears when the clipboard actually holds a valid one. Co-Authored-By: Claude Opus 5 --- .../UI/Payments/Pay/Send/SendScreen.swift | 190 +++++++++--------- 1 file changed, 91 insertions(+), 99 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift index bc73883bd..5dbc65bad 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -35,11 +35,11 @@ struct SendScreen: View { var body: some View { VStack(spacing: 0) { - if showsHeader { - header - .padding(.horizontal, 20) - .padding(.top, 10) - } +// if showsHeader { +// header +// .padding(.horizontal, 20) +// .padding(.top, 10) +// } ScrollView { VStack(spacing: 14) { @@ -51,7 +51,6 @@ struct SendScreen: View { clipboardChip(for: suggestion) } - scanRow } .padding(.bottom, 8) } @@ -74,95 +73,103 @@ struct SendScreen: View { .navigationBarHidden(true) } - // MARK: - Header - - private var header: some View { - HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(Color.dash.primaryText) - .frame(width: 36, height: 36) - .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) - } - Spacer() - Text(NSLocalizedString("Send", comment: "")) - .font(.headline) - .foregroundColor(.dash.primaryText) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - } +// // MARK: - Header +// +// private var header: some View { +// HStack { +// Button(action: onClose) { +// Image(systemName: "xmark") +// .font(.system(size: 16, weight: .medium)) +// .foregroundColor(Color.dash.primaryText) +// .frame(width: 36, height: 36) +// .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) +// } +// Spacer() +// Text(NSLocalizedString("Send", comment: "")) +// .font(.headline) +// .foregroundColor(.dash.primaryText) +// Spacer() +// Color.clear.frame(width: 36, height: 36) +// } +// } // MARK: - Address + @ViewBuilder private var addressField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Spacer() + if isAddressLocked { + // Collapsed: a decoded address takes one line and gives the rest + // of the screen back. `AddressFieldView` has no such state, so + // the card stays here, with the label row it needs. + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(NSLocalizedString("Address", comment: "")) + .dashFont(.footnote) + .foregroundStyle(Color.dash.gray500) + Spacer() + if let destination = viewModel.destination { + destinationBadge(destination) + } + } + lockedAddressCard + } + .padding(.horizontal, 20) + } else { + DashUIKit.AddressFieldView( + text: $viewModel.addressText, + label: NSLocalizedString("Address", comment: ""), + placeholder: NSLocalizedString("Dash address", comment: "Send screen address placeholder"), + hasError: addressErrorText != nil, + errorText: addressErrorText, + onScanQR: onScanQR, + // No `onPaste`: the clipboard chip below already offers this, + // and it shows WHICH address it would paste and what kind it + // is — a bare Paste button would be the same action twice, + // told less well. + ) { if let destination = viewModel.destination { destinationBadge(destination) } } - if isAddressLocked { - lockedAddressCard - } else { - TextField( - NSLocalizedString("Dash address", comment: "Send screen address placeholder"), - text: $viewModel.addressText, - axis: .vertical) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .padding(12) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.asciiCapable) - .lineLimit(2...4) - .focused($addressFieldFocused) - .onAppear { - // Rendered after tapping the locked card → put the - // cursor straight back into the field. - if isEditingAddress { - addressFieldFocused = true - } - } - .onChange(of: addressFieldFocused) { _, focused in - // Focus left the field → re-lock (when valid). - if !focused { - isEditingAddress = false - } - } - .onChange(of: viewModel.destination) { _, destination in - // The address just became valid (a paste, or the - // final typed character) → lock in right away. - // Software-keyboard-less setups (simulator with a - // hardware keyboard) never drop focus on their own, - // so don't wait for that. - if destination != nil { - addressFieldFocused = false - isEditingAddress = false - } - } + .focused($addressFieldFocused) + .padding(.horizontal, 20) + .onAppear { + // Rendered after tapping the locked card → put the cursor + // straight back into the field. + if isEditingAddress { + addressFieldFocused = true + } } - - if viewModel.showsInvalidAddress { - Text(NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen")) - .font(.caption) - .foregroundColor(.red) - } else if viewModel.pinnedSourceMismatch { - Text(String( - format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), - viewModel.pinnedSourceTitle)) - .font(.caption) - .foregroundColor(.red) + .onChange(of: addressFieldFocused) { _, focused in + // Focus left the field → re-lock (when valid). + if !focused { + isEditingAddress = false + } + } + .onChange(of: viewModel.destination) { _, destination in + // The address just became valid (a paste, or the final typed + // character) → lock in right away. Software-keyboard-less + // setups (simulator with a hardware keyboard) never drop + // focus on their own, so don't wait for that. + if destination != nil { + addressFieldFocused = false + isEditingAddress = false + } } } - .padding(.horizontal, 20) + } + + /// Whichever refusal applies, in the one slot the field has for it. + private var addressErrorText: String? { + if viewModel.showsInvalidAddress { + return NSLocalizedString("This is not a valid Dash address for this network", comment: "Send screen") + } + if viewModel.pinnedSourceMismatch { + return String( + format: NSLocalizedString("This address can't be paid from your %@ balance", comment: "Send sheet source/destination mismatch"), + viewModel.pinnedSourceTitle) + } + return nil } /// The locked-in address: middle-truncated single line + pencil. @@ -248,21 +255,6 @@ struct SendScreen: View { .padding(.horizontal, 20) } - private var scanRow: some View { - Button(action: onScanQR) { - HStack(spacing: 6) { - Image(systemName: "qrcode.viewfinder") - Text(NSLocalizedString("Scan QR", comment: "")) - } - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.dash.blue.opacity(0.12)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } } #if DEBUG From 97a59472df8ef8a74410ff8594670512c110531a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:40:03 +0300 Subject: [PATCH 06/19] refactor(send): drop the collapsed address card The field folded into a truncated one-line card once an address decoded, and tapping it reopened the editor. That was the reason the screen kept two branches, a locked flag and an editing flag, and three onChange handlers to move between them. The address now stays in AddressFieldView, always. One behaviour survives: focus still drops when the address becomes valid, so Continue is reachable without a second tap. It no longer has anything to do with locking, and the comment says so. Co-Authored-By: Claude Opus 5 --- .../UI/Payments/Pay/Send/SendScreen.swift | 113 ++++-------------- 1 file changed, 25 insertions(+), 88 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift index 5dbc65bad..e1c9559b8 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -24,14 +24,6 @@ struct SendScreen: View { var showsHeader: Bool = true @FocusState private var addressFieldFocused: Bool - @State private var isEditingAddress = false - - /// The address is "locked" (shown as a truncated card) once a valid - /// destination is decoded and focus has left the field; tapping it reopens - /// the editable field. - private var isAddressLocked: Bool { - viewModel.destination != nil && !isEditingAddress && !addressFieldFocused - } var body: some View { VStack(spacing: 0) { @@ -95,66 +87,33 @@ struct SendScreen: View { // MARK: - Address - @ViewBuilder private var addressField: some View { - if isAddressLocked { - // Collapsed: a decoded address takes one line and gives the rest - // of the screen back. `AddressFieldView` has no such state, so - // the card stays here, with the label row it needs. - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .dashFont(.footnote) - .foregroundStyle(Color.dash.gray500) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - lockedAddressCard - } - .padding(.horizontal, 20) - } else { - DashUIKit.AddressFieldView( - text: $viewModel.addressText, - label: NSLocalizedString("Address", comment: ""), - placeholder: NSLocalizedString("Dash address", comment: "Send screen address placeholder"), - hasError: addressErrorText != nil, - errorText: addressErrorText, - onScanQR: onScanQR, - // No `onPaste`: the clipboard chip below already offers this, - // and it shows WHICH address it would paste and what kind it - // is — a bare Paste button would be the same action twice, - // told less well. - ) { - if let destination = viewModel.destination { - destinationBadge(destination) - } + DashUIKit.AddressFieldView( + text: $viewModel.addressText, + label: NSLocalizedString("Address", comment: ""), + placeholder: NSLocalizedString("Dash address", comment: "Send screen address placeholder"), + hasError: addressErrorText != nil, + errorText: addressErrorText, + onScanQR: onScanQR, + // No `onPaste`: the clipboard chip below already offers this, and + // it shows WHICH address it would paste and what kind it is — a + // bare Paste button would be the same action twice, told less + // well. + ) { + if let destination = viewModel.destination { + destinationBadge(destination) } - .focused($addressFieldFocused) - .padding(.horizontal, 20) - .onAppear { - // Rendered after tapping the locked card → put the cursor - // straight back into the field. - if isEditingAddress { - addressFieldFocused = true - } - } - .onChange(of: addressFieldFocused) { _, focused in - // Focus left the field → re-lock (when valid). - if !focused { - isEditingAddress = false - } - } - .onChange(of: viewModel.destination) { _, destination in - // The address just became valid (a paste, or the final typed - // character) → lock in right away. Software-keyboard-less - // setups (simulator with a hardware keyboard) never drop - // focus on their own, so don't wait for that. - if destination != nil { - addressFieldFocused = false - isEditingAddress = false - } + } + .focused($addressFieldFocused) + .padding(.horizontal, 20) + .onChange(of: viewModel.destination) { _, destination in + // The address just became valid (a paste, or the final typed + // character) → drop the keyboard so Continue is reachable without + // a second tap. Software-keyboard-less setups (a simulator with a + // hardware keyboard) never lose focus on their own, so don't wait + // for that. + if destination != nil { + addressFieldFocused = false } } } @@ -172,28 +131,6 @@ struct SendScreen: View { return nil } - /// The locked-in address: middle-truncated single line + pencil. - /// Tapping reopens the editable field with the cursor in place. - private var lockedAddressCard: some View { - Button(action: { isEditingAddress = true }) { - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Color.dash.secondaryText) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - } - .buttonStyle(.plain) - } - private func destinationBadge(_ destination: SendViewModel.DestinationKind) -> some View { HStack(spacing: 4) { Image(systemName: destinationIconName(destination)) From d35e88f8d351ad6b819949fd92f2dc9a449a23f3 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:47:46 +0300 Subject: [PATCH 07/19] refactor(send): paste from the field, not a card below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clipboard suggestion card is replaced by AddressFieldView's own Paste button, which the component shows only while the field is empty. Paste now takes whatever is on the clipboard rather than only a pre-detected address: the previous action came from the suggestion, so with unparseable content the button would have sat there doing nothing. The field's validation already explains what is wrong with what landed in it. The detection itself stays — the "Send to Address" shortcut still opens the screen prefilled when the clipboard holds a valid address. Co-Authored-By: Claude Opus 5 --- .../UI/Payments/Pay/Send/SendScreen.swift | 43 ++----------------- .../UI/Payments/Pay/Send/SendViewModel.swift | 11 +++++ 2 files changed, 14 insertions(+), 40 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift index e1c9559b8..2fed39453 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -38,11 +38,6 @@ struct SendScreen: View { addressField .padding(.top, 12) - if let suggestion = viewModel.clipboardSuggestion, - suggestion.address != viewModel.trimmedAddress { - clipboardChip(for: suggestion) - } - } .padding(.bottom, 8) } @@ -95,10 +90,9 @@ struct SendScreen: View { hasError: addressErrorText != nil, errorText: addressErrorText, onScanQR: onScanQR, - // No `onPaste`: the clipboard chip below already offers this, and - // it shows WHICH address it would paste and what kind it is — a - // bare Paste button would be the same action twice, told less - // well. + // Shown by the component only while the field is empty, which is + // exactly when it is useful. + onPaste: { viewModel.pasteFromClipboard() } ) { if let destination = viewModel.destination { destinationBadge(destination) @@ -161,37 +155,6 @@ struct SendScreen: View { } } - private func clipboardChip(for suggestion: SendViewModel.ClipboardSuggestion) -> some View { - Button(action: { viewModel.useClipboardSuggestion() }) { - HStack(spacing: 10) { - Image(systemName: "doc.on.doc.fill") - .foregroundColor(.blue) - VStack(alignment: .leading, spacing: 2) { - Text(NSLocalizedString("Send to copied address", comment: "")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) - Text(truncateMiddle(suggestion.address)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.dash.primaryText) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Text(destinationTitle(suggestion.kind)) - .font(.caption2) - .foregroundColor(Color.dash.secondaryText) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color.dash.gray300.opacity(0.3)) - .cornerRadius(8) - } - .padding(12) - .background(Color.dash.blue.opacity(0.08)) - .cornerRadius(10) - } - .padding(.horizontal, 20) - } - } #if DEBUG diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index e60833b58..a861636e7 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -301,11 +301,22 @@ final class SendViewModel: ObservableObject { clipboardSuggestion = Self.detect(in: raw) } + /// Apply the DETECTED address — used by the "Send to Address" shortcut, + /// which only opens the screen prefilled when the clipboard holds one. func useClipboardSuggestion() { guard let suggestion = clipboardSuggestion else { return } addressText = suggestion.address } + /// Paste whatever is on the clipboard, valid or not. The field's own + /// validation says what is wrong with it — a Paste button that silently + /// does nothing because the content didn't parse is worse than one that + /// pastes and lets the error explain. + func pasteFromClipboard() { + guard let raw = UIPasteboard.general.string else { return } + addressText = raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func detect(in raw: String) -> ClipboardSuggestion? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } From 6990ed3272cdef40a4a3b41d387a00c48b6ee0ca Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:57:24 +0300 Subject: [PATCH 08/19] refactor(send): read the destination as a heading, not a field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source and amount steps opened with a boxed Address control carrying a pencil, but by then the address is settled and neither step can change it — tapping only went back, which the navigation bar's back button already does. It read as a form where it was context, and cost the top of the screen. It is now a single line, the way the gift-card screens head themselves with the merchant: "to
" with the badge naming what kind of address it turned out to be. Truncation stays in the middle — the tail is what tells two addresses of the same form apart. Co-Authored-By: Claude Opus 5 --- .../Send/Components/SendAddressSummary.swift | 93 ++++++++----------- .../Pay/Send/ExternalSendAmountScreen.swift | 2 +- .../Payments/Pay/Send/SendSourceScreen.swift | 2 +- 3 files changed, 40 insertions(+), 57 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift index b8b7ce757..f40f89dd4 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift @@ -1,82 +1,65 @@ // -// SendStepChrome.swift +// SendAddressSummary.swift // DashWallet // -// The card the address field collapses into once a destination decodes. +// Where the money is going, as read-only context on the steps after the +// address has been decided. // import SwiftUI import DashUIKit -import SwiftDashSDK -import UIKit -// MARK: - Shared step chrome - -/// Back-chevron + "Send" title, shared by the source and amount steps. +/// Destination context for the source and amount steps. +/// +/// Not a field: by this point the address is settled, and the boxed control +/// with a pencil this replaced offered an edit the step could not make — +/// tapping it only went back, which the navigation bar's own back button +/// already does. So it reads as a heading, the way the merchant header does +/// on the gift-card screens. struct SendAddressSummary: View { @ObservedObject var viewModel: SendViewModel - var onEdit: () -> Void var body: some View { - Button(action: onEdit) { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(NSLocalizedString("Address", comment: "")) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - if let destination = viewModel.destination { - destinationBadge(destination) - } - } - HStack(spacing: 8) { - Text(truncateMiddle(viewModel.trimmedAddress, visible: 10)) - .font(.system(.footnote, design: .monospaced)) - .foregroundColor(.primaryText) - .lineLimit(1) - Spacer() - Image(systemName: "pencil") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity) - .background(Color.secondaryBackground) - .cornerRadius(10) + HStack(alignment: .firstTextBaseline, spacing: 8) { + // Middle-truncated, not tail: the last characters are what + // distinguishes two addresses of the same form, so a plain + // ellipsis at the end hides exactly what is worth checking. + Text(String( + format: NSLocalizedString("to %@", comment: "Send screen — the destination address"), + truncateMiddle(viewModel.trimmedAddress, visible: 10))) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(Color.dash.primaryText) + .lineLimit(1) + + Spacer(minLength: 8) + + if let destination = viewModel.destination { + destinationBadge(destination) } } - .buttonStyle(.plain) .padding(.horizontal, 20) } } #if DEBUG -/// The card the address field collapses into once a destination decodes — -/// one preview per destination kind, since the badge is what differs. -#Preview("Address summary") { - VStack(spacing: 12) { - SendAddressSummary( - viewModel: .preview( - address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", - destination: .core), - onEdit: {}) +/// One per destination kind — the badge is what differs. +#Preview("Destination context") { + VStack(alignment: .leading, spacing: 16) { + SendAddressSummary(viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core)) - SendAddressSummary( - viewModel: .preview( - address: "dash:8xKq2mVn4pLrTyWvBcDfGhJkMnPqRsTuVwXyZ", - destination: .platform), - onEdit: {}) + SendAddressSummary(viewModel: .preview( + address: "8xKq2mVn4pLrTyWvBcDfGhJkMnPqRsTuVwXyZaBcDeFg", + destination: .platform)) - SendAddressSummary( - viewModel: .preview( - address: "dashs1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", - destination: .shielded(raw43: Data(repeating: 0x2a, count: 43))), - onEdit: {}) + SendAddressSummary(viewModel: .preview( + address: "dashs1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + destination: .shielded(raw43: Data(repeating: 0x2a, count: 43)))) } - .padding() + .padding(.vertical) .background(Color.dash.primaryBackground) } #endif - diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index 45d56374f..874b6bb2d 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -44,7 +44,7 @@ struct ExternalSendAmountScreen: View { ScrollView { VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) + SendAddressSummary(viewModel: viewModel) .padding(.top, 12) fromSummary diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift index a5e93cf5b..2fd82f44f 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -40,7 +40,7 @@ struct SendSourceScreen: View { ScrollView { VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel, onEdit: onBack) + SendAddressSummary(viewModel: viewModel) .padding(.top, 12) sourceCards From 662a26819fa1478797fc2095590af9932e7a7a21 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:03:22 +0300 Subject: [PATCH 09/19] feat(send): say which balance the amount step is spending from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amount step showed where the money was going but not where it was coming out of — the source was chosen a screen earlier and then never mentioned again, so the amount had nothing to be measured against without going back. SendAddressSummary gains an optional source line: which balance, and how much is in it. Optional because the source step below it already says both while the choice is still open; only the amount step, where it is settled, turns it on. The number honours DWGlobalOptions.balanceHidden — the same flag the home balance reads — so hiding in one place hides in both, and the eye here toggles that one flag rather than a second local notion of hidden. Co-Authored-By: Claude Opus 5 --- .../Send/Components/SendAddressSummary.swift | 115 +++++++++++++++--- .../Pay/Send/ExternalSendAmountScreen.swift | 29 ++--- .../Payments/Pay/Send/SendSourceScreen.swift | 59 ++++----- .../UI/Payments/Pay/Send/SendViewModel.swift | 7 ++ 4 files changed, 141 insertions(+), 69 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift index f40f89dd4..c546e78e2 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift @@ -18,47 +18,124 @@ import DashUIKit /// on the gift-card screens. struct SendAddressSummary: View { @ObservedObject var viewModel: SendViewModel + /// Show which balance funds the send, and how much is in it. Off on the + /// source step, where the cards below already say both and the choice is + /// still open; on for the amount step, where the source is settled and + /// the number is what the amount is being measured against. + var showsSource: Bool = false + + /// Mirrors the app-wide flag the home balance uses, so hiding in one + /// place hides everywhere. `@State` seeded from it, since a plain read + /// would not re-render on toggle. + @State private var isBalanceHidden = DWGlobalOptions.sharedInstance().balanceHidden var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - // Middle-truncated, not tail: the last characters are what - // distinguishes two addresses of the same form, so a plain - // ellipsis at the end hides exactly what is worth checking. - Text(String( - format: NSLocalizedString("to %@", comment: "Send screen — the destination address"), - truncateMiddle(viewModel.trimmedAddress, visible: 10))) - .font(.system(.subheadline, design: .monospaced)) + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("Send", comment: "Send screen")) + .dashFont(.title1) .foregroundStyle(Color.dash.primaryText) - .lineLimit(1) - Spacer(minLength: 8) + Text(String(format: NSLocalizedString("to %@", comment: "Send screen"), viewModel.trimmedAddress)) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + .lineLimit(1) + .truncationMode(.middle) if let destination = viewModel.destination { - destinationBadge(destination) + Text(String(format: NSLocalizedString("to %@", comment: "Send screen"), destinationTitle(destination))) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + } + + if showsSource { + Text(String( + format: NSLocalizedString("from %@", comment: "Send screen — the balance being spent"), + sourceTitle(viewModel.source))) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) + + sourceBalanceLine + } + } + } + + private var sourceBalanceLine: some View { + HStack(spacing: 6) { + Text(NSLocalizedString("Balance:", comment: "Send screen")) + .dashFont(.footnote) + .foregroundStyle(Color.dash.secondaryText) + + if isBalanceHidden { + // Same masking the home balance uses — the width is fixed, so + // the row doesn't change size when it is revealed. + Text("••••••••") + .dashFont(.footnote) + .foregroundStyle(Color.dash.secondaryText) + } else { + // Qualified: the app has a `DashAmount` of its own with a + // different initializer, and an unqualified name picks it. + DashUIKit.DashAmount( + amount: Int64(clamping: viewModel.selectedSourceBalanceDuffs), + fontSize: 13, + sign: DashAmountSign.none) + .foregroundStyle(Color.dash.secondaryText) + } + + Button { + isBalanceHidden.toggle() + DWGlobalOptions.sharedInstance().balanceHidden = isBalanceHidden + } label: { + Image(systemName: isBalanceHidden ? "eye.slash.fill" : "eye.fill") + .font(.system(size: 12)) + .foregroundStyle(Color.dash.secondaryText) + .frame(width: 28, height: 28) + .contentShape(.rect) } + .buttonStyle(.plain) + .accessibilityLabel(isBalanceHidden + ? NSLocalizedString("Show balance", comment: "Send screen") + : NSLocalizedString("Hide balance", comment: "Send screen")) } - .padding(.horizontal, 20) } } #if DEBUG -/// One per destination kind — the badge is what differs. -#Preview("Destination context") { +/// The source step: destination only — the From cards below carry the choice. +#Preview("Destination only") { VStack(alignment: .leading, spacing: 16) { SendAddressSummary(viewModel: .preview( address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", destination: .core)) - SendAddressSummary(viewModel: .preview( - address: "8xKq2mVn4pLrTyWvBcDfGhJkMnPqRsTuVwXyZaBcDeFg", - destination: .platform)) - SendAddressSummary(viewModel: .preview( address: "dashs1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", destination: .shielded(raw43: Data(repeating: 0x2a, count: 43)))) } - .padding(.vertical) + .padding() + .background(Color.dash.primaryBackground) +} + +/// The amount step: the source is settled, so it says which balance is being +/// spent and how much is in it. Whether the number is masked follows the +/// app-wide flag, so this preview shows it as the device last left it. +#Preview("With source") { + VStack(alignment: .leading, spacing: 16) { + SendAddressSummary( + viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core, + coreBalanceDuffs: 158_998_000), + showsSource: true) + + SendAddressSummary( + viewModel: .preview( + address: "yQzt83pPGeXQpVn4rL8mKdWvBcTfGhJkMnPaPuNstuWJK", + destination: .core, + source: .shielded), + showsSource: true) + } + .padding() .background(Color.dash.primaryBackground) } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index 874b6bb2d..ea4586f8c 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -36,31 +36,24 @@ struct ExternalSendAmountScreen: View { // goes with it. DashUIKit.NavigationBar(leading: { NavigationBarElement.back.button { onBack() } - }, central: { - Text(NSLocalizedString("Send", comment: "")) - .dashFont(.subheadMedium) - .foregroundColor(.dash.primaryText) }) - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel) - .padding(.top, 12) + VStack(spacing: 14) { + SendAddressSummary(viewModel: viewModel, showsSource: true) + .padding(.top, 12) - fromSummary + fromSummary - amountRow - .padding(.horizontal, 20) - .padding(.top, 6) + amountRow + .padding(.horizontal, 20) + .padding(.top, 6) - if let message = viewModel.amountValidationMessage { - TransferAmountValidationNote(message: message) - .padding(.horizontal, 20) - } + if let message = viewModel.amountValidationMessage { + TransferAmountValidationNote(message: message) + .padding(.horizontal, 20) } - .padding(.bottom, 8) } - .scrollBounceBehavior(.basedOnSize) + .padding(.bottom, 8) keyboardSection } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift index 2fd82f44f..3a67e7d1d 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -27,41 +27,36 @@ struct SendSourceScreen: View { var body: some View { VStack(spacing: 0) { - // DashUIKit's bar carries its own height and horizontal - // insets, so the manual padding the hand-rolled header needed - // goes with it. DashUIKit.NavigationBar(leading: { NavigationBarElement.back.button { onBack() } - }, central: { - Text(NSLocalizedString("Send", comment: "")) - .dashFont(.subheadMedium) - .foregroundColor(.dash.primaryText) }) - ScrollView { - VStack(spacing: 14) { - SendAddressSummary(viewModel: viewModel) - .padding(.top, 12) + // Content - sourceCards + VStack(alignment: .leading, spacing: 20) { + SendAddressSummary(viewModel: viewModel) - if viewModel.isBlockedBySync { - SyncGateNote() - .padding(.horizontal, 20) - } + sourceCards + + if viewModel.isBlockedBySync { + SyncGateNote() + .padding(.horizontal, 20) } - .padding(.bottom, 8) + + Spacer() + + DashButton( + text: NSLocalizedString("Continue", comment: ""), + style: .filled, + stretch: true, + isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, + action: onContinue + ) + .padding(.horizontal, 60) } - .scrollBounceBehavior(.basedOnSize) - - DashButton( - text: NSLocalizedString("Continue", comment: ""), - style: .filled, - stretch: true, - isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, - action: onContinue) - .padding(.horizontal, 16) - .padding(.bottom, 12) + .padding(.top, 10) + .padding(.bottom, 20) + .padding(.horizontal, 20) } .background(Color.primaryBackground) .navigationBarHidden(true) @@ -72,17 +67,13 @@ struct SendSourceScreen: View { @ViewBuilder private var sourceCards: some View { if let pinned = viewModel.pinnedSource { - // Balance-row send sheet: the source is the tapped balance, - // rendered as a fixed card (no radio, not tappable). sourceRow(pinned, showsRadio: false) - .padding(.horizontal, 20) } else { VStack(spacing: 8) { ForEach(viewModel.validSources, id: \.self) { network in sourceRow(network) } } - .padding(.horizontal, 20) } } @@ -93,6 +84,7 @@ struct SendSourceScreen: View { case .platform: balance = viewModel.platformCreditsFormatted case .shielded: balance = viewModel.shieldedBalanceFormatted } + return TransferSourceRow( iconSystemName: sourceIconName(network), caption: NSLocalizedString("From", comment: ""), @@ -100,7 +92,10 @@ struct SendSourceScreen: View { balanceTrailing: TransferSourceRow.dashBalanceTrailing(balance), selected: viewModel.source == network, showsRadio: showsRadio, - action: { viewModel.source = network }) + action: { + viewModel.source = network + } + ) } } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index a861636e7..8ff691183 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -406,6 +406,13 @@ final class SendViewModel: ObservableObject { InternalTransferViewModel.cardBalanceString(duffs: shieldedBalance / 1000) } + /// The selected source's balance, in duffs — what the amount step shows + /// under "from", so the user can see what they are spending out of + /// without going back a step. + var selectedSourceBalanceDuffs: UInt64 { + balanceDuffs(of: source) + } + /// A source's balance normalised to duffs, for the "first source with /// funds" auto-pick. private func balanceDuffs(of network: ChainNetwork) -> UInt64 { From 0cff40fdd5e3e2140dcc31a48e7496f7483bfe2e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:38:46 +0300 Subject: [PATCH 10/19] feat(send): send Transparent to Transparent through the new amount step too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source step sent Transparent → Transparent straight to the classic L1 amount screen and every other route to the redesigned one, so the route most people use was the only one that looked different. Nothing new was needed to route it: the amount step's Send already hands a core route to `continueCore`, and the view model's `canContinue` already has its Transparent branch, gating on balance alone because the L1 fee rides on top and the payment processor refuses an unfundable send itself. Only the shortcut in the navigation stood in the way. The processor still finishes the send and still owns the fee math — it just receives an amount now instead of zero, so it opens at confirmation rather than at an amount screen the user has already filled in. Comments on all three steps described the old split; they describe this one. Co-Authored-By: Claude Opus 5 --- .../Send/Components/SendAddressSummary.swift | 46 +++++++++++-------- .../Pay/Send/ExternalSendAmountScreen.swift | 24 +++++----- .../UI/Payments/Pay/Send/SendScreen.swift | 29 ++++-------- .../Payments/Pay/Send/SendSourceScreen.swift | 11 ++--- .../Pay/SendScreenViewController.swift | 19 ++++---- 5 files changed, 60 insertions(+), 69 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift index c546e78e2..b83cabcd2 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/Components/SendAddressSummary.swift @@ -17,6 +17,15 @@ import DashUIKit /// already does. So it reads as a heading, the way the merchant header does /// on the gift-card screens. struct SendAddressSummary: View { + + /// Mirrors `MerchantCellRow`'s card metrics — this is the same idea in a + /// different flow, so it should not be a second set of numbers. + private enum Layout { + static let textSpacing: CGFloat = 4 + static let contentPadding: CGFloat = 20 + static let cornerRadius: CGFloat = 16 + } + @ObservedObject var viewModel: SendViewModel /// Show which balance funds the send, and how much is in it. Off on the /// source step, where the cards below already say both and the choice is @@ -30,7 +39,7 @@ struct SendAddressSummary: View { @State private var isBalanceHidden = DWGlobalOptions.sharedInstance().balanceHidden var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Layout.textSpacing) { Text(NSLocalizedString("Send", comment: "Send screen")) .dashFont(.title1) .foregroundStyle(Color.dash.primaryText) @@ -48,37 +57,36 @@ struct SendAddressSummary: View { } if showsSource { - Text(String( - format: NSLocalizedString("from %@", comment: "Send screen — the balance being spent"), - sourceTitle(viewModel.source))) - .dashFont(.subhead) - .foregroundStyle(Color.dash.primaryText) + HStack(spacing: 0) { + Text(String( + format: NSLocalizedString("from %@ ", comment: "Send screen — the balance being spent"), + sourceTitle(viewModel.source))) + - sourceBalanceLine + sourceBalanceLine + } + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) } } + .frame(maxWidth: .infinity, alignment: .leading) } private var sourceBalanceLine: some View { HStack(spacing: 6) { - Text(NSLocalizedString("Balance:", comment: "Send screen")) - .dashFont(.footnote) - .foregroundStyle(Color.dash.secondaryText) if isBalanceHidden { - // Same masking the home balance uses — the width is fixed, so - // the row doesn't change size when it is revealed. Text("••••••••") - .dashFont(.footnote) - .foregroundStyle(Color.dash.secondaryText) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) } else { - // Qualified: the app has a `DashAmount` of its own with a - // different initializer, and an unqualified name picks it. DashUIKit.DashAmount( amount: Int64(clamping: viewModel.selectedSourceBalanceDuffs), - fontSize: 13, - sign: DashAmountSign.none) - .foregroundStyle(Color.dash.secondaryText) + fontSize: 15, + sign: DashAmountSign.none + ) + .dashFont(.subhead) + .foregroundStyle(Color.dash.primaryText) } Button { diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index ea4586f8c..a33f98bf8 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -12,11 +12,13 @@ import UIKit // MARK: - Step 3: amount -/// The final step of the split external-send flow: the address and source are -/// already chosen (both shown read-only), leaving only the amount keypad and -/// the balance/affordability validation. Continue routes exactly as the old -/// single-screen form did — Core → Core into the L1 payment processor, -/// everything else into `SendConfirmSheet`. +/// The final step: the address and source are settled and shown read-only, +/// leaving the amount keypad and the balance/affordability validation. +/// +/// Where Send goes depends on the route, not on how the user got here: +/// Transparent → Transparent hands the amount to the L1 payment processor, +/// which owns the real fee math and its own confirmation; every other route +/// confirms in `SendConfirmSheet`. struct ExternalSendAmountScreen: View { @ObservedObject var viewModel: SendViewModel /// Pop back to the source step. @@ -38,22 +40,20 @@ struct ExternalSendAmountScreen: View { NavigationBarElement.back.button { onBack() } }) - VStack(spacing: 14) { + VStack(spacing: 20) { SendAddressSummary(viewModel: viewModel, showsSource: true) - .padding(.top, 12) - - fromSummary amountRow - .padding(.horizontal, 20) - .padding(.top, 6) if let message = viewModel.amountValidationMessage { TransferAmountValidationNote(message: message) .padding(.horizontal, 20) } } - .padding(.bottom, 8) + .padding(.horizontal, 20) + .padding(.top, 10) + + Spacer() keyboardSection } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift index 2fed39453..30027e8ed 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendScreen.swift @@ -26,23 +26,10 @@ struct SendScreen: View { @FocusState private var addressFieldFocused: Bool var body: some View { - VStack(spacing: 0) { -// if showsHeader { -// header -// .padding(.horizontal, 20) -// .padding(.top, 10) -// } - - ScrollView { - VStack(spacing: 14) { - addressField - .padding(.top, 12) + VStack(spacing: 20) { + addressField - } - .padding(.bottom, 8) - } - .scrollBounceBehavior(.basedOnSize) - .scrollDismissesKeyboard(.interactively) + Spacer() DashButton( text: NSLocalizedString("Continue", comment: ""), @@ -52,10 +39,13 @@ struct SendScreen: View { action: { addressFieldFocused = false onContinue() - }) - .padding(.horizontal, 16) - .padding(.bottom, 12) + } + ) + .padding(.horizontal, 40) } + .padding(.top, 10) + .padding(.horizontal, 20) + .padding(.bottom, 20) .background(Color.dash.primaryBackground) .navigationBarHidden(true) } @@ -99,7 +89,6 @@ struct SendScreen: View { } } .focused($addressFieldFocused) - .padding(.horizontal, 20) .onChange(of: viewModel.destination) { _, destination in // The address just became valid (a paste, or the final typed // character) → drop the keyboard so Continue is reachable without diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift index 3a67e7d1d..3eace0bc7 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendSourceScreen.swift @@ -12,12 +12,9 @@ import UIKit // MARK: - Step 2: amount -/// The amount step for the split external-send flow. The address is already -/// chosen (locked, read-only) — this screen carries the From picker, the -/// sync gate, and the amount keypad, plus the balance/affordability -/// validations. Continue routes exactly as the old single-screen form did: -/// Core → Core into the L1 payment processor, everything else into -/// `SendConfirmSheet`. +/// The source step: which balance funds the send. The address is settled by +/// now and shown read-only above the cards; this screen adds the sync gate. +/// Continue always advances to the amount step — no route skips it. struct SendSourceScreen: View { @ObservedObject var viewModel: SendViewModel /// Pop back to the address step. @@ -52,7 +49,7 @@ struct SendSourceScreen: View { isEnabled: viewModel.route != nil && !viewModel.isBlockedBySync, action: onContinue ) - .padding(.horizontal, 60) + .padding(.horizontal, 40) } .padding(.top, 10) .padding(.bottom, 20) diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift index 53cb6504d..6bf3be83f 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift @@ -110,7 +110,7 @@ extension DWBasePayViewController { /// Push the external-send SOURCE step (From picker) onto the current /// navigation stack, sharing the address step's view model. Continue there - /// pushes the amount step. Shared by the Send screen and the balance-row + /// pushes the amount step, whatever the route. Shared by the Send screen and the balance-row /// send sheet (both `DWBasePayViewController` hosts inside a nav /// controller). func pushExternalSendSource(viewModel: SendViewModel, @@ -120,16 +120,13 @@ extension DWBasePayViewController { onBack: { [weak self] in self?.navigationController?.popViewController(animated: true) }, onContinue: { [weak self] in guard let self else { return } - if viewModel.route == .coreToCore { - // Transparent → Transparent: skip our amount step and use - // the classic L1 amount screen (real fee math + its own - // confirm) reached through the payment processor. - self.continueCore(address: viewModel.trimmedAddress, amountDuffs: 0) - } else { - // Legs the L1 amount screen can't fund (asset-lock shield, - // platform/shielded sources): our amount step + confirm. - self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) - } + // Every route takes the same amount step, Transparent → + // Transparent included: it used to jump straight to the + // classic L1 amount screen from here, so the one route most + // people use was the one that looked different. That step + // still finishes through the payment processor — it just + // hands it an amount now instead of zero. + self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) }) // Every UIHostingController already conforms to NavigationBarDisplayable // (nav bar + back button hidden); the screen draws its own back + title. From 4ca12945209b43138417f13b59667c691837472a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:46:32 +0300 Subject: [PATCH 11/19] fix(send): gate Core sends on what is actually spendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue let a Core-funded amount through on `coreBalanceDuffs` — the wallet total, which counts unconfirmed and immature coins and leaves nothing for the fee. Max has always filled in something stricter: spendable minus the send fee reserve. So amounts existed that Max would never offer, that armed the button anyway, and that only failed later at the payment processor. Reaching that window used to need the shielded route; routing Transparent through this step opened it to the common case. Both Core-funded routes now gate on the number Max uses, and Transparent gets the dust floor the classic amount screen enforces — below it the network drops the output whatever the balance says. The validation message follows the same number. Its own comment promises a button disabled on affordability is never left unexplained, and quoting the total there would have reported an amount as affordable while Continue stayed dead. Balances shown to the user are unchanged: the cards and the header still say what is in the wallet. Only what may be spent right now moved. Co-Authored-By: Claude Opus 5 --- .../UI/Payments/Pay/Send/SendViewModel.swift | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index 8ff691183..a3a4e3a38 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -406,6 +406,17 @@ final class SendViewModel: ObservableObject { InternalTransferViewModel.cardBalanceString(duffs: shieldedBalance / 1000) } + /// What a Core-funded send can actually spend: confirmed, mature funds + /// with the send fee already set aside — the exact number Max fills in. + /// + /// `coreBalanceDuffs` is the wallet TOTAL, so it counts unconfirmed and + /// immature coins and leaves nothing for the fee. Gating on it let the + /// user type an amount Max would never offer, arm the button, and only + /// find out at the payment processor's error. + var coreSpendableAfterFeeDuffs: UInt64 { + SwiftDashSDKWalletState.shared.feeAwareMaxSendable() + } + /// The selected source's balance, in duffs — what the amount step shows /// under "from", so the user can see what they are spending out of /// without going back a step. @@ -520,6 +531,16 @@ final class SendViewModel: ObservableObject { if let shieldedMaxNotice { return shieldedMaxNotice } guard dashDuffsUnsigned > 0, let route else { return nil } + // Below the dust threshold the network drops the output, whatever the + // balance is — say so rather than leave Continue dead. + if route == .coreToCore, dashDuffsUnsigned < CoreTxConstants.minOutputAmount { + return String.localizedStringWithFormat( + NSLocalizedString( + "The minimum amount you can send is %@", + comment: "External send — dust threshold"), + "\(CoreTxConstants.minOutputAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + } + // Route minimum first: below the Type-18 pool fee the SDK refuses the // asset lock however much Core balance backs it. if route == .coreToShielded { @@ -549,10 +570,13 @@ final class SendViewModel: ObservableObject { switch route { case .coreToCore, .coreToShielded: + // The same envelope `canContinue` gates on — quoting the wallet + // total here would report an amount as affordable while the + // button stayed dead. return TransferSpendAmountPolicy.insufficientBalanceMessage( balanceName: balanceName, requestedDuffs: dashDuffsUnsigned, - spendableDuffs: coreBalanceDuffs) + spendableDuffs: coreSpendableAfterFeeDuffs) case .platformToPlatform: guard let reserve = feeReserveCredits else { @@ -624,9 +648,11 @@ final class SendViewModel: ObservableObject { guard dashDuffsUnsigned > 0, let route, !isBlockedBySync else { return false } switch route { case .coreToCore: - // The L1 fee rides on top; the payment processor rejects an - // unfundable send with its own error, so gate on the balance only. - return dashDuffsUnsigned <= coreBalanceDuffs + // Below the dust threshold the network won't relay the output at + // all — the same floor `BaseAmountModel` enforces on the classic + // amount screen, which this step now replaces for this route. + guard dashDuffsUnsigned >= CoreTxConstants.minOutputAmount else { return false } + return dashDuffsUnsigned <= coreSpendableAfterFeeDuffs case .coreToShielded: // Asset-lock route: the pool fee is carved from the locked value // and the Rust side rejects an undersized lock. Enforce the same @@ -635,7 +661,7 @@ final class SendViewModel: ObservableObject { guard let minimumDuffs = coreToShieldedMinimumAmountDuffs, dashDuffsUnsigned >= minimumDuffs else { return false } - return dashDuffsUnsigned <= coreBalanceDuffs + return dashDuffsUnsigned <= coreSpendableAfterFeeDuffs case .platformToPlatform: guard let reserve = feeReserveCredits else { return false } return platformCredits >= reserve From d86fc3e3cd60edd28a6ebbe96936f0a5cf7e8eef Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:17:55 +0300 Subject: [PATCH 12/19] feat(send): confirm a Transparent send in the app's own sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transparent → Transparent still left the redesigned flow at the last step: it handed the amount to the legacy payment processor, which then showed its own confirmation. Three screens looked one way and the fourth another, on the route most people use. It now presents SendConfirmSheet like every other route. A small controller drives the Core leg rather than ShieldedTransferCoordinator, whose phases — locking, proving, asset-lock polling — a plain L1 send never passes through; its checklist is the two stages this send actually has. The sheet prepares on appear through WalletSendService, which runs the auth gate and signs without broadcasting, and Confirm calls broadcast() on the signed transaction. That ordering is not new: DWPaymentProcessor already prepares before showing its confirmation, so the fee shown is the real one off a signed transaction rather than an estimate — the only route here that can say that. Retrying a failed broadcast re-sends the same signed transaction; a result the relay accepted but could not confirm stays terminal, as PreparedStandardSend's own claim state and every other route already treat it. continueCore had one caller and is gone with it. Scan routing and the "Send to Address" shortcut prefill the view model and come through the amount step, so neither reached it. Co-Authored-By: Claude Opus 5 --- .../Pay/Send/ExternalSendAmountScreen.swift | 30 +-- .../Payments/Pay/Send/SendConfirmSheet.swift | 246 ++++++++++++++++-- .../UI/Payments/Pay/Send/SendViewModel.swift | 14 +- .../Pay/SendScreenViewController.swift | 33 +-- 4 files changed, 248 insertions(+), 75 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index a33f98bf8..e43c95a85 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -15,18 +15,15 @@ import UIKit /// The final step: the address and source are settled and shown read-only, /// leaving the amount keypad and the balance/affordability validation. /// -/// Where Send goes depends on the route, not on how the user got here: -/// Transparent → Transparent hands the amount to the L1 payment processor, -/// which owns the real fee math and its own confirmation; every other route -/// confirms in `SendConfirmSheet`. +/// Every route, Transparent → Transparent included, confirms in +/// `SendConfirmSheet` — Core → Core prepares (auth + build/sign, showing the +/// real fee) and broadcasts through `WalletSendService` there instead of +/// `ShieldedTransferCoordinator`. struct ExternalSendAmountScreen: View { @ObservedObject var viewModel: SendViewModel /// Pop back to the source step. var onBack: () -> Void - /// Core → Core: hand (address, amount in duffs) to the hosting - /// controller, which routes through the L1 payment processor. - var onContinueCore: (String, UInt64) -> Void - /// A non-core route finished successfully (confirm sheet's Done). + /// A route finished successfully (confirm sheet's Done). var onSendCompleted: () -> Void @State private var showConfirm = false @@ -60,7 +57,7 @@ struct ExternalSendAmountScreen: View { .background(Color.dash.primaryBackground) .navigationBarHidden(true) .sheet(isPresented: $showConfirm) { - if let route = viewModel.route, route != .coreToCore { + if let route = viewModel.route { SendConfirmSheet( route: route, destinationAddress: viewModel.trimmedAddress, @@ -125,12 +122,8 @@ struct ExternalSendAmountScreen: View { } private func continueAction() { - guard let route = viewModel.route else { return } - if route == .coreToCore { - onContinueCore(viewModel.trimmedAddress, viewModel.dashDuffsUnsigned) - } else { - showConfirm = true - } + guard viewModel.route != nil else { return } + showConfirm = true } /// The source picked on the previous step, read-only. Tapping goes back. @@ -234,19 +227,18 @@ struct ExternalSendAmountScreen: View { #Preview("Amount") { ExternalSendAmountScreen( viewModel: .preview(address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core), - onBack: {}, onContinueCore: { _, _ in }, onSendCompleted: {}) + onBack: {}, onSendCompleted: {}) .background(Color.dash.primaryBackground) } -/// Shielded source: the route leaves the classic payment processor, so the -/// screen confirms in `SendConfirmSheet` instead of continuing to it. +/// Shielded source: confirms in `SendConfirmSheet`, same as every other route. #Preview("From Shielded") { ExternalSendAmountScreen( viewModel: .preview( address: "yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY", destination: .core, source: .shielded), - onBack: {}, onContinueCore: { _, _ in }, onSendCompleted: {}) + onBack: {}, onSendCompleted: {}) .background(Color.dash.primaryBackground) } diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift index 60b228989..43e36f9c4 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift @@ -2,7 +2,7 @@ // SendConfirmSheet.swift // DashWallet // -// Confirmation half-sheet for the non-core external send routes. +// Confirmation half-sheet for every external send route. // import SwiftUI @@ -10,13 +10,112 @@ import DashUIKit import SwiftDashSDK import UIKit +// MARK: - CoreSendConfirmController + +/// Drives the Core → Core leg of `SendConfirmSheet`. Every other route runs +/// through `ShieldedTransferCoordinator`; a plain L1 send has no shielding/ +/// asset-lock/platform stages, so it gets its own minimal state machine +/// instead of borrowing a coordinator phase that doesn't apply to it. +/// +/// Shape: `prepareStandardSendForConfirmation` runs the auth gate and +/// builds + signs the transaction WITHOUT broadcasting — that happens on +/// appear, so the sheet can show the real fee off the signed transaction +/// instead of an estimate. `.ready` is the only phase with a live Confirm +/// button; tapping it calls `PreparedStandardSend.broadcast()`, the explicit +/// user-confirm step. +@MainActor +final class CoreSendConfirmController: ObservableObject { + + enum Phase: Equatable { + /// Auth gate + build/sign in flight (`prepareStandardSendForConfirmation`). + case signing + /// Prepared: `feeDuffs` holds the real fee off the signed transaction. + case ready + /// `PreparedStandardSend.broadcast()` in flight. + case broadcasting + case success + /// Broadcast accepted by relay but its result couldn't be confirmed — + /// terminal + non-retryable, mirrors + /// `ShieldedTransferCoordinator.Phase.submittedUnconfirmed`. + case submittedUnconfirmed + case failed(String) + } + + @Published private(set) var phase: Phase = .signing + /// The exact fee (duffs) off the signed transaction. `nil` until + /// `.ready` — the summary row shows "—" rather than a guess until then. + @Published private(set) var feeDuffs: UInt64? + + private var prepared: PreparedStandardSend? + + /// Build + sign, without broadcasting. Called once when the sheet + /// appears; safe to call again from `retry` after a prepare-time + /// failure (auth cancelled, offline, chain not synced, ...). + func prepare(address: String, amountDuffs: UInt64) async { + phase = .signing + prepared = nil + feeDuffs = nil + do { + let result = try await WalletSendService.shared.prepareStandardSendForConfirmation( + address: address, amount: amountDuffs) + prepared = result + feeDuffs = result.fee + phase = .ready + } catch { + phase = .failed(Self.message(for: error)) + } + } + + /// Explicit user-confirm step: broadcasts the already-prepared, already- + /// signed transaction. No-op unless a prepare succeeded. + func confirmBroadcast() async { + guard case .ready = phase else { return } + await broadcastPrepared() + } + + /// `Try again` from `.failed`. A broadcast-time failure (e.g. rejected) + /// leaves the signed transaction retryable — `PreparedStandardSend` + /// resets its own claim state to `.ready` on a rejected/local failure — + /// so retry re-broadcasts the SAME transaction rather than re-signing. A + /// prepare-time failure (nothing was ever signed) re-prepares. + func retry(address: String, amountDuffs: UInt64) async { + guard case .failed = phase else { return } + if prepared != nil { + await broadcastPrepared() + } else { + await prepare(address: address, amountDuffs: amountDuffs) + } + } + + private func broadcastPrepared() async { + guard let prepared else { return } + phase = .broadcasting + do { + try prepared.broadcast() + phase = .success + } catch { + if WalletSendService.isBroadcastUnknownError(error as NSError) { + phase = .submittedUnconfirmed + } else { + phase = .failed(Self.message(for: error)) + } + } + } + + private static func message(for error: Error) -> String { + (error as NSError).localizedDescription + } +} + // MARK: - SendConfirmSheet -/// Confirmation half-sheet for the non-core external send routes. Same -/// skeleton as `InternalTransferConfirmSheet` (summary → progress checklist → -/// success), but the To row is the recipient's address and execution runs the -/// coordinator's external-destination legs. Core → Core never reaches this -/// sheet — it rides the classic payment processor. +/// Confirmation half-sheet for every external send route. Same skeleton as +/// `InternalTransferConfirmSheet` (summary → progress checklist → success); +/// the To row is the recipient's address. Every route except Core → Core +/// executes via `ShieldedTransferCoordinator`'s external-destination legs; +/// Core → Core executes via `CoreSendConfirmController` / +/// `WalletSendService` instead — see that type's doc for why it doesn't +/// share the coordinator. struct SendConfirmSheet: View { let route: SendViewModel.Route @@ -35,6 +134,8 @@ struct SendConfirmSheet: View { var onCompleted: () -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() + /// Core → Core only — see `CoreSendConfirmController`. + @StateObject private var coreSend = CoreSendConfirmController() var body: some View { VStack(spacing: 0) { @@ -47,20 +148,46 @@ struct SendConfirmSheet: View { .foregroundColor(.dash.primaryText) .padding(.top, 20) - switch coordinator.phase { - case .success: - successBody - case .submittedUnconfirmed: - ShieldedSubmittedUnconfirmedView(onDone: onCompleted) - default: - detailsBody + if route == .coreToCore { + switch coreSend.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onCompleted) + default: + detailsBody + } + } else { + switch coordinator.phase { + case .success: + successBody + case .submittedUnconfirmed: + ShieldedSubmittedUnconfirmedView(onDone: onCompleted) + default: + detailsBody + } } } .background(Color.dash.primaryBackground) .interactiveDismissDisabled(isInFlight) + .task { + // Prepare (auth gate + build/sign, no broadcast) as soon as the + // sheet appears, so the summary can show the real fee off the + // signed transaction rather than an estimate. + guard route == .coreToCore else { return } + await coreSend.prepare(address: destinationAddress, amountDuffs: UInt64(dashDuffs)) + } } private var isInFlight: Bool { + if route == .coreToCore { + switch coreSend.phase { + case .signing, .broadcasting: + return true + default: + return false + } + } switch coordinator.phase { case .signing, .locking, .proving, .broadcasting: return true @@ -89,8 +216,8 @@ struct SendConfirmSheet: View { .padding(.horizontal, 20) .padding(.top, 20) - if case let .failed(message) = coordinator.phase { - Text(message) + if let failureMessage { + Text(failureMessage) .font(.system(size: 13)) .foregroundColor(.red) .multilineTextAlignment(.center) @@ -104,6 +231,54 @@ struct SendConfirmSheet: View { Spacer(minLength: 12) + actionSection + } + } + + private var failureMessage: String? { + if route == .coreToCore { + if case let .failed(message) = coreSend.phase { return message } + return nil + } + if case let .failed(message) = coordinator.phase { return message } + return nil + } + + @ViewBuilder + private var actionSection: some View { + if route == .coreToCore { + switch coreSend.phase { + case .ready: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Confirm", comment: ""), + positiveButtonAction: confirm, + negativeButtonText: NSLocalizedString("Cancel", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .failed: + ButtonsGroup( + orientation: .horizontal, + size: .large, + positiveButtonText: NSLocalizedString("Try again", comment: ""), + positiveButtonAction: tryAgain, + negativeButtonText: NSLocalizedString("Close", comment: ""), + negativeButtonAction: onCancel) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + case .signing, .broadcasting: + ShieldedTransferStepList(labels: coreProgressLabels, currentIndex: coreProgressIndex) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + case .success, .submittedUnconfirmed: + EmptyView() + } + } else { switch coordinator.phase { case .idle: ButtonsGroup( @@ -138,6 +313,23 @@ struct SendConfirmSheet: View { } } + /// Core → Core's two-stage checklist (`CoreSendConfirmController.Phase` + /// has no `.locking`/`.proving` — a plain L1 send doesn't pass through + /// them), rendered with `ShieldedTransferStepList`'s positional + /// initializer since its phase-based one is typed to + /// `ShieldedTransferCoordinator.Phase`. + private var coreProgressLabels: [String] { + [NSLocalizedString("Authorizing", comment: ""), NSLocalizedString("Broadcasting", comment: "")] + } + + private var coreProgressIndex: Int? { + switch coreSend.phase { + case .signing: return 0 + case .broadcasting: return 1 + default: return nil + } + } + // MARK: - Success body private var successBody: some View { @@ -272,15 +464,21 @@ struct SendConfirmSheet: View { case .shieldedToShielded: return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 2) case .coreToCore: - // Never presented here — the L1 processor shows the real fee. - return nil + // The one route with a real (not projected) fee: off the signed + // transaction, once `CoreSendConfirmController` finishes + // preparing. `nil` while unknown — the row shows "—", not a guess. + guard let feeDuffs = coreSend.feeDuffs else { return nil } + return feeDuffs * 1000 } } private var networkFeeString: String { guard let credits = networkFeeCredits else { return "—" } let dash = Decimal(credits) / Self.creditsPerDash - return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash) + let formatted = CurrencyExchanger.shared.fiatAmountString(for: dash) + // Every other route estimates; Core → Core's fee is the exact amount + // off the signed transaction, so it doesn't get the "~" prefix. + return route == .coreToCore ? formatted : "~ " + formatted } // MARK: - Info card @@ -413,14 +611,20 @@ struct SendConfirmSheet: View { amountCredits: creditsAmount, recipientRaw43: destinationRaw43) case .coreToCore: - // Unreachable: the screen routes Core → Core through the L1 - // payment processor and never presents this sheet. - break + // Already prepared (auth + build/sign) on appear — Confirm + // only broadcasts the signed transaction. + await coreSend.confirmBroadcast() } } } private func tryAgain() { + if route == .coreToCore { + Task { + await coreSend.retry(address: destinationAddress, amountDuffs: UInt64(dashDuffs)) + } + return + } // If the just-failed Core → Shielded attempt already committed its // asset lock, RESUME that exact outpoint with the same recipient // instead of building a second lock (which strands the first) — diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift index a3a4e3a38..d4f6406f1 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendViewModel.swift @@ -29,8 +29,9 @@ final class SendViewModel: ObservableObject { } /// Every (source balance → destination address type) leg the Send screen - /// can execute. Core → Core rides the classic L1 payment processor; the - /// rest run through `ShieldedTransferCoordinator` / the Platform seam. + /// can execute — all confirmed and executed from `SendConfirmSheet`. + /// Core → Core runs through `WalletSendService` directly; the rest run + /// through `ShieldedTransferCoordinator` / the Platform seam. enum Route: Equatable { case coreToCore /// BIP44 UTXOs → asset lock → Type 18 shield note for the @@ -451,10 +452,11 @@ final class SendViewModel: ObservableObject { private var feeReserveCredits: UInt64? { switch route { case .coreToCore, .coreToShielded, .platformToCore, nil: - // L1 send fees are handled by the payment processor; the - // asset-lock shield carves its pool fee from the locked value - // (nothing reserved from the Core balance, mirroring the - // internal transfer); the full-balance platform withdrawal + // Core → Core's fee headroom is netted by `coreSpendableAfterFeeDuffs` + // (`SwiftDashSDKWalletState.feeAwareMaxSendable()`), not a credits + // reserve; the asset-lock shield carves its pool fee from the + // locked value (nothing reserved from the Core balance, mirroring + // the internal transfer); the full-balance platform withdrawal // nets its fee out of the preflighted payout. return 0 case .platformToPlatform: diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift index 6bf3be83f..1fc20d237 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreenViewController.swift @@ -88,26 +88,9 @@ final class SendScreenViewController: DWBasePayViewController { } } -// MARK: - Core → Core routing +// MARK: - External send navigation extension DWBasePayViewController { - /// Core L1 send with a fixed amount: a BIP21 `dash:` URI carries the - /// amount into the classic payment processor, which goes straight to - /// its confirm (real fee math) — the amount screen is skipped because - /// the intent already has an amount. Shared by the Send screen and the - /// balance-row send sheet. - func continueCore(address: String, amountDuffs: UInt64) { - guard address.isValidDashAddressForCurrentNetwork else { return } - var uriString = "dash:\(address)" - if amountDuffs > 0 { - let dashAmount = InternalTransferViewModel.formatTyped( - amountDuffs.dashAmount, fractionDigits: 8) - uriString += "?amount=\(dashAmount)" - } - guard let url = URL(string: uriString) else { return } - performPay(to: url) - } - /// Push the external-send SOURCE step (From picker) onto the current /// navigation stack, sharing the address step's view model. Continue there /// pushes the amount step, whatever the route. Shared by the Send screen and the balance-row @@ -121,11 +104,7 @@ extension DWBasePayViewController { onContinue: { [weak self] in guard let self else { return } // Every route takes the same amount step, Transparent → - // Transparent included: it used to jump straight to the - // classic L1 amount screen from here, so the one route most - // people use was the one that looked different. That step - // still finishes through the payment processor — it just - // hands it an amount now instead of zero. + // Transparent included, and finishes in `SendConfirmSheet`. self.pushExternalSendAmount(viewModel: viewModel, onSendCompleted: onSendCompleted) }) // Every UIHostingController already conforms to NavigationBarDisplayable @@ -134,17 +113,13 @@ extension DWBasePayViewController { navigationController?.pushViewController(host, animated: true) } - /// Push the external-send AMOUNT step (final). Core → Core rides - /// `continueCore` (the L1 payment processor); every other route confirms in - /// `SendConfirmSheet`. + /// Push the external-send AMOUNT step (final). Every route, Transparent → + /// Transparent included, confirms and executes in `SendConfirmSheet`. func pushExternalSendAmount(viewModel: SendViewModel, onSendCompleted: @escaping () -> Void) { let screen = ExternalSendAmountScreen( viewModel: viewModel, onBack: { [weak self] in self?.navigationController?.popViewController(animated: true) }, - onContinueCore: { [weak self] address, amountDuffs in - self?.continueCore(address: address, amountDuffs: amountDuffs) - }, onSendCompleted: onSendCompleted) let host = UIHostingController(rootView: screen) navigationController?.pushViewController(host, animated: true) From 5aa8ab94a8acaa9a16a8e42d3ce8e03b7df72f4b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:57:14 +0300 Subject: [PATCH 13/19] fix(send): sign before presenting the confirm sheet, not from inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Transparent send failed on the first attempt. The sheet prepared in its own `.task`, so the auth gate asked for the PIN while the sheet was still animating in; UIKit refuses that presentation and returns nothing, and `PinPromptPresenter`'s watchdog resolved it as an authentication failure. The log reads: preparing standard send → presentation rejected → authentication failed. Tapping "Try again" then worked, because by then the sheet had settled. The amount step signs first and presents the sheet only once it holds a signed transaction — the order DWPaymentProcessor has always used, and there is no sheet in the way when the PIN goes up. The sheet still shows the real fee off that transaction and still broadcasts only on Confirm. Backing out of the PIN now returns to idle rather than failed: it is a decision, not an error, and it left the amount step showing "Authentication cancelled" as though something had gone wrong. A signature that genuinely could not be produced does say so there, since Continue would otherwise look broken. Co-Authored-By: Claude Opus 5 --- .../Pay/Send/ExternalSendAmountScreen.swift | 39 +++++++++++++-- .../Payments/Pay/Send/SendConfirmSheet.swift | 48 ++++++++++++++----- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift index e43c95a85..0b4a2e3ca 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/ExternalSendAmountScreen.swift @@ -27,6 +27,12 @@ struct ExternalSendAmountScreen: View { var onSendCompleted: () -> Void @State private var showConfirm = false + /// Core route: built and signed here, before the sheet is presented. The + /// auth gate inside it puts up the PIN, and UIKit refuses to present that + /// over a sheet that is still animating in — doing it from the sheet's own + /// `.task` failed the send on the first attempt every time. + @StateObject private var coreSend = CoreSendConfirmController() + @State private var isPreparingCoreSend = false var body: some View { VStack(spacing: 0) { @@ -42,7 +48,13 @@ struct ExternalSendAmountScreen: View { amountRow - if let message = viewModel.amountValidationMessage { + // A signature that could not be produced (offline, chain not + // synced) keeps the user here — say why, or Continue would + // just look broken. + if let message = coreSend.failureMessage { + TransferAmountValidationNote(message: message) + .padding(.horizontal, 20) + } else if let message = viewModel.amountValidationMessage { TransferAmountValidationNote(message: message) .padding(.horizontal, 20) } @@ -68,6 +80,7 @@ struct ExternalSendAmountScreen: View { withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, isFullShieldedSweep: viewModel.isFullShieldedSweep, + coreSend: coreSend, onCancel: { showConfirm = false }, onCompleted: { showConfirm = false @@ -110,7 +123,7 @@ struct ExternalSendAmountScreen: View { showDecimalSeparator: true, actionButtonText: NSLocalizedString("Continue", comment: ""), actionEnabled: viewModel.canContinue, - inProgress: false, + inProgress: isPreparingCoreSend, actionHandler: continueAction ) .padding(.top, 12) @@ -122,8 +135,26 @@ struct ExternalSendAmountScreen: View { } private func continueAction() { - guard viewModel.route != nil else { return } - showConfirm = true + guard let route = viewModel.route else { return } + guard route == .coreToCore else { + showConfirm = true + return + } + // Sign first, then present: the sheet opens already knowing the real + // fee, and the PIN goes up over this screen rather than over a sheet + // in mid-presentation. Same order the legacy payment processor uses. + Task { + isPreparingCoreSend = true + defer { isPreparingCoreSend = false } + await coreSend.prepare( + address: viewModel.trimmedAddress, + amountDuffs: viewModel.dashDuffsUnsigned) + // A refused or cancelled signature keeps the user here with the + // reason, rather than opening a confirm sheet that has nothing to + // confirm. + guard coreSend.isReady else { return } + showConfirm = true + } } /// The source picked on the previous step, read-only. Tapping goes back. diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift index 43e36f9c4..24643868c 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift @@ -27,6 +27,10 @@ import UIKit final class CoreSendConfirmController: ObservableObject { enum Phase: Equatable { + /// Nothing attempted yet, or the user backed out of the PIN — the + /// same resting state, because a cancelled prompt is a decision and + /// has nothing to report. + case idle /// Auth gate + build/sign in flight (`prepareStandardSendForConfirmation`). case signing /// Prepared: `feeDuffs` holds the real fee off the signed transaction. @@ -41,16 +45,27 @@ final class CoreSendConfirmController: ObservableObject { case failed(String) } - @Published private(set) var phase: Phase = .signing + @Published private(set) var phase: Phase = .idle /// The exact fee (duffs) off the signed transaction. `nil` until /// `.ready` — the summary row shows "—" rather than a guess until then. @Published private(set) var feeDuffs: UInt64? + /// Signed and waiting for the user's confirmation. + var isReady: Bool { phase == .ready } + + /// Why the signature could not be produced, for the step that asked. + var failureMessage: String? { + if case let .failed(message) = phase { return message } + return nil + } + private var prepared: PreparedStandardSend? - /// Build + sign, without broadcasting. Called once when the sheet - /// appears; safe to call again from `retry` after a prepare-time - /// failure (auth cancelled, offline, chain not synced, ...). + /// Build + sign, without broadcasting. Run by the amount step before it + /// presents the confirm sheet — the PIN this puts up cannot be presented + /// over a sheet that is still appearing. Safe to call again from `retry` + /// after a prepare-time failure (auth cancelled, offline, chain not + /// synced, ...). func prepare(address: String, amountDuffs: UInt64) async { phase = .signing prepared = nil @@ -62,7 +77,9 @@ final class CoreSendConfirmController: ObservableObject { feeDuffs = result.fee phase = .ready } catch { - phase = .failed(Self.message(for: error)) + phase = WalletSendService.isAuthenticationCancelledError(error as NSError) + ? .idle + : .failed(Self.message(for: error)) } } @@ -130,12 +147,18 @@ struct SendConfirmSheet: View { var withdrawalFeeCredits: UInt64? = nil var isFullPlatformWithdrawal: Bool = false var isFullShieldedSweep: Bool = false + /// Core route only: prepared by the amount step BEFORE this sheet is + /// presented. Preparing from here raced the sheet's own presentation — + /// `PinPromptPresenter` cannot put the PIN over a sheet that is still + /// animating in, UIKit rejects it silently, and its watchdog resolved the + /// send as an authentication failure. + @ObservedObject var coreSend: CoreSendConfirmController + var onCancel: () -> Void var onCompleted: () -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() /// Core → Core only — see `CoreSendConfirmController`. - @StateObject private var coreSend = CoreSendConfirmController() var body: some View { VStack(spacing: 0) { @@ -170,13 +193,7 @@ struct SendConfirmSheet: View { } .background(Color.dash.primaryBackground) .interactiveDismissDisabled(isInFlight) - .task { - // Prepare (auth gate + build/sign, no broadcast) as soon as the - // sheet appears, so the summary can show the real fee off the - // signed transaction rather than an estimate. - guard route == .coreToCore else { return } - await coreSend.prepare(address: destinationAddress, amountDuffs: UInt64(dashDuffs)) - } + } private var isInFlight: Bool { @@ -277,6 +294,11 @@ struct SendConfirmSheet: View { case .success, .submittedUnconfirmed: EmptyView() + + // Unreachable: the amount step only presents this sheet once the + // transaction is signed, and a cancelled PIN keeps it there. + case .idle: + EmptyView() } } else { switch coordinator.phase { From 32d607970edab8e3575d90060b0d91c3c66f6258 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:26:59 +0300 Subject: [PATCH 14/19] refactor(payments): extract the landing's tab selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Receive / Internal / Send switcher was fifty lines inside PaymentsLandingScreen's body, reaching into the view model for both the tab list and the selection. It takes the tabs and a binding now. Which tabs are offered is the presentation's decision — the full landing shows three, the balance-row receive sheet narrows to two — and the switcher has no reason to know why. That also makes it previewable: all three, narrowed, and dark, where the selected pill switches fill so the label stays legible. Metrics move into a Layout enum rather than staying loose in the body. The screen's colorScheme environment value went with the switcher — nothing else was reading it. Co-Authored-By: Claude Opus 5 --- DashWallet.xcodeproj/project.pbxproj | 272 +++++++++--------- .../PaymentsLandingTabSelector.swift | 97 +++++++ .../Landing/PaymentsLandingScreen.swift | 39 +-- 3 files changed, 228 insertions(+), 180 deletions(-) create mode 100644 DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 8f22d3b72..15d601824 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 00B3E9708BF84A08409B431B /* BIP70PaymentOutputFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5822AAB9B5EA605FF82B009A /* BIP70PaymentOutputFactory.swift */; }; 026B0383557D0181E034B64E /* SDKIdentityProfileSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */; }; - 5ACTMYQR020000000000QR02 /* MyDashPayUserQRSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */; }; 02EDCC7635BCD84768554D9C /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 02EED2FC7EAC55B96ECEAAD6 /* DWLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59C18FCF827D815007BAEF29 /* DWLogger.m */; }; 0308E777B29CC6DE1DB30FDD /* PlatformSyncStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */; }; @@ -101,13 +100,6 @@ 12BD10DCCEFF4142EA3BA0CA /* SwiftDashSDKWalletSending.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4BAA92B00D4109567DF870F /* SwiftDashSDKWalletSending.swift */; }; 12DE15416F9F7A0B8749CAF1 /* PaymentsLandingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */; }; 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; - 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; - 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; - 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; - 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; - 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; - 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; - 5ASB00100000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; 16AA1B384A9758C1DA97676A /* SwiftDashSDKHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */; }; 17ED79A8B56522CD03A8CD5E /* ShieldedTransferCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D2C8F8102C5C603DB63569F /* ShieldedTransferCoordinator.swift */; }; 197F19679BBAABA2E99503B8 /* DWAvatarUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A461F75E1E170FFE3294337 /* DWAvatarUploadClient.swift */; }; @@ -120,7 +112,6 @@ 1C2999FF1FC854E7DB138250 /* TransferTimingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67FCD2ED5EDFD68CDE1FE7CC /* TransferTimingSheet.swift */; }; 1C2D3E4F5A6B7C8D9E0F1A2B /* TransferAmountHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C9D0E1F2A3B4C5D6E7F809 /* TransferAmountHostingController.swift */; }; 1CDA90D00719D6249F2761AC /* ContactItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */; }; - 9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */; }; 1F3FF21B0295D5A326F08B74 /* OrderPreviewFeeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D1A08496F925D17031493E /* OrderPreviewFeeRow.swift */; }; 1FAFEA3F516E6CBDD2B49010 /* SwiftDashSDKContactsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02357928871603392E797C9A /* SwiftDashSDKContactsService.swift */; }; 1FED991A9D2D236582CE3C99 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; @@ -128,7 +119,6 @@ 2284B63F1C7BD28F0012ECDF /* BRAWKeypad.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F45A0A1C30EAB700B07A15 /* BRAWKeypad.swift */; }; 22B03841025A55A573432CC9 /* WalletAccountsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285B961142AB3AFF2442F4EB /* WalletAccountsScreen.swift */; }; 22E89B6C00B546D294D7F331 /* SelfSizingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB7E093E994D2391D36DEC /* SelfSizingSheet.swift */; }; - 5ACPHD0002F9A0000000000S2 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; 2556F3C5C72D70A771C6C61A /* PaymentProtocolWireFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B030D4B574621DA2C5816 /* PaymentProtocolWireFormat.swift */; }; 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; @@ -364,7 +354,6 @@ 4539AA3FC5C209480BA279AA /* PaymentProtocolMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4833BE986D59F69A555C5F /* PaymentProtocolMessages.swift */; }; 45EAD8E50593EE5158E92E9B /* WalletAccountsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 285B961142AB3AFF2442F4EB /* WalletAccountsScreen.swift */; }; 4636DCF2B17C1DC56E9CDD84 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; - 5ACPHD0002F9A0000000000S3 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; 470617D6299A671900DCC667 /* CrowdNodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470617D5299A671900DCC667 /* CrowdNodeCell.swift */; }; 47081197298CF20C003FCA3D /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47081196298CF20B003FCA3D /* Transaction.swift */; }; 4708119F2990F56F003FCA3D /* TransactionDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4708119E2990F56F003FCA3D /* TransactionDataItem.swift */; }; @@ -653,6 +642,29 @@ 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; 5AACV0002F9A0000000000S2 /* AddContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */; }; 5AACV0002F9A0000000000S3 /* AddContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */; }; + 5ACPHD0002F9A0000000000S2 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; + 5ACPHD0002F9A0000000000S3 /* ContactPreviewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */; }; + 5ACSH0002F9A0000000000S2 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; + 5ACSH0002F9A0000000000S3 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; + 5ACTCARD0200000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; + 5ACTCARD0300000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; + 5ACTCAVM0200000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; + 5ACTCAVM0300000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; + 5ACTCEVR020000000000EV02 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; + 5ACTCEVR030000000000EV03 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; + 5ACTCROW0200000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; + 5ACTCROW0300000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; + 5ACTCSPR020000000000SP02 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; + 5ACTCSPR030000000000SP03 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; + 5ACTCSTC020000000000SC02 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; + 5ACTCSTC030000000000SC03 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; + 5ACTCSTV020000000000SV02 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; + 5ACTCSTV030000000000SV03 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; + 5ACTDAYG020000000000DG02 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; + 5ACTDAYG030000000000DG03 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; + 5ACTMYQR020000000000QR02 /* MyDashPayUserQRSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */; }; + 5ACTTFB0020000000000FB02 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; + 5ACTTFB0030000000000FB03 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 5AGOV0002F9A0000000000S2 /* GovernanceMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */; }; 5AGOV0002F9A0000000000S3 /* GovernanceMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */; }; 5AGOV0002F9A0000000000V2 /* GovernanceMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */; }; @@ -663,6 +675,20 @@ 5ANVM0002F9A0000000000S3 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ANVM0002F9A0000000000S1 /* NotificationsViewModel.swift */; }; 5APCV0002F9A0000000000S2 /* PayContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */; }; 5APCV0002F9A0000000000S3 /* PayContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */; }; + 5ASB00100000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; + 5ASB00200000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; + 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; + 5ASB02100000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; + 5ASB03100000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; + 5ASB04100000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; + 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; + 5ASB05100000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; + 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; + 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; 5BBF50F1EFB3EC99854ECE69 /* SwiftDashSDKTransactionSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */; }; 5D8D3B4397A957BA6DD6FC0C /* DWAboutModel+MasternodeSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1D2D397047E72188C90FAE /* DWAboutModel+MasternodeSync.swift */; }; 5EAD111230A0000000A1B201 /* MayaAmountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAD111130A0000000A1B201 /* MayaAmountFormatter.swift */; }; @@ -672,15 +698,6 @@ 6181C570B638E07D8B369CDB /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; 61E0BCA4B860A2578137B28F /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 62FDC661BC3BDD5C2EA676BA /* ContactAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */; }; - 5ACSH0002F9A0000000000S2 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; - 5ACTCSTV020000000000SV02 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; - 5ACTCSTC020000000000SC02 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; - 5ACTCSPR020000000000SP02 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; - 5ACTCARD0200000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; - 5ACTCAVM0200000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; - 5ACTDAYG020000000000DG02 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; - 5ACTCROW0200000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; - 5ACTCEVR020000000000EV02 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; 630227C0414D5C74BA95BB8F /* BIP70Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94606903417F75BE99D4B31C /* BIP70Error.swift */; }; 63869E343191C55BDBBEED72 /* SwiftDashSDKInsightClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7BE8E4519203200CDFB460 /* SwiftDashSDKInsightClient.swift */; }; 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; @@ -689,6 +706,7 @@ 6715F4CB33E5F472FEC43B7E /* DWParsedPaymentURI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DC91C11E382E22D1C17F05 /* DWParsedPaymentURI.swift */; }; 6C4AA7FD67FEE5A9E5CF9781 /* WalletsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76AB0835FF44EA88F36991E0 /* WalletsViewModel.swift */; }; 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 5APLTSB10000000000000000 /* PaymentsLandingTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */; }; 6FAE60AB81526ADAE67D1BE7 /* BIP70PaymentService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9155233A128831C0ABF18A3 /* BIP70PaymentService+App.swift */; }; 6FD1512E7774A997C1F8466B /* SwapMenuStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286D4D1CADBBD51F09BE8E8 /* SwapMenuStyles.swift */; }; 6FFC566DBB2CFAA3DB3CEFB4 /* SwiftDashSDKPhraseRepairer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CA14DC8416012B813B5E2C /* SwiftDashSDKPhraseRepairer.swift */; }; @@ -892,9 +910,7 @@ 75D5D5342CC928630049ED7B /* UIHostingController+DashWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D5D5322CC9285B0049ED7B /* UIHostingController+DashWallet.swift */; }; 75D5F3CE191EC270004AB296 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 75D5F3CD191EC270004AB296 /* main.m */; }; 75D657672DF579F300ACE570 /* TransactionFilterDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */; }; - 5ACTTFB0020000000000FB02 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 75D657682DF579F300ACE570 /* TransactionFilterDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */; }; - 5ACTTFB0030000000000FB03 /* TransactionFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */; }; 75D6D8E42D96A6DB00E40A6D /* StubTransactionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */; }; 75D6D8E52D96A6DB00E40A6D /* StubTransactionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */; }; 75D9EBB82DE5CC3A009416A2 /* AddGiftCardsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D9EBB72DE5CC3A009416A2 /* AddGiftCardsTable.swift */; }; @@ -966,6 +982,7 @@ 8A7607606A594C6E99A2FAC7 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; 8AF94168DE3494EAF0811D77 /* PaymentURIBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E3F6CC617DA824F467AADC /* PaymentURIBuilder.swift */; }; 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */; }; + 5APLTSB20000000000000000 /* PaymentsLandingTabSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */; }; 8DD6758D2AC6F5BB23D5504D /* WalletSendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */; }; 8DF3D24EE3B247B79AE9CAB5 /* BuySellPortalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */; }; 8E3AF21ED19AEC0FD89A63FC /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; @@ -995,6 +1012,7 @@ 9B54AF317E7728C18646F702 /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */; }; 9B627F0FCC93D887BAB3E9A9 /* BIP70PaymentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D58056D713E4B50DAE0F24B /* BIP70PaymentService.swift */; }; 9E39B6CDE39A9CA4E74ADF3B /* IdentitiesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528594007157A56ABDEAD552 /* IdentitiesScreen.swift */; }; + 9E51F3B86C207A15D2B84E60 /* DashPayUserLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */; }; 9E8A618E9D824AFFEDAC4F0A /* BIP70InteractiveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36BCF99A4D30077C9C9B0EB /* BIP70InteractiveCoordinator.swift */; }; A0795C9369A1402498C00F69 /* DerivationPathKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC1F4DB87C442BEABDF0DB2 /* DerivationPathKeysView.swift */; }; A1060800B100000000000001 /* ExplorePointOfUse+PreviewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1060800F100000000000001 /* ExplorePointOfUse+PreviewMock.swift */; }; @@ -1008,19 +1026,14 @@ A15ED0B9E7AAF29C694E4E16 /* DWIdentityAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33FBA94DA84A9EE303FF48B4 /* DWIdentityAuthorizer.swift */; }; A15ED0B9E7AAF29C694E4E17 /* DWIdentityAuthorizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33FBA94DA84A9EE303FF48B4 /* DWIdentityAuthorizer.swift */; }; A17EED0126C7000000000001 /* WalletWipeSerialExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */; }; + A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; + A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; A22906F4DFABDDE03929141C /* AcceptPillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC166485A7B75F489382900A /* AcceptPillButton.swift */; }; A2BBDCD2F99F98CF729EB4B3 /* ContactsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA7FBAB805983E9BDA442D6 /* ContactsCard.swift */; }; A361573C34CF9107E6B8A221 /* SwiftDashSDKWalletCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697D56A525CBF142F4401FE /* SwiftDashSDKWalletCreator.swift */; }; A365C2F95472285830E6CDEB /* HomeUsernameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E55DD28C4EBE049216291A2 /* HomeUsernameRow.swift */; }; A3ED42ED80F32AA5CA530A40 /* DamerauLevenshtein.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CE60A95629A2883710A75C /* DamerauLevenshtein.swift */; }; A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */; }; - 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF06100000000000000000 /* SyncGateNote.swift */; }; - 5ASB05200000000000000000 /* SendChainStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF05100000000000000000 /* SendChainStyle.swift */; }; - 5ASB04200000000000000000 /* SendAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF04100000000000000000 /* SendAddressSummary.swift */; }; - 5ASB03200000000000000000 /* SendConfirmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF03100000000000000000 /* SendConfirmSheet.swift */; }; - 5ASB02200000000000000000 /* ExternalSendAmountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */; }; - 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF01100000000000000000 /* SendSourceScreen.swift */; }; - 5ASB00200000000000000000 /* SendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ASF00100000000000000000 /* SendScreen.swift */; }; A6AAB271A1ADA69C272DA1D4 /* PinPromptPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D385FC174E5B887CF3398CCD /* PinPromptPresenter.swift */; }; A6DB8889C5594B1988BDE0CE /* SwapCoinIconLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F0351CAD174EEB8E64012A /* SwapCoinIconLoader.swift */; }; A72BF1D1A1F3F067215CE04C /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A52F6A39C711B1E955B7E3A /* QRCodeGenerator.swift */; }; @@ -1927,15 +1940,6 @@ EE0000042DUMMYID001234567 /* PercentageFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000052DUMMYID001234567 /* PercentageFormatter.swift */; }; EE0000062DUMMYID001234567 /* PercentageFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000052DUMMYID001234567 /* PercentageFormatter.swift */; }; EE4D60EF9BDBB7CC4F7924C7 /* ContactAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */; }; - 5ACSH0002F9A0000000000S3 /* ContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */; }; - 5ACTCSTV030000000000SV03 /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */; }; - 5ACTCSTC030000000000SC03 /* ContactSettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */; }; - 5ACTCSPR030000000000SP03 /* ContactSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */; }; - 5ACTCARD0300000000000000 /* ContactActivityCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCARD0100000000000000 /* ContactActivityCard.swift */; }; - 5ACTCAVM0300000000000000 /* ContactActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */; }; - 5ACTDAYG030000000000DG03 /* DayGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTDAYG010000000000DG01 /* DayGrouping.swift */; }; - 5ACTCROW0300000000000000 /* ContactActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCROW0100000000000000 /* ContactActivityRow.swift */; }; - 5ACTCEVR030000000000EV03 /* ContactActivityEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */; }; EE5F3D5C458E92B69D14BFD8 /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6615F1246B5935365B05BD /* NavigationBar.swift */; }; EF053C0D81A007CC015EB3EE /* InternalTransferViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7249AC40404B20F2AEE590F1 /* InternalTransferViewModel.swift */; }; EF8FE439EB96236940DFBDE7 /* SwiftDashSDKTransactionSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */; }; @@ -1978,27 +1982,6 @@ RC00A0052FA0000000000001 /* ExploreMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */; }; RC00C0012FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; RC00C0022FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; - BCB10EF569912077058577E4 /* ContestedNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1426BD13E794C788646055 /* ContestedNamesService.swift */; }; - EB88594208EAD0E9E1FA752F /* MasternodeVoterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */; }; - F29CC1CE29F3544F9E2F9BE7 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; - 25C2103A3D2448AB40AB8485 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - D0C564276BD8B9770F0E0499 /* ContestDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */; }; - 1FED991A9D2D236582CE3C99 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; - 8F0D34E5B93E7D2EB9062394 /* ContestedNamesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1426BD13E794C788646055 /* ContestedNamesService.swift */; }; - F5D223F887ABBFCEB478816F /* MasternodeVoterRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */; }; - 5A5F8C183D2C037FB350ACC9 /* MasternodeVoteCaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */; }; - 12118430106B8A5EBB2FBC60 /* UsernameVotingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */; }; - F34816414D829A45E0762757 /* ContestDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */; }; - AD24D88215A4E51186112B34 /* CastVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */; }; - 6181C570B638E07D8B369CDB /* VotingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75A8C1682AE6A1AC0042256E /* VotingViewModel.swift */; }; - E503EDEA94E656CAE70E5A11 /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; - D19A81C4F6103AC513D737AA /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; - 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; - A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; - A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; - 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; - 7062FCF792F292D84A0B819B /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; - 553696A24F315D361BC86231 /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2081,7 +2064,6 @@ 0286D4D1CADBBD51F09BE8E8 /* SwapMenuStyles.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwapMenuStyles.swift; sourceTree = ""; }; 0448A6769BCF20987BE9960C /* DiagnosticLogExporter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiagnosticLogExporter.swift; sourceTree = ""; }; 05723F61BFF76C3BB9EF3CDC /* InternalTransferConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InternalTransferConfirmSheet.swift; sourceTree = ""; }; - 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactPreviewHeader.swift; sourceTree = ""; }; 067B8024942F1481CD9B9CC1 /* PaymentNetworkResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentNetworkResolver.swift; sourceTree = ""; }; 0755F840BCC1E0754D807B04 /* libPods-TodayExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-TodayExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 080B9ECCDC82A7B7FF4039C6 /* ScriptAddressCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ScriptAddressCodec.swift; sourceTree = ""; }; @@ -2627,7 +2609,6 @@ 2AFF01DA243F4559003718DC /* DWDPRegistrationStatus.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DWDPRegistrationStatus.m; sourceTree = ""; }; 2C4FDA5A52AB80D7F349A7AF /* WalletSendService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletSendService.swift; sourceTree = ""; }; 2CE7E8766B8C1601A85AAB4F /* ContactItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactItem.swift; sourceTree = ""; }; - 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayUserLink.swift; sourceTree = ""; }; 2DCBA057E553F81F54ACE6E1 /* StorageRecordDetailViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageRecordDetailViews.swift; sourceTree = ""; }; 2F134B2E67D68A97892FD760 /* WalletsScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletsScreen.swift; sourceTree = ""; }; 31271807D89A63FB9413C936 /* SwiftDashSDKTransactionSender.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKTransactionSender.swift; sourceTree = ""; }; @@ -2837,18 +2818,10 @@ 48D8BC6623C81DD3F008A668 /* DWPreviewSeedPhraseModel+Mnemonic.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "DWPreviewSeedPhraseModel+Mnemonic.swift"; sourceTree = ""; }; 4BA7FBAB805983E9BDA442D6 /* ContactsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactsCard.swift; sourceTree = ""; }; 4C4833BE986D59F69A555C5F /* PaymentProtocolMessages.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentProtocolMessages.swift; sourceTree = ""; }; + 4D0A7C21B5E39F04A11C2D57 /* DashPayUserLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DashPayUserLink.swift; sourceTree = ""; }; 4E55DD28C4EBE049216291A2 /* HomeUsernameRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeUsernameRow.swift; sourceTree = ""; }; 4E9C4B39B015CD9D8AD4741D /* SwiftDashSDKWalletRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKWalletRuntime.swift; sourceTree = ""; }; 4FE98321C13F41C2B381FD01 /* ContactAvatarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactAvatarView.swift; sourceTree = ""; }; - 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheet.swift; sourceTree = ""; }; - 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; - 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsCard.swift; sourceTree = ""; }; - 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheetPresenter.swift; sourceTree = ""; }; - 5ACTCARD0100000000000000 /* ContactActivityCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityCard.swift; sourceTree = ""; }; - 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityViewModel.swift; sourceTree = ""; }; - 5ACTDAYG010000000000DG01 /* DayGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DayGrouping.swift; sourceTree = ""; }; - 5ACTCROW0100000000000000 /* ContactActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityRow.swift; sourceTree = ""; }; - 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityEventRow.swift; sourceTree = ""; }; 514AEA122FB324FA00F9B54C /* SwapConvertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConvertView.swift; sourceTree = ""; }; 514AEA4A2FB33B4300F9B54C /* SwapConvertHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapConvertHostingController.swift; sourceTree = ""; }; 514AEAD42FB34F8600F9B54C /* OrderPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderPreviewView.swift; sourceTree = ""; }; @@ -2900,11 +2873,30 @@ 5A1EC0FE2E29A30000000001 /* CoinJoinMoveFundsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinJoinMoveFundsSheet.swift; sourceTree = ""; }; 5A58BF094C42E410DD1A71B6 /* PlatformSyncStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformSyncStatusScreen.swift; sourceTree = ""; }; 5AACV0002F9A0000000000S1 /* AddContactViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddContactViewModel.swift; sourceTree = ""; }; + 5ACPHD0002F9A0000000000S1 /* ContactPreviewHeader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactPreviewHeader.swift; sourceTree = ""; }; + 5ACSH0002F9A0000000000S1 /* ContactSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheet.swift; sourceTree = ""; }; + 5ACTCARD0100000000000000 /* ContactActivityCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityCard.swift; sourceTree = ""; }; + 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityViewModel.swift; sourceTree = ""; }; + 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityEventRow.swift; sourceTree = ""; }; + 5ACTCROW0100000000000000 /* ContactActivityRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactActivityRow.swift; sourceTree = ""; }; + 5ACTCSPR010000000000SP01 /* ContactSheetPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSheetPresenter.swift; sourceTree = ""; }; + 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsCard.swift; sourceTree = ""; }; + 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = ""; }; + 5ACTDAYG010000000000DG01 /* DayGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DayGrouping.swift; sourceTree = ""; }; + 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MyDashPayUserQRSheet.swift; sourceTree = ""; }; + 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionFilterButton.swift; sourceTree = ""; }; 5AGOV0002F9A0000000000S1 /* GovernanceMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuScreen.swift; sourceTree = ""; }; 5AGOV0002F9A0000000000V1 /* GovernanceMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GovernanceMenuViewModel.swift; sourceTree = ""; }; 5AMMS0002F9A0000000000S1 /* MainMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuScreen.swift; sourceTree = ""; }; 5ANVM0002F9A0000000000S1 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = ""; }; 5APCV0002F9A0000000000S1 /* PayContactViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayContactViewModel.swift; sourceTree = ""; }; + 5ASF00100000000000000000 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; + 5ASF01100000000000000000 /* SendSourceScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendSourceScreen.swift; sourceTree = ""; }; + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalSendAmountScreen.swift; sourceTree = ""; }; + 5ASF03100000000000000000 /* SendConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendConfirmSheet.swift; sourceTree = ""; }; + 5ASF04100000000000000000 /* SendAddressSummary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendAddressSummary.swift; sourceTree = ""; }; + 5ASF05100000000000000000 /* SendChainStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendChainStyle.swift; sourceTree = ""; }; + 5ASF06100000000000000000 /* SyncGateNote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncGateNote.swift; sourceTree = ""; }; 5B30DA60B985C3CC3ED05B64 /* DWCurrentUserIdentityInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWCurrentUserIdentityInfo.swift; sourceTree = ""; }; 5C3839568283FDC3A6933824 /* DWRegistrationPhaseAdapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWRegistrationPhaseAdapter.swift; sourceTree = ""; }; 5CBC0F417B9E8DE631F9DE2A /* Pods-DashWalletTests.testnet.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletTests.testnet.xcconfig"; path = "Target Support Files/Pods-DashWalletTests/Pods-DashWalletTests.testnet.xcconfig"; sourceTree = ""; }; @@ -3053,7 +3045,6 @@ 75D5F3CF191EC270004AB296 /* DashWallet-Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "DashWallet-Prefix.pch"; sourceTree = ""; }; 75D5F3E5191EC270004AB296 /* DashWalletTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashWalletTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 75D657662DF579F300ACE570 /* TransactionFilterDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionFilterDialog.swift; sourceTree = ""; }; - 5ACTTFB0010000000000FB01 /* TransactionFilterButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransactionFilterButton.swift; sourceTree = ""; }; 75D6D8E32D96A6D500E40A6D /* StubTransactionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubTransactionSource.swift; sourceTree = ""; }; 75D9EBB72DE5CC3A009416A2 /* AddGiftCardsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGiftCardsTable.swift; sourceTree = ""; }; 75D9EBBB2DE5CD8B009416A2 /* GiftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftCard.swift; sourceTree = ""; }; @@ -3100,20 +3091,12 @@ 7B56B94951BA51EAC9E0A6E1 /* libPods-DashWalletScreenshotsUITests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletScreenshotsUITests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7B94B3697D7742BFA991B5CC /* BuySellPortalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuySellPortalScreen.swift; sourceTree = ""; }; 7E62262597A8B22870184A36 /* SDKIdentityProfileSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKIdentityProfileSheet.swift; sourceTree = ""; }; - 5ACTMYQR010000000000QR01 /* MyDashPayUserQRSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MyDashPayUserQRSheet.swift; sourceTree = ""; }; 7FA744C4A6023E26A3B5B860 /* DWLogger.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = DWLogger.h; sourceTree = ""; }; 82926C69F3BB675BEF1A1FFD /* DWProfileUpdateCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWProfileUpdateCoordinator.swift; sourceTree = ""; }; 8571065059CF5168F45C86E4 /* Pods-dashpay.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dashpay.testflight.xcconfig"; path = "Target Support Files/Pods-dashpay/Pods-dashpay.testflight.xcconfig"; sourceTree = ""; }; 8C25F44380CDB841AF55D3FF /* DarkCoinMessageFraming.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DarkCoinMessageFraming.swift; sourceTree = ""; }; 8C7313C7337036FEC54729E4 /* BIP70URI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BIP70URI.swift; sourceTree = ""; }; 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendViewModel.swift; sourceTree = ""; }; - 5ASF06100000000000000000 /* SyncGateNote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncGateNote.swift; sourceTree = ""; }; - 5ASF05100000000000000000 /* SendChainStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendChainStyle.swift; sourceTree = ""; }; - 5ASF04100000000000000000 /* SendAddressSummary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendAddressSummary.swift; sourceTree = ""; }; - 5ASF03100000000000000000 /* SendConfirmSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendConfirmSheet.swift; sourceTree = ""; }; - 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalSendAmountScreen.swift; sourceTree = ""; }; - 5ASF01100000000000000000 /* SendSourceScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendSourceScreen.swift; sourceTree = ""; }; - 5ASF00100000000000000000 /* SendScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendScreen.swift; sourceTree = ""; }; 91F0B8B82F3FAB7C00E713AE /* KeysOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeysOverviewView.swift; sourceTree = ""; }; 94606903417F75BE99D4B31C /* BIP70Error.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BIP70Error.swift; sourceTree = ""; }; 96CB7E093E994D2391D36DEC /* SelfSizingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfSizingSheet.swift; sourceTree = ""; }; @@ -3130,6 +3113,7 @@ A11E57A70003C0DE0003B003 /* ClaimInvitationScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClaimInvitationScreen.swift; sourceTree = ""; }; A11E57A70009C0DE0009B009 /* SyncingAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncingAlertView.swift; sourceTree = ""; }; A17EED0126C7000000000002 /* WalletWipeSerialExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletWipeSerialExecutorTests.swift; sourceTree = ""; }; + A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = ""; }; A59D5720437F8A4137605940 /* PaymentRequestVerifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentRequestVerifier.swift; sourceTree = ""; }; A62C70E4EBB7458C362B7669 /* InternalTransferScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InternalTransferScreen.swift; sourceTree = ""; }; A876A874736D422F781F797F /* SwiftDashSDKReceiveAddressReader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKReceiveAddressReader.swift; sourceTree = ""; }; @@ -3626,6 +3610,7 @@ E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformAddressSyncCoordinator.swift; sourceTree = ""; }; E7333F2D05D0DD2D4373FF9B /* StorageExplorerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageExplorerView.swift; sourceTree = ""; }; E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingScreen.swift; sourceTree = ""; }; + 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentsLandingTabSelector.swift; sourceTree = ""; }; E83D8CEAFF69B6D666C02116 /* Pods-WatchApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchApp.release.xcconfig"; path = "Target Support Files/Pods-WatchApp/Pods-WatchApp.release.xcconfig"; sourceTree = ""; }; E90D495F8DF1E8EE8A84C8E8 /* SwiftDashSDKSPVStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKSPVStatusScreen.swift; sourceTree = ""; }; E950113F9E258B2F1CC0D50C /* DWContestedNameStatusService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DWContestedNameStatusService.swift; sourceTree = ""; }; @@ -3688,16 +3673,6 @@ RC00A0032FA0000000000001 /* TopIntro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopIntro.swift; sourceTree = ""; }; RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreMenuScreen.swift; sourceTree = ""; }; RC00C0032FA0000000000001 /* CSVExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExportSheet.swift; sourceTree = ""; }; - FC1426BD13E794C788646055 /* ContestedNamesService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestedNamesService.swift; sourceTree = ""; }; - 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoterRegistry.swift; sourceTree = ""; }; - 23AEFB2398EDA01C69E5A8F1 /* MasternodeVoteCaster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoteCaster.swift; sourceTree = ""; }; - 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameVotingScreen.swift; sourceTree = ""; }; - 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestDetailScreen.swift; sourceTree = ""; }; - FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = ""; }; - ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameRequestStatusScreen.swift; sourceTree = ""; }; - C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BulkVoteSheet.swift; sourceTree = ""; }; - A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = ""; }; - 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteHistoryDAO.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -4973,29 +4948,6 @@ path = ControllerCollection; sourceTree = ""; }; - 5ASGRPS10000000000000000 /* Send */ = { - isa = PBXGroup; - children = ( - 5ASGRPC10000000000000000 /* Components */, - 5ASF00100000000000000000 /* SendScreen.swift */, - 5ASF01100000000000000000 /* SendSourceScreen.swift */, - 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */, - 5ASF03100000000000000000 /* SendConfirmSheet.swift */, - 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, - ); - path = Send; - sourceTree = ""; - }; - 5ASGRPC10000000000000000 /* Components */ = { - isa = PBXGroup; - children = ( - 5ASF04100000000000000000 /* SendAddressSummary.swift */, - 5ASF05100000000000000000 /* SendChainStyle.swift */, - 5ASF06100000000000000000 /* SyncGateNote.swift */, - ); - path = Components; - sourceTree = ""; - }; 2A8B9E5722FF6F2900FF8653 /* Pay */ = { isa = PBXGroup; children = ( @@ -6805,6 +6757,35 @@ path = PlatformAddress; sourceTree = ""; }; + 5ACTGRPA0100000000000000 /* Activity */ = { + isa = PBXGroup; + children = ( + 5ACTGRPA0200000000000000 /* Components */, + 5ACTCARD0100000000000000 /* ContactActivityCard.swift */, + 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */, + 5ACTDAYG010000000000DG01 /* DayGrouping.swift */, + ); + path = Activity; + sourceTree = ""; + }; + 5ACTGRPA0200000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5ACTCROW0100000000000000 /* ContactActivityRow.swift */, + 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5ACTGSET010000000000GS01 /* Settings */ = { + isa = PBXGroup; + children = ( + 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */, + 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */, + ); + path = Settings; + sourceTree = ""; + }; 5AGOV0002F9A0000000000G0 /* Governance */ = { isa = PBXGroup; children = ( @@ -6814,6 +6795,29 @@ path = Governance; sourceTree = ""; }; + 5ASGRPC10000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5ASF04100000000000000000 /* SendAddressSummary.swift */, + 5ASF05100000000000000000 /* SendChainStyle.swift */, + 5ASF06100000000000000000 /* SyncGateNote.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5ASGRPS10000000000000000 /* Send */ = { + isa = PBXGroup; + children = ( + 5ASGRPC10000000000000000 /* Components */, + 5ASF00100000000000000000 /* SendScreen.swift */, + 5ASF01100000000000000000 /* SendSourceScreen.swift */, + 5ASF02100000000000000000 /* ExternalSendAmountScreen.swift */, + 5ASF03100000000000000000 /* SendConfirmSheet.swift */, + 8E6FAA6524ACE5F315B57AC3 /* SendViewModel.swift */, + ); + path = Send; + sourceTree = ""; + }; 622476CB6BA5E7F2315CC2AF /* Contacts */ = { isa = PBXGroup; children = ( @@ -6833,9 +6837,18 @@ path = Codec; sourceTree = ""; }; + 5APLTSGRP000000000000000 /* Components */ = { + isa = PBXGroup; + children = ( + 5APLTSF10000000000000000 /* PaymentsLandingTabSelector.swift */, + ); + path = Components; + sourceTree = ""; + }; 6916981A49557073ABA80EAB /* Landing */ = { isa = PBXGroup; children = ( + 5APLTSGRP000000000000000 /* Components */, E735446F9E918618E97EDD38 /* PaymentsLandingScreen.swift */, FE64C10DA9C338D408982EBE /* PaymentsLandingViewModel.swift */, B401E660599FEF6B18087F91 /* PaymentsLandingHostingController.swift */, @@ -7841,35 +7854,6 @@ path = DashPay; sourceTree = ""; }; - 5ACTGRPA0100000000000000 /* Activity */ = { - isa = PBXGroup; - children = ( - 5ACTGRPA0200000000000000 /* Components */, - 5ACTCARD0100000000000000 /* ContactActivityCard.swift */, - 5ACTCAVM0100000000000000 /* ContactActivityViewModel.swift */, - 5ACTDAYG010000000000DG01 /* DayGrouping.swift */, - ); - path = Activity; - sourceTree = ""; - }; - 5ACTGRPA0200000000000000 /* Components */ = { - isa = PBXGroup; - children = ( - 5ACTCROW0100000000000000 /* ContactActivityRow.swift */, - 5ACTCEVR010000000000EV01 /* ContactActivityEventRow.swift */, - ); - path = Components; - sourceTree = ""; - }; - 5ACTGSET010000000000GS01 /* Settings */ = { - isa = PBXGroup; - children = ( - 5ACTCSTC010000000000SC01 /* ContactSettingsCard.swift */, - 5ACTCSTV010000000000SV01 /* ContactSettingsViewModel.swift */, - ); - path = Settings; - sourceTree = ""; - }; C943B3502A40A54500AF23C5 /* Contacts */ = { isa = PBXGroup; children = ( @@ -10161,7 +10145,6 @@ E16E2BA180551E3B6661B9F4 /* PlatformCreditsFormatter.swift in Sources */, F1F870569B75C5B1452C5D65 /* ChainNetworkToggle.swift in Sources */, A72BF1D1A1F3F067215CE04C /* QRCodeGenerator.swift in Sources */, - 25C88271468A4CD23F34A6E9 /* SendScreen.swift in Sources */, C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */, 15F9C3B621487500E2919619 /* SendViewModel.swift in Sources */, 5ASB06100000000000000000 /* SyncGateNote.swift in Sources */, @@ -10172,6 +10155,7 @@ 5ASB01100000000000000000 /* SendSourceScreen.swift in Sources */, 5ASB00100000000000000000 /* SendScreen.swift in Sources */, 8B8898971DC24152E171DABC /* PaymentsLandingScreen.swift in Sources */, + 5APLTSB10000000000000000 /* PaymentsLandingTabSelector.swift in Sources */, 12DE15416F9F7A0B8749CAF1 /* PaymentsLandingViewModel.swift in Sources */, 908AB7EFCC69D3828BBD70A8 /* PaymentsLandingHostingController.swift in Sources */, 0EB4679BC135B464D90CDC8B /* TransferTimingSheet.swift in Sources */, @@ -11192,7 +11176,6 @@ 3705C73F15BCD91AF70940B9 /* PlatformCreditsFormatter.swift in Sources */, 56F5C9FD51AE5508BB5E579B /* ChainNetworkToggle.swift in Sources */, 9B54AF317E7728C18646F702 /* QRCodeGenerator.swift in Sources */, - C5906424C3D379AF0CB83370 /* SendScreen.swift in Sources */, 7C8C17249DC2DD0ECB3F2720 /* SendScreenViewController.swift in Sources */, A5C8B5B00075FB8E84009327 /* SendViewModel.swift in Sources */, 5ASB06200000000000000000 /* SyncGateNote.swift in Sources */, @@ -11203,6 +11186,7 @@ 5ASB01200000000000000000 /* SendSourceScreen.swift in Sources */, 5ASB00200000000000000000 /* SendScreen.swift in Sources */, 6D5D6EFD5F40D11B2FB6E8A1 /* PaymentsLandingScreen.swift in Sources */, + 5APLTSB20000000000000000 /* PaymentsLandingTabSelector.swift in Sources */, C6138156317BAF449AA76861 /* PaymentsLandingViewModel.swift in Sources */, 72F0556F329AE232966A8B4A /* PaymentsLandingHostingController.swift in Sources */, 1C2999FF1FC854E7DB138250 /* TransferTimingSheet.swift in Sources */, @@ -13358,7 +13342,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/dashpay/DashUIKit"; requirement = { - branch = master; + branch = "feat/bottom-sheet-background"; kind = branch; }; }; diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift new file mode 100644 index 000000000..4112d547d --- /dev/null +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift @@ -0,0 +1,97 @@ +// +// PaymentsLandingTabSelector.swift +// DashWallet +// +// The Receive / Internal / Send switcher at the top of the payments landing. +// + +import SwiftUI +import DashUIKit + +/// Segmented switcher for the landing's hero tabs. +/// +/// Takes the tabs and a binding rather than the view model: which tabs are +/// offered is the presentation's decision — the full landing shows all three, +/// the balance-row receive sheet narrows to two — and this view has no reason +/// to know why. +struct PaymentsLandingTabSelector: View { + + private enum Layout { + static let spacing: CGFloat = 4 + static let itemSpacing: CGFloat = 4 + static let itemPadding: CGFloat = 10 + static let itemCornerRadius: CGFloat = 8 + static let cornerRadius: CGFloat = 10 + } + + let tabs: [PaymentsLandingTab] + @Binding var selection: PaymentsLandingTab + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: Layout.spacing) { + ForEach(tabs) { tab in + Button { selection = tab } label: { + item(for: tab) + } + } + } + .padding(Layout.spacing) + .background(Color.dash.secondaryBackground) + .cornerRadius(Layout.cornerRadius) + } + + private func item(for tab: PaymentsLandingTab) -> some View { + let isSelected = selection == tab + return VStack(spacing: Layout.itemSpacing) { + Image(systemName: tab.iconSystemName) + .font(.system(size: 16, weight: .semibold)) + Text(tab.title) + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(isSelected ? Color.dash.primaryText : Color.dash.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, Layout.itemPadding) + .background( + RoundedRectangle(cornerRadius: Layout.itemCornerRadius) + // 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(isSelected + ? (colorScheme == .dark ? Color.dash.whiteAlpha20 : Color.dash.white) + : Color.clear) + .shadow(color: isSelected ? Color.dash.shadow : .clear, radius: 2, x: 0, y: 1)) + } +} + +#if DEBUG + +private struct TabSelectorHost: View { + let tabs: [PaymentsLandingTab] + @State var selection: PaymentsLandingTab + + var body: some View { + PaymentsLandingTabSelector(tabs: tabs, selection: $selection) + .padding() + .background(Color.dash.primaryBackground) + } +} + +#Preview("All three") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send) +} + +/// The balance-row receive sheet offers only two — the pill has to fill the +/// wider slots without the layout shifting. +#Preview("Narrowed") { + TabSelectorHost(tabs: [.receive, .internalTransfer], selection: .receive) +} + +#Preview("Dark") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .internalTransfer) + .preferredColorScheme(.dark) +} + +#endif diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index c98ca2d82..a4496b07b 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -9,7 +9,6 @@ import UIKit struct PaymentsLandingScreen: View { @ObservedObject var viewModel: PaymentsLandingViewModel - @Environment(\.colorScheme) private var colorScheme var onClose: () -> Void var onCopyAddress: () -> Void @@ -47,7 +46,9 @@ struct PaymentsLandingScreen: View { header } - tabSelector + PaymentsLandingTabSelector( + tabs: viewModel.visibleTabs, + selection: $viewModel.activeTab) .padding(.horizontal, 20) .padding(.top, showsHeader ? 0 : 12) @@ -128,40 +129,6 @@ struct PaymentsLandingScreen: View { // MARK: - Tab selector - private var tabSelector: some View { - HStack(spacing: 4) { - ForEach(viewModel.visibleTabs) { tab in - Button(action: { viewModel.activeTab = tab }) { - VStack(spacing: 4) { - Image(systemName: tab.iconSystemName) - .font(.system(size: 16, weight: .semibold)) - Text(tab.title) - .font(.system(size: 13, weight: .medium)) - } - .foregroundColor(viewModel.activeTab == tab ? Color.dash.primaryText : Color.dash.secondaryText) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 8) - // Selected pill: a solid white raised card in light mode; a - // translucent light fill in dark so the primaryText label stays - // legible (pure white would be invisible on the dark selector). - // Mirrors the app's SegmentedControl selected-fill treatment. - .fill(viewModel.activeTab == tab - ? (colorScheme == .dark ? Color.dash.whiteAlpha20 : Color.dash.white) - : Color.clear) - .shadow( - color: viewModel.activeTab == tab - ? Color.dash.shadow : .clear, - radius: 2, x: 0, y: 1)) - } - } - } - .padding(4) - .background(Color.dash.secondaryBackground) - .cornerRadius(10) - } - // MARK: - Receive private var receiveContent: some View { From d79d017cf69dc823b94884e61d0bc5a3a6e7ca06 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:54:28 +0300 Subject: [PATCH 15/19] feat(payments): rebuild the landing tab selector to the design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was a segmented control: three equal cells in a filled box, the selected one a white pill with a shadow, all three labelled and all three the same size. The design is a row of capsules. The active tab takes the space that is left, fills with its own colour — green for Receive, light blue for Internal transfer, blue for Send — and is the only one that carries a label; the other two shrink to a fixed 60pt icon. Colours are existing tokens: gray300Alpha20, green, lightBlue, blue map straight onto the ones the design names. The glyphs are not — the icon set has an arrow-down but no arrow-up, and the design's are solid variants — so the three are exported from Figma as template assets, with fill-opacity stripped since a baked one survives tinting and washes the glyph out. Each keeps its own width and height: the arrows and the transfer glyph have different aspect ratios and a shared square frame would squash them. Icon and accent move onto PaymentsLandingTab, next to the title, so a tab describes itself in one place. Internal's label becomes "Internal transfer", which the screen header already said — and the header now reads the tab's own title rather than repeating the switch. Co-Authored-By: Claude Opus 5 --- .../Contents.json | 16 ++++ .../payments-option-arrow-down.svg | 3 + .../Contents.json | 16 ++++ .../payments-option-arrow-up.svg | 3 + .../Contents.json | 16 ++++ .../payments-option-transfer.svg | 5 + .../PaymentsLandingTabSelector.swift | 96 ++++++++++++------- .../Landing/PaymentsLandingScreen.swift | 10 +- .../Landing/PaymentsLandingViewModel.swift | 23 ++++- 9 files changed, 140 insertions(+), 48 deletions(-) create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json create mode 100644 DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json new file mode 100644 index 000000000..d7b09bcee --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-arrow-down.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg new file mode 100644 index 000000000..a5b1fb320 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-down.imageset/payments-option-arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json new file mode 100644 index 000000000..59bdabb88 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-arrow-up.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg new file mode 100644 index 000000000..ba02deb24 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json new file mode 100644 index 000000000..b867bd34b --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "payments-option-transfer.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg new file mode 100644 index 000000000..65e37bdb2 --- /dev/null +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-transfer.imageset/payments-option-transfer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift index 4112d547d..2354c032b 100644 --- a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift @@ -2,13 +2,16 @@ // PaymentsLandingTabSelector.swift // DashWallet // -// The Receive / Internal / Send switcher at the top of the payments landing. +// The Receive / Internal transfer / Send switcher at the top of the payments +// landing. // import SwiftUI import DashUIKit -/// Segmented switcher for the landing's hero tabs. +/// A row of pills, one per tab. The active one carries the tab's colour and +/// its label and takes the space that is left; the others shrink to their +/// icon. /// /// Takes the tabs and a binding rather than the view model: which tabs are /// offered is the presentation's decision — the full landing shows all three, @@ -17,52 +20,64 @@ import DashUIKit struct PaymentsLandingTabSelector: View { private enum Layout { - static let spacing: CGFloat = 4 - static let itemSpacing: CGFloat = 4 - static let itemPadding: CGFloat = 10 - static let itemCornerRadius: CGFloat = 8 - static let cornerRadius: CGFloat = 10 + static let spacing: CGFloat = 8 + static let contentGap: CGFloat = 8 + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 10 + /// The inactive pill holds its icon and nothing else, so it is sized + /// rather than left to hug — otherwise the three widths would shift + /// with each glyph. + static let collapsedWidth: CGFloat = 60 + /// Height of the box the glyph is centred in, whatever its own size. + static let glyphBoxHeight: CGFloat = 20 } let tabs: [PaymentsLandingTab] @Binding var selection: PaymentsLandingTab - @Environment(\.colorScheme) private var colorScheme - var body: some View { HStack(spacing: Layout.spacing) { ForEach(tabs) { tab in Button { selection = tab } label: { - item(for: tab) + pill(for: tab) } + .buttonStyle(.plain) } } - .padding(Layout.spacing) - .background(Color.dash.secondaryBackground) - .cornerRadius(Layout.cornerRadius) } - private func item(for tab: PaymentsLandingTab) -> some View { + @ViewBuilder + private func pill(for tab: PaymentsLandingTab) -> some View { let isSelected = selection == tab - return VStack(spacing: Layout.itemSpacing) { - Image(systemName: tab.iconSystemName) - .font(.system(size: 16, weight: .semibold)) - Text(tab.title) - .font(.system(size: 13, weight: .medium)) + + HStack(spacing: Layout.contentGap) { + icon(for: tab) + + if isSelected { + Text(tab.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.dash.whiteText) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } } - .foregroundColor(isSelected ? Color.dash.primaryText : Color.dash.secondaryText) - .frame(maxWidth: .infinity) - .padding(.vertical, Layout.itemPadding) - .background( - RoundedRectangle(cornerRadius: Layout.itemCornerRadius) - // 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(isSelected - ? (colorScheme == .dark ? Color.dash.whiteAlpha20 : Color.dash.white) - : Color.clear) - .shadow(color: isSelected ? Color.dash.shadow : .clear, radius: 2, x: 0, y: 1)) + .padding(.horizontal, Layout.horizontalPadding) + .padding(.vertical, Layout.verticalPadding) + // Selected takes what's left; the rest keep the fixed icon width. + .frame(maxWidth: isSelected ? .infinity : Layout.collapsedWidth) + .background(isSelected ? tab.accent : Color.dash.gray300Alpha20) + .clipShape(Capsule()) + } + + private func icon(for tab: PaymentsLandingTab) -> some View { + // Both dimensions are set from the design: the three glyphs have + // different aspect ratios, and a single square frame would squash + // the arrows. + Image(tab.icon.name) + .resizable() + .frame(width: tab.icon.width, height: tab.icon.height) + .foregroundStyle(selection == tab ? Color.dash.whiteText : Color.dash.secondaryText) + .frame(height: Layout.glyphBoxHeight) } } @@ -79,18 +94,27 @@ private struct TabSelectorHost: View { } } -#Preview("All three") { +/// One per state — the active pill's fill is the tab's own colour, so the +/// three look materially different. +#Preview("Receive") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .receive) +} + +#Preview("Internal transfer") { + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .internalTransfer) +} + +#Preview("Send") { TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send) } -/// The balance-row receive sheet offers only two — the pill has to fill the -/// wider slots without the layout shifting. +/// The balance-row receive sheet offers two, so the active pill grows wider. #Preview("Narrowed") { TabSelectorHost(tabs: [.receive, .internalTransfer], selection: .receive) } #Preview("Dark") { - TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .internalTransfer) + TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send) .preferredColorScheme(.dark) } diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index a4496b07b..692d7c7e4 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -119,13 +119,9 @@ struct PaymentsLandingScreen: View { .padding(.top, 10) } - private var headerTitle: String { - switch viewModel.activeTab { - case .receive: return NSLocalizedString("Receive", comment: "") - case .internalTransfer: return NSLocalizedString("Internal transfer", comment: "") - case .send: return NSLocalizedString("Send", comment: "") - } - } + /// The tab's own label — the selector shows the same words on the active + /// pill, and two copies would be free to drift. + private var headerTitle: String { viewModel.activeTab.title } // MARK: - Tab selector diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift index 29aa865c9..31fa974cd 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingViewModel.swift @@ -6,6 +6,8 @@ import Combine import Foundation import SwiftDashSDK +import SwiftUI +import DashUIKit import UIKit enum PaymentsLandingTab: String, CaseIterable, Identifiable { @@ -18,16 +20,27 @@ enum PaymentsLandingTab: String, CaseIterable, Identifiable { var title: String { switch self { case .receive: return NSLocalizedString("Receive", comment: "") - case .internalTransfer: return NSLocalizedString("Internal", comment: "") + case .internalTransfer: return NSLocalizedString("Internal transfer", comment: "") case .send: return NSLocalizedString("Send", comment: "") } } - var iconSystemName: String { + /// Asset name and the size the glyph is drawn at — the three differ, and + /// one shared size would distort them. + var icon: (name: String, width: CGFloat, height: CGFloat) { switch self { - case .receive: return "arrow.down" - case .internalTransfer: return "arrow.up.arrow.down" - case .send: return "arrow.up" + case .receive: return ("payments-option-arrow-down", 8.881, 14) + case .internalTransfer: return ("payments-option-transfer", 15.297, 16) + case .send: return ("payments-option-arrow-up", 7.771, 14) + } + } + + /// Fill of the pill while this tab is the active one. + var accent: Color { + switch self { + case .receive: return Color.dash.green + case .internalTransfer: return Color.dash.lightBlue + case .send: return Color.dash.blue } } } From 4d8ad4d26d6eef08a2af066236f40413a4bbf79d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:20:30 +0300 Subject: [PATCH 16/19] fix(payments): give the tab pills room under the header The gap was written for the boxed segmented control, which brought its own padding and a visible edge; the capsules that replaced it have neither, so twelve points left them sitting against the close button. Co-Authored-By: Claude Opus 5 --- .../Sources/UI/Payments/Landing/PaymentsLandingScreen.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index 692d7c7e4..9d0a9b17f 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -50,7 +50,11 @@ struct PaymentsLandingScreen: View { tabs: viewModel.visibleTabs, selection: $viewModel.activeTab) .padding(.horizontal, 20) - .padding(.top, showsHeader ? 0 : 12) + // The pills carry no container of their own, so they need the + // gap the boxed control used to get from its own padding and + // edge — under the header most of all, where the close button + // sits right above them. + .padding(.top, showsHeader ? 8 : 12) switch viewModel.activeTab { case .receive: From 829012fb68099c444bc7a0ceeec110a30166a1f7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:27:47 +0300 Subject: [PATCH 17/19] fix(payments): point the Send arrow up, and stop the pills jumping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the screenshots showed. Figma exports one arrow glyph and flips it vertically for the up variant (-scale-y-100 in the reference). Downloading it under the name arrow-up left the flip behind, so Send pointed down — on its own blue pill and on the collapsed one beside Receive. The flip is baked into the asset rather than applied at the call site, so the name says what the file draws. The stack's spacing was chosen by the active tab — 12 where a tab embeds a full form, 20 otherwise — which spaced the header from the pills too. Switching tabs therefore moved both by 8pt. The tightening belongs below the selector, where the form that needs the room actually is; above it the gap is fixed and reads the same on every tab. Co-Authored-By: Claude Opus 5 --- .../payments-option-arrow-up.svg | 2 ++ .../UI/Payments/Landing/PaymentsLandingScreen.swift | 12 ++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg index ba02deb24..630a4d675 100644 --- a/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg +++ b/DashWallet/Resources/AppAssets.xcassets/payments-option-arrow-up.imageset/payments-option-arrow-up.svg @@ -1,3 +1,5 @@ + + diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index 9d0a9b17f..ccd7571cb 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -38,10 +38,12 @@ struct PaymentsLandingScreen: View { var body: some View { // Tighter chrome when a tab embeds a full form (transfer or send) — - // its amount + cards + keypad need most of the sheet. + // its amount + cards + keypad need most of the sheet. It shortens the + // gap BELOW the selector only: sizing the whole stack by it moved the + // header and the pills too, so switching tabs jumped them 8pt. let isEmbeddedForm = (viewModel.activeTab == .internalTransfer && embeddedTransferViewModel != nil) || viewModel.activeTab == .send - VStack(alignment: .center, spacing: isEmbeddedForm ? 12 : 20) { + VStack(alignment: .center, spacing: 0) { if showsHeader { header } @@ -53,8 +55,10 @@ struct PaymentsLandingScreen: View { // The pills carry no container of their own, so they need the // gap the boxed control used to get from its own padding and // edge — under the header most of all, where the close button - // sits right above them. - .padding(.top, showsHeader ? 8 : 12) + // sits right above them. Fixed, so it reads the same on every + // tab. + .padding(.top, showsHeader ? 20 : 12) + .padding(.bottom, isEmbeddedForm ? 12 : 20) switch viewModel.activeTab { case .receive: From b1e7e65c04abcec0a74ef094422626c61084935b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:32:30 +0300 Subject: [PATCH 18/19] feat(payments): animate the landing's tab change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching tabs cut instantly — the active pill jumped to its new width and colour and the content underneath was replaced in the same frame, so a change that has an obvious direction had none. The pill morphs: width, fill and the label that only the active one carries, which fades in while the capsule is still growing rather than landing at full width against a moving edge. The animation is scoped to the selector instead of wrapping the binding write, so it does not silently animate everything else that observes the selection. The content cross-fades, and briefly — two of these tabs carry a keypad and a scroll view, where a long fade reads as slow rather than smooth. Co-Authored-By: Claude Opus 5 --- .../Components/PaymentsLandingTabSelector.swift | 9 +++++++++ .../Payments/Landing/PaymentsLandingScreen.swift | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift index 2354c032b..d06e35976 100644 --- a/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift +++ b/DashWallet/Sources/UI/Payments/Landing/Components/PaymentsLandingTabSelector.swift @@ -44,6 +44,11 @@ struct PaymentsLandingTabSelector: View { .buttonStyle(.plain) } } + // Scoped to the pills rather than wrapping the binding write in + // `withAnimation`: that would animate whatever else observes the + // selection — the whole tab's content — which is the host's call to + // make, not this view's. + .animation(.snappy(duration: 0.25), value: selection) } @ViewBuilder @@ -59,6 +64,10 @@ struct PaymentsLandingTabSelector: View { .foregroundStyle(Color.dash.whiteText) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) + // Fades rather than appearing at full width: the pill is + // still growing underneath it, and a hard cut reads as a + // second, competing change. + .transition(.opacity) } } .padding(.horizontal, Layout.horizontalPadding) diff --git a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift index ccd7571cb..0980382d6 100644 --- a/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift +++ b/DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift @@ -60,6 +60,19 @@ struct PaymentsLandingScreen: View { .padding(.top, showsHeader ? 20 : 12) .padding(.bottom, isEmbeddedForm ? 12 : 20) + tabContent + } + .background(Color.dash.primaryBackground) + .navigationBarHidden(true) + } + + /// Cross-faded rather than swapped outright — the pills animate their own + /// morph, and an instant content change beside that reads as a glitch. + /// Deliberately short: two of these tabs carry a keypad and a scroll view, + /// and a long fade on those looks slow rather than smooth. + @ViewBuilder + private var tabContent: some View { + Group { switch viewModel.activeTab { case .receive: receiveContent @@ -101,8 +114,7 @@ struct PaymentsLandingScreen: View { showsHeader: false) } } - .background(Color.dash.primaryBackground) - .navigationBarHidden(true) + .animation(.easeInOut(duration: 0.18), value: viewModel.activeTab) } // MARK: - Header From 36c4a5714080cb5a7fb99ac5d8165f6f65fe5ad7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:22:26 +0300 Subject: [PATCH 19/19] fix(send): re-prepare a consumed Core send, and total the fee on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CoreSendConfirmController.retry` re-broadcast the prepared send after any non-unknown failure, but `PreparedStandardSend` captures a single-shot `FinalizedCoreTransaction`: once the broadcast has reached the SDK, a retry can only surface its already-consumed error. The prepared object now reports whether its broadcast ran, and the controller drops it when it did, so "Try again" re-prepares (auth + build + sign). A failure raised before the broadcast — the online precheck — still re-broadcasts the same signed transaction, and an unknown outcome stays terminal. A Core send charges the network fee on top of the amount, so the confirm sheet's Total now adds the exact fee off the signed transaction: the same all-in wallet debit the legacy confirm screen showed. --- .../Transactions/WalletSendService.swift | 16 +++++++ .../Payments/Pay/Send/SendConfirmSheet.swift | 42 +++++++++++++++---- DashWalletTests/PaymentProtocolTests.swift | 28 +++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/DashWallet/Sources/Models/Transactions/WalletSendService.swift b/DashWallet/Sources/Models/Transactions/WalletSendService.swift index d1d5b0cbe..629d29a2e 100644 --- a/DashWallet/Sources/Models/Transactions/WalletSendService.swift +++ b/DashWallet/Sources/Models/Transactions/WalletSendService.swift @@ -56,6 +56,19 @@ final class PreparedStandardSend: NSObject { /// BEFORE the broadcast (`ensureOnlineAction`) leave a retryable object. private let claimLock = NSLock() private var broadcastState = BroadcastState.ready + private var broadcastActionInvoked = false + + /// `true` once `broadcastAction` has been invoked, whatever it returned or + /// threw. The captured `FinalizedCoreTransaction` is single-shot, so from + /// that point on this object can only surface the SDK's already-consumed + /// error — a caller offering "try again" must re-prepare instead of + /// re-broadcasting it. Failures raised BEFORE the broadcast (the online + /// precheck) leave this `false`, and the object rebroadcastable. + var isBroadcastConsumed: Bool { + claimLock.lock() + defer { claimLock.unlock() } + return broadcastActionInvoked + } init( txData: Data, @@ -124,6 +137,9 @@ final class PreparedStandardSend: NSObject { // was prepared. Claiming the state first ensures a stored unknown // result is returned without consulting changing network state. try ensureOnlineAction() + claimLock.lock() + broadcastActionInvoked = true + claimLock.unlock() outcome = try broadcastAction() } catch { claimLock.lock() diff --git a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift index 24643868c..2d9832bb9 100644 --- a/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/Pay/Send/SendConfirmSheet.swift @@ -90,11 +90,14 @@ final class CoreSendConfirmController: ObservableObject { await broadcastPrepared() } - /// `Try again` from `.failed`. A broadcast-time failure (e.g. rejected) - /// leaves the signed transaction retryable — `PreparedStandardSend` - /// resets its own claim state to `.ready` on a rejected/local failure — - /// so retry re-broadcasts the SAME transaction rather than re-signing. A - /// prepare-time failure (nothing was ever signed) re-prepares. + /// `Try again` from `.failed`. Re-broadcasts the SAME signed transaction + /// only while it is still intact — a failure raised before the broadcast + /// reached the SDK (the online precheck). Once the broadcast ran, the + /// captured transaction is consumed and `broadcastPrepared` has dropped + /// it, so this re-prepares (auth + build + sign) instead of replaying an + /// object that could only report the SDK's already-consumed error. A + /// prepare-time failure (nothing was ever signed) re-prepares too. + /// Unknown outcomes are terminal and never reach `.failed`. func retry(address: String, amountDuffs: UInt64) async { guard case .failed = phase else { return } if prepared != nil { @@ -114,6 +117,15 @@ final class CoreSendConfirmController: ObservableObject { if WalletSendService.isBroadcastUnknownError(error as NSError) { phase = .submittedUnconfirmed } else { + // A rejection (or any local failure raised once the broadcast + // reached the SDK) consumed the single-shot transaction. Drop + // it — and the fee read off it — so `Try again` re-signs + // rather than replaying a consumed object. A pre-broadcast + // failure keeps it, and retry re-broadcasts as before. + if prepared.isBroadcastConsumed { + self.prepared = nil + feeDuffs = nil + } phase = .failed(Self.message(for: error)) } } @@ -424,12 +436,25 @@ struct SendConfirmSheet: View { divider summaryRow( label: NSLocalizedString("Total", comment: ""), - value: dashDuffs.formattedDashAmount) + value: totalString) } .background(Color.dash.secondaryBackground) .cornerRadius(12) } + /// The wallet debit. Core → Core charges the network fee ON TOP of what + /// the recipient receives, so its Total is the amount plus the exact fee + /// off the signed transaction — the all-in figure the legacy confirm + /// screen showed (`DWPaymentProcessor` passed `amount + preparedSend.fee`). + /// Until that fee is known, and on every other route (whose fee row is an + /// estimate, as in `InternalTransferConfirmSheet`), Total is the amount. + private var totalString: String { + guard route == .coreToCore, let feeDuffs = coreSend.feeDuffs else { + return dashDuffs.formattedDashAmount + } + return (dashDuffs + Int64(clamping: feeDuffs)).formattedDashAmount + } + private var fromLabel: String { switch route { case .platformToPlatform, .platformToCore: @@ -633,8 +658,9 @@ struct SendConfirmSheet: View { amountCredits: creditsAmount, recipientRaw43: destinationRaw43) case .coreToCore: - // Already prepared (auth + build/sign) on appear — Confirm - // only broadcasts the signed transaction. + // Already prepared (auth + build/sign) by the amount step + // before this sheet was presented — Confirm only broadcasts + // the signed transaction. await coreSend.confirmBroadcast() } } diff --git a/DashWalletTests/PaymentProtocolTests.swift b/DashWalletTests/PaymentProtocolTests.swift index f0e2e1058..5cbb4c0dc 100644 --- a/DashWalletTests/PaymentProtocolTests.swift +++ b/DashWalletTests/PaymentProtocolTests.swift @@ -452,6 +452,34 @@ final class PreparedStandardSendBroadcastTests: XCTestCase { XCTAssertEqual(calls, 2) } + /// The claim state returning to `.ready` does not mean the captured + /// transaction survived: only a failure raised BEFORE the broadcast leaves + /// it intact. `CoreSendConfirmController` reads this to decide between + /// re-broadcasting and re-preparing. + func testBroadcastIsConsumedOnlyOnceTheBroadcastRan() { + let offline = NSError( + domain: "test.network", code: 1, + userInfo: [NSLocalizedDescriptionKey: "offline"]) + let blocked = prepared( + hashByte: 0x17, + ensureOnline: { throw offline }, + action: { .accepted(txid: String(repeating: "17", count: 32)) }) + + XCTAssertFalse(blocked.isBroadcastConsumed) + XCTAssertThrowsError(try blocked.broadcast()) + XCTAssertFalse(blocked.isBroadcastConsumed) + + let rejected = prepared(hashByte: 0x28) { + .rejected( + txid: String(repeating: "28", count: 32), + reason: "broadcast was not started") + } + + XCTAssertFalse(rejected.isBroadcastConsumed) + XCTAssertThrowsError(try rejected.broadcast()) + XCTAssertTrue(rejected.isBroadcastConsumed) + } + func testUnknownRemainsTerminalWhenNetworkStateChanges() { let offline = NSError( domain: "test.network", code: 2,