diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ee1d94729..03916343c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,8 @@ -#### ✨ This PR Will: - -- -- -- +## What + -#### 🎯 Purpose - -- -- +## Why + -#### 🧪 Unit Tests - -- -- - -#### 📸 Screenshots - -- - -#### 💻 Code Snippets - - - - - - - +## Changes + diff --git a/.github/scripts/extract_coverage.sh b/.github/scripts/extract_coverage.sh deleted file mode 100644 index df89ec65f..000000000 --- a/.github/scripts/extract_coverage.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -CODECOV_PATH=$(swift test --enable-code-coverage --show-codecov-path) - -# Extract all line coverage data for files containing 'SnappTheming/Sources' -FILES_LINE_COUNTS=$(jq -r '.data[0].files[] | select(.filename | contains("SnappTheming/Sources")) | .summary.lines' "$CODECOV_PATH") - -total_lines=0 -covered_lines=0 - -# Loop through each file's line count data (FILES_LINE_COUNTS) -for lines_data in $(echo "$FILES_LINE_COUNTS" | jq -c '.'); do - # Extract total and covered lines for each file - total=$(echo "$lines_data" | jq '.count') - covered=$(echo "$lines_data" | jq '.covered') - - # Add to the total lines and covered lines - total_lines=$((total_lines + total)) - covered_lines=$((covered_lines + covered)) -done - -# Calculate the average line coverage percentage -if [ $total_lines -gt 0 ]; then - average_coverage=$(echo "scale=6; $covered_lines * 100 / $total_lines" | bc) # Keep 6 decimal places -else - average_coverage=0 -fi - -average_coverage_rounded=$(echo "$average_coverage" | awk '{print int($1 * 100 + 0.5) / 100}') -average_coverage_with_percentage="${average_coverage_rounded}%" - -# Save to pr_coverage_summary.txt -cat < pr_coverage_summary.txt -| ID | Name | Executable Lines | Coverage | -|----|------|-----------------:|---------:| -| 0 | SnappTheming | $total_lines | **$average_coverage_with_percentage** | -EOF diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 871aef599..c295e98df 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,80 +1,14 @@ - name: Package Test - - env: - EXTRACT_COVERAGE: '.github/scripts/extract_coverage.sh' - XCODE_PATH: '/Applications/Xcode_16.2.app/Contents/Developer' - COVERAGE_SUMMARY_FILE: 'pr_coverage_summary.txt' - BOT_COMMENT_HEADER: '### 🛡️ Code Coverage Report' - - on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - - jobs: - build: - - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - - - name: List Xcode installations - run: sudo ls -1 /Applications | grep "Xcode" - - - name: Select Xcode 16.2 - run: sudo xcode-select -s ${{ env.XCODE_PATH }} - - - name: Run Swift Tests with Coverage - run: | - swift test --enable-swift-testing --enable-code-coverage - CODECOV_PATH=$(swift test --enable-code-coverage --show-codecov-path) - echo $CODECOV_PATH - - - name: Run Coverage Extraction Script - run: bash ${{ env.EXTRACT_COVERAGE }} - - - name: Comment on Pull Request - if: github.event_name == 'pull_request' - uses: actions/github-script@v6 - with: - script: | - const fs = require('fs'); - const coverageOutput = fs.readFileSync('${{ env.COVERAGE_SUMMARY_FILE }}', 'utf8'); - - const newComment = ` - ${{ env.BOT_COMMENT_HEADER }} - - ${coverageOutput} - - _Generated by GitHub Actions._ - `; - - // Fetch existing comments - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - }); - - // Identify and delete previous bot comments - const botComments = comments.data.filter(comment => - comment.body.includes("${{ env.BOT_COMMENT_HEADER }}") - ); - - for (const botComment of botComments) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - }); - } - - // Create a comment on the pull request - await github.rest.issues.createComment({ - issue_number: context.payload.pull_request.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: newComment, - }); +name: Package Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main", "next" ] + +jobs: + coverage: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: Snapp-Mobile/swift-coverage-action@v1.0.1 diff --git a/.gitignore b/.gitignore index b83897447..e8dac98c9 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ fastlane/test_output .DS_Store Example/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm /**/*.docc/.docc-build -.swiftpm \ No newline at end of file +.swiftpm +Package.resolved diff --git a/Example/Example/Example.xcodeproj/project.pbxproj b/Example/Example/Example.xcodeproj/project.pbxproj index cd264d6cd..c182cca96 100644 --- a/Example/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example/Example.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ Modules/ThemeViewer/Images/ImagesViewer.swift, Modules/ThemeViewer/Images/ImageViewer.swift, Modules/ThemeViewer/Metrics/MetricsViewer.swift, + Modules/ThemeViewer/Shadows/ShadowsViewer.swift, Modules/ThemeViewer/Shapes/ShapesViewer.swift, Modules/ThemeViewer/ThemeViewer.swift, Modules/ThemeViewer/Typography/TypographyViewer.swift, @@ -117,9 +118,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CEF2CF432D5369890012251C /* SnappThemingSVGSupport in Frameworks */, 53F240072D36753400264B36 /* Lottie in Frameworks */, - CEF2CC1A2D4A685E0012251C /* SnappThemingSVGSupport in Frameworks */, 53679CA12D3FA147002F2787 /* SnappTheming in Frameworks */, 53679CA32D3FA147002F2787 /* SnappThemingSwiftUIHelpers in Frameworks */, ); @@ -188,8 +187,6 @@ 53F240062D36753400264B36 /* Lottie */, 53679CA02D3FA147002F2787 /* SnappTheming */, 53679CA22D3FA147002F2787 /* SnappThemingSwiftUIHelpers */, - CEF2CC192D4A685E0012251C /* SnappThemingSVGSupport */, - CEF2CF422D5369890012251C /* SnappThemingSVGSupport */, ); productName = Example; productReference = 53F233062D31511E00264B36 /* Example.app */; @@ -246,7 +243,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 2600; TargetAttributes = { 53F233052D31511E00264B36 = { CreatedOnToolsVersion = 16.2; @@ -271,7 +268,6 @@ packageReferences = ( 53F240032D3674F900264B36 /* XCRemoteSwiftPackageReference "lottie-ios" */, 53679C9F2D3FA147002F2787 /* XCLocalSwiftPackageReference "../../../SnappTheming" */, - CEF2CF412D5369890012251C /* XCRemoteSwiftPackageReference "SnappThemingSVGSupport" */, ); preferredProjectObjectVersion = 77; productRefGroup = 53F233072D31511E00264B36 /* Products */; @@ -415,6 +411,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -471,6 +468,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -509,7 +507,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; TVOS_DEPLOYMENT_TARGET = 18.0; XROS_DEPLOYMENT_TARGET = 2.0; @@ -549,7 +547,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; TVOS_DEPLOYMENT_TARGET = 18.0; XROS_DEPLOYMENT_TARGET = 2.0; @@ -580,7 +578,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 11.2; }; @@ -610,7 +608,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 11.2; }; @@ -701,14 +699,6 @@ minimumVersion = 4.5.0; }; }; - CEF2CF412D5369890012251C /* XCRemoteSwiftPackageReference "SnappThemingSVGSupport" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Snapp-Mobile/SnappThemingSVGSupport"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.0.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -725,10 +715,6 @@ package = 53F240032D3674F900264B36 /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; - CEF2CC192D4A685E0012251C /* SnappThemingSVGSupport */ = { - isa = XCSwiftPackageProductDependency; - productName = SnappThemingSVGSupport; - }; CEF2CF242D5363E00012251C /* SnappTheming */ = { isa = XCSwiftPackageProductDependency; package = 53679C9F2D3FA147002F2787 /* XCLocalSwiftPackageReference "../../../SnappTheming" */; @@ -739,11 +725,6 @@ package = 53679C9F2D3FA147002F2787 /* XCLocalSwiftPackageReference "../../../SnappTheming" */; productName = SnappThemingSwiftUIHelpers; }; - CEF2CF422D5369890012251C /* SnappThemingSVGSupport */ = { - isa = XCSwiftPackageProductDependency; - package = CEF2CF412D5369890012251C /* XCRemoteSwiftPackageReference "SnappThemingSVGSupport" */; - productName = SnappThemingSVGSupport; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 53F232FE2D31511E00264B36 /* Project object */; diff --git a/Example/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme index 868411f63..f589e1cd8 100644 --- a/Example/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme +++ b/Example/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -1,6 +1,6 @@ : View { ForEach( sections: content, content: { section in - let items = section.content.enumerated().map(Item.init(index:subview:)) + let items = section.content.enumerated() + .map { Item(index: $0, subview: $1) } ForEach(items, id: \.index) { item in item.subview if item.index != section.content.endIndex - 1 { Spacer() } } - }) + } + ) } .padding(.horizontal, 7) .frame(maxWidth: .infinity) diff --git a/Example/Example/Example/Modules/Banking/ButtonStyles/ActionButtonStyle.swift b/Example/Example/Example/Modules/Banking/ButtonStyles/ActionButtonStyle.swift index 7858357d4..3eb32052a 100644 --- a/Example/Example/Example/Modules/Banking/ButtonStyles/ActionButtonStyle.swift +++ b/Example/Example/Example/Modules/Banking/ButtonStyles/ActionButtonStyle.swift @@ -41,11 +41,7 @@ struct ActionButtonStyle: ButtonStyle { isEnabled: isEnabled) ) .clipShape(style.shape.shape) - .shadow( - color: theme.colors.shadow, - radius: theme.metrics.shadowRadius, - x: theme.metrics.shadowXOffset, - y: theme.metrics.shadowYOffset) + .shadow(theme.shadows.action) configuration.title .font(style.typography.font) diff --git a/Example/Example/Example/Modules/Banking/CreditCardView.swift b/Example/Example/Example/Modules/Banking/CreditCardView.swift index 519fbdd05..1d624120b 100644 --- a/Example/Example/Example/Modules/Banking/CreditCardView.swift +++ b/Example/Example/Example/Modules/Banking/CreditCardView.swift @@ -42,10 +42,7 @@ struct CreditCardView: View { maxHeight: isCompact ? .infinity : 230 ) .aspectRatio(theme.metrics.creditCardAspectRatio, contentMode: .fill) - .shadow( - color: theme.colors.shadow, - radius: theme.metrics.shadowRadius - ) + .shadow(theme.shadows.card) #if os(watchOS) .padding(.horizontal, theme.metrics.small) #endif diff --git a/Example/Example/Example/Modules/Banking/Model/Transaction.swift b/Example/Example/Example/Modules/Banking/Model/Transaction.swift index 832cb5e85..cc8bd03cf 100644 --- a/Example/Example/Example/Modules/Banking/Model/Transaction.swift +++ b/Example/Example/Example/Modules/Banking/Model/Transaction.swift @@ -8,9 +8,10 @@ import Foundation import SwiftUI -struct Transaction: Identifiable { +@MainActor +struct Transaction: Identifiable, @MainActor CaseIterable { enum Category: String { - case entertainment = "Entertainemnt" + case entertainment = "Entertainment" case cardTransfer = "Card Transfer" case groceries = "Groceries" case subscriptions = "Subscriptions" @@ -35,9 +36,7 @@ struct Transaction: Identifiable { let category: Category let identity: Identity let date: Date -} -extension Transaction: CaseIterable { #if os(tvOS) || os(macOS) || os(visionOS) static var allCases: [Transaction] = extended #else @@ -55,33 +54,33 @@ extension Transaction: CaseIterable { static private var base: [Transaction] = [ Transaction( amount: -9.99, category: .entertainment, identity: .netflix, - date: .init(timeIntervalSinceNow: -24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -24 * 60 * 60)), Transaction( amount: 999.99, category: .cardTransfer, identity: .timCook, - date: .init(timeIntervalSinceNow: -2 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -2 * 24 * 60 * 60)), Transaction( amount: -125.12, category: .groceries, identity: .lidl, - date: .init(timeIntervalSinceNow: -2 * 24 * 60 * 60 - 2 * 60 * 60)), + date: Date(timeIntervalSinceNow: -2 * 24 * 60 * 60 - 2 * 60 * 60)), Transaction( amount: -5.99, category: .subscriptions, identity: .apple, - date: .init(timeIntervalSinceNow: -7 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)), ] static private var additional: [Transaction] = [ Transaction( amount: 0.99, category: .cardTransfer, identity: .mark, - date: .init(timeIntervalSinceNow: -4 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -4 * 24 * 60 * 60)), Transaction( amount: 434.00, category: .cardTransfer, identity: .dylan, - date: .init(timeIntervalSinceNow: -5 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -5 * 24 * 60 * 60)), Transaction( amount: 661.44, category: .cardTransfer, identity: .irving, - date: .init(timeIntervalSinceNow: -6 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -6 * 24 * 60 * 60)), Transaction( amount: 239.11, category: .cardTransfer, identity: .helly, - date: .init(timeIntervalSinceNow: -8 * 24 * 60 * 60)), + date: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60)), Transaction( amount: -15.42, category: .groceries, identity: .lidl, - date: .init(timeIntervalSinceNow: -3 * 24 * 60 * 60 - 2 * 60 * 60)), + date: Date(timeIntervalSinceNow: -3 * 24 * 60 * 60 - 2 * 60 * 60)), ] } diff --git a/Example/Example/Example/Modules/Settings/SettingsView.swift b/Example/Example/Example/Modules/Settings/SettingsView.swift index c9bd92017..855dd7967 100644 --- a/Example/Example/Example/Modules/Settings/SettingsView.swift +++ b/Example/Example/Example/Modules/Settings/SettingsView.swift @@ -71,6 +71,8 @@ struct SettingsView: View { GradientsViewer() case .shapes: ShapesViewer() + case .shadows: + ShadowsViewer() #if !os(watchOS) case .animations: AnimationsViewer() diff --git a/Example/Example/Example/Modules/ThemeViewer/Shadows/ShadowsViewer.swift b/Example/Example/Example/Modules/ThemeViewer/Shadows/ShadowsViewer.swift new file mode 100644 index 000000000..b52dc3a1b --- /dev/null +++ b/Example/Example/Example/Modules/ThemeViewer/Shadows/ShadowsViewer.swift @@ -0,0 +1,50 @@ +// +// ShadowsViewer.swift +// Example +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import SwiftUI + +struct ShadowsViewer: View { + @Environment(Theme.self) private var theme + @FocusState var focusedKey: String? + + var body: some View { + List { + Section { + ForEach(theme.shadows.keys, id: \.self) { key in + + ZStack { + // Background to make shadow visible + theme.colors.surfacePrimary + .ignoresSafeArea() + + let shadow = theme.shadows[dynamicMember: key] + RoundedRectangle(cornerRadius: 24) + .fill(theme.colors.surfacePrimary) + .frame(height: 120) + .shadow( + color: shadow.color, + radius: shadow.radius, + x: shadow.x, + y: shadow.y + ) + .padding(40) + + Text("\(key)") + } + } + } + } + .navigationTitle("Shadows") + #if os(iOS) || targetEnvironment(macCatalyst) + .navigationBarTitleDisplayMode(.inline) + #endif + } +} + +#Preview { + ShadowsViewer() +} diff --git a/Example/Example/Example/Modules/ThemeViewer/ThemeViewer.swift b/Example/Example/Example/Modules/ThemeViewer/ThemeViewer.swift index 7fb5a8097..c0c41bec3 100644 --- a/Example/Example/Example/Modules/ThemeViewer/ThemeViewer.swift +++ b/Example/Example/Example/Modules/ThemeViewer/ThemeViewer.swift @@ -12,7 +12,7 @@ enum ThemeDestination: String, Hashable, CaseIterable { #if !os(watchOS) case animations #endif - case buttons, colors, fonts, gradients, images, metrics, shapes, typography + case buttons, colors, fonts, gradients, images, metrics, shapes, typography, shadows #if !os(tvOS) && !os(watchOS) case themeJSON = "Theme JSON" #endif diff --git a/Example/Example/Example/Shared/Settings/SettingsManager.swift b/Example/Example/Example/Shared/Settings/SettingsManager.swift index cdeddd111..b4262b68b 100644 --- a/Example/Example/Example/Shared/Settings/SettingsManager.swift +++ b/Example/Example/Example/Shared/Settings/SettingsManager.swift @@ -113,8 +113,6 @@ extension SettingsManager.Storage { currentThemeSource = $0 } } - - static let preview: Self = preview() } extension EnvironmentValues { diff --git a/Example/Example/Example/Shared/Themes/colorful.json b/Example/Example/Example/Shared/Themes/colorful.json index 67a303b5b..eacd8ec1f 100644 --- a/Example/Example/Example/Shared/Themes/colorful.json +++ b/Example/Example/Example/Shared/Themes/colorful.json @@ -48,6 +48,22 @@ "shadowXOffset": 0, "shadowYOffset": 4 }, + "shadows": { + "action": { + "color": "$colors/shadow", + "x": "$metrics/shadowXOffset", + "y": "$metrics/shadowYOffset", + "radius": "$metrics/shadowRadius", + "spread": 0, + }, + "card": { + "color": "$colors/shadow", + "x": 0, + "y": 0, + "radius": "$metrics/shadowRadius", + "spread": 120, + } + }, "fonts": { "Doto-Regular": { "postScriptName": "Doto-Regular", diff --git a/Example/Example/Example/Shared/Themes/dark.json b/Example/Example/Example/Shared/Themes/dark.json index 7e41e04ed..7e95e7748 100644 --- a/Example/Example/Example/Shared/Themes/dark.json +++ b/Example/Example/Example/Shared/Themes/dark.json @@ -47,6 +47,22 @@ "shadowXOffset": 0, "shadowYOffset": 2 }, + "shadows": { + "action": { + "color": "$colors/shadow", + "x": "$metrics/shadowXOffset", + "y": "$metrics/shadowYOffset", + "radius": "$metrics/shadowRadius", + "spread": 0, + }, + "card": { + "color": "$colors/shadow", + "x": 0, + "y": 0, + "radius": "$metrics/shadowRadius", + "spread": 0, + } + }, "fonts": { "Inter-Bold": { "postScriptName": "Inter-Bold", diff --git a/Example/Example/Example/Shared/Themes/light.json b/Example/Example/Example/Shared/Themes/light.json index e1723781b..a4f6c0161 100644 --- a/Example/Example/Example/Shared/Themes/light.json +++ b/Example/Example/Example/Shared/Themes/light.json @@ -46,6 +46,22 @@ "shadowXOffset": 0, "shadowYOffset": 4 }, + "shadows": { + "action": { + "color": "$colors/shadow", + "x": "$metrics/shadowXOffset", + "y": "$metrics/shadowYOffset", + "radius": "$metrics/shadowRadius", + "spread": 0, + }, + "card": { + "color": "$colors/shadow", + "x": 0, + "y": 0, + "radius": "$metrics/shadowRadius", + "spread": 0, + } + }, "fonts": { "Inter-Bold": { "postScriptName": "Inter-Bold", diff --git a/Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd8c..07e3afc34 100644 --- a/Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "logo.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/Example/Example/Example/Assets.xcassets/AppIcon.appiconset/9262DB8E-2A17-4EE6-B5B7-60F85F33CB7F 1.png b/Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/logo.png similarity index 100% rename from Example/Example/Example/Assets.xcassets/AppIcon.appiconset/9262DB8E-2A17-4EE6-B5B7-60F85F33CB7F 1.png rename to Example/Example/ExampleWatchOSApp Watch App/Assets.xcassets/AppIcon.appiconset/logo.png diff --git a/Example/MeetSnappTheming/MeetSnappTheming.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/MeetSnappTheming/MeetSnappTheming.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 1b5793bf2..000000000 --- a/Example/MeetSnappTheming/MeetSnappTheming.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "0fb407c043677cc21d521286d3bcc5c7966397bf6beb4569c7b35bb59fa7caac", - "pins" : [ - { - "identity" : "snapptheming", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Snapp-Mobile/SnappTheming", - "state" : { - "revision" : "6d42e1aedba9080364223e67bcf1abf7366e5430", - "version" : "0.0.8" - } - } - ], - "version" : 3 -} diff --git a/LICENSE b/LICENSE index 9d6cb13bb..069259463 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Point-Free, Inc. +Copyright (c) 2025 Snapp Mobile Germany GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index 8218a3aef..74b341afd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 6.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -13,22 +12,25 @@ let package = Package( .visionOS(.v1), ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "SnappTheming", - targets: ["SnappTheming"]), + targets: ["SnappTheming"] + ), .library( name: "SnappThemingSwiftUIHelpers", - targets: ["SnappThemingSwiftUIHelpers"]), + targets: ["SnappThemingSwiftUIHelpers"] + ), + ], + dependencies: [ + .package(url: "https://github.com/Snapp-Mobile/SwiftFormatLintPlugin.git", exact: "1.0.4") ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "SnappTheming", plugins: [ - .plugin(name: "SnappThemingSwiftFormatPlugin") - ]), + .plugin(name: "Lint", package: "SwiftFormatLintPlugin") + ] + ), .testTarget( name: "SnappThemingTests", dependencies: ["SnappTheming"], @@ -36,7 +38,9 @@ let package = Package( .copy("Resources/fonts.json") ] ), - .target(name: "SnappThemingSwiftUIHelpers"), - .plugin(name: "SnappThemingSwiftFormatPlugin", capability: .buildTool(), path: "Plugins/SnappThemingSwiftFormatPlugin"), + .target( + name: "SnappThemingSwiftUIHelpers", + dependencies: ["SnappTheming"] + ), ] ) diff --git a/Plugins/SnappThemingSwiftFormatPlugin/SnappThemingSwiftFormatPlugin.swift b/Plugins/SnappThemingSwiftFormatPlugin/SnappThemingSwiftFormatPlugin.swift deleted file mode 100644 index f925896e4..000000000 --- a/Plugins/SnappThemingSwiftFormatPlugin/SnappThemingSwiftFormatPlugin.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// SwiftLintPlugin.swift -// SnappTheming -// -// Created by Oleksii Kolomiiets on 21.01.2025. -// - -import Foundation -import PackagePlugin - -/// A Swift Package Manager build tool plugin to run SwiftFormat during the build process. -@main -struct SnappThemingSwiftFormatPlugin: BuildToolPlugin { - /// Creates build commands for the plugin. - /// - Parameters: - /// - context: The plugin context providing details like the package directory. - /// - target: The target on which the plugin is applied. - /// - Returns: An array of build commands to execute during the build process. - /// - Throws: `PluginError` if the script is not found. - func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { - // Define the path to your script - let scriptPath = context.package.directoryURL.appending(path: "Plugins/SnappThemingSwiftFormatPlugin/swift-format-script.sh").path - let configurationPath = context.package.directoryURL.absoluteString - - // Validate that the script exists - guard FileManager.default.fileExists(atPath: scriptPath) else { - throw PluginError.scriptNotFound("Script not found at \(scriptPath)") - } - - // Return a build command to run your script - return [ - .buildCommand( - displayName: "Running SnappThemingSwiftFormatPlugin", - executable: URL(filePath: "/bin/bash"), - arguments: [scriptPath, configurationPath], - environment: [:], - inputFiles: [], - outputFiles: [] - ) - ] - } -} - -/// Custom errors for the plugin. -enum PluginError: Error, CustomStringConvertible { - /// Thrown when the script file is not found. - case scriptNotFound(String) - - /// A description of the error. - var description: String { - switch self { - case .scriptNotFound(let message): - return message - } - } -} diff --git a/Plugins/SnappThemingSwiftFormatPlugin/swift-format-script.sh b/Plugins/SnappThemingSwiftFormatPlugin/swift-format-script.sh deleted file mode 100755 index 4c54b0875..000000000 --- a/Plugins/SnappThemingSwiftFormatPlugin/swift-format-script.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -# Locate swift-format -linter=$(xcrun --find swift-format) - -if [ -z "$linter" ]; then - echo "error: swift-format not found" >&2 - exit 1 -fi - -# Resolve paths -SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -PACKAGE_DIRECTORY=$(cd "$SCRIPT_DIR/../.." && pwd) -CONFIG_PATH="$PACKAGE_DIRECTORY/.swiftformat" - -# Debugging outputs -echo "SCRIPT_DIR: $SCRIPT_DIR" -echo "PACKAGE_DIRECTORY: $PACKAGE_DIRECTORY" -echo "CONFIG_PATH: $CONFIG_PATH" - -# Check if the configuration file exists -if [ ! -f "$CONFIG_PATH" ]; then - echo "error: Config file not found at: $CONFIG_PATH" >&2 - exit 1 -fi - -# Verify the package directory exists -if [ ! -d "$PACKAGE_DIRECTORY" ]; then - echo "error: Directory $PACKAGE_DIRECTORY does not exist" >&2 - exit 1 -fi - -# Run swift-format lint -echo "Linting directory: $PACKAGE_DIRECTORY" -output=$("$linter" lint --configuration "$CONFIG_PATH" -r "$PACKAGE_DIRECTORY" 2>&1) - -# Debugging output -echo "Swift-format output:" -echo "$output" - -# Parse and format the output for Xcode compatibility -echo "$output" | while IFS= read -r line; do - # Match lines in the form: [file]:[line]:[column]: [level]: [message] - if [[ $line =~ ^([^:]+):([0-9]+):([0-9]+):\ (warning|error|note):\ (.+)$ ]]; then - file="${BASH_REMATCH[1]}" - line_number="${BASH_REMATCH[2]}" - column_number="${BASH_REMATCH[3]}" - level="${BASH_REMATCH[4]}" - message="${BASH_REMATCH[5]}" - - # Print the formatted output - echo "$file:$line_number:$column_number: $level: $message" - else - # Print unmatched lines as-is for debugging - echo "No match for line: $line" >&2 - fi -done diff --git a/Sources/SnappTheming/Builders/ShapeBuilder.swift b/Sources/SnappTheming/Builders/ShapeBuilder.swift deleted file mode 100644 index 686752d49..000000000 --- a/Sources/SnappTheming/Builders/ShapeBuilder.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ShapeBuilder.swift -// SnappTheming -// -// Created by Ilian Konchev on 14.01.25. -// - -import SwiftUI - -#if swift(>=5.4) - /// A result builder that constructs a `Shape` from multiple shape expressions. - /// - /// `ShapeBuilder` enables the composition of `Shape` instances using Swift’s result builder syntax, - /// allowing for a flexible, declarative way to combine shapes. - @resultBuilder - public enum ShapeBuilder { - /// Constructs a single `Shape` from a given shape expression. - /// - /// - Parameter builder: The `Shape` to be used in the result. - /// - Returns: The provided `Shape` instance. - public static func buildBlock(_ builder: S) -> some Shape { - builder - } - } -#else - @_functionBuilder - public enum ShapeBuilder { - /// Constructs a single `Shape` from a given shape expression. - /// - /// - Parameter builder: The `Shape` to be used in the result. - /// - Returns: The provided `Shape` instance. - public static func buildBlock(_ builder: S) -> some Shape { - builder - } - } -#endif - -extension ShapeBuilder { - /// Constructs an `EitherShape` from an optional `Shape`. - /// - /// - If `component` is non-nil, it is wrapped in `EitherShape.first(_:)`. - /// - If `component` is `nil`, it defaults to `EmptyShape()`, wrapped in `EitherShape.second(_:)`. - /// - /// - Parameter component: The optional `Shape` to construct. - /// - Returns: An `EitherShape` containing either the provided shape or `EmptyShape`. - public static func buildOptional(_ component: S?) -> EitherShape { - component.flatMap(EitherShape.first) ?? EitherShape.second(EmptyShape()) - } - - /// Constructs an `EitherShape` using the first branch of a conditional statement. - /// - /// This method is used when a shape is chosen in a Swift result builder `if-else` or `switch` construct. - /// - /// - Parameter component: The `Shape` used when the first branch is selected. - /// - Returns: An `EitherShape` wrapping the provided shape in the `.first` case. - public static func buildEither(first component: First) -> EitherShape { - .first(component) - } - - /// Constructs an `EitherShape` using the second branch of a conditional statement. - /// - /// This method is used when a shape is chosen in a Swift result builder `if-else` or `switch` construct. - /// - /// - Parameter component: The `Shape` used when the second branch is selected. - /// - Returns: An `EitherShape` wrapping the provided shape in the `.second` case. - public static func buildEither(second component: Second) -> EitherShape { - .second(component) - } -} diff --git a/Sources/SnappTheming/SnappTheming.docc/Resources/logo.png b/Sources/SnappTheming/SnappTheming.docc/Resources/logo.png index 5131ccdce..68b4ddb05 100644 Binary files a/Sources/SnappTheming/SnappTheming.docc/Resources/logo.png and b/Sources/SnappTheming/SnappTheming.docc/Resources/logo.png differ diff --git a/Sources/SnappTheming/SnappTheming.docc/Resources/logo@2x.png b/Sources/SnappTheming/SnappTheming.docc/Resources/logo@2x.png index 0a8e17b29..089f40ea2 100644 Binary files a/Sources/SnappTheming/SnappTheming.docc/Resources/logo@2x.png and b/Sources/SnappTheming/SnappTheming.docc/Resources/logo@2x.png differ diff --git a/Sources/SnappTheming/SnappTheming.docc/Resources/logo@3x.png b/Sources/SnappTheming/SnappTheming.docc/Resources/logo@3x.png index 29513f8aa..8215b01cf 100644 Binary files a/Sources/SnappTheming/SnappTheming.docc/Resources/logo@3x.png and b/Sources/SnappTheming/SnappTheming.docc/Resources/logo@3x.png differ diff --git a/Sources/SnappTheming/SnappTheming.docc/SnappTheming.md b/Sources/SnappTheming/SnappTheming.docc/SnappTheming.md index e26aeeaa5..bd8e79a07 100644 --- a/Sources/SnappTheming/SnappTheming.docc/SnappTheming.md +++ b/Sources/SnappTheming/SnappTheming.docc/SnappTheming.md @@ -4,7 +4,7 @@ Framework for parsing design theme declarations from JSON. ## Overview -![SnappTheming logo](logo.png) +![SnappTheming logo](Resources/logo.png) `SnappTheming` is a Swift framework designed to simplify the integration of dynamic design themes into iOS applications. Utilizing JSON declarations, the framework facilitates the extraction and application of various theming elements, including colors, fonts, gradients, and shape styles, directly into the application’s user interface. diff --git a/Sources/SnappTheming/Theme/Animations/SnappThemingAnimationRepresentation.swift b/Sources/SnappTheming/Theme/Animations/SnappThemingAnimationRepresentation.swift index 742825b8b..56499c220 100644 --- a/Sources/SnappTheming/Theme/Animations/SnappThemingAnimationRepresentation.swift +++ b/Sources/SnappTheming/Theme/Animations/SnappThemingAnimationRepresentation.swift @@ -82,6 +82,13 @@ public struct SnappThemingAnimationRepresentation: Codable { } } + /// Initializes a `SnappThemingAnimationRepresentation` with a specific animation value. + /// + /// - Parameter animation: The `SnappThemingAnimationValue` to represent. + public init(animation: SnappThemingAnimationValue) { + self.animation = animation + } + /// Encodes an instance of `SnappThemingAnimationRepresentation` to an encoder. /// /// - Parameter encoder: The encoder to encode to. diff --git a/Sources/SnappTheming/Theme/Color/SnappThemingDynamicColor.swift b/Sources/SnappTheming/Theme/Color/SnappThemingDynamicColor.swift index 35b402630..195f1723b 100644 --- a/Sources/SnappTheming/Theme/Color/SnappThemingDynamicColor.swift +++ b/Sources/SnappTheming/Theme/Color/SnappThemingDynamicColor.swift @@ -20,6 +20,16 @@ public struct SnappThemingDynamicColor: Codable { private let light: String private let dark: String + /// Initializes a `SnappThemingDynamicColor` with separate color string values for light and dark appearances. + /// + /// - Parameters: + /// - light: The hexadecimal string representation of the color for light mode. + /// - dark: The hexadecimal string representation of the color for dark mode. + public init(light: String, dark: String) { + self.light = light + self.dark = dark + } + #if canImport(UIKit) /// Returns a `UIColor` for the current user interface style (light or dark mode). /// - Parameter colorFormat: The color format to use (e.g., ARGB, RGBA). diff --git a/Sources/SnappTheming/Theme/Font/SnappThemingFontInformation.swift b/Sources/SnappTheming/Theme/Font/SnappThemingFontInformation.swift index 105139913..182583262 100644 --- a/Sources/SnappTheming/Theme/Font/SnappThemingFontInformation.swift +++ b/Sources/SnappTheming/Theme/Font/SnappThemingFontInformation.swift @@ -14,6 +14,17 @@ public struct SnappThemingFontInformation: Codable, Hashable { /// The data source for the font, typically encoded as a data URI. This property is optional and will be `nil` if the font is either bundled within the app or provided by the system. public let source: SnappThemingDataURI? + + /// Initializes `SnappThemingFontInformation` with the PostScript name and an optional data source. + /// + /// - Parameters: + /// - postScriptName: The PostScript name of the font. + /// - source: An optional `SnappThemingDataURI` representing the font's data source. + /// This can be `nil` if the font is system-provided or bundled. + public init(postScriptName: String, source: SnappThemingDataURI?) { + self.postScriptName = postScriptName + self.source = source + } } extension SnappThemingFontInformation { diff --git a/Sources/SnappTheming/Theme/Gradient/Gradients/AngularGradient/SnappThemingAngularGradientRepresentation.swift b/Sources/SnappTheming/Theme/Gradient/Gradients/AngularGradient/SnappThemingAngularGradientRepresentation.swift index 357b55b36..a3bad27c2 100644 --- a/Sources/SnappTheming/Theme/Gradient/Gradients/AngularGradient/SnappThemingAngularGradientRepresentation.swift +++ b/Sources/SnappTheming/Theme/Gradient/Gradients/AngularGradient/SnappThemingAngularGradientRepresentation.swift @@ -20,6 +20,25 @@ public struct SnappThemingAngularGradientRepresentation { /// The ending angle of the gradient, represented as a theming token. public let endAngle: SnappThemingToken + + /// Initializes a `SnappThemingAngularGradientRepresentation` with the specified properties. + /// + /// - Parameters: + /// - colors: An array of `SnappThemingToken` objects, each representing a color in the gradient. + /// - center: A `SnappThemingUnitPointWrapper` defining the center point of the gradient. + /// - startAngle: A `SnappThemingToken` representing the starting angle of the gradient in degrees. + /// - endAngle: A `SnappThemingToken` representing the ending angle of the gradient in degrees. + public init( + colors: [SnappThemingToken], + center: SnappThemingUnitPointWrapper, + startAngle: SnappThemingToken, + endAngle: SnappThemingToken + ) { + self.colors = colors + self.center = center + self.startAngle = startAngle + self.endAngle = endAngle + } } extension SnappThemingAngularGradientRepresentation: SnappThemingGradientProviding { diff --git a/Sources/SnappTheming/Theme/Gradient/Gradients/LinearGradient/SnappThemingLinearGradientRepresentation.swift b/Sources/SnappTheming/Theme/Gradient/Gradients/LinearGradient/SnappThemingLinearGradientRepresentation.swift index 16cd8e370..f8a65527a 100644 --- a/Sources/SnappTheming/Theme/Gradient/Gradients/LinearGradient/SnappThemingLinearGradientRepresentation.swift +++ b/Sources/SnappTheming/Theme/Gradient/Gradients/LinearGradient/SnappThemingLinearGradientRepresentation.swift @@ -19,6 +19,22 @@ public struct SnappThemingLinearGradientRepresentation: Codable { /// The ending point of the gradient. public let endPoint: SnappThemingUnitPointWrapper + + /// Initializes a `SnappThemingLinearGradientRepresentation` with the specified properties. + /// + /// - Parameters: + /// - colors: An array of `SnappThemingToken` objects, each representing a color in the gradient. + /// - startPoint: A `SnappThemingUnitPointWrapper` defining the starting point of the gradient. + /// - endPoint: A `SnappThemingUnitPointWrapper` defining the ending point of the gradient. + public init( + colors: [SnappThemingToken], + startPoint: SnappThemingUnitPointWrapper, + endPoint: SnappThemingUnitPointWrapper + ) { + self.colors = colors + self.startPoint = startPoint + self.endPoint = endPoint + } } extension SnappThemingLinearGradientRepresentation: SnappThemingGradientProviding { diff --git a/Sources/SnappTheming/Theme/Gradient/Gradients/RadialGradient/SnappThemingRadialGradientRepresentation.swift b/Sources/SnappTheming/Theme/Gradient/Gradients/RadialGradient/SnappThemingRadialGradientRepresentation.swift index 3807b4df0..def64d9e1 100644 --- a/Sources/SnappTheming/Theme/Gradient/Gradients/RadialGradient/SnappThemingRadialGradientRepresentation.swift +++ b/Sources/SnappTheming/Theme/Gradient/Gradients/RadialGradient/SnappThemingRadialGradientRepresentation.swift @@ -24,6 +24,25 @@ public struct SnappThemingRadialGradientRepresentation: Codable { /// The ending radius of the gradient, represented as a theming token. public let endRadius: SnappThemingToken + + /// Initializes a `SnappThemingRadialGradientRepresentation` with the specified properties. + /// + /// - Parameters: + /// - colors: An array of `SnappThemingToken` objects, each representing a color in the gradient. + /// - center: A `SnappThemingUnitPointWrapper` defining the center point of the gradient. + /// - startRadius: A `SnappThemingToken` representing the starting radius of the gradient. + /// - endRadius: A `SnappThemingToken` representing the ending radius of the gradient. + public init( + colors: [SnappThemingToken], + center: SnappThemingUnitPointWrapper, + startRadius: SnappThemingToken, + endRadius: SnappThemingToken + ) { + self.colors = colors + self.center = center + self.startRadius = startRadius + self.endRadius = endRadius + } } extension SnappThemingRadialGradientRepresentation: SnappThemingGradientProviding { diff --git a/Sources/SnappTheming/Theme/Gradient/SnappThemingGradientRepresentation.swift b/Sources/SnappTheming/Theme/Gradient/SnappThemingGradientRepresentation.swift index afba91f85..fbc191a40 100644 --- a/Sources/SnappTheming/Theme/Gradient/SnappThemingGradientRepresentation.swift +++ b/Sources/SnappTheming/Theme/Gradient/SnappThemingGradientRepresentation.swift @@ -68,6 +68,14 @@ public struct SnappThemingGradientRepresentation: Codable { } } + /// Initializes a `SnappThemingGradientRepresentation` with a specific gradient-providing configuration. + /// + /// - Parameter configuration: An object conforming to `SnappThemingGradientProviding` + /// that defines the gradient's appearance and behavior. + public init(configuration: any SnappThemingGradientProviding) { + self.configuration = configuration + } + /// Encodes the shape style representation into the provided encoder. /// /// This method encodes the `configuration` property (which contains the shape style) diff --git a/Sources/SnappTheming/Theme/Image/SnappThemingExternalImageProcessorProtocol.swift b/Sources/SnappTheming/Theme/Image/SnappThemingExternalImageProcessorProtocol.swift index c21c7b7de..5b024a771 100644 --- a/Sources/SnappTheming/Theme/Image/SnappThemingExternalImageProcessorProtocol.swift +++ b/Sources/SnappTheming/Theme/Image/SnappThemingExternalImageProcessorProtocol.swift @@ -12,8 +12,8 @@ import UniformTypeIdentifiers public protocol SnappThemingExternalImageProcessorProtocol: Sendable { /// Processing image `Data` and `UIType` to convert into a ``SnappThemingImage``. /// - /// - Parameter data: Image `Data`. + /// - Parameter object: Image object of `SnappThemingImageObject` type - contains raw data and url if available. /// - Parameter type: Image `UTType`. /// - Returns: A `SnappThemingImage` if the conversion is successful; otherwise, `nil`. - func process(_ data: Data, of type: UTType) -> SnappThemingImage? + func process(_ object: SnappThemingImageObject, of type: UTType) -> SnappThemingImage? } diff --git a/Sources/SnappTheming/Theme/Image/SnappThemingImageDeclarations.swift b/Sources/SnappTheming/Theme/Image/SnappThemingImageDeclarations.swift index ae8e9b3ff..f5477ffc1 100644 --- a/Sources/SnappTheming/Theme/Image/SnappThemingImageDeclarations.swift +++ b/Sources/SnappTheming/Theme/Image/SnappThemingImageDeclarations.swift @@ -54,10 +54,11 @@ where DeclaredValue == String, Configuration == SnappThemingImageConfiguration { } if let representation = try? SnappThemingDataURI(from: rawValue) { - let cachedData = configuration.imagesManager.object(for: keyPath, of: representation) ?? representation.data - if let themingImage = configuration.imagesManager.image(from: cachedData, of: representation.type) { - configuration.imagesManager.setObject(representation.data, for: keyPath) - configuration.imagesManager.store(representation, for: keyPath) + let object = configuration.imagesManager.object(for: keyPath, of: representation) + let imageObject = object ?? SnappThemingImageObject(data: representation.data) + configuration.imagesManager.setObject(representation.data, for: keyPath) + configuration.imagesManager.store(representation, for: keyPath) + if let themingImage = configuration.imagesManager.image(from: imageObject, of: representation.type) { return themingImage.image } } else if rawValue.starts(with: "system:") { diff --git a/Sources/SnappTheming/Theme/Image/SnappThemingImageManager.swift b/Sources/SnappTheming/Theme/Image/SnappThemingImageManager.swift index d4b9eb0f5..74fc982bf 100644 --- a/Sources/SnappTheming/Theme/Image/SnappThemingImageManager.swift +++ b/Sources/SnappTheming/Theme/Image/SnappThemingImageManager.swift @@ -16,16 +16,16 @@ public protocol SnappThemingImageManager: Sendable { /// - Parameters: /// - key: A `String` representing the unique key associated with the image. /// - dataURI: A `SnappThemingDataURI` containing the data and type information of the image. - /// - Returns: A `Data` if an object for the given key and data URI is available; otherwise, `nil`. - func object(for key: String, of dataURI: SnappThemingDataURI) -> Data? + /// - Returns: A `SnappThemingImageObject` if an object for the given key and data URI is available; otherwise, `nil`. + func object(for key: String, of dataURI: SnappThemingDataURI) -> SnappThemingImageObject? - /// Converts raw data into a `SnappThemingImage` based on the specified type. + /// Converts an image object into a `SnappThemingImage` based on the specified type. /// /// - Parameters: - /// - data: The raw `Data` representing the image content. + /// - object: The `SnappThemingImageObject` containing the image data and optional source URL context. /// - type: The `UTType` indicating the type of the image (e.g., `.png`, `.jpeg`, `.svg`). /// - Returns: A `SnappThemingImage` if the conversion is successful; otherwise, `nil`. - func image(from data: Data, of type: UTType) -> SnappThemingImage? + func image(from object: SnappThemingImageObject, of type: UTType) -> SnappThemingImage? /// Stores a `Data` object in the image manager associated with the given key. /// diff --git a/Sources/SnappTheming/Theme/Image/SnappThemingImageManagerDefault.swift b/Sources/SnappTheming/Theme/Image/SnappThemingImageManagerDefault.swift index 8ce8af6ab..7f8449246 100644 --- a/Sources/SnappTheming/Theme/Image/SnappThemingImageManagerDefault.swift +++ b/Sources/SnappTheming/Theme/Image/SnappThemingImageManagerDefault.swift @@ -67,20 +67,19 @@ public final class SnappThemingImageManagerDefault: SnappThemingImageManager { /// - Parameters: /// - key: The unique key identifying the image. /// - dataURI: A `SnappThemingDataURI` object containing the image data and MIME type. - /// - Returns: The retrieved image data or `nil` if not found. + /// - Returns: The retrieved image object or `nil` if not found. public func object( for key: String, of dataURI: SnappThemingDataURI - ) -> Data? { + ) -> SnappThemingImageObject? { accessQueue.sync { do { if let cachedImage = cache.object(forKey: key as NSString) as? Data { - return cachedImage + return SnappThemingImageObject(data: cachedImage, url: nil) } else if let imageURL = imageCacheURL(for: key, of: dataURI), fileManager.fileExists(atPath: imageURL.path()) { - return try Data(contentsOf: imageURL) - + return try SnappThemingImageObject(url: imageURL) } return nil } catch let error { @@ -92,7 +91,7 @@ public final class SnappThemingImageManagerDefault: SnappThemingImageManager { /// Processes image `Data` and `UIType` to generate a corresponding `SnappThemingImage`. /// - /// - Parameter data: Image `Data`. + /// - Parameter object: The `SnappThemingImageObject` containing the image data and optional source URL context. /// - Parameter type: Image `UTType`. /// - Returns: A `SnappThemingImage` created from the provided representation, or `nil` if the conversion fails. /// @@ -103,13 +102,16 @@ public final class SnappThemingImageManagerDefault: SnappThemingImageManager { /// /// - Warning: Make sure to validate `data` in external processors to prevent potential issues with corrupted or malicious data. /// See how register external processors ``SnappThemingImageProcessorsRegistry``. - public func image(from data: Data, of type: UTType) -> SnappThemingImage? { - let dataURI = "data:\(String(describing: type.preferredMIMEType));\(data.base64EncodedString())" + public func image( + from object: SnappThemingImageObject, + of type: UTType + ) -> SnappThemingImage? { + let dataURI = "data:\(String(describing: type.preferredMIMEType));\(object.data.base64EncodedString())" switch type { case .pdf: #if !os(watchOS) - guard let pdfImage = SnappThemingImage.pdf(data: data) else { + guard let pdfImage = SnappThemingImage.pdf(data: object.data) else { os_log(.error, "Failed to process PDF data into an image. DataURI: %@.", dataURI) return nil } @@ -119,7 +121,7 @@ public final class SnappThemingImageManagerDefault: SnappThemingImageManager { #endif case .png, .jpeg: - guard let image = SnappThemingImage(data: data) else { + guard let image = SnappThemingImage(data: object.data) else { os_log(.error, "Failed to process PNG/JPEG data into image. DataURI: %@.", dataURI) return nil } @@ -128,7 +130,7 @@ public final class SnappThemingImageManagerDefault: SnappThemingImageManager { default: let processors = SnappThemingImageProcessorsRegistry.shared.registeredProcessors() for processor in processors { - if let processedImage: SnappThemingImage = processor.process(data, of: type) { + if let processedImage: SnappThemingImage = processor.process(object, of: type) { return processedImage } } diff --git a/Sources/SnappTheming/Theme/Image/SnappThemingImageObject.swift b/Sources/SnappTheming/Theme/Image/SnappThemingImageObject.swift new file mode 100644 index 000000000..04fe2cae0 --- /dev/null +++ b/Sources/SnappTheming/Theme/Image/SnappThemingImageObject.swift @@ -0,0 +1,93 @@ +// +// SnappThemingImageObject.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/5/25. +// + +import Foundation + +/// A container for image data with optional source URL context. +/// +/// `SnappThemingImageObject` encapsulates raw image data alongside optional URL information, +/// enabling image processors to access resource context when rendering. +public struct SnappThemingImageObject { + /// The raw image data. + /// + /// Contains the encoded image content (e.g., SVG XML, PNG bytes, etc.). + public var data: Data + + /// The URL source of the image data, if available. + /// + /// Providing a URL helps image processors understand the resource context, + /// which is essential for formats that support relative file references. + /// For SVG images, this URL enables proper handling of relative links + /// within the SVG content. + /// + /// When `nil`, image processors must work with data alone, which may + /// limit functionality for certain formats. + public var url: URL? + + /// Creates an image object with data and optional URL. + /// + /// - Parameters: + /// - data: The raw image data. + /// - url: The source URL of the image data. Defaults to `nil`. + /// + /// This initializer always succeeds and is the primary way to create + /// an `ImageObject` from existing data. + /// + /// ## Example + /// ```swift + /// let svgData = try Data(contentsOf: themePath) + /// let imageObj = ImageObject(data: svgData, url: themePath) + /// ``` + public init(data: Data, url: URL? = nil) { + self.data = data + self.url = url + } + + /// Creates an image object with optional data and URL. + /// + /// - Parameters: + /// - data: The raw image data. Returns `nil` if data is `nil`. + /// - url: The source URL of the image data. + /// + /// - Returns: An initialized `ImageObject` if data is provided; `nil` otherwise. + /// + /// This initializer is useful for handling optional data sources where + /// a missing data value should result in a failed initialization. + /// + /// ## Example + /// ```swift + /// if let imageObj = ImageObject(data: optionalSVGData, url: sourceURL) { + /// // Use imageObj + /// } + /// ``` + public init?(data: Data?, url: URL?) { + guard let data else { + return nil + } + + self.init(data: data, url: url) + } + + /// Creates an image object by loading data from a file URL. + /// + /// - Parameter url: The file URL to load image data from. + /// + /// - Throws: An error if the file cannot be read. + /// + /// This initializer is convenient for loading images from bundle resources + /// or disk while automatically preserving the source URL for context. + /// + /// ## Example + /// ```swift + /// let imageObj = try ImageObject(url: bundledSVGURL) + /// ``` + public init(url: URL) throws { + let data = try Data(contentsOf: url) + + self.init(data: data, url: url) + } +} diff --git a/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowConfiguration.swift b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowConfiguration.swift new file mode 100644 index 000000000..e2617e37a --- /dev/null +++ b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowConfiguration.swift @@ -0,0 +1,52 @@ +// +// SnappThemingShadowConfiguration.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import Foundation +import SwiftUI + +/// Configuration for theming shadow. +/// +/// This struct holds fallback data for shadow in case of any of properties are not provided. +public struct SnappThemingShadowConfiguration { + // MARK: - Public properties + + /// The fallback color used when a shadow's color cannot be resolved. + /// + /// This color is applied to shadows when the theming system cannot resolve + /// the specified color token from the color declarations. + public let fallbackColor: Color + + /// The fallback blur radius used when a shadow's radius cannot be resolved. + /// + /// This radius value is applied to shadows when the theming system cannot resolve + /// the specified radius token from the metric declarations. + public let fallbackRadius: Double + + /// The fallback horizontal offset used when a shadow's x-offset cannot be resolved. + /// + /// This x-offset value is applied to shadows when the theming system cannot resolve + /// the specified x-offset token from the metric declarations. + public let fallbackX: Double + + /// The fallback vertical offset used when a shadow's y-offset cannot be resolved. + /// + /// This y-offset value is applied to shadows when the theming system cannot resolve + /// the specified y-offset token from the metric declarations. + public let fallbackY: Double + + /// The fallback spread radius used when a shadow's spread cannot be resolved. + /// + /// This spread value is applied to shadows when the theming system cannot resolve + /// the specified spread token from the metric declarations. + public let fallbackSpread: Double + + // MARK: - Internal properties + + let metrics: SnappThemingMetricDeclarations + let colors: SnappThemingColorDeclarations + let colorFormat: SnappThemingColorFormat +} diff --git a/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowDeclarations.swift b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowDeclarations.swift new file mode 100644 index 000000000..b8d7c46a3 --- /dev/null +++ b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowDeclarations.swift @@ -0,0 +1,91 @@ +// +// SnappThemingShadowDeclarations.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import Foundation + +/// A type alias for shadow-specific theming declarations. +/// +/// This provides a convenient way to work with shadow theming declarations that use +/// ``SnappThemingShadowRepresentation`` as the declared value type and +/// ``SnappThemingShadowConfiguration`` as the configuration type. +public typealias SnappThemingShadowDeclarations = SnappThemingDeclarations< + SnappThemingShadowRepresentation, + SnappThemingShadowConfiguration +> + +extension SnappThemingShadowDeclarations +where + DeclaredValue == SnappThemingShadowRepresentation, + Configuration == SnappThemingShadowConfiguration +{ + public init( + cache: [String: SnappThemingToken]?, + configuration: SnappThemingParserConfiguration = .default, + metrics: SnappThemingMetricDeclarations, + colors: SnappThemingColorDeclarations + ) { + self.init( + cache: cache, + rootKey: .shadows, + configuration: Configuration( + fallbackColor: configuration.fallbackShadow.color, + fallbackRadius: configuration.fallbackShadow.radius, + fallbackX: configuration.fallbackShadow.x, + fallbackY: configuration.fallbackShadow.y, + fallbackSpread: configuration.fallbackShadow.spread, + metrics: metrics, + colors: colors, + colorFormat: configuration.colorFormat + ) + ) + } + + /// Dynamically resolves a shadow token using dot notation and returns a shadow resolver. + /// + /// This subscript enables accessing shadow tokens using dynamic member lookup syntax. + /// It attempts to resolve all shadow properties (color, radius, x, y, spread) and + /// returns a ``SnappThemingShadowResolver`` with either the resolved values or fallback values. + /// + /// ### Usage + /// ```swift + /// let cardShadow = shadowDeclarations.card.elevated + /// let shadowColor = cardShadow.color + /// let shadowRadius = cardShadow.radius + /// ``` + /// + /// - Parameter keyPath: The dot-separated path to the desired shadow token. + /// - Returns: A ``SnappThemingShadowResolver`` containing the resolved or fallback shadow properties. + /// + /// - Note: If any shadow property cannot be resolved, all properties fall back to the configured fallback values. + public subscript(dynamicMember keyPath: String) -> SnappThemingShadowResolver { + guard + let representation: DeclaredValue = self[dynamicMember: keyPath], + let resolvedColor = configuration.colors.resolver.resolve(representation.color)? + .color(using: configuration.colorFormat), + let resolvedRadius = configuration.metrics.resolver.resolve(representation.radius), + let resolvedX = configuration.metrics.resolver.resolve(representation.x), + let resolvedY = configuration.metrics.resolver.resolve(representation.y), + let resolvedSpread = configuration.metrics.resolver.resolve(representation.spread) + else { + return SnappThemingShadowResolver( + color: configuration.fallbackColor, + radius: configuration.fallbackRadius, + x: configuration.fallbackX, + y: configuration.fallbackY, + spread: configuration.fallbackSpread + ) + } + + return SnappThemingShadowResolver( + color: resolvedColor, + radius: resolvedRadius, + x: resolvedX, + y: resolvedY, + spread: resolvedSpread + ) + } +} diff --git a/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowRepresentation.swift b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowRepresentation.swift new file mode 100644 index 000000000..43ac76aa3 --- /dev/null +++ b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowRepresentation.swift @@ -0,0 +1,44 @@ +// +// SnappThemingShadowRepresentation.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import Foundation + +/// Representation of a shadow configuration using theming tokens. +/// +/// This struct defines a shadow using theming tokens that reference other design tokens +/// for colors and metrics, allowing for consistent and themeable shadow definitions. +public struct SnappThemingShadowRepresentation: Codable { + /// Color defines the color of the shadow as a solid, reduced opacity, or modified color value. + /// + /// This token references a color representation that will be resolved through the + /// color theming system to produce the final shadow color. + public let color: SnappThemingToken + + /// Blur(radius) defines the the softness of the shadow edges. + /// + /// Defines the softness of edges (higher = softer, more diffused). This token + /// references a metric value that determines how blurred the shadow appears. + public let radius: SnappThemingToken + + /// X defines the horizontal position of the shadow. + /// + /// This token references a metric value that determines the horizontal offset + /// of the shadow relative to the element casting it. + public let x: SnappThemingToken + + /// Y defines the vertical position of the shadow. + /// + /// This token references a metric value that determines the vertical offset + /// of the shadow relative to the element casting it. + public let y: SnappThemingToken + + /// Spread(radius) defines the size of the shadow. + /// + /// Defines how much the shadow grows or shrinks. This token references a metric + /// value that controls the shadow's expansion beyond the original element size. + public let spread: SnappThemingToken +} diff --git a/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowResolver.swift b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowResolver.swift new file mode 100644 index 000000000..50fb862ad --- /dev/null +++ b/Sources/SnappTheming/Theme/Shadow/SnappThemingShadowResolver.swift @@ -0,0 +1,79 @@ +// +// SnappThemingShadowResolver.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import SwiftUI + +/// A resolver that contains fully resolved shadow properties for UI rendering. +/// +/// This struct represents a shadow with all properties resolved to concrete values, +/// ready to be applied to SwiftUI views or other UI components. +/// +/// ### Usage +/// ```swift +/// +/// @Environment(Theme.self) private var theme +/// +/// // Apply to a SwiftUI view +/// Text("Hello") +/// .shadow( +/// color: theme.shadows.action.color, +/// radius: theme.shadows.action.radius, +/// x: theme.shadows.action.x, +/// y: theme.shadows.action.y +/// ) +/// ``` +public struct SnappThemingShadowResolver: Sendable { + /// The resolved color for the shadow. + public let color: Color + + /// The resolved blur radius for the shadow. + public let radius: Double + + /// The resolved horizontal offset for the shadow. + public let x: Double + + /// The resolved vertical offset for the shadow. + public let y: Double + + /// The resolved spread radius for the shadow. + public let spread: Double + + /// Creates a new shadow resolver with the specified properties. + /// + /// - Parameter color: The color of the shadow. + /// - Parameter radius: The blur radius of the shadow. + /// - Parameter x: The horizontal offset of the shadow. + /// - Parameter y: The vertical offset of the shadow. + /// - Parameter spread: The spread radius of the shadow. + public init( + color: Color, + radius: Double, + x: Double, + y: Double, + spread: Double + ) { + self.color = color + self.radius = radius + self.x = x + self.y = y + self.spread = spread + } + + /// A fallback shadow resolver used for debugging purposes. + /// + /// This shadow uses distinctive values (pink color and negative radius) to make it + /// easily identifiable when fallback values are being used in the theming system. + public static var fallback: Self { + SnappThemingShadowResolver( + color: .pink, + radius: -11, + x: 1, + y: 3, + spread: 0.25 + ) + } +} diff --git a/Sources/SnappTheming/Theme/Shape/SnappThemingShapeDeclarations.swift b/Sources/SnappTheming/Theme/Shape/SnappThemingShapeDeclarations.swift index 281a0c30b..4a8acb88c 100644 --- a/Sources/SnappTheming/Theme/Shape/SnappThemingShapeDeclarations.swift +++ b/Sources/SnappTheming/Theme/Shape/SnappThemingShapeDeclarations.swift @@ -38,7 +38,7 @@ where ) } - @ShapeBuilder + @InsettableShapeBuilder public subscript(dynamicMember keyPath: String) -> some Shape { let shapeRepresentation: SnappThemingShapeType = self[dynamicMember: keyPath] shapeRepresentation.shape diff --git a/Sources/SnappTheming/Theme/Shape/SnappThemingShapeType.swift b/Sources/SnappTheming/Theme/Shape/SnappThemingShapeType.swift index cfe161b7c..638a19a6e 100644 --- a/Sources/SnappTheming/Theme/Shape/SnappThemingShapeType.swift +++ b/Sources/SnappTheming/Theme/Shape/SnappThemingShapeType.swift @@ -35,7 +35,7 @@ public enum SnappThemingShapeType: Equatable, Sendable { case unevenRoundedRectangle(RectangleCornerRadii, RoundedCornerStyle) /// Generates the corresponding SwiftUI `Shape` representation based on the selected shape type. - @ShapeBuilder public var shape: some Shape { + @InsettableShapeBuilder public var shape: some InsettableShape { switch self { case .circle: Circle() diff --git a/Sources/SnappTheming/Theme/SnappThemingDeclaration.swift b/Sources/SnappTheming/Theme/SnappThemingDeclaration.swift index ccca0aca2..59f427d33 100644 --- a/Sources/SnappTheming/Theme/SnappThemingDeclaration.swift +++ b/Sources/SnappTheming/Theme/SnappThemingDeclaration.swift @@ -40,12 +40,15 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { public let fontInformation: [SnappThemingFontInformation] /// Animation declarations. public let animations: SnappThemingAnimationDeclarations + /// Shadows declarations. + public let shadows: SnappThemingShadowDeclarations public enum CodingKeys: String, CodingKey { case images, colors, metrics, fonts, typography, gradients case buttonStyles, interactiveColors, shapes case segmentControlStyle, sliderStyle, toggleStyle case animations + case shadows } /// Key used to pass parser configuration through `JSONDecoder` or `JSONEncoder` user info. @@ -70,6 +73,7 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { sliderStyleCache: [String: SnappThemingToken]? = nil, toggleStyleCache: [String: SnappThemingToken]? = nil, animationCache: [String: SnappThemingToken]? = nil, + shadowCache: [String: SnappThemingToken]? = nil, using parserConfiguration: SnappThemingParserConfiguration = .default ) { self.images = .init(cache: imageCache, configuration: parserConfiguration) @@ -140,6 +144,12 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { cache: animationCache, configuration: parserConfiguration ) + + self.shadows = .init( + cache: shadowCache, + metrics: metrics, + colors: colors + ) } public init(from decoder: any Decoder) throws { @@ -198,6 +208,10 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { [String: SnappThemingToken].self, forKey: .animations ) + let shadowCache = try container.decodeIfPresent( + [String: SnappThemingToken].self, + forKey: .shadows + ) self.init( imageCache: imageCache, @@ -213,6 +227,7 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { sliderStyleCache: sliderStyleCache, toggleStyleCache: toggleStyleCache, animationCache: animationCache, + shadowCache: shadowCache, using: parserConfiguration) } catch { error.log() @@ -264,6 +279,9 @@ public struct SnappThemingDeclaration: Codable, SnappThemingOutput { if !animations.cache.isEmpty { try container.encode(animations.cache, forKey: .animations) } + if !shadows.cache.isEmpty { + try container.encode(shadows.cache, forKey: .shadows) + } } } @@ -285,6 +303,8 @@ extension SnappThemingDeclaration { segmentControlStyleCache: segmentControlStyle.cache.override(other.segmentControlStyle.cache), sliderStyleCache: sliderStyle.cache.override(other.sliderStyle.cache), toggleStyleCache: toggleStyle.cache.override(other.toggleStyle.cache), + animationCache: animations.cache.override(other.animations.cache), + shadowCache: shadows.cache.override(other.shadows.cache), using: configuration) } } diff --git a/Sources/SnappTheming/Theme/SnappThemingImageProcessorsRegistry.swift b/Sources/SnappTheming/Theme/SnappThemingImageProcessorsRegistry.swift index fce220c28..f72167536 100644 --- a/Sources/SnappTheming/Theme/SnappThemingImageProcessorsRegistry.swift +++ b/Sources/SnappTheming/Theme/SnappThemingImageProcessorsRegistry.swift @@ -9,7 +9,30 @@ import Foundation /// A registry for managing image processors in the SnappTheming framework. /// -/// ### Usage +/// Use this registry to register custom image processors that handle specific image formats +/// or transformations within the theming system. The registry maintains a thread-safe collection +/// of processors that can be accessed throughout your application's lifetime. +/// +/// ## Overview +/// +/// The registry follows the singleton pattern and provides thread-safe access to registered +/// image processors. Register your processors early in your application lifecycle, typically +/// in your app's initialization phase. +/// +/// ## Topics +/// +/// ### Getting the Shared Instance +/// +/// - ``shared`` +/// +/// ### Managing Processors +/// +/// - ``register(_:)`` +/// - ``unregister(_:)`` +/// - ``registeredProcessors()`` +/// +/// ## Usage +/// /// ```swift /// @main /// struct ExampleApp: App { @@ -25,7 +48,6 @@ import Foundation /// .register(SnappThemingSVGSupportSVGProcessor()) /// /// self.configuration = AvailableTheme.night.configuration -/// /// self.json = themeJSON /// } /// @@ -37,36 +59,95 @@ import Foundation /// } /// ``` public final class SnappThemingImageProcessorsRegistry: @unchecked Sendable { - /// The default shared instance of the registry. - /// This singleton provides a thread-safe shared instance. - public static let shared: SnappThemingImageProcessorsRegistry = .init() + /// The shared instance of the registry. + /// + /// Use this instance to register and access image processors throughout your application. + /// The shared instance is thread-safe and can be accessed from any thread. + /// + /// ## Example + /// + /// ```swift + /// SnappThemingImageProcessorsRegistry.shared.register(MyCustomProcessor()) + /// ``` + public static let shared = SnappThemingImageProcessorsRegistry() - /// A private initializer to enforce the singleton pattern. private init() {} - /// An internal thread-safe array for storing external image processors. private var _externalImageProcessors: [any SnappThemingExternalImageProcessorProtocol] = [] - private let queue = DispatchQueue(label: "ImageProcessorsQueue") + private let queue = DispatchQueue(label: "ImageProcessorsQueue", attributes: .concurrent) /// Registers a new external image processor. /// - /// - Parameter processor: The image processor to register. - /// - Discussion: - /// This method is thread-safe. Registered processors are stored in a private array. + /// This method adds a processor to the registry's collection. Processors are typically + /// registered during application initialization and remain available throughout the + /// app's lifetime. + /// + /// - Parameter processor: The image processor conforming to + /// ``SnappThemingExternalImageProcessorProtocol`` to register. + /// + /// - Note: This method is thread-safe and can be called from any thread. However, + /// registering processors after your UI has been initialized may lead to inconsistent + /// behavior. It's recommended to register all processors during app startup. + /// + /// ## Example + /// + /// ```swift + /// let svgProcessor = SnappThemingSVGSupportSVGProcessor() + /// SnappThemingImageProcessorsRegistry.shared.register(svgProcessor) + /// ``` public func register(_ processor: any SnappThemingExternalImageProcessorProtocol) { - queue.sync { + queue.sync(flags: .barrier) { [weak self] in + guard let self else { return } _externalImageProcessors.append(processor) } } - /// Retrieves all registered external image processors. + /// Retrieves all currently registered external image processors. + /// + /// This method returns a snapshot of all processors registered at the time of the call. + /// The returned array is a copy, so modifications to it won't affect the registry. + /// + /// - Returns: An array of all registered image processors conforming to + /// ``SnappThemingExternalImageProcessorProtocol``. /// - /// - Returns: A copy of the current list of registered processors. - /// - Discussion: - /// Accessing the processors is thread-safe and returns a snapshot of the current state. + /// ## Example + /// + /// ```swift + /// let processors = SnappThemingImageProcessorsRegistry.shared.registeredProcessors() + /// print("Total processors registered: \(processors.count)") + /// ``` public func registeredProcessors() -> [any SnappThemingExternalImageProcessorProtocol] { - queue.sync { + queue.sync(flags: .barrier) { [weak self] in + guard let self else { return [] } return _externalImageProcessors } } + + /// Unregisters all image processors of a specific type. + /// + /// This method removes all processors from the registry that match the specified type. + /// + /// - Parameter processorType: The metatype of the processor to remove. Pass the type + /// followed by `.self` (e.g., `MyProcessor.self`). + /// + /// - Note: This method is thread-safe and can be called from any thread. If no processors + /// of the specified type are found in the registry, this method does nothing. + /// + /// ## Example + /// + /// ```swift + /// // Register an SVG processor + /// SnappThemingImageProcessorsRegistry.shared + /// .register(SnappThemingSVGSupportSVGProcessor()) + /// + /// // Later, remove all SVG processors by type: + /// SnappThemingImageProcessorsRegistry.shared + /// .unregister(SnappThemingSVGSupportSVGProcessor.self) + /// ``` + public func unregister(_ processorType: T.Type) where T: SnappThemingExternalImageProcessorProtocol { + queue.sync(flags: .barrier) { [weak self] in + guard let self else { return } + _externalImageProcessors.removeAll { type(of: $0) == processorType } + } + } } diff --git a/Sources/SnappTheming/Theme/SnappThemingParserConfiguration.swift b/Sources/SnappTheming/Theme/SnappThemingParserConfiguration.swift index 0f17adb60..7d4beddb1 100644 --- a/Sources/SnappTheming/Theme/SnappThemingParserConfiguration.swift +++ b/Sources/SnappTheming/Theme/SnappThemingParserConfiguration.swift @@ -75,6 +75,8 @@ public struct SnappThemingParserConfiguration: Sendable { public let fallbackCornerRadii: RectangleCornerRadii + public let fallbackShadow: SnappThemingShadowResolver + /// Initializes a new configuration with optional values. Defaults are provided for each property. public init( colorFormat: SnappThemingColorFormat = .rgba, @@ -95,7 +97,8 @@ public struct SnappThemingParserConfiguration: Sendable { fallbackGradientRadius: Double = 0, fallbackCornerRadius: Double = 0, fallbackRoundedCornerStyle: RoundedCornerStyle = .circular, - fallbackCornerRadii: RectangleCornerRadii = .init() + fallbackCornerRadii: RectangleCornerRadii = .init(), + fallbackShadow: SnappThemingShadowResolver = .fallback ) { self.colorFormat = colorFormat self.encodeFonts = encodeFonts @@ -116,6 +119,7 @@ public struct SnappThemingParserConfiguration: Sendable { self.fallbackCornerRadius = fallbackCornerRadius self.fallbackRoundedCornerStyle = fallbackRoundedCornerStyle self.fallbackCornerRadii = fallbackCornerRadii + self.fallbackShadow = fallbackShadow } } diff --git a/Sources/SnappTheming/Theme/Typography/SnappThemingTypographyRepresentation.swift b/Sources/SnappTheming/Theme/Typography/SnappThemingTypographyRepresentation.swift index b607940ae..6c4bad6ba 100644 --- a/Sources/SnappTheming/Theme/Typography/SnappThemingTypographyRepresentation.swift +++ b/Sources/SnappTheming/Theme/Typography/SnappThemingTypographyRepresentation.swift @@ -13,4 +13,17 @@ public struct SnappThemingTypographyRepresentation: Codable { public let font: SnappThemingToken /// A token that represents the font size. public let fontSize: SnappThemingToken + + /// Initializes a `SnappThemingTypographyRepresentation` with font and font size tokens. + /// + /// - Parameters: + /// - font: A `SnappThemingToken` representing the font. + /// - fontSize: A `SnappThemingToken` representing the font size. + public init( + font: SnappThemingToken, + fontSize: SnappThemingToken + ) { + self.font = font + self.fontSize = fontSize + } } diff --git a/Sources/SnappTheming/Theme/Util/SnappThemingDataURI.swift b/Sources/SnappTheming/Theme/Util/SnappThemingDataURI.swift index a2f62c08e..8a2c0886d 100644 --- a/Sources/SnappTheming/Theme/Util/SnappThemingDataURI.swift +++ b/Sources/SnappTheming/Theme/Util/SnappThemingDataURI.swift @@ -34,6 +34,18 @@ public struct SnappThemingDataURI: Sendable, Hashable { /// The raw data represented by the data URI. public let data: Data + + /// Initializes a `SnappThemingDataURI` with the specified type, encoding, and data. + /// + /// - Parameters: + /// - type: The `UTType` representing the data's type. + /// - encoding: The `Encoding` (e.g., `.base64`) used for the data. + /// - data: The raw `Data`. + public init(type: UTType, encoding: Encoding, data: Data) { + self.type = type + self.encoding = encoding + self.data = data + } } extension SnappThemingDataURI: Codable { diff --git a/Sources/SnappTheming/Theme/Util/SnappThemingUnitPointWrapper.swift b/Sources/SnappTheming/Theme/Util/SnappThemingUnitPointWrapper.swift index ce77da8c3..7c0c12fbb 100644 --- a/Sources/SnappTheming/Theme/Util/SnappThemingUnitPointWrapper.swift +++ b/Sources/SnappTheming/Theme/Util/SnappThemingUnitPointWrapper.swift @@ -17,6 +17,13 @@ public struct SnappThemingUnitPointWrapper { /// /// It can represent values like `.center`, `.topLeading`, etc., based on the theme configuration. public let value: UnitPoint + + /// Initializes a `SnappThemingUnitPointWrapper` with a specific `UnitPoint` value. + /// + /// - Parameter value: The `UnitPoint` to wrap. + public init(value: UnitPoint) { + self.value = value + } } extension SnappThemingUnitPointWrapper: Codable { diff --git a/Sources/SnappThemingSwiftUIHelpers/SnappThemingShadowModifier.swift b/Sources/SnappThemingSwiftUIHelpers/SnappThemingShadowModifier.swift new file mode 100644 index 000000000..01ba2e07c --- /dev/null +++ b/Sources/SnappThemingSwiftUIHelpers/SnappThemingShadowModifier.swift @@ -0,0 +1,76 @@ +// +// SnappThemingShadowModifier.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/11/25. +// + +import SnappTheming +import SwiftUI + +/// A SwiftUI view modifier that applies shadow effects using resolved shadow configuration. +/// +/// This modifier provides a convenient way to apply shadows to SwiftUI views using +/// the theming system's shadow resolver. +public struct SnappThemingShadowModifier: ViewModifier { + /// The resolved shadow configuration containing all shadow properties. + var shadowConfiguration: SnappThemingShadowResolver + + /// Applies the shadow configuration to the provided content view. + /// + /// - Parameter content: The view to which the shadow will be applied. + /// - Returns: The content view with the shadow effect applied. + /// + /// - Note: The spread property from the shadow configuration is not applied as SwiftUI's shadow modifier doesn't support spread radius. If you need spread functionality, consider creating a custom shadow implementation or using alternative approaches such as background views with scaling transforms. + public func body(content: Content) -> some View { + content + .shadow( + color: shadowConfiguration.color, + radius: shadowConfiguration.radius, + x: shadowConfiguration.x, + y: shadowConfiguration.y + ) + } +} + +/// Extends `View` to include a modifier for applying themed shadow effects. +extension View { + /// Applies a shadow effect to the view using a resolved shadow configuration. + /// + /// This convenience method allows you to apply themed shadows directly to any SwiftUI view + /// using the shadow resolver from the theming system. + /// + /// - Parameter configuration: The resolved shadow configuration to apply. + /// - Returns: The view with the shadow effect applied. + /// + /// Example usage: + /// ```swift + /// struct CardView: View { + /// var body: some View { + /// VStack { + /// Text("Card Content") + /// .padding() + /// } + /// .background(Color.white) + /// .cornerRadius(12) + /// .shadow(shadowDeclarations.card.elevated) // Apply themed shadow + /// } + /// } + /// + /// struct ButtonView: View { + /// var body: some View { + /// Button("Tap Me") { + /// // Action + /// } + /// .padding() + /// .background(Color.blue) + /// .foregroundColor(.white) + /// .cornerRadius(8) + /// .shadow(shadowDeclarations.button.primary) // Apply themed shadow + /// } + /// } + /// ``` + public func shadow(_ configuration: SnappThemingShadowResolver) -> some View { + modifier(SnappThemingShadowModifier(shadowConfiguration: configuration)) + } +} diff --git a/Tests/SnappThemingTests/Extensions/FileManager+Extensions.swift b/Tests/SnappThemingTests/Extensions/FileManager+Extensions.swift new file mode 100644 index 000000000..dc9f167c2 --- /dev/null +++ b/Tests/SnappThemingTests/Extensions/FileManager+Extensions.swift @@ -0,0 +1,28 @@ +// +// File.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/8/25. +// + +import Foundation + +extension FileManager { + static let withFileExistTrue: MockFileManager = { withFileExistTrue() }() + static func withFileExistTrue( + _ createDirectoryError: MockFileManager.FileManagerError? = nil, + cachedURLs: [URL] = [] + ) -> MockFileManager { + let fileManager = MockFileManager() + fileManager.fileExists = true + fileManager.urlsResult = cachedURLs + fileManager.createDirectoryError = createDirectoryError + return fileManager + } + + static let emptyCacheDirectory: MockFileManager = { + let fileManager = MockFileManager() + fileManager.urlsResult = [] + return fileManager + }() +} diff --git a/Tests/SnappThemingTests/Extensions/SnappThemingImage+system.swift b/Tests/SnappThemingTests/Extensions/SnappThemingImage+system.swift new file mode 100644 index 000000000..3463aa704 --- /dev/null +++ b/Tests/SnappThemingTests/Extensions/SnappThemingImage+system.swift @@ -0,0 +1,19 @@ +// +// File.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/8/25. +// + +import SnappTheming +import Foundation + +extension SnappThemingImage { + static func system(name: String) -> SnappThemingImage? { + #if canImport(UIKit) + SnappThemingImage(systemName: name) + #elseif canImport(AppKit) + SnappThemingImage(systemSymbolName: name, accessibilityDescription: "test") + #endif + } +} diff --git a/Tests/SnappThemingTests/GradientsTests.swift b/Tests/SnappThemingTests/GradientsTests.swift index 94d2b46c7..45ead73cc 100644 --- a/Tests/SnappThemingTests/GradientsTests.swift +++ b/Tests/SnappThemingTests/GradientsTests.swift @@ -18,13 +18,16 @@ struct GradientsTests { try compareEncoded(declaration, and: json) let horizontalLinearGradientRepresentation = try #require( - declaration.gradients.horizontalLinearGradient) + declaration.gradients.cache["horizontalLinearGradient"]?.value + ) let _: any ShapeStyle = declaration.gradients.horizontalLinearGradient let horizontalLinearGradientConfiguration = try #require( horizontalLinearGradientRepresentation.configuration - as? SnappThemingLinearGradientRepresentation) + as? SnappThemingLinearGradientRepresentation + ) let resolvedHorizontalConfiguration = horizontalLinearGradientConfiguration.resolve( - using: declaration.gradients.configuration) + using: declaration.gradients.configuration + ) #expect(resolvedHorizontalConfiguration.startPoint == .leading) #expect(resolvedHorizontalConfiguration.endPoint == .trailing) @@ -33,13 +36,16 @@ struct GradientsTests { #expect(resolvedHorizontalConfiguration.colors[1].toHex() == "#FF00FF") let verticalLinearGradientRepresentation = try #require( - declaration.gradients.verticalLinearGradient) + declaration.gradients.cache["verticalLinearGradient"]?.value + ) let _: any ShapeStyle = declaration.gradients.verticalLinearGradient let verticalLinearGradientConfiguration = try #require( verticalLinearGradientRepresentation.configuration - as? SnappThemingLinearGradientRepresentation) + as? SnappThemingLinearGradientRepresentation + ) let resolvedVerticalConfiguration = verticalLinearGradientConfiguration.resolve( - using: declaration.gradients.configuration) + using: declaration.gradients.configuration + ) #expect(resolvedVerticalConfiguration.startPoint == .top) #expect(resolvedVerticalConfiguration.endPoint == .bottom) @@ -48,13 +54,16 @@ struct GradientsTests { #expect(resolvedVerticalConfiguration.colors[1].toHex() == "#FF00AA") let diagonalLinearGradientRepresentation = try #require( - declaration.gradients.diagonalLinearGradient) + declaration.gradients.cache["diagonalLinearGradient"]?.value + ) let _: any ShapeStyle = declaration.gradients.diagonalLinearGradient let diagonalLinearGradientConfiguration = try #require( diagonalLinearGradientRepresentation.configuration - as? SnappThemingLinearGradientRepresentation) + as? SnappThemingLinearGradientRepresentation + ) let resolveDiagonalConfiguration = diagonalLinearGradientConfiguration.resolve( - using: declaration.gradients.configuration) + using: declaration.gradients.configuration + ) #expect(resolveDiagonalConfiguration.startPoint == .topLeading) #expect(resolveDiagonalConfiguration.endPoint == .bottomTrailing) @@ -68,12 +77,13 @@ struct GradientsTests { let declaration = try SnappThemingParser.parse(from: json) try compareEncoded(declaration, and: json) let fallbackConfiguration = declaration.gradients.configuration - let gradientRepresentation = try #require(declaration.gradients.horizontalLinearGradient) + let gradientRepresentation = try #require(declaration.gradients.cache["horizontalLinearGradient"]?.value) let _: any ShapeStyle = declaration.gradients.horizontalLinearGradient let configuration = try #require( gradientRepresentation.configuration - as? SnappThemingLinearGradientRepresentation) + as? SnappThemingLinearGradientRepresentation + ) let resolvedConfiguration = configuration.resolve(using: declaration.gradients.configuration) #expect(resolvedConfiguration.startPoint == fallbackConfiguration.fallbackUnitPoint) @@ -87,7 +97,7 @@ struct GradientsTests { typealias Radial = SnappThemingRadialGradientRepresentation let declaration = try SnappThemingParser.parse(from: json) try compareEncoded(declaration, and: json) - let gradientRepresentation = try #require(declaration.gradients.radialGradient) + let gradientRepresentation = try #require(declaration.gradients.cache["radialGradient"]?.value) let _: any ShapeStyle = declaration.gradients.radialGradient let configuration = try #require(gradientRepresentation.configuration as? Radial) let resolvedConfiguration = configuration.resolve(using: declaration.gradients.configuration) @@ -103,13 +113,15 @@ struct GradientsTests { func parseAngularGradient(json: String) throws { let declaration = try SnappThemingParser.parse(from: json) try compareEncoded(declaration, and: json) - let gradientRepresentation = try #require(declaration.gradients.angularGradient) + let gradientRepresentation = try #require(declaration.gradients.cache["angularGradient"]?.value) let _: any ShapeStyle = declaration.gradients.angularGradient let configuration = try #require( gradientRepresentation.configuration - as? SnappThemingAngularGradientRepresentation) + as? SnappThemingAngularGradientRepresentation + ) let resolvedConfiguration = configuration.resolve( - using: declaration.gradients.configuration) + using: declaration.gradients.configuration + ) #expect(resolvedConfiguration.center.x == 2.0) #expect(resolvedConfiguration.center.y == -0.2) @@ -128,9 +140,10 @@ struct GradientsTests { try compareEncoded(declaration, and: json) let _: any ShapeStyle = declaration.gradients.angularGradient let fallbackConfiguration = declaration.gradients.configuration - let gradientRepresentation = try #require(declaration.gradients.angularGradient) + let gradientRepresentation = try #require(declaration.gradients.cache["angularGradient"]?.value) let configuration = try #require( - gradientRepresentation.configuration as? SnappThemingAngularGradientRepresentation) + gradientRepresentation.configuration as? SnappThemingAngularGradientRepresentation + ) let resolvedConfiguration = configuration.resolve(using: declaration.gradients.configuration) #expect(resolvedConfiguration.center == .center) @@ -163,9 +176,10 @@ struct GradientsTests { let declaration = try SnappThemingParser.parse(from: json) let _: any ShapeStyle = declaration.gradients.angularGradient let fallbackConfiguration = declaration.gradients.configuration - let gradientRepresentation = try #require(declaration.gradients.notSupportedGradient) + let gradientRepresentation = try #require(declaration.gradients.cache["notSupportedGradient"]?.value) let configuration = try #require( - gradientRepresentation.configuration as? SnappThemingClearShapeStyleConfiguration) + gradientRepresentation.configuration as? SnappThemingClearShapeStyleConfiguration + ) #expect(configuration.shapeStyleUsing(fallbackConfiguration) == Color.clear) } diff --git a/Tests/SnappThemingTests/ImageTests.swift b/Tests/SnappThemingTests/ImageTests.swift index eac009f13..07ad0a6b1 100644 --- a/Tests/SnappThemingTests/ImageTests.swift +++ b/Tests/SnappThemingTests/ImageTests.swift @@ -113,10 +113,9 @@ struct ImageTests { let rawData: String = try #require( declaration.images.basket) - let imageData = try #require(try? SnappThemingDataURI(from: rawData)) + let _ = try #require(try? SnappThemingDataURI(from: rawData)) let image: Image = declaration.images.basket - #expect(imageData != nil) #expect(image == fallbackImage) } } diff --git a/Tests/SnappThemingTests/Mocks/MockExternalProcessor.swift b/Tests/SnappThemingTests/Mocks/MockExternalProcessor.swift new file mode 100644 index 000000000..3c37060b6 --- /dev/null +++ b/Tests/SnappThemingTests/Mocks/MockExternalProcessor.swift @@ -0,0 +1,16 @@ +// +// MockExternalProcessor.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/8/25. +// + +import Foundation +import SnappTheming +import UniformTypeIdentifiers + +final class MockExternalProcessor: SnappThemingExternalImageProcessorProtocol { + func process(_ object: SnappTheming.SnappThemingImageObject, of type: UTType) -> SnappThemingImage? { + .system(name: "pencil") + } +} diff --git a/Tests/SnappThemingTests/Mocks/MockFileManager.swift b/Tests/SnappThemingTests/Mocks/MockFileManager.swift new file mode 100644 index 000000000..d0e50bc2d --- /dev/null +++ b/Tests/SnappThemingTests/Mocks/MockFileManager.swift @@ -0,0 +1,30 @@ +// +// MockFileManager.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/8/25. +// + +import Foundation + +class MockFileManager: FileManager, @unchecked Sendable { + enum FileManagerError: Error { + case failedToCreateDirectory + } + var fileExists: Bool = false + override func fileExists(atPath path: String) -> Bool { + fileExists + } + + var urlsResult: [URL] = [] + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + urlsResult + } + + var createDirectoryError: Error? = nil + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + if let createDirectoryError { + throw createDirectoryError + } + } +} diff --git a/Tests/SnappThemingTests/Mocks/SnappThemingImageManagerMock.swift b/Tests/SnappThemingTests/Mocks/SnappThemingImageManagerMock.swift index d93a04da5..b3e88072e 100644 --- a/Tests/SnappThemingTests/Mocks/SnappThemingImageManagerMock.swift +++ b/Tests/SnappThemingTests/Mocks/SnappThemingImageManagerMock.swift @@ -26,15 +26,15 @@ final class SnappThemingImageManagerMock: SnappThemingImageManager { self.image = image } - func image(from data: Data, of type: UTType) -> SnappThemingImage? { - image(data, type) + func image(from object: SnappThemingImageObject, of type: UTType) -> SnappThemingImage? { + image(object.data, type) } func object(for key: String, of dataURI: SnappThemingDataURI) - -> Data? + -> SnappThemingImageObject? { accessQueue.sync { - cache.object(forKey: key as NSString) as? Data + SnappThemingImageObject(data: cache.object(forKey: key as NSString) as? Data, url: nil) } } diff --git a/Tests/SnappThemingTests/ShadowTests.swift b/Tests/SnappThemingTests/ShadowTests.swift new file mode 100644 index 000000000..f1fd08d9e --- /dev/null +++ b/Tests/SnappThemingTests/ShadowTests.swift @@ -0,0 +1,85 @@ +import SwiftUI +import Testing + +@testable import SnappTheming + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +@Suite +final class ShadowTests { + @Test func parseDeclaration() async throws { + let json = """ + { + "shadows": { + "thin": { + "color": "#FFF1FF", + "radius": 2, + "x": -1, + "y": 3, + "spread": 0.25, + }, + "thick": { + "color": "#000001", + "radius": 5, + "x": 2, + "y": -2, + "spread": -1.25, + }, + "primary": "$shadows/thin", + "secondary": "$shadows/thick" + } + } + """ + let configuration = SnappThemingParserConfiguration.default + let declaration = try SnappThemingParser.parse(from: json, using: configuration) + let fallbackConfiguration = declaration.shadows.configuration + + try compareEncoded(declaration, and: json) + + let thin: SnappThemingShadowResolver = declaration.shadows.thin + let thick: SnappThemingShadowResolver = declaration.shadows.thick + let primary: SnappThemingShadowResolver = declaration.shadows.primary + let secondary: SnappThemingShadowResolver = declaration.shadows.secondary + + let typo: SnappThemingShadowResolver = declaration.shadows.typo + + #expect(thin.radius == 2.0) + #expect(thick.radius == 5.0) + #expect(primary.radius == 2.0) + #expect(secondary.radius == 5.0) + + #expect(typo.radius == fallbackConfiguration.fallbackRadius) + + #expect(thin.x == -1.0) + #expect(thick.x == 2.0) + #expect(primary.x == -1.0) + #expect(secondary.x == 2.0) + + #expect(typo.x == fallbackConfiguration.fallbackX) + + #expect(thin.y == 3.0) + #expect(thick.y == -2.0) + #expect(primary.y == 3.0) + #expect(secondary.y == -2.0) + + #expect(typo.y == fallbackConfiguration.fallbackY) + + #expect(thin.spread == 0.25) + #expect(thick.spread == -1.25) + #expect(primary.spread == 0.25) + #expect(secondary.spread == -1.25) + + #expect(typo.spread == fallbackConfiguration.fallbackSpread) + + #expect(thin.color == Color(hex: "#FFF1FF")) + #expect(thick.color == Color(hex: "000001")) + #expect(primary.color == Color(hex: "FFF1FF")) + #expect(secondary.color == Color(hex: "000001")) + + #expect(typo.color == fallbackConfiguration.fallbackColor) + } +} diff --git a/Tests/SnappThemingTests/ShapeTests.swift b/Tests/SnappThemingTests/ShapeTests.swift index 51b49c5d2..096409851 100644 --- a/Tests/SnappThemingTests/ShapeTests.swift +++ b/Tests/SnappThemingTests/ShapeTests.swift @@ -170,9 +170,9 @@ struct ShapeTests { let declaration = try SnappThemingParser.parse(from: json) let fallbackConfiguration = declaration.shapes.configuration - let firstShapeRepresentation = try #require(declaration.shapes.roundedRectangle) - let secondShapeRepresentation = try #require(declaration.shapes.roundedRectangleAlt) - let thirdShapeRepresentation = try #require(declaration.shapes.roundedRectangleAltWithWrongAlias) + let firstShapeRepresentation = try #require(declaration.shapes.cache["roundedRectangle"]?.value) + let secondShapeRepresentation = try #require(declaration.shapes.cache["roundedRectangleAlt"]?.value) + let thirdShapeRepresentation = try #require(declaration.shapes.cache["roundedRectangleAltWithWrongAlias"]?.value) let resolvedShape1 = fallbackConfiguration.resolve(firstShapeRepresentation) let _ = resolvedShape1.shape @@ -325,9 +325,9 @@ struct ShapeTests { let declaration = try SnappThemingParser.parse(from: json) let fallbackConfiguration = declaration.shapes.configuration - let funkyRectShapeRepresentation = try #require(declaration.shapes.funkyRect) - let funkyRectAltShapeRepresentation = try #require(declaration.shapes.funkyRectAlt) - let funkyRectWithBrokenAliasesShapeRepresentation = try #require(declaration.shapes.funkyRectWithBrokenAliases) + let funkyRectShapeRepresentation = try #require(declaration.shapes.cache["funkyRect"]?.value) + let funkyRectAltShapeRepresentation = try #require(declaration.shapes.cache["funkyRectAlt"]?.value) + let funkyRectWithBrokenAliasesShapeRepresentation = try #require(declaration.shapes.cache["funkyRectWithBrokenAliases"]?.value) let resolvedShape1 = fallbackConfiguration.resolve(funkyRectShapeRepresentation) let _ = resolvedShape1.shape diff --git a/Tests/SnappThemingTests/SliderStyleTests.swift b/Tests/SnappThemingTests/SliderStyleTests.swift index 70ddcbfde..291a60c8a 100644 --- a/Tests/SnappThemingTests/SliderStyleTests.swift +++ b/Tests/SnappThemingTests/SliderStyleTests.swift @@ -82,7 +82,8 @@ struct SliderStyleTests { #expect( minimumTrackTintColor != sliderStyleFallbackConfiguration - .fallbackMinimumTrackTintColor) + .fallbackMinimumTrackTintColor + ) #expect(minimumTrackTintColor == Color(hex: "#ABCD12")) let minimumTrackTintColorSecondary: Color = sliderStyle @@ -90,26 +91,28 @@ struct SliderStyleTests { #expect( minimumTrackTintColorSecondary != sliderStyleFallbackConfiguration - .fallbackMinimumTrackTintColor) + .fallbackMinimumTrackTintColor + ) #expect(minimumTrackTintColorSecondary == Color(hex: "#616161")) let maximumTrackTintColor: Color = sliderStyle.maximumTrackTintColor #expect( maximumTrackTintColor != sliderStyleFallbackConfiguration - .fallbackMaximumTrackTintColor) + .fallbackMaximumTrackTintColor + ) #expect(maximumTrackTintColor == Color(hex: "#FFFFFF0F")) let tickMarkColor: Color = sliderStyle.tickMarkColor #expect( tickMarkColor - != sliderStyleFallbackConfiguration.fallbackTickMarkColor) + != sliderStyleFallbackConfiguration.fallbackTickMarkColor + ) #expect(tickMarkColor == Color(hex: "#FFFFFF80")) - // Header Typography - let headerTypography = sliderStyle.headerTypography - #expect(headerTypography.font != nil) #if canImport(UIKit) + // Header Typography + let headerTypography = sliderStyle.headerTypography #expect(headerTypography.uiFont != nil) #expect( headerTypography.uiFont.pointSize @@ -118,14 +121,10 @@ struct SliderStyleTests { ) #expect( headerTypography.uiFont.pointSize == 34.0, - "Header typography font size should be 34.") - #endif - - // Tick Mark Typography - let tickMarkTypography = sliderStyle.tickMarkTypography - #expect(tickMarkTypography.font != nil) - - #if canImport(UIKit) + "Header typography font size should be 34." + ) + // Tick Mark Typography + let tickMarkTypography = sliderStyle.tickMarkTypography #expect(tickMarkTypography.uiFont != nil) #expect( tickMarkTypography.uiFont.pointSize @@ -134,7 +133,8 @@ struct SliderStyleTests { ) #expect( tickMarkTypography.uiFont.pointSize == 22.0, - "Header typography font size should be 22.") + "Header typography font size should be 22." + ) #endif } @@ -178,7 +178,9 @@ struct SliderStyleTests { let configuration = SnappThemingParserConfiguration.default let declaration = try SnappThemingParser.parse( - from: json, using: configuration) + from: json, + using: configuration + ) let _ = try #require(declaration.sliderStyle.cache["primary"]?.value) let sliderStyle: SnappThemingSliderStyleResolver = declaration .sliderStyle.primary @@ -191,28 +193,29 @@ struct SliderStyleTests { #expect( minimumTrackTintColor == sliderStyleFallbackConfiguration - .fallbackMinimumTrackTintColor) + .fallbackMinimumTrackTintColor + ) let minimumTrackTintColorSecondary: Color = sliderStyle .minimumTrackTintColorSecondary #expect( minimumTrackTintColorSecondary == sliderStyleFallbackConfiguration - .fallbackMinimumTrackTintColor) + .fallbackMinimumTrackTintColor + ) let maximumTrackTintColor: Color = sliderStyle.maximumTrackTintColor #expect( maximumTrackTintColor == sliderStyleFallbackConfiguration - .fallbackMaximumTrackTintColor) + .fallbackMaximumTrackTintColor + ) let tickMarkColor: Color = sliderStyle.tickMarkColor #expect( tickMarkColor - == sliderStyleFallbackConfiguration.fallbackTickMarkColor) - - // Header Typography - let headerTypography = sliderStyle.headerTypography - #expect(headerTypography.font != nil) - + == sliderStyleFallbackConfiguration.fallbackTickMarkColor + ) #if canImport(UIKit) + // Header Typography + let headerTypography = sliderStyle.headerTypography #expect(headerTypography.uiFont != nil) #expect( headerTypography.uiFont.pointSize @@ -221,14 +224,10 @@ struct SliderStyleTests { ) #expect( headerTypography.uiFont.pointSize == 0.0, - "Header typography font size should fall to 0.") - #endif - - // Tick Mark Typography - let tickMarkTypography = sliderStyle.tickMarkTypography - #expect(tickMarkTypography.font != nil) - - #if canImport(UIKit) + "Header typography font size should fall to 0." + ) + // Tick Mark Typography + let tickMarkTypography = sliderStyle.tickMarkTypography #expect(tickMarkTypography.uiFont != nil) #expect( tickMarkTypography.uiFont.pointSize @@ -237,7 +236,8 @@ struct SliderStyleTests { ) #expect( tickMarkTypography.uiFont.pointSize == 0.0, - "Tick Mark typography font size should fall to 0.") + "Tick Mark typography font size should fall to 0." + ) #endif } @@ -255,21 +255,15 @@ struct SliderStyleTests { let tickMarkColor: Color = sliderStyle.tickMarkColor #expect(tickMarkColor == .clear) - let headerTypography = sliderStyle.headerTypography - #expect(headerTypography.font != nil) - #if canImport(UIKit) + let headerTypography = sliderStyle.headerTypography #expect(headerTypography.uiFont != nil) #expect( headerTypography.uiFont.pointSize == 32, "Empty slider style should fall back to the default typography font size of 32." ) - #endif - - // Tick Mark Typography - let tickMarkTypography = sliderStyle.tickMarkTypography - - #if canImport(UIKit) + // Tick Mark Typography + let tickMarkTypography = sliderStyle.tickMarkTypography #expect(tickMarkTypography.font != nil) #expect(tickMarkTypography.uiFont != nil) #expect( diff --git a/Tests/SnappThemingTests/SnappThemingDeclarationTests.swift b/Tests/SnappThemingTests/SnappThemingDeclarationTests.swift index 29f2dd1f0..c922469fe 100644 --- a/Tests/SnappThemingTests/SnappThemingDeclarationTests.swift +++ b/Tests/SnappThemingTests/SnappThemingDeclarationTests.swift @@ -102,22 +102,3 @@ final class SnappThemingDeclarationTests { #endif } } - -func compareEncoded(_ declaration: SnappThemingDeclaration, and json: String) throws { - let jsonData = Data(json.utf8) - let declarationData = try JSONEncoder().encode(declaration) - - // Normalize both JSONs - let normalizedJSON1 = try normalizeJSON(jsonData) - let normalizedJSON2 = try normalizeJSON(declarationData) - - // Compare normalized strings - #expect(normalizedJSON1 == normalizedJSON2) -} - -/// Converts JSON `Data` into a normalized, sorted string representation -func normalizeJSON(_ data: Data) throws -> String { - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - let normalizedData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.sortedKeys]) - return String(data: normalizedData, encoding: .utf8) ?? "" -} diff --git a/Tests/SnappThemingTests/SnappThemingImageManagerTests.swift b/Tests/SnappThemingTests/SnappThemingImageManagerTests.swift new file mode 100644 index 000000000..0f58c05da --- /dev/null +++ b/Tests/SnappThemingTests/SnappThemingImageManagerTests.swift @@ -0,0 +1,174 @@ +// +// SnappThemingImageManagerTests.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 11/7/25. +// + +import Foundation +import Testing + +@testable import SnappTheming + +@Suite +struct SnappThemingImageManagerTests { + // Successes + @Test func testPreparingImage_PNG() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue, + themeCacheRootURL: URL.cachesDirectory + ) + let fakeKey = "fake-data" + let dataURI = try SnappThemingDataURI(from: encodedImage) + + manager.setObject(dataURI.data, for: fakeKey) + manager.store(dataURI, for: fakeKey) + + let object = try #require(manager.object(for: fakeKey, of: dataURI)) + #expect(object.url == nil) + #expect(object.data == dataURI.data) + let image = try #require(manager.image(from: object, of: dataURI.type)) + #expect(image.size == SnappThemingImage(data: dataURI.data)?.size) + } + + @Test func testPreparingImage_PDF() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue, + themeCacheRootURL: URL.cachesDirectory + ) + let fakeKey = "fake-data" + let dataURI = try SnappThemingDataURI(from: encodedPDF) + + manager.setObject(dataURI.data, for: fakeKey) + manager.store(dataURI, for: fakeKey) + + let object = try #require(manager.object(for: fakeKey, of: dataURI)) + #expect(object.url == nil) + #expect(object.data == dataURI.data) + let _ = try #require(manager.image(from: object, of: dataURI.type)) + } + + @Test func testPreparingImage_External() throws { + let mock = MockExternalProcessor() + let mockImage = SnappThemingImage.system(name: "pencil") + + SnappThemingImageProcessorsRegistry.shared.register(mock) + #expect(SnappThemingImageProcessorsRegistry.shared.registeredProcessors().count == 1) + + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue, + themeCacheRootURL: URL.cachesDirectory + ) + let fakeKey = "fake-data" + let dataURI = try SnappThemingDataURI(from: encodedSVG) + + manager.setObject(dataURI.data, for: fakeKey) + manager.store(dataURI, for: fakeKey) + + let object = try #require(manager.object(for: fakeKey, of: dataURI)) + #expect(object.url == nil) + #expect(object.data == dataURI.data) + let image = try #require(manager.image(from: object, of: dataURI.type)) + #expect(image.size == mockImage?.size) + + SnappThemingImageProcessorsRegistry.shared.unregister(MockExternalProcessor.self) + #expect(SnappThemingImageProcessorsRegistry.shared.registeredProcessors().count == 0) + + #expect(manager.image(from: object, of: .svg) == nil) + } + + // Failures + @Test func testPreparingImageFromBadData() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue, + themeCacheRootURL: URL.cachesDirectory + ) + let fakeKey = "fake-data" + let dataURI = SnappThemingDataURI( + type: .png, + encoding: .base64, + data: try #require(encodedImage.data(using: .utf8)) + ) + + manager.setObject(dataURI.data, for: fakeKey) + manager.store(dataURI, for: fakeKey) + + let object = try #require(manager.object(for: fakeKey, of: dataURI)) + #expect(object.url == nil) + #expect(object.data == dataURI.data) + + #expect(manager.image(from: object, of: dataURI.type) == nil) + #expect(manager.image(from: object, of: .pdf) == nil) + #expect(manager.image(from: object, of: .jpeg) == nil) + } + + @Test + func testPreparingImageObject_Failing() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue, + themeCacheRootURL: URL(string: "dummy-url") + ) + let dataURI = SnappThemingDataURI( + type: .png, + encoding: .base64, + data: Data() + ) + + let object = manager.object(for: "dummy", of: dataURI) + #expect(object == nil) + } + + @Test + func testPreparingImageObject_noCacheURLProvided_Failing() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue + ) + let dataURI = SnappThemingDataURI( + type: .png, + encoding: .base64, + data: Data() + ) + + let object = manager.object(for: "dummy", of: dataURI) + #expect(object == nil) + } + + @Test + func testPreparingImageObject_noCacheURLProvidedAndStored_Failing() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .emptyCacheDirectory + ) + let dataURI = SnappThemingDataURI( + type: .png, + encoding: .base64, + data: Data() + ) + + let object = manager.object(for: "dummy", of: dataURI) + #expect(object == nil) + } + + @Test + func testPreparingImageObject_failedToCrerateDirectory_Failing() throws { + let manager: SnappThemingImageManager = SnappThemingImageManagerDefault( + .withFileExistTrue(.failedToCreateDirectory, cachedURLs: [URL(string: "dummy-url")!]) + ) + let dataURI = SnappThemingDataURI( + type: .png, + encoding: .base64, + data: Data() + ) + + let object = manager.object(for: "dummy", of: dataURI) + #expect(object == nil) + } +} + +private let encodedImage = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAOwAAADsAEnxA+tAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA1JJREFUeJzt3T1rFFEUgOHXGAsVRIhKesEkYkzjf/CjT36KtbWVv8NCJWirVYoQECSdYGUhqBFJ0rmoRbbwY244szOZ3dzzPnBZOOLsjPfdDyx2QJIkSZIkSVL9znT8+1eA1fGjhvcF2AX2hn7iFWATGAG/XFNdI+AFsHTsjvXoAXB4ghfkmmwdAPeP2bderODmz/I6AJaLu9eDzRm4SNfx60Vx9xq0+RJ4FfgEnG3zBBrcCFgk+MVwvsWBV2ne/J/Au/HjabEMXGyYv+XoVXQazAFr48c/zQO3gTd9P+E6zW85230/0QB2aL6WNi+IWbBN83WsRw/wbz3HKX1cjFocY1Z0/f+PWVH6tw9fX5sAalJLAJ0ZQGxeLQOIzatlALF5tQwgNq+WAcTm1TKA2LxaWQMoMYAkfAcYM4DYvFoGkJwBxObVMoDYvFoGEJtXywBi82plDaDEAJLwHWDMAGLzahlAbF4tA4jNq2UAyRlAbF4tA4jNq5U1gBIDSMJ3gDEDiM2rZQCxebUMIDavlgHE5tUygOQMIDavVtYASgwgCd8BxgwgNq+WAcTm1TKA2LxaBhCbV8sAYvNqZQ2gxACSSLfRJQYQm1erj59GXQKe9nCcIV0ozJ8A+0OeSEedbxLRRwALtPht2hl3d9onMLSsHwEaM4DkDCA5A0iujy+BH4DHPRxnSE+A8w3zRxzdFue0eAhcH+rJNmi+O8XWUCfQo+80X8vNaZ7UBLZovo6N6AH8CEjOAJIzgOQMIDkDSM4AkjOA5AwgOQNIzgCSM4DkDCA5A0jOAJIzgOQMIDkDSM4AkjOA5AwgOQNIzgCSM4DkDCA5A0jOAJIzgOQMIDkDSM4AkjOA5AwgOQNIzgCSM4DkDCA5A0jOAJIzgOQMIDkDSC5rAIct59VqE8CvwryP3xse2uuG2Xvg49An0tG5wry0V/9ps3l7hfkd4C3ws8Wxpu1yw+wSsDP0iXQwB6wV/uxr9CBtbpJ0haNf0j6Nr/hMRsAi5RfsX9p8BHwFXk1yRhrUS4KbP4ll4IDmnyh3TX/tAzeKu9eTexjBLK59Brzr2RLwHPhxghfkiq0fwDMmfOV3vVPmAnALuNbxOJrMZ2AX+DbtE5EkSZIkSZI0y34Dp9katvKJ0poAAAAASUVORK5CYII=" + +private let encodedPDF = + "data:application/pdf;base64,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H4cOinDa3VJgoPvAgrEMDj1FdE6RAaEXdvSd/uJLhjLX5IVGKBseIGSjnFui7ggEIlk85HAp2Wje6+gNH0xEwC6KV8QAwgphunos9qoAQQECpT09wTcM6eJKZEGfNkM2O3kZxdaNAitEiQGKEOqPa2S7L1+RNL8n+8GQgW5HCbw3b1dA6PZaFB0RvTZUK5Ezpldqjl9klUFNTh5XBa1xFDkdPvR6yXgPKB7MctFOyoKPLcRgELLutde06bVxez+yZzow06yFu16jNKrrUpxsIISUzORdxnKvXvG9mFmT3bxN2YU3IOqFc5qd22EViL70on8dyCM3O61BFjFcUUariN2GP+/amBp6idR4WS8IhniDWpE8qUCtVlu0pOUtvCXoFdAMh61lC54g1gOpqCT1kHsLCAKu0erC2pQX3IsWMIH3g9mjEbrw/n5ZmRyMk6eNpQXEA+w8Lnhh7SB7IF5kufyhmczFZAMyAxul4FinsRSAePkXrzMG7Ak2ZEP3T4TX8V//cohmvFvENmR4+eWwx57GQ/AF7OLue0IQlpV/Qi2061LX9saSTIsVSspvX5Rz33pCPghi5+ePsEuuqm+Q2O97j69P6EvITxAn79Q4vd3x9X3zCfsJNQH/Hj+Pv4/fbC3gqwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCiAgMTIwOQplbmRvYmoKCjcgMCBvYmoKICA8PCAvQW5ub3RzIFtdCiAgICAgL1R5cGUgL1BhZ2UKICAgICAvTWVkaWFCb3ggWyAwLjAwMDAwMCAwLjAwMDAwMCAyNC4wMDAwMDAgMjQuMDAwMDAwIF0KICAgICAvUmVzb3VyY2VzIDQgMCBSCiAgICAgL0NvbnRlbnRzIDUgMCBSCiAgICAgL1BhcmVudCA4IDAgUgogID4+CmVuZG9iagoKOCAwIG9iagogIDw8IC9LaWRzIFsgNyAwIFIgXQogICAgIC9Db3VudCAxCiAgICAgL1R5cGUgL1BhZ2VzCiAgPj4KZW5kb2JqCgo5IDAgb2JqCiAgPDwgL1BhZ2VzIDggMCBSCiAgICAgL1R5cGUgL0NhdGFsb2cKICA+PgplbmRvYmoKCnhyZWYKMCAxMAowMDAwMDAwMDAwIDY1NTM1IGYKMDAwMDAwMDAxMCAwMDAwMCBuCjAwMDAwMDI2OTcgMDAwMDAgbgowMDAwMDAyNzIwIDAwMDAwIG4KMDAwMDAwMjc1OCAwMDAwMCBuCjAwMDAwMDI4MTAgMDAwMDAgbgowMDAwMDA0MTAzIDAwMDAwIG4KMDAwMDAwNDEyNiAwMDAwMCBuCjAwMDAwMDQyOTkgMDAwMDAgbgowMDAwMDA0MzczIDAwMDAwIG4KdHJhaWxlcgo8PCAvSUQgWyAoc29tZSkgKGlkKSBdCiAgIC9Sb290IDkgMCBSCiAgIC9TaXplIDEwCj4+CnN0YXJ0eHJlZgo0NDMyCiUlRU9G" + +let encodedSVG = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjUiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNSAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMuOTY0MzYgOC4yNzEwMkg3LjA1ODExQzcuMjIzMzMgOC45MTYzMyA3LjU5ODYzIDkuNDg4MyA4LjEyNDg0IDkuODk2NzVDOC42NTEwNCAxMC4zMDUyIDkuMjk4MjMgMTAuNTI2OSA5Ljk2NDM2IDEwLjUyNjlDMTAuNjMwNSAxMC41MjY5IDExLjI3NzcgMTAuMzA1MiAxMS44MDM5IDkuODk2NzVDMTIuMzMwMSA5LjQ4ODMgMTIuNzA1NCA4LjkxNjMzIDEyLjg3MDYgOC4yNzEwMkgyMC40NjQ0QzIwLjY2MzMgOC4yNzEwMiAyMC44NTQgOC4xOTIgMjAuOTk0NyA4LjA1MTM1QzIxLjEzNTMgNy45MTA3IDIxLjIxNDQgNy43MTk5MyAyMS4yMTQ0IDcuNTIxMDJDMjEuMjE0NCA3LjMyMjExIDIxLjEzNTMgNy4xMzEzNCAyMC45OTQ3IDYuOTkwNjlDMjAuODU0IDYuODUwMDQgMjAuNjYzMyA2Ljc3MTAyIDIwLjQ2NDQgNi43NzEwMkgxMi44NzA2QzEyLjcwNTQgNi4xMjU3MSAxMi4zMzAxIDUuNTUzNzQgMTEuODAzOSA1LjE0NTI5QzExLjI3NzcgNC43MzY4NCAxMC42MzA1IDQuNTE1MTQgOS45NjQzNiA0LjUxNTE0QzkuMjk4MjMgNC41MTUxNCA4LjY1MTA0IDQuNzM2ODQgOC4xMjQ4NCA1LjE0NTI5QzcuNTk4NjMgNS41NTM3NCA3LjIyMzMzIDYuMTI1NzEgNy4wNTgxMSA2Ljc3MTAySDMuOTY0MzZDMy43NjU0NCA2Ljc3MTAyIDMuNTc0NjggNi44NTAwNCAzLjQzNDAzIDYuOTkwNjlDMy4yOTMzNyA3LjEzMTM0IDMuMjE0MzYgNy4zMjIxMSAzLjIxNDM2IDcuNTIxMDJDMy4yMTQzNiA3LjcxOTkzIDMuMjkzMzcgNy45MTA3IDMuNDM0MDMgOC4wNTEzNUMzLjU3NDY4IDguMTkyIDMuNzY1NDQgOC4yNzEwMiAzLjk2NDM2IDguMjcxMDJaTTkuOTY0MzYgNi4wMjEwMkMxMC4yNjEgNi4wMjEwMiAxMC41NTEgNi4xMDg5OSAxMC43OTc3IDYuMjczODJDMTEuMDQ0NCA2LjQzODY0IDExLjIzNjYgNi42NzI5MSAxMS4zNTAyIDYuOTQ2OTlDMTEuNDYzNyA3LjIyMTA4IDExLjQ5MzQgNy41MjI2OCAxMS40MzU1IDcuODEzNjVDMTEuMzc3NyA4LjEwNDYzIDExLjIzNDggOC4zNzE5IDExLjAyNSA4LjU4MTY4QzEwLjgxNTIgOC43OTE0NiAxMC41NDggOC45MzQzMiAxMC4yNTcgOC45OTIyQzkuOTY2MDIgOS4wNTAwOCA5LjY2NDQyIDkuMDIwMzcgOS4zOTAzMyA4LjkwNjg0QzkuMTE2MjQgOC43OTMzMSA4Ljg4MTk3IDguNjAxMDUgOC43MTcxNSA4LjM1NDM3QzguNTUyMzMgOC4xMDc3IDguNDY0MzYgNy44MTc2OSA4LjQ2NDM2IDcuNTIxMDJDOC40NjQzNiA3LjEyMzE5IDguNjIyMzkgNi43NDE2NiA4LjkwMzcgNi40NjAzNkM5LjE4NSA2LjE3OTA1IDkuNTY2NTMgNi4wMjEwMiA5Ljk2NDM2IDYuMDIxMDJaTTIwLjQ2NDQgMTUuNzcxSDE4Ljg3MDZDMTguNzA1NCAxNS4xMjU3IDE4LjMzMDEgMTQuNTUzNyAxNy44MDM5IDE0LjE0NTNDMTcuMjc3NyAxMy43MzY4IDE2LjYzMDUgMTMuNTE1MSAxNS45NjQ0IDEzLjUxNTFDMTUuMjk4MiAxMy41MTUxIDE0LjY1MSAxMy43MzY4IDE0LjEyNDggMTQuMTQ1M0MxMy41OTg2IDE0LjU1MzcgMTMuMjIzMyAxNS4xMjU3IDEzLjA1ODEgMTUuNzcxSDMuOTY0MzZDMy43NjU0NCAxNS43NzEgMy41NzQ2OCAxNS44NSAzLjQzNDAzIDE1Ljk5MDdDMy4yOTMzNyAxNi4xMzEzIDMuMjE0MzYgMTYuMzIyMSAzLjIxNDM2IDE2LjUyMUMzLjIxNDM2IDE2LjcxOTkgMy4yOTMzNyAxNi45MTA3IDMuNDM0MDMgMTcuMDUxM0MzLjU3NDY4IDE3LjE5MiAzLjc2NTQ0IDE3LjI3MSAzLjk2NDM2IDE3LjI3MUgxMy4wNTgxQzEzLjIyMzMgMTcuOTE2MyAxMy41OTg2IDE4LjQ4ODMgMTQuMTI0OCAxOC44OTY3QzE0LjY1MSAxOS4zMDUyIDE1LjI5ODIgMTkuNTI2OSAxNS45NjQ0IDE5LjUyNjlDMTYuNjMwNSAxOS41MjY5IDE3LjI3NzcgMTkuMzA1MiAxNy44MDM5IDE4Ljg5NjdDMTguMzMwMSAxOC40ODgzIDE4LjcwNTQgMTcuOTE2MyAxOC44NzA2IDE3LjI3MUgyMC40NjQ0QzIwLjY2MzMgMTcuMjcxIDIwLjg1NCAxNy4xOTIgMjAuOTk0NyAxNy4wNTEzQzIxLjEzNTMgMTYuOTEwNyAyMS4yMTQ0IDE2LjcxOTkgMjEuMjE0NCAxNi41MjFDMjEuMjE0NCAxNi4zMjIxIDIxLjEzNTMgMTYuMTMxMyAyMC45OTQ3IDE1Ljk5MDdDMjAuODU0IDE1Ljg1IDIwLjY2MzMgMTUuNzcxIDIwLjQ2NDQgMTUuNzcxWk0xNS45NjQ0IDE4LjAyMUMxNS42Njc3IDE4LjAyMSAxNS4zNzc3IDE3LjkzMyAxNS4xMzEgMTcuNzY4MkMxNC44ODQzIDE3LjYwMzQgMTQuNjkyMSAxNy4zNjkxIDE0LjU3ODUgMTcuMDk1QzE0LjQ2NSAxNi44MjEgMTQuNDM1MyAxNi41MTk0IDE0LjQ5MzIgMTYuMjI4NEMxNC41NTExIDE1LjkzNzQgMTQuNjkzOSAxNS42NzAxIDE0LjkwMzcgMTUuNDYwNEMxNS4xMTM1IDE1LjI1MDYgMTUuMzgwNyAxNS4xMDc3IDE1LjY3MTcgMTUuMDQ5OEMxNS45NjI3IDE0Ljk5MiAxNi4yNjQzIDE1LjAyMTcgMTYuNTM4NCAxNS4xMzUyQzE2LjgxMjUgMTUuMjQ4NyAxNy4wNDY3IDE1LjQ0MSAxNy4yMTE2IDE1LjY4NzdDMTcuMzc2NCAxNS45MzQzIDE3LjQ2NDQgMTYuMjI0MyAxNy40NjQ0IDE2LjUyMUMxNy40NjQ0IDE2LjkxODggMTcuMzA2MyAxNy4zMDA0IDE3LjAyNSAxNy41ODE3QzE2Ljc0MzcgMTcuODYzIDE2LjM2MjIgMTguMDIxIDE1Ljk2NDQgMTguMDIxWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" diff --git a/Tests/SnappThemingTests/Utils/EncodedHelpers.swift b/Tests/SnappThemingTests/Utils/EncodedHelpers.swift new file mode 100644 index 000000000..daf4bf61c --- /dev/null +++ b/Tests/SnappThemingTests/Utils/EncodedHelpers.swift @@ -0,0 +1,29 @@ +// +// EncodedHelpers.swift +// SnappTheming +// +// Created by Oleksii Kolomiiets on 9/10/25. +// + +import Foundation +import SnappTheming +import Testing + +func compareEncoded(_ declaration: SnappThemingDeclaration, and json: String) throws { + let jsonData = Data(json.utf8) + let declarationData = try JSONEncoder().encode(declaration) + + // Normalize both JSONs + let normalizedJSON1 = try normalizeJSON(jsonData) + let normalizedJSON2 = try normalizeJSON(declarationData) + + // Compare normalized strings + #expect(normalizedJSON1 == normalizedJSON2) +} + +/// Converts JSON `Data` into a normalized, sorted string representation +func normalizeJSON(_ data: Data) throws -> String { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + let normalizedData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.sortedKeys]) + return String(data: normalizedData, encoding: .utf8) ?? "" +}