From 700b9ce0ab9b528a11f00d0171ab5ef77219fe1d Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:39:08 +0300 Subject: [PATCH 1/2] feat: add core components Add the core presentational components on top of Foundation + primitives: SearchBar, Toast, BottomSheet, NavigationBar, MenuItem, RadioButtonRow, DashSwitch, SystemMessageView, AddressFieldView, DashAmount, List1View, plus the assets they use (menu-*.disabled, info-rect, toast-no-wifi). iOS 14 compatibility fixes applied during migration: - SystemMessageView: add missing @available(iOS 14, macOS 11, *) (was absent, broke the build) - SystemMessageView / AddressFieldView / SearchBar / Toast: replace iOS 16+ .rect(cornerRadius:) and .contentShape(.rect) with RoundedRectangle(..., style: .continuous) / Rectangle() so the declared iOS 14/15 surface holds. --- .../Components/AddressFieldView.swift | 294 ++++++++++++++++++ .../DashUIKit/Components/BottomSheet.swift | 285 +++++++++++++++++ Sources/DashUIKit/Components/DashAmount.swift | 159 ++++++++++ Sources/DashUIKit/Components/DashSwitch.swift | 58 ++++ Sources/DashUIKit/Components/MenuItem.swift | 269 ++++++++++++++++ .../DashUIKit/Components/NavigationBar.swift | 196 ++++++++++++ .../DashUIKit/Components/RadioButtonRow.swift | 130 ++++++++ Sources/DashUIKit/Components/SearchBar.swift | 228 ++++++++++++++ .../Components/SystemMessageView.swift | 144 +++++++++ Sources/DashUIKit/Components/Toast.swift | 156 ++++++++++ .../Contents.json | 23 ++ .../receive-disabled.png | Bin 0 -> 697 bytes .../receive-disabled@2x.png | Bin 0 -> 1224 bytes .../receive-disabled@3x.png | Bin 0 -> 1695 bytes .../menu-send.disabled.imageset/Contents.json | 23 ++ .../send-disabled.png | Bin 0 -> 710 bytes .../send-disabled@2x.png | Bin 0 -> 1221 bytes .../send-disabled@3x.png | Bin 0 -> 1705 bytes .../info-rect.imageset/Contents.json | 23 ++ .../info-rect.imageset/info-rect.png | Bin 0 -> 507 bytes .../info-rect.imageset/info-rect@2x.png | Bin 0 -> 850 bytes .../info-rect.imageset/info-rect@3x.png | Bin 0 -> 1259 bytes .../toast-no-wifi.imageset/Contents.json | 23 ++ .../toast-no-wifi.imageset/toast-no-wifi.png | Bin 0 -> 510 bytes .../toast-no-wifi@2x.png | Bin 0 -> 787 bytes .../toast-no-wifi@3x.png | Bin 0 -> 1173 bytes Sources/DashUIKit/Table List/List1View.swift | 51 +++ 27 files changed, 2062 insertions(+) create mode 100644 Sources/DashUIKit/Components/AddressFieldView.swift create mode 100644 Sources/DashUIKit/Components/BottomSheet.swift create mode 100644 Sources/DashUIKit/Components/DashAmount.swift create mode 100644 Sources/DashUIKit/Components/DashSwitch.swift create mode 100644 Sources/DashUIKit/Components/MenuItem.swift create mode 100644 Sources/DashUIKit/Components/NavigationBar.swift create mode 100644 Sources/DashUIKit/Components/RadioButtonRow.swift create mode 100644 Sources/DashUIKit/Components/SearchBar.swift create mode 100644 Sources/DashUIKit/Components/SystemMessageView.swift create mode 100644 Sources/DashUIKit/Components/Toast.swift create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/Contents.json create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@2x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-receive.disabled.imageset/receive-disabled@3x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/Contents.json create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@2x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Menu/menu-send.disabled.imageset/send-disabled@3x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/Contents.json create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@2x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/System message/info-rect.imageset/info-rect@3x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/Contents.json create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@2x.png create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Icons & Illustrations/Toast/toast-no-wifi.imageset/toast-no-wifi@3x.png create mode 100644 Sources/DashUIKit/Table List/List1View.swift 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..b49f416 --- /dev/null +++ b/Sources/DashUIKit/Components/MenuItem.swift @@ -0,0 +1,269 @@ +// +// 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() + 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..1ccf340 --- /dev/null +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -0,0 +1,196 @@ +// +// 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()) + } +} + +@available(iOS 14, macOS 11, *) +private struct NavigationBarButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1) + .opacity(configuration.isPressed ? 0.7 : 1) + .animation(.easeInOut(duration: 0.12), value: configuration.isPressed) + } +} + +@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..58d09ef --- /dev/null +++ b/Sources/DashUIKit/Components/RadioButtonRow.swift @@ -0,0 +1,130 @@ +// +// 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()) + } +} + +#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..f98bba5 --- /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 || secondaryButtonName != 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 0000000000000000000000000000000000000000..8835adfd9fb92e8cc35588f45fc788c9be5c988e GIT binary patch literal 697 zcmV;q0!ICbP)EQ;Fg#2Gw<_Ke%=roQw< zSO>3wi|x&!KCVO*Ipbk@+j+$eV$B8GXf&|cY{D*iaZaEGtJ4c9SKy%5v#0sTQLX&l zIxqu&1xGD57bt4a)g47TYQV11UQ$6`fXd+VR$POIRj&kwM1bp-pi)}{o;PP*$bJ=k z!x|q;&Qm&EM(2nA13Y>BXv+4Yvz&kb_`QYy>7MaP+^=e^D~`4~}3JMfgr%q*WYyY|r0kKF*voKuJkSNgqMzA#STx0|x63V>dI`K{PC<<;TM>Rl$@0 zJpwKHj6dNx?Pg=dLyxd7*mW0%oo)UEGsZMw-o$PMX_N2`-G-O)`j1)rSiu zRv~o>>Xd@Sf>oa_l-NzAPBUkuZi$O0z-`UT2+2RAz{mhP**th)Dz03t;9#DW=^(q; z^v38g4eW`Vr(>Hm!9QkGY_(d&uG&uQ{A@>XY>{E&Zfw(zv~XzM?iE;M-`;QW@kbv9 z$9L}C$Juk|VG|}EX*LeDP1hqW99q{N)oSI1ScN*?KC}%s+3@F$7zHcqDt8VRsa7g@ z1JsqjV7h3wu;1Mhx6ulN>&Chp1ryO>^eIyJBc^Lw!9jB_{xlRtEsVCjj={oy9sQ0! zSl6A_3--BqE@W!-8nl9mup51jX!u@t-q56}hW5y!EAU7qLzYLsaOaMuu9Bn(Dz}7mXMPN z4<7};v^;tG40k0kkRQDU!=UA~VS(SzU%=Y>dgAg*?AddFzzU9DgH~`D2vd36d?*2n z7yeFMe);0%;GX=DXA0&S=V5057g%8B=51W^uEm!7SHxbuz5*La@_oj1&FpxrR$^nk zN%`xa|04Udvg-2x7Gs6ZfU)jI!5jISHY{Q4&sO`bLh{H_vFS+HQbL}`VF_J-Rv$oF z-%h^`QAzWt>#^>|^yxWQ{#Z+RD@S9;r=JAJs(;%QtE@ZL7(E(QB-T5S^u?qE7?(E!@ghD5<1%!Qef2Q15 z=9rdxArl?7vB*dcb%L!88N~)qKu*H6YWF93&=K?!ho+gG$-G++Ep9IDneeu9aig;O zX|TV2SP7Tha+q$-zI`}Yp2~_X?VuF4B}`( mXlb?GY&1NSl$4Z|0M7xQS+8sOXoyDu0000c$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1fd1da53212723b0fcbab49a6b812d0796afe673 GIT binary patch literal 1695 zcmV;Q24MM#P)x1KJ-=x9N|m|2q~82kLZi^CLk$Vln7LE$z5(G`KgNb z<+_t3#D#cbl~@vjmhq3Xl&17lL>E@q2mO|diwVhXfNbp~rNf!ej}7g>Fh)hQU=lPd ze;q`c5G|S(ZbURk1ogTLvg5jlR>Gk0#6>TEwsA?gG-oa%qCz_Y?#KD@k;_Ps#6%ZX zH*+LvpCIj+P^Ty;GnG{9PDHey#g3FLa%d}L>(18l`O(o5G~+-s92K&|+iX9{jL1>d z*($88PeU_WMK7;jofR$;%{mjv3!Vl3EYOIy={MW||%TH6^;RwmCr6+7`M1L1HLBHc~`PVt0n#IJxQ;&?WG2l|M6L z5?i!?G187hxVVVJ3$R$k63w}c_POHI$za?ucI1rqFRk4+y&%y-Sv<^~i8{BPMMMbh zY+8~}y?PQSUwIilJz4DjvWGjr?jZ&InMhzN%8Mek==wy_Pe_6DulM7Pb7vlV?3)|6 zu)T8!X%KZ35-Fqo)|?K98_f`jB38GKMQMl*`J!{vZ;3!nI>{;K2B@h)y zDxIN?j&Pcbj(W)3VO=Y^8o~l7GztqDhHXsMj0%ctY}>Xi#~p4 z#1cTTEA4ylERC_E4PiwaB3Lw^?-mqc#`lc_vzP8%B8Cl%CGt0{rCR&*n|Cj z^Q+{1s5F3J(XLYh)nEda{O((CA}Wp66Zx^1UVOn@Q#7_p9wIslQn1|G)<6kR_!V?=gZ+XggI4%@k;GV0KNaHbKQtGc=S$2}M#)KREtA@DT}uyy@A z98{~&#e@9{ZvMCp6R0=}Z_$oef&o~}m)EXC7dL;{_TJM73XZ}>`yp>6r|<~l0gn;9EZ$IWa0HW*E;vPYEG%tSwxwX(4&U!=5yQYSnk*DoiDo`3FH92`_}U&h7l zpLf0AjU!w0MzA82Ne6z{nEaYBWK;VBjXY0FlkS zlOG+5J?GQZb^{(;%e$HL0hX@Xcl8>6FG)p3$3nYp$$K-Em4ElXySJJn2) zswa`iCP%LADahf0eG%YsK5BDm%=!hw zg}N)wjEUO+hD*=4jPN4QttS-|jY#Qc3WvgJ!sN{qPskY;Sm@#v}QBu$0d{HoAoqlc|+Z ptCjO(V>_@|EEbE!VzF3~=U-`1nc0!r6R`jQ002ovPDHLkV1mP-E2;nh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b1c470566f5b8e2c94c998107c21bc2fde79763e GIT binary patch literal 710 zcmV;%0y+JOP)zj3T;P{9-JQroc)#-1*gJl5J9V5|z#R+1NJ;v2C*m zHd!!onLPVSk8MnYUI5<;x_(mIrS6BY7kMldAw}roO0YyaXF<^E#MZ)@Xf$zK?dVHK z_*LR1;okBcT}X)ZE-2gd4gHP&@T%^B$zb}aas7( zQH_1fkV*py0$+*7m=$zY=3mDS7unhtEKw~PluWrMDG7=>| z7^E;l(SGi(ZRDFu=_J{M4Rw{lfQr^iR9%Zyjg(rex`w?T6#`sEe$7IFQ6WHgamO#6 zok{~y6@#iAY$Bf^n$(Hb3XjC8a*)6_re{}I9W|nQS+GFgT#~I*=Lz>UqjZ$9%8YB5 sxAlwQ9;6ul|JrKF^~iuSFSw_Yb8gYms0Hp~=Kufz07*qoM6N<$f&kY&djJ3c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..09cf1a08ef422c86d8313a490a69a0ac00ad1946 GIT binary patch literal 1221 zcmV;$1UmbPP)8`5ou76I~?VJK~a&mHd2ttauwOR=nC_9XU%v=UhQIh50;wx2X$aRB2 ziyq@2I8MD<8EPOUtP6JC`J%JM8)wFt2q_^JB!2*=JaW$37H>_kTl=F-OsC|u44V+0 zV>qX62~RB8t<9H+yfg546lr2fxPt^tUzi;y_AaF^R>3?b@0xdK=EjhK*cP5XKX-<~ zzhDy}hbE8zJUoFIL>8<%B^qeW%bnk2Ghr<9a^Fz3I5lZ7S=A#bSFdwY@E`W3aGSh6N7)IN0@5 z-bjCc0Y3~LfEB!fvMyOK*yF@g$ekbVh$OayYR}u>--hJqHE0DB;UE&yTvHu)&8~gjaGm9?D@LutBo74rNyMEOEd_!GnM@B z%2og9pv0>2_*E`0{AEkvMzn$x`Lr_5J11C+NpK`TQ7>T-OmBnWhAvSrnEBljECbS? z=y6yD<}~On?9d{59=!%b8+PjGbsW70t>CsJOvP<`6>6BAdB$;=+4~(miUi-MO?l>t z$8sUi$D3K4K4aaDf>(V)b)-ylr3I@<9J!lsy6e_&j66)8pLONOTEJU*HFkfr%m2N8^OguszV=;1tUFp6KH05E0Rc$(VnW=tWf)c; zQBeYwQ2S6L$hwNa)skut46^!Zr-96@Lhb(uwLGjI9iEg2o<&Acb*mm5J{P8Jq#XcL zGqaa67b9jYjvhO5C~Waa4gj^iK)nE2VRg$xlw0ymEn#tIW+%lJI*v`C-DZ!(ihafQ zX{i@7(aATv$e<;EYeP)2{u7W8KMuvMU*$n3kR}dIF*_4Uw;o#DT$-Bjx|-s4i|R+g z{`O%dTw=@ZaBKYS!~XJAR%}r_rNAd6McQFki9||pt{tzU()vHa_l_<0{&>T^700000NkvXXu0mjfTO~OM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..38f51df7345774ab50f5ae489fae19ec3bff003b GIT binary patch literal 1705 zcmV;a23GlrP)@<%hfS1L_5Af=>6=t`xUgW*=eV8$E+#)U=xV7Wjy0pvonJp|k{(cDOr_=yT_ zi)+p!V>iMHE5tfuxB>qe>q&~9h|ra-oe4W~;bu(YI>4hHx#(c5l!}veP>fQcNnm8S z%%4s`k|3_B7F>zYWDz)QuJVTKCZq|4g2yiO#y7jSn2BX|77-MzGcdm_%}(Ej7>P~j z%2qXxto;=vZ4=_u7-kmYitbp1w!PTkBC|a76g;}K?NVuGW)CD|fMm!jc!O8%J&_5P z$Ch!wvbnPel94L(#@5|sWK)7O>U64+8*0Hd!i8*B~UJ8$+v~+%hXLAW(Oe zJ~M(|C%DEneuJUwW__xFB;$4{PsYJ&Dc0(rC@&1!k&)G7FE{7RR|_Rof`e*j&hn$7}- zFv3DxU7G|Z?nz`4)D5zct5+^T0!+{>rRc@NLMIW;A6*#Zw(+X(b!Xmq9X`4AAt=Yh zdC^OSgtk_z856##4BmPBEx7o>sPOgg=g-5NTyT{j!VOCs0mLGD*d&paEk%*%UJenV z*DRY+>eo4V$rK7eA)%46IZ%k7FT0V1UnA0<6n}h$?tC?r*@mbuyODrj7ewsCKca_Q z1yDp#Uv`81n(n2DoQOvLLI-GBqY&1Y-59_8u{ysN(8yP4-m-oQ3b=S-G?Xv9q0+BK zG!khHl~~7_7`kQ(dmz!Qqhs3LC=x`U8+n9QSmEHq3kA zJx$6`Sv3F3r#vYF$9Ru{rbJKT=HE1f*ZtZ7JpBC;Jow`gDC5w|N^srb z2heyTqLF_@8*Cjk-Uw*qFSKp)+NZtnX$T2lp*tq8ecAILvGo~SA ztzsj?rgbxIAKf&l;EKqFLPFD5gSV_Ys!xI_T-8Q=u0!`ex}aJgqSp!w-Pzq~x3E(3qJu(i_s zJn?V!KmZf5kThV`UkXtUD0I^O`sQvG1}}TIHb49MB!nRJQWh0@qOTDQ4BRrT&m8jI z5P?p?z$y#?EStGnnwbne=M!mhmipG*ua}_Du?nL^5SfU)3SOF>y3H8t+s2VTn~dUv z(u!AAzTPP#qVlBAII%?jV!8|xkXm4{pTw3aG9kT^V`cO1A_7~59!5s7s*}W1GIM*o zfUQiWCrf6cxe%pDl^d~a38UJc3=Qho*PwT((cc`0$VBXAjcqG|Tj`B%9Khp2oH#o0h7#EA-0JWL-dKC#XfatIcq7Jh@hP#C zavq(Ia3V78J!rQZrDCxT8jVJy(P%UpP5k^1ERS1xU8MR400000NkvXXu0mjfEV>=) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0e3a6196ffb60681bd5f1e3ed81f2d579f2abb94 GIT binary patch literal 507 zcmV@%)syF?c#J;BZC;%~Ek#%gm77g|5?HFL%V_WZC5 z6OG^^+}v>jJ;P0EYm9{81TNIz4cNxcIM9Wy)jnhGAxwOZ2_@WE2N$6&6b`Gqb|zuR z!av1fwn7`EbL_K(2O(s2r@{rig*CmSE66S^?6Lab9+zlhwwm&=&)Bh=F^*+Hs9adU zP0n#sXOyu?Z(&ng$xGaY=*_Y_(fUf-A1_Q_*h8sBUxX<48@hiL!+002ovPDHLkV1g+W(ft4b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..40b701cd72ca397c08f4deb8aa675bc23445fd68 GIT binary patch literal 850 zcmV-Y1FigtP)j6*>Vex>b7{ECC8+|w$sKzP_`W6fNEZ_`%x&n#)1Z-Vkv|T_MJ;9Yo zdjRJ$5;lMi1&uQ#>3c2l?FF+?Ysci74Be8G@RPQ}+Xmf8)y)+!(pF;yn_jXVG1t(?*4cM!Hn0|Qz4UF@4 zM$gv}F4?6YXiZ{H>EcqaUyBSH2$B_l!N&D9^rAk)b$5GXc707HyYv8|;{5OP{(_xm zlE6oj*0EI4fncqy$YO+}drzyuN0bho2-e|vXLj>@i@dfe>leIXqToANAY8n^o4;W5 zzX=Z6!=@_kc_BE5O@UBx?uW0bAf)YQgi1#qzCfs0H}^Faq=)Go8oXUe2`As5LxXh7 z+ZnPV-sI|1r*FjfXvdpihPH4xAMWh=nhR@MPF(o@$3J2(XHda?lvv9IC%qh@VR|!( zxlAw@${duutU}&>=OImBBZNx`uU&4R3Xu!0pXdmi#-UNKxLLNud8~Efr>h3f-7uGK z*AVBnslizB6^Faw1ZGLm^>PypafY9=Ngu6gBjdUD+lXa!Uur3-*_KfMNz3EBF_m=o z_j-9aFSY!QN<6n_s9RyEfX_AUy3E_ZRsF2=j`jQ{`u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8fdf209cf039a504f29465be300946006b95aa56 GIT binary patch literal 1259 zcmVYS(l{^R={WlS6cwN=^!O!-xw1`G zyMr=?y`3q%o6v{+9HVciP(nxOT;Ka0Yk2A#K?n(u9>UnksT4O*M0uCyT7m>A{CMMB}07%HeDWg2=g!ma*ut7^0n!Rd+>vwUFy@ zTWBqA8v^i!&ZBUOzS+^Dq6mv>(ZlcgJ+j~n9k31!lE5Ku;AwxAUJogLvYB7g!1M2s zFSK)yp+Up}ez_D&{`rR!Wbp|!5V%La&{4^vcl(oT*SZUx>`COuYN{8(3|Z9o>h0j!8y$^=nlmndX#j1P6MY|$JR0>rbM17{%V*) z)8J7x?KnhmLsUY_%zr)1hoNT;-YnunFp!T&w-r~ZMYk$icRP`nT;}=csGQl(3t{m0 z@_hT2>ZRJj6I!)|z@6afcbk_kJj3DZW5q1{01TSrqrD$pzZMVB5SkdToGnmMWBF2$*LFK>xtmQ*$^6} zI5-o50-E>&8hCe%KmNOjJr^~9V)e_nB5;q~z<;WDIHiwEA=8IB}-!sROzb!#okVuIbiXg7g;hQ)t) zqPAKrgWP4Dk06Y^(8-0k&SoR?iTTb3osBZ~>?kK^n0%W%|B-^_4VsC#lVbaba<=^` zzluEV5>V%Y#MHBxrH-$rZuN7SVn>>N8m&GZwn^YXbN@6gEopn7tHWuM1YazM=(w%Q zEIhYh3K?{1HLD=-#jsV8+I~|gRwN>ivJ_1QA>x7iwe7!;je z?h(DS!SXxl*!yhJ`^D^LUL=d-yhxN+MJ40!!QiPFbu;m48t%C!!V2%^AWA# VNC_BZa1a0h002ovPDHLkV1kKhQV{?E literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b5e220742c950455607551466b93f398b1693164 GIT binary patch literal 510 zcmVhlzXVQBO)lNp!HX1bR*sv zFG<0SJRy7T6O?euXi_z`xE1EQGtZS1_zCx!9UKyo@`-6{Z&ve&B2&vV$~)U_se>ZS z!+mpC!K{y%dV=4>f5E!hc72q!!OmV(&B`qC2f!fBS~#{|eE1Q}W$qCY& z0CR%U6O*SA z?(YIJNx=qMA(X6vL?Elc2BVX7hk?fI$Dx1 zqK`e*_{xI8?Y!zw2F?Dm2t5@cvV%=fJPI|=M#bimk8sR?yAIW~(v@mqysLta6D$rM z0;GBwDMt*I@El6paCAkVmKM*A4ex*Oqi>qy)@S6ApNveMRhXUJ|FJ@(WsNxp;fAxZ<(xzj1LNF<1-S;7|?O!diJyED6-XatfO%@TMAV=d`MFbtxDu!0=GfGH9e z7~2|zCPwANN=eJJz8ixO>g8y={R{9q&Xds5Bck16Bq#wlJ$jCDBl$t2$|UXchtJODV+l? zL{r+iB)00R>sy1AVbWWB)U?m&=IZxHfC_*L zkSoAc0Ind;UuFW>R1kL%r2|d)4Ay1uPPS#~q+7`{0g4b09(%Ci;*{Td_CSJ25g3Z6!zF0j{R0@Os{g?ojr`Co8p-n-I zf{@nCGQfteAuEg&4v-jmyA5d-1`lH@l%GXdC{pg)XZ zMPb2cX9EV1L5D9it;IKh?IHT#BWNIO7%8s+G&4E@k=y{hAJ&OjEf^`U2c#qd6|t=z zCt8&-s&G1g&XuCg+4TV#?Hg6fZjdvw^54R20dt&PH;{~!5EP}yJJZbqOd5?Vswo#m z?TrAUF|ZHGPA|WnmSDu_iwpQd{g~*K8)^dNx%%pD`kjz{q_J?1?t0KnC`wy?b}ivz zz|S4PL^hVL)l37RDp6A!#3^}mz#4xc1mql}|6`(t)W#IZh%R?|HjpyR&T%%(ZFLG_ zbVyLnNsWlb1msH~)Kly4Phf#a2j2#d!$813 zHIRmRg*CU}IZ2_QgLqvhY4Enq3Pgm^L=aB#;9)Sg)g(g9I>kBAkPCeTY>{;-q3v%n zrXNvuPM10hz?gE*{;UTp9T_ZQOxtF;qXBPh145az%A2YzCkywuatqBoov8wn=@BkD zHKJ%Y;ME~WhMdA(<8;PUSY<_?hPF3WZ8xw2ai!+zOufR&w;QwqRQq&B5AJm+8WV<| z3$W$sUkOFeiOSYg8$gXw|L;l#PNrYO949Kl@Gl@)p zHi=LxFsjaosSnnzZ9!yZ_sb5)>jJf$04NpfI^;6cQxMLUJSa{%!}g%fX{Pq->EVN= z2FVtP2cqp7BBajNmZen~J@9p7Rx)Ic}n*&mvc}@q`B Date: Tue, 14 Jul 2026 13:43:48 +0300 Subject: [PATCH 2/2] fix: address core-components review - MenuItem: disable the toggle accessory when the row is disabled - NavigationBar: add localized accessibility labels to back/close/plus/info icon-only buttons - NavigationBar: gate animation(_:value:) (iOS 15+) behind if #available with an iOS 14 fallback - RadioButtonRow: expose selection state to VoiceOver via accessibilityAddTraits - SystemMessageView: only render the button container when a button actually has both a name and an action --- Sources/DashUIKit/Components/MenuItem.swift | 1 + .../DashUIKit/Components/NavigationBar.swift | 24 +++++++++++++++++-- .../DashUIKit/Components/RadioButtonRow.swift | 1 + .../Components/SystemMessageView.swift | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Sources/DashUIKit/Components/MenuItem.swift b/Sources/DashUIKit/Components/MenuItem.swift index b49f416..fd7e6c0 100644 --- a/Sources/DashUIKit/Components/MenuItem.swift +++ b/Sources/DashUIKit/Components/MenuItem.swift @@ -112,6 +112,7 @@ public struct MenuItem: View { case .toggle(let isOn): Toggle("", isOn: isOn) .labelsHidden() + .disabled(!isEnabled) case .text(let value): Text(value) .dashFont(.subhead) diff --git a/Sources/DashUIKit/Components/NavigationBar.swift b/Sources/DashUIKit/Components/NavigationBar.swift index 1ccf340..0b4ef96 100644 --- a/Sources/DashUIKit/Components/NavigationBar.swift +++ b/Sources/DashUIKit/Components/NavigationBar.swift @@ -113,16 +113,36 @@ public enum NavigationBarElement: String { .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 { - configuration.label + let content = configuration.label .scaleEffect(configuration.isPressed ? 0.88 : 1) .opacity(configuration.isPressed ? 0.7 : 1) - .animation(.easeInOut(duration: 0.12), value: configuration.isPressed) + + if #available(iOS 15, macOS 12, *) { + content.animation(.easeInOut(duration: 0.12), value: configuration.isPressed) + } else { + content.animation(.easeInOut(duration: 0.12)) + } } } diff --git a/Sources/DashUIKit/Components/RadioButtonRow.swift b/Sources/DashUIKit/Components/RadioButtonRow.swift index 58d09ef..c6bfa4e 100644 --- a/Sources/DashUIKit/Components/RadioButtonRow.swift +++ b/Sources/DashUIKit/Components/RadioButtonRow.swift @@ -98,6 +98,7 @@ public struct RadioButtonRow: View { .frame(minHeight: subtitle != nil ? 60 : 54) } .buttonStyle(PlainButtonStyle()) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/Sources/DashUIKit/Components/SystemMessageView.swift b/Sources/DashUIKit/Components/SystemMessageView.swift index f98bba5..e92d8bc 100644 --- a/Sources/DashUIKit/Components/SystemMessageView.swift +++ b/Sources/DashUIKit/Components/SystemMessageView.swift @@ -72,7 +72,7 @@ public struct SystemMessageView: View { } } - if buttonName != nil || secondaryButtonName != nil { + if (buttonName != nil && onAction != nil) || (secondaryButtonName != nil && onSecondaryAction != nil) { HStack(spacing: 10) { if let buttonName, let onAction { DashUIKit.DashButton(