Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
264a874
refactor(send): split the send flow out of one 1156-line file
jeanpierreroma Aug 9, 2026
0ba58de
test(send): previewable view model, and previews for each send step
jeanpierreroma Aug 9, 2026
9aa1270
test(send): previews for the shared send components
jeanpierreroma Aug 9, 2026
6e5b270
refactor(send): use DashUIKit's NavigationBar for the send steps
jeanpierreroma Aug 9, 2026
578df39
refactor(send): use DashUIKit's AddressFieldView for the address entry
jeanpierreroma Aug 9, 2026
97a5947
refactor(send): drop the collapsed address card
jeanpierreroma Aug 9, 2026
d35e88f
refactor(send): paste from the field, not a card below it
jeanpierreroma Aug 9, 2026
6990ed3
refactor(send): read the destination as a heading, not a field
jeanpierreroma Aug 9, 2026
662a268
feat(send): say which balance the amount step is spending from
jeanpierreroma Aug 9, 2026
0cff40f
feat(send): send Transparent to Transparent through the new amount st…
jeanpierreroma Aug 9, 2026
4ca1294
fix(send): gate Core sends on what is actually spendable
jeanpierreroma Aug 9, 2026
d86fc3e
feat(send): confirm a Transparent send in the app's own sheet
jeanpierreroma Aug 10, 2026
5aa8ab9
fix(send): sign before presenting the confirm sheet, not from inside it
jeanpierreroma Aug 10, 2026
32d6079
refactor(payments): extract the landing's tab selector
jeanpierreroma Aug 10, 2026
d79d017
feat(payments): rebuild the landing tab selector to the design
jeanpierreroma Aug 10, 2026
4d8ad4d
fix(payments): give the tab pills room under the header
jeanpierreroma Aug 10, 2026
829012f
fix(payments): point the Send arrow up, and stop the pills jumping
jeanpierreroma Aug 10, 2026
b1e7e65
feat(payments): animate the landing's tab change
jeanpierreroma Aug 10, 2026
36c4a57
fix(send): re-prepare a consumed Core send, and total the fee on top
jeanpierreroma Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 143 additions & 105 deletions DashWallet.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// PaymentsLandingTabSelector.swift
// DashWallet
//
// The Receive / Internal transfer / Send switcher at the top of the payments
// landing.
//

import SwiftUI
import DashUIKit

/// A row of pills, one per tab. The active one carries the tab's colour and
/// its label and takes the space that is left; the others shrink to their
/// icon.
///
/// Takes the tabs and a binding rather than the view model: which tabs are
/// offered is the presentation's decision — the full landing shows all three,
/// the balance-row receive sheet narrows to two — and this view has no reason
/// to know why.
struct PaymentsLandingTabSelector: View {

private enum Layout {
static let spacing: CGFloat = 8
static let contentGap: CGFloat = 8
static let horizontalPadding: CGFloat = 16
static let verticalPadding: CGFloat = 10
/// The inactive pill holds its icon and nothing else, so it is sized
/// rather than left to hug — otherwise the three widths would shift
/// with each glyph.
static let collapsedWidth: CGFloat = 60
/// Height of the box the glyph is centred in, whatever its own size.
static let glyphBoxHeight: CGFloat = 20
}

let tabs: [PaymentsLandingTab]
@Binding var selection: PaymentsLandingTab

var body: some View {
HStack(spacing: Layout.spacing) {
ForEach(tabs) { tab in
Button { selection = tab } label: {
pill(for: tab)
}
.buttonStyle(.plain)
}
}
// Scoped to the pills rather than wrapping the binding write in
// `withAnimation`: that would animate whatever else observes the
// selection — the whole tab's content — which is the host's call to
// make, not this view's.
.animation(.snappy(duration: 0.25), value: selection)
}

@ViewBuilder
private func pill(for tab: PaymentsLandingTab) -> some View {
let isSelected = selection == tab

HStack(spacing: Layout.contentGap) {
icon(for: tab)

if isSelected {
Text(tab.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.dash.whiteText)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
// Fades rather than appearing at full width: the pill is
// still growing underneath it, and a hard cut reads as a
// second, competing change.
.transition(.opacity)
}
}
.padding(.horizontal, Layout.horizontalPadding)
.padding(.vertical, Layout.verticalPadding)
// Selected takes what's left; the rest keep the fixed icon width.
.frame(maxWidth: isSelected ? .infinity : Layout.collapsedWidth)
.background(isSelected ? tab.accent : Color.dash.gray300Alpha20)
.clipShape(Capsule())
}

private func icon(for tab: PaymentsLandingTab) -> some View {
// Both dimensions are set from the design: the three glyphs have
// different aspect ratios, and a single square frame would squash
// the arrows.
Image(tab.icon.name)
.resizable()
.frame(width: tab.icon.width, height: tab.icon.height)
.foregroundStyle(selection == tab ? Color.dash.whiteText : Color.dash.secondaryText)
.frame(height: Layout.glyphBoxHeight)
}
}

#if DEBUG

private struct TabSelectorHost: View {
let tabs: [PaymentsLandingTab]
@State var selection: PaymentsLandingTab

var body: some View {
PaymentsLandingTabSelector(tabs: tabs, selection: $selection)
.padding()
.background(Color.dash.primaryBackground)
}
}

