From 97fb39ac2e8d034e87eca755ee6efc8e18d3be89 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 16 Jul 2026 12:42:23 +0700 Subject: [PATCH 1/5] feat(ui): balance breakdown with fiat and in/out transfer buttons on home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home header showed only the transparent balance as the hero, with platform and shielded amounts squeezed into two label-less columns — full 9-decimal precision, no fiat, no way to move funds between them. Now the hero is the COMBINED total (transparent + platform + shielded, rendered by the existing DashAmount component and its Dash-Đ glyph) with its fiat value, above a breakdown card with one row per balance: icon, name, fiat value, Đ amount, and two circular transfer buttons mapping onto the routes the app supports: - Transparent: in = Receive, out = Send (existing shortcut actions); - Platform: in = Shielded → Platform, out = Platform → Shielded; - Shielded: in = Transparent → Shielded, out = Shielded → Transparent. The buttons push InternalTransferHostingController through a new init(direction:source:) (user can still flip the route on-screen) via a new HomeViewDelegate method. PlatformBalanceView is deleted — the breakdown card supersedes it. Tap-to-hide, the syncing shimmer, and the long-press currency picker behave as before; the breakdown hides with the hidden-balance state and (as before) while BLAST isn't running. Verified on a funded testnet QA simulator: totals sum correctly, rows show fiat + Đ amounts, all six buttons expose 44pt accessibility targets, and Shielded-in opens Internal transfer prefilled Dash Wallet → Shielded. Co-Authored-By: Claude Fable 5 --- DashWallet.xcodeproj/project.pbxproj | 6 - .../Sources/UI/Home/HomeViewController.swift | 6 + .../Home Balance View/BalanceModel.swift | 7 ++ .../Home Balance View/HomeBalanceView.swift | 117 +++++++++++++++++- .../PlatformBalanceView.swift | 42 ------- .../Sources/UI/Home/Views/HomeView.swift | 28 +++-- .../InternalTransferHostingController.swift | 9 ++ DashWallet/en.lproj/Localizable.strings | 9 ++ 8 files changed, 163 insertions(+), 61 deletions(-) delete mode 100644 DashWallet/Sources/UI/Home/Views/Home Balance View/PlatformBalanceView.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index c3e6397a2..eef80d0b6 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -344,7 +344,6 @@ 39DC037E9CF04CCFA3EFBB53 /* SelfSizingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB7E093E994D2391D36DEC /* SelfSizingSheet.swift */; }; 3A377045AA5C10F3FD63A7C2 /* DWContestedNameStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950113F9E258B2F1CC0D50C /* DWContestedNameStatusService.swift */; }; 3A958F915CDACC6FE30923BA /* DarkCoinMessageFraming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C25F44380CDB841AF55D3FF /* DarkCoinMessageFraming.swift */; }; - 3ABC7253C995C16E654DF76D /* PlatformBalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CCA0579F761A8ABFF951AC4 /* PlatformBalanceView.swift */; }; 3B1ECCE5521BAB084D74B5ED /* StorageModelListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A762BDA473947F1A7EF199 /* StorageModelListViews.swift */; }; 3B9C0EAAC6DE69DBF8271920 /* BIP70PaymentOutputFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5822AAB9B5EA605FF82B009A /* BIP70PaymentOutputFactory.swift */; }; 3E715F8F82AA68ED8270F7CE /* ContactAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF8C5EF6DE6FBECB82DA704 /* ContactAvatarView.swift */; }; @@ -1145,7 +1144,6 @@ 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 */; }; - C8B1DB706D68E65C2FCD0E83 /* PlatformBalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CCA0579F761A8ABFF951AC4 /* PlatformBalanceView.swift */; }; C8D0B892715B6C46126FBE3A /* SendScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F454341FADB260861FE05EB4 /* SendScreenViewController.swift */; }; C909614D29EFF7D600002D82 /* WalletKeysOverviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C909614C29EFF7D600002D82 /* WalletKeysOverviewModel.swift */; }; C909615129F158D700002D82 /* DerivationPathKeysViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C909615029F158D700002D82 /* DerivationPathKeysViewController.swift */; }; @@ -2102,7 +2100,6 @@ 1940CA64E8FD6DDD0CFC14A2 /* libPods-DashWalletTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DashWalletTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 1A7BE8E4519203200CDFB460 /* SwiftDashSDKInsightClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SwiftDashSDKInsightClient.swift; sourceTree = ""; }; 1AF20341A5E313C9D309BF39 /* Pods-DashWalletTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DashWalletTests.release.xcconfig"; path = "Target Support Files/Pods-DashWalletTests/Pods-DashWalletTests.release.xcconfig"; sourceTree = ""; }; - 1CCA0579F761A8ABFF951AC4 /* PlatformBalanceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformBalanceView.swift; sourceTree = ""; }; 1D57AD13519CA41EBE3B8D53 /* CoinJoinWithdrawalTxSet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoinJoinWithdrawalTxSet.swift; sourceTree = ""; }; 1E0B188220763649450C9423 /* WalletEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WalletEnvironment.swift; sourceTree = ""; }; 1E7370138411E3E74B42F4CC /* Pods-dashwallet.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dashwallet.testflight.xcconfig"; path = "Target Support Files/Pods-dashwallet/Pods-dashwallet.testflight.xcconfig"; sourceTree = ""; }; @@ -8661,7 +8658,6 @@ children = ( 472D13E7299E4EE7006903F1 /* BalanceModel.swift */, C9F067F129E4576D0022D958 /* HomeBalanceView.swift */, - 1CCA0579F761A8ABFF951AC4 /* PlatformBalanceView.swift */, ); path = "Home Balance View"; sourceTree = ""; @@ -10363,7 +10359,6 @@ 4DB882B7974693571FD16952 /* WalletsScreen.swift in Sources */, E5B549CD6364E8982A7262C0 /* WalletsViewModel.swift in Sources */, 938DE0B52BD0B57A76CDEE90 /* WalletSwitchDialog.swift in Sources */, - 3ABC7253C995C16E654DF76D /* PlatformBalanceView.swift in Sources */, 5D8D3B4397A957BA6DD6FC0C /* DWAboutModel+MasternodeSync.swift in Sources */, 0A7C300A1E685D356BEE3F54 /* SwiftDashSDKCoinJoinBalanceReader.swift in Sources */, C05FA6CE3DDE2D0327D334E5 /* CoinJoinRecovery.swift in Sources */, @@ -11375,7 +11370,6 @@ 43001E4B0F1FD846676FCE1F /* NetworkReachability.swift in Sources */, E623DF8B207A512573314A83 /* MasternodeAPYCalculator.swift in Sources */, 44A49D70CBFD36A9A68B56B0 /* WalletSwitchDialog.swift in Sources */, - C8B1DB706D68E65C2FCD0E83 /* PlatformBalanceView.swift in Sources */, 1BA3482EEBB453D45365772F /* DWAboutModel+MasternodeSync.swift in Sources */, 7408C11E2FBCCD879AE9CFFC /* SwiftDashSDKCoinJoinBalanceReader.swift in Sources */, B66294BFAF57B052A74EE77D /* CoinJoinRecovery.swift in Sources */, diff --git a/DashWallet/Sources/UI/Home/HomeViewController.swift b/DashWallet/Sources/UI/Home/HomeViewController.swift index 060536ef0..8e435747c 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController.swift @@ -648,6 +648,12 @@ extension HomeViewController: HomeViewDelegate { let controller = SyncingAlertViewController() present(controller, animated: true, completion: nil) } + + func homeViewShowInternalTransfer(direction: InternalTransferDirection, source: InternalTransferSource) { + let controller = InternalTransferHostingController(direction: direction, source: source) + controller.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(controller, animated: true) + } #if DASHPAY func homeView(_ homeView: HomeView, didUpdateProfile identity: DSBlockchainIdentity?, unreadNotifications: UInt) { diff --git a/DashWallet/Sources/UI/Home/Views/Home Balance View/BalanceModel.swift b/DashWallet/Sources/UI/Home/Views/Home Balance View/BalanceModel.swift index 42981edb1..fdaddb8a1 100644 --- a/DashWallet/Sources/UI/Home/Views/Home Balance View/BalanceModel.swift +++ b/DashWallet/Sources/UI/Home/Views/Home Balance View/BalanceModel.swift @@ -158,5 +158,12 @@ extension BalanceModel { func fiatAmountString() -> String { CurrencyExchanger.shared.fiatAmountString(for: value.dashAmount) } + + /// Fiat string for an arbitrary duff amount — used by the balance + /// breakdown rows (transparent / platform / shielded) and the + /// combined-total hero, which aggregate more than `value`. + func fiatString(forDuffs duffs: UInt64) -> String { + CurrencyExchanger.shared.fiatAmountString(for: duffs.dashAmount) + } } diff --git a/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift b/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift index c871a2540..fa27a74a5 100644 --- a/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift +++ b/DashWallet/Sources/UI/Home/Views/Home Balance View/HomeBalanceView.swift @@ -26,11 +26,26 @@ enum HomeBalanceViewState: Int { // MARK: - HomeBalanceView +/// Home header: the combined total (transparent + platform + shielded) +/// as the hero amount, then one row per balance with its fiat value and +/// circular in/out transfer buttons. Rows map onto the routes the app +/// actually supports: +/// - Transparent: in = Receive, out = Send (external money); +/// - Platform: in = Shielded → Platform, out = Platform → Shielded; +/// - Shielded: in = Transparent → Shielded, out = Shielded → Transparent. struct HomeBalanceView: View { @ObservedObject var viewModel: BalanceModel + @ObservedObject private var platformSync = PlatformAddressSyncCoordinator.shared @State private var opacity: Double = 0.3 var onLongPress: () -> Void - + var onReceive: () -> Void = {} + var onSend: () -> Void = {} + var onTransfer: (InternalTransferDirection, InternalTransferSource) -> Void = { _, _ in } + + private var platformDuffs: UInt64 { platformSync.platformBalance / 1_000 } + private var shieldedDuffs: UInt64 { platformSync.shieldedBalance / 1_000 } + private var totalDuffs: UInt64 { viewModel.value + platformDuffs + shieldedDuffs } + var body: some View { VStack(spacing: 0) { ZStack { @@ -50,7 +65,7 @@ struct HomeBalanceView: View { } } .frame(height: 15) - + ZStack { if viewModel.isBalanceHidden { Image(systemName: "eye.slash.fill") @@ -63,12 +78,12 @@ struct HomeBalanceView: View { .frame(width: 58, height: 58) } else { VStack(spacing: 0) { - DashAmount(amount: Int64(viewModel.value), font: .largeTitle, dashSymbolFactor: 0.7, showDirection: false) + DashAmount(amount: Int64(totalDuffs), font: .largeTitle, dashSymbolFactor: 0.7, showDirection: false) .foregroundColor(.white) - Text(viewModel.fiatAmountString()) + Text(viewModel.fiatString(forDuffs: totalDuffs)) .font(.subhead) .foregroundColor(.white) - + ZStack { if viewModel.shouldShowTapToHideBalance { Text(NSLocalizedString("Tap to hide balance", comment: "")) @@ -90,6 +105,12 @@ struct HomeBalanceView: View { .onLongPressGesture { onLongPress() } + + if !viewModel.isBalanceHidden && platformSync.isRunning { + breakdownCard + .padding(.horizontal, 16) + .padding(.top, 12) + } } .onAppear { viewModel.reloadBalance() @@ -98,4 +119,90 @@ struct HomeBalanceView: View { viewModel.hideBalanceIfNeeded() } } + + private var breakdownCard: some View { + VStack(spacing: 0) { + balanceRow( + icon: "wallet.pass", + title: NSLocalizedString("Transparent", comment: "Balance breakdown"), + duffs: viewModel.value, + inAction: onReceive, + outAction: onSend) + rowDivider + balanceRow( + icon: "cloud", + title: NSLocalizedString("Platform", comment: ""), + duffs: platformDuffs, + inAction: { onTransfer(.fromShielded, .platform) }, + outAction: { onTransfer(.toShielded, .platform) }) + rowDivider + balanceRow( + icon: "shield", + title: NSLocalizedString("Shielded", comment: ""), + duffs: shieldedDuffs, + inAction: { onTransfer(.toShielded, .core) }, + outAction: { onTransfer(.fromShielded, .core) }) + } + .padding(.horizontal, 12) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var rowDivider: some View { + Rectangle() + .fill(Color.white.opacity(0.18)) + .frame(height: 0.5) + } + + private func balanceRow( + icon: String, + title: String, + duffs: UInt64, + inAction: @escaping () -> Void, + outAction: @escaping () -> Void + ) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundColor(.white) + .frame(width: 20) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.footnote) + .fontWeight(.medium) + .foregroundColor(.white) + Text(viewModel.fiatString(forDuffs: duffs)) + .font(.caption2) + .foregroundColor(.white.opacity(0.7)) + } + Spacer(minLength: 8) + DashAmount(amount: Int64(duffs), font: .footnote, dashSymbolFactor: 0.8, showDirection: false) + .foregroundColor(.white) + transferButton(systemName: "arrow.down", + label: NSLocalizedString("Transfer in", comment: "Balance breakdown"), + action: inAction) + transferButton(systemName: "arrow.up", + label: NSLocalizedString("Transfer out", comment: "Balance breakdown"), + action: outAction) + } + .padding(.vertical, 9) + } + + private func transferButton( + systemName: String, + label: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.white.opacity(0.18))) + // Keep the visual small but the tap target comfortable. + .contentShape(Rectangle().inset(by: -8)) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } } diff --git a/DashWallet/Sources/UI/Home/Views/Home Balance View/PlatformBalanceView.swift b/DashWallet/Sources/UI/Home/Views/Home Balance View/PlatformBalanceView.swift deleted file mode 100644 index 9b5da8495..000000000 --- a/DashWallet/Sources/UI/Home/Views/Home Balance View/PlatformBalanceView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// PlatformBalanceView.swift -// DashWallet -// - -import SwiftUI - -struct PlatformBalanceView: View { - @ObservedObject private var coordinator = PlatformAddressSyncCoordinator.shared - - var body: some View { - if coordinator.isRunning { - HStack(spacing: 0) { - balanceColumn( - title: NSLocalizedString("Platform", comment: ""), - credits: coordinator.platformBalance) - balanceColumn( - title: NSLocalizedString("Shielded", comment: ""), - credits: coordinator.shieldedBalance) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.navigationBarColor) - } - } - - /// One centered balance column (title over a DASH amount). Both the - /// platform and shielded balances are denominated in Platform credits - /// (1e11 per DASH), so they share `PlatformCreditsFormatter.dashString`. - private func balanceColumn(title: String, credits: UInt64) -> some View { - VStack(spacing: 2) { - Text(title) - .font(.caption) - .foregroundColor(.white.opacity(0.7)) - Text(PlatformCreditsFormatter.dashString(credits)) - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .monospacedDigit() - } - .frame(maxWidth: .infinity) - } -} diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 0108f7a5c..7cbbf6556 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -24,6 +24,7 @@ import DashUIKit protocol HomeViewDelegate: AnyObject { func homeViewShowSyncingStatus() + func homeViewShowInternalTransfer(direction: InternalTransferDirection, source: InternalTransferSource) #if DASHPAY func homeView(_ homeView: HomeView, didUpdateProfile identity: DSBlockchainIdentity?, unreadNotifications: UInt) @@ -188,20 +189,31 @@ struct HomeViewContent: View { .padding(EdgeInsets(top: -topOverscrollSize, leading: 0, bottom: 0, trailing: 0)) LazyVStack(pinnedViews: [.sectionHeaders]) { - HomeBalanceView(viewModel: balanceModel) { - let action = ShortcutAction(type: .localCurrency) - shortcutsDelegate?.shortcutsView(didSelectAction: action, sender: nil) - } - .frame(height: 110) + HomeBalanceView( + viewModel: balanceModel, + onLongPress: { + let action = ShortcutAction(type: .localCurrency) + shortcutsDelegate?.shortcutsView(didSelectAction: action, sender: nil) + }, + onReceive: { + let action = ShortcutAction(type: .receive) + shortcutsDelegate?.shortcutsView(didSelectAction: action, sender: nil) + }, + onSend: { + let action = ShortcutAction(type: .send) + shortcutsDelegate?.shortcutsView(didSelectAction: action, sender: nil) + }, + onTransfer: { direction, source in + delegate?.homeViewShowInternalTransfer(direction: direction, source: source) + }) .frame(maxWidth: .infinity) .background(Color.navigationBarColor) .padding(.top, 5) .padding(.bottom, -12) VStack(spacing: 0) { - PlatformBalanceView() - .frame(maxWidth: .infinity) - .background(Color.navigationBarColor) + Color.navigationBarColor + .frame(height: 16) headerView() .frame(height: viewModel.headerHeight) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift index ff0725d9c..11308b4c3 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift @@ -22,6 +22,15 @@ final class InternalTransferHostingController: UIViewController { viewModel.amountText = "\(prefillDashAmount)" } + /// Open the screen pre-filled for a specific route — used by the home + /// balance breakdown's in/out buttons. The user can still flip the + /// direction or source on-screen. + convenience init(direction: InternalTransferDirection, source: InternalTransferSource) { + self.init(nibName: nil, bundle: nil) + viewModel.direction = direction + viewModel.source = source + } + private lazy var hostingController: UIHostingController = { let screen = InternalTransferScreen(viewModel: viewModel) { [weak self] in // After a successful transfer the user taps "Done" inside the diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 4203443b0..a042cfefc 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -3440,9 +3440,15 @@ /* No comment provided by engineer. */ "Transfer from Uphold" = "Transfer from Uphold"; +/* Balance breakdown */ +"Transfer in" = "Transfer in"; + /* No comment provided by engineer. */ "Transfer In" = "Transfer In"; +/* Balance breakdown */ +"Transfer out" = "Transfer out"; + /* No comment provided by engineer. */ "Transfer Out" = "Transfer Out"; @@ -3455,6 +3461,9 @@ /* No comment provided by engineer. */ "Transfers take different times" = "Transfers take different times"; +/* Balance breakdown */ +"Transparent" = "Transparent"; + /* Usernames */ "Transparent funding publicly links your username to these funds." = "Transparent funding publicly links your username to these funds."; From 622af59abcdeb9891143befa7b5c7d2aa8971748 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 16 Jul 2026 12:48:49 +0700 Subject: [PATCH 2/5] feat(ui): present the balance-breakdown transfer as a sheet A push dragged the blue navigation header along; the sheet keeps the transfer form on its own background with a grabber and swipe-to-dismiss. The flow's Done now dismisses when presented modally and still pops when pushed (readiness / payments-landing entries keep their push). Co-Authored-By: Claude Fable 5 --- DashWallet/Sources/UI/Home/HomeViewController.swift | 10 ++++++++-- .../InternalTransferHostingController.swift | 12 +++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/DashWallet/Sources/UI/Home/HomeViewController.swift b/DashWallet/Sources/UI/Home/HomeViewController.swift index 8e435747c..22215f436 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController.swift @@ -650,9 +650,15 @@ extension HomeViewController: HomeViewDelegate { } func homeViewShowInternalTransfer(direction: InternalTransferDirection, source: InternalTransferSource) { + // Sheet, not push: a push drags the blue navigation header along; + // the sheet keeps the transfer form on its own background with + // swipe-to-dismiss (Done inside the flow also dismisses). let controller = InternalTransferHostingController(direction: direction, source: source) - controller.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(controller, animated: true) + if let sheet = controller.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + present(controller, animated: true) } #if DASHPAY diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift index 11308b4c3..e2414ae87 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift @@ -34,9 +34,15 @@ final class InternalTransferHostingController: UIViewController { private lazy var hostingController: UIHostingController = { let screen = InternalTransferScreen(viewModel: viewModel) { [weak self] in // After a successful transfer the user taps "Done" inside the - // confirm sheet; the screen forwards that to us so we can pop - // the Internal Transfer view back to the landing. - self?.navigationController?.popViewController(animated: true) + // confirm sheet; the screen forwards that to us so we can + // leave — pop when pushed (landing / readiness flows), dismiss + // when presented as a sheet (home balance breakdown). + guard let self else { return } + if let navigationController = self.navigationController { + navigationController.popViewController(animated: true) + } else { + self.dismiss(animated: true) + } } return UIHostingController(rootView: screen) }() From 483c8eb84842a01b3eacc70b4ad1028034d07064 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 16 Jul 2026 13:11:48 +0700 Subject: [PATCH 3/5] fix(ui): inset the transfer sheet content below the grabber Pushed presentations get their top spacing from the navigation bar; the sheet has no bar, so the title crowded the sheet edge. 28pt inset when presented modally, unchanged when pushed. Co-Authored-By: Claude Fable 5 --- .../InternalTransferHostingController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift index e2414ae87..bbd4703e3 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferHostingController.swift @@ -57,8 +57,13 @@ final class InternalTransferHostingController: UIViewController { hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.backgroundColor = .clear view.addSubview(hostingController.view) + // Pushed: the navigation bar provides the top spacing. Presented + // as a sheet: there is no bar, so inset the content below the + // grabber instead of letting the title crowd the sheet's edge. + let isSheet = navigationController == nil + let topInset: CGFloat = isSheet ? 28.0 : 0.0 NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor, constant: topInset), hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), From 8772fb9891f45399496c5c89bfdafc26c9472a30 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 16 Jul 2026 13:34:36 +0700 Subject: [PATCH 4/5] feat(ui): collapse the home navigation bar at the top of the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the top of the home feed the navigation bar is hidden entirely, so the balance header gets its full height (this also un-clips the "Syncing Balance" shimmer, which sat under the bar). Scrolling down past 100pt slides the bar (Dash logo + avatar) in; returning under 60pt hides it again — the two thresholds are hysteresis so the bar doesn't flicker on the boundary. Mechanics: a zero-height scroll sentinel feeds a PreferenceKey in the home ScrollView's coordinate space; HomeViewController tracks the state, applies it live while it is the top view controller, and reports it through NavigationBarDisplayable.isNavigationBarHidden so BaseNavigationController keeps pushes/pops consistent (pushed screens show their bar; popping back restores the scroll-derived state). viewWillAppear applies the state directly as well for first display and non-Base hosts. Co-Authored-By: Claude Fable 5 --- .../Sources/UI/Home/HomeViewController.swift | 33 ++++++++++++- .../Sources/UI/Home/Views/HomeView.swift | 49 ++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/DashWallet/Sources/UI/Home/HomeViewController.swift b/DashWallet/Sources/UI/Home/HomeViewController.swift index 22215f436..b714cfa61 100644 --- a/DashWallet/Sources/UI/Home/HomeViewController.swift +++ b/DashWallet/Sources/UI/Home/HomeViewController.swift @@ -36,6 +36,14 @@ class HomeViewController: DWBasePayViewController, NavigationBarDisplayable { private var homeView: HomeView! weak var delegate: (HomeViewControllerDelegate & DWWipeDelegate)? + /// True while the home feed is scrolled to the top — the navigation + /// bar stays hidden there so the balance header owns the space; it + /// slides in once the balances scroll away. Read by + /// `BaseNavigationController` through `isNavigationBarHidden` on + /// every navigation transition, so pushed screens keep their bar and + /// popping back restores the current scroll-derived state. + private var hidesNavigationBarAtTop = true + #if DASHPAY var isBackButtonHidden: Bool = false private var invitationSetup: DWInvitationSetupState? @@ -43,7 +51,9 @@ class HomeViewController: DWBasePayViewController, NavigationBarDisplayable { #else var isBackButtonHidden: Bool = true #endif - + + var isNavigationBarHidden: Bool { hidesNavigationBarAtTop } + override var payModel: any DWPayModelProtocol { get { return model.payModel } set { } @@ -93,6 +103,12 @@ class HomeViewController: DWBasePayViewController, NavigationBarDisplayable { super.viewWillAppear(animated) navigationController?.navigationBar.applyOpaqueAppearance(with: UIColor.dw_dashNavigationBlue(), shadowColor: .clear) + // Apply the scroll-derived bar state directly too — + // `BaseNavigationController.willShow` reads `isNavigationBarHidden` + // on transitions, but the home tab must also start hidden on + // first display and stay correct if hosted outside that + // navigation controller subclass. + navigationController?.setNavigationBarHidden(hidesNavigationBarAtTop, animated: animated) } override func viewDidAppear(_ animated: Bool) { @@ -649,6 +665,21 @@ extension HomeViewController: HomeViewDelegate { present(controller, animated: true, completion: nil) } + func homeViewDidChangeTopBarVisibility(shouldShow: Bool) { + // At the top of the feed the navigation bar stays hidden so the + // balance header gets the full height; once the user scrolls the + // balances away, the bar (Dash logo + avatar) slides in. + // `isNavigationBarHidden` keeps `BaseNavigationController`'s + // willShow pass consistent with the live state, so pushes show + // the bar for their own screens and pops restore ours. + let shouldHide = !shouldShow + guard hidesNavigationBarAtTop != shouldHide else { return } + hidesNavigationBarAtTop = shouldHide + if navigationController?.topViewController === self { + navigationController?.setNavigationBarHidden(shouldHide, animated: true) + } + } + func homeViewShowInternalTransfer(direction: InternalTransferDirection, source: InternalTransferSource) { // Sheet, not push: a push drags the blue navigation header along; // the sheet keeps the transfer form on its own background with diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 7cbbf6556..66cf8bb8d 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -25,6 +25,9 @@ import DashUIKit protocol HomeViewDelegate: AnyObject { func homeViewShowSyncingStatus() func homeViewShowInternalTransfer(direction: InternalTransferDirection, source: InternalTransferSource) + /// Scroll-derived chrome: false at the top of the feed (bar hidden, + /// balance header owns the space), true once the user scrolls down. + func homeViewDidChangeTopBarVisibility(shouldShow: Bool) #if DASHPAY func homeView(_ homeView: HomeView, didUpdateProfile identity: DSBlockchainIdentity?, unreadNotifications: UInt) @@ -159,6 +162,22 @@ extension HomeView: HomeHeaderViewDelegate { } } +/// Scroll thresholds (pt scrolled down) for showing/hiding the +/// navigation bar. Two values (hysteresis) so the bar doesn't flicker +/// when the user rests exactly on the boundary. File-scoped because +/// `HomeViewContent` is generic (no static stored properties). +private let kTopBarShowThreshold: CGFloat = 100 +private let kTopBarHideThreshold: CGFloat = 60 + +/// Scroll offset of the home feed's top sentinel in the scroll view's +/// coordinate space — feeds the collapsing top bar. +private struct HomeScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + struct HomeViewContent: View { @State private var selectedTxDataItem: TransactionListDataItem? = nil @State private var showFilterDialog: Bool = false @@ -179,7 +198,8 @@ struct HomeViewContent: View { @ViewBuilder var headerView: () -> Content private let topOverscrollSize: CGFloat = 1000 // Fixed value for top overscroll area - + + @State private var isTopBarShown = false var body: some View { ZStack { @@ -187,7 +207,17 @@ struct HomeViewContent: View { ZStack { Color.navigationBarColor } // Top overscroll area .frame(height: topOverscrollSize) .padding(EdgeInsets(top: -topOverscrollSize, leading: 0, bottom: 0, trailing: 0)) - + + // Zero-height scroll sentinel: its minY in the scroll + // view's space is 0 at rest and goes negative as the + // user scrolls down. Drives the collapsing top bar. + GeometryReader { proxy in + Color.clear.preference( + key: HomeScrollOffsetKey.self, + value: proxy.frame(in: .named("homeScroll")).minY) + } + .frame(height: 0) + LazyVStack(pinnedViews: [.sectionHeaders]) { HomeBalanceView( viewModel: balanceModel, @@ -280,6 +310,21 @@ struct HomeViewContent: View { } .padding(EdgeInsets(top: -20, leading: 0, bottom: 0, trailing: 0)) } + .coordinateSpace(name: "homeScroll") + .onPreferenceChange(HomeScrollOffsetKey.self) { minY in + // minY == 0 at rest; more negative the further down the + // user has scrolled. + let scrolled = -minY + if isTopBarShown { + if scrolled < kTopBarHideThreshold { + isTopBarShown = false + delegate?.homeViewDidChangeTopBarVisibility(shouldShow: false) + } + } else if scrolled > kTopBarShowThreshold { + isTopBarShown = true + delegate?.homeViewDidChangeTopBarVisibility(shouldShow: true) + } + } } .sheet(item: $selectedTxDataItem) { item in TransactionDetailsSheet(item: item) From 9b989ac625ae854f72dc4e8addbf028068ef06cf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 16 Jul 2026 13:58:55 +0700 Subject: [PATCH 5/5] fix(ui): attach the scroll sentinel as a background to close the top gap The zero-height sentinel sat as a sibling between the blue overscroll block and the content stack, so the ScrollView's implicit spacing opened a light hairline across the header. As a .background of the content stack it contributes no layout; same offset signal. Co-Authored-By: Claude Fable 5 --- .../Sources/UI/Home/Views/HomeView.swift | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 66cf8bb8d..872791f10 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -208,16 +208,6 @@ struct HomeViewContent: View { .frame(height: topOverscrollSize) .padding(EdgeInsets(top: -topOverscrollSize, leading: 0, bottom: 0, trailing: 0)) - // Zero-height scroll sentinel: its minY in the scroll - // view's space is 0 at rest and goes negative as the - // user scrolls down. Drives the collapsing top bar. - GeometryReader { proxy in - Color.clear.preference( - key: HomeScrollOffsetKey.self, - value: proxy.frame(in: .named("homeScroll")).minY) - } - .frame(height: 0) - LazyVStack(pinnedViews: [.sectionHeaders]) { HomeBalanceView( viewModel: balanceModel, @@ -309,6 +299,18 @@ struct HomeViewContent: View { } } .padding(EdgeInsets(top: -20, leading: 0, bottom: 0, trailing: 0)) + // Scroll sentinel as a background so it adds no layout + // (a zero-height sibling would still get the scroll + // content's implicit spacing and open a light gap in the + // blue header). minY is ~0 at rest and goes negative as + // the user scrolls down; drives the collapsing top bar. + .background( + GeometryReader { proxy in + Color.clear.preference( + key: HomeScrollOffsetKey.self, + value: proxy.frame(in: .named("homeScroll")).minY) + } + ) } .coordinateSpace(name: "homeScroll") .onPreferenceChange(HomeScrollOffsetKey.self) { minY in