diff --git a/Sources/DashUIKit/Button/DashButton.swift b/Sources/DashUIKit/Button/DashButton.swift new file mode 100644 index 0000000..15ed94a --- /dev/null +++ b/Sources/DashUIKit/Button/DashButton.swift @@ -0,0 +1,393 @@ +// +// 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 10.15, *) +public enum DashButtonSize: Sendable { + case large, medium, small, extraSmall + + var gap: CGFloat { + switch self { + case .large: return 10 + case .medium: return 8 + case .small: return 6 + case .extraSmall: return 6 + } + } + + var hPadding: CGFloat { + switch self { + case .large: return 20 + case .medium: return 16 + case .small: return 12 + case .extraSmall: return 8 + } + } + + var vPadding: CGFloat { + switch self { + case .large: return 14 + case .medium: return 10 + case .small: return 6 + case .extraSmall: return 4 + } + } + + var radius: CGFloat { + switch self { + case .large: return 16 + case .medium: return 14 + case .small: return 11 + case .extraSmall: return 9 + } + } + + var fontSize: Font { + switch self { + case .large: return .system(size: 16) + case .medium: return .system(size: 14) + case .small: return .system(size: 13) + case .extraSmall: return .system(size: 12) + } + } +} + +@available(iOS 14, macOS 10.15, *) +public enum DashButtonStyle { + case filledBlue, filledRed, strokeGray, tintedBlue, tintedGray, plainBlue, plainBlack, plainRed, filledWhiteBlue, tintedWhite, plainWhite + + func backgroundColor(isEnabled: Bool) -> Color { + switch self { + case .filledBlue: + return isEnabled ? .dash.buttonFilledBlueBackground : .dash.buttonFilledBlueBackgroundDisabled + case .filledRed: + return isEnabled ? .dash.buttonFilledRedBackground : .dash.buttonFilledRedBackgroundDisabled + case .strokeGray: + return isEnabled ? .clear : .dash.buttonStrokeGrayBackgroundDisabled + case .tintedBlue: + return isEnabled ? .dash.buttonTintedBlueBackground : .dash.buttonTintedBlueBackgroundDisabled + case .tintedGray: + return isEnabled ? .dash.buttonTintedGrayBackground : .dash.buttonTintedGrayBackgroundDisabled + case .plainBlue, .plainBlack, .plainRed, .plainWhite: + return .clear + case .filledWhiteBlue: + return isEnabled ? .dash.buttonFilledWhiteBackground : .dash.buttonFilledWhiteBackgroundDisabled + case .tintedWhite: + return isEnabled ? .dash.buttonTintedWhiteBackground : .dash.buttonTintedWhiteBackgroundDisabled + } + } + + func foregroundColor(isEnabled: Bool) -> Color { + switch self { + case .filledBlue: + return isEnabled ? .dash.buttonFilledBlueContent : .dash.buttonFilledBlueContentDisabled + case .filledRed: + return isEnabled ? .dash.buttonFilledRedContent : .dash.buttonFilledRedContentDisabled + case .strokeGray: + return isEnabled ? .dash.buttonStrokeGrayContent : .dash.buttonStrokeGrayContentDisabled + case .tintedBlue: + return isEnabled ? .dash.buttonTintedBlueContent : .dash.buttonTintedBlueContentDisabled + case .tintedGray: + return isEnabled ? .dash.buttonTintedGrayContent : .dash.buttonTintedGrayContentDisabled + case .plainBlue: + return isEnabled ? .dash.buttonPlainBlueContent : .dash.buttonPlainBlueContentDisabled + case .plainBlack: + return isEnabled ? .dash.buttonPlainBlackContent : .dash.buttonPlainBlackContentDisabled + case .plainRed: + return isEnabled ? .dash.buttonPlainRedContent : .dash.buttonPlainRedContentDisabled + case .filledWhiteBlue: + return isEnabled ? .dash.buttonFilledWhiteContent : .dash.buttonFilledWhiteContentDisabled + case .tintedWhite: + return isEnabled ? .dash.buttonTintedWhiteContent : .dash.buttonTintedWhiteContentDisabled + case .plainWhite: + return isEnabled ? .dash.buttonPlainWhiteContent : .dash.buttonPlainWhiteContentDisabled + } + } +} + +@available(iOS 14, macOS 11, *) +public struct DashButton: View { + + public var text: String? = "Label" + public var leadingIcon: DashIconSource? = nil + public var trailingIcon: DashIconSource? = nil + + public var isEnabled: Bool = true + public var isLoading: Bool = false + public var fillsWidth: Bool = false + + public var size: DashButtonSize = .large + public var style: DashButtonStyle = .filledBlue + public var action: () -> Void = {} + + public init( + text: String? = nil, + leadingIcon: DashIconSource? = nil, + trailingIcon: DashIconSource? = nil, + isEnabled: Bool = true, + isLoading: Bool = false, + fillsWidth: Bool = false, + size: DashButtonSize, + style: DashButtonStyle, + action: @escaping () -> Void = {} + ) { + self.text = text + self.leadingIcon = leadingIcon + self.trailingIcon = trailingIcon + self.isEnabled = isEnabled + self.isLoading = isLoading + self.fillsWidth = fillsWidth + self.size = size + self.style = style + self.action = action + } + + public var body: some View { + Button(action: action) { + styledContent + } + .buttonStyle(.plain) + .disabled(!isEnabled || isLoading) + } + + private var styledContent: some View { + Group { + if style == .strokeGray { + content + .padding(.horizontal, size.hPadding) + .padding(.vertical, size.vPadding) + .foregroundColor(style.foregroundColor(isEnabled: isEnabled)) + .background(style.backgroundColor(isEnabled: isEnabled)) + .clipShape(RoundedRectangle(cornerRadius: size.radius, style: .continuous)) + .frame(maxWidth: fillsWidth ? .infinity : nil) + .overlay( + RoundedRectangle(cornerRadius: size.radius) + .inset(by: 0.5) + .stroke(Color.dash.buttonStrokeGrayStroke, lineWidth: 1) + ) + } else { + content + .padding(.horizontal, size.hPadding) + .padding(.vertical, size.vPadding) + .foregroundColor(style.foregroundColor(isEnabled: isEnabled)) + .frame(maxWidth: fillsWidth ? .infinity : nil) + .background(style.backgroundColor(isEnabled: isEnabled)) + .clipShape(RoundedRectangle(cornerRadius: size.radius, style: .continuous)) + } + } + + } + + private var content: some View { + HStack(spacing: size.gap) { + if let icon = leadingIcon { + Image(dash: icon) + } + + if isLoading { + ProgressView() + } else if let text { + Text(text) + .font(size.fontSize) + .fontWeight(.semibold) + } + + if let icon = trailingIcon { + Image(dash: icon) + } + } + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +private let previewButtonSizes: [DashButtonSize] = [ + .large, + .medium, + .small, + .extraSmall, +] + +@available(iOS 17, macOS 14, *) +private extension DashButtonSize { + var previewTitle: String { + switch self { + case .large: return "Large" + case .medium: return "Medium" + case .small: return "Small" + case .extraSmall: return "Extra Small" + } + } +} + +@available(iOS 17, macOS 14, *) +private extension DashButtonStyle { + var previewTitle: String { + switch self { + case .filledBlue: return "Filled Blue" + case .filledRed: return "Filled Red" + case .strokeGray: return "Stroke Gray" + case .tintedBlue: return "Tinted Blue" + case .tintedGray: return "Tinted Gray" + case .plainBlue: return "Plain Blue" + case .plainBlack: return "Plain Black" + case .plainRed: return "Plain Red" + case .filledWhiteBlue: return "Filled White Blue" + case .tintedWhite: return "Tinted White" + case .plainWhite: return "Plain White" + } + } + + var previewBackgroundColor: Color { + switch self { + case .filledWhiteBlue, .tintedWhite, .plainWhite: + return .dash.buttonFilledBlueBackground + default: + return .clear + } + } +} + +@available(iOS 17, macOS 14, *) +private struct DashButtonStylePreview: View { + let style: DashButtonStyle + + var body: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Enabled") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(alignment: .top, spacing: 12) { + ForEach(Array(previewButtonSizes.enumerated()), id: \.offset) { _, size in + DashButton( + text: size.previewTitle, + isEnabled: true, + size: size, + style: style, + action: {} + ) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Disabled") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(alignment: .top, spacing: 12) { + ForEach(Array(previewButtonSizes.enumerated()), id: \.offset) { _, size in + DashButton( + text: size.previewTitle, + isEnabled: false, + size: size, + style: style, + action: {} + ) + } + } + } + } + .padding(24) + } + .background(style.previewBackgroundColor) + } +} + +@available(iOS 17, macOS 14, *) +#Preview("Example") { + VStack { + DashButton( + text: "Withdraw funds", + fillsWidth: true, + size: .large, + style: .filledBlue, + action: {} + ) + + DashButton( + text: "Withdraw funds", + fillsWidth: true, + size: .large, + style: .strokeGray, + action: {} + ) + } + .padding(.horizontal) +} + +@available(iOS 17, macOS 14, *) +#Preview("Filled Blue") { + DashButtonStylePreview(style: .filledBlue) +} + +@available(iOS 17, macOS 14, *) +#Preview("Filled Red") { + DashButtonStylePreview(style: .filledRed) +} + +@available(iOS 17, macOS 14, *) +#Preview("Stroke Gray") { + DashButtonStylePreview(style: .strokeGray) +} + +@available(iOS 17, macOS 14, *) +#Preview("Tinted Blue") { + DashButtonStylePreview(style: .tintedBlue) +} + +@available(iOS 17, macOS 14, *) +#Preview("Tinted Gray") { + DashButtonStylePreview(style: .tintedGray) +} + +@available(iOS 17, macOS 14, *) +#Preview("Plain Blue") { + DashButtonStylePreview(style: .plainBlue) +} + +@available(iOS 17, macOS 14, *) +#Preview("Plain Black") { + DashButtonStylePreview(style: .plainBlack) +} + +@available(iOS 17, macOS 14, *) +#Preview("Plain Red") { + DashButtonStylePreview(style: .plainRed) +} + +@available(iOS 17, macOS 14, *) +#Preview("Filled White Blue") { + DashButtonStylePreview(style: .filledWhiteBlue) +} + +@available(iOS 17, macOS 14, *) +#Preview("Tinted White") { + DashButtonStylePreview(style: .tintedWhite) +} + +@available(iOS 17, macOS 14, *) +#Preview("Plain White") { + DashButtonStylePreview(style: .plainWhite) +} + +#endif diff --git a/Sources/DashUIKit/Components/Geometry/FrameReader.swift b/Sources/DashUIKit/Components/Geometry/FrameReader.swift new file mode 100644 index 0000000..9c3c3c2 --- /dev/null +++ b/Sources/DashUIKit/Components/Geometry/FrameReader.swift @@ -0,0 +1,104 @@ +// +// 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: - FrameReader + +/// Publishes the live frame of whatever view it backs, resolved in `coordinateSpace`. +/// +/// It paints nothing: a transparent, greedy overlay that forwards the resolved `CGRect` +/// once on first layout and again on every subsequent change. Attach it through +/// ``SwiftUI/View/readingFrame(coordinateSpace:onChange:)`` rather than building it directly. +@available(iOS 14, macOS 11, *) +struct FrameReader: View { + + private let coordinateSpace: CoordinateSpace + private let report: (CGRect) -> Void + + init(coordinateSpace: CoordinateSpace, onChange: @escaping (CGRect) -> Void) { + self.coordinateSpace = coordinateSpace + self.report = onChange + } + + var body: some View { + GeometryReader { proxy in + // Resolve once per layout pass and reuse for both the initial emit and the diff. + let frame = proxy.frame(in: coordinateSpace) + + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { report(frame) } + .onChange(of: frame, perform: report) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - View sugar + +@available(iOS 14, macOS 11, *) +public extension View { + + /// Reports this view's frame — in `coordinateSpace` — as it appears and whenever it moves + /// or resizes. Installed as a transparent background so it never affects layout. + func readingFrame( + coordinateSpace: CoordinateSpace = .global, + onChange: @escaping (_ frame: CGRect) -> Void + ) -> some View { + background(FrameReader(coordinateSpace: coordinateSpace, onChange: onChange)) + } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct FrameReader_Previews: PreviewProvider { + + private struct Demo: View { + @State private var topY: CGFloat = 0 + + var body: some View { + ScrollView(.vertical) { + VStack(spacing: 16) { + ForEach(0..<24) { index in + Color.green + .frame(maxWidth: .infinity) + .frame(height: 160) + .cornerRadius(10) + .overlay(Text("\(index)")) + // Track only the first tile's position within the named space. + .readingFrame(coordinateSpace: .named("demo")) { frame in + if index == 0 { topY = frame.minY } + } + } + } + .padding() + } + .coordinateSpace(name: "demo") + .overlay(Text(String(format: "minY: %.1f", topY)).padding(), alignment: .top) + } + } + + static var previews: some View { + Demo() + } +} + +#endif diff --git a/Sources/DashUIKit/Components/Geometry/LocationReader.swift b/Sources/DashUIKit/Components/Geometry/LocationReader.swift new file mode 100644 index 0000000..bd74f24 --- /dev/null +++ b/Sources/DashUIKit/Components/Geometry/LocationReader.swift @@ -0,0 +1,116 @@ +// +// 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: - LocationReader + +/// Publishes the center point of whatever view it backs, resolved in `coordinateSpace`. +/// +/// The point-sized sibling of ``FrameReader``: it collapses to a zero-sized probe and only +/// emits the midpoint, which is all most scroll/position effects need. Reach for it through +/// ``SwiftUI/View/readingLocation(coordinateSpace:onChange:)``. +@available(iOS 14, macOS 11, *) +struct LocationReader: View { + + private let coordinateSpace: CoordinateSpace + private let report: (CGPoint) -> Void + + init(coordinateSpace: CoordinateSpace, onChange: @escaping (CGPoint) -> Void) { + self.coordinateSpace = coordinateSpace + self.report = onChange + } + + var body: some View { + GeometryReader { proxy in + // Pinned to a 0×0 box, so the frame's midpoint collapses onto the probe's origin. + let center = proxy.frame(in: coordinateSpace).center + + Color.clear + .onAppear { report(center) } + .onChange(of: center, perform: report) + } + .frame(width: 0, height: 0, alignment: .center) + } +} + +// MARK: - View sugar + +@available(iOS 14, macOS 11, *) +extension View { + + /// Reports this view's center point — in `coordinateSpace` — on appear and on every change. + /// Installed as a zero-sized, transparent background so it never affects layout. + func readingLocation( + coordinateSpace: CoordinateSpace = .global, + onChange: @escaping (_ location: CGPoint) -> Void + ) -> some View { + background(LocationReader(coordinateSpace: coordinateSpace, onChange: onChange)) + } +} + +// MARK: - Helpers + +@available(iOS 14, macOS 11, *) +private extension CGRect { + /// Geometric center of the rect. + var center: CGPoint { CGPoint(x: midX, y: midY) } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct LocationReader_Previews: PreviewProvider { + + private struct Demo: View { + @State private var headerCenterY: CGFloat = 0 + + var body: some View { + ScrollView(.vertical) { + VStack(spacing: 16) { + Text("Header") + .frame(maxWidth: .infinity) + .frame(height: 160) + .background(Color.green) + .cornerRadius(10) + .readingLocation(coordinateSpace: .named("demo")) { point in + headerCenterY = point.y + } + + ForEach(0..<24) { index in + Color.green.opacity(0.4) + .frame(maxWidth: .infinity) + .frame(height: 160) + .cornerRadius(10) + .overlay(Text("\(index)")) + } + } + .padding() + } + .coordinateSpace(name: "demo") + .overlay(Text(String(format: "header midY: %.1f", headerCenterY)).padding(), alignment: .top) + } + } + + static var previews: some View { + Demo() + } +} + +#endif diff --git a/Sources/DashUIKit/Components/Geometry/ScaleToFitWidth.swift b/Sources/DashUIKit/Components/Geometry/ScaleToFitWidth.swift new file mode 100644 index 0000000..cfce5b8 --- /dev/null +++ b/Sources/DashUIKit/Components/Geometry/ScaleToFitWidth.swift @@ -0,0 +1,79 @@ +// +// 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: - ScaleToFitWidth + +private struct ScaleToFitContentSizeKey: PreferenceKey { + nonisolated(unsafe) static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + let next = nextValue() + if next.width > 0 { value = next } + } +} + +/// Scales its content uniformly to fit the available width on a single line, shrinking the WHOLE +/// group (e.g. currency symbol + amount + logo) together — unlike `minimumScaleFactor`, which only +/// shrinks each `Text` independently. Content renders at full size when it fits and never scales +/// below `minScale`. +/// +/// The modifier reserves the content's natural single-line height (constant) so it keeps the +/// surrounding vertical layout stable while only the horizontal scale changes. +@available(iOS 14, macOS 11, *) +public struct ScaleToFitWidth: ViewModifier { + public var minScale: CGFloat = 0.35 + + @State private var naturalSize: CGSize = .zero + + public func body(content: Content) -> some View { + GeometryReader { proxy in + content + .fixedSize() // natural, single-line size — the unit we scale + .scaleEffect(scale(forAvailableWidth: proxy.size.width), anchor: .center) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) + } + .frame(maxWidth: .infinity) + // Collapse the greedy GeometryReader to one content line so vertical layout is unaffected. + .frame(height: naturalSize.height == 0 ? nil : naturalSize.height) + .background( + // Measure the content's natural (unconstrained) size off-screen. + content + .fixedSize() + .hidden() + .background( + GeometryReader { proxy in + Color.clear.preference(key: ScaleToFitContentSizeKey.self, value: proxy.size) + } + ) + ) + .onPreferenceChange(ScaleToFitContentSizeKey.self) { naturalSize = $0 } + } + + private func scale(forAvailableWidth available: CGFloat) -> CGFloat { + guard naturalSize.width > 0, available > 0, available < naturalSize.width else { return 1 } + return max(minScale, available / naturalSize.width) + } +} + +@available(iOS 14, macOS 11, *) +public extension View { + /// Scales the view uniformly to fit the available width on one line, down to `minScale`. + func scaleToFitWidth(minScale: CGFloat = 0.35) -> some View { + modifier(ScaleToFitWidth(minScale: minScale)) + } +} diff --git a/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift b/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift new file mode 100644 index 0000000..8cb5825 --- /dev/null +++ b/Sources/DashUIKit/Components/Geometry/ScrollViewWithOnScrollChanged.swift @@ -0,0 +1,100 @@ +// +// 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: - ScrollViewWithOnScrollChanged + +/// A drop-in `ScrollView` that reports its content offset as the user scrolls — useful for +/// collapsing headers, scroll-driven shadows, parallax, etc., without depending on iOS 17's +/// `onScrollGeometryChange`. +/// +/// The trick: a zero-sized ``LocationReader`` rides at the very top of the content inside a +/// private named coordinate space. As the content slides, the probe's position within that +/// space is exactly the scroll offset, which is forwarded through `onScrollChanged`. +@available(iOS 14, macOS 11, *) +public struct ScrollViewWithOnScrollChanged: View { + + private let axes: Axis.Set + private let showsIndicators: Bool + private let content: Content + private let report: (CGPoint) -> Void + + /// A per-instance coordinate-space name, so stacked scroll views never read each other's probe. + @State private var spaceName = UUID().uuidString + + public init( + _ axes: Axis.Set = .vertical, + showsIndicators: Bool = false, + @ViewBuilder content: () -> Content, + onScrollChanged: @escaping (_ origin: CGPoint) -> Void + ) { + self.axes = axes + self.showsIndicators = showsIndicators + self.content = content() + self.report = onScrollChanged + } + + public var body: some View { + ScrollView(axes, showsIndicators: showsIndicators) { + offsetProbe + content + } + .coordinateSpace(name: spaceName) + } + + /// Top-of-content marker whose location within `spaceName` mirrors the scroll offset. + private var offsetProbe: some View { + LocationReader(coordinateSpace: .named(spaceName), onChange: report) + } +} + +// MARK: - Preview + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct ScrollViewWithOnScrollChanged_Previews: PreviewProvider { + + private struct Demo: View { + @State private var offsetY: CGFloat = 0 + + var body: some View { + ScrollViewWithOnScrollChanged { + VStack(spacing: 16) { + ForEach(0..<24) { index in + Color.red.opacity(0.4) + .frame(maxWidth: .infinity) + .frame(height: 160) + .cornerRadius(10) + .overlay(Text("Row \(index)")) + } + } + .padding() + } onScrollChanged: { origin in + offsetY = origin.y + } + .overlay(Text(String(format: "offset.y: %.1f", offsetY)).padding(), alignment: .top) + } + } + + static var previews: some View { + Demo() + } +} + +#endif diff --git a/Sources/DashUIKit/Components/Icons/XmarkIcon.swift b/Sources/DashUIKit/Components/Icons/XmarkIcon.swift new file mode 100644 index 0000000..8887323 --- /dev/null +++ b/Sources/DashUIKit/Components/Icons/XmarkIcon.swift @@ -0,0 +1,75 @@ +// +// Created by Roman Chornyi +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +// MARK: - XmarkIcon + +/// A code-drawn "✕" (close) icon. Mirrors the source SVG (9×9 viewBox, two diagonals +/// inset from 0.75 to 7.75, round caps/joins). Scales cleanly to any `size`. +@available(iOS 14, macOS 11, *) +struct XmarkIcon: View { + var size: CGFloat = 9 + var color: Color = Color.dash.primaryText + var lineWidth: CGFloat = 1.5 + + var body: some View { + XmarkShape() + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .frame(width: size, height: size) + } +} + +// MARK: - XmarkShape + +/// Two diagonal strokes forming an "✕". Endpoints are normalized from the 9-unit +/// source viewBox (inset 0.75 → 7.75) so the cross keeps its proportions at any size. +@available(iOS 14, macOS 11, *) +private struct XmarkShape: Shape { + private let insetRatio: CGFloat = 0.75 / 9 + private let extentRatio: CGFloat = 7.75 / 9 + + func path(in rect: CGRect) -> Path { + let minX = rect.minX + rect.width * insetRatio + let maxX = rect.minX + rect.width * extentRatio + let minY = rect.minY + rect.height * insetRatio + let maxY = rect.minY + rect.height * extentRatio + + var path = Path() + path.move(to: CGPoint(x: minX, y: minY)) + path.addLine(to: CGPoint(x: maxX, y: maxY)) + path.move(to: CGPoint(x: minX, y: maxY)) + path.addLine(to: CGPoint(x: maxX, y: minY)) + return path + } +} + +#if DEBUG + +@available(iOS 17, macOS 14, *) +#Preview { + VStack(spacing: 24) { + XmarkIcon() + XmarkIcon(size: 24, color: .white, lineWidth: 2) + .padding(20) + .background(Circle().fill(Color.dash.blue)) + XmarkIcon(size: 40, color: .red, lineWidth: 3) + } + .padding() +} + +#endif diff --git a/Sources/DashUIKit/Components/Illustrations/ErrorIllustration.swift b/Sources/DashUIKit/Components/Illustrations/ErrorIllustration.swift new file mode 100644 index 0000000..df39527 --- /dev/null +++ b/Sources/DashUIKit/Components/Illustrations/ErrorIllustration.swift @@ -0,0 +1,50 @@ +// +// 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 ErrorIllustration: View { + + public init() {} + + public var body: some View { + ZStack { + Color.dash.red + + Image(dash: .custom("illustration-xmark", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 29) + } + .frame(width: 90, height: 90) + .clipShape(Circle()) + } +} + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct ErrorIllustration_Previews: PreviewProvider { + static var previews: some View { + ErrorIllustration() + .padding() + .previewLayout(.sizeThatFits) + } +} + +#endif diff --git a/Sources/DashUIKit/Components/Illustrations/LoadingIllustration.swift b/Sources/DashUIKit/Components/Illustrations/LoadingIllustration.swift new file mode 100644 index 0000000..6d5a3a0 --- /dev/null +++ b/Sources/DashUIKit/Components/Illustrations/LoadingIllustration.swift @@ -0,0 +1,145 @@ +// +// 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 Combine +import SwiftUI + +// MARK: - LoadingIllustration + +/// Wrapper that centers the loading spinner inside a fixed-size frame, matching the +/// Maya design (Figma node 6:254 — a 90×90 frame containing a 61.73 spinner). +@available(iOS 14, macOS 11, *) +public struct LoadingIllustration: View { + + /// Diameter of the spinner. + public var size: CGFloat + /// Spinner tint. + public var color: Color + /// Size of the surrounding square frame (the spinner is centered within it). + public var containerSize: CGFloat + + public init(size: CGFloat = 61.73, color: Color = LoadingSpinner.defaultColor, containerSize: CGFloat = 90) { + self.size = size + self.color = color + self.containerSize = containerSize + } + + public var body: some View { + ZStack { + LoadingSpinner(size: size, color: color) + } + .frame(width: containerSize, height: containerSize) + } +} + +// MARK: - LoadingSpinner + +/// A spinner built from `spokeCount` capsules arranged in a ring. Mirrors the standard iOS +/// `UIActivityIndicatorView`: the spokes are **static**, and instead of rotating the whole ring +/// the bright "head" steps clockwise from spoke to spoke while the others fade — i.e. each line +/// changes opacity in turn. The opacity crossfades between steps for a smooth iOS-style look. +@available(iOS 14, macOS 11, *) +public struct LoadingSpinner: View { + + /// Default tint — DS blue. + public static let defaultColor = Color.dash.blue + + /// Diameter of the spinner. + public let size: CGFloat + /// Spoke tint. + public let color: Color + /// Number of spokes in the ring. + public let spokeCount: Int + /// Seconds for the bright head to travel once around the ring. + public let duration: Double + + /// Index of the currently-brightest spoke; advances clockwise on each timer tick. + @State private var phase = 0 + /// Fires once per spoke step (duration / spokeCount). Created once so it isn't restarted + /// on every body re-evaluation; connected on appear and cancelled on disappear so no timer + /// runs while the view is off-screen. + private let timer: Timer.TimerPublisher + /// Handle for the connected timer; `nil` while the view is off-screen. + @State private var timerCancellable: Cancellable? + + public init(size: CGFloat = 61.73, color: Color = defaultColor, spokeCount: Int = 12, duration: Double = 1) { + self.size = size + self.color = color + self.spokeCount = max(spokeCount, 1) + self.duration = duration + self.timer = Timer + .publish(every: duration / Double(max(spokeCount, 1)), on: .main, in: .common) + } + + private var stepInterval: Double { duration / Double(max(spokeCount, 1)) } + + public var body: some View { + ZStack { + ForEach(0.. Double { + guard spokeCount > 1 else { return 0.75 } + let distanceFromHead = (index - phase + spokeCount) % spokeCount + return 0.2 + 0.55 * (1 - Double(distanceFromHead) / Double(spokeCount - 1)) + } +} + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct LoadingIllustration_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 40) { + LoadingIllustration() + LoadingIllustration(size: 32, color: .red) + LoadingSpinner(size: 24, color: .gray, spokeCount: 8, duration: 0.8) + } + .previewLayout(.sizeThatFits) + .padding() + } +} + +#endif diff --git a/Sources/DashUIKit/Components/Illustrations/SuccessIllustration.swift b/Sources/DashUIKit/Components/Illustrations/SuccessIllustration.swift new file mode 100644 index 0000000..6f9631e --- /dev/null +++ b/Sources/DashUIKit/Components/Illustrations/SuccessIllustration.swift @@ -0,0 +1,50 @@ +// +// 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 SuccessIllustration: View { + + public init() {} + + public var body: some View { + ZStack { + Color.dash.green + + Image(dash: .custom("checkmark", bundle: .dashUIKit)) + .resizable() + .scaledToFit() + .frame(maxHeight: 29) + } + .frame(width: 90, height: 90) + .clipShape(Circle()) + } +} + +#if DEBUG + +@available(iOS 14, macOS 11, *) +struct SuccessIllustration_Previews: PreviewProvider { + static var previews: some View { + SuccessIllustration() + .padding() + .previewLayout(.sizeThatFits) + } +} + +#endif diff --git a/Sources/DashUIKit/ViewModifiers/MenuViewModifier.swift b/Sources/DashUIKit/ViewModifiers/MenuViewModifier.swift new file mode 100644 index 0000000..b00e1e5 --- /dev/null +++ b/Sources/DashUIKit/ViewModifiers/MenuViewModifier.swift @@ -0,0 +1,39 @@ +// +// 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 MenuViewModifier: ViewModifier { + var shadowRadius: CGFloat + var innerPadding: CGFloat + + public init(shadowRadius: CGFloat = 10, innerPadding: CGFloat = 6) { + self.shadowRadius = shadowRadius + self.innerPadding = innerPadding + } + + public func body(content: Content) -> some View { + content + .padding(innerPadding) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.dash.secondaryBackground) + ) + .shadow(color: Color.dash.shadow, radius: shadowRadius, x: 0, y: 5) + } +}