/// One per state — the active pill's fill is the tab's own colour, so the
/// three look materially different.
#Preview("Receive") {
TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .receive)
}

#Preview("Internal transfer") {
TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .internalTransfer)
}

#Preview("Send") {
TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send)
}

/// The balance-row receive sheet offers two, so the active pill grows wider.
#Preview("Narrowed") {
TabSelectorHost(tabs: [.receive, .internalTransfer], selection: .receive)
}

#Preview("Dark") {
TabSelectorHost(tabs: PaymentsLandingTab.allCases, selection: .send)
.preferredColorScheme(.dark)
}

#endif
79 changes: 31 additions & 48 deletions DashWallet/Sources/UI/Payments/Landing/PaymentsLandingScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import UIKit

struct PaymentsLandingScreen: View {
@ObservedObject var viewModel: PaymentsLandingViewModel
@Environment(\.colorScheme) private var colorScheme

var onClose: () -> Void
var onCopyAddress: () -> Void
Expand Down Expand Up @@ -39,18 +38,41 @@ struct PaymentsLandingScreen: View {

var body: some View {
// Tighter chrome when a tab embeds a full form (transfer or send) —
// its amount + cards + keypad need most of the sheet.
// its amount + cards + keypad need most of the sheet. It shortens the
// gap BELOW the selector only: sizing the whole stack by it moved the
// header and the pills too, so switching tabs jumped them 8pt.
let isEmbeddedForm = (viewModel.activeTab == .internalTransfer && embeddedTransferViewModel != nil)
|| viewModel.activeTab == .send
VStack(alignment: .center, spacing: isEmbeddedForm ? 12 : 20) {
VStack(alignment: .center, spacing: 0) {
if showsHeader {
header
}

tabSelector
PaymentsLandingTabSelector(
tabs: viewModel.visibleTabs,
selection: $viewModel.activeTab)
.padding(.horizontal, 20)
.padding(.top, showsHeader ? 0 : 12)
// The pills carry no container of their own, so they need the
// gap the boxed control used to get from its own padding and
// edge — under the header most of all, where the close button
// sits right above them. Fixed, so it reads the same on every
// tab.
.padding(.top, showsHeader ? 20 : 12)
.padding(.bottom, isEmbeddedForm ? 12 : 20)

tabContent
}
.background(Color.dash.primaryBackground)
.navigationBarHidden(true)
}

/// Cross-faded rather than swapped outright — the pills animate their own
/// morph, and an instant content change beside that reads as a glitch.
/// Deliberately short: two of these tabs carry a keypad and a scroll view,
/// and a long fade on those looks slow rather than smooth.
@ViewBuilder
private var tabContent: some View {
Group {
switch viewModel.activeTab {
case .receive:
receiveContent
Expand Down Expand Up @@ -92,8 +114,7 @@ struct PaymentsLandingScreen: View {
showsHeader: false)
}
}
.background(Color.dash.primaryBackground)
.navigationBarHidden(true)
.animation(.easeInOut(duration: 0.18), value: viewModel.activeTab)
}

// MARK: - Header
Expand All @@ -118,50 +139,12 @@ struct PaymentsLandingScreen: View {
.padding(.top, 10)
}

private var headerTitle: String {
switch viewModel.activeTab {
case .receive: return NSLocalizedString("Receive", comment: "")
case .internalTransfer: return NSLocalizedString("Internal transfer", comment: "")
case .send: return NSLocalizedString("Send", comment: "")
}
}
/// The tab's own label — the selector shows the same words on the active
/// pill, and two copies would be free to drift.
private var headerTitle: String { viewModel.activeTab.title }

// MARK: - Tab selector

private var tabSelector: some View {
HStack(spacing: 4) {
ForEach(viewModel.visibleTabs) { tab in
Button(action: { viewModel.activeTab = tab }) {
VStack(spacing: 4) {
Image(systemName: tab.iconSystemName)
.font(.system(size: 16, weight: .semibold))
Text(tab.title)
.font(.system(size: 13, weight: .medium))
}
.foregroundColor(viewModel.activeTab == tab ? Color.dash.primaryText : Color.dash.secondaryText)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
// Selected pill: a solid white raised card in light mode; a
// translucent light fill in dark so the primaryText label stays
// legible (pure white would be invisible on the dark selector).
// Mirrors the app's SegmentedControl selected-fill treatment.
.fill(viewModel.activeTab == tab
? (colorScheme == .dark ? Color.dash.whiteAlpha20 : Color.dash.white)
: Color.clear)
.shadow(
color: viewModel.activeTab == tab
? Color.dash.shadow : .clear,
radius: 2, x: 0, y: 1))
}
}
}
.padding(4)
.background(Color.dash.secondaryBackground)
.cornerRadius(10)
}

// MARK: - Receive

private var receiveContent: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import Combine
import Foundation
import SwiftDashSDK
import SwiftUI
import DashUIKit
import UIKit

enum PaymentsLandingTab: String, CaseIterable, Identifiable {
Expand All @@ -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
}
}
}
Expand Down
Loading
Loading