Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
06a8fa5
fix(menu-item): render the toggle accessory with the Dash switch
jeanpierreroma Aug 18, 2026
2217282
feat(icons): add InfoRoundIcon
jeanpierreroma Aug 18, 2026
4f19f99
feat(menu-item): give the info glyph a type, and draw the design syst…
jeanpierreroma Aug 18, 2026
37d03f8
feat(icons): add the Features icon set
jeanpierreroma Aug 18, 2026
bc908c6
feat(bottom-sheet): group the sheet with SheetFeature
jeanpierreroma Aug 18, 2026
99c45dd
Merge remote-tracking branch 'origin/feat/bottom-sheet-background' in…
jeanpierreroma Aug 18, 2026
9729d24
feat(icons): add the instant and timer feature icons
jeanpierreroma Aug 18, 2026
0824bc5
feat(icons): add purple variants of the two-colour feature icons
jeanpierreroma Aug 18, 2026
66cd5bf
fix(sheet-feature): stop forcing the icon into template mode
jeanpierreroma Aug 18, 2026
3980c93
feat(icons): name the Features group in DashIcon
jeanpierreroma Aug 19, 2026
247a950
feat(keyboard): give NumericKeyboardView the panel it always sits on
jeanpierreroma Aug 20, 2026
971ce13
feat(enter-amount): say a rejected amount where the converted one goes
jeanpierreroma Aug 21, 2026
ca870f9
feat(converter-card): let a row carry its own tap
jeanpierreroma Aug 22, 2026
0dc75ba
feat(menu-item): mark the chosen row of a picker list
jeanpierreroma Aug 22, 2026
1a6dd4c
feat(icons): draw the chevron in all four directions
jeanpierreroma Aug 23, 2026
b351db4
feat(converter-card): show a chevron on a row that opens a picker
jeanpierreroma Aug 23, 2026
13c6879
feat(icons): add the segmented control's directional arrows
jeanpierreroma Aug 23, 2026
4b43ddb
fix(dash-amount): let a caller ask for more decimal places
jeanpierreroma Aug 23, 2026
3caefd6
feat(icons): add the swap-to-crypto menu icon
jeanpierreroma Aug 24, 2026
66aba02
Merge remote-tracking branch 'origin/master' into integration/menu-it…
jeanpierreroma Aug 24, 2026
63093ff
feat(icons): give copy-outline a dark appearance
jeanpierreroma Aug 25, 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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Sources/DashUIKit/
Components/ Most components (one type per file)
EnterAmount/ Amount-entry suite (EnterAmountView, SwapAmountView, …)
Geometry/ Layout readers + scale-to-fit helpers
Icons/ Code-drawn icons (XmarkIcon)
Icons/ Code-drawn icons (XmarkIcon, CheckmarkIcon, ChevronIcon, InfoRoundIcon)
Illustrations/ Loading / success / error illustrations
Table List/ List1View (label/value row)
ViewModifiers/ MenuViewModifier (card chrome)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ a component already exists before building your own.
| `LoadingIllustration` / `LoadingSpinner` | iOS-style activity spinner | [Feedback](docs/feedback.md) |
| `SuccessIllustration` / `ErrorIllustration` | 90×90 status badges | [Feedback](docs/feedback.md) |
| `XmarkIcon` | Code-drawn close (✕) icon | [Feedback](docs/feedback.md) |
| `CheckmarkIcon` | Code-drawn selection (✓) mark | [Feedback](docs/feedback.md) |
| `ChevronIcon` | Code-drawn chevron, in any of the four directions | [Feedback](docs/feedback.md) |
| Geometry helpers | `readingFrame`, `readingLocation`, `ScrollViewWithOnScrollChanged`, `scaleToFitWidth` | [Utilities](docs/utilities.md) |
| Foundation | `Color.dash.*`, `Font.dash.*`, `.dashFont`, `DashIconSource` | [Foundation](docs/foundation.md) |

Expand Down
141 changes: 141 additions & 0 deletions Sources/DashUIKit/Components/BottomSheet/SheetFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// Created by Roman Chornyi
// Copyright © 2026 Dash Core Group. All rights reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI

// MARK: - SheetFeature

/// One "here is what this gives you" line inside a `BottomSheet`: an icon
/// beside a name and a sentence explaining it. Stack several to describe what a
/// feature unlocks.
///
/// The icon slot is a `ViewBuilder` rather than a `DashIconSource` because the
/// leading mark is not always an image — a tinted glyph, a badge or a coloured
/// container all appear in this position. It is sized to 40×40 here so a column
/// of features stays aligned whatever each row puts in it.
@available(iOS 14, macOS 11, *)
public struct SheetFeature<Icon: View>: View {
public var title: String
public var description: String
@ViewBuilder public var icon: () -> Icon

public init(
title: String,
description: String,
@ViewBuilder icon: @escaping () -> Icon
) {
self.title = title
self.description = description
self.icon = icon
}

public var body: some View {
HStack(alignment: .top, spacing: 16) {
icon()
.frame(width: 40, height: 40)

VStack(alignment: .leading, spacing: 2) {
// `.subheadMedium`, not `.subhead` + `.fontWeight`: the
// modifier is macOS 13, and the weight belongs to a type token
// anyway — the library carries both weights of this size.
Text(title)
.dashFont(.subheadMedium)
.foregroundColor(Color.dash.primaryText)

Text(description)
.dashFont(.subhead)
.foregroundColor(Color.dash.primaryText)
}
.padding(.top, 10)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
}

@available(iOS 14, macOS 11, *)
public extension SheetFeature where Icon == AnyView {
/// Convenience for an asset in the icon slot.
///
/// `iconColor` is optional on purpose. Given one, the image is drawn as a
/// template in that colour — right for a single-colour glyph. Left `nil`,
/// the asset renders as authored, which is the only way an icon carrying
/// more than one colour keeps them: a template draws the alpha channel in
/// a single tint and flattens the rest away.
init(
title: String,
description: String,
icon source: DashIconSource,
iconColor: Color? = nil
) {
// Built before the call so the slot receives exactly `AnyView`; a
// `@ViewBuilder` if/else would hand back `_ConditionalContent`.
// `.renderingMode` also lives on `Image`, so it is applied before the
// layout modifiers erase the type.
let rendered: AnyView
if let iconColor {
rendered = AnyView(
Image(dash: source)
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundColor(iconColor))
} else {
rendered = AnyView(
Image(dash: source)
.resizable()
.scaledToFit())
}
self.init(title: title, description: description) { rendered }
}
}

#if DEBUG

@available(iOS 17, macOS 14, *)
#Preview("Feature list") {
VStack(alignment: .leading, spacing: 16) {
SheetFeature(
title: "Identity",
description: "Register a username and be paid by name instead of an address.",
icon: .custom("feature-identity", bundle: .dashUIKit))
SheetFeature(
title: "Platform",
description: "Store contacts and profile data on Dash Platform.",
icon: .custom("feature-platform", bundle: .dashUIKit))
SheetFeature(
title: "Shield",
description: "Move funds into shielded balances that stay off the public ledger.",
icon: .custom("feature-shield", bundle: .dashUIKit))
}
.padding(20)
.background(Color.dash.primaryBackground)
}

@available(iOS 17, macOS 14, *)
#Preview("Custom icon slot") {
SheetFeature(
title: "Anything in the slot",
description: "The icon is a ViewBuilder, so a badge or a coloured container fits too."
) {
Circle().fill(Color.dash.blueAlpha10)
.overlay(InfoRoundIcon(size: 20))
}
.padding(20)
.background(Color.dash.primaryBackground)
}

