diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c04fc98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# DashUIKit context + +SwiftUI design-system library for Dash Core Group apps. Pure UI components, design +tokens (colors / typography), and small layout utilities. No business logic, no +networking — every component is dumb and driven entirely by the values and closures +the host passes in. + +- Swift tools: **6.3** +- Single library target **`DashUIKit`** (`import DashUIKit`) +- Assets live in `Sources/DashUIKit/Resources/Media.xcassets` and are reached via + `Bundle.module` / `Bundle.dashUIKit`. + +## ⚠️ Hard constraint: must support iOS 14 + +**This library must remain available on iOS 14.** The package deployment target is +`iOS(.v14)` (see `Package.swift`) and this is non-negotiable — every public component +must be usable from an iOS 14 host. + +When writing or changing code: + +- Annotate public API with `@available(iOS 14, macOS 11, *)` (or `macOS 10.15` for + Foundation-level token types). **Do not** make a component's *minimum* availability + higher than iOS 14. +- If you need a SwiftUI API introduced after iOS 14 (e.g. `@FocusState` 15, + `presentationDetents` 16, `#Preview` macro 17), **gate it with `if #available(...)` + and provide an iOS 14 fallback path** — never raise the whole component's floor. + See `SearchBar` (15+ focus path + 14 legacy path), `BottomSheet` / `selfSizingSheet` + (16+ detents, no-op below), and `AddressFieldView` (17 axis field + older fallback). +- It is fine for `#Preview`-only code to require iOS 17 — previews are `#if DEBUG` and + never ship — but the component itself must still build and run on iOS 14. + +## Where things are + +```text +Sources/DashUIKit/ + Button/ DashButton (+ DashButtonSize / DashButtonStyle) + 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) + Illustrations/ Loading / success / error illustrations + Table List/ List1View (label/value row) + ViewModifiers/ MenuViewModifier (card chrome) + Foundation/ Color / Font / DashTextStyle / Image / line-height / Bundle + Resources/ Media.xcassets (colors, icons, illustrations) +``` + +## Conventions (match these when adding code) + +- **One public type per file**, named after the file. Heavy `#Preview` / `PreviewProvider` + coverage under `#if DEBUG` is expected — add previews for every state. +- **Theming is asset-driven.** Never hardcode a `Color(...)`/hex. Use `Color.dash.` + (see `Foundation/Color+DashUI.swift`) — each maps to a named color set in the asset + catalog with light/dark variants. Add a new token there, not inline. +- **Typography:** use `.dashFont(.subhead)` (sets font **and** design line height together) + or `Font.dash.subhead` when you only need the font. Tokens defined in `DashTextStyle.swift`. +- **Icons:** pass a `DashIconSource` (`.system` / `.custom(name, bundle:)` / `.uiImage`) and + render with `Image(dash: source)`. Custom assets resolve from `.dashUIKit`/`.module`. +- **Availability:** annotate public API with `@available(iOS 14, macOS 11, *)` (or higher only + when a newer SwiftUI feature requires it, with an iOS 14 fallback path — see `SearchBar`, + `BottomSheet`). +- **UIKit-only files** are wrapped in `#if canImport(UIKit)` (e.g. `SearchBar`, `Toast`, + `AddressFieldView`, `DashSwitch`). +- **Localization:** user-facing strings use `NSLocalizedString(_, bundle: .module, comment:)`. +- Components are **stateless/value-driven** — state (`@Binding`) and callbacks live with the + host. Don't add view models or persistence here. + +## Build / preview + +- This is a plain SwiftPM library — `swift build` compiles it; there is no app target. +- Develop visually with Xcode SwiftUI **#Previews** (open a file, use the canvas). Previews + require iOS 17 for the `#Preview` macro; older `PreviewProvider` previews work back further. + +## Component catalog + +Full per-component reference (API, props, behavior, usage) lives in **`docs/`**. Start at +[`docs/README.md`](docs/README.md) — it indexes every component so you can check whether a +given UI element already exists before building a new one. The public surface is also +summarized in [`README.md`](README.md). diff --git a/Package.swift b/Package.swift index 8f7c656..64df805 100644 --- a/Package.swift +++ b/Package.swift @@ -17,5 +17,9 @@ let package = Package( .process("Resources/Media.xcassets"), ] ), + .testTarget( + name: "DashUIKitTests", + dependencies: ["DashUIKit"] + ), ] ) diff --git a/Sources/DashUIKit/Components/CoinSelector.swift b/Sources/DashUIKit/Components/CoinSelector.swift new file mode 100644 index 0000000..7ce6ec4 --- /dev/null +++ b/Sources/DashUIKit/Components/CoinSelector.swift @@ -0,0 +1,155 @@ +// +// Created by 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 + +@available(iOS 14, macOS 11, *) +public enum CoinSelectorTrailing { + case price(String) + case halted +} + +private enum CoinSelectorLayout { + static let spacing: CGFloat = 16 + static let textSpacing: CGFloat = 1 + static let padding: CGFloat = 10 + static let badgeHPadding: CGFloat = 8 + static let badgeVPadding: CGFloat = 2 + static let badgeCornerRadius: CGFloat = 6 +} + +@available(iOS 14, macOS 11, *) +public struct CoinSelector: View { + + private let name: String + private let code: String + private let network: String? + private let trailing: CoinSelectorTrailing? + private let icon: Icon + + public init( + name: String, + code: String, + network: String? = nil, + trailing: CoinSelectorTrailing? = nil, + @ViewBuilder icon: () -> Icon + ) { + self.name = name + self.code = code + self.network = network + self.trailing = trailing + self.icon = icon() + } + + private var isHalted: Bool { + if case .halted = trailing { return true } + return false + } + + public var body: some View { + HStack(spacing: CoinSelectorLayout.spacing) { + icon + info + trailingView + } + .padding(CoinSelectorLayout.padding) + .opacity(isHalted ? 0.5 : 1.0) + .contentShape(Rectangle()) + } + + private var info: some View { + VStack(alignment: .leading, spacing: CoinSelectorLayout.textSpacing) { + Text(name) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.primaryText) + .lineLimit(1) + .truncationMode(.tail) + Text(code) + .dashFont(.footnote) + .foregroundColor(Color.dash.tertiaryText) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var trailingView: some View { + if case .halted = trailing { + haltedLabel + } else { + VStack(alignment: .trailing, spacing: 0) { + if case .price(let value) = trailing { + Text(value) + .dashFont(.caption1) + .foregroundColor(Color.dash.primaryText) + .lineLimit(1) + } + if let network { + Text(network) + .dashFont(.caption1) + .foregroundColor(Color.dash.tertiaryText) + .lineLimit(1) + .fixedSize() + } + } + } + } + + private var haltedLabel: some View { + Text(NSLocalizedString("halted", bundle: .module, comment: "DashUIKit")) + .dashFont(.caption1) + .foregroundColor(Color.dash.tertiaryText) + .padding(.horizontal, CoinSelectorLayout.badgeHPadding) + .padding(.vertical, CoinSelectorLayout.badgeVPadding) + .background(Color.dash.black1000Alpha8) + .clipShape(RoundedRectangle(cornerRadius: CoinSelectorLayout.badgeCornerRadius, style: .continuous)) + } +} + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct CoinSelector_Previews: PreviewProvider { + + static var iconPlaceholder: some View { + RoundedRectangle(cornerRadius: 6) + .fill(Color.dash.blue) + .frame(width: 30, height: 30) + } + + static var previews: some View { + VStack(spacing: 0) { + CoinSelector(name: "Rune", code: "RUNE", network: "Maya", trailing: .price("$0.40")) { + iconPlaceholder + } + CoinSelector(name: "Ethereum", code: "ETH", network: "NEAR", trailing: .price("$3,200.00")) { + iconPlaceholder + } + CoinSelector(name: "USD Coin", code: "USDC", network: "Multiple", trailing: .price("$1.00")) { + iconPlaceholder + } + CoinSelector(name: "Tether", code: "USDT", trailing: .halted) { + iconPlaceholder + } + CoinSelector(name: "Bitcoin", code: "BTC") { + iconPlaceholder + } + } + .previewLayout(.sizeThatFits) + } +} + +#endif diff --git a/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift b/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift new file mode 100644 index 0000000..31168fa --- /dev/null +++ b/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift @@ -0,0 +1,63 @@ +// +// 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: - ConverterArrowBadge + +/// The arrow badge centered on the seam between the two `ConverterCard` rows. +/// +/// - `onSwap == nil` → static `arrow-down` icon, non-interactive. +/// - `onSwap != nil` → tappable `diagonal-up-down` button that rotates 180° on each tap and fires +/// the swap action. +@available(iOS 14, macOS 11, *) +struct ConverterArrowBadge: View { + let onSwap: (() -> Void)? + @State private var rotation: Double = 0 + + var body: some View { + Group { + if let onSwap { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { rotation += 180 } + onSwap() + } label: { + badge(iconName: "diagonal-up-down", iconRotation: rotation) + } + .buttonStyle(.plain) + } else { + badge(iconName: "arrow-down", iconRotation: 0) + } + } + .frame(height: 35) + .padding(.horizontal, 10) + } + + private func badge(iconName: String, iconRotation: Double) -> some View { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground) + .frame(width: 35, height: 35) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.dash.primaryBackground, lineWidth: 5) + ) + .overlay( + Image(dash: .custom(iconName, bundle: .dashUIKit)) + .rotationEffect(.degrees(iconRotation)) + ) + } +} diff --git a/Sources/DashUIKit/Components/ConverterCard/ConverterCard.swift b/Sources/DashUIKit/Components/ConverterCard/ConverterCard.swift new file mode 100644 index 0000000..79bbd41 --- /dev/null +++ b/Sources/DashUIKit/Components/ConverterCard/ConverterCard.swift @@ -0,0 +1,229 @@ +// +// 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: - ConverterCard + +/// A two-row card (source → destination) with an arrow badge centered on the seam between the +/// rows. Each row is a `MenuItem` wrapped in rounded card chrome. +/// +/// 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. +@available(iOS 14, macOS 11, *) +public struct ConverterCard: View { + + private enum Layout { + static let cardSpacing: CGFloat = 5 + } + + private let fromItem: ConverterCardItem + private let toItem: ConverterCardItem + private let onSwap: (() -> Void)? + + @State private var topRowHeight: CGFloat = 74 + + public init( + fromItem: ConverterCardItem, + toItem: ConverterCardItem, + onSwap: (() -> Void)? = nil + ) { + self.fromItem = fromItem + self.toItem = toItem + self.onSwap = onSwap + } + + private var orderedItems: [ConverterCardItem] { [fromItem, toItem] } + + 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) { + row(item: item) + } + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.82), value: fromItem.id) + .overlay( + ConverterArrowBadge(onSwap: onSwap) + // Anchor the badge's CENTER to the overlay's .top point, so the offset below + // places the badge centre exactly on the seam — independent of the badge's height. + .alignmentGuide(VerticalAlignment.top) { $0[VerticalAlignment.center] } + .offset(y: seamY), + alignment: .top + ) + .onPreferenceChange(ConverterRowHeightKey.self) { heights in + if let h = heights[.top], h > 0 { topRowHeight = h } + } + } + + /// Centre of the gap between the two rows. Depends only on the top row height and the stack + /// spacing — never on the bottom row — so it stays correct when the rows differ in height. + private var seamY: CGFloat { topRowHeight + Layout.cardSpacing / 2 } + + /// 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 { + HStack(spacing: 10) { + leading(item) + + VStack(alignment: .leading, spacing: 1) { + Text(item.title) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.primaryText) + + if let subtitle = item.subtitle { + Text(subtitle) + .dashFont(.footnote) + .foregroundColor(Color.dash.secondaryText) + .lineLimit(item.subtitleLineLimit) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.leading, 6) + + Spacer(minLength: 8) + + trailing(item) + } + .padding(10) + } + + @ViewBuilder + private func leading(_ item: ConverterCardItem) -> some View { + if let iconView = item.iconView { + iconView.frame(width: 30, height: 30) + } else if let icon = item.icon { + Image(dash: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + } + + @ViewBuilder + private func trailing(_ item: ConverterCardItem) -> some View { + if let trailingView = item.trailingView { + trailingView + } else if item.showsBalance { + VStack(alignment: .trailing, spacing: 1) { + DashAmount(amount: item.dashBalance, sign: .none) + .foregroundColor(Color.dash.primaryText) + + if item.dashBalance != 0, let fiat = item.fiat { + Text(fiat) + .dashFont(.footnote) + .foregroundColor(Color.dash.secondaryText) + } + } + } + } +} + +#if DEBUG +@available(iOS 14, macOS 11, *) +struct ConverterCard_Previews: PreviewProvider { + static var previews: some View { + Group { + ConverterCard( + fromItem: ConverterCardItem( + icon: .system("bitcoinsign.circle.fill"), + title: "Coinbase", + subtitle: "Dash Wallet", + dashBalance: 420_000_000, + fiat: "$410.00" + ), + toItem: ConverterCardItem( + icon: .system("d.circle.fill"), + title: "Dash", + subtitle: "Dash Wallet", + dashBalance: 123_456_789, + fiat: "$120.00", + showsBalance: false + ), + onSwap: {} + ) + .previewDisplayName("Swappable") + + ConverterCard( + fromItem: ConverterCardItem( + icon: .system("d.circle.fill"), + title: "Dash", + dashBalance: 500_000_000, + fiat: "$490.00" + ), + toItem: ConverterCardItem( + icon: .system("arrow.right.circle.fill"), + title: "Destination", + showsBalance: false + ) + ) + .previewDisplayName("Static (no swap)") + + // Unequal rows — badge must stay on the seam when the bottom row is taller. + ConverterCard( + fromItem: ConverterCardItem( + icon: .system("bitcoinsign.circle.fill"), + title: "Coinbase", + dashBalance: 420_000_000, + fiat: "$410.00" + ), + toItem: ConverterCardItem( + icon: .system("d.circle.fill"), + title: "Dash", + subtitle: "XqK7pMn2rV9wL4tYeB8dN3cF6hJsA1uZ — long address wraps and makes this row taller than the top row", + subtitleLineLimit: nil, + showsBalance: false + ), + onSwap: {} + ) + .previewDisplayName("Unequal rows (tall bottom)") + } + .padding() + .background(Color.dash.primaryBackground) + } +} + +@available(iOS 14, macOS 11, *) +struct SwapPreview: View { + @State private var swapped = false + + var coinbase: ConverterCardItem { + ConverterCardItem(id: "coinbase", icon: .system("bitcoinsign.circle.fill"), + title: "Coinbase", dashBalance: 131_715_000, fiat: "$0.04") + } + var dash: ConverterCardItem { + ConverterCardItem(id: "dash", icon: .system("d.circle.fill"), + title: "Dash", subtitle: "Dash Wallet", + dashBalance: 17_351_000, fiat: "$6.04") + } + + var body: some View { + ConverterCard( + fromItem: swapped ? dash : coinbase, + toItem: swapped ? coinbase : dash, + onSwap: { swapped.toggle() } + ) + .padding() + .background(Color.dash.primaryBackground) + } +} + +@available(iOS 17, macOS 14, *) +#Preview("Swap animation") { SwapPreview() } +#endif diff --git a/Sources/DashUIKit/Components/ConverterCard/ConverterCardItem.swift b/Sources/DashUIKit/Components/ConverterCard/ConverterCardItem.swift new file mode 100644 index 0000000..1dd5519 --- /dev/null +++ b/Sources/DashUIKit/Components/ConverterCard/ConverterCardItem.swift @@ -0,0 +1,75 @@ +// +// 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: - ConverterCardItem + +/// Display data for a single row of `ConverterCard`. +/// +/// The common case (a `DashIconSource` icon + a Dash balance) is fully described by the plain +/// values. For richer rows — a remotely-loaded icon, a custom trailing view, or a multi-line +/// subtitle — pass `iconView` / `trailingView` / `subtitleLineLimit`, which take precedence. +/// +/// `id` drives the swap slide animation: it must be **stable per data-source across rebuilds** +/// (the Coinbase row keeps the same id whether it occupies the top or bottom slot). The default +/// `id = title` is sufficient when titles are distinct and stable. Pass an explicit `id` if the +/// title might change or if two rows could share a title. +@available(iOS 14, macOS 11, *) +public struct ConverterCardItem: Identifiable { + public let id: AnyHashable + /// Static leading icon. Ignored when `iconView` is non-nil. + public let icon: DashIconSource? + /// Custom leading view (e.g. a remotely-loaded coin icon). Takes precedence over `icon`. + public let iconView: AnyView? + public let title: String + public let subtitle: String? + /// Subtitle line cap; `nil` allows unlimited wrapping (e.g. a long address). + public let subtitleLineLimit: Int? + /// Dash balance in duffs (10⁸ per Dash) — rendered as a Dash amount when `showsBalance`. + public let dashBalance: Int64 + public let fiat: String? + /// Custom trailing view (e.g. a formatted `DashBalanceView`). Takes precedence over the + /// built-in dash-balance rendering. + public let trailingView: AnyView? + /// When `false` (and no `trailingView`), the row hides its trailing balance. + public let showsBalance: Bool + + public init( + id: AnyHashable? = nil, + icon: DashIconSource? = nil, + iconView: AnyView? = nil, + title: String, + subtitle: String? = nil, + subtitleLineLimit: Int? = 1, + dashBalance: Int64 = 0, + fiat: String? = nil, + trailingView: AnyView? = nil, + showsBalance: Bool = true + ) { + self.id = id ?? AnyHashable(title) + self.icon = icon + self.iconView = iconView + self.title = title + self.subtitle = subtitle + self.subtitleLineLimit = subtitleLineLimit + self.dashBalance = dashBalance + self.fiat = fiat + self.trailingView = trailingView + self.showsBalance = showsBalance + } +} diff --git a/Sources/DashUIKit/Components/ConverterCard/ConverterCardRow.swift b/Sources/DashUIKit/Components/ConverterCard/ConverterCardRow.swift new file mode 100644 index 0000000..a2fd207 --- /dev/null +++ b/Sources/DashUIKit/Components/ConverterCard/ConverterCardRow.swift @@ -0,0 +1,63 @@ +// +// 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: - ConverterRowSlot + +/// Identifies the two cards in the `ConverterCard` stack so the parent can position the arrow +/// badge on the seam between them. +enum ConverterRowSlot { + case top + case bottom +} + +// MARK: - ConverterRowHeightKey + +/// Reports each row's measured height up to `ConverterCard`, which uses the values to center the +/// arrow badge exactly between the two rows. Kept here next to the row that writes it. +struct ConverterRowHeightKey: PreferenceKey { + static let defaultValue: [ConverterRowSlot: CGFloat] = [:] + + static func reduce(value: inout [ConverterRowSlot: CGFloat], nextValue: () -> [ConverterRowSlot: CGFloat]) { + value.merge(nextValue()) { _, new in new } + } +} + +// 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`. +@available(iOS 14, macOS 11, *) +struct ConverterCardRow: View { + let slot: ConverterRowSlot + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .allowsHitTesting(false) + .padding(6) + .background(Color.dash.secondaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: ConverterRowHeightKey.self, value: [slot: proxy.size.height]) + } + ) + } +} diff --git a/Sources/DashUIKit/Components/EnterAmount/CurrencyOption.swift b/Sources/DashUIKit/Components/EnterAmount/CurrencyOption.swift new file mode 100644 index 0000000..beade79 --- /dev/null +++ b/Sources/DashUIKit/Components/EnterAmount/CurrencyOption.swift @@ -0,0 +1,70 @@ +// +// Created by Roman Chornyi +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - DashCurrencySymbol (internal) + +enum DashCurrencySymbol { + /// Locale-based currency symbol (deterministic; no app state). + static func symbol(for currencyCode: String) -> String { + let locale = Locale.current as NSLocale + if let s = locale.displayName(forKey: .currencySymbol, value: currencyCode), !s.isEmpty { + return s + } + let f = NumberFormatter() + f.numberStyle = .currency + f.currencyCode = currencyCode + return f.currencySymbol + } +} + +// MARK: - CurrencyOption + +@available(iOS 14, macOS 11, *) +public enum CurrencyOption: Hashable { + case fiat(String) + case dash + case coin(String) + + public var isFiat: Bool { + if case .fiat = self { return true } + return false + } + + public var isCoinInput: Bool { + if case .coin = self { return true } + return false + } + + public var displayName: String { + switch self { + case .fiat(let code): return code + case .dash: return "DASH" + case .coin(let code): return code + } + } + + public var symbol: String? { + switch self { + case .fiat(let code): + return DashCurrencySymbol.symbol(for: code) + case .dash: return nil + case .coin(let code): return code + } + } +} diff --git a/Sources/DashUIKit/Components/EnterAmount/DashBalanceView.swift b/Sources/DashUIKit/Components/EnterAmount/DashBalanceView.swift new file mode 100644 index 0000000..b8243ec --- /dev/null +++ b/Sources/DashUIKit/Components/EnterAmount/DashBalanceView.swift @@ -0,0 +1,76 @@ +// +// 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: - DashBalanceView + +/// Trailing balance for the convert source row: a symbol-free Dash amount followed by the Dash +/// symbol, with the fiat value beneath. Both strings come from the view model +/// (`dashBalanceFormatted` / `dashBalanceFiat`); pass `fiat: nil` to hide the fiat line. +@available(iOS 14, macOS 11, *) +public struct DashBalanceView: View { + /// Symbol-free formatted balance, e.g. "1.5". + public let balance: String + /// Formatted fiat value; `nil` hides the line (e.g. zero balance). + public var fiat: String? + + public init(balance: String, fiat: String? = nil) { + self.balance = balance + self.fiat = fiat + } + + public var body: some View { + VStack(alignment: .trailing, spacing: 0) { + HStack(spacing: 6) { + Text(balance) + .font(Font.dash.subhead) + .lineLimit(1) + + dashSymbol + } + .foregroundColor(Color.dash.primaryText) + + if let fiat { + Text(fiat) + .font(Font.dash.caption1) + .foregroundColor(Color.dash.primaryText) + } + } + } + + private var dashSymbol: some View { + Image(dash: .custom("icon_dash_currency", bundle: .dashUIKit)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 10) + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview { + VStack(spacing: 16) { + DashBalanceView(balance: "1.5", fiat: "$ 150.00") + DashBalanceView(balance: "0", fiat: nil) + } + .padding() + .background(Color.dash.secondaryBackground) +} + +#endif diff --git a/Sources/DashUIKit/Components/EnterAmount/DashPickerView.swift b/Sources/DashUIKit/Components/EnterAmount/DashPickerView.swift new file mode 100644 index 0000000..e9817ae --- /dev/null +++ b/Sources/DashUIKit/Components/EnterAmount/DashPickerView.swift @@ -0,0 +1,77 @@ +// +// 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 + +@available(iOS 14, macOS 11, *) +private enum Layout { + static let hPadding: CGFloat = 6 + static let vPadding: CGFloat = 3 + static let cornerRadius: CGFloat = 6 +} + +@available(iOS 14, macOS 11, *) +public struct DashPickerView: View { + + public let options: [Option] + public let title: (Option) -> String + @Binding public var selected: Option + + public init(options: [Option], title: @escaping (Option) -> String, selected: Binding