diff --git a/Sources/DashUIKit/Components/AddressFieldView.swift b/Sources/DashUIKit/Components/AddressFieldView.swift new file mode 100644 index 0000000..7c231b6 --- /dev/null +++ b/Sources/DashUIKit/Components/AddressFieldView.swift @@ -0,0 +1,294 @@ +// +// 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. +// + +#if canImport(UIKit) +import SwiftUI + +@available(iOS 15, macOS 12, *) +public struct AddressFieldView: View { + + private enum Layout { + static let hSpacing: CGFloat = 20 + static let lPadding: CGFloat = 20 + static let tPadding: CGFloat = 10 + static let iconSize: CGFloat = 17 + static let cornerRadius: CGFloat = 16 + static let actionTapArea: CGFloat = 40 + } + + @Binding private var text: String + private let label: String + private let placeholder: String + private let hasError: Bool + private let errorText: String? + private var isDisabled: Bool + private var onScanQR: (() -> Void)? + private var onPaste: (() -> Void)? + + @FocusState private var isTextFieldFocused: Bool + + public init( + text: Binding, + label: String, + placeholder: String, + hasError: Bool, + errorText: String? = nil, + isDisabled: Bool = false, + onScanQR: (() -> Void)? = nil, + onPaste: (() -> Void)? = nil + ) { + self._text = text + self.label = label + self.placeholder = placeholder + self.hasError = hasError + self.errorText = errorText + self.isDisabled = isDisabled + self.onScanQR = onScanQR + self.onPaste = onPaste + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(label) + .dashFont(.footnote) + .foregroundStyle(Color.dash.gray500) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .center, spacing: Layout.hSpacing) { + textField + .padding(.vertical, 15) + + if !isDisabled { + // In the blurred-filled state (text present, unfocused, no error) the trailing + // icon stays in the layout so the field width — and the address text wrapping — + // doesn't shift between focused and unfocused. Per design it's just hidden: + // opacity 0 and non-interactive, but the space is reserved. + HStack(spacing: 8) { + if showsPasteButton { pasteButton } + actionButton + .opacity(isBlurredFilledState ? 0 : 1) + .allowsHitTesting(!isBlurredFilledState) + } + } + } + .padding(.leading, Layout.lPadding) + .padding(.trailing, Layout.tPadding) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: Layout.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Layout.cornerRadius) + .stroke(borderColor, lineWidth: borderWidth) + ) + + if let errorText { + Text(errorText) + .dashFont(.footnote) + .foregroundStyle(Color.dash.errorText) + } + } + } + + // MARK: - Subviews + + private var showsPasteButton: Bool { + text.isEmpty && !isDisabled && onPaste != nil + } + + private var pasteButton: some View { + DashButton( + text: NSLocalizedString("Paste", bundle: .module, comment: "DashUIKit"), + size: .medium, + style: .plainBlue, + action: { onPaste?() } + ) + } + + @ViewBuilder private var textField: some View { + if #available(iOS 17.0, *) { + TextField( + "", + text: $text, + prompt: Text(placeholder) + .font(Font.dash.subhead) + .foregroundStyle(Color.dash.black1000Alpha30), + axis: .vertical + ) + .lineLimit(1...2) + .font(Font.dash.subhead) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .foregroundStyle(Color.dash.primaryText) + .tint(Color.dash.primaryText) + .focused($isTextFieldFocused) + .disabled(isDisabled) + } else { + TextField(placeholder, text: $text) + .font(Font.dash.subhead) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .foregroundStyle(Color.dash.primaryText) + .tint(Color.dash.primaryText) + .focused($isTextFieldFocused) + .disabled(isDisabled) + } + } + + private var actionButton: some View { + Group { + if text.isEmpty { + Button(action: { onScanQR?() }) { + Image(dash: .custom("text-field-qr", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(width: Layout.iconSize, height: Layout.iconSize) + } + .accessibilityLabel(NSLocalizedString("Scan QR code", bundle: .module, comment: "DashUIKit")) + } else { + Button(action: { text = "" }) { + Image(dash: .custom("text-field-clear", bundle: .dashUIKit)) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(Color.dash.primaryText) + .frame(width: 11, height: 11) + } + .accessibilityLabel(NSLocalizedString("Clear address", bundle: .module, comment: "DashUIKit")) + } + } + .frame(width: Layout.actionTapArea, height: Layout.actionTapArea) + .contentShape(Rectangle()) + } + + // MARK: - Styling + + private var backgroundColor: Color { + if isFocusedState { return .clear } + if hasError { return Color.dash.redAlpha5 } + return Color.dash.gray300Alpha10 + } + + private var borderColor: Color { + isFocusedState ? Color.dash.gray300Alpha40 : .clear + } + + private var borderWidth: CGFloat { + isFocusedState ? 1 : 0 + } + + private var isFocusedState: Bool { + isTextFieldFocused && !isDisabled + } + + private var isFilledState: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isFocusedState + } + + /// Field is unfocused ("tapped outside"), has text, and has no error — a clean read-out state + /// with no editing affordance (the trailing action button is suppressed). + private var isBlurredFilledState: Bool { + isFilledState && !hasError && !isDisabled + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("Empty") { + AddressFieldView( + text: .constant(""), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Empty with Paste button") { + AddressFieldView( + text: .constant(""), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {}, + onPaste: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("With Text") { + AddressFieldView( + text: .constant("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Multiline") { + AddressFieldView( + text: .constant("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh\nbc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Error") { + AddressFieldView( + text: .constant("invalid-address"), + label: "Destination address", + placeholder: "BTC address", + hasError: true, errorText: "Error text", + onScanQR: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Filled") { + AddressFieldView( + text: .constant("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {} + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Blurred + filled (no action button)") { + AddressFieldView( + text: .constant("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"), + label: "Destination address", + placeholder: "BTC address", + hasError: false, errorText: nil, + onScanQR: {} + ) + .padding() +} + +#endif +#endif // canImport(UIKit) diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift new file mode 100644 index 0000000..1c96b2b --- /dev/null +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -0,0 +1,285 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +@available(iOS 14, macOS 11, *) +public struct BottomSheet: View { + @Environment(\.presentationMode) private var presentationMode + + public var title: String = "" + @Binding public var showBackButton: Bool + public var onBackButtonPressed: (() -> Void)? = nil + /// `true` (default) — greedy: content fills the sheet (use with an explicit detent or a + /// `.large`/`.medium` detent). `false` — natural height: pair with `.selfSizingSheet()` so + /// the sheet snaps to its content. Prefer `BottomSheet.selfSizing(...)` as the entry point + /// when natural sizing is needed — it guarantees `fillsHeight: false` and the modifier are + /// always applied together. + public var fillsHeight: Bool = true + @ViewBuilder public var content: () -> Content + + public init( + title: String = "", + showBackButton: Binding, + onBackButtonPressed: (() -> Void)? = nil, + fillsHeight: Bool = true, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self._showBackButton = showBackButton + self.onBackButtonPressed = onBackButtonPressed + self.fillsHeight = fillsHeight + self.content = content + } + + public var body: some View { + let sheet = VStack(spacing: 0) { + grabber + .frame(maxWidth: .infinity, minHeight: 18, maxHeight: 18, alignment: .center) + + header + + contentSection + } + .background(Color.dash.primaryBackground) + + if fillsHeight { + sheet.edgesIgnoringSafeArea(.bottom) + } else { + // Publish the natural content height for `.selfSizingSheet()`. The bottom safe area is + // intentionally NOT ignored here, so the measured height excludes the home-indicator + // inset — `.presentationDetents([.height])` adds that inset itself. + // + // `.fixedSize(vertical:)` is critical: it makes the sheet report its *ideal* height + // independent of the height the sheet currently offers. Without it the measurement is + // coupled to the detent (detent <- measured <- offered height <- detent), so it ping-pongs + // by ~the safe-area inset and the presenting view (HomeView) jitters up/down. + sheet + .fixedSize(horizontal: false, vertical: true) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: BottomSheetHeightPreferenceKey.self, + value: proxy.size.height + ) + } + ) + } + } + + private var grabber: some View { + Rectangle() + .foregroundColor(.clear) + .frame(width: 36, height: 5) + .background(Color.dash.grabberFill) + .cornerRadius(5) + } + + private var header: some View { + NavigationBar( + leading: { + if showBackButton { + NavigationBarElement.back.button { onBackButtonPressed?() } + } + }, + central: { + Text(title) + .dashFont(.calloutMedium) + .foregroundColor(.dash.primaryText) + }, + trailing: { + NavigationBarElement.close.button { presentationMode.wrappedValue.dismiss() } + } + ) + } + + @ViewBuilder + private var contentSection: some View { + if fillsHeight { + NavigationView { + content() + #if os(iOS) + .navigationBarHidden(true) + #endif + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.dash.primaryBackground) + } + } else { + // Natural height — no greedy NavigationView / maxHeight so the sheet can self-size. + content() + .frame(maxWidth: .infinity) + .background(Color.dash.primaryBackground) + } + } +} + +@available(iOS 14, macOS 11, *) +public struct BottomSheetHeightPreferenceKey: PreferenceKey { + public static let defaultValue: CGFloat = 0 + + public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +@available(iOS 14, macOS 11, *) +public extension BottomSheet { + /// Self-sizing bottom sheet: builds with `fillsHeight: false` and applies + /// `.selfSizingSheet(...)` so the two can't be mismatched. Drop the result + /// directly into a `.sheet { }`. + static func selfSizing( + title: String = "", + showBackButton: Binding, + onBackButtonPressed: (() -> Void)? = nil, + fallback: CGFloat = 0, + maxHeightFraction: CGFloat = 0.95, + cornerRadius: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + BottomSheet( + title: title, + showBackButton: showBackButton, + onBackButtonPressed: onBackButtonPressed, + fillsHeight: false, + content: content + ) + .selfSizingSheet(fallback: fallback, maxHeightFraction: maxHeightFraction, cornerRadius: cornerRadius) + } +} + +@available(iOS 14, macOS 11, *) +public extension View { + /// Sizes a `BottomSheet` (built with `fillsHeight: false`) to its content's natural height — + /// no hardcoded `.height(...)` needed. On iOS < 16 it is a no-op. + /// + /// The content is measured directly (via a `GeometryReader` background), so it does not rely + /// on any published preference — it self-sizes whatever finite-height view it wraps. The + /// measured view must have a finite intrinsic height (no greedy `Spacer` / `maxHeight: .infinity`), + /// otherwise it expands to fill the offered space and the measurement is wrong — see + /// `BottomSheet(fillsHeight: false)`. + /// + /// - Parameters: + /// - fallback: Height used before the first measurement (avoids a `.medium` flash). + /// - maxHeightFraction: Caps the sheet at this fraction of the window height; taller content + /// is clipped, so wrap it in a `ScrollView`. + /// - cornerRadius: Optional corner radius applied via `presentationCornerRadius` on + /// iOS 16.4..<26 (iOS 26+ keeps the system corner styling). When provided, the sheet + /// background is also filled so the bottom safe-area strip matches the content. + @ViewBuilder + func selfSizingSheet( + fallback: CGFloat = 0, + maxHeightFraction: CGFloat = 0.95, + cornerRadius: CGFloat? = nil + ) -> some View { + if #available(iOS 16.0, macOS 13.0, *) { + let modified = modifier(SelfSizingSheetModifier(fallback: fallback, maxHeightFraction: maxHeightFraction)) + #if os(iOS) + if #available(iOS 16.4, *), let cornerRadius { + if #unavailable(iOS 26.0) { + // iOS 16.4..<26: apply the custom corner radius + fill the sheet background. + modified + .presentationCornerRadius(cornerRadius) + .presentationBackground(Color.dash.primaryBackground) + } else { + // iOS 26+: keep the system corner styling, just fill the background. + modified + .presentationBackground(Color.dash.primaryBackground) + } + } else { + modified + } + #else + modified + #endif + } else { + self + } + } +} + +@available(iOS 16.0, macOS 13.0, *) +private struct SelfSizingSheetModifier: ViewModifier { + let fallback: CGFloat + let maxHeightFraction: CGFloat + @State private var measured: CGFloat = 0 + + func body(content: Content) -> some View { + // Measure the wrapped content directly — reliable on first layout (`onAppear`) and on + // changes, without depending on a published preference. + content + .background( + GeometryReader { proxy in + Color.clear + .onAppear { update(proxy.size.height) } + .onChange(of: proxy.size.height) { update($0) } + } + ) + .presentationDetents(detents) + .presentationDragIndicator(.hidden) + } + + private var detents: Set { + let resolved = min(measured > 0 ? measured : fallback, maxSheetHeight) + // Before the first measurement (and when no fallback is provided) use .medium so the + // sheet is never given an invalid 0-height detent. + return resolved > 0 ? [.height(resolved)] : [.medium] + } + + private func update(_ newHeight: CGFloat) { + guard newHeight > 0, newHeight != measured else { return } + measured = newHeight + } + + private var maxSheetHeight: CGFloat { + #if canImport(UIKit) + let windowHeight = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .keyWindow?.bounds.height + let height = windowHeight ?? UIScreen.main.bounds.height + return height * maxHeightFraction + #else + return .greatestFiniteMagnitude + #endif + } +} + +@available(iOS 17, macOS 14, *) +#Preview("BottomSheet Filled Height") { + BottomSheet( + title: "Bottom Sheet", + showBackButton: .constant(true) + ) { + VStack(alignment: .leading, spacing: 16) { + Text("Greedy content") + .dashFont(.calloutMedium) + .foregroundColor(.dash.primaryText) + + Text("Fills the available sheet height.") + .dashFont(.body) + .foregroundColor(.dash.secondaryText) + } + .padding() + } +} + +@available(iOS 17, macOS 14, *) +#Preview("BottomSheet Natural Height") { + BottomSheet( + title: "Bottom Sheet", + showBackButton: .constant(false), + fillsHeight: false + ) { + VStack(alignment: .leading, spacing: 12) { + Text("Natural height content") + .dashFont(.calloutMedium) + .foregroundColor(.dash.primaryText) + + Text("Use this with selfSizingSheet() so the sheet snaps to content height.") + .dashFont(.body) + .foregroundColor(.dash.secondaryText) + } + .padding() + } +} diff --git a/Sources/DashUIKit/Components/DashAmount.swift b/Sources/DashUIKit/Components/DashAmount.swift new file mode 100644 index 0000000..7824df3 --- /dev/null +++ b/Sources/DashUIKit/Components/DashAmount.swift @@ -0,0 +1,159 @@ +// +// 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: - Sign control + +@available(iOS 14, macOS 11, *) +public enum DashAmountSign { + /// Never render a sign: "0.05 Ð". + case none + /// Only render the minus for negatives: "-0.05 Ð"; positives have no prefix. + case negativeOnly + /// Render +/-: "+0.05 Ð", "-0.05 Ð". + case always +} + +// MARK: - Internal formatter + +private enum DashAmountFormat { + static let duffsPerDash: Decimal = 100_000_000 + + static let numberFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.locale = .current + f.minimumFractionDigits = 0 + f.maximumFractionDigits = 5 + f.usesGroupingSeparator = true + return f + }() + + static func string(forDuffs duffs: Int64) -> String { + let value = Decimal(duffs) / duffsPerDash + return numberFormatter.string(from: value as NSNumber) ?? "\(value)" + } +} + +// MARK: - DashAmount view + +@available(iOS 14, macOS 11, *) +public struct DashAmount: View { + + public var amount: Int64 + public var fontSize: CGFloat + public var weight: Font.Weight + public var dashSymbolFactor: CGFloat + public var sign: DashAmountSign + + public init( + amount: Int64, + fontSize: CGFloat = 13, + weight: Font.Weight = .medium, + dashSymbolFactor: CGFloat = 1, + sign: DashAmountSign = .negativeOnly + ) { + self.amount = amount + self.fontSize = fontSize + self.weight = weight + self.dashSymbolFactor = dashSymbolFactor + self.sign = sign + } + + public var body: some View { + if amount == .max || amount == .min { + Text(NSLocalizedString("Not available", bundle: .module, comment: "DashUIKit")) + .font(.system(size: fontSize, weight: weight)) + } else { + HStack(spacing: 0) { + if let prefix = signPrefix { + Text(prefix) + .font(.system(size: fontSize, weight: weight)) + } + Text(DashAmountFormat.string(forDuffs: abs(amount))) + .font(.system(size: fontSize, weight: weight)) + .lineLimit(1) + Image(dash: .custom("icon_dash_currency", bundle: .dashUIKit)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: fontSize * dashSymbolFactor, + height: fontSize * dashSymbolFactor) + .padding(.leading, 2) + } + } + } + + private var signPrefix: String? { + guard amount != 0 else { return nil } + switch sign { + case .none: return nil + case .negativeOnly: return amount < 0 ? "-" : nil + case .always: return amount > 0 ? "+" : "-" + } + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("sign: .none (positive)") { + DashAmount(amount: 6_791_000, fontSize: 15, sign: .none) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("sign: .negativeOnly — positive (no prefix)") { + DashAmount(amount: 6_791_000, fontSize: 15, sign: .negativeOnly) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("sign: .negativeOnly — negative (-)") { + DashAmount(amount: -6_791_000, fontSize: 15, sign: .negativeOnly) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("sign: .always — positive (+)") { + DashAmount(amount: 6_791_000, fontSize: 15, sign: .always) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("sign: .always — negative (-)") { + DashAmount(amount: -6_791_000, fontSize: 15, sign: .always) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Zero (no sign in any mode)") { + VStack(spacing: 8) { + DashAmount(amount: 0, fontSize: 15, sign: .none) + DashAmount(amount: 0, fontSize: 15, sign: .negativeOnly) + DashAmount(amount: 0, fontSize: 15, sign: .always) + } + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Not available (Int64.max)") { + DashAmount(amount: .max, fontSize: 15) + .padding() +} + +#endif diff --git a/Sources/DashUIKit/Components/DashSwitch.swift b/Sources/DashUIKit/Components/DashSwitch.swift new file mode 100644 index 0000000..f65fd03 --- /dev/null +++ b/Sources/DashUIKit/Components/DashSwitch.swift @@ -0,0 +1,58 @@ +// +// 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. +// + +#if canImport(UIKit) +import SwiftUI + +// TODO: Add a custom DashToggleStyle using switchThumbFill / switchTrackFillOff / +// switchTrackFillOffDisabled tokens for full pixel-exact fidelity when needed. +@available(iOS 14, *) +public struct DashSwitch: View { + + @Binding private var isOn: Bool + + public init(isOn: Binding) { + self._isOn = isOn + } + + public var body: some View { + if #available(iOS 15, *) { + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(Color.dash.switchTrackFillOn as Color?) + } else { + Toggle("", isOn: $isOn) + .labelsHidden() + .accentColor(Color.dash.switchTrackFillOn) + } + } +} + +#if DEBUG + +@available(iOS 17, *) +#Preview { + @Previewable @State var on = true + VStack(spacing: 20) { + DashSwitch(isOn: $on) + DashSwitch(isOn: .constant(false)) + } + .padding() +} + +#endif +#endif diff --git a/Sources/DashUIKit/Components/MenuItem.swift b/Sources/DashUIKit/Components/MenuItem.swift new file mode 100644 index 0000000..fd7e6c0 --- /dev/null +++ b/Sources/DashUIKit/Components/MenuItem.swift @@ -0,0 +1,270 @@ +// +// 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 + +/// Finite set of trailing accessories for `MenuItem`. +/// Add a new case here — not a per-call-site font/color override — when a +/// new trailing look is needed, to keep all rows consistent. +@available(iOS 14, macOS 11, *) +public enum MenuItemAccessory { + case none + case toggle(isOn: Binding) + case text(String) + case button(DashButton) + /// Dash amount with an optional pre-formatted fiat sub-line. + /// The caller converts the fiat value via its own exchange infrastructure; + /// the library only renders the string it receives. + case balance(dash: Int64, sign: DashAmountSign = .negativeOnly, fiat: String? = nil) +} + +@available(iOS 14, macOS 11, *) +public struct MenuItem: View { + + public var leadingIcon: DashIconSource? + public var isEnabled: Bool + public var disabledLeadingIcon: DashIconSource? + public var title: String + public var helpText: String? + public var infoIcon: DashIconSource? + public var accessory: MenuItemAccessory + + public init( + leadingIcon: DashIconSource? = nil, + isEnabled: Bool = true, + disabledLeadingIcon: DashIconSource? = nil, + title: String, + helpText: String? = nil, + infoIcon: DashIconSource? = nil, + accessory: MenuItemAccessory = .none + ) { + self.leadingIcon = leadingIcon + self.isEnabled = isEnabled + self.disabledLeadingIcon = disabledLeadingIcon + self.title = title + self.helpText = helpText + self.infoIcon = infoIcon + self.accessory = accessory + } + + public var body: some View { + HStack(spacing: 10) { + leading + central + Spacer() + trailing + } + .padding(10) + } + + @ViewBuilder + private var leading: some View { + let icon = isEnabled ? leadingIcon : (disabledLeadingIcon ?? leadingIcon) + if let icon { + Image(dash: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + } + + private var central: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(title) + .dashFont(.subheadMedium) + .foregroundColor(isEnabled ? Color.dash.primaryText : Color.dash.secondaryText) + + if let icon = infoIcon { + Image(dash: icon) + .frame(width: 20, height: 20, alignment: .center) + } + } + + if let helpText { + Text(helpText) + .dashFont(.footnote) + .foregroundColor(isEnabled ? Color.dash.secondaryText : Color.dash.tertiaryText) + } + } + .padding(.leading, 6) + } + + @ViewBuilder + private var trailing: some View { + switch accessory { + case .none: + EmptyView() + case .toggle(let isOn): + Toggle("", isOn: isOn) + .labelsHidden() + .disabled(!isEnabled) + case .text(let value): + Text(value) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + case .button(let button): + button + case .balance(let dash, let sign, let fiat): + VStack(alignment: .trailing, spacing: 1) { + DashAmount(amount: dash, sign: sign) + .foregroundColor(Color.dash.primaryText) + + if dash != 0, dash != .max, dash != .min, let fiat { + Text(fiat) + .dashFont(.footnote) + .foregroundColor(Color.dash.secondaryText) + } + } + } + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("Title only") { + MenuItem(title: "Notifications") + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Title + helpText") { + MenuItem( + title: "Recovery phrase", + helpText: "Back up your wallet to keep your funds safe" + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Title + infoIcon + helpText") { + MenuItem( + title: "Network fee", + helpText: "Estimated cost for this transaction", + infoIcon: .system("info.circle"), + accessory: .text("0.0001 DASH") + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: toggle") { + @Previewable @State var isOn = true + MenuItem( + title: "Enable biometrics", + accessory: .toggle(isOn: $isOn) + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: text") { + MenuItem( + title: "Balance", + accessory: .text("0.0001 DASH") + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: button") { + MenuItem( + title: "Withdraw", + accessory: .button(DashButton( + text: "Withdraw", + size: .small, + style: .tintedBlue, + action: {} + )) + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: balance default (.negativeOnly)") { + VStack(spacing: 0) { + MenuItem( + title: "Staking balance", + accessory: .balance(dash: 6_791_000, fiat: "$1.23") + ) + Divider().padding(.leading, 16) + MenuItem( + title: "Outgoing", + accessory: .balance(dash: -6_791_000, fiat: "$1.23") + ) + } + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: balance .always (transaction style)") { + VStack(spacing: 0) { + MenuItem( + title: "Received", + accessory: .balance(dash: 6_791_000, sign: .always, fiat: "$1.23") + ) + Divider().padding(.leading, 16) + MenuItem( + title: "Sent", + accessory: .balance(dash: -6_791_000, sign: .always, fiat: "$1.23") + ) + } + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: balance zero") { + MenuItem( + title: "Available", + accessory: .balance(dash: 0) + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Accessory: balance not available") { + MenuItem( + title: "Pending", + accessory: .balance(dash: .max) + ) + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Enabled vs disabled") { + VStack(spacing: 0) { + MenuItem( + leadingIcon: .custom("menu-receive", bundle: .dashUIKit), + title: "Buy Dash", + helpText: "From any crypto to your Dash Wallet" + ) + + Divider().padding(.leading, 16) + + MenuItem( + leadingIcon: .custom("menu-receive", bundle: .dashUIKit), + isEnabled: false, + disabledLeadingIcon: .custom("menu-receive.disabled", bundle: .dashUIKit), + title: "Buy Dash", + helpText: "From any crypto to your Dash Wallet" + ) + } + .padding(.horizontal) +} + +#endif diff --git a/Sources/DashUIKit/Components/NavigationBar.swift b/Sources/DashUIKit/Components/NavigationBar.swift new file mode 100644 index 0000000..0b4ef96 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -0,0 +1,216 @@ +// +// 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, *) +public struct NavigationBar: View { + private let leading: Leading + private let central: Central + private let trailing: Trailing + + public init( + @ViewBuilder leading: () -> Leading, + @ViewBuilder central: () -> Central, + @ViewBuilder trailing: () -> Trailing + ) { + self.leading = leading() + self.central = central() + self.trailing = trailing() + } + + public var body: some View { + ZStack { + HStack { + leading + + Spacer() + + trailing + } + .padding(.horizontal, 20) + + central + } + .frame(maxWidth: .infinity, minHeight: 64) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Trailing == EmptyView { + init(@ViewBuilder leading: () -> Leading, @ViewBuilder central: () -> Central) { + self.init(leading: leading, central: central, trailing: { EmptyView() }) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Central == EmptyView { + init(@ViewBuilder leading: () -> Leading, @ViewBuilder trailing: () -> Trailing) { + self.init(leading: leading, central: { EmptyView() }, trailing: trailing) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Leading == EmptyView { + init(@ViewBuilder central: () -> Central, @ViewBuilder trailing: () -> Trailing) { + self.init(leading: { EmptyView() }, central: central, trailing: trailing) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Central == EmptyView, Trailing == EmptyView { + init(@ViewBuilder leading: () -> Leading) { + self.init(leading: leading, central: { EmptyView() }, trailing: { EmptyView() }) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Leading == EmptyView, Trailing == EmptyView { + init(@ViewBuilder central: () -> Central) { + self.init(leading: { EmptyView() }, central: central, trailing: { EmptyView() }) + } +} + +@available(iOS 14, macOS 11, *) +public extension NavigationBar where Leading == EmptyView, Central == EmptyView { + init(@ViewBuilder trailing: () -> Trailing) { + self.init(leading: { EmptyView() }, central: { EmptyView() }, trailing: trailing) + } +} + +@available(iOS 14, macOS 11, *) +public enum NavigationBarElement: String { + case back = "navigationbar-back" + case close = "navigationbar-close" + case plus = "navigationbar-plus" + case info = "navigationbar-info" + + public var icon: some View { + Image(dash: .custom(rawValue, bundle: .module)) + .resizable() + .scaledToFit() + } + + public func button(action: @escaping () -> Void) -> some View { + Button(action: action) { + icon + .fixedSize() + .frame(width: 44, height: 44, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(NavigationBarButtonStyle()) + .accessibilityLabel(Text(accessibilityLabelText)) + } + + var accessibilityLabelText: String { + switch self { + case .back: + return NSLocalizedString("Back", bundle: .module, comment: "DashUIKit navigation bar") + case .close: + return NSLocalizedString("Close", bundle: .module, comment: "DashUIKit navigation bar") + case .plus: + return NSLocalizedString("Add", bundle: .module, comment: "DashUIKit navigation bar") + case .info: + return NSLocalizedString("Info", bundle: .module, comment: "DashUIKit navigation bar") + } + } +} + +@available(iOS 14, macOS 11, *) +private struct NavigationBarButtonStyle: ButtonStyle { + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + let content = configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1) + .opacity(configuration.isPressed ? 0.7 : 1) + + if #available(iOS 15, macOS 12, *) { + content.animation(.easeInOut(duration: 0.12), value: configuration.isPressed) + } else { + content.animation(.easeInOut(duration: 0.12)) + } + } +} + +@available(iOS 17, macOS 14, *) +#Preview("NavigationBar Back") { + NavigationBar( + leading: { NavigationBarElement.back.button { } } + ) +} + +@available(iOS 17, macOS 14, *) +#Preview("NavigationBar Back Title") { + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + central: { + Text("Title") + .dashFont(.subheadMedium) + .foregroundColor(.dash.primaryText) + } + ) +} + +@available(iOS 17, macOS 14, *) +#Preview("NavigationBar Back Title Info") { + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + central: { + Text("Title") + .dashFont(.subheadMedium) + .foregroundColor(.dash.primaryText) + }, + trailing: { NavigationBarElement.info.button { } } + ) +} + +@available(iOS 17, macOS 14, *) +#Preview("NavigationBar Variants") { + VStack { + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { NavigationBarElement.info.button { } } + ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { NavigationBarElement.back.button { } } + ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { NavigationBarElement.close.button { } } + ) + + NavigationBar( + leading: { NavigationBarElement.back.button { } }, + trailing: { NavigationBarElement.plus.button { } } + ) + } +} + +@available(iOS 17, macOS 14, *) +#Preview("NavigationBar Title Close") { + NavigationBar( + central: { + Text("Title") + .dashFont(.subheadMedium) + .foregroundColor(.dash.primaryText) + }, + trailing: { NavigationBarElement.close.button { } } + ) +} diff --git a/Sources/DashUIKit/Components/RadioButtonRow.swift b/Sources/DashUIKit/Components/RadioButtonRow.swift new file mode 100644 index 0000000..c6bfa4e --- /dev/null +++ b/Sources/DashUIKit/Components/RadioButtonRow.swift @@ -0,0 +1,131 @@ +// +// Created by Andrei Ashikhmin +// Copyright © 2025 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 struct RadioButtonRow: View { + + public enum Style { + case radio + case checkbox + } + + private let title: String + private let subtitle: String? + private let trailingText: String? + private let icon: DashIconSource? + private let isSelected: Bool + private let style: Style + private let action: () -> Void + + public init( + title: String, + subtitle: String? = nil, + trailingText: String? = nil, + icon: DashIconSource? = nil, + isSelected: Bool, + style: Style = .radio, + action: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.trailingText = trailingText + self.icon = icon + self.isSelected = isSelected + self.style = style + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: 16) { + if let icon = icon { + Image(dash: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + + VStack(alignment: .leading, spacing: 0) { + Text(title) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.primaryText) + + if let subtitle = subtitle { + Text(subtitle) + .dashFont(.caption1) + .foregroundColor(Color.dash.secondaryText) + } + } + + Spacer() + + if let trailingText = trailingText { + Text(trailingText) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.primaryText) + } + + switch style { + case .radio: + Circle() + .stroke(isSelected ? Color.dash.blue : Color.dash.gray300.opacity(0.5), lineWidth: isSelected ? 6 : 2) + .frame(width: isSelected ? 21 : 24, height: isSelected ? 21 : 24) + .padding(.trailing, isSelected ? 2 : 0) + case .checkbox: + Image(dash: .custom(isSelected ? "icon_checkbox_square_checked" : "icon_checkbox_square", bundle: .dashUIKit)) + .resizable() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 16) + .contentShape(Rectangle()) + .frame(minHeight: subtitle != nil ? 60 : 54) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct RadioButtonRow_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 0) { + RadioButtonRow(title: "Radio selected", isSelected: true) {} + RadioButtonRow(title: "Radio unselected", isSelected: false) {} + RadioButtonRow(title: "Checkbox selected", isSelected: true, style: .checkbox) {} + RadioButtonRow(title: "Checkbox unselected", isSelected: false, style: .checkbox) {} + RadioButtonRow( + title: "With subtitle and trailing", + subtitle: "Secondary text", + trailingText: "-10%", + isSelected: true + ) {} + RadioButtonRow( + title: "With icon", + icon: .system("star.fill"), + isSelected: false + ) {} + } + .previewLayout(.sizeThatFits) + } +} + +#endif diff --git a/Sources/DashUIKit/Components/SearchBar.swift b/Sources/DashUIKit/Components/SearchBar.swift new file mode 100644 index 0000000..1cad5c2 --- /dev/null +++ b/Sources/DashUIKit/Components/SearchBar.swift @@ -0,0 +1,228 @@ +// +// 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. +// + +#if canImport(UIKit) +import SwiftUI + +// MARK: - Public shell (iOS 14+) + +@available(iOS 14, *) +public struct SearchBar: View { + @Binding private var text: String + private let placeholder: String + + public init( + text: Binding, + placeholder: String? = nil + ) { + self._text = text + self.placeholder = placeholder + ?? NSLocalizedString("Search", bundle: .module, comment: "DashUIKit") + } + + public var body: some View { + if #available(iOS 15, *) { + SearchBarFocused(text: $text, placeholder: placeholder) + } else { + SearchBarLegacy(text: $text, placeholder: placeholder) + } + } +} + +// MARK: - iOS 15+ (focus-driven cancel button) + +@available(iOS 15, *) +private struct SearchBarFocused: View { + private enum Layout { + static let fieldHeight: CGFloat = 40 + static let fieldCornerRadius: CGFloat = 14 + static let fieldHorizontalPadding: CGFloat = 14 + static let fieldSpacing: CGFloat = 10 + static let cancelHorizontalPadding: CGFloat = 12 + static let cancelVerticalPadding: CGFloat = 6 + static let animationDuration: TimeInterval = 0.25 + } + + @Binding var text: String + let placeholder: String + + @FocusState private var isFocused: Bool + @State private var isEditing: Bool = false + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: Layout.fieldSpacing) { + magnifyingGlass + searchField + clearButton + } + .padding(.horizontal, Layout.fieldHorizontalPadding) + .frame(height: Layout.fieldHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.dash.searchBackground) + .clipShape(RoundedRectangle(cornerRadius: Layout.fieldCornerRadius, style: .continuous)) + + if isEditing { + cancelButton + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: Layout.animationDuration), value: isEditing) + .onAppear { + isEditing = isFocused + } + .onChange(of: isFocused) { focused in + withAnimation(.easeInOut(duration: Layout.animationDuration)) { + isEditing = focused + } + } + } + + private var magnifyingGlass: some View { + Image(dash: .custom("searchbar-magnifyingglass-icon", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 15) + } + + @ViewBuilder + private var clearButton: some View { + if !text.isEmpty { + Button( + action: { text = "" }, + label: { + Image(dash: .custom("searchbar-xmark-icon", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 15) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + ) + .accessibilityLabel(Text(NSLocalizedString("Clear", bundle: .module, comment: "DashUIKit"))) + } + } + + private var cancelButton: some View { + Button( + action: { + text = "" + withAnimation(.easeInOut(duration: Layout.animationDuration)) { + isEditing = false + } + isFocused = false + }, + label: { + Text(NSLocalizedString("Cancel", bundle: .module, comment: "DashUIKit")) + .font(Font.dash.footnote.weight(.semibold)) + .padding(.horizontal, Layout.cancelHorizontalPadding) + .padding(.vertical, Layout.cancelVerticalPadding) + } + ) + .tint(Color.dash.primaryText as Color?) + } + + @ViewBuilder + private var searchField: some View { + if #available(iOS 17.0, *) { + TextField( + text: $text, + prompt: Text(placeholder) + .font(Font.dash.subhead) + .foregroundStyle(Color.dash.black1000Alpha30) + ) { + EmptyView() + } + .focused($isFocused) + .tint(Color.dash.primaryText as Color?) + } else { + TextField(placeholder, text: $text) + .focused($isFocused) + .tint(Color.dash.primaryText as Color?) + .foregroundColor(Color.dash.primaryText) + } + } +} + +// MARK: - iOS 14 fallback (no focus state — static bar without cancel animation) + +@available(iOS 14, *) +private struct SearchBarLegacy: View { + private enum Layout { + static let fieldHeight: CGFloat = 40 + static let fieldCornerRadius: CGFloat = 14 + static let fieldHorizontalPadding: CGFloat = 14 + static let fieldSpacing: CGFloat = 10 + } + + @Binding var text: String + let placeholder: String + + var body: some View { + HStack(spacing: Layout.fieldSpacing) { + magnifyingGlass + TextField(placeholder, text: $text) + .accentColor(Color.dash.primaryText) + .foregroundColor(Color.dash.primaryText) + clearButton + } + .padding(.horizontal, Layout.fieldHorizontalPadding) + .frame(height: Layout.fieldHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.dash.searchBackground) + .clipShape(RoundedRectangle(cornerRadius: Layout.fieldCornerRadius, style: .continuous)) + } + + private var magnifyingGlass: some View { + Image(dash: .custom("searchbar-magnifyingglass-icon", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 15) + } + + @ViewBuilder + private var clearButton: some View { + if !text.isEmpty { + Button( + action: { text = "" }, + label: { + Image(dash: .custom("searchbar-xmark-icon", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 15) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + ) + .accessibilityLabel(Text(NSLocalizedString("Clear", bundle: .module, comment: "DashUIKit"))) + } + } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview { + @Previewable @State var text = "" + SearchBar(text: $text) + .padding() +} + +#endif // DEBUG +#endif // canImport(UIKit) diff --git a/Sources/DashUIKit/Components/SystemMessageView.swift b/Sources/DashUIKit/Components/SystemMessageView.swift new file mode 100644 index 0000000..e92d8bc --- /dev/null +++ b/Sources/DashUIKit/Components/SystemMessageView.swift @@ -0,0 +1,144 @@ +// +// 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, *) +public struct SystemMessageView: View { + + public let title: String + public let subtitle: String? + public let icon: DashIconSource + public let backgroundColor: Color + public let buttonName: String? + public let onAction: (() -> Void)? + public let secondaryButtonName: String? + public let onSecondaryAction: (() -> Void)? + public let onClose: (() -> Void)? + public init( + title: String, + subtitle: String? = nil, + icon: DashIconSource = .custom("warning_triangle", bundle: .dashUIKit), + backgroundColor: Color = Color.dash.gray300Alpha10, + buttonName: String? = nil, + onAction: (() -> Void)? = nil, + secondaryButtonName: String? = nil, + onSecondaryAction: (() -> Void)? = nil, + onClose: (() -> Void)? = nil + ) { + self.title = title + self.subtitle = subtitle + self.icon = icon + self.backgroundColor = backgroundColor + self.buttonName = buttonName + self.onAction = onAction + self.secondaryButtonName = secondaryButtonName + self.onSecondaryAction = onSecondaryAction + self.onClose = onClose + } + + public var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(dash: icon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .dashFont(.subhead) + .foregroundColor(Color.dash.primaryText) + + if let subtitle { + Text(subtitle) + .dashFont(.subhead) + .foregroundColor(Color.dash.secondaryText) + .multilineTextAlignment(.leading) + } + } + + if (buttonName != nil && onAction != nil) || (secondaryButtonName != nil && onSecondaryAction != nil) { + HStack(spacing: 10) { + if let buttonName, let onAction { + DashUIKit.DashButton( + text: buttonName, + size: .small, + style: .filledBlue, + action: onAction + ) + } + if let secondaryButtonName, let onSecondaryAction { + DashUIKit.DashButton( + text: secondaryButtonName, + size: .small, + style: .tintedBlue, + action: onSecondaryAction + ) + } + } + .padding(.bottom, 14) + } + } + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .topLeading) + + if onClose != nil { + Button(action: { onClose?() }) { + XmarkIcon(size: 9, color: Color.dash.primaryText) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .accessibilityLabel(NSLocalizedString("Close", bundle: .module, comment: "DashUIKit")) + } + } + .padding(10) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview("Default (back-compat)") { + SystemMessageView( + title: "You have a balance on CrowdNode", + subtitle: "These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device." + ) + .padding() +} + +@available(iOS 17, macOS 14, *) +#Preview("Custom icon + background + two buttons + close") { + SystemMessageView( + title: "Address Expired", + subtitle: "The receiving address has expired. Generate a new one or extend the expiry.", + icon: .system("clock.badge.exclamationmark"), + backgroundColor: Color.dash.blueAlpha5, + buttonName: "Renew", + onAction: {}, + secondaryButtonName: "Extend", + onSecondaryAction: {}, + onClose: {} + ) + .padding() +} + +#endif // DEBUG diff --git a/Sources/DashUIKit/Components/Toast.swift b/Sources/DashUIKit/Components/Toast.swift new file mode 100644 index 0000000..84dfbf7 --- /dev/null +++ b/Sources/DashUIKit/Components/Toast.swift @@ -0,0 +1,156 @@ +// +// 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. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit + +// MARK: - BackgroundBlurView + +@available(iOS 14, *) +struct BackgroundBlurView: UIViewRepresentable { + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) {} +} + +// MARK: - ToastStyle + +/// NOTE: Create the 6 imagesets listed below under +/// `Media.xcassets/Icons & Illustrations/Toast/` before shipping. +/// Until they exist, the icon slot renders empty — that is expected. +/// +/// toast-warning.imageset +/// toast-info.imageset +/// toast-error.imageset +/// toast-success.imageset +/// toast-copied.imageset +/// toast-loading.imageset (optional — `loading` uses `LoadingSpinner`) +@available(iOS 14, macOS 11, *) +public enum ToastStyle { + case warning, info, error, success, copied, loading, noInternet + + /// Placeholder asset names — REAL ICONS TO BE ADDED by the user into + /// `Media.xcassets/Icons & Illustrations/Toast/`. + /// (`loading` renders the `LoadingSpinner` instead — see `Toast.leadingIcon`.) + var iconName: String { + switch self { + case .warning: return "toast-warning" + case .info: return "toast-info" + case .error: return "toast-error" + case .success: return "toast-success" + case .copied: return "toast-copied" + case .loading: return "toast-loading" + case .noInternet: return "toast-no-wifi" + } + } +} + +// MARK: - Toast + +@available(iOS 14, macOS 11, *) +public struct Toast: View { + + private let style: ToastStyle + private let message: String + private let onDismiss: (() -> Void)? + + public init( + style: ToastStyle, + message: String, + onDismiss: (() -> Void)? = nil + ) { + self.style = style + self.message = message + self.onDismiss = onDismiss + } + + public var body: some View { + HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 0) { + leadingIcon + .frame(width: 24, height: 24) + + Text(message) + .dashFont(.footnoteMedium) + .foregroundColor(Color.dash.toastText) + .padding(.vertical, 3) + .padding(.leading, 8) + } + .padding(.trailing, 8) + + if let onDismiss { + Spacer(minLength: 10) + + Button(action: onDismiss) { + XmarkIcon(color: Color.dash.toastText) + .padding(8) + .background(Circle().fill(Color.dash.whiteAlpha10)) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 8) + .background( + ZStack { + BackgroundBlurView() + Color.dash.toastBackground + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var leadingIcon: some View { + if case .loading = style { + // Reuse the design-system spinner (12 white spokes at graduated opacity, 18×18). + // LoadingSpinner applies its own per-spoke opacity on top of toastText. + LoadingSpinner(size: 16, color: Color.dash.toastText) + } else { + Image(dash: .custom(style.iconName, bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview { + VStack(spacing: 12) { + Toast(style: .warning, message: "Sgome coins are not available", onDismiss: {}) + Toast(style: .info, message: "Heads up", onDismiss: {}) + Toast(style: .error, message: "Something went wrong", onDismiss: {}) + Toast(style: .success, message: "Done", onDismiss: {}) + Toast(style: .copied, message: "Copied to clipboard") + Toast(style: .loading, message: "Loading…") + } + .padding() + .background(Color.dash.blue) +} + +#endif +#endif // canImport(UIKit) diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/Contents.json new file mode 100644 index 0000000..4e64eaf --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "receive-disabled.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "receive-disabled@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "receive-disabled@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled.png new file mode 100644 index 0000000..8835adf Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@2x.png new file mode 100644 index 0000000..7ac0110 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@3x.png new file mode 100644 index 0000000..1fd1da5 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/Contents.json new file mode 100644 index 0000000..33bb111 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "send-disabled.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "send-disabled@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "send-disabled@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled.png new file mode 100644 index 0000000..b1c4705 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@2x.png new file mode 100644 index 0000000..09cf1a0 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@3x.png new file mode 100644 index 0000000..38f51df Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/Contents.json new file mode 100644 index 0000000..39c327c --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "info-rect.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "info-rect@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "info-rect@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect.png new file mode 100644 index 0000000..0e3a619 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@2x.png new file mode 100644 index 0000000..40b701c Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@3x.png new file mode 100644 index 0000000..8fdf209 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@3x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/Contents.json new file mode 100644 index 0000000..5f2fac1 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toast-no-wifi.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toast-no-wifi@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toast-no-wifi@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi.png new file mode 100644 index 0000000..b5e2207 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@2x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@2x.png new file mode 100644 index 0000000..da938d9 Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@2x.png differ diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@3x.png b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@3x.png new file mode 100644 index 0000000..3758a0f Binary files /dev/null and b/Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@3x.png differ diff --git a/Sources/DashUIKit/Table List/List1View.swift b/Sources/DashUIKit/Table List/List1View.swift new file mode 100644 index 0000000..7c36767 --- /dev/null +++ b/Sources/DashUIKit/Table List/List1View.swift @@ -0,0 +1,51 @@ +// +// 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, *) +public struct List1View: View { + + public var label: String + public var value: String + + public init(label: String = "Label", value: String = "Value") { + self.label = label + self.value = value + } + + public var body: some View { + HStack { + Text(label) + .dashFont(.subheadMedium) + .foregroundColor(Color.dash.tertiaryText) + + Text(value) + .dashFont(.subhead) + .foregroundColor(Color.dash.primaryText) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } +} + +@available(iOS 17, macOS 14, *) +#Preview { + List1View() +}