#endif
53 changes: 51 additions & 2 deletions Sources/DashUIKit/Components/ConverterCard/ConverterCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import SwiftUI
///
/// Pass `onSwap` to show a tappable `diagonal-up-down` button; omit it (or pass `nil`) for a
/// static `arrow-down` indicator when the card is non-swappable.
///
/// A row given an `onTap` becomes a button and grows a trailing chevron, so a
/// row that opens a picker reads as one at rest.
@available(iOS 14, macOS 11, *)
public struct ConverterCard: View {

Expand Down Expand Up @@ -52,7 +55,10 @@ public struct ConverterCard: View {
public var body: some View {
VStack(spacing: Layout.cardSpacing) {
ForEach(Array(orderedItems.enumerated()), id: \.element.id) { index, item in
ConverterCardRow(slot: index == 0 ? .top : .bottom) {
ConverterCardRow(
slot: index == 0 ? .top : .bottom,
isInteractive: item.onTap != nil
) {
row(item: item)
}
}
Expand All @@ -75,9 +81,25 @@ public struct ConverterCard: View {
/// spacing — never on the bottom row — so it stays correct when the rows differ in height.
private var seamY: CGFloat { topRowHeight + Layout.cardSpacing / 2 }

/// A row with an `onTap` becomes a plain button over the whole row area
/// (`contentShape` keeps the padding and the spacer tappable); without
/// one it stays inert, exactly as before the action existed.
@ViewBuilder
private func row(item: ConverterCardItem) -> some View {
if let onTap = item.onTap {
Button(action: onTap) {
rowContent(item: item)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
rowContent(item: item)
}
}

/// Row layout mirrors `MenuItem` (icon 30pt, 10pt padding) but stays flexible enough for a
/// custom icon/trailing view and a multi-line subtitle.
private func row(item: ConverterCardItem) -> some View {
private func rowContent(item: ConverterCardItem) -> some View {
HStack(spacing: 10) {
leading(item)

Expand All @@ -100,6 +122,14 @@ public struct ConverterCard: View {
Spacer(minLength: 8)

trailing(item)

// A row that opens something says so. `onTap` is the only signal
// the card has for that, and it is exactly the right one: the two
// are set together by definition.
if item.onTap != nil {
ChevronIcon()
.padding(.leading, 2)
}
}
.padding(10)
}
Expand Down Expand Up @@ -175,6 +205,25 @@ struct ConverterCard_Previews: PreviewProvider {
)
.previewDisplayName("Static (no swap)")

// Tappable from-row: the whole row is a plain button carrying the
// trailing chevron; the bottom row stays inert and chevron-less.
ConverterCard(
fromItem: ConverterCardItem(
icon: .system("d.circle.fill"),
title: "Tap to pick a source",
subtitle: "From",
dashBalance: 245_000_000,
onTap: {}
),
toItem: ConverterCardItem(
icon: .system("person.crop.circle.fill"),
title: "Identity",
subtitle: "To",
showsBalance: false
)
)
.previewDisplayName("Tappable from-row")

// Unequal rows — badge must stay on the seam when the bottom row is taller.
ConverterCard(
fromItem: ConverterCardItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ public struct ConverterCardItem: Identifiable {
public let trailingView: AnyView?
/// When `false` (and no `trailingView`), the row hides its trailing balance.
public let showsBalance: Bool
/// Invoked when the whole row is tapped (e.g. to open an endpoint picker).
/// `nil` — the default — leaves the row inert, exactly as before this
/// parameter existed.
public let onTap: (() -> Void)?

public init(
id: AnyHashable? = nil,
Expand All @@ -59,7 +63,8 @@ public struct ConverterCardItem: Identifiable {
dashBalance: Int64 = 0,
fiat: String? = nil,
trailingView: AnyView? = nil,
showsBalance: Bool = true
showsBalance: Bool = true,
onTap: (() -> Void)? = nil
) {
self.id = id ?? AnyHashable(title)
self.icon = icon
Expand All @@ -71,5 +76,6 @@ public struct ConverterCardItem: Identifiable {
self.fiat = fiat
self.trailingView = trailingView
self.showsBalance = showsBalance
self.onTap = onTap
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,21 @@ struct ConverterRowHeightKey: PreferenceKey {

// MARK: - ConverterCardRow

/// One card in the conversion stack: wraps arbitrary content with the shared, non-interactive
/// card chrome and reports its height via `ConverterRowHeightKey` for its `slot`.
/// One card in the conversion stack: wraps arbitrary content with the shared card chrome and
/// reports its height via `ConverterRowHeightKey` for its `slot`.
///
/// The chrome swallows touches by default: a row is display-only, and the badge drawn over the
/// seam is the card's only control. A row that carries its own action opts back in with
/// `isInteractive` — without it the button inside the content would never see the tap.
@available(iOS 14, macOS 11, *)
struct ConverterCardRow<Content: View>: View {
let slot: ConverterRowSlot
var isInteractive: Bool = false
@ViewBuilder var content: () -> Content

var body: some View {
content()
.allowsHitTesting(false)
.allowsHitTesting(isInteractive)
.padding(6)
.background(Color.dash.secondaryBackground)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
Expand Down
28 changes: 20 additions & 8 deletions Sources/DashUIKit/Components/DashAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,29 @@ public enum DashAmountSign {

// MARK: - Internal formatter

private enum DashAmountFormat {
public enum DashAmountFormat {
static let duffsPerDash: Decimal = 100_000_000

static let numberFormatter: NumberFormatter = {
/// Five is what a balance wants: enough to be exact at everyday sizes,
/// short enough not to dominate a row. It is NOT enough for every figure —
/// a Core fee of a few hundred duffs rounds to zero at five places — so the
/// digit count is a parameter and this is only its default.
public static let defaultMaximumFractionDigits = 5

static func numberFormatter(maximumFractionDigits: Int) -> NumberFormatter {
let f = NumberFormatter()
f.numberStyle = .decimal
f.locale = .current
f.minimumFractionDigits = 0
f.maximumFractionDigits = 5
f.maximumFractionDigits = maximumFractionDigits
f.usesGroupingSeparator = true
return f
}()
}

static func string(forDuffs duffs: Int64) -> String {
static func string(forDuffs duffs: Int64, maximumFractionDigits: Int) -> String {
let value = Decimal(duffs) / duffsPerDash
return numberFormatter.string(from: value as NSNumber) ?? "\(value)"
return numberFormatter(maximumFractionDigits: maximumFractionDigits)
.string(from: value as NSNumber) ?? "\(value)"
}
}

Expand All @@ -60,19 +67,24 @@ public struct DashAmount: View {
public var weight: Font.Weight
public var dashSymbolFactor: CGFloat
public var sign: DashAmountSign
/// Raise it for a figure the default would round away — a Core network fee
/// is a few hundred duffs, which is zero at five places.
public var maximumFractionDigits: Int

public init(
amount: Int64,
fontSize: CGFloat = 13,
weight: Font.Weight = .medium,
dashSymbolFactor: CGFloat = 1,
sign: DashAmountSign = .negativeOnly
sign: DashAmountSign = .negativeOnly,
maximumFractionDigits: Int = DashAmountFormat.defaultMaximumFractionDigits
) {
self.amount = amount
self.fontSize = fontSize
self.weight = weight
self.dashSymbolFactor = dashSymbolFactor
self.sign = sign
self.maximumFractionDigits = maximumFractionDigits
}

public var body: some View {
Expand All @@ -85,7 +97,7 @@ public struct DashAmount: View {
Text(prefix)
.font(.system(size: fontSize, weight: weight))
}
Text(DashAmountFormat.string(forDuffs: abs(amount)))
Text(DashAmountFormat.string(forDuffs: abs(amount), maximumFractionDigits: maximumFractionDigits))
.font(.system(size: fontSize, weight: weight))
.lineLimit(1)
DashIcon.Common.iconDashCurrency.image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ internal struct DualSwapAmountView: View {
/// When `true`, primary amount occupies the large/top slot; when `false`, secondary does.
let isPrimarySelected: Bool
let isCurrencySelectorHidden: Bool
/// Stands in for the B row's value when the amount is rejected.
var secondaryErrorMessage: String? = nil
let onSwap: () -> Void
let onSelectCurrency: () -> Void
let onPaste: (() -> Void)?
Expand All @@ -54,6 +56,7 @@ internal struct DualSwapAmountView: View {
secondarySymbol: secondaryCurrency.symbol,
showSecondaryDashLogo: secondaryCurrency == .dash,
showSecondaryCurrencyButton: !isCurrencySelectorHidden,
secondaryErrorMessage: secondaryErrorMessage,
onSecondaryCurrencyTap: onSelectCurrency,
swapAnimationKey: isPrimarySelected
)
Expand Down
Loading
Loading