diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8d1a466bb..9469db7a2 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -42,3 +42,14 @@ jobs: uses: schmiedmayerlab/action-swiftlint@v4 with: args: --strict + + documentation: + name: Documentation + runs-on: macOS-26 + steps: + - uses: actions/checkout@v4 + - name: Build DocC Documentation + run: Scripts/build-documentation.sh + - name: Clean generated artifacts + if: always() + run: bash Scripts/cleanup-generated-artifacts.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c73f63436..ff6e54d02 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,8 +82,11 @@ jobs: needs: detect if: needs.detect.outputs.has_jobs == 'true' # Runner per job: Linux -> GitHub-hosted ubuntu; otherwise self-hosted iff packages.toml's - # self-hosted-ci routed unit tests there (matrix.selfHosted), else GitHub-hosted macOS-26. - runs-on: ${{ matrix.platform == 'Linux' && 'ubuntu-latest' || (matrix.selfHosted && fromJson('["self-hosted", "macOS"]') || fromJson('["macOS-26"]')) }} + # self-hosted-ci routed unit tests there (matrix.selfHosted), else GitHub-hosted macOS-26. The + # self-hosted label set (matrix.selfHostedLabels) is the base ["self-hosted","macOS"] plus any + # per-package `extra_runner_labels` from packages.toml (e.g. "python3.11+"), computed by + # Scripts/affected-test-matrix.py. + runs-on: ${{ matrix.platform == 'Linux' && 'ubuntu-latest' || (matrix.selfHosted && fromJson(matrix.selfHostedLabels) || fromJson('["macOS-26"]')) }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect.outputs.matrix) }} @@ -107,7 +110,7 @@ jobs: if: needs.detect.outputs.has_ui_jobs == 'true' # UI tests run on the self-hosted runner by default (packages.toml self-hosted-ci defaults to # ["ui"]); a package can move its UI tests to GitHub-hosted by dropping "ui" from that list. - runs-on: ${{ matrix.selfHosted && fromJson('["self-hosted", "macOS"]') || fromJson('["macOS-26"]') }} + runs-on: ${{ matrix.selfHosted && fromJson(matrix.selfHostedLabels) || fromJson('["macOS-26"]') }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect.outputs.ui_matrix) }} diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 000000000..88c1d8d90 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,79 @@ +version: 1 +builder: + configs: + - documentation_targets: + - Spezi + - FHIRModelsExtensions + - FHIRPathParser + - FHIRQuestionnaires + - HealthKitOnFHIR + - ResearchKitOnFHIR + - SpeziTesting + - XCTSpezi + - SpeziAccessGuard + - SpeziAccount + - XCTSpeziAccount + - SpeziAccountPhoneNumbers + - SpeziBluetoothServices + - SpeziBluetooth + - SpeziChat + - SpeziConsent + - SpeziContact + - SpeziDevices + - SpeziDevicesUI + - SpeziOmron + - SpeziFHIR + - SpeziFHIRHealthKit + - SpeziFHIRMockPatients + - EDFFormat + - SpeziFirebaseAccount + - SpeziFirebaseConfiguration + - SpeziFirestore + - SpeziFirebaseStorage + - SpeziFirebaseAccountStorage + - SpeziFoundation + - SpeziLocalization + - ThreadLocal + - SpeziHealthKit + - SpeziHealthKitBulkExport + - SpeziHealthKitUI + - SpeziLLM + - SpeziLLMLocal + - SpeziLLMLocalDownload + - SpeziLLMOpenAI + - SpeziLLMFog + - SpeziLLMOpenAIRealtime + - SpeziLLMAnthropic + - SpeziLLMGemini + - SpeziLicense + - SpeziLocation + - ByteCoding + - SpeziNumerics + - XCTByteCoding + - ByteCodingTesting + - SpeziNotifications + - XCTSpeziNotifications + - XCTSpeziNotificationsUI + - SpeziOnboarding + - SpeziQuestionnaire + - SpeziQuestionnaireCatalog + - SpeziQuestionnaireFHIR + - XCTSpeziQuestionnaire + - SpeziScheduler + - SpeziSensorKit + - SpeziSpeechRecognizer + - SpeziSpeechSynthesizer + - SpeziLocalStorage + - SpeziKeychainStorage + - SpeziStudyDefinition + - SpeziViews + - SpeziPersonalInfo + - SpeziValidation + - XCTHealthKit + - RuntimeAssertions + - RuntimeAssertionsTesting + - XCTRuntimeAssertions + - XCTestApp + - XCTestExtensions + - SpeziSchedulerUI + - SpeziStudy diff --git a/.swiftlint.yml b/.swiftlint.yml index 4e7485890..b9f4ea19f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -12,6 +12,8 @@ only_rules: - accessibility_label_for_image # Prefer using Array(seq) over seq.map { $0 } to convert a sequence into an Array. - array_init + # Attribute names should be followed by at most one balanced set of parentheses. + - attribute_name_spacing # Prefer the new block based KVO API with keypaths when using Swift 3.2 or later. - block_based_kvo # Non-constant variables should not be listed in a closure’s capture list to avoid confusion about closures capturing variables at creation time. @@ -74,6 +76,10 @@ only_rules: - duplicated_key_in_dictionary_literal # Duplicate Imports - duplicate_imports + # Condition lists should not contain duplicated conditions. + - duplicate_conditions + # Enum cases should not be duplicated. + - duplicate_enum_cases # Avoid using 'dynamic' and '@inline(__always)' together. - dynamic_inline # Prefer checking isEmpty over comparing collection to an empty array or dictionary literal. @@ -139,6 +145,10 @@ only_rules: - implicitly_unwrapped_optional # Identifiers should use inclusive language that avoids discrimination against groups of people based on race, gender, or socioeconomic status - inclusive_language + # SwiftLint commands should be valid. + - invalid_swiftlint_command + # Files should not contain invisible characters. + - invisible_character # Prefer using Set.isDisjoint(with:) over Set.intersection(_:).isEmpty. - is_disjoint # Discouraged explicit usage of the default separator. @@ -204,6 +214,10 @@ only_rules: - notification_center_detachment # Static strings should be used as key in NSLocalizedString in order to genstrings work. - nslocalizedstring_key + # Prefer non-optional Data initializers when converting Strings to Data. + - non_optional_string_data_conversion + # Prefer NSNumber initializer references over as-function references. + - ns_number_init_as_function_reference # NSObject subclasses should implement isEqual instead of ==. - nsobject_prefer_isequal # Prefer object literals over image and color inits. @@ -224,6 +238,8 @@ only_rules: - pattern_matching_keywords # Prefer Self over type(of: self) when accessing properties or calling methods. - prefer_self_type_over_type_of_self + # Prefer `is` and `as?` over comparing type names. + - prefer_type_checking # Prefer .zero over explicit init with zero parameters (e.g. CGPoint(x: 0, y: 0)) - prefer_zero_over_explicit_init # Prefer private over fileprivate declarations. @@ -266,6 +282,8 @@ only_rules: - return_value_from_void_function # Re-bind self to a consistent identifier name. - self_binding + # `self` should not be used before all stored properties are initialized. + - self_in_property_initialization # Prefer shorthand operators (+=, -=, *=, /=) over doing the operation and assigning. - shorthand_operator # Test files should contain a single QuickSpec or XCTestCase class. @@ -278,6 +296,8 @@ only_rules: - statement_position # Operators should be declared as static functions, not free functions. - static_operator + # Prefer `static` members over `class` members on final classes. + - static_over_final_class # SwiftLint ‘disable’ commands are superfluous when the disabled rule would not have triggered a violation in the disabled region. Use “ - ” if you wish to document a command. - superfluous_disable_command # Case statements should vertically align with their enclosing switch statement, or indented if configured otherwise. @@ -315,6 +335,8 @@ only_rules: - unavailable_function # Avoid using unneeded break statements. - unneeded_break_in_switch + # Unneeded overrides should be removed. + - unneeded_override # Parentheses are not needed when declaring closure arguments. - unneeded_parentheses_in_closure_argument # Prefer capturing references as weak to avoid potential crashes. diff --git a/Package.swift b/Package.swift index 3e9ca326a..9565033bd 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,9 @@ // import CompilerPluginSupport +import class Foundation.FileManager +import class Foundation.ProcessInfo +import struct Foundation.URL import PackageDescription @@ -27,18 +30,93 @@ let optionalPackageTraits = [textualTrait, mlxTrait, researchKitTrait] let defaultEnabledTraits: Set = Context.environment["SPEZI_ENABLE_DEFAULT_PACKAGE_TRAITS"] == "1" ? Set(optionalPackageTraits) : [] +// Compile/test builds can exclude DocC catalogs to avoid SwiftPM unhandled-file warnings. +// Documentation builds keep them included so DocC can resolve articles and assets. +let excludeDocCCatalogs = Context.environment["SPEZI_EXCLUDE_DOCC_CATALOGS"] == "1" let packagePlatforms: [SupportedPlatform] = [ .iOS(.v15), .macOS(.v12), .watchOS(.v8) ] +let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + +let reusableTargetExcludes = [ + "CITATION.cff", + "CONTRIBUTORS.md", + "LICENSE", + "LICENSE.md", + "LICENSES", + "README.md", + "REUSE.toml" +] + +func targetExcludes(_ targetName: String, additional: [String] = []) -> [String] { + reusableExcludes(in: "Sources/\(targetName)", additional: additional) +} + +func testTargetExcludes(_ targetName: String, additional: [String] = []) -> [String] { + reusableExcludes(in: "Tests/\(targetName)", additional: additional) +} + +func reusableExcludes(in targetPath: String, additional: [String] = []) -> [String] { + let existingAdditionalExcludes = existingExcludes(in: targetPath, matching: additional) + let excludes = existingExcludes(in: targetPath, matching: reusableTargetExcludes) + + doccCatalogExcludes(in: targetPath, skipping: existingAdditionalExcludes) + + licenseExcludes(in: targetPath, skipping: existingAdditionalExcludes) + + existingAdditionalExcludes + + var seenExcludes: Set = [] + return excludes.filter { seenExcludes.insert($0).inserted } +} + +func existingExcludes(in targetPath: String, matching candidates: [String]) -> [String] { + let targetDirectory = packageDirectory.appendingPathComponent(targetPath, isDirectory: true) + return candidates.filter { FileManager.default.fileExists(atPath: targetDirectory.appendingPathComponent($0).path) } +} + +func licenseExcludes(in targetPath: String, skipping skippedExcludes: [String]) -> [String] { + matchingFiles(in: targetPath, skipping: skippedExcludes) { relativePath in + relativePath.hasSuffix(".license") + } +} + +func doccCatalogExcludes(in targetPath: String, skipping skippedExcludes: [String]) -> [String] { + guard excludeDocCCatalogs else { + return [] + } + + return matchingFiles(in: targetPath, skipping: skippedExcludes) { relativePath in + relativePath.hasSuffix(".docc") + } +} + +func matchingFiles(in targetPath: String, skipping skippedExcludes: [String], where matches: (String) -> Bool) -> [String] { + let targetDirectory = packageDirectory.appendingPathComponent(targetPath, isDirectory: true) + guard let enumerator = FileManager.default.enumerator(atPath: targetDirectory.path) else { + return [] + } + + var excludes: [String] = [] + while let relativePath = enumerator.nextObject() as? String { + guard !skippedExcludes.contains(where: { relativePath == $0 || relativePath.hasPrefix("\($0)/") }) else { + enumerator.skipDescendants() + continue + } + + if matches(relativePath) { + excludes.append(relativePath) + enumerator.skipDescendants() + } + } + return excludes.sorted() +} var dependencies: [Package.Dependency] = [ .package(url: "https://github.com/antlr/antlr4.git", from: "4.13.1"), .package(url: "https://github.com/apple/FHIRModels.git", .upToNextMinor(from: "0.8.0")), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "12.1.0"), - .package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.1.0"), + .package(url: "https://github.com/PhoneNumberKit/PhoneNumberKit.git", from: "5.0.0"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", .upToNextMinor(from: "0.16.0")), .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.1"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), @@ -57,6 +135,10 @@ var dependencies: [Package.Dependency] = [ .package(url: "https://github.com/ml-explore/mlx-swift.git", .upToNextMinor(from: "0.29.1")), .package(url: "https://github.com/ml-explore/mlx-swift-examples.git", from: "2.29.1"), .package(url: "https://github.com/huggingface/swift-transformers.git", from: "1.0.0"), + // swift-transformers 1.0.0 (the only version mlx-swift-examples 2.29.1 allows, via its <1.1.0 cap) + // builds String-keyed Jinja objects, but swift-jinja 2.3.3 renamed that key type to `ObjectKey`. + // Pin below 2.3.3 until a newer mlx-swift-examples permits a swift-transformers that supports it. + .package(url: "https://github.com/huggingface/swift-jinja.git", "2.0.0"..<"2.3.3"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.19.2"), .package(url: "https://github.com/SchmiedmayerLab/ResearchKit.git", "3.1.4"..<"3.2.0"), .package(url: "https://github.com/swiftlang/swift-syntax.git", "602.0.0"..<"603.0.0"), @@ -177,7 +259,7 @@ var products: [Product] = [ .library(name: "XCTRuntimeAssertions", targets: ["XCTRuntimeAssertions"]), // MARK: XCTestExtensions .library(name: "XCTestApp", targets: ["XCTestApp"]), - .library(name: "XCTestExtensions", targets: ["XCTestExtensions"]), + .library(name: "XCTestExtensions", targets: ["XCTestExtensions"]) ] #if canImport(Darwin) @@ -185,7 +267,7 @@ products += [ // MARK: SpeziScheduler .library(name: "SpeziSchedulerUI", targets: ["SpeziSchedulerUI"]), // MARK: SpeziStudy - .library(name: "SpeziStudy", targets: ["SpeziStudy"]), + .library(name: "SpeziStudy", targets: ["SpeziStudy"]) ] #endif @@ -198,13 +280,7 @@ var targets: [Target] = [ .target(name: "FHIRPathParser"), .product(name: "ModelsR4", package: "FHIRModels") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("FHIRModelsExtensions"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -216,9 +292,7 @@ var targets: [Target] = [ dependencies: [ .product(name: "Antlr4", package: "antlr4") ], - exclude: [ - "ANTLUtils" - ], + exclude: targetExcludes("FHIRPathParser", additional: ["ANTLUtils"]), plugins: [] + defaultPlugins ), .target( @@ -226,6 +300,7 @@ var targets: [Target] = [ dependencies: [ .product(name: "ModelsR4", package: "FHIRModels") ], + exclude: targetExcludes("FHIRQuestionnaires"), resources: [ .process("Resources") ], @@ -274,13 +349,7 @@ var targets: [Target] = [ .product(name: "ModelsR4", package: "FHIRModels"), .target(name: "FHIRModelsExtensions") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "Scripts" - ], + exclude: targetExcludes("HealthKitOnFHIR", additional: ["Scripts"]), resources: [ .process("Resources") ], @@ -295,9 +364,7 @@ var targets: [Target] = [ .target(name: "HealthKitOnFHIR"), .target(name: "SpeziFoundation") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("HealthKitOnFHIRTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -324,12 +391,7 @@ var targets: [Target] = [ .target(name: "FHIRModelsExtensions"), .target(name: "FHIRPathParser") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("ResearchKitOnFHIR"), plugins: [] + defaultPlugins ), .testTarget( @@ -338,9 +400,7 @@ var targets: [Target] = [ .target(name: "ResearchKitOnFHIR", condition: .when(traits: [researchKitTrait])), .target(name: "FHIRQuestionnaires") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("ResearchKitOnFHIRTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: Spezi @@ -351,12 +411,7 @@ var targets: [Target] = [ .target(name: "RuntimeAssertions"), .product(name: "OrderedCollections", package: "swift-collections") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("Spezi"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -367,6 +422,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziTesting"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -391,9 +447,7 @@ var targets: [Target] = [ .target(name: "RuntimeAssertionsTesting"), .product(name: "TestingExpectation", package: "swift-testing-expectation") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .define("DEBUG", .when(configuration: .debug)) @@ -409,12 +463,9 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .target(name: "SpeziFoundation") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" + exclude: targetExcludes("SpeziAccessGuard"), + resources: [ + .process("Resources") ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), @@ -429,9 +480,7 @@ var targets: [Target] = [ .target(name: "SpeziTesting"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziAccessGuardTests", additional: ["UITests"]), resources: [ .process("__Snapshots__") ], @@ -467,12 +516,7 @@ var targets: [Target] = [ .product(name: "Atomics", package: "swift-atomics"), .target(name: "SpeziAccountMacros") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziAccount"), resources: [ .process("Resources") ], @@ -487,6 +531,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .target(name: "XCTestExtensions") ], + exclude: targetExcludes("XCTSpeziAccount"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -498,6 +543,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .product(name: "PhoneNumberKit", package: "PhoneNumberKit") ], + exclude: targetExcludes("SpeziAccountPhoneNumbers"), resources: [ .process("Resources") ], @@ -516,9 +562,7 @@ var targets: [Target] = [ .target(name: "SpeziTesting"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziAccountTests", additional: ["UITests"]), resources: [ .process("__Snapshots__") ], @@ -551,13 +595,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .product(name: "Atomics", package: "swift-atomics") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "bin" - ], + exclude: targetExcludes("SpeziBluetooth", additional: ["bin"]), resources: [ .process("Resources") ], @@ -570,6 +608,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .target(name: "SpeziNumerics") ], + exclude: targetExcludes("SpeziBluetoothServices"), plugins: [] + defaultPlugins ), .executableTarget( @@ -579,6 +618,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetoothServices"), .target(name: "ByteCoding") ], + exclude: targetExcludes("TestPeripheral"), plugins: [] + defaultPlugins ), .testTarget( @@ -587,9 +627,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziBluetoothTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), .testTarget( @@ -612,12 +650,7 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .product(name: "Textual", package: "textual", condition: .when(traits: [textualTrait])) ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziChat"), resources: [ .process("Resources") ], @@ -631,9 +664,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziChat") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziChatTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: SpeziConsent @@ -648,13 +679,7 @@ var targets: [Target] = [ .product(name: "TPPDF", package: "TPPDF"), .product(name: "MarkdownUI", package: "swift-markdown-ui") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziConsent"), resources: [ .process("Resources") ], @@ -671,9 +696,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziConsentTests", additional: ["UITests"]), resources: [ .process("Resources"), .process("__Snapshots__") @@ -690,11 +713,9 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .target(name: "SpeziPersonalInfo") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" + exclude: targetExcludes("SpeziContact"), + resources: [ + .process("Resources") ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") @@ -706,9 +727,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziContact") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziContactTests", additional: ["UITests"]), swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ], @@ -725,12 +744,7 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .target(name: "Spezi") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziDevices"), plugins: [] + defaultPlugins ), .target( @@ -741,6 +755,7 @@ var targets: [Target] = [ .target(name: "SpeziValidation"), .target(name: "SpeziBluetooth") ], + exclude: targetExcludes("SpeziDevicesUI"), resources: [ .process("Resources") ], @@ -753,6 +768,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], + exclude: targetExcludes("SpeziOmron"), resources: [ .process("Resources") ], @@ -767,9 +783,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziDevicesTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), .testTarget( @@ -791,12 +805,7 @@ var targets: [Target] = [ .target(name: "HealthKitOnFHIR"), .target(name: "SpeziHealthKit") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziFHIR"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -820,6 +829,7 @@ var targets: [Target] = [ .target(name: "SpeziFHIR"), .product(name: "ModelsR4", package: "FHIRModels") ], + exclude: targetExcludes("SpeziFHIRMockPatients"), resources: [ .process("Resources") ], @@ -836,9 +846,7 @@ var targets: [Target] = [ .target(name: "HealthKitOnFHIR"), .target(name: "SpeziHealthKit") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFHIRTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -847,12 +855,7 @@ var targets: [Target] = [ // MARK: SpeziFileFormats .target( name: "SpeziFileFormats", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziFileFormats"), plugins: [] + defaultPlugins ), .target( @@ -861,6 +864,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .target(name: "SpeziNumerics") ], + exclude: targetExcludes("EDFFormat"), swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ], @@ -880,12 +884,7 @@ var targets: [Target] = [ // MARK: SpeziFirebase .target( name: "SpeziFirebase", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziFirebase"), plugins: [] + defaultPlugins ), .target( @@ -900,6 +899,10 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .product(name: "FirebaseAuth", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseAccount"), + resources: [ + .process("Resources") + ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -911,6 +914,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .product(name: "FirebaseFirestore", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseConfiguration"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -924,6 +928,10 @@ var targets: [Target] = [ .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), .product(name: "Atomics", package: "swift-atomics") ], + exclude: targetExcludes("SpeziFirestore"), + resources: [ + .process("Resources") + ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -936,6 +944,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .product(name: "FirebaseStorage", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -949,6 +958,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .target(name: "SpeziFirestore") ], + exclude: targetExcludes("SpeziFirebaseAccountStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -961,9 +971,7 @@ var targets: [Target] = [ .target(name: "SpeziFirebaseConfiguration"), .target(name: "SpeziFirestore") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFirebaseTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1024,13 +1032,7 @@ var targets: [Target] = [ .product(name: "Logging", package: "swift-log"), .target(name: "ThreadLocal") ], - exclude: [ - "CONTRIBUTORS.md", - "Dockerfile", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziFoundation", additional: ["Dockerfile"]), resources: [ .process("Resources") ], @@ -1050,6 +1052,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .product(name: "Algorithms", package: "swift-algorithms") ], + exclude: targetExcludes("SpeziLocalization"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1062,9 +1065,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "RuntimeAssertionsTesting") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFoundationTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1076,6 +1077,7 @@ var targets: [Target] = [ .target(name: "SpeziLocalization"), .target(name: "SpeziFoundation") ], + exclude: testTargetExcludes("SpeziLocalizationTests"), resources: [ .process("Resources") ], @@ -1094,17 +1096,12 @@ var targets: [Target] = [ .product(name: "Algorithms", package: "swift-algorithms"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") ], - exclude: [ + exclude: targetExcludes("SpeziHealthKit", additional: [ "Sample Types/SampleTypeDefs.py", "Sample Types/SampleTypes.swift.gyb", - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml", "codecov.yml", "useGYB" - ], + ]), resources: [ .process("Resources") ], @@ -1121,6 +1118,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "SpeziLocalStorage", condition: .when(platforms: [.macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS])) ], + exclude: targetExcludes("SpeziHealthKitBulkExport"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1133,6 +1131,7 @@ var targets: [Target] = [ .target(name: "SpeziHealthKit"), .target(name: "SpeziFoundation") ], + exclude: targetExcludes("SpeziHealthKitUI"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1148,9 +1147,7 @@ var targets: [Target] = [ .target(name: "SpeziHealthKitUI"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziHealthKitTests", additional: ["UITests"]), resources: [ .process("__Snapshots__") ], @@ -1167,13 +1164,7 @@ var targets: [Target] = [ .target(name: "SpeziChat"), .target(name: "SpeziViews") ], - exclude: [ - "CONTRIBUTORS.md", - "FogNode", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziLLM", additional: ["FogNode"]), resources: [ .process("Resources") ], @@ -1191,8 +1182,12 @@ var targets: [Target] = [ .product(name: "MLX", package: "mlx-swift", condition: .when(traits: [mlxTrait])), .product(name: "MLXRandom", package: "mlx-swift", condition: .when(traits: [mlxTrait])), .product(name: "Transformers", package: "swift-transformers", condition: .when(traits: [mlxTrait])), + // Gives the root swift-jinja version pin (see the dependencies list) a real consumer, so it + // doesn't trip SwiftPM's "unused dependency" warning. Transformers uses Jinja for chat templates. + .product(name: "Jinja", package: "swift-jinja", condition: .when(traits: [mlxTrait])), .product(name: "MLXLLM", package: "mlx-swift-examples", condition: .when(traits: [mlxTrait])) ], + exclude: targetExcludes("SpeziLLMLocal"), resources: [ .process("Resources") ], @@ -1209,6 +1204,7 @@ var targets: [Target] = [ .target(name: "SpeziLLMLocal"), .product(name: "MLXLLM", package: "mlx-swift-examples", condition: .when(traits: [mlxTrait])) ], + exclude: targetExcludes("SpeziLLMLocalDownload"), resources: [ .process("Resources") ], @@ -1230,6 +1226,7 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMOpenAI"), resources: [ .process("Resources") ], @@ -1252,6 +1249,7 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMOpenAIRealtime"), resources: [ .process("Resources") ], @@ -1266,6 +1264,7 @@ var targets: [Target] = [ .target(name: "SpeziLLMOpenAI"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLLMAnthropic"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1277,6 +1276,7 @@ var targets: [Target] = [ .target(name: "SpeziLLMOpenAI"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLLMGemini"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1292,6 +1292,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMFog"), resources: [ .process("Resources") ], @@ -1308,14 +1309,11 @@ var targets: [Target] = [ .target(name: "SpeziOnboarding"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime") ], - exclude: [ + exclude: targetExcludes("GeneratedOpenAIClient", additional: [ "package.json", - "package.json.license", "preprocess-openapi-spec.js", - "package-lock.json", - "README.md", - "package-lock.json.license" - ], + "package-lock.json" + ]), resources: [ .process("Resources") ], @@ -1333,9 +1331,7 @@ var targets: [Target] = [ .target(name: "SpeziLLM"), .target(name: "SpeziLLMOpenAI") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLLMTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1347,12 +1343,9 @@ var targets: [Target] = [ dependencies: [ .product(name: "SwiftPackageList", package: "swift-package-list") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" + exclude: targetExcludes("SpeziLicense"), + resources: [ + .process("Resources") ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny") @@ -1365,9 +1358,7 @@ var targets: [Target] = [ .target(name: "SpeziLicense"), .target(name: "Spezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLicenseTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1379,12 +1370,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziLocation"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1395,9 +1381,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziLocation") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLocationTests", additional: ["UITests"]), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1406,12 +1390,7 @@ var targets: [Target] = [ // MARK: SpeziNetworking .target( name: "SpeziNetworking", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziNetworking"), plugins: [] + defaultPlugins ), .target( @@ -1420,6 +1399,7 @@ var targets: [Target] = [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio") ], + exclude: targetExcludes("ByteCoding"), plugins: [] + defaultPlugins ), .target( @@ -1428,6 +1408,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .product(name: "NIOCore", package: "swift-nio") ], + exclude: targetExcludes("SpeziNumerics"), plugins: [] + defaultPlugins ), .target( @@ -1435,6 +1416,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "ByteCoding") ], + exclude: targetExcludes("ByteCodingTesting"), plugins: [] + defaultPlugins ), .target( @@ -1442,6 +1424,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "ByteCoding") ], + exclude: targetExcludes("XCTByteCoding"), plugins: [] + defaultPlugins ), .testTarget( @@ -1468,12 +1451,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziNotifications"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1484,6 +1462,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziNotifications") ], + exclude: targetExcludes("XCTSpeziNotifications"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1495,6 +1474,7 @@ var targets: [Target] = [ .target(name: "SpeziNotifications"), .target(name: "SpeziViews") ], + exclude: targetExcludes("XCTSpeziNotificationsUI"), resources: [ .process("Resources") ], @@ -1510,9 +1490,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "XCTSpezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziNotificationsTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1525,13 +1503,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "SpeziViews") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziOnboarding"), resources: [ .process("Resources") ], @@ -1545,9 +1517,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziOnboarding") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziOnboardingTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1562,13 +1532,7 @@ var targets: [Target] = [ .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "Numerics", package: "swift-numerics") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziQuestionnaire"), resources: [ .process("Resources") ], @@ -1598,6 +1562,7 @@ var targets: [Target] = [ .product(name: "Algorithms", package: "swift-algorithms"), .target(name: "SpeziFoundation") ], + exclude: targetExcludes("SpeziQuestionnaireFHIR"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1639,9 +1604,7 @@ var targets: [Target] = [ .target(name: "FHIRModelsExtensions"), .target(name: "FHIRQuestionnaires") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziQuestionnaireTests", additional: ["UITests"]), resources: [ .process("Resources") ], @@ -1668,13 +1631,7 @@ var targets: [Target] = [ #endif return deps }(), - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziScheduler"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1688,12 +1645,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "SpeziLocalStorage") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziSensorKit"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1705,20 +1657,13 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziSensorKit") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziSensorKitTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: SpeziSpeech .target( name: "SpeziSpeech", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziSpeech"), plugins: [] + defaultPlugins ), .target( @@ -1726,6 +1671,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziSpeechRecognizer"), plugins: [] + defaultPlugins ), .target( @@ -1733,6 +1679,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziSpeechSynthesizer"), plugins: [] + defaultPlugins ), .testTarget( @@ -1741,20 +1688,13 @@ var targets: [Target] = [ .target(name: "SpeziSpeechRecognizer"), .target(name: "SpeziSpeechSynthesizer") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziSpeechTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: SpeziStorage .target( name: "SpeziStorage", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziStorage"), plugins: [] + defaultPlugins ), .target( @@ -1763,6 +1703,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("SpeziKeychainStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1775,6 +1716,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLocalStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1786,9 +1728,7 @@ var targets: [Target] = [ .target(name: "SpeziLocalStorage"), .target(name: "XCTSpezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziStorageTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1807,6 +1747,7 @@ var targets: [Target] = [ .product(name: "DequeModule", package: "swift-collections"), .product(name: "Logging", package: "swift-log") ], + exclude: targetExcludes("SpeziStudyDefinition"), resources: [ .process("Resources") ], @@ -1830,9 +1771,7 @@ var targets: [Target] = [ #endif return deps }(), - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziStudyTests", additional: ["UITests"]), resources: [ .process("Resources/questionnaires"), .copy("Resources/assets") @@ -1851,13 +1790,7 @@ var targets: [Target] = [ .target(name: "SpeziLocalization"), .product(name: "MarkdownUI", package: "swift-markdown-ui") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziViews"), resources: [ .process("Resources") ], @@ -1868,6 +1801,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziViews") ], + exclude: targetExcludes("SpeziPersonalInfo"), resources: [ .process("Resources") ], @@ -1880,6 +1814,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .product(name: "OrderedCollections", package: "swift-collections") ], + exclude: targetExcludes("SpeziValidation"), resources: [ .process("Resources") ], @@ -1892,20 +1827,16 @@ var targets: [Target] = [ .target(name: "SpeziValidation"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], - exclude: [ - "UITests" + exclude: testTargetExcludes("SpeziViewsTests", additional: ["UITests"]), + resources: [ + .process("__Snapshots__") ], plugins: [] + defaultPlugins ), // MARK: XCTHealthKit .target( name: "XCTHealthKit", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("XCTHealthKit"), plugins: [] + defaultPlugins ), .testTarget( @@ -1913,20 +1844,13 @@ var targets: [Target] = [ dependencies: [ .target(name: "XCTHealthKit") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("XCTHealthKitTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: XCTRuntimeAssertions .target( name: "RuntimeAssertions", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("RuntimeAssertions"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1937,6 +1861,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("RuntimeAssertionsTesting"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1947,6 +1872,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("XCTRuntimeAssertions"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1976,16 +1902,12 @@ var targets: [Target] = [ // MARK: XCTestExtensions .target( name: "XCTestApp", + exclude: targetExcludes("XCTestApp"), plugins: [] + defaultPlugins ), .target( name: "XCTestExtensions", - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("XCTestExtensions"), plugins: [] + defaultPlugins ), .testTarget( @@ -1993,9 +1915,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "XCTestExtensions") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("XCTestExtensionsTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), ] @@ -2022,6 +1942,7 @@ targets += [ .target(name: "SpeziScheduler"), .target(name: "SpeziViews") ], + exclude: targetExcludes("SpeziSchedulerUI"), resources: [ .process("Resources") ], @@ -2042,9 +1963,7 @@ targets += [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziSchedulerTests", additional: ["UITests"]), resources: [ .process("Resources") ], @@ -2059,6 +1978,7 @@ targets += [ dependencies: [ .target(name: "SpeziScheduler"), .target(name: "SpeziSchedulerUI"), + .target(name: "SpeziTesting"), .target(name: "XCTSpezi"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing", condition: .when(platforms: [.iOS])) ], @@ -2083,13 +2003,7 @@ targets += [ .target(name: "SpeziSchedulerUI"), .product(name: "Algorithms", package: "swift-algorithms") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziStudy"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -2116,9 +2030,7 @@ targets += [ .target(name: "SpeziHealthKit"), .product(name: "ArgumentParser", package: "swift-argument-parser") ], - exclude: [ - "HKTypeIdentifierDefs+Linux.swift.gyb" - ], + exclude: targetExcludes("Codegen", additional: ["HKTypeIdentifierDefs+Linux.swift.gyb"]), plugins: [] + defaultPlugins ), ] diff --git a/REUSE.toml b/REUSE.toml index 00c0a2528..f795a173c 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -46,11 +46,12 @@ SPDX-FileCopyrightText = "2026 Stanford University and the project authors (see SPDX-License-Identifier = "MIT" # Monorepo infrastructure created during the migration (2026) that can't carry an inline SPDX header -# (JSON test plans, the shared Xcode test-host scheme). +# (JSON test plans, the shared Xcode test-host scheme, and the size-constrained SPI manifest). [[annotations]] path = [ "Tests/TestPlans/**", - ".swiftpm/xcode/xcshareddata/xcschemes/Spezi-Tests.xcscheme" + ".swiftpm/xcode/xcshareddata/xcschemes/Spezi-Tests.xcscheme", + ".spi.yml" ] SPDX-FileCopyrightText = "2026 Stanford University and the project authors (see CONTRIBUTORS.md)" SPDX-License-Identifier = "MIT" diff --git a/Scripts/affected-test-matrix.py b/Scripts/affected-test-matrix.py index 9fecaef00..26518be1b 100644 --- a/Scripts/affected-test-matrix.py +++ b/Scripts/affected-test-matrix.py @@ -19,6 +19,10 @@ # self-hosted-ci = which test kinds run on the self-hosted runner (vs GitHub-hosted): a subset of # ["unit", "ui"]. Optional; default ["ui"] (= today's behavior). Linux unit jobs # always run on GitHub-hosted ubuntu regardless (the self-hosted runner is macOS). +# extra_runner_labels = additional runner labels to require for this package's self-hosted jobs, on +# top of the base ["self-hosted", "macOS"]. Optional; default []. Emitted per job as +# `selfHostedLabels` for the workflow's `runs-on` (e.g. ["python3.11+"] pins the +# jobs to a self-hosted runner with a new-enough Python). # The dir -> package map used for change detection is derived from each package's targets+tests. # # Usage: @@ -26,8 +30,8 @@ # git diff --name-only A B | affected-test-matrix.py # # Emits (to stdout, GITHUB_OUTPUT format): -# matrix={"include":[{"package":"SpeziAccount","platform":"macOS","selfHosted":false}, ...]} # unit -# ui_matrix={"include":[{"package":"SpeziViews","platform":"iOS","selfHosted":true}, ...]} # UI +# matrix={"include":[{"package":"SpeziAccount","platform":"macOS","selfHosted":false,"selfHostedLabels":"[...]"}, ...]} # unit +# ui_matrix={"include":[{"package":"SpeziViews","platform":"iOS","selfHosted":true,"selfHostedLabels":"[...]"}, ...]} # UI # has_jobs=true|false # has_ui_jobs=true|false # affected=SpeziAccount,SpeziViews @@ -87,15 +91,20 @@ def main(): # ["unit", "ui"]. Default ["ui"] keeps today's behavior (UI on self-hosted, unit on # GitHub-hosted). Each emitted job carries a `selfHosted` bool the workflow `runs-on` reads. self_hosted = info.get("self-hosted-ci", ["ui"]) + # Self-hosted runner label set for this package: base labels + any package-specific extras, + # emitted as a JSON string the workflow's `runs-on` reads via fromJson(matrix.selfHostedLabels). + self_hosted_labels = json.dumps(["self-hosted", "macOS"] + list(info.get("extra_runner_labels", []))) for platform in info["platforms"]: if platform in CI_PLATFORMS: # TEMPORARY unit-test platform limit (see CI_PLATFORMS above) # Linux unit jobs always use GitHub-hosted ubuntu (the self-hosted runner is macOS). unit.append({"package": pkg, "platform": platform, - "selfHosted": ("unit" in self_hosted) and platform != "Linux"}) + "selfHosted": ("unit" in self_hosted) and platform != "Linux", + "selfHostedLabels": self_hosted_labels}) for platform in info.get("uiTests", []): # UI tests: per-project platforms from packages.toml if platform not in UI_PLATFORMS: # TEMPORARY UI-test platform limit (see UI_PLATFORMS above) continue - ui.append({"package": pkg, "platform": platform, "selfHosted": "ui" in self_hosted}) + ui.append({"package": pkg, "platform": platform, "selfHosted": "ui" in self_hosted, + "selfHostedLabels": self_hosted_labels}) lines = [ f'matrix={json.dumps({"include": unit})}', diff --git a/Scripts/build-documentation.sh b/Scripts/build-documentation.sh new file mode 100755 index 000000000..547443f4f --- /dev/null +++ b/Scripts/build-documentation.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# +# This source file is part of the Stanford Spezi open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ -n "${RUNNER_TEMP:-}" ]; then + DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$RUNNER_TEMP/spezi-docs-derivedData}" + DOC_OUTPUT_DIR="${DOC_OUTPUT_DIR:-$RUNNER_TEMP/spezi-documentation}" +else + DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-.derivedData-docs}" + DOC_OUTPUT_DIR="${DOC_OUTPUT_DIR:-.build/documentation}" +fi + +DOC_SCHEME="${DOC_SCHEME:-Spezi-Package}" +DOC_DESTINATION="${DOC_DESTINATION:-generic/platform=iOS Simulator}" +DOC_DEPLOYMENT_TARGET="${DOC_DEPLOYMENT_TARGET:-18.0}" +DOC_LOG_PATH="${DOC_LOG_PATH:-$DOC_OUTPUT_DIR/docbuild.log}" +COMBINED_ARCHIVE="${COMBINED_ARCHIVE:-$DOC_OUTPUT_DIR/Spezi.doccarchive}" +STATIC_ARCHIVE="${STATIC_ARCHIVE:-$DOC_OUTPUT_DIR/Spezi-static.doccarchive}" + +documentation_targets() { + python3 - <<'PY' +import pathlib +import sys + +lines = pathlib.Path(".spi.yml").read_text(encoding="utf-8").splitlines() + +for index, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("- "): + stripped = stripped[2:].strip() + if not stripped.startswith("documentation_targets:"): + continue + + _, value = stripped.split(":", maxsplit=1) + value = value.strip() + if value.startswith("[") and value.endswith("]"): + targets = [ + target.strip().strip("'\"") + for target in value[1:-1].split(",") + if target.strip() + ] + else: + targets = [] + parent_indent = len(line) - len(line.lstrip()) + for child in lines[index + 1:]: + child_stripped = child.strip() + if not child_stripped or child_stripped.startswith("#"): + continue + child_indent = len(child) - len(child.lstrip()) + if child_indent <= parent_indent: + break + if child_stripped.startswith("- "): + targets.append(child_stripped[2:].strip().strip("'\"")) + + if not targets: + sys.exit("Could not find documentation_targets in .spi.yml") + print("\n".join(targets)) + sys.exit(0) + +sys.exit("Could not find documentation_targets in .spi.yml") +PY +} + +collect_repo_documentation_warnings() { + python3 - "$PWD" "$DOC_LOG_PATH" <<'PY' +import pathlib +import sys + +repo = pathlib.Path(sys.argv[1]).resolve() +log_path = pathlib.Path(sys.argv[2]) +repo_prefix = f"{repo}/" +ignored_markers = ( + f"{repo}/.build/", + f"{repo}/.derivedData", + "/SourcePackages/", # dependency checkouts — never this repository's own docs +) + +warnings = [] +for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): + if ": warning:" not in line or repo_prefix not in line: + continue + if any(marker in line for marker in ignored_markers): + continue + is_documentation_warning = ( + "/Sources/" in line and "/.docc/" in line + ) or any( + marker in line + for marker in ( + " doesn't exist at ", + " is ambiguous at ", + "Parameter '" , + " not found in ", + ) + ) + if not is_documentation_warning: + continue + warnings.append(line) + +if warnings: + print("Documentation warnings from this repository:") + print("\n".join(warnings)) + sys.exit(1) +PY +} + +targets=() +while IFS= read -r target; do + targets+=("$target") +done < <(documentation_targets) + +mkdir -p "$DOC_OUTPUT_DIR" +rm -rf "$COMBINED_ARCHIVE" "$STATIC_ARCHIVE" + +export SPEZI_ENABLE_DEFAULT_PACKAGE_TRAITS="${SPEZI_ENABLE_DEFAULT_PACKAGE_TRAITS:-1}" +export SPEZI_EXCLUDE_DOCC_CATALOGS=0 +export LLVM_PROFILE_FILE="${LLVM_PROFILE_FILE:-$DOC_OUTPUT_DIR/default-%p.profraw}" + +echo "Building DocC documentation for scheme '$DOC_SCHEME' with all default package traits enabled." +if ! xcodebuild \ + -scheme "$DOC_SCHEME" \ + -destination "$DOC_DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + -skipPackageUpdates \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + IPHONEOS_DEPLOYMENT_TARGET="$DOC_DEPLOYMENT_TARGET" \ + docbuild \ + >"$DOC_LOG_PATH" 2>&1; then + tail -n 200 "$DOC_LOG_PATH" + exit 1 +fi + +collect_repo_documentation_warnings + +archives=() +missing_targets=() +for target in "${targets[@]}"; do + archive="$(find "$DERIVED_DATA_PATH/Build/Products" -maxdepth 3 -name "$target.doccarchive" -type d -print -quit)" + if [ -z "$archive" ]; then + missing_targets+=("$target") + else + archives+=("$archive") + fi +done + +if [ "${#missing_targets[@]}" -gt 0 ]; then + printf 'Missing DocC archives for targets:\n' >&2 + printf ' %s\n' "${missing_targets[@]}" >&2 + exit 1 +fi + +xcrun docc merge \ + "${archives[@]}" \ + --output-path "$COMBINED_ARCHIVE" \ + --synthesized-landing-page-name Spezi \ + --synthesized-landing-page-kind Package \ + --synthesized-landing-page-topics-style compactGrid + +xcrun docc process-archive transform-for-static-hosting \ + "$COMBINED_ARCHIVE" \ + --output-path "$STATIC_ARCHIVE" \ + --hosting-base-path "${DOC_HOSTING_BASE_PATH:-/Spezi}" + +echo "Built combined DocC archive at $COMBINED_ARCHIVE" +echo "Built static-hosting DocC archive at $STATIC_ARCHIVE" diff --git a/Scripts/cleanup-generated-artifacts.sh b/Scripts/cleanup-generated-artifacts.sh new file mode 100755 index 000000000..fffd9fc58 --- /dev/null +++ b/Scripts/cleanup-generated-artifacts.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# This source file is part of the Stanford Spezi open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# +set -euo pipefail +cd "$(dirname "$0")/.." + +rm -rf .build .xcodebuild .derivedData .derivedData-* DerivedData + +find . -maxdepth 1 -name "*.xcresult" -exec rm -rf {} + +find . -maxdepth 1 -name "*.xccovarchive" -exec rm -rf {} + +find . -maxdepth 1 -name "*.xccovreport" -exec rm -rf {} + +find . -maxdepth 1 -name "*.profraw" -exec rm -rf {} + +find . -maxdepth 1 -name "*.profdata" -exec rm -rf {} + +find . -name "__pycache__" -type d -prune -exec rm -rf {} + + +if [ -n "${RUNNER_TEMP:-}" ] && [ -d "$RUNNER_TEMP" ]; then + find "$RUNNER_TEMP" -maxdepth 1 \( \ + -name "spezi-derivedData*" \ + -o -name "spezi-docs-derivedData" \ + -o -name "spezi-documentation" \ + -o -name "*-Tests.xcresult" \ + -o -name "*-UITests.xcresult" \ + \) -exec rm -rf {} + +fi diff --git a/Scripts/run-package-tests.sh b/Scripts/run-package-tests.sh index 7838f09b7..8aadc9030 100755 --- a/Scripts/run-package-tests.sh +++ b/Scripts/run-package-tests.sh @@ -35,6 +35,12 @@ TESTING_FLOOR_DEPLOYMENT_TARGETS="IPHONEOS_DEPLOYMENT_TARGET=26.0 MACOSX_DEPLOYM # conditions still keep watchOS-/macOS-incompatible deps out of those platforms' graphs). export SPEZI_ENABLE_DEFAULT_PACKAGE_TRAITS=1 +# DocC catalogs are never needed to compile or run the tests. Excluding them from the test build +# (the manifest's targetExcludes() honors this flag) avoids unnecessary work; doc builds set it to 0 +# so DocC can still resolve their articles and assets. (`.license` files are excluded unconditionally +# by the manifest to suppress SwiftPM unhandled-file warnings, independent of this flag.) +export SPEZI_EXCLUDE_DOCC_CATALOGS="${SPEZI_EXCLUDE_DOCC_CATALOGS:-1}" + PACKAGES="FHIRModelsExtensions HealthKitOnFHIR ResearchKitOnFHIR Spezi SpeziAccessGuard SpeziAccount SpeziBluetooth SpeziChat SpeziConsent SpeziContact SpeziDevices SpeziFHIR SpeziFileFormats SpeziFirebase SpeziFoundation SpeziHealthKit SpeziLLM SpeziLicense SpeziLocation SpeziNetworking SpeziNotifications SpeziOnboarding SpeziQuestionnaire SpeziScheduler SpeziSensorKit SpeziSpeech SpeziStorage SpeziStudy SpeziViews ThreadLocal XCTHealthKit XCTRuntimeAssertions XCTestExtensions" # package -> the platforms it was tested on upstream (the union CI matrix) diff --git a/Sources/ByteCodingTesting/ByteCodingTesting.docc/ByteCodingTesting.md b/Sources/ByteCodingTesting/ByteCodingTesting.docc/ByteCodingTesting.md index 766739703..5fd2299b4 100644 --- a/Sources/ByteCodingTesting/ByteCodingTesting.docc/ByteCodingTesting.md +++ b/Sources/ByteCodingTesting/ByteCodingTesting.docc/ByteCodingTesting.md @@ -20,5 +20,5 @@ This package provides several utilities that make your life easier when testing ### Testing Byte Codable -- ``testIdentity(from:)`` -- ``testIdentity(of:from:)`` +- ``testIdentity(from:sourceLocation:)`` +- ``testIdentity(of:from:sourceLocation:)`` diff --git a/Sources/Codegen/Codegen.swift b/Sources/Codegen/Codegen.swift index e3888c9de..97c3f886b 100644 --- a/Sources/Codegen/Codegen.swift +++ b/Sources/Codegen/Codegen.swift @@ -16,6 +16,7 @@ import SpeziHealthKit @main +@available(iOS 18, macOS 15, watchOS 11, *) struct Codegen: ParsableCommand { static var configuration: CommandConfiguration { CommandConfiguration( @@ -52,10 +53,6 @@ struct Codegen: ParsableCommand { func run() throws { - guard #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) else { - print("Must be run on macOS 15+") - Foundation.exit(EXIT_FAILURE) - } let file = makeIdentifierDefsFile() if let outputUrl { try Data(file.utf8).write(to: outputUrl) @@ -191,6 +188,7 @@ private struct IdentifierDefinitionsFile: ~Copyable { } +@available(iOS 18, macOS 15, watchOS 11, *) extension URL: @retroactive ExpressibleByArgument { public init?(argument: String) { self = URL(filePath: argument, relativeTo: .currentDirectory()).absoluteURL diff --git a/Sources/FHIRModelsExtensions/CONTRIBUTORS.md b/Sources/FHIRModelsExtensions/CONTRIBUTORS.md index 3497b249e..911c1aa46 100644 --- a/Sources/FHIRModelsExtensions/CONTRIBUTORS.md +++ b/Sources/FHIRModelsExtensions/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -FHIRModelsExtensions contributors -==================== +# FHIRModelsExtensions contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/FHIRModelsExtensions/FHIR Extension Builder/FHIRExtensionBuilder+AbsoluteTimeRange.swift b/Sources/FHIRModelsExtensions/FHIR Extension Builder/FHIRExtensionBuilder+AbsoluteTimeRange.swift index ad8f9a26c..a17eeb4c9 100644 --- a/Sources/FHIRModelsExtensions/FHIR Extension Builder/FHIRExtensionBuilder+AbsoluteTimeRange.swift +++ b/Sources/FHIRModelsExtensions/FHIR Extension Builder/FHIRExtensionBuilder+AbsoluteTimeRange.swift @@ -32,7 +32,7 @@ extension Observation { /// /// The absolute timestamps (decimals representing the time interval since 1970) are stored using the ``FHIRExtensionUrls/absoluteTimeRangeStart`` and ``FHIRExtensionUrls/absoluteTimeRangeEnd`` urls. /// - /// - throws: If an error was encountered when converting the effective time range into the extension values. If the Observation's effecrive time uses an unsupported format (eg: `Timing`), ``HealthKitOnFHIRError/notSupported`` is thrown. + /// - throws: If an error was encountered when converting the effective time range into the extension values. If the Observation's effective time uses an unsupported format (e.g., `Timing`), an unsupported-format error is thrown. public func encodeAbsoluteTimeRangeIntoExtension() throws { removeAllExtensions(withUrl: FHIRExtensionUrls.absoluteTimeRangeStart) removeAllExtensions(withUrl: FHIRExtensionUrls.absoluteTimeRangeEnd) diff --git a/Sources/FHIRModelsExtensions/Questionnaire+Extensions+UI.swift b/Sources/FHIRModelsExtensions/Questionnaire+Extensions+UI.swift index d64b0126f..fa0068c2d 100644 --- a/Sources/FHIRModelsExtensions/Questionnaire+Extensions+UI.swift +++ b/Sources/FHIRModelsExtensions/Questionnaire+Extensions+UI.swift @@ -6,10 +6,12 @@ // SPDX-License-Identifier: MIT // -#if canImport(UIKit) +#if canImport(UIKit) && (os(iOS) || os(visionOS) || os(tvOS)) public import ModelsR4 +#if os(iOS) || os(visionOS) public import enum UIKit.UIKeyboardType +#endif public import enum UIKit.UITextAutocapitalizationType public import struct UIKit.UITextContentType diff --git a/Sources/HealthKitOnFHIR/CONTRIBUTORS.md b/Sources/HealthKitOnFHIR/CONTRIBUTORS.md index 8bb028a61..f592a06ce 100644 --- a/Sources/HealthKitOnFHIR/CONTRIBUTORS.md +++ b/Sources/HealthKitOnFHIR/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -HealthKitOnFHIR contributors -==================== +# HealthKitOnFHIR contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/HealthKitOnFHIR/HKSampleMapping/HKStateOfMindSampleMapping.swift b/Sources/HealthKitOnFHIR/HKSampleMapping/HKStateOfMindSampleMapping.swift index 616c3da5b..685f7463c 100644 --- a/Sources/HealthKitOnFHIR/HKSampleMapping/HKStateOfMindSampleMapping.swift +++ b/Sources/HealthKitOnFHIR/HKSampleMapping/HKStateOfMindSampleMapping.swift @@ -36,6 +36,11 @@ public struct HKStateOfMindMapping: Decodable, Sendable { /// - Parameters: /// - codings: The FHIR codings defined as ``MappedCode``s used for the `HKStateOfMind` sample /// - categories: The FHIR categories defined as ``MappedCode``s used for the `HKStateOfMind` sample + /// - kind: The mapping for the state-of-mind kind. + /// - valence: The mapping for the state-of-mind valence. + /// - valenceClassification: The mapping for the state-of-mind valence classification. + /// - label: The mapping for the state-of-mind labels. + /// - association: The mapping for the state-of-mind associations. public init( codings: [MappedCode] = Self.default.codings, categories: [MappedCode] = Self.default.categories, diff --git a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift index 649cb9445..b59c5f770 100644 --- a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift +++ b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift @@ -14,13 +14,15 @@ import ModelsR4 /// Models a value type used by a `HKCategoryType`. protocol FHIRCodingConvertible { static var system: FHIRPrimitive { get } - + var code: String { get } var display: String? { get } - + init?(rawValue: Int) } +protocol FHIRCodingConvertibleHKEnum: FHIRCodingConvertible {} + extension FHIRCodingConvertible { var asCoding: Coding { Coding( @@ -39,8 +41,6 @@ extension FHIRCodingConvertible where Self: RawRepresentable, RawValue == Int { } -protocol FHIRCodingConvertibleHKEnum: FHIRCodingConvertible {} - extension FHIRCodingConvertibleHKEnum { static var system: FHIRPrimitive { let typename = String(describing: Self.self).lowercased() diff --git a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift index 96d9e5eb8..7ccf76ff4 100644 --- a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift +++ b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift @@ -47,7 +47,7 @@ extension HKElectrocardiogram { /// - voltageMeasurements: The URL pointing to the raw voltage measurement data corrolated ot the FHIR observation. /// - mapping: The ``HKSampleMapping`` used to populate the FHIR observation. /// - issuedDate: `Instant` specifying when this version of the resource was made available. Defaults to `Date.now`. - /// - extensions: ``FHIRExtensionBuilder``s that should be applied to the resulting `Observation`. + /// - extensions: `FHIRExtensionBuilder`s that should be applied to the resulting `Observation`. public func observation( symptoms: Symptoms, voltageMeasurements: VoltageMeasurements, diff --git a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift index e90c9bd47..71aa79e69 100644 --- a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift +++ b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift @@ -17,13 +17,13 @@ extension HKSample { /// /// - parameter mapping: A mapping to map `HKSample`s to corresponding FHIR observations allowing the customization of, e.g., codings and units. See ``HKSampleMapping``. /// - parameter issuedDate: `Instant` specifying when this version of the resource was made available. Defaults to `Date.now`. - /// - parameter extensions: Any ``FHIRExtensionBuilder``s that should be applied to each of the produced observations. - /// The ``FHIRExtensionBuilder/sourceDevice-9m1t7``, ``FHIRExtensionBuilder/sourceRevision-8b3xb``, and ``FHIRExtensionBuilder/metadata`` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. + /// - parameter extensions: Any `FHIRExtensionBuilder`s that should be applied to each of the produced observations. + /// The `sourceDevice`, `sourceRevision`, and `metadata` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. /// - returns: A `ResourceProxy`containing an FHIR `Observation` based on the concrete subclass of `HKSample`. /// - throws: If a specific `HKSample` type is not supported, or if the sample for some reason cannot be turned into a FHIR resource /// (e.g., because it contains values that cannot be represented using the FHIR types) /// - /// - Important: When mapping an array of HKSample objects into ResourceProxies, for performance reasons always prefer ``Swift/Sequence/mapIntoResourceProxies(using:extensions:)`` or ``Swift/Sequence/mapIntoResourceProxies(using:extensions:)``. + /// - Important: When mapping an array of HKSample objects into ResourceProxies, for performance reasons always prefer ``Swift/Sequence/mapIntoResourceProxies(using:issuedDate:extensions:)`` or ``Swift/Sequence/compactMapIntoResourceProxies(using:issuedDate:extensions:)``. public func resource( withMapping mapping: HKSampleMapping = .default, issuedDate: FHIRPrimitive? = nil, @@ -76,8 +76,8 @@ extension Sequence where Element: HKSample { /// /// - parameter mapping: A mapping to map `HKSample`s to corresponding FHIR observations allowing the customization of, e.g., codings and units. See ``HKSampleMapping``. /// - parameter issuedDate: `Instant` specifying when this version of the resource was made available. Defaults to `Date.now`. - /// - parameter extensions: Any ``FHIRExtensionBuilder``s that should be applied to each of the produced observations. - /// The ``FHIRExtensionBuilder/sourceDevice-9m1t7``, ``FHIRExtensionBuilder/sourceRevision-8b3xb``, and ``FHIRExtensionBuilder/metadata`` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. + /// - parameter extensions: Any `FHIRExtensionBuilder`s that should be applied to each of the produced observations. + /// The `sourceDevice`, `sourceRevision`, and `metadata` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. public func mapIntoResourceProxies( using mapping: HKSampleMapping = .default, issuedDate: FHIRPrimitive? = nil, @@ -95,8 +95,8 @@ extension Sequence where Element: HKSample { /// /// - parameter mapping: A mapping to map `HKSample`s to corresponding FHIR observations allowing the customization of, e.g., codings and units. See ``HKSampleMapping``. /// - parameter issuedDate: `Instant` specifying when this version of the resource was made available. Defaults to `Date.now`. - /// - parameter extensions: Any ``FHIRExtensionBuilder``s that should be applied to each of the produced observations. - /// The ``FHIRExtensionBuilder/sourceDevice-9m1t7``, ``FHIRExtensionBuilder/sourceRevision-8b3xb``, and ``FHIRExtensionBuilder/metadata`` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. + /// - parameter extensions: Any `FHIRExtensionBuilder`s that should be applied to each of the produced observations. + /// The `sourceDevice`, `sourceRevision`, and `metadata` extension builders are always enabled when creating a FHIR `Observation`s from a `HKSample`. public func compactMapIntoResourceProxies( using mapping: HKSampleMapping = .default, issuedDate: FHIRPrimitive? = nil, diff --git a/Sources/HealthKitOnFHIR/HealthKitOnFHIR.docc/HealthKitOnFHIR.md b/Sources/HealthKitOnFHIR/HealthKitOnFHIR.docc/HealthKitOnFHIR.md index 992defabe..0a05b74fa 100644 --- a/Sources/HealthKitOnFHIR/HealthKitOnFHIR.docc/HealthKitOnFHIR.md +++ b/Sources/HealthKitOnFHIR/HealthKitOnFHIR.docc/HealthKitOnFHIR.md @@ -145,5 +145,4 @@ The following example generates the following FHIR observation: - ### Working with FHIR Extensions -- ``FHIRExtensionBuilder`` -- ``FHIRTypeWithExtensions`` +- ``FHIRModelsExtensions`` diff --git a/Sources/LocalizationsProcessor/LocalizationsProcessor.swift b/Sources/LocalizationsProcessor/LocalizationsProcessor.swift index aaa8c8795..d46bc04c9 100644 --- a/Sources/LocalizationsProcessor/LocalizationsProcessor.swift +++ b/Sources/LocalizationsProcessor/LocalizationsProcessor.swift @@ -15,6 +15,7 @@ import Foundation import SpeziHealthKit +@available(iOS 18, macOS 15, watchOS 11, *) private let allObjectTypes = HKObjectType.allKnownObjectTypes.sorted { $0.identifier < $1.identifier } @@ -33,6 +34,7 @@ struct LocalizationEntry: Hashable { @main +@available(iOS 18, macOS 15, watchOS 11, *) struct LocalizationsProcessor: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Generate localized string catalogues for HealthKit data types", @@ -112,6 +114,7 @@ struct LocalizationsProcessor: ParsableCommand { } +@available(iOS 18, macOS 15, watchOS 11, *) private struct Localizations { private let displayNameKeys: [HKObjectType: String] private var mergedLoctables: [Locale: [String: [LocalizationEntry]]] @@ -222,6 +225,7 @@ private struct Localizations { } +@available(iOS 18, macOS 15, watchOS 11, *) extension Localizations { /// mapping of localization keys to lang-value dictionaries private static let hardcodedMappings: [String: [String: String]] = [ @@ -237,6 +241,7 @@ extension Localizations { } +@available(iOS 18, macOS 15, watchOS 11, *) extension Locale.Language { static let english = Locale.Language(identifier: "en") static let englishUK = Locale.Language(identifier: "en_GB") @@ -258,6 +263,7 @@ extension Locale: @retroactive ExpressibleByArgument { } +@available(iOS 18, macOS 15, watchOS 11, *) extension URL: @retroactive ExpressibleByArgument { public init?(argument: String) { self = URL(filePath: argument, relativeTo: .currentDirectory()).absoluteURL diff --git a/Sources/ResearchKitOnFHIR/CONTRIBUTORS.md b/Sources/ResearchKitOnFHIR/CONTRIBUTORS.md index 982b7138a..d175de858 100644 --- a/Sources/ResearchKitOnFHIR/CONTRIBUTORS.md +++ b/Sources/ResearchKitOnFHIR/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -ResearchKitOnFHIR contributors -==================== +# ResearchKitOnFHIR contributors * [Vishnu Ravi](https://github.com/vishnuravi) * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) diff --git a/Sources/RuntimeAssertions/CONTRIBUTORS.md b/Sources/RuntimeAssertions/CONTRIBUTORS.md index d44c7b40f..1482f939e 100644 --- a/Sources/RuntimeAssertions/CONTRIBUTORS.md +++ b/Sources/RuntimeAssertions/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -XCTRuntimeAssertions contributors -==================== +# XCTRuntimeAssertions contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/RuntimeAssertionsTesting/RuntimeAssertionsTesting.docc/RuntimeAssertionsTesting.md b/Sources/RuntimeAssertionsTesting/RuntimeAssertionsTesting.docc/RuntimeAssertionsTesting.md index 28dba31d4..b52b26657 100644 --- a/Sources/RuntimeAssertionsTesting/RuntimeAssertionsTesting.docc/RuntimeAssertionsTesting.md +++ b/Sources/RuntimeAssertionsTesting/RuntimeAssertionsTesting.docc/RuntimeAssertionsTesting.md @@ -20,8 +20,8 @@ This package allows developers to test assertions and preconditions in tests usi ### Testing Runtime Assertions -In your unit tests you can use the ``expectRuntimeAssertion(expectedCount:_:assertion:sourceLocation:_:)-62s7y`` and -``expectRuntimePrecondition(timeout:_:precondition:sourceLocation:_:)-96i1f`` functions to test a block of code for which you expect +In your unit tests you can use the ``expectRuntimeAssertion(_:expectedCount:sourceLocation:_:assertion:)-25h24`` and +``expectRuntimePrecondition(timeout:_:sourceLocation:_:precondition:)-5w4q1`` functions to test a block of code for which you expect a runtime assertion to occur. Below is a short code example demonstrating this for assertions: @@ -59,11 +59,10 @@ func testPrecondition() { ### Testing Assertions -- ``expectRuntimeAssertion(expectedCount:_:assertion:sourceLocation:_:)-62s7y`` -- ``expectRuntimeAssertion(expectedCount:_:assertion:sourceLocation:_:)-8hn1j`` +- ``expectRuntimeAssertion(_:expectedCount:sourceLocation:_:assertion:)-25h24`` +- ``expectRuntimePrecondition(timeout:_:sourceLocation:_:precondition:)-5w4q1`` ### Testing Preconditions -- ``expectRuntimePrecondition(timeout:_:precondition:sourceLocation:_:)-96i1f`` -- ``expectRuntimePrecondition(timeout:_:precondition:sourceLocation:_:)-60tb0`` - +- ``expectRuntimePrecondition(timeout:_:sourceLocation:_:precondition:)-5w4q1`` +- ``expectNoRuntimePrecondition(timeout:_:sourceLocation:_:precondition:)-kae3`` diff --git a/Sources/Spezi/CONTRIBUTORS.md b/Sources/Spezi/CONTRIBUTORS.md index 6fd5f94e9..8741da3e4 100644 --- a/Sources/Spezi/CONTRIBUTORS.md +++ b/Sources/Spezi/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -Spezi contributors -==================== +# Spezi contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/Spezi/Spezi.docc/Initial Setup.md b/Sources/Spezi/Spezi.docc/Initial Setup.md index 11423ca13..3cf4acc8a 100644 --- a/Sources/Spezi/Spezi.docc/Initial Setup.md +++ b/Sources/Spezi/Spezi.docc/Initial Setup.md @@ -65,7 +65,7 @@ class ExampleAppDelegate: SpeziAppDelegate { } ``` -Different Spezi Modules can enforce ``Constraint``s on the Spezi ``Standard`` in your application that needs to be implemented, allowing modules to push data to a ``Standard`` for further processing and transformation. +Different Spezi Modules can enforce constraints on the Spezi ``Standard`` in your application that needs to be implemented, allowing modules to push data to a ``Standard`` for further processing and transformation. For example, the Spezi HealthKit module requires that your ``Standard`` instance in your Spezi application conforms to the [`HealthKitConstraint`](../../SpeziHealthKit/SpeziHealthKit.docc/SpeziHealthKit.md) protocol to receive HealthKit data: diff --git a/Sources/Spezi/Spezi.docc/Interactions with Application.md b/Sources/Spezi/Spezi.docc/Interactions with Application.md deleted file mode 100644 index ebaa68b9b..000000000 --- a/Sources/Spezi/Spezi.docc/Interactions with Application.md +++ /dev/null @@ -1,42 +0,0 @@ -# Interactions with Application - -Interact with the Application. - - - -## Overview - -Spezi provides platform-agnostic mechanisms to interact with your application instance. -To access application properties or actions you can use the ``Module/Application`` property wrapper within your -``Module`` or ``Standard``. - -> Tip: The articles illustrates how you can easily manage user notifications within your Spezi application. - -## Topics - -### Application Interaction - -- ``Module/Application`` - -### Properties - -- ``Spezi/logger`` -- ``Spezi/launchOptions`` - -### Notifications - -- ``Spezi/registerRemoteNotifications`` -- ``Spezi/unregisterRemoteNotifications`` - -### Platform-agnostic type-aliases - -- ``ApplicationDelegateAdaptor`` -- ``BackgroundFetchResult`` diff --git a/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png b/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png new file mode 100644 index 000000000..84fe597c5 Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png.license b/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png.license new file mode 100644 index 000000000..9bfad3b01 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/AccountSetup.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/Spezi/Spezi.docc/Resources/ChatView.png b/Sources/Spezi/Spezi.docc/Resources/ChatView.png new file mode 100644 index 000000000..a665db4fb Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/ChatView.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/ChatView.png.license b/Sources/Spezi/Spezi.docc/Resources/ChatView.png.license new file mode 100644 index 000000000..7f16969d0 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/ChatView.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Sources/Spezi/Spezi.docc/Resources/Consent1.png b/Sources/Spezi/Spezi.docc/Resources/Consent1.png new file mode 100644 index 000000000..68d747e88 Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/Consent1.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/Consent1.png.license b/Sources/Spezi/Spezi.docc/Resources/Consent1.png.license new file mode 100644 index 000000000..cdbacd0ff --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/Consent1.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2025 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png b/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png new file mode 100644 index 000000000..867d2f67a Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png.license b/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png.license new file mode 100644 index 000000000..a648e99b7 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/PairedDevices.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png b/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png new file mode 100644 index 000000000..e43407f2a Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png.license b/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png.license new file mode 100644 index 000000000..9bfad3b01 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/QuestionnaireOverview.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/Spezi/Spezi.docc/Resources/Validation.png b/Sources/Spezi/Spezi.docc/Resources/Validation.png new file mode 100644 index 000000000..3bd2da0fe Binary files /dev/null and b/Sources/Spezi/Spezi.docc/Resources/Validation.png differ diff --git a/Sources/Spezi/Spezi.docc/Resources/Validation.png.license b/Sources/Spezi/Spezi.docc/Resources/Validation.png.license new file mode 100644 index 000000000..9bfad3b01 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Resources/Validation.png.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/Spezi/Spezi.docc/Spezi.md b/Sources/Spezi/Spezi.docc/Spezi.md index 19c3756f3..c5c54ef41 100644 --- a/Sources/Spezi/Spezi.docc/Spezi.md +++ b/Sources/Spezi/Spezi.docc/Spezi.md @@ -23,34 +23,34 @@ Unfortunately, DocC currently does not support dark mode images: https://github. --> @Row { @Column { - @Image(source: "../../SpeziConsent/SpeziConsent.docc/Resources/Consent1.png", alt: "Screenshot displaying the UI of the consent module.") { + @Image(source: "Consent1", alt: "Screenshot displaying the UI of the consent module.") { The [Spezi Onboarding](../../SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md) and [Spezi Consent](../../SpeziConsent/SpeziConsent.docc/SpeziConsent.md) modules. } } @Column { - @Image(source: "../../SpeziDevicesUI/SpeziDevicesUI.docc/Resources/PairedDevices.png", alt: "Screenshot displaying Spezi Devices and Bluetooth pairing user interface.") { + @Image(source: "PairedDevices", alt: "Screenshot displaying Spezi Devices and Bluetooth pairing user interface.") { The [Spezi Bluetooth](../../SpeziBluetooth/SpeziBluetooth.docc/SpeziBluetooth.md) and [Spezi Devices](../../SpeziDevices/SpeziDevices.docc/SpeziDevices.md) modules. } } @Column { - @Image(source: "../../SpeziQuestionnaire/SpeziQuestionnaire.docc/Resources/Overview.png", alt: "Screenshot displaying the UI of the questionnaire module.") { + @Image(source: "QuestionnaireOverview", alt: "Screenshot displaying the UI of the questionnaire module.") { The [Spezi Questionnaire](../../SpeziQuestionnaire/SpeziQuestionnaire.docc/SpeziQuestionnaire.md) module. } } } @Row { @Column { - @Image(source: "../../SpeziAccount/SpeziAccount.docc/Resources/AccountSetup.png", alt: "Screenshot displaying the account setup view with email and password prompt and Sign In with Apple button using the Spezi Account module.") { + @Image(source: "AccountSetup", alt: "Screenshot displaying the account setup view with email and password prompt and Sign In with Apple button using the Spezi Account module.") { The [Spezi Account](../../SpeziAccount/SpeziAccount.docc/SpeziAccount.md) module. } } @Column { - @Image(source: "../../SpeziValidation/SpeziValidation.docc/Resources/Validation.png", alt: "Three different text fields showing validation errors with the Spezi Validation package.") { + @Image(source: "Validation", alt: "Three different text fields showing validation errors with the Spezi Validation package.") { The [Spezi Views](../../SpeziViews/SpeziViews.docc/SpeziViews.md) module, including the [SpeziValidation](../../SpeziValidation/SpeziValidation.docc/SpeziValidation.md) target. } } @Column { - @Image(source: "../../SpeziLLMLocal/SpeziLLMLocal.docc/Resources/ChatView.png", alt: "Chat view of a locally executed LLM using the Spezi LLM module.") { + @Image(source: "ChatView", alt: "Chat view of a locally executed LLM using the Spezi LLM module.") { The [Spezi LLM](../../SpeziLLM/SpeziLLM.docc/SpeziLLM.md) module. } } diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index a23f7b908..3ac98764b 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -11,6 +11,9 @@ import Foundation import OrderedCollections import RuntimeAssertions import SpeziFoundation +#if canImport(Observation) +import Observation +#endif #if canImport(SwiftUI) import SwiftUI #endif @@ -87,7 +90,7 @@ import SwiftUI /// ### Dynamically Loading Modules /// - ``loadModule(_:ownership:)`` /// - ``unloadModule(_:)`` -#if canImport(SwiftUI) +#if canImport(Observation) @Observable #endif @available(iOS 18, macOS 15, watchOS 11, *) @@ -98,10 +101,46 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length private let serviceGroup = ServiceModuleGroup(logger: Spezi.logger) + /// Guards `_storage`; held only for the duration of a single `storage` access + private let storageLock = RWLock() + /// A shared repository to store any `KnowledgeSource`s restricted to the ``SpeziAnchor``. /// /// Every `Module` automatically conforms to `KnowledgeSource` and is stored within this storage object. - nonisolated(unsafe) var storage: SpeziStorage // nonisolated, writes are all isolated to @MainActor, just reads are non-isolated + /// + /// - Note: All accesses are guarded by `storageLock`: reads return a snapshot taken under the read lock; + /// mutations run in place under the write lock via the `_modify` accessor. + #if canImport(Observation) + // we can't put just the `@ObservationIgnored` macro into the compiler directive + // (the issue being that the @Observation macro above won't properly pick it up), + // so we sadly need to declare the property twice... + @ObservationIgnored nonisolated(unsafe) private var _storage: SpeziStorage + #else + nonisolated(unsafe) private var _storage: SpeziStorage + #endif + + nonisolated var storage: SpeziStorage { + get { + #if canImport(Observation) + access(keyPath: \.storage) + #endif + return storageLock.withReadLock { _storage } + } + _modify { + // `yield` cannot appear inside a closure, so withMutation and withWriteLock are unrolled by hand here. + #if canImport(Observation) + _$observationRegistrar.willSet(self, keyPath: \.storage) + #endif + storageLock._pthreadWriteLock() + defer { + storageLock._pthreadUnlock() + #if canImport(Observation) + _$observationRegistrar.didSet(self, keyPath: \.storage) + #endif + } + yield &_storage + } + } #if canImport(SwiftUI) /// Key is either a UUID for `@Modifier` or `@Model` property wrappers, or a `ModuleReference` for `EnvironmentAccessible` modifiers. @@ -152,12 +191,7 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length #endif @_spi(APISupport) - @MainActor public var modules: [any Module] { - _modules - } - - @_spi(APISupport) - public var _modules: [any Module] { // swiftlint:disable:this identifier_name + public var modules: [any Module] { storage.collect(allOf: (any AnyStoredModules).self) .reduce(into: []) { partialResult, modules in partialResult.append(contentsOf: modules.anyModules) @@ -198,7 +232,7 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length storage: consuming SpeziStorage = SpeziStorage() ) { self.standard = standard - self.storage = consume storage + self._storage = consume storage do { try self.loadModules(modules, ownership: .spezi) @@ -456,28 +490,25 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length guard removed != nil else { return } - storage[CollectedModuleValues.self] = entries - for module in modules { module.injectModuleValues(from: storage) } } -#if canImport(SwiftUI) + #if canImport(SwiftUI) @MainActor func handleViewModifierRemoval(for id: UUID) { if _viewModifiers[id] != nil { _viewModifiers.removeValue(forKey: id) } } -#endif + #endif func retrieveDependencyReplacement(for type: M.Type) -> M? { guard let storedModules = storage[StoredModulesKey.self] else { return nil } - let replacement = storedModules.retrieveFirstAvailable() storedModules.removeNilReferences(in: &storage) // if we ask for a replacement, there is opportunity to clean up weak reference objects return replacement @@ -498,15 +529,8 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length @_spi(APISupport) @inlinable - @MainActor public func module(_ moduleType: M.Type = M.self) -> M? { - _module(moduleType) - } - - @_spi(APISupport) - @inlinable - public func _module(_ moduleType: M.Type = M.self) -> M? { // swiftlint:disable:this identifier_name - _modules.first { type(of: $0) == moduleType.self } as? M + modules.first { type(of: $0) == moduleType.self } as? M } } diff --git a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift index f3f0f6efe..35df1fec5 100644 --- a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift +++ b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift @@ -38,14 +38,14 @@ extension SpeziPropertyWrapper { extension Module { @MainActor func inject(spezi: Spezi) throws(SpeziPropertyError) { - for wrapper in retrieveProperties(ofType: SpeziPropertyWrapper.self) { + for wrapper in retrieveProperties(ofType: (any SpeziPropertyWrapper).self) { try wrapper.inject(spezi: spezi) } } @MainActor func clear() { - for wrapper in retrieveProperties(ofType: SpeziPropertyWrapper.self) { + for wrapper in retrieveProperties(ofType: (any SpeziPropertyWrapper).self) { wrapper.clear() } } diff --git a/Sources/SpeziAccessGuard/AccessGuards.swift b/Sources/SpeziAccessGuard/AccessGuards.swift index 59395528a..f27304957 100644 --- a/Sources/SpeziAccessGuard/AccessGuards.swift +++ b/Sources/SpeziAccessGuard/AccessGuards.swift @@ -11,7 +11,7 @@ public import Observation public import Spezi public import SpeziFoundation import SpeziKeychainStorage -public import class UIKit.UIScene +import class UIKit.UIScene /// Enforce code or biometrics-guarded access to SwiftUI views. @@ -107,7 +107,7 @@ public import class UIKit.UIScene /// - ``setupComplete(for:)`` @available(iOS 18, macOS 15, watchOS 11, *) @Observable -public final class AccessGuards: Module, EnvironmentAccessible, LifecycleHandler { +public final class AccessGuards: Module, EnvironmentAccessible { private struct ModelKey: Hashable { private let rawIdentifier: String private let identifierType: ObjectIdentifier @@ -119,6 +119,7 @@ public final class AccessGuards: Module, EnvironmentAccessible, LifecycleHandler } @ObservationIgnored @Dependency(KeychainStorage.self) var keychain + @ObservationIgnored private var lifecycleObservers: [any NSObjectProtocol] = [] private(set) var lastEnteredBackground: Date = .now private var configs: [any _AccessGuardConfig] private var models: [ModelKey: any _AnyAccessGuardModel] = [:] @@ -142,6 +143,48 @@ public final class AccessGuards: Module, EnvironmentAccessible, LifecycleHandler } preconditionFailure(errorMsg) } + + @_documentation(visibility: internal) + @MainActor + public func configure() { + guard lifecycleObservers.isEmpty else { + return + } + observeLifecycleNotification(named: UIScene.didEnterBackgroundNotification) { [weak self] in + self?.handleSceneDidEnterBackground() + } + observeLifecycleNotification(named: UIScene.willEnterForegroundNotification) { [weak self] in + self?.handleSceneWillEnterForeground() + } + } + + private func observeLifecycleNotification( + named name: Notification.Name, + handler: @escaping @MainActor @Sendable () -> Void + ) { + let observer = NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil + ) { _ in + if Thread.isMainThread { + MainActor.assumeIsolated { + handler() + } + } else { + Task { @MainActor in + handler() + } + } + } + lifecycleObservers.append(observer) + } + + deinit { + for observer in lifecycleObservers { + NotificationCenter.default.removeObserver(observer) + } + } } @@ -149,18 +192,16 @@ public final class AccessGuards: Module, EnvironmentAccessible, LifecycleHandler @available(iOS 18, macOS 15, watchOS 11, *) extension AccessGuards { - @_documentation(visibility: internal) @MainActor - public func sceneDidEnterBackground(_ scene: UIScene) { // swiftlint:disable:this missing_docs + private func handleSceneDidEnterBackground() { lastEnteredBackground = .now for model in models.values { model.didEnterBackground() } } - @_documentation(visibility: internal) @MainActor - public func sceneWillEnterForeground(_ scene: UIScene) { // swiftlint:disable:this missing_docs + private func handleSceneWillEnterForeground() { for model in models.values { model.willEnterForeground(lastEnteredBackground: lastEnteredBackground) } diff --git a/Sources/SpeziAccessGuard/CONTRIBUTORS.md b/Sources/SpeziAccessGuard/CONTRIBUTORS.md index 95110e2d9..388685653 100644 --- a/Sources/SpeziAccessGuard/CONTRIBUTORS.md +++ b/Sources/SpeziAccessGuard/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziAccessGuard contributors -==================== +# SpeziAccessGuard contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziAccount/AccountOverview.swift b/Sources/SpeziAccount/AccountOverview.swift index eb5054166..25dc3bc01 100644 --- a/Sources/SpeziAccount/AccountOverview.swift +++ b/Sources/SpeziAccount/AccountOverview.swift @@ -54,7 +54,7 @@ import SwiftUI /// ### Configuration /// - ``CloseBehavior`` /// - ``AccountDeletionBehavior`` -/// - ``init(close:deletion:additionalSections:)`` +/// - ``init(close:logout:deletion:additionalSections:)`` @available(iOS 18, macOS 15, watchOS 11, *) @available(macOS, unavailable) @available(watchOS, unavailable) @@ -202,19 +202,40 @@ public struct AccountOverview: View { /// - Parameters: /// - closeBehavior: Define the behavior of the close button that can be rendered in the toolbar. This is useful when presenting the AccountOverview /// as a sheet. Disabled by default. + /// - logoutBehavior: Define how the Account Overview offers the user to log out. Enabled by default. /// - deletionBehavior: Define how the Account Overview offers the user to delete their account. By default the Logout button turns into a delete button when entering edit mode. /// - additionalSections: Optional additional sections displayed between the other AccountOverview information and the log out button. public init( close closeBehavior: CloseBehavior = .disabled, logout logoutBehavior: AccountLogoutBehavior = .enabled, deletion deletionBehavior: AccountDeletionBehavior = .inEditMode, - @ViewBuilder additionalSections: () -> AdditionalSections = { EmptyView() } + @ViewBuilder additionalSections: () -> AdditionalSections ) { self.closeBehavior = closeBehavior self.logoutBehavior = logoutBehavior self.deletionBehavior = deletionBehavior self.additionalSections = additionalSections() } + + + /// Display a new Account Overview. + /// - Parameters: + /// - closeBehavior: Define the behavior of the close button that can be rendered in the toolbar. This is useful when presenting the AccountOverview + /// as a sheet. Disabled by default. + /// - logoutBehavior: Define how the Account Overview offers the user to log out. Enabled by default. + /// - deletionBehavior: Define how the Account Overview offers the user to delete their account. By default the Logout button turns into a delete button when entering edit mode. + public init( + close closeBehavior: CloseBehavior = .disabled, + logout logoutBehavior: AccountLogoutBehavior = .enabled, + deletion deletionBehavior: AccountDeletionBehavior = .inEditMode + ) where AdditionalSections == EmptyView { + self.init( + close: closeBehavior, + logout: logoutBehavior, + deletion: deletionBehavior, + additionalSections: EmptyView.init + ) + } } diff --git a/Sources/SpeziAccount/CONTRIBUTORS.md b/Sources/SpeziAccount/CONTRIBUTORS.md index 934e484a5..379fa71f4 100644 --- a/Sources/SpeziAccount/CONTRIBUTORS.md +++ b/Sources/SpeziAccount/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -Spezi Account Contributors -==================== +# Spezi Account Contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziAccount/Mock/InMemoryAccountService.swift b/Sources/SpeziAccount/Mock/InMemoryAccountService.swift index 040d8f143..e4e3c4939 100644 --- a/Sources/SpeziAccount/Mock/InMemoryAccountService.swift +++ b/Sources/SpeziAccount/Mock/InMemoryAccountService.swift @@ -94,6 +94,8 @@ private struct MockSecurityAlert: ViewModifier { } } + nonisolated init() {} + func body(content: Content) -> some View { content .onAppear { @@ -205,7 +207,11 @@ public final class InMemoryAccountService: AccountService { continue } - try await access.waitCheckingCancellation() + do { + try await access.waitCheckingCancellation() + } catch { + return + } var details = _buildUser(from: storage, isNew: false) details.add(contentsOf: updatedDetails.details) account.supplyUserDetails(details) diff --git a/Sources/SpeziAccount/Mock/InMemoryAccountStorageProvider.swift b/Sources/SpeziAccount/Mock/InMemoryAccountStorageProvider.swift index 58bef1933..4460a69cc 100644 --- a/Sources/SpeziAccount/Mock/InMemoryAccountStorageProvider.swift +++ b/Sources/SpeziAccount/Mock/InMemoryAccountStorageProvider.swift @@ -47,7 +47,7 @@ public actor InMemoryAccountStorageProvider: AccountStorageProvider, Environment // simulate loading from external storage Task { - try await Task.sleep(for: .seconds(1)) + try? await Task.sleep(for: .seconds(1)) let details = records[accountId] ?? AccountDetails() diff --git a/Sources/SpeziAccount/SpeziAccount.docc/AccountKey/Adding new Account Values.md b/Sources/SpeziAccount/SpeziAccount.docc/AccountKey/Adding new Account Values.md index 221f2833c..8cfb85dff 100644 --- a/Sources/SpeziAccount/SpeziAccount.docc/AccountKey/Adding new Account Values.md +++ b/Sources/SpeziAccount/SpeziAccount.docc/AccountKey/Adding new Account Values.md @@ -23,7 +23,7 @@ This articles guides you through all the necessary steps to declare your custom ### Declaring the property -You use the ``AccountKey(id:name:category:as:initial:displayView:entryView:)`` macro to declare a new ``AccountKey``. +You use the `@AccountKey` macro to declare a new ``AccountKey``. It is mandatory to provide a localizable ``AccountKey/name`` and the `Value` type. > Note: Refer to to learn more of the mandatory conformances for the `Value` type. @@ -171,11 +171,7 @@ Still, you are required to evaluate to which extent validation has to be handled ### Account Key Declaration -- ``AccountKey(id:name:category:as:initial:displayView:entryView:)`` -- ``AccountKey(id:name:category:as:displayView:entryView:)-7hix5`` -- ``AccountKey(id:name:category:as:displayView:entryView:)-2hptl`` -- ``AccountKey(id:name:category:as:displayView:entryView:)-73ut1`` -- ``AccountKey(id:name:category:as:displayView:entryView:)-945ks`` +- ``AccountKey`` ### Key Entry Declaration diff --git a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md index 84d71d088..036622af7 100644 --- a/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md +++ b/Sources/SpeziAccount/SpeziAccount.docc/Setup Guides/Initial Setup.md @@ -44,7 +44,7 @@ class MyAppDelegate: SpeziAppDelegate { } ``` -> Note: You may also use the ``ConfiguredAccountKey/supports(_:)-7wwdi`` configuration to mark a ``AccountKey`` as +> Note: You may also use the ``ConfiguredAccountKey/supports(_:file:line:)-5n6kv`` configuration to mark a ``AccountKey`` as ``AccountKeyRequirement/supported``. Such account keys are not collected during signup but may be added when editing your account information later on in the account overview. diff --git a/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift b/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift index 0654c12ae..5cecb2a92 100644 --- a/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift +++ b/Sources/SpeziAccount/ViewModifier/RequiredValidationModifier.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import SpeziFoundation import SpeziValidation import SwiftUI diff --git a/Sources/SpeziAccount/Views/AccountSetup/AccountSetupState.swift b/Sources/SpeziAccount/Views/AccountSetup/AccountSetupState.swift index 0fa4531a1..557e85775 100644 --- a/Sources/SpeziAccount/Views/AccountSetup/AccountSetupState.swift +++ b/Sources/SpeziAccount/Views/AccountSetup/AccountSetupState.swift @@ -97,7 +97,7 @@ extension EnvironmentValues { /// The current account setup state. /// /// This environment property can be retrieved for child views of the ``AccountSetup`` view to determine the current setup state. - /// Use this in the `Header` view passed to ``AccountSetup/init(setupComplete:header:`continue`:)``. + /// Use this in the `Header` view passed to ``AccountSetup/init(setupComplete:header:continue:)``. @Entry public var accountSetupState: AccountSetupState = .presentingSignup /// The current account setup state. diff --git a/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift index f75b25268..a2d9a1200 100644 --- a/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift +++ b/Sources/SpeziAccount/Views/DataEntry/GeneralizedDataEntryView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import SpeziFoundation import SpeziValidation import SwiftUI diff --git a/Sources/SpeziAccount/Views/PasswordResetView.swift b/Sources/SpeziAccount/Views/PasswordResetView.swift index 7980e00a7..44c6fa486 100644 --- a/Sources/SpeziAccount/Views/PasswordResetView.swift +++ b/Sources/SpeziAccount/Views/PasswordResetView.swift @@ -160,9 +160,8 @@ public struct PasswordResetView: View { } Task { - // we are creating a detached task, as otherwise this one might be cancelled - // as the view update above results in our current ask getting freed - try await Task.sleep(for: .milliseconds(515)) + // Keep the reset delay independent of the task owned by this view. + try? await Task.sleep(for: .milliseconds(515)) state = .idle } } diff --git a/Sources/SpeziAccountPhoneNumbers/DataEntry/OTCEntryView.swift b/Sources/SpeziAccountPhoneNumbers/DataEntry/OTCEntryView.swift index 346d70352..1786cb539 100644 --- a/Sources/SpeziAccountPhoneNumbers/DataEntry/OTCEntryView.swift +++ b/Sources/SpeziAccountPhoneNumbers/DataEntry/OTCEntryView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Combine import SpeziAccount import SpeziViews import SwiftUI diff --git a/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift b/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift index 5ba210a6f..fa8197125 100644 --- a/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift +++ b/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift @@ -15,17 +15,17 @@ import Spezi /// Adopt this protocol in your Standard to implement phone number verification services. /// This protocol defines the interface for starting and completing phone verification processes. /// -/// - Note: The `data` parameter in both methods typically contains: -/// - For `startVerification`: The phone number to verify -/// - For `completeVerification`: The verification code to validate +/// - Note: The `number` parameter in both methods identifies the phone number being verified. public protocol PhoneVerificationConstraint: Standard { /// Starts the phone verification process. - /// - Parameter data: Dictionary containing verification data, typically including the phone number. + /// - Parameter number: The phone number to verify. /// - Throws: An error if the verification process cannot be started. func startVerification(_ number: PhoneNumber) async throws - + /// Completes the phone verification process. - /// - Parameter data: Dictionary containing verification data, typically including the verification code. + /// - Parameters: + /// - number: The phone number being verified. + /// - code: The verification code to validate. /// - Throws: An error if the verification process cannot be completed. func completeVerification(_ number: PhoneNumber, _ code: String) async throws diff --git a/Sources/SpeziAccountPhoneNumbers/SpeziAccountPhoneNumbers.docc/SpeziAccountPhoneNumbers.md b/Sources/SpeziAccountPhoneNumbers/SpeziAccountPhoneNumbers.docc/SpeziAccountPhoneNumbers.md index 4b29adca9..3e05412e3 100644 --- a/Sources/SpeziAccountPhoneNumbers/SpeziAccountPhoneNumbers.docc/SpeziAccountPhoneNumbers.md +++ b/Sources/SpeziAccountPhoneNumbers/SpeziAccountPhoneNumbers.docc/SpeziAccountPhoneNumbers.md @@ -18,7 +18,7 @@ SPDX-License-Identifier: MIT [Spezi](../../Spezi/Spezi.docc/Spezi.md) framework ecosystem, enabling users to add, verify, and manage phone numbers in their accounts. The framework integrates with the `SpeziAccount` framework to provide a seamless phone number management experience. -It uses the [PhoneNumberKit](https://github.com/marmelroy/PhoneNumberKit) library for robust phone number validation and formatting. +It uses the [PhoneNumberKit](https://github.com/PhoneNumberKit/PhoneNumberKit) library for robust phone number validation and formatting. ## Setup @@ -102,20 +102,8 @@ actor YourStandard: PhoneVerificationConstraint { ## Topics -### Storage +### Phone Verification - ``PhoneVerificationProvider`` - ``PhoneVerificationConstraint`` - -### Views - -- ``PhoneNumbersDetailView`` -- ``PhoneNumberSteps`` -- ``PhoneNumberEntryField`` -- ``CountryListSheet`` - -### Models - -- ``PhoneNumberViewModel`` -- ``StartVerificationRequest`` -- ``CompleteVerificationRequest`` +- ``SpeziAccount/AccountDetails/PhoneNumbersArray`` diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift b/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift index 8cc0a269f..855e92f11 100644 --- a/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift +++ b/Sources/SpeziBluetooth/AccessorySetupKit/ASAccessoryEventType+Description.swift @@ -27,6 +27,8 @@ extension ASAccessoryEventType: @retroactive CustomStringConvertible, @retroacti "accessoryRemoved" case .accessoryChanged: "accessoryChanged" + case .accessoryDiscovered: + "accessoryDiscovered" case .pickerDidPresent: "pickerDidPresent" case .pickerDidDismiss: diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift index f9b909add..fcb1e09d4 100644 --- a/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift +++ b/Sources/SpeziBluetooth/AccessorySetupKit/AccessoryEventRegistration.swift @@ -25,19 +25,14 @@ public struct AccessoryEventRegistration: ~Copyable, Sendable { } static func cancel(id: UUID, setupKit: (AnyObject & Sendable)?, isolation: isolated (any Actor)? = #isolation) { -#if os(iOS) && !targetEnvironment(macCatalyst) - guard #available(iOS 18, *) else { - return - } - + #if os(iOS) && !targetEnvironment(macCatalyst) guard let setupKit, let typedSetupKit = setupKit as? AccessorySetupKit else { return } - typedSetupKit.cancelHandler(for: id) -#else + #else preconditionFailure("Not available on this platform!") -#endif + #endif } /// Cancel the subscription. diff --git a/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift index 4e6356974..d053562af 100644 --- a/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift +++ b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift @@ -294,6 +294,8 @@ public final class AccessorySetupKit { } state.withMutation(keyPath: \.accessories) {} callHandler(with: .changed(accessory)) + case .accessoryDiscovered: + state.withMutation(keyPath: \.accessories) {} case .pickerDidPresent: Task { @MainActor in state.pickerPresented = true diff --git a/Sources/SpeziBluetooth/CONTRIBUTORS.md b/Sources/SpeziBluetooth/CONTRIBUTORS.md index 659554238..41a1822bb 100644 --- a/Sources/SpeziBluetooth/CONTRIBUTORS.md +++ b/Sources/SpeziBluetooth/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziBluetooth contributors -==================== +# SpeziBluetooth contributors * [Andreas Bauer](https://github.com/bauer-andreas) * [Lukas Kollmer](https://github.com/lukaskollmer) diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift index b24ad3308..ef144eb49 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothManager.swift @@ -645,7 +645,7 @@ extension BluetoothManager { // order and make sure to capture all important state before that. // // Note: this is now possible in Swift 6 when running on iOS 18 versions. However, we currently maintain backwards compatibility. - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in manager.storage.update(state: state) logger.info("BluetoothManager central state is now \(manager.state)") @@ -685,7 +685,7 @@ extension BluetoothManager { let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger, data] in + SpeziBluetooth.assumeIsolated { [logger, data] in guard let session = manager.discoverySession, manager.isScanning else { return @@ -736,7 +736,7 @@ extension BluetoothManager { } let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didConnect for unknown peripheral \(peripheral.debugIdentifier). Cancelling connection ...") manager.centralManager.cancelPeripheralConnection(peripheral.cbObject) @@ -762,7 +762,7 @@ extension BluetoothManager { let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.warning("Unknown peripheral \(peripheral.debugIdentifier) failed with error: \(String(describing: error))") manager.centralManager.cancelPeripheralConnection(peripheral.cbObject) @@ -789,7 +789,7 @@ extension BluetoothManager { } let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in guard let device = manager.knownPeripheral(for: peripheral.identifier) else { logger.error("Received didDisconnect for unknown peripheral \(peripheral.debugIdentifier).") return diff --git a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift index 53276d5ae..e26691fdf 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/BluetoothPeripheral.swift @@ -887,7 +887,7 @@ extension BluetoothPeripheral { let name = peripheral.name - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { device.storage.peripheralName = name } } @@ -897,7 +897,7 @@ extension BluetoothPeripheral { return } - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { let rssi = RSSI.intValue device.storage.rssi = rssi @@ -923,7 +923,7 @@ extension BluetoothPeripheral { logger.debug("Services modified, invalidating \(serviceIds)") let peripheral = CBInstance(instantiatedOnDispatchQueue: peripheral) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { // update our local model! device.invalidateServices(Set(serviceIds)) @@ -951,7 +951,7 @@ extension BluetoothPeripheral { result = .success([]) } - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { if let cbServices { device.discovered(services: cbServices.cbObject) } @@ -979,7 +979,7 @@ extension BluetoothPeripheral { } let service = CBInstance(instantiatedOnDispatchQueue: service) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { // update our model with latest characteristics! device.synchronizeModel(for: service.cbObject) @@ -1007,7 +1007,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { device.synchronizeModel(for: characteristic.cbObject, capture: capture) } } @@ -1020,7 +1020,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in // make sure value is propagated beforehand device.synchronizeModel(for: characteristic.cbObject, capture: capture) @@ -1041,7 +1041,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) let result: Result @@ -1065,7 +1065,7 @@ extension BluetoothPeripheral { return } - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { device.writeWithoutResponseAccess.resume() } } @@ -1087,7 +1087,7 @@ extension BluetoothPeripheral { let capture = GATTCharacteristicCapture(from: characteristic) let characteristic = CBInstance(instantiatedOnDispatchQueue: characteristic) - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { [logger] in + SpeziBluetooth.assumeIsolated { [logger] in device.synchronizeModel(for: characteristic.cbObject, capture: capture) if error == nil { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index d263ff32d..9e4759df6 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -164,7 +164,7 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { extension BluetoothManagerStorage { var stateSubscription: AsyncStream { AsyncStream(BluetoothState.self) { continuation in - Task { @SpeziBluetooth in + Task { @SpeziBluetooth [self] in let id = subscribe(continuation) continuation.onTermination = { @Sendable [weak self] _ in guard let self = self else { diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift index 147c9aff4..ed9798090 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Model/CharacteristicAccesses.swift @@ -85,7 +85,7 @@ final class CharacteristicAccesses: Sendable { private func perform( for characteristic: CBCharacteristic, - returning value: Value.Type = Void.self, + returning _: Value.Type, action: () -> Void, mapping: (CheckedContinuation) -> CharacteristicAccess.Access ) async throws -> Value { @@ -105,13 +105,13 @@ final class CharacteristicAccesses: Sendable { } func performWrite(for characteristic: CBCharacteristic, action: () -> Void) async throws { - try await self.perform(for: characteristic, action: action) { continuation in + try await self.perform(for: characteristic, returning: Void.self, action: action) { continuation in .write(continuation) } } func performNotify(for characteristic: CBCharacteristic, action: () -> Void) async throws { - try await self.perform(for: characteristic, action: action) { continuation in + try await self.perform(for: characteristic, returning: Void.self, action: action) { continuation in .notify(continuation) } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift index b9148d968..8b9560ce9 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/BluetoothWorkItem.swift @@ -15,7 +15,7 @@ final class BluetoothWorkItem { init(handler: @SpeziBluetooth @escaping @Sendable () -> Void) { self.workItem = DispatchWorkItem { - SpeziBluetooth.assumeIsolatedIfAvailableOrTask { + SpeziBluetooth.assumeIsolated { handler() } } diff --git a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift index 0a8934b82..fe2df13b2 100644 --- a/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift +++ b/Sources/SpeziBluetooth/CoreBluetooth/Utilities/SpeziBluetoothActor.swift @@ -83,7 +83,7 @@ public actor SpeziBluetooth { } -@available(iOS 18, macOS 15, watchOS 11, *) +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension SpeziBluetooth { /// Assume isolation to the global `SpeziBluetooth` actor. /// - Parameters: @@ -91,7 +91,6 @@ extension SpeziBluetooth { /// - file: The file in which this method is called. /// - line: The line in which this method is called. /// - Returns: Returns `T` from the `operation`. - @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) @_alwaysEmitIntoClient public static func assumeIsolated( _ operation: @SpeziBluetooth () throws -> T, @@ -116,20 +115,3 @@ extension SpeziBluetooth { } } } - - -@available(iOS 18, macOS 15, watchOS 11, *) -extension SpeziBluetooth { - @_alwaysEmitIntoClient - static func assumeIsolatedIfAvailableOrTask( - _ operation: @SpeziBluetooth @escaping () -> Void, - file: StaticString = #fileID, - line: UInt = #line - ) { - if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { - assumeIsolated(operation, file: file, line: line) - } else { - Task(operation: operation) - } - } -} diff --git a/Sources/SpeziChat/CONTRIBUTORS.md b/Sources/SpeziChat/CONTRIBUTORS.md index 19588dd1e..821c51b91 100644 --- a/Sources/SpeziChat/CONTRIBUTORS.md +++ b/Sources/SpeziChat/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziChat contributors -==================== +# SpeziChat contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziChat/MessageInputView.swift b/Sources/SpeziChat/MessageInputView.swift index 90b88c4b5..94a88f2a7 100644 --- a/Sources/SpeziChat/MessageInputView.swift +++ b/Sources/SpeziChat/MessageInputView.swift @@ -217,6 +217,8 @@ public struct MessageInputView: View { message = result.bestTranscription.formattedString } } + } catch { + // Treat speech recognition failures as a stopped recording session. } } } diff --git a/Sources/SpeziConsent/CONTRIBUTORS.md b/Sources/SpeziConsent/CONTRIBUTORS.md index 2983d5180..84f4b50fe 100644 --- a/Sources/SpeziConsent/CONTRIBUTORS.md +++ b/Sources/SpeziConsent/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziConsent contributors -==================== +# SpeziConsent contributors * [Lukas Kollmer](https://github.com/lukaskollmer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziConsent/Model/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziConsent/Model/ConsentDocument+ExportConfiguration.swift index 019899459..4e8966f77 100644 --- a/Sources/SpeziConsent/Model/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziConsent/Model/ConsentDocument+ExportConfiguration.swift @@ -17,7 +17,7 @@ extension ConsentDocument { public struct ExportConfiguration: Equatable, Sendable { /// Represents common paper sizes with their dimensions. /// - /// You can use the ``dimensions`` property to get the width and height of each paper size in points. + /// The dimensions are used to derive the width and height of each paper size in points. /// /// - Note: The dimensions are calculated based on the standard DPI (dots per inch) of 72 for print. public enum PaperSize: Equatable, Sendable { diff --git a/Sources/SpeziConsent/Model/ConsentDocument.swift b/Sources/SpeziConsent/Model/ConsentDocument.swift index 9afa0955b..fa29f0595 100644 --- a/Sources/SpeziConsent/Model/ConsentDocument.swift +++ b/Sources/SpeziConsent/Model/ConsentDocument.swift @@ -123,8 +123,9 @@ import SwiftUI /// ## Topics /// /// ### Creating Consent Documents -/// - ``init(markdown:initialName:enableCustomElements:)-(String,_,_)`` -/// - ``init(contentsOf:initialName:enableCustomElements:)`` +/// - ``init(markdown:initialName:)-(String,_)`` +/// - ``init(markdown:initialName:)-(Data,_)`` +/// - ``init(contentsOf:initialName:)`` /// - ``LoadError`` /// /// ### Accessing Form Contents @@ -146,8 +147,6 @@ import SwiftUI /// - ``ExportResult`` /// - ``isExporting`` /// -/// ### Other -/// - ``customElementsEnabled`` @available(iOS 18, macOS 15, watchOS 11, *) @Observable @MainActor @@ -159,9 +158,7 @@ public final class ConsentDocument: Sendable { /// - ``inputNotUTF8`` /// - ``failedToParse(_:)`` /// - ``duplicateCustomElementId(_:)`` - /// - /// ### Other - /// - ``ConsentParseError`` + /// public enum LoadError: Error { /// The input was not valid UTF-8-encoded text. case inputNotUTF8 diff --git a/Sources/SpeziContact/CONTRIBUTORS.md b/Sources/SpeziContact/CONTRIBUTORS.md index 4d3be8c67..9bee22437 100644 --- a/Sources/SpeziContact/CONTRIBUTORS.md +++ b/Sources/SpeziContact/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziContact contributors -==================== +# SpeziContact contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziContact/Contact Views/ContactView.swift b/Sources/SpeziContact/Contact Views/ContactView.swift index 90bcf5507..453330767 100644 --- a/Sources/SpeziContact/Contact Views/ContactView.swift +++ b/Sources/SpeziContact/Contact Views/ContactView.swift @@ -7,7 +7,6 @@ // import Contacts -@_implementationOnly import MessageUI import SpeziPersonalInfo import SpeziViews import SwiftUI diff --git a/Sources/SpeziContact/Contact Views/ContactsList.swift b/Sources/SpeziContact/Contact Views/ContactsList.swift index 2142be86c..b02ebce7c 100644 --- a/Sources/SpeziContact/Contact Views/ContactsList.swift +++ b/Sources/SpeziContact/Contact Views/ContactsList.swift @@ -35,7 +35,7 @@ public struct ContactsList: View { /// Create a view displaying a list of multiple `Contact`s. - /// - Parameter contact: The `Contact` instances to populate the list. + /// - Parameter contacts: The `Contact` instances to populate the list. public init(contacts: [Contact]) { self.contacts = contacts } diff --git a/Sources/SpeziContact/Models/ContactOption.swift b/Sources/SpeziContact/Models/ContactOption.swift index 9e9a6068b..c59df1109 100644 --- a/Sources/SpeziContact/Models/ContactOption.swift +++ b/Sources/SpeziContact/Models/ContactOption.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -@_implementationOnly import MessageUI import SwiftUI diff --git a/Sources/SpeziDevices/CONTRIBUTORS.md b/Sources/SpeziDevices/CONTRIBUTORS.md index 9f8307e03..21fd7ad5c 100644 --- a/Sources/SpeziDevices/CONTRIBUTORS.md +++ b/Sources/SpeziDevices/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -TemplatePackage contributors -==================== +# TemplatePackage contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index 54d4d1954..67d4108ba 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -97,6 +97,7 @@ public final class PairedDeviceInfo { /// - name: The device name. /// - model: A model string. /// - icon: The device icon. + /// - variantIdentifier: An optional identifier for the device variant. /// - lastSeen: The date the device was last seen. /// - batteryPercentage: The last known battery percentage of the device. public init( diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index d2bd11ced..37fb71062 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -216,13 +216,11 @@ public final class PairedDevices: ServiceModule { public required init() { self.internalEvents = AsyncStream.makeStream() #if canImport(AccessorySetupKit) && !os(macOS) && !targetEnvironment(macCatalyst) - if #available(iOS 18, *) { - if AccessorySetupKit.supportedProtocols.contains(.bluetooth) { - __accessorySetup = Dependency { - // Dynamic dependencies are always loaded independent if the module was already supplied in the environment. - // Therefore, we create a helper module, that loads the accessory setup kit module. - LoadAccessorySetupKit() - } + if AccessorySetupKit.supportedProtocols.contains(.bluetooth) { + __accessorySetup = Dependency { + // Dynamic dependencies are always loaded independent if the module was already supplied in the environment. + // Therefore, we create a helper module, that loads the accessory setup kit module. + LoadAccessorySetupKit() } } #endif @@ -258,12 +256,10 @@ public final class PairedDevices: ServiceModule { var powerUpUsingASKit = false #if canImport(AccessorySetupKit) && !os(macOS) && !targetEnvironment(macCatalyst) - if #available(iOS 18, *) { - if accessorySetup != nil { - powerUpUsingASKit = true - } else { - logger.info("AccessorySetupKit is supported by the platform but `NSAccessorySetupKitSupports` doesn't declare support for Bluetooth.") - } + if accessorySetup != nil { + powerUpUsingASKit = true + } else { + logger.info("AccessorySetupKit is supported by the platform but `NSAccessorySetupKitSupports` doesn't declare support for Bluetooth.") } #endif @@ -279,9 +275,7 @@ public final class PairedDevices: ServiceModule { #if canImport(AccessorySetupKit) && !os(macOS) && !targetEnvironment(macCatalyst) // power up accessory setup kit after we determined the migration state - if #available(iOS 18, *) { - setupAccessoryChangeSubscription() - } + setupAccessoryChangeSubscription() #endif } else { asKitMigrationState = .notDetermined // support downgrades @@ -332,7 +326,7 @@ public final class PairedDevices: ServiceModule { @MainActor public func showAccessoryDiscovery() { #if canImport(AccessorySetupKit) && !os(macOS) && !targetEnvironment(macCatalyst) - if #available(iOS 18, *), accessorySetup != nil { + if accessorySetup != nil { showAccessorySetupPicker() } else { shouldPresentDevicePairing = true @@ -663,15 +657,11 @@ extension PairedDevices { public func forgetDevice(id: UUID) async throws { #if canImport(AccessorySetupKit) && !os(macOS) && !targetEnvironment(macCatalyst) let externallyManaged: Bool - if #available(iOS 18, *) { - if let accessorySetup, - let accessory = accessorySetup.accessories.first(where: { $0.bluetoothIdentifier == id }) { - // this will trigger a disconnect - try await accessorySetup.removeAccessory(accessory) - externallyManaged = true - } else { - externallyManaged = false - } + if let accessorySetup, + let accessory = accessorySetup.accessories.first(where: { $0.bluetoothIdentifier == id }) { + // this will trigger a disconnect + try await accessorySetup.removeAccessory(accessory) + externallyManaged = true } else { externallyManaged = false } diff --git a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit Integration.md similarity index 99% rename from Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md rename to Sources/SpeziDevices/SpeziDevices.docc/HealthKit Integration.md index f3f468445..0e5cfebf5 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit Integration.md @@ -1,4 +1,4 @@ -# HealthKit +# HealthKit Integration Convert Bluetooth measurement types to HealthKit samples. diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md index 5273d940f..16e5669db 100644 --- a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -132,7 +132,6 @@ struct MyHomeView: View { - ``PairedDevices`` - ``PairedDeviceInfo`` - ``DevicePairingError`` -- ``ImageReference`` ### Devices @@ -146,5 +145,5 @@ struct MyHomeView: View { - ``HealthMeasurements`` - ``HealthDevice`` - ``BluetoothHealthMeasurement`` -- +- - ``HealthKitMeasurement`` diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift index 4a6c78344..05a29d913 100644 --- a/Sources/SpeziDevices/Testing/MockDevice.swift +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -61,7 +61,7 @@ public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevic if isInPairingMode { // automatically respond to pairing event if case .connected = state { Task { @MainActor in - try await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: .seconds(2)) guard case .connected = self.state else { return diff --git a/Sources/SpeziFHIR/CONTRIBUTORS.md b/Sources/SpeziFHIR/CONTRIBUTORS.md index 121009002..62c944cba 100644 --- a/Sources/SpeziFHIR/CONTRIBUTORS.md +++ b/Sources/SpeziFHIR/CONTRIBUTORS.md @@ -8,7 +8,6 @@ SPDX-License-Identifier: MIT --> -Spezi FHIR contributors -==================== +# Spezi FHIR contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) diff --git a/Sources/SpeziFHIR/FHIRResource/FHIRResource+StringifyAttachment.swift b/Sources/SpeziFHIR/FHIRResource/FHIRResource+StringifyAttachment.swift index f9ab9d798..236d94a8c 100644 --- a/Sources/SpeziFHIR/FHIRResource/FHIRResource+StringifyAttachment.swift +++ b/Sources/SpeziFHIR/FHIRResource/FHIRResource+StringifyAttachment.swift @@ -11,7 +11,7 @@ import ModelsR4 extension FHIRResource { - /// Best effort function to transform the base64 data representatino of any ``FHIRAttachment`` to a string-based respresentation of the data type. + /// Best effort function to transform the base64 data representation of a FHIR attachment to a string-based representation of the data type. /// /// This funcationality is especially useful if the data content is inspected for debug purposes or passing it ot a LLM component. public func stringifyAttachments() throws { diff --git a/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift b/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift index 866fb956f..a35ee972f 100644 --- a/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift +++ b/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift @@ -225,9 +225,9 @@ public struct FHIRResource: Identifiable, Hashable { switch versionedResource { case let .r4(resource): - return (try? String(decoding: encoder.encode(resource), as: UTF8.self)) ?? "{}" + return (try? encoder.encode(resource)).map { String(decoding: $0, as: UTF8.self) } ?? "{}" case let .dstu2(resource): - return (try? String(decoding: encoder.encode(resource), as: UTF8.self)) ?? "{}" + return (try? encoder.encode(resource)).map { String(decoding: $0, as: UTF8.self) } ?? "{}" } } } diff --git a/Sources/SpeziFHIR/FHIRStore.swift b/Sources/SpeziFHIR/FHIRStore.swift index e9ad1b1ef..67e61d697 100644 --- a/Sources/SpeziFHIR/FHIRStore.swift +++ b/Sources/SpeziFHIR/FHIRStore.swift @@ -122,7 +122,7 @@ extension FHIRStore { /// /// Any resources that already exist in the store will be skipped; only new resources will be inserted /// - /// - Parameter resources: The `FHIRResource`s to be inserted. + /// - Parameter resourcesToInsert: The `FHIRResource`s to be inserted. @MainActor public func insert(contentsOf resourcesToInsert: some Sequence) { let resourcesToInsert = resourcesToInsert.filter { !self._resources.contains($0) } diff --git a/Sources/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift b/Sources/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift index 0e082df6c..0c86f0913 100644 --- a/Sources/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift +++ b/Sources/SpeziFHIRHealthKit/FHIRStore+HealthKit.swift @@ -32,7 +32,7 @@ extension FHIRStore { } /// Remove a HealthKit sample delete object from the FHIR store. - /// - Parameter sample: The sample delete object that should be removed. + /// - Parameter deletedObject: The sample delete object that should be removed. @MainActor public func remove(_ deletedObject: HKDeletedObject) { removeResource(withHealthKitUUID: deletedObject.uuid.uuidString) diff --git a/Sources/SpeziFileFormats/CONTRIBUTORS.md b/Sources/SpeziFileFormats/CONTRIBUTORS.md index 0d5969fc6..d65d744fd 100644 --- a/Sources/SpeziFileFormats/CONTRIBUTORS.md +++ b/Sources/SpeziFileFormats/CONTRIBUTORS.md @@ -10,7 +10,6 @@ --> -SpeziFileFormats contributors -==================== +# SpeziFileFormats contributors * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziFirebase/CONTRIBUTORS.md b/Sources/SpeziFirebase/CONTRIBUTORS.md index 9683c2a6b..c7069fab1 100644 --- a/Sources/SpeziFirebase/CONTRIBUTORS.md +++ b/Sources/SpeziFirebase/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -Spezi Firebase contributors -==================== +# Spezi Firebase contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Philipp Zagar](https://github.com/philippzagar) \ No newline at end of file diff --git a/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md b/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md index 7b27ad7f2..2ff2f70e3 100644 --- a/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md +++ b/Sources/SpeziFirestore/SpeziFirestore.docc/SpeziFirestore.md @@ -39,9 +39,9 @@ class FirestoreExampleDelegate: SpeziAppDelegate { ### Document Reference -- ``FirebaseFirestoreInternal/DocumentReference/setData(from:encoder:)`` -- ``FirebaseFirestoreInternal/DocumentReference/setData(from:merge:encoder:)`` -- ``FirebaseFirestoreInternal/DocumentReference/setData(from:mergeFields:encoder:)`` +- ``FirebaseFirestoreInternal/DocumentReference/setData(isolation:from:encoder:)`` +- ``FirebaseFirestoreInternal/DocumentReference/setData(isolation:from:merge:encoder:)`` +- ``FirebaseFirestoreInternal/DocumentReference/setData(isolation:from:mergeFields:encoder:)`` ### Errors diff --git a/Sources/SpeziFoundation/CONTRIBUTORS.md b/Sources/SpeziFoundation/CONTRIBUTORS.md index d88a995c9..574194f81 100644 --- a/Sources/SpeziFoundation/CONTRIBUTORS.md +++ b/Sources/SpeziFoundation/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziFoundation contributors -==================== +# SpeziFoundation contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziFoundation/LocalPreferences/LocalPreference.swift b/Sources/SpeziFoundation/LocalPreferences/LocalPreference.swift index 6b24fb322..eb6ee52b1 100644 --- a/Sources/SpeziFoundation/LocalPreferences/LocalPreference.swift +++ b/Sources/SpeziFoundation/LocalPreferences/LocalPreference.swift @@ -68,7 +68,7 @@ public struct LocalPreference: DynamicProperty, Sendable { private let key: LocalPreferenceKey private let store: LocalPreferencesStore @State private var observer = UserDefaultsKeyObserver() - + /// The current value of the local preference.. public var wrappedValue: T { get { @@ -79,7 +79,7 @@ public struct LocalPreference: DynamicProperty, Sendable { store[key] = newValue } } - + /// A `Binding` that provides read-write access to the value. public var projectedValue: Binding { _ = observer.viewUpdate @@ -89,18 +89,18 @@ public struct LocalPreference: DynamicProperty, Sendable { store[key] = $0 } } - + /// Creates a property for a local preference value. nonisolated public init(_ key: LocalPreferenceKey) { self.init(key, store: .standard) } - + /// Creates a property for a local preference value in a custom preferences store. nonisolated public init(_ key: LocalPreferenceKey, store: LocalPreferencesStore) { self.key = key self.store = store } - + @_documentation(visibility: internal) nonisolated public func update() { observer.configure(for: key, in: store) @@ -130,18 +130,23 @@ private final class UserDefaultsKeyObserver: NSObject, Send let config: Config let observation: ObservationInfo } - + private let lock = RWLock() @ObservationIgnored nonisolated(unsafe) private var state: State? - // https://github.com/swiftlang/swift/issues/81962 - nonisolated(unsafe) private(set) var viewUpdate: UInt64 = 0 + // Work around https://github.com/swiftlang/swift/issues/81962 by keeping the backing storage out of macro expansion + // while explicitly preserving observation tracking. + @ObservationIgnored nonisolated(unsafe) private var _viewUpdate: UInt64 = 0 + nonisolated var viewUpdate: UInt64 { + access(keyPath: \.viewUpdate) + return _viewUpdate + } // only used if observing via KVO @ObservationIgnored nonisolated(unsafe) private var lastSeenValue: T? - + override nonisolated init() { super.init() } - + func configure(for key: LocalPreferenceKey, in store: LocalPreferencesStore) { let newConfig = State.Config(key: key, store: store) guard newConfig != state?.config else { @@ -169,7 +174,7 @@ private final class UserDefaultsKeyObserver: NSObject, Send state = .init(config: newConfig, observation: .notifications(token)) } } - + // swiftlint:disable:next block_based_kvo discouraged_optional_collection override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { lock.withWriteLock { @@ -178,10 +183,10 @@ private final class UserDefaultsKeyObserver: NSObject, Send super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) return } - viewUpdate &+= 1 + updateView() } } - + private func handleNCUpdate() { guard let state else { return @@ -192,7 +197,7 @@ private final class UserDefaultsKeyObserver: NSObject, Send } // T.self is any Equatable.Type guard let oldValue = lastSeenValue else { - viewUpdate &+= 1 + updateView() return } precondition(((newValue as? any Equatable) != nil) == (T.self is any Equatable.Type)) @@ -202,13 +207,19 @@ private final class UserDefaultsKeyObserver: NSObject, Send return } // the value did actually change - viewUpdate &+= 1 + updateView() } else { // the value is not Equatable, so we need to just assume that it changed (even though it might not have...) - viewUpdate &+= 1 + updateView() + } + } + + private func updateView() { + withMutation(keyPath: \.viewUpdate) { + _viewUpdate &+= 1 } } - + private func stop() { lastSeenValue = nil guard let state else { @@ -222,7 +233,7 @@ private final class UserDefaultsKeyObserver: NSObject, Send } self.state = nil } - + deinit { // one small annoyance here is that @State objects don't necessarily get deallocated right away when the view that's owning them // gets dismissed. it seems that some SwiftUI view elements keep previously-presented-but-now-dismissed views in memory for a while, diff --git a/Sources/SpeziFoundation/Misc/Version.swift b/Sources/SpeziFoundation/Misc/Version.swift index 308de1477..c692bece5 100644 --- a/Sources/SpeziFoundation/Misc/Version.swift +++ b/Sources/SpeziFoundation/Misc/Version.swift @@ -15,7 +15,6 @@ import Foundation /// ### Creating a Version /// - ``init(_:_:_:)`` /// - ``init(_:_:_:prereleaseIdentifiers:buildMetadata:)`` -/// - ``init(_:)`` /// - ``init(_:)-(OperatingSystemVersion)`` /// - ``init(stringLiteral:)`` /// @@ -220,7 +219,7 @@ extension Version: LosslessStringConvertible { extension Version: ExpressibleByStringLiteral { /// Attempts to create a ``Version`` by parsing a `String` literal. /// - /// - Note: The compiler will prefer this function over ``Version/init(_:)`` when calling e.g. `Version("1.2.3")`. + /// - Note: The compiler will prefer this function over `Version.init(_:)` when calling e.g. `Version("1.2.3")`. /// If you want to call the failible initializer with a `String` literal, you need to add an explicit `init` call: `Version.init("1.2.3")`. /// This is not applicable if the parameter is a non-literal expression of type `String`. public init(stringLiteral value: String) { diff --git a/Sources/SpeziHealthKit/CONTRIBUTORS.md b/Sources/SpeziHealthKit/CONTRIBUTORS.md index c42df35f6..d3bbdc001 100644 --- a/Sources/SpeziHealthKit/CONTRIBUTORS.md +++ b/Sources/SpeziHealthKit/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziHealthKit contributors -==================== +# SpeziHealthKit contributors * [Lukas Kollmer](https://github.com/lukaskollmer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziHealthKit/Health Data Collection/HealthDataCollector.swift b/Sources/SpeziHealthKit/Health Data Collection/HealthDataCollector.swift index 068d0625b..a9c53983e 100644 --- a/Sources/SpeziHealthKit/Health Data Collection/HealthDataCollector.swift +++ b/Sources/SpeziHealthKit/Health Data Collection/HealthDataCollector.swift @@ -19,7 +19,7 @@ import SwiftUI /// In most cases, it shouldn't be necessary to define and implement a custom data collector. /// /// Custom `HealthDataCollector`s can be registered with the ``HealthKit-swift.class`` module -/// using ``HealthKit-swift.class/addHealthDataCollector(_:)-1sq79`` or ``HealthKit-swift.class/addHealthDataCollector(_:)-10bp6``. +/// using the `addHealthDataCollector(_:)` registration APIs. /// The ``HealthKit-swift.class`` module will establish a strong reference to the collector, /// which will exist for the entire lifetime of the application. @available(iOS 18, macOS 15, watchOS 11, *) diff --git a/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift b/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift index 3af0bf8fa..8c1128f58 100644 --- a/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift +++ b/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift @@ -109,7 +109,7 @@ extension HKHealthStore { if newActiveObservation <= 0 { Self.activeObservations[objectType] = nil Task { @MainActor in - try await self.disableBackgroundDelivery(for: objectType) + try? await self.disableBackgroundDelivery(for: objectType) } } else { Self.activeObservations[objectType] = newActiveObservation diff --git a/Sources/SpeziHealthKit/HealthKit Extensions/Sleep Sessions/SleepSession.swift b/Sources/SpeziHealthKit/HealthKit Extensions/Sleep Sessions/SleepSession.swift index f93286e49..7b8a85f04 100644 --- a/Sources/SpeziHealthKit/HealthKit Extensions/Sleep Sessions/SleepSession.swift +++ b/Sources/SpeziHealthKit/HealthKit Extensions/Sleep Sessions/SleepSession.swift @@ -92,13 +92,9 @@ public struct SleepSession: Hashable, Sendable { assert(samples.allSatisfy { $0.startDate <= $0.endDate }) assert(samples.adjacentPairs().allSatisfy { $0.startDate <= $1.startDate }) func calcTotalTime(samplesPredicate: @escaping (HKCategorySample) -> Bool) -> TimeInterval { - if #available(iOS 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) { - return RangeSet(samples.lazy.filter(samplesPredicate).map { $0.timeRange }) - .ranges - .reduce(into: 0) { $0 += $1.timeInterval } - } else { - return samples.reduce(into: 0) { $0 += $1.timeRange.timeInterval } - } + RangeSet(samples.lazy.filter(samplesPredicate).map { $0.timeRange }) + .ranges + .reduce(into: 0) { $0 += $1.timeInterval } } timeSpentInSleepPhase = SleepPhase.allKnownValues.reduce(into: [:]) { mapping, phase in mapping[phase] = calcTotalTime { $0.sleepPhase == phase } diff --git a/Sources/SpeziHealthKit/HealthKit.swift b/Sources/SpeziHealthKit/HealthKit.swift index bce05d4f7..fc84513a5 100644 --- a/Sources/SpeziHealthKit/HealthKit.swift +++ b/Sources/SpeziHealthKit/HealthKit.swift @@ -37,8 +37,7 @@ import SwiftUI /// - ``ConfigState`` /// /// ### Registering Data Collectors -/// - ``addHealthDataCollector(_:)-1sq79`` -/// - ``addHealthDataCollector(_:)-10bp6`` +/// - ``addHealthDataCollector(_:)-(CollectSamples)`` /// - ``triggerDataSourceCollection()`` /// - ``resetSampleCollection(for:)`` /// diff --git a/Sources/SpeziHealthKit/Queries/HealthKitStatisticsQuery.swift b/Sources/SpeziHealthKit/Queries/HealthKitStatisticsQuery.swift index b3bca9e40..ebc8b3f64 100644 --- a/Sources/SpeziHealthKit/Queries/HealthKitStatisticsQuery.swift +++ b/Sources/SpeziHealthKit/Queries/HealthKitStatisticsQuery.swift @@ -163,7 +163,7 @@ extension HealthKit { /// - Note: There is a known bug, where a query that uses a `SourceFilter` and initially doesn't match any samples /// (e.g.: because no samples from a matching `HKSource` exist), will not auto-update when a source that matches the filter adds new samples. /// Instead, these samples will only be returned when the function is called again. - /// If this is a likely scenario for your app, use ``continuousStatisticsQuery`` without a `SourceFilter` and perform filtering on the resulting samples. + /// If this is a likely scenario for your app, use ``continuousStatisticsQuery(_:options:aggInterval:timeRange:source:filterPredicate:)`` without a `SourceFilter` and perform filtering on the resulting samples. @available(macOS 15.0, iOS 18.0, watchOS 11.0, *) public func continuousStatisticsQuery( _ sampleType: SampleType, @@ -198,7 +198,7 @@ extension HealthKit { /// - Note: There is a known bug, where a query that uses a `SourceFilter` and initially doesn't match any samples /// (e.g.: because no samples from a matching `HKSource` exist), will not auto-update when a source that matches the filter adds new samples. /// Instead, these samples will only be returned when the function is called again. - /// If this is a likely scenario for your app, use ``continuousStatisticsQuery`` without a `SourceFilter` and perform filtering on the resulting samples. + /// If this is a likely scenario for your app, use ``continuousStatisticsQuery(_:options:aggInterval:timeRange:source:filterPredicate:)`` without a `SourceFilter` and perform filtering on the resulting samples. @available(iOS, deprecated: 18.0) @available(macOS, deprecated: 15.0) @available(watchOS, deprecated: 11.0) diff --git a/Sources/SpeziHealthKit/Sample Types/AnySampleType.swift b/Sources/SpeziHealthKit/Sample Types/AnySampleType.swift index 863b07934..d5bf3afc3 100644 --- a/Sources/SpeziHealthKit/Sample Types/AnySampleType.swift +++ b/Sources/SpeziHealthKit/Sample Types/AnySampleType.swift @@ -19,14 +19,11 @@ import HealthKit /// ### Instance Properties /// - ``hkSampleType`` /// - ``displayTitle`` -/// - ``displayTitle-65fs3`` /// - ``identifier`` /// ### Comparing type-erased sample types /// - ``==(_:_:)-4zjyo`` /// - ``==(_:_:)-5dq7`` /// - ``==(_:_:)-80mw5`` -/// - ``~=(_:_:)-(_,SampleType)`` -/// - ``~=(_:_:)-(SampleType,_)`` @available(iOS 18, macOS 15, watchOS 11, *) public protocol AnySampleType: Hashable, Identifiable, Sendable where ID == String { /// The type of the sample type's underlying samples. diff --git a/Sources/SpeziHealthKit/Sample Types/SampleTypeDefs.py b/Sources/SpeziHealthKit/Sample Types/SampleTypeDefs.py index b3a6593ef..a619e5c18 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleTypeDefs.py +++ b/Sources/SpeziHealthKit/Sample Types/SampleTypeDefs.py @@ -6,6 +6,8 @@ # SPDX-License-Identifier: MIT # +import os +import re from typing import Optional, Any def localeDependentUnit(*, us: str, uk: Optional[str] = None, metric: str) -> str: @@ -15,6 +17,69 @@ def localeDependentUnit(*, us: str, uk: Optional[str] = None, metric: str) -> st return f'localeDependentUnit(us: {us}, metric: {metric})' +def _parse_version(version: str) -> tuple[int, ...]: + """Parse a version string ('18', '18.0', '16.4') or a SwiftPM enum token ('.v15') into a tuple.""" + version = version.strip().strip('"').strip("'") + if version.startswith('.v'): + version = version[2:] + return tuple(int(part) for part in version.split('.') if part != '') + + +def _is_satisfied_by_floor(version: str, floor: tuple[int, ...]) -> bool: + """True iff `version` is at or below `floor`, i.e. an availability check for it is redundant.""" + parsed = _parse_version(version) + width = max(len(parsed), len(floor)) + parsed = parsed + (0,) * (width - len(parsed)) # so 'iOS 15' and 'iOS 15.0' compare equal + padded_floor = floor + (0,) * (width - len(floor)) + return parsed <= padded_floor + + +def _load_deployment_target() -> dict[str, tuple[int, ...]]: + """ + The package's effective per-platform deployment target, parsed from `packagePlatforms` in the + repo-root Package.swift so generated availability annotations always track the real floor. A + platform the package supports but does not explicitly pin (e.g. visionOS) falls back to SwiftPM's + implicit minimum. + """ + # SwiftPM's implicit minimum for supported-but-unpinned platforms (only visionOS applies here). + floor: dict[str, tuple[int, ...]] = {'visionOS': (1, 0)} + manifest_path = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..', 'Package.swift') + ) + with open(manifest_path, encoding='utf-8') as manifest: + source = manifest.read() + match = re.search(r'packagePlatforms\b.*?=\s*\[(.*?)\]', source, re.DOTALL) + if match is None: + raise RuntimeError(f'could not locate `packagePlatforms` in {manifest_path}') + for platform, version in re.findall(r'\.(\w+)\(\s*(\.v\d+|"[^"]+")\s*\)', match.group(1)): + floor[platform] = _parse_version(version) + return floor + + +# The package's effective deployment target. An availability constraint at or below it is already +# guaranteed by the deployment target, so it's redundant and gets dropped from the generated code — +# leaving only the constraints that are genuinely stricter than what the package already requires. +DEPLOYMENT_TARGET = _load_deployment_target() + + +def _effective_floor(enclosing: Optional['Availability']) -> dict[str, tuple[int, ...]]: + """ + The per-platform floor in effect at a given point: the package's deployment target, raised by an + enclosing `@available` gate when the emitted code is nested inside one. Only the platforms the + enclosing gate explicitly names are raised — `@available(..., *)` leaves every other platform at + its deployment minimum, which is why e.g. a visionOS constraint survives inside an iOS-only gate. + """ + floor = dict(DEPLOYMENT_TARGET) + if enclosing is not None: + for platform, version in (('iOS', enclosing.iOS), ('macOS', enclosing.macOS), ('watchOS', enclosing.watchOS), ('visionOS', enclosing.visionOS)): + if version is None: + continue + parsed = _parse_version(version) + existing = floor.get(platform) + floor[platform] = parsed if existing is None else max(existing, parsed) + return floor + + class Availability(object): def __init__(self, *, iOS: Optional[str] = None, macOS: Optional[str] = None, watchOS: Optional[str] = None, visionOS: Optional[str] = None): self.iOS = iOS @@ -22,19 +87,41 @@ def __init__(self, *, iOS: Optional[str] = None, macOS: Optional[str] = None, wa self.watchOS = watchOS self.visionOS = visionOS - def components(self) -> list[str]: + def components(self, enclosing: Optional['Availability'] = None) -> list[str]: + # Emit only the platform constraints that are stricter than the floor in effect at this point. + # That floor is the package's deployment target, raised by any enclosing `@available` gate the + # code is nested in: inside an `@available(iOS 18, ...)` extension the effective iOS floor is 18, + # so an `iOS 18.0` constraint is already guaranteed and gets dropped, whereas an `iOS 18.4` one + # is genuinely newer and is kept. Platforms the enclosing gate does not name stay bounded only + # by the deployment target, so e.g. a `visionOS 2.0` constraint survives inside an iOS-only gate. + floor = _effective_floor(enclosing) components: list[str] = [] - if self.iOS is not None: - components.append(f'iOS {self.iOS}') - if self.macOS is not None: - components.append(f'macOS {self.macOS}') - if self.watchOS is not None: - components.append(f'watchOS {self.watchOS}') - if self.visionOS is not None: - components.append(f'visionOS {self.visionOS}') + for platform, version in (('iOS', self.iOS), ('macOS', self.macOS), ('watchOS', self.watchOS), ('visionOS', self.visionOS)): + if version is None: + continue + platform_floor = floor.get(platform) + if platform_floor is not None and _is_satisfied_by_floor(version, platform_floor): + continue + components.append(f'{platform} {version}') return components +def is_availability_restricted(availability: Optional[Availability], enclosing: Optional[Availability] = None) -> bool: + """True iff `availability` still constrains at least one platform beyond the floor in effect — the + deployment target, raised by any enclosing `@available` gate (see `Availability.components`).""" + return availability is not None and len(availability.components(enclosing)) > 0 + + +# The availability of the generated `SampleType` API surface itself, mirroring the `@available` on +# `struct SampleType` in SampleType.swift. Every generated top-level construct (the `SampleType` +# extensions, the HealthKit-type helper extensions, `localeDependentUnit`) references either +# `SampleType` (macOS 15+) or a HealthKit type (macOS 13+), neither of which the package's own floor +# (macOS 12) guarantees — so all of them must carry at least this gate. It is routed through +# `Availability.components()` like every other annotation, so should the deployment target ever rise +# past it the now-redundant gate is dropped automatically. +SAMPLE_TYPE_AVAILABILITY = Availability(iOS='18', macOS='15', watchOS='11') + + class SampleType(object): def __init__( self, diff --git a/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy+Initializers.swift b/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy+Initializers.swift index a0b042dcb..8f9b08ba8 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy+Initializers.swift +++ b/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy+Initializers.swift @@ -39,21 +39,14 @@ extension SampleTypeProxy { self = .heartbeatSeries(sampleType) case let sampleType as SampleType: self = .visionPrescription(sampleType) + case let sampleType as SampleType: + self = .stateOfMind(sampleType) + case let sampleType as SampleType: + self = .gad7(sampleType) + case let sampleType as SampleType: + self = .phq9(sampleType) default: - if #available(iOS 18.0, watchOS 11.0, macOS 15.0, visionOS 2.0, *) { - switch sampleType { - case let sampleType as SampleType: - self = .stateOfMind(sampleType) - case let sampleType as SampleType: - self = .gad7(sampleType) - case let sampleType as SampleType: - self = .phq9(sampleType) - default: - return nil - } - } else { - return nil - } + return nil } } } diff --git a/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy.swift b/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy.swift index c2de32c51..420f3d185 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy.swift +++ b/Sources/SpeziHealthKit/Sample Types/SampleTypeProxy.swift @@ -186,33 +186,26 @@ extension SampleTypeProxy: Codable { default: throw SampleTypeDecodingError.unknownSampleTypeIdentifier(sampleTypeIdentifier) } - case .some(let cls): - if #available(iOS 18.0, watchOS 11.0, macOS 15.0, visionOS 2.0, *) { - switch cls { - case is HKStateOfMindType.Type: - self = .stateOfMind(SampleType.stateOfMind) - case is HKScoredAssessmentType.Type: - #if canImport(ObjectiveC) - let scoredAssessmentType = try catchingNSException { - HKScoredAssessmentType(.init(rawValue: sampleTypeIdentifier)) - } - #else - let scoredAssessmentType = HKScoredAssessmentType(.init(rawValue: sampleTypeIdentifier)) - #endif - switch scoredAssessmentType { - case .init(.GAD7): - self = .gad7(SampleType.gad7) - case .init(.PHQ9): - self = .phq9(SampleType.phq9) - default: - throw SampleTypeDecodingError.unknownSampleTypeIdentifier(sampleTypeIdentifier) - } - default: - throw SampleTypeDecodingError.unknownSampleTypeClassname(sampleTypeClassname) - } - } else { - throw SampleTypeDecodingError.unknownSampleTypeClassname(sampleTypeClassname) + case is HKStateOfMindType.Type: + self = .stateOfMind(SampleType.stateOfMind) + case is HKScoredAssessmentType.Type: + #if canImport(ObjectiveC) + let scoredAssessmentType = try catchingNSException { + HKScoredAssessmentType(.init(rawValue: sampleTypeIdentifier)) } + #else + let scoredAssessmentType = HKScoredAssessmentType(.init(rawValue: sampleTypeIdentifier)) + #endif + switch scoredAssessmentType { + case .init(.GAD7): + self = .gad7(SampleType.gad7) + case .init(.PHQ9): + self = .phq9(SampleType.phq9) + default: + throw SampleTypeDecodingError.unknownSampleTypeIdentifier(sampleTypeIdentifier) + } + default: + throw SampleTypeDecodingError.unknownSampleTypeClassname(sampleTypeClassname) } } diff --git a/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift b/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift index c9ae15f40..49f9c2b00 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift +++ b/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift @@ -1162,7 +1162,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records breathing disturbances during sleep. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var appleSleepingBreathingDisturbances: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.appleSleepingBreathingDisturbances.rawValue, @@ -1174,7 +1174,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records cross-country skiing speed. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var crossCountrySkiingSpeed: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.crossCountrySkiingSpeed.rawValue, @@ -1230,7 +1230,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records cross-country skiing distance. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var distanceCrossCountrySkiing: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.distanceCrossCountrySkiing.rawValue, @@ -1242,7 +1242,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records paddle sports distance. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var distancePaddleSports: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.distancePaddleSports.rawValue, @@ -1254,7 +1254,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records rowing distance. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var distanceRowing: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.distanceRowing.rawValue, @@ -1266,7 +1266,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records skating sports distance. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var distanceSkatingSports: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.distanceSkatingSports.rawValue, @@ -1289,7 +1289,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records estimated physical effort during workouts. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var estimatedWorkoutEffortScore: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.estimatedWorkoutEffortScore.rawValue, @@ -1301,7 +1301,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records paddle sports speed. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var paddleSportsSpeed: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.paddleSportsSpeed.rawValue, @@ -1324,7 +1324,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records rowing speed. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var rowingSpeed: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.rowingSpeed.rawValue, @@ -1347,7 +1347,7 @@ extension SampleType where Sample == HKQuantitySample { ) } /// A quantity sample that records workout effort. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var workoutEffortScore: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.workoutEffortScore.rawValue, @@ -1565,9 +1565,9 @@ extension SampleType where Sample == HKQuantitySample { self = .underwaterDepth } else if identifier == .waterTemperature { self = .waterTemperature - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .appleSleepingBreathingDisturbances { + } else if #available(visionOS 2.0, *), identifier == .appleSleepingBreathingDisturbances { self = .appleSleepingBreathingDisturbances - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .crossCountrySkiingSpeed { + } else if #available(visionOS 2.0, *), identifier == .crossCountrySkiingSpeed { self = .crossCountrySkiingSpeed } else if identifier == .cyclingCadence { self = .cyclingCadence @@ -1577,27 +1577,27 @@ extension SampleType where Sample == HKQuantitySample { self = .cyclingPower } else if identifier == .cyclingSpeed { self = .cyclingSpeed - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .distanceCrossCountrySkiing { + } else if #available(visionOS 2.0, *), identifier == .distanceCrossCountrySkiing { self = .distanceCrossCountrySkiing - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .distancePaddleSports { + } else if #available(visionOS 2.0, *), identifier == .distancePaddleSports { self = .distancePaddleSports - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .distanceRowing { + } else if #available(visionOS 2.0, *), identifier == .distanceRowing { self = .distanceRowing - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .distanceSkatingSports { + } else if #available(visionOS 2.0, *), identifier == .distanceSkatingSports { self = .distanceSkatingSports } else if identifier == .environmentalSoundReduction { self = .environmentalSoundReduction - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .estimatedWorkoutEffortScore { + } else if #available(visionOS 2.0, *), identifier == .estimatedWorkoutEffortScore { self = .estimatedWorkoutEffortScore - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .paddleSportsSpeed { + } else if #available(visionOS 2.0, *), identifier == .paddleSportsSpeed { self = .paddleSportsSpeed } else if identifier == .physicalEffort { self = .physicalEffort - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .rowingSpeed { + } else if #available(visionOS 2.0, *), identifier == .rowingSpeed { self = .rowingSpeed } else if identifier == .timeInDaylight { self = .timeInDaylight - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .workoutEffortScore { + } else if #available(visionOS 2.0, *), identifier == .workoutEffortScore { self = .workoutEffortScore } else { return nil @@ -1720,41 +1720,41 @@ extension HKQuantityTypeIdentifier { identifiers.insert(Self.uvExposure) identifiers.insert(Self.underwaterDepth) identifiers.insert(Self.waterTemperature) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.appleSleepingBreathingDisturbances) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.crossCountrySkiingSpeed) } identifiers.insert(Self.cyclingCadence) identifiers.insert(Self.cyclingFunctionalThresholdPower) identifiers.insert(Self.cyclingPower) identifiers.insert(Self.cyclingSpeed) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.distanceCrossCountrySkiing) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.distancePaddleSports) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.distanceRowing) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.distanceSkatingSports) } identifiers.insert(Self.environmentalSoundReduction) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.estimatedWorkoutEffortScore) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.paddleSportsSpeed) } identifiers.insert(Self.physicalEffort) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.rowingSpeed) } identifiers.insert(Self.timeInDaylight) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.workoutEffortScore) } return identifiers @@ -2356,7 +2356,7 @@ extension SampleType where Sample == HKCategorySample { ) } /// A category type that records bleeding during pregnancy as a symptom. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var bleedingDuringPregnancy: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.bleedingDuringPregnancy.rawValue, @@ -2367,7 +2367,7 @@ extension SampleType where Sample == HKCategorySample { ) } /// A category type that records bleeding after pregnancy as a symptom. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var bleedingAfterPregnancy: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.bleedingAfterPregnancy.rawValue, @@ -2428,7 +2428,7 @@ extension SampleType where Sample == HKCategorySample { ) } /// A category type that records sleep apnea as a symptom. - @available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) + @available(visionOS 2.0, *) @inlinable public static var sleepApneaEvent: SampleType { SampleTypeCache.get( identifier: Sample._SampleType._Identifier.sleepApneaEvent.rawValue, @@ -2569,9 +2569,9 @@ extension SampleType where Sample == HKCategorySample { self = .pelvicPain } else if identifier == .vaginalDryness { self = .vaginalDryness - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .bleedingDuringPregnancy { + } else if #available(visionOS 2.0, *), identifier == .bleedingDuringPregnancy { self = .bleedingDuringPregnancy - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .bleedingAfterPregnancy { + } else if #available(visionOS 2.0, *), identifier == .bleedingAfterPregnancy { self = .bleedingAfterPregnancy } else if identifier == .acne { self = .acne @@ -2583,7 +2583,7 @@ extension SampleType where Sample == HKCategorySample { self = .nightSweats } else if identifier == .sleepChanges { self = .sleepChanges - } else if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *), identifier == .sleepApneaEvent { + } else if #available(visionOS 2.0, *), identifier == .sleepApneaEvent { self = .sleepApneaEvent } else if identifier == .bladderIncontinence { self = .bladderIncontinence @@ -2665,10 +2665,10 @@ extension HKCategoryTypeIdentifier { identifiers.insert(Self.breastPain) identifiers.insert(Self.pelvicPain) identifiers.insert(Self.vaginalDryness) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.bleedingDuringPregnancy) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.bleedingAfterPregnancy) } identifiers.insert(Self.acne) @@ -2676,7 +2676,7 @@ extension HKCategoryTypeIdentifier { identifiers.insert(Self.hairLoss) identifiers.insert(Self.nightSweats) identifiers.insert(Self.sleepChanges) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { identifiers.insert(Self.sleepApneaEvent) } identifiers.insert(Self.bladderIncontinence) @@ -2905,15 +2905,15 @@ extension HKObjectType { types.insert(SampleType.audiogram.hkSampleType) types.insert(SampleType.workout.hkSampleType) types.insert(SampleType.visionPrescription.hkSampleType) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { types.insert(SampleType.stateOfMind.hkSampleType) } types.insert(SampleType.heartbeatSeries.hkSampleType) types.insert(SampleType.workoutRoute.hkSampleType) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { types.insert(SampleType.gad7.hkSampleType) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { types.insert(SampleType.phq9.hkSampleType) } return types @@ -3043,15 +3043,15 @@ extension SampleType { retval.append(SpeziHealthKit.SampleType.audiogram) retval.append(SpeziHealthKit.SampleType.workout) retval.append(SpeziHealthKit.SampleType.visionPrescription) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { retval.append(SpeziHealthKit.SampleType.stateOfMind) } retval.append(SpeziHealthKit.SampleType.heartbeatSeries) retval.append(SpeziHealthKit.SampleType.workoutRoute) - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { retval.append(SpeziHealthKit.SampleType.gad7) } - if #available(iOS 18.0, macOS 15.0, watchOS 11.0, visionOS 2.0, *) { + if #available(visionOS 2.0, *) { retval.append(SpeziHealthKit.SampleType.phq9) } return retval diff --git a/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift.gyb b/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift.gyb index 151803f0a..2ddca0ecd 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift.gyb +++ b/Sources/SpeziHealthKit/Sample Types/SampleTypes.swift.gyb @@ -9,6 +9,18 @@ %{ from typing import Optional from SampleTypeDefs import * + +def _make_availability_decl_imp(prefix: str, availability: Availability, trailing_comma: bool, enclosing: Optional[Availability]): + if not is_availability_restricted(availability, enclosing): + return '' + else: + return f'{prefix}available({', '.join(availability.components(enclosing))}, *){', ' if trailing_comma else ''}' + +def make_hashtag_available(availability: Availability, *, trailing_comma: bool, enclosing: Optional[Availability] = None) -> str: + return _make_availability_decl_imp('#', availability, trailing_comma, enclosing) + +def make_at_available(availability: Availability, *, enclosing: Optional[Availability] = None) -> str: + return _make_availability_decl_imp('@', availability, False, enclosing) }% // NOTE: This file was automatically generated and should not be edited. @@ -21,6 +33,9 @@ public import HealthKit /// Selects one of the specified units, based on the current locale. +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end @inlinable func localeDependentUnit( us: @autoclosure () -> HKUnit, uk: @autoclosure () -> HKUnit? = nil, @@ -39,14 +54,17 @@ public import HealthKit // MARK: ${display_title} Types +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end % if hk_class == 'HKClinicalRecord': @available(watchOS, unavailable) % end extension SampleType where Sample == ${hk_class} { % for ty in types: /// ${ty.doc} -% if ty.availability is not None: - @available(${', '.join(ty.availability.components())}, *) +% if is_availability_restricted(ty.availability, SAMPLE_TYPE_AVAILABILITY): + @available(${', '.join(ty.availability.components(SAMPLE_TYPE_AVAILABILITY))}, *) % end @inlinable public static var ${ty.property_name}: SampleType<${hk_class}> { SampleTypeCache.get( @@ -62,25 +80,12 @@ extension SampleType where Sample == ${hk_class} { } % end -%{ -def _make_availability_decl_imp(prefix: str, availability: Availability, trailing_comma: bool): - if availability is None: - return '' - else: - return f'{prefix}available({', '.join(availability.components())}, *){', ' if trailing_comma else ''}' - -def make_hashtag_available(availability: Availability, *, trailing_comma: bool) -> str: - return _make_availability_decl_imp('#', availability, trailing_comma) - -def make_at_available(availability: Availability) -> str: - return _make_availability_decl_imp('@', availability, False) -}% /// Returns the shared ${display_title} type for the specified identifier. public init?(_ identifier: ${hk_sampletype_class}Identifier) where Sample == ${hk_class} { - if ${make_hashtag_available(types[0].availability, trailing_comma=True)}identifier == .${types[0].identifier} { + if ${make_hashtag_available(types[0].availability, trailing_comma=True, enclosing=SAMPLE_TYPE_AVAILABILITY)}identifier == .${types[0].identifier} { self = .${types[0].property_name} % for ty in types[1:]: - } else if ${make_hashtag_available(ty.availability, trailing_comma=True)}identifier == .${ty.identifier} { + } else if ${make_hashtag_available(ty.availability, trailing_comma=True, enclosing=SAMPLE_TYPE_AVAILABILITY)}identifier == .${ty.identifier} { self = .${ty.property_name} % end } else { @@ -89,6 +94,9 @@ def make_at_available(availability: Availability) -> str: } } +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end extension ${hk_sampletype_class} { /// All well-known `${hk_sampletype_class}`s public static let allKnown${''.join(display_title_plural.split())}: Set<${hk_sampletype_class}> = Set( @@ -96,12 +104,15 @@ extension ${hk_sampletype_class} { ) } +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end extension ${hk_sampletype_class}Identifier { /// All well-known `${hk_sampletype_class}Identifier`s public static let allKnownIdentifiers: Set<${hk_sampletype_class}Identifier> = { var identifiers = Set<${hk_sampletype_class}Identifier>() % for ty in types: - % availability_check = make_hashtag_available(ty.availability, trailing_comma=False) + % availability_check = make_hashtag_available(ty.availability, trailing_comma=False, enclosing=SAMPLE_TYPE_AVAILABILITY) % if availability_check != '': if ${availability_check} { identifiers.insert(Self.${ty.identifier}) @@ -115,6 +126,9 @@ extension ${hk_sampletype_class}Identifier { } % end +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end extension HKObjectType { /// All well-known `HKObjectType`s public static let allKnownObjectTypes: Set = { @@ -125,8 +139,8 @@ extension HKObjectType { types.formUnion(HKCharacteristicTypeIdentifier.allKnownIdentifiers.map { HKCharacteristicType($0) }) // types.formUnion(SampleType.otherSampleTypes.map(\.hkSampleType)) % for sample_type in other_sample_types: - % if sample_type.availability is not None: - if ${make_hashtag_available(sample_type.availability, trailing_comma=False)} { + % if is_availability_restricted(sample_type.availability, SAMPLE_TYPE_AVAILABILITY): + if ${make_hashtag_available(sample_type.availability, trailing_comma=False, enclosing=SAMPLE_TYPE_AVAILABILITY)} { types.insert(SampleType.${sample_type.sampleTypePropertyName}.hkSampleType) } % else: @@ -141,8 +155,9 @@ extension HKObjectType { // MARK: Other Sample Types % for sample_type in other_sample_types: -% if sample_type.availability: -${make_at_available(sample_type.availability)} +% extension_availability = sample_type.availability if is_availability_restricted(sample_type.availability) else SAMPLE_TYPE_AVAILABILITY +% if is_availability_restricted(extension_availability): +${make_at_available(extension_availability)} % end extension SampleType where Sample == ${sample_type.hkSampleClass} { /// ${sample_type.doc} @@ -157,6 +172,9 @@ extension SampleType where Sample == ${sample_type.hkSampleClass} { % end +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end extension SampleType { /// All currently-known "other" sample types, which are not quantity, correlation, category, or clinical samples. /// @@ -164,8 +182,8 @@ extension SampleType { public static var otherSampleTypes: [any AnySampleType] { var retval: [any AnySampleType] = [] % for sample_type in other_sample_types: - % if sample_type.availability is not None: - if ${make_hashtag_available(sample_type.availability, trailing_comma=False)} { + % if is_availability_restricted(sample_type.availability, SAMPLE_TYPE_AVAILABILITY): + if ${make_hashtag_available(sample_type.availability, trailing_comma=False, enclosing=SAMPLE_TYPE_AVAILABILITY)} { retval.append(SpeziHealthKit.SampleType.${sample_type.sampleTypePropertyName}) } % else: @@ -177,6 +195,9 @@ extension SampleType { } +% if is_availability_restricted(SAMPLE_TYPE_AVAILABILITY): +${make_at_available(SAMPLE_TYPE_AVAILABILITY)} +% end extension HKCharacteristicTypeIdentifier { /// All well-known `HKCharacteristicTypeIdentifier`s public static let allKnownIdentifiers: Set = [ diff --git a/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift b/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift index 851ed3491..a1725f1c2 100644 --- a/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift +++ b/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift @@ -98,11 +98,9 @@ public enum StartSessionError: Error { /// - ``failedBatches`` /// - ``numTotalBatches`` /// - ``numProcessedBatches`` -/// - ``currentBatch`` /// - ``progress`` /// ### Instance Methods -/// - ``start(retryFailedBatches:)-23rws`` -/// - ``start(retryFailedBatches:)-9dmk0`` +/// - ``start(retryFailedBatches:concurrencyLevel:)`` /// - ``pause()`` /// ### Other /// - ``SpeziHealthKitBulkExport/==(_:_:)`` diff --git a/Sources/SpeziHealthKitBulkExport/SpeziHealthKitBulkExport.docc/BulkHealthExporter.md b/Sources/SpeziHealthKitBulkExport/SpeziHealthKitBulkExport.docc/BulkHealthExporter.md index 88d0f0866..6a4d6652d 100644 --- a/Sources/SpeziHealthKitBulkExport/SpeziHealthKitBulkExport.docc/BulkHealthExporter.md +++ b/Sources/SpeziHealthKitBulkExport/SpeziHealthKitBulkExport.docc/BulkHealthExporter.md @@ -36,7 +36,7 @@ The ``BulkHealthExporter/session(withId:for:startDate:endDate:batchSize:using:)` - Important: Ensure that your app has sufficient HealthKit access permissions before starting bulk export sessions. The session itself will *not* prompt the user for access; instead, it will fail to fetch and process any sample types for which no HealthKit permission is granted. -It is possible to ``BulkExportSession/pause()`` an export session, which can then be resumed using the ``BulkExportSession/start(retryFailedBatches:)`` function. +It is possible to ``BulkExportSession/pause()`` an export session, which can then be resumed using the ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` function. ### Example 1: Bulk-Upload of Historical Health Data to Firebase @@ -76,7 +76,7 @@ try session.start() This Bulk Export Session will, in the background, go through all historical Health data for the Active Energy, Heart Rate, and Step Count quantity types, fetch the data from HealthKit, and pass it to the Batch Processor, which will then upload it to Firebase. -In this example, since the `FirebaseUploader`'s `Output` type is `Void`, we simply can call ``BulkExportSession/start(retryFailedBatches:)`` and don't need to do anything beyond that. +In this example, since the `FirebaseUploader`'s `Output` type is `Void`, we simply can call ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` and don't need to do anything beyond that. @@ -116,7 +116,7 @@ Task { } ``` -Since the `FHIREncodedExporter` returns a `URL` (rather than `Void`, as with the `FirebaseUploader`), the ``BulkExportSession/start(retryFailedBatches:)`` function's return type will be an `AsyncStream` which gives us access to the individual batch processing results (in this case the urls of the exported JSON files). +Since the `FHIREncodedExporter` returns a `URL` (rather than `Void`, as with the `FirebaseUploader`), the ``BulkExportSession/start(retryFailedBatches:concurrencyLevel:)`` function's return type will be an `AsyncStream` which gives us access to the individual batch processing results (in this case the urls of the exported JSON files). ### Performance Considerations diff --git a/Sources/SpeziKeychainStorage/Credentials/Credentials.swift b/Sources/SpeziKeychainStorage/Credentials/Credentials.swift index 14f3fa19d..f65dc7bf9 100644 --- a/Sources/SpeziKeychainStorage/Credentials/Credentials.swift +++ b/Sources/SpeziKeychainStorage/Credentials/Credentials.swift @@ -162,7 +162,6 @@ extension _CredentialsContainer { // swiftlint:disable:this file_types_order /// ### Credentials Attributes /// - ``username`` /// - ``password`` -/// - ``account`` /// - ``synchronizable`` /// - ``accessControl`` /// - ``accessGroup`` @@ -224,7 +223,6 @@ public struct Credentials: _CredentialsContainer, Hashable, @unchecked Sendable /// - ``username`` /// - ``password`` /// - ``service`` -/// - ``account`` /// - ``synchronizable`` /// - ``accessControl`` /// - ``accessGroup`` @@ -275,7 +273,6 @@ public struct GenericCredentials: _CredentialsContainer, @unchecked Sendable { /// - ``label`` /// - ``isInvisible`` /// - ``isNegative`` -/// - ``account`` /// - ``securityDomain`` /// - ``protocol`` /// - ``authenticationType`` @@ -397,7 +394,7 @@ extension _CredentialsContainer { /// The password stored in the Credentials item. @_documentation(visibility: public) public var password: String { - get { self[kSecValueData, as: Data.self].map { String(decoding: $0, as: UTF8.self) } ?? "" } + get { self[kSecValueData, as: Data.self].flatMap { String(bytes: $0, encoding: .utf8) } ?? "" } set { self[kSecValueData] = Data(newValue.utf8) } } } diff --git a/Sources/SpeziKeychainStorage/KeychainStorage.swift b/Sources/SpeziKeychainStorage/KeychainStorage.swift index 938a55bb7..0c845b87d 100644 --- a/Sources/SpeziKeychainStorage/KeychainStorage.swift +++ b/Sources/SpeziKeychainStorage/KeychainStorage.swift @@ -34,9 +34,6 @@ import Spezi /// - ``retrieveAllInternetCredentials(forServer:)`` /// - ``retrieveAllCredentials()`` /// - ``deleteCredentials(withUsername:for:)`` -/// - ``deleteAllGenericCredentials(service:accessGroup:)`` -/// - ``deleteAllInternetCredentials(server:accessGroup:)`` -/// - ``deleteAllCredentials(accessGroup:)`` /// /// ### Cryptographic Key Storage /// - ``CryptographicKeyTag`` @@ -46,7 +43,6 @@ import Spezi /// - ``retrieveAllKeys(_:accessGroup:)`` /// - ``deleteKey(for:)`` /// - ``deleteKey(_:)`` -/// - ``deleteAllKeys(accessGroup:)`` /// - ``Security/SecKey`` /// /// ### Other diff --git a/Sources/SpeziLLM/CONTRIBUTORS.md b/Sources/SpeziLLM/CONTRIBUTORS.md index 74310900b..a16d76a9e 100644 --- a/Sources/SpeziLLM/CONTRIBUTORS.md +++ b/Sources/SpeziLLM/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziLLM contributors -==================== +# SpeziLLM contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziLicense/CONTRIBUTORS.md b/Sources/SpeziLicense/CONTRIBUTORS.md index e9db255b3..5b61edf2d 100644 --- a/Sources/SpeziLicense/CONTRIBUTORS.md +++ b/Sources/SpeziLicense/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziLicense contributors -==================== +# SpeziLicense contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Nikolai Madlener](https://github.com/nikolaimadlener) diff --git a/Sources/SpeziLicense/Views/ContributionsList.swift b/Sources/SpeziLicense/Views/ContributionsList.swift index a935a858b..0a95b8de6 100644 --- a/Sources/SpeziLicense/Views/ContributionsList.swift +++ b/Sources/SpeziLicense/Views/ContributionsList.swift @@ -85,6 +85,7 @@ public struct ContributionsList: View { /// - parameter appName: The name of the app to be rendered in the information text at the top of the view. /// Defaults to ``AppName/automatic``, which reads the name from the main Bundle. /// - parameter projectLicense: Optional SPDX-License-Identifier to inform user about the project's license. + /// - parameter projectUrl: Optional URL to the project whose contributions are displayed. /// - parameter additionalPackages: Additional entries that should be displayed in the list but are not present in the app's SPM dependencies. /// Intended for non-SPM dependencies. public init( diff --git a/Sources/SpeziLocalStorage/LocalStorage.swift b/Sources/SpeziLocalStorage/LocalStorage.swift index f6e44490e..7b1b53d92 100644 --- a/Sources/SpeziLocalStorage/LocalStorage.swift +++ b/Sources/SpeziLocalStorage/LocalStorage.swift @@ -95,6 +95,7 @@ public final class LocalStorage: Module, DefaultInitializable, EnvironmentAccess /// /// - parameter value: The value which should be persisted. Passing `nil` will delete the most-recently-stored value. /// - parameter key: The ``LocalStorageKey`` with which the value should be associated. + /// - parameter configuration: The encoding configuration used to encode the value. /// /// - Note: This operation will overwrite any previously-stored values for this key. public func store( @@ -312,6 +313,8 @@ public final class LocalStorage: Module, DefaultInitializable, EnvironmentAccess /// - parameter transform: A mapping closure, which will be called with the current value stored for `key` (or `nil`, if no value is stored). /// The value after the closure invocation will be stored into the `LocalStorage`, for the entry identified by `key`. /// If the closure sets `value` to `nil`, the entry will be removed from the `LocalStorage`. + /// - parameter decodingConfiguration: The decoding configuration used to decode the stored value. + /// - parameter encodingConfiguration: The encoding configuration used to encode the updated value. /// /// - throws: if `transform` throws, public func modify( diff --git a/Sources/SpeziLocalStorage/LocalStorageKey.swift b/Sources/SpeziLocalStorage/LocalStorageKey.swift index c64430d4e..5964b7cbe 100644 --- a/Sources/SpeziLocalStorage/LocalStorageKey.swift +++ b/Sources/SpeziLocalStorage/LocalStorageKey.swift @@ -57,7 +57,7 @@ public class LocalStorageKeys { /// ## Topics /// ### Creating Storage Keys /// - ``init(_:setting:)-21oqu`` -/// - ``init(_:setting:encoder:decoder:)`` +/// - ``init(_:setting:encoder:decoder:)-4yhmm`` /// - ``init(_:setting:)-1sf9p`` /// - ``init(_:setting:)-9t3s8`` /// - ``init(key:setting:encode:decode:)`` @@ -100,11 +100,11 @@ public final class LocalStorageKey: LocalStorageKeys, @unchecked Sendable } func withReadLock(_ block: () throws -> Result) rethrows -> Result { - try lock.withReadLock(body: block) + try lock.withReadLock(block) } func withWriteLock(_ block: () throws -> Result) rethrows -> Result { - try lock.withWriteLock(body: block) + try lock.withWriteLock(block) } func informSubscribersAboutNewValue(_ newValue: Value?) { diff --git a/Sources/SpeziLocalStorage/SpeziLocalStorage.docc/SpeziLocalStorage.md b/Sources/SpeziLocalStorage/SpeziLocalStorage.docc/SpeziLocalStorage.md index 41e7af6b1..3e7273c0a 100644 --- a/Sources/SpeziLocalStorage/SpeziLocalStorage.docc/SpeziLocalStorage.md +++ b/Sources/SpeziLocalStorage/SpeziLocalStorage.docc/SpeziLocalStorage.md @@ -92,7 +92,7 @@ do { } ``` -See ``LocalStorage/store(_:encoder:storageKey:settings:)`` for more details. +See ``LocalStorage/store(_:for:configuration:)`` for more details. @@ -109,7 +109,7 @@ do { } ``` -See ``LocalStorage/read(_:decoder:storageKey:settings:)`` for more details. +See ``LocalStorage/load(_:configuration:)`` for more details. ### Deleting Data @@ -124,7 +124,7 @@ do { } ``` -See ``LocalStorage/delete(_:)`` or ``LocalStorage/delete(storageKey:)`` for more details. +See ``LocalStorage/delete(_:)`` for more details. If you need to fully delete the entire local storage, use ``LocalStorage/deleteAll()``. diff --git a/Sources/SpeziLocation/CONTRIBUTORS.md b/Sources/SpeziLocation/CONTRIBUTORS.md index a2262cde2..571fcaed5 100644 --- a/Sources/SpeziLocation/CONTRIBUTORS.md +++ b/Sources/SpeziLocation/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziLocation contributors -==================== +# SpeziLocation contributors * [Vishnu Ravi](https://github.com/vishnuravi) * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) diff --git a/Sources/SpeziNetworking/CONTRIBUTORS.md b/Sources/SpeziNetworking/CONTRIBUTORS.md index fb3f8e476..7270d1be1 100644 --- a/Sources/SpeziNetworking/CONTRIBUTORS.md +++ b/Sources/SpeziNetworking/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziNetworking contributors -==================== +# SpeziNetworking contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziNotifications/CONTRIBUTORS.md b/Sources/SpeziNotifications/CONTRIBUTORS.md index f87f21be6..8b78cad8e 100644 --- a/Sources/SpeziNotifications/CONTRIBUTORS.md +++ b/Sources/SpeziNotifications/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziNotifications contributors -==================== +# SpeziNotifications contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziNumerics/MedFloatImpl.swift b/Sources/SpeziNumerics/MedFloatImpl.swift index bc1b6e2d2..38bc94969 100644 --- a/Sources/SpeziNumerics/MedFloatImpl.swift +++ b/Sources/SpeziNumerics/MedFloatImpl.swift @@ -252,7 +252,7 @@ extension MedFloatProtocol { extension MedFloatProtocol { - /// ``Double`` approximation of the medfloat. + /// `Double` approximation of the medfloat. public var double: Double { // For some reason writing e.g. `Self.nan.bitPattern` in a switch case causes the compiler to reject the code, saying that // "'nan' is not a member type of type 'Self'". Writing `type(of: self).nan.bitPattern` instead compiles. diff --git a/Sources/SpeziOnboarding/CONTRIBUTORS.md b/Sources/SpeziOnboarding/CONTRIBUTORS.md index 4f47fb59c..5292b0dde 100644 --- a/Sources/SpeziOnboarding/CONTRIBUTORS.md +++ b/Sources/SpeziOnboarding/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziOnboarding contributors -==================== +# SpeziOnboarding contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift b/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift index a0612e7d4..b2b0a8fbf 100644 --- a/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift +++ b/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift @@ -42,6 +42,12 @@ import SwiftUI /// ``` @available(iOS 18, macOS 15, watchOS 11, *) public struct NameFieldRow: View { + #if os(macOS) + private static var usesPromptLabel: Bool { true } + #else + private static var usesPromptLabel: Bool { false } + #endif + private let description: Description private let label: Label private let component: WritableKeyPath @@ -50,12 +56,7 @@ public struct NameFieldRow: View { public var body: some View { - #if os(macOS) - let isMacOS = true - #else - let isMacOS = false - #endif - if isMacOS, let label = label as? Text { + if Self.usesPromptLabel, let label = label as? Text { NameTextField(name: $name, for: component, prompt: label) { description } diff --git a/Sources/SpeziQuestionnaire/CONTRIBUTORS.md b/Sources/SpeziQuestionnaire/CONTRIBUTORS.md index 82a6fcac8..65e1ef0f9 100644 --- a/Sources/SpeziQuestionnaire/CONTRIBUTORS.md +++ b/Sources/SpeziQuestionnaire/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -Spezi Questionnaire Contributors -==================== +# Spezi Questionnaire Contributors * [Lukas Kollmer](https://github.com/lukaskollmer) * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) diff --git a/Sources/SpeziScheduler/CONTRIBUTORS.md b/Sources/SpeziScheduler/CONTRIBUTORS.md index 32da66107..6969b1e5b 100644 --- a/Sources/SpeziScheduler/CONTRIBUTORS.md +++ b/Sources/SpeziScheduler/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -Spezi Scheduler contributors -==================== +# Spezi Scheduler contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Sources/SpeziScheduler/Scheduler.swift b/Sources/SpeziScheduler/Scheduler.swift index 12f024b87..2be36d811 100644 --- a/Sources/SpeziScheduler/Scheduler.swift +++ b/Sources/SpeziScheduler/Scheduler.swift @@ -25,7 +25,7 @@ import SwiftUI /// for tasks. It allows to modify the properties (e.g., schedule) of future events without affecting occurrences of the past. /// /// You create and automatically update your tasks -/// using ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:shadowedOutcomesHandling:with:)``. +/// using ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:notificationTime:tags:effectiveFrom:shadowedOutcomesHandling:with:)``. /// /// Below is a example on how to create your own [`Module`](../Spezi/Spezi.docc/Module/Module.md) /// to manage your tasks and ensure they are always up to date. @@ -64,7 +64,7 @@ import SwiftUI /// - ``init()`` /// /// ### Creating and Updating Tasks -/// - ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:shadowedOutcomesHandling:with:)`` +/// - ``createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:notificationTime:tags:effectiveFrom:shadowedOutcomesHandling:with:)`` /// /// ### Query Tasks /// - ``queryTasks(for:predicate:sortBy:fetchLimit:prefetchOutcomes:)-8z86i`` diff --git a/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md b/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md index 9dd229f0c..208a8ac89 100644 --- a/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md +++ b/Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md @@ -23,7 +23,7 @@ You use the `Scheduler` module to manage the persistence store of your tasks. It for tasks. It allows to modify the properties (e.g., schedule) of future events without affecting occurrences of the past. You create and automatically update your tasks -using ``Scheduler/createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)``. +using ``Scheduler/createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:notificationTime:tags:effectiveFrom:shadowedOutcomesHandling:with:)``. Below is a example on how to create your own [`Module`](../../Spezi/Spezi.docc/Module/Module.md) to manage your tasks and ensure they are always up to date. @@ -70,11 +70,11 @@ class MySchedulerModule: Module { ### Task - ``Task`` -- ``Task/ID-swift.struct`` +- ``Task/id`` - ``Task/Category-swift.struct`` - ``Event`` - ``Outcome`` -- ``Property(coding:)`` +- ``Property(coding:storageIdentifier:)`` - ``AllowedCompletionPolicy`` ### Notifications diff --git a/Sources/SpeziScheduler/Task/Outcome.swift b/Sources/SpeziScheduler/Task/Outcome.swift index fc6e3b423..e94ce9d73 100644 --- a/Sources/SpeziScheduler/Task/Outcome.swift +++ b/Sources/SpeziScheduler/Task/Outcome.swift @@ -21,7 +21,7 @@ import SwiftData /// /// An outcome supports storing additional metadata information (e.g., the measurement value or medication). /// -/// - Tip: Refer to the ``Property(coding:)`` macro on how to create new data types that can be stored alongside an outcome. +/// - Tip: Refer to the ``Property(coding:storageIdentifier:)`` macro on how to create new data types that can be stored alongside an outcome. /// /// You provide the additional outcome values upon completion of an event (see ``Event/complete(ignoreCompletionPolicy:with:)``. /// Below is a short code example that sets a custom `measurement` property to the weight measurement that was received diff --git a/Sources/SpeziScheduler/Task/Task.swift b/Sources/SpeziScheduler/Task/Task.swift index e86686799..942c4f901 100644 --- a/Sources/SpeziScheduler/Task/Task.swift +++ b/Sources/SpeziScheduler/Task/Task.swift @@ -31,7 +31,7 @@ import SwiftData /// /// Tasks support storing additional metadata information. /// -/// - Tip: Refer to the ``Property(coding:)`` macro on how to create new data types that can be stored alongside a task. +/// - Tip: Refer to the ``Property(coding:storageIdentifier:)`` macro on how to create new data types that can be stored alongside a task. /// /// You can set additional information by supplying an additional closure that modifies the ``Context`` when creating or updating a task. /// The code example below assume that the `measurementType` exists to store the type of measurement the user should record to complete the task. @@ -65,7 +65,7 @@ import SwiftData /// - ``notificationThread`` /// /// ### Modifying a task -/// - ``Scheduler/createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:tags:effectiveFrom:with:)`` +/// - ``Scheduler/createOrUpdateTask(id:title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:notificationTime:tags:effectiveFrom:shadowedOutcomesHandling:with:)`` /// - ``createUpdatedVersion(title:instructions:category:schedule:completionPolicy:scheduleNotifications:notificationThread:tags:effectiveFrom:with:)`` /// /// ### Storing additional information @@ -309,7 +309,6 @@ public final class Task { // swiftlint:disable:this type_body_length /// - completionPolicy: The policy to decide when an event can be completed by the user. /// - scheduleNotifications: Automatically schedule notifications for upcoming events. /// - notificationThread: The behavior how task notifications are grouped in the notification center. - /// - notificationTime: The time the tasks notifications should be sent out. /// - tags: Custom tags associated with the task. /// - effectiveFrom: The date this update is effective from. /// - contextClosure: The updated context or `nil` if the context should not be updated. diff --git a/Sources/SpeziScheduler/UserInfo/UserStorageCoding.swift b/Sources/SpeziScheduler/UserInfo/UserStorageCoding.swift index b8b25bcc2..b1bef6199 100644 --- a/Sources/SpeziScheduler/UserInfo/UserStorageCoding.swift +++ b/Sources/SpeziScheduler/UserInfo/UserStorageCoding.swift @@ -13,7 +13,7 @@ import SpeziFoundation /// Defining the coding strategy for a user storage property. /// -/// This type defines the encoders and decoders for a user storage ``Property(coding:)``. +/// This type defines the encoders and decoders for a user storage ``Property(coding:storageIdentifier:)``. /// /// Below is a code example that specifies ``json`` encoding for the `measurementType` property. /// diff --git a/Sources/SpeziSensorKit/CONTRIBUTORS.md b/Sources/SpeziSensorKit/CONTRIBUTORS.md index 10eed8b76..b6afc2d80 100644 --- a/Sources/SpeziSensorKit/CONTRIBUTORS.md +++ b/Sources/SpeziSensorKit/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziSensorKit contributors -==================== +# SpeziSensorKit contributors * [Lukas Kollmer](https://github.com/lukaskollmer) * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) diff --git a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift index 201300a0e..28a9681b9 100644 --- a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift +++ b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift @@ -27,7 +27,7 @@ extension AnchoredFetcher { private let anchor: ManagedQueryAnchor private let quarantineCutoff: Date private let batchSize: TimeInterval - nonisolated(unsafe) private let device: SRDevice + private let device: SRDevice private var state: State = .initial init( @@ -83,6 +83,7 @@ extension AnchoredFetcher { try advanceState() return try await next(isolation: isolation) case .process(let timeRange): + nonisolated(unsafe) let device = device let results = try await sensor.fetch(from: device, timeRange: timeRange) try advanceState() let batchInfo = SensorKit.BatchInfo(timeRange: timeRange, device: SensorKit.DeviceInfo(device)) diff --git a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift index 45d33b511..efc954cb7 100644 --- a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift +++ b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift @@ -25,7 +25,7 @@ public struct AnchoredFetcher: AsyncSequence { private let sensor: Sensor private let queryAnchorProvider: (SensorKit.QueryAnchorKey) -> ManagedQueryAnchor private let batchSize: BatchSize - nonisolated(unsafe) private let devices: [SRDevice] + private let devices: [SRDevice] public init( sensor: some AnySensor, @@ -46,7 +46,7 @@ public struct AnchoredFetcher: AsyncSequence { switch batchSize { case .numberOfSamples(let limit): for device in devices { - nonisolated(unsafe) let device = device + let device = device SampleCountBasedFetcher( sensor: sensor, batchSize: limit, @@ -63,7 +63,7 @@ public struct AnchoredFetcher: AsyncSequence { @_AsyncIteratorBuilder private func timeIntervalBasedIterator(batchDuration duration: Duration) -> some AsyncIteratorProtocol { for device in devices { - nonisolated(unsafe) let device = device + let device = device TimeIntervalBasedFetcher( sensor: sensor, anchor: queryAnchorProvider(SensorKit.QueryAnchorKey(sensor: sensor, deviceProductType: device.productType)), diff --git a/Sources/SpeziSensorKit/SensorKit.swift b/Sources/SpeziSensorKit/SensorKit.swift index 3d3549787..08210a4ec 100644 --- a/Sources/SpeziSensorKit/SensorKit.swift +++ b/Sources/SpeziSensorKit/SensorKit.swift @@ -26,8 +26,8 @@ import SwiftUI /// - ``requestAccess(to:)`` /// /// ## Anchored Querying -/// - ``fetchAnchored(_:)`` -/// - ``resetQueryAnchor(for:)`` +/// - ``fetchAnchored(_:batchSize:)`` +/// - ``resetQueryAnchors(for:)`` @available(iOS 18, macOS 15, watchOS 11, *) @Observable public final class SensorKit: Module, EnvironmentAccessible, @unchecked Sendable { @@ -154,7 +154,7 @@ extension SensorKit { /// Resets the query anchors for the specified sensor. /// - /// This will cause subsequent calls to ``fetchAnchored(_:)`` to potentially re-fetch already-processed samples. + /// This will cause subsequent calls to ``fetchAnchored(_:batchSize:)`` to potentially re-fetch already-processed samples. public func resetQueryAnchors(for sensor: any AnySensor) throws { try localStorage.deleteAll { rawKey in rawKey.starts(with: "\(SensorKit.queryAnchorKeyPrefix).\(sensor.id)") diff --git a/Sources/SpeziSensorKit/SpeziSensorKit.docc/SpeziSensorKit.md b/Sources/SpeziSensorKit/SpeziSensorKit.docc/SpeziSensorKit.md index 22011eb88..87db27f56 100644 --- a/Sources/SpeziSensorKit/SpeziSensorKit.docc/SpeziSensorKit.md +++ b/Sources/SpeziSensorKit/SpeziSensorKit.docc/SpeziSensorKit.md @@ -54,7 +54,7 @@ You use the ``Sensor`` type to interact with individual SensorKit sensors. #### SensorKit Sample Safe Representations Since most sensors' returned samples aren't thread-safe, SpeziSensorKit provides so-called "safe representations" for most sensors, which are small Swift structs that act as `Sendable` representations of the data returned by a ``Sensor``. -When you fetch data from a sensor (e.g., using ``Sensor/fetch(from:timeRange:)`` or ``SensorKit-class/fetchAnchored(_:)``), SpeziSensorKit automatically transforms the raw SensorKit samples into their respective safe representations. +When you fetch data from a sensor (e.g., using ``Sensor/fetch(from:timeRange:)`` or ``SensorKit-class/fetchAnchored(_:batchSize:)``), SpeziSensorKit automatically transforms the raw SensorKit samples into their respective safe representations. For some sensors this step also performs additional pre-processing; for example, when fetching ECG data, SensorKit returns a bunch of individual [`SRElectrocardiogramSample`](https://developer.apple.com/documentation/sensorkit/srelectrocardiogramsample) objects each of which represents just a small part of the total ECG. Fetching ECG data via SpeziSensorKit implicitly processes the raw `SRElectrocardiogramSample`s into ``SensorKitECGSession``s, each of which represents a logical ECG session. @@ -77,8 +77,8 @@ for device in devices { #### Fetching Data: Anchored Queries -You can implement continuous SensorKit data fetching using the Anchored Fetching API (e.g., ``SensorKit-class/fetchAnchored(_:)``). -The ``SensorKit-class/fetchAnchored(_:)`` function returns an `AsyncSequence` which fetches batches of SensorKit data on-demand (as the sequence is being iterated), and keeps track of the most recent already-fetched timestamp. +You can implement continuous SensorKit data fetching using the Anchored Fetching API (e.g., ``SensorKit-class/fetchAnchored(_:batchSize:)``). +The ``SensorKit-class/fetchAnchored(_:batchSize:)`` function returns an `AsyncSequence` which fetches batches of SensorKit data on-demand (as the sequence is being iterated), and keeps track of the most recent already-fetched timestamp. ```swift import SpeziSensorKit diff --git a/Sources/SpeziSpeech/CONTRIBUTORS.md b/Sources/SpeziSpeech/CONTRIBUTORS.md index dbd5e3aba..ee24d754e 100644 --- a/Sources/SpeziSpeech/CONTRIBUTORS.md +++ b/Sources/SpeziSpeech/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziSpeech contributors -==================== +# SpeziSpeech contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziStorage/CONTRIBUTORS.md b/Sources/SpeziStorage/CONTRIBUTORS.md index 90a974bc9..69c9feeee 100644 --- a/Sources/SpeziStorage/CONTRIBUTORS.md +++ b/Sources/SpeziStorage/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziStorage contributors -==================== +# SpeziStorage contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziStudy/CONTRIBUTORS.md b/Sources/SpeziStudy/CONTRIBUTORS.md index 2baf38fe9..2dec9b1b5 100644 --- a/Sources/SpeziStudy/CONTRIBUTORS.md +++ b/Sources/SpeziStudy/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -SpeziStudy contributors -==================== +# SpeziStudy contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziStudy/Study Manager/StudyManager+Other.swift b/Sources/SpeziStudy/Study Manager/StudyManager+Other.swift index 05769a456..c69d62f11 100644 --- a/Sources/SpeziStudy/Study Manager/StudyManager+Other.swift +++ b/Sources/SpeziStudy/Study Manager/StudyManager+Other.swift @@ -8,6 +8,7 @@ import Foundation import class ModelsR4.Questionnaire +import SpeziFoundation import SpeziScheduler import SpeziStudyDefinition import SwiftData diff --git a/Sources/SpeziStudy/Study Manager/StudyManager.swift b/Sources/SpeziStudy/Study Manager/StudyManager.swift index a064f8a80..0e8a4990c 100644 --- a/Sources/SpeziStudy/Study Manager/StudyManager.swift +++ b/Sources/SpeziStudy/Study Manager/StudyManager.swift @@ -149,12 +149,17 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable { @_documentation(visibility: internal) public func configure() { // swiftlint:disable:this function_body_length typealias Task = _Concurrency.Task - Task { @MainActor in - let enrollments = try modelContext.fetch(FetchDescriptor()) - try registerStudyTasksWithScheduler(for: enrollments) - try await setupStudyBackgroundComponents(for: enrollments) - try removeOrphanedTasks() - try removeOrphanedStudyBundles() + Task { @MainActor [self] in + let enrollments: [StudyEnrollment] + do { + enrollments = try modelContext.fetch(FetchDescriptor()) + try registerStudyTasksWithScheduler(for: enrollments) + try await setupStudyBackgroundComponents(for: enrollments) + try removeOrphanedTasks() + try removeOrphanedStudyBundles() + } catch { + return + } #if targetEnvironment(simulator) if autosaveTask == nil { autosaveTask = Task.detached { diff --git a/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle+FileReference.swift b/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle+FileReference.swift index 25d92374e..93d585103 100644 --- a/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle+FileReference.swift +++ b/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle+FileReference.swift @@ -14,7 +14,7 @@ import SpeziLocalization extension StudyBundle { /// A reference to a non-localized version of a file within a StudyBundle. /// - /// ``FileReference``s are non-localized references to files stored within a ``StudyBundle``; they can be resolved against a specific `Locale` using ``StudyBundle/resolve(_:in:using:)``. + /// ``FileReference``s are non-localized references to files stored within a ``StudyBundle``; they can be resolved against a specific `Locale` using ``StudyBundle/resolve(_:in:using:fallback:)``. public struct FileReference: Hashable, Sendable, Codable, CustomStringConvertible { /// A ``StudyBundle/FileReference``'s category. /// diff --git a/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle.swift b/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle.swift index 2a64c46d9..f7b8304c0 100644 --- a/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle.swift +++ b/Sources/SpeziStudyDefinition/StudyBundle/StudyBundle.swift @@ -46,17 +46,17 @@ extension UTType { /// - ``studyDefinition`` /// /// ### Accessing the Bundle's Contents -/// - ``consentText(for:in:using:)`` -/// - ``questionnaire(for:in:using:)`` +/// - ``consentText(for:in:using:fallbackLocale:)`` +/// - ``questionnaire(for:in:using:fallback:)`` /// - ``displayTitle(for:in:using:)`` -/// - ``resolve(_:in:using:)`` +/// - ``resolve(_:in:using:fallback:)`` /// /// ### Operations /// - ``copy(to:)`` /// /// ### Creating Study Bundles /// - ``writeToDisk(at:definition:files:)`` -/// - ``FileInput`` +/// - ``FileResourceInput`` /// /// ### Other /// - ``fileExtension`` diff --git a/Sources/SpeziStudyDefinition/StudyBundle/Validation/StudyBundle+QuestionnaireValidation.swift b/Sources/SpeziStudyDefinition/StudyBundle/Validation/StudyBundle+QuestionnaireValidation.swift index cd4c1730a..84b204f5d 100644 --- a/Sources/SpeziStudyDefinition/StudyBundle/Validation/StudyBundle+QuestionnaireValidation.swift +++ b/Sources/SpeziStudyDefinition/StudyBundle/Validation/StudyBundle+QuestionnaireValidation.swift @@ -143,12 +143,12 @@ extension StudyBundle.BundleValidationIssue { } /// Creates a new ``Path`` that accesses the root-level field `name`. - static subscript(dynamicMember name: String) -> Self { + public static subscript(dynamicMember name: String) -> Self { .root.appending(name) } /// Creates a new ``Path`` by appending a field access component. - subscript(dynamicMember name: String) -> Self { + public subscript(dynamicMember name: String) -> Self { Self(components + CollectionOfOne(.field(name: name))) } diff --git a/Sources/SpeziStudyDefinition/StudyDefinition.swift b/Sources/SpeziStudyDefinition/StudyDefinition.swift index c6844aadf..36f672cbe 100644 --- a/Sources/SpeziStudyDefinition/StudyDefinition.swift +++ b/Sources/SpeziStudyDefinition/StudyDefinition.swift @@ -80,7 +80,7 @@ public typealias StudyDefinitionElement = Hashable & Codable & Sendable /// - ``allCollectedHealthData(includingOptionalSampleTypes:)`` /// - ``component(withId:)`` /// - ``removeComponent(at:)`` -/// - ``validate()`` +/// - ``validate(in:)`` @available(iOS 18, macOS 15, watchOS 11, *) public struct StudyDefinition: Identifiable, Hashable, Sendable, Encodable, DecodableWithConfiguration { /// The ``StudyDefinition`` type's current schema version. diff --git a/Sources/SpeziValidation/Views/VerifiableTextField.swift b/Sources/SpeziValidation/Views/VerifiableTextField.swift index 20dee3897..44259934c 100644 --- a/Sources/SpeziValidation/Views/VerifiableTextField.swift +++ b/Sources/SpeziValidation/Views/VerifiableTextField.swift @@ -67,11 +67,24 @@ public struct VerifiableTextField: View { _ label: LocalizedStringResource, text: Binding, type: TextFieldType = .text, - @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + @ViewBuilder footer: () -> FieldFooter ) where FieldLabel == Text { self.init(text: text, type: type, label: { Text(label) }, footer: footer) } + /// Create a new verifiable text field. + /// - Parameters: + /// - label: The localized text label for the text field. + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + public init( + _ label: LocalizedStringResource, + text: Binding, + type: TextFieldType = .text + ) where FieldLabel == Text, FieldFooter == EmptyView { + self.init(label, text: text, type: type, footer: EmptyView.init) + } + /// Create a new verifiable text field. /// - Parameters: /// - text: The binding to the stored value. @@ -82,13 +95,26 @@ public struct VerifiableTextField: View { text: Binding, type: TextFieldType = .text, @ViewBuilder label: () -> FieldLabel, - @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + @ViewBuilder footer: () -> FieldFooter ) { self._text = text self.fieldType = type self.label = label() self.textFieldFooter = footer() } + + /// Create a new verifiable text field. + /// - Parameters: + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + /// - label: An arbitrary label for the text field. + public init( + text: Binding, + type: TextFieldType = .text, + @ViewBuilder label: () -> FieldLabel + ) where FieldFooter == EmptyView { + self.init(text: text, type: type, label: label, footer: EmptyView.init) + } } diff --git a/Sources/SpeziViews/CONTRIBUTORS.md b/Sources/SpeziViews/CONTRIBUTORS.md index ef865d40f..c7f223c35 100644 --- a/Sources/SpeziViews/CONTRIBUTORS.md +++ b/Sources/SpeziViews/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -SpeziViews contributors -==================== +# SpeziViews contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Vishnu Ravi](https://github.com/vishnuravi) diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index d50f7d20d..9f8051028 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -113,12 +113,6 @@ Default layouts and utilities to automatically adapt your view layouts to dynami - ``ReverseLabelStyle`` - ``SwiftUI/LabelStyle/reverse`` -### Localization - -- ``Foundation/LocalizedStringResource/BundleDescription/atURL(from:)`` -- ``Foundation/LocalizedStringResource/localizedString(for:)`` -- ``Swift/StringProtocol/localized(_:)`` - ### Readers - ``HorizontalGeometryReader`` diff --git a/Sources/SpeziViews/Views/Button/AsyncButton.swift b/Sources/SpeziViews/Views/Button/AsyncButton.swift index 1fd3aab4d..bb4e8d775 100644 --- a/Sources/SpeziViews/Views/Button/AsyncButton.swift +++ b/Sources/SpeziViews/Views/Button/AsyncButton.swift @@ -11,6 +11,12 @@ import SwiftUI +private enum AsyncButtonGroupResult { + case debounce(showProcessing: Bool) + case result(Result) +} + + /// A SwiftUI `Button` that initiates an asynchronous (throwing) action. /// /// The `AsyncButton` closely works together with the ``ViewState`` to control processing and error states. @@ -65,11 +71,6 @@ import SwiftUI @available(iOS 18, macOS 15, watchOS 11, *) @MainActor public struct AsyncButton: View { - private enum GroupResult { - case debounce - case result(Result) - } - private enum AsyncButtonState { case idle case disabled @@ -263,10 +264,33 @@ public struct AsyncButton: View { withAnimation(.easeOut(duration: 0.2)) { viewState = .processing } - let result = await withTaskGroup(of: GroupResult.self) { group in + let result = await executeDebouncedAction(action) + + switch result { + case .success: + // the button action might set the state back to idle to prevent this animation + if viewState != .idle { + withAnimation(.easeIn(duration: 0.2)) { + viewState = .idle + } + } + case let .failure(error): + viewState = .error(AnyLocalizedError( + error: error, + defaultErrorDescription: defaultErrorDescription + )) + } + } + + private func executeDebouncedAction( + _ action: @MainActor @escaping () async throws -> Void + ) async -> Result { + let processingDebounceDuration = processingDebounceDuration + return await withTaskGroup(of: AsyncButtonGroupResult.self) { group in group.addTask { - await debounceProcessingIndicator() - return .debounce + try? await Task.sleep(for: processingDebounceDuration) + // catching the case where the action runs a tiny bit faster than the debounce timer + return .debounce(showProcessing: !Task.isCancelled) } group.addTask { do { @@ -281,6 +305,11 @@ public struct AsyncButton: View { if case .result = first { group.cancelAll() // cancel the debounce } + if case .debounce(showProcessing: true) = first { + withAnimation(.easeOut(duration: 0.2)) { + buttonState = .disabledAndProcessing + } + } guard let second = await group.next() else { fatalError("Unexpected TaskGroup state.") } @@ -291,31 +320,6 @@ public struct AsyncButton: View { fatalError("TaskGroup inconsistency.") } } - switch result { - case .success: - // the button action might set the state back to idle to prevent this animation - if viewState != .idle { - withAnimation(.easeIn(duration: 0.2)) { - viewState = .idle - } - } - case let .failure(error): - viewState = .error(AnyLocalizedError( - error: error, - defaultErrorDescription: defaultErrorDescription - )) - } - } - - private func debounceProcessingIndicator() async { - try? await Task.sleep(for: processingDebounceDuration) - // this is actually important to catch cases where the action runs a tiny bit faster than the debounce timer - guard !Task.isCancelled else { - return - } - withAnimation(.easeOut(duration: 0.2)) { - buttonState = .disabledAndProcessing - } } } diff --git a/Sources/SpeziViews/Views/Drawing/CanvasView.swift b/Sources/SpeziViews/Views/Drawing/CanvasView.swift index b0119c1c8..bbb6be1d5 100644 --- a/Sources/SpeziViews/Views/Drawing/CanvasView.swift +++ b/Sources/SpeziViews/Views/Drawing/CanvasView.swift @@ -59,6 +59,7 @@ public struct CanvasView: View { @Binding private var tool: any PKTool @Binding private var isDrawing: Bool @Binding private var showToolPicker: Bool + private let synchronizesToolPicker: Bool public var body: some View { @@ -68,7 +69,8 @@ public struct CanvasView: View { isDrawing: $isDrawing, tool: $tool, drawingPolicy: drawingPolicy, - showToolPicker: $showToolPicker + showToolPicker: $showToolPicker, + synchronizesToolPicker: synchronizesToolPicker ) .accessibilityIdentifier("Canvas") .preference(key: CanvasSizePreferenceKey.self, value: geometry.size) @@ -96,6 +98,7 @@ public struct CanvasView: View { self._isDrawing = isDrawing self._tool = tool self._showToolPicker = showToolPicker + self.synchronizesToolPicker = true } /// Creates a new ``CanvasView`` providing a SwiftUI wrapper around the PencilKit `PKCanvasView` @@ -112,14 +115,12 @@ public struct CanvasView: View { tool: PKInkingTool = PKInkingTool(.pen, color: .label, width: 1), drawingPolicy: PKCanvasViewDrawingPolicy = .anyInput ) { - self.init( - drawing: drawing, - tool: .constant(tool), - drawingPolicy: drawingPolicy, - isDrawing: isDrawing, - // we're using a fixed tool, so there is no point in allowing the tool picker be shown. - showToolPicker: .constant(false) - ) + self.drawingPolicy = drawingPolicy + self._drawing = drawing + self._isDrawing = isDrawing + self._tool = .constant(tool) + self._showToolPicker = .constant(false) + self.synchronizesToolPicker = false } @@ -144,7 +145,7 @@ public struct CanvasView: View { extension CanvasView { - private struct Impl: UIViewRepresentable { + fileprivate struct Impl: UIViewRepresentable { final class Coordinator: NSObject, PKCanvasViewDelegate, PKToolPickerObserver { let parent: Impl @@ -181,9 +182,12 @@ extension CanvasView { @MainActor private func handleToolDidChange(_ toolPicker: PKToolPicker) { - if #available(iOS 26, visionOS 26, *) { - // we don't support custom items, so we should never run into a nil value here? - parent.tool = toolPicker.selectedToolItem.tool ?? toolPicker.selectedTool + if #available(iOS 18.0, visionOS 2.0, *) { + guard let tool = Impl.tool(from: toolPicker.selectedToolItem), + !Impl.toolsMatch(parent.tool, tool) else { + return + } + parent.tool = tool } else { parent.tool = toolPicker.selectedTool } @@ -198,28 +202,41 @@ extension CanvasView { @Binding private var tool: any PKTool @Binding private var isDrawing: Bool @Binding private var showToolPicker: Bool + private let synchronizesToolPicker: Bool init( drawing: Binding, isDrawing: Binding, tool: Binding, drawingPolicy: PKCanvasViewDrawingPolicy, - showToolPicker: Binding + showToolPicker: Binding, + synchronizesToolPicker: Bool ) { self._drawing = drawing self._isDrawing = isDrawing self._tool = tool self.drawingPolicy = drawingPolicy self._showToolPicker = showToolPicker + self.synchronizesToolPicker = synchronizesToolPicker + } + + private static func toolsMatch(_ lhs: any PKTool, _ rhs: any PKTool) -> Bool { + guard let lhs = lhs as? any Equatable, + let rhs = rhs as? any Equatable else { + return false + } + return lhs.isEqual(rhs) } - func makeUIView(context: Context) -> PKCanvasView { let canvasView = PKCanvasView() canvasView.delegate = context.coordinator canvasView.backgroundColor = .clear canvasView.isOpaque = false - toolPicker.addObserver(context.coordinator) + if synchronizesToolPicker { + toolPicker.addObserver(context.coordinator) + toolPicker.addObserver(canvasView) + } return canvasView } @@ -227,12 +244,14 @@ extension CanvasView { if canvasView.drawing != drawing { canvasView.drawing = drawing } - toolPicker.selectedTool = tool + canvasView.tool = tool canvasView.drawingPolicy = drawingPolicy - toolPicker.addObserver(canvasView) - toolPicker.setVisible(showToolPicker, forFirstResponder: canvasView) - if showToolPicker { - canvasView.becomeFirstResponder() + if synchronizesToolPicker { + updateToolPickerSelection(toolPicker) + toolPicker.setVisible(showToolPicker, forFirstResponder: canvasView) + if showToolPicker { + canvasView.becomeFirstResponder() + } } if #available(iOS 18.0, visionOS 2.0, *) { canvasView.isDrawingEnabled = context.environment.isEnabled @@ -242,6 +261,61 @@ extension CanvasView { func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + private func updateToolPickerSelection(_ toolPicker: PKToolPicker) { + if #available(iOS 18.0, visionOS 2.0, *) { + guard let item = Self.toolPickerItem(for: tool) else { + return + } + if let selectedTool = Self.tool(from: toolPicker.selectedToolItem), Self.toolsMatch(selectedTool, tool) { + return + } + toolPicker.selectedToolItem = item + } else { + guard !Self.toolsMatch(toolPicker.selectedTool, tool) else { + return + } + toolPicker.selectedTool = tool + } + } + } +} + + +@available(iOS 18.0, visionOS 2.0, *) +extension CanvasView.Impl { + private static func toolPickerItem(for tool: any PKTool) -> PKToolPickerItem? { + if let tool = tool as? PKInkingTool { + if #available(iOS 26.0, visionOS 26.0, *) { + return PKToolPickerInkingItem( + type: tool.inkType, + color: tool.color, + width: tool.width, + azimuth: tool.azimuth + ) + } + return PKToolPickerInkingItem(type: tool.inkType, color: tool.color, width: tool.width) + } else if let tool = tool as? PKEraserTool { + return PKToolPickerEraserItem(type: tool.eraserType, width: tool.width) + } else if tool is PKLassoTool { + return PKToolPickerLassoItem() + } else { + return nil + } + } + + private static func tool(from item: PKToolPickerItem) -> (any PKTool)? { + if #available(iOS 26.0, visionOS 26.0, *), let tool = item.tool { + return tool + } else if let item = item as? PKToolPickerInkingItem { + return item.inkingTool + } else if let item = item as? PKToolPickerEraserItem { + return item.eraserTool + } else if let item = item as? PKToolPickerLassoItem { + return item.lassoTool + } else { + return nil + } } } diff --git a/Sources/SpeziViews/Views/Sharing/ShareSheetInput.swift b/Sources/SpeziViews/Views/Sharing/ShareSheetInput.swift index fa83975fe..a446ef95e 100644 --- a/Sources/SpeziViews/Views/Sharing/ShareSheetInput.swift +++ b/Sources/SpeziViews/Views/Sharing/ShareSheetInput.swift @@ -44,7 +44,6 @@ struct CombinedShareSheetInput: Identifiable, Equatable { /// /// ## Topics /// ### Initializers -/// - ``init(_:)-(HasDirectUIActivityViewControllerSupportHashable)`` /// - ``init(_:id:)`` /// - ``init(_:)-(NSItemProviderWriting)`` /// - ``init(_:)-(T)`` diff --git a/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift b/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift index b153abdfe..9aa3a5875 100644 --- a/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift +++ b/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift @@ -29,6 +29,10 @@ struct MarkdownViewImageProvider: ImageProvider { } } } + + nonisolated init(url: URL?) { + self.url = url + } } func makeImage(url: URL?) -> some View { diff --git a/Sources/SpeziViews/Views/Text/MarkdownView.swift b/Sources/SpeziViews/Views/Text/MarkdownView.swift index 363dd31eb..6e2f7f5a0 100644 --- a/Sources/SpeziViews/Views/Text/MarkdownView.swift +++ b/Sources/SpeziViews/Views/Text/MarkdownView.swift @@ -152,7 +152,7 @@ public struct MarkdownView: View { public init( document: MarkdownDocument, dividerRule: DividerRule = .never, - @ViewBuilder _ customElementViewProvider: @escaping CustomElementViewProvider = { _, _ in EmptyView() } + @ViewBuilder _ customElementViewProvider: @escaping CustomElementViewProvider ) { self.init( loadingState: .loaded(document), @@ -162,6 +162,20 @@ public struct MarkdownView: View { } + /// Creates a new MarkdownView. + /// + /// - parameter document: The [`MarkdownDocument`](../../../SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md) the view should display. + /// - parameter dividerRule: Defines when the view should place a `Divider` between two sections. Defaults to ``DividerRule/never``. + public init( + document: MarkdownDocument, + dividerRule: DividerRule = .never + ) where CustomElementView == EmptyView { + self.init(document: document, dividerRule: dividerRule) { _, _ in + EmptyView() + } + } + + @ViewBuilder private func view(for block: MarkdownDocument.Block, at idx: Int, in document: MarkdownDocument) -> some View { switch block { @@ -205,7 +219,7 @@ extension MarkdownView { public init( markdownDocument: MarkdownDocument, dividerRule: DividerRule = .never, - @ViewBuilder customElementViewProvider: @escaping CustomElementViewProvider = { _, _ in EmptyView() } + @ViewBuilder customElementViewProvider: @escaping CustomElementViewProvider ) { self.init( loadingState: .loaded(markdownDocument), @@ -214,6 +228,20 @@ extension MarkdownView { ) } + /// Creates a new MarkdownView. + /// + /// - parameter markdownDocument: The [`MarkdownDocument`](../../../SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md) the view should display. + /// - parameter dividerRule: Defines when the view should place a `Divider` between two sections. Defaults to ``DividerRule/never``. + @available(*, deprecated, renamed: "init(document:dividerRule:)") + public init( + markdownDocument: MarkdownDocument, + dividerRule: DividerRule = .never + ) where CustomElementView == EmptyView { + self.init(markdownDocument: markdownDocument, dividerRule: dividerRule) { _, _ in + EmptyView() + } + } + /// Creates a ``MarkdownView`` that displays the content of a markdown file as an UTF-8 representation that is loaded asynchronously. /// - Parameters: /// - asyncMarkdown: An async closure to load the markdown in an UTF-8 representation. diff --git a/Sources/ThreadLocal/CONTRIBUTORS.md b/Sources/ThreadLocal/CONTRIBUTORS.md index d3ff24940..34a8e0544 100644 --- a/Sources/ThreadLocal/CONTRIBUTORS.md +++ b/Sources/ThreadLocal/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -ThreadLocal contributors -==================== +# ThreadLocal contributors * [Lukas Kollmer](https://github.com/lukaskollmer) diff --git a/Sources/XCTHealthKit/CONTRIBUTORS.md b/Sources/XCTHealthKit/CONTRIBUTORS.md index 5dc0ebdb5..1748882d8 100644 --- a/Sources/XCTHealthKit/CONTRIBUTORS.md +++ b/Sources/XCTHealthKit/CONTRIBUTORS.md @@ -10,8 +10,7 @@ --> -XCTHealthKit contributors -==================== +# XCTHealthKit contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Lukas Kollmer](https://github.com/lukaskollmer) diff --git a/Sources/XCTHealthKit/XCTHealthKitAddSampleInput.swift b/Sources/XCTHealthKit/XCTHealthKitAddSampleInput.swift index 493499552..04c16d407 100644 --- a/Sources/XCTHealthKit/XCTHealthKitAddSampleInput.swift +++ b/Sources/XCTHealthKit/XCTHealthKitAddSampleInput.swift @@ -41,6 +41,7 @@ public struct NewHealthSampleInput { extension NewHealthSampleInput.EnterSampleValueHandler { /// Creates a new sample value input handler that inserts a numeric value into a text field. + /// - parameter value: The numeric value to insert. /// - parameter textFieldPredicate: optional predicate allowing the caller to specify which exact text field the value should be inserted into public static func enterSimpleNumericValue(_ value: Double, inTextField textFieldPredicate: NSPredicate? = nil) -> Self { nonisolated(unsafe) let textFieldPredicate = textFieldPredicate diff --git a/Sources/XCTHealthKit/XCTest+HealthRecord.swift b/Sources/XCTHealthKit/XCTest+HealthRecord.swift index 55dd662d2..0063ccc73 100644 --- a/Sources/XCTHealthKit/XCTest+HealthRecord.swift +++ b/Sources/XCTHealthKit/XCTest+HealthRecord.swift @@ -90,7 +90,7 @@ extension XCTestCase { /// - systemUnderTest: The app under test (`XCUIApplication`) that initiates the Health Records authorization. /// Defaults to a new `XCUIApplication` instance. /// - healthApp: The `XCUIApplication` instance representing the Health app. Defaults to `.healthApp`. - /// - account: The `HealthAppHealthRecordAccount` to use when authorizing access. Defaults to `.sampleA`. + /// - accounts: The `HealthAppHealthRecordAccount`s to use when authorizing access. Defaults to all available sample accounts. /// - healthRecordTypes: The clinical record types to enable for sharing. Defaults to all available types. /// - automaticallyShareUpdates: A Boolean value indicating whether to enable automatic sharing of updates /// when prompted. Defaults to `true`. diff --git a/Sources/XCTRuntimeAssertions/XCTRuntimeAssertions.docc/XCTRuntimeAssertions.md b/Sources/XCTRuntimeAssertions/XCTRuntimeAssertions.docc/XCTRuntimeAssertions.md index 3e0dac59a..1a16e247f 100644 --- a/Sources/XCTRuntimeAssertions/XCTRuntimeAssertions.docc/XCTRuntimeAssertions.md +++ b/Sources/XCTRuntimeAssertions/XCTRuntimeAssertions.docc/XCTRuntimeAssertions.md @@ -57,4 +57,4 @@ XCTRuntimePrecondition { ### Testing Preconditions - ``XCTRuntimePrecondition(validateRuntimeAssertion:timeout:_:file:line:_:)-2c7hq`` -- ``XCTRuntimePrecondition(validateRuntimeAssertion:timeout:_:file:line:_:)-77wsm`` +- ``XCTRuntimePrecondition(validateRuntimeAssertion:timeout:_:file:line:_:)-8m9qt`` diff --git a/Sources/XCTSpeziAccount/XCTSpeziAccount.docc/XCTSpeziAccount.md b/Sources/XCTSpeziAccount/XCTSpeziAccount.docc/XCTSpeziAccount.md index f94f16077..9961b501f 100644 --- a/Sources/XCTSpeziAccount/XCTSpeziAccount.docc/XCTSpeziAccount.md +++ b/Sources/XCTSpeziAccount/XCTSpeziAccount.docc/XCTSpeziAccount.md @@ -16,12 +16,12 @@ SPDX-License-Identifier: MIT ### Login -- ``XCTest/XCUIApplication/login(email:password:)`` -- ``XCTest/XCUIApplication/login(username:password:)`` +- ``XCUIAutomation/XCUIApplication/login(email:password:)`` +- ``XCUIAutomation/XCUIApplication/login(username:password:)`` ### Signup Form -- ``XCTest/XCUIApplication/fillSignupForm(email:password:name:genderIdentity:supplyDateOfBirth:)`` -- ``XCTest/XCUIApplication/updateGenderIdentity(from:to:file:line:)`` -- ``XCTest/XCUIApplication/changeDateOfBirth()`` -- ``XCTest/XCUIApplication/closeSignupForm(discardChangesIfAsked:)`` +- ``XCUIAutomation/XCUIApplication/fillSignupForm(email:password:name:genderIdentity:supplyDateOfBirth:)`` +- ``XCUIAutomation/XCUIApplication/updateGenderIdentity(from:to:file:line:)`` +- ``XCUIAutomation/XCUIApplication/changeDateOfBirth()`` +- ``XCUIAutomation/XCUIApplication/closeSignupForm(discardChangesIfAsked:)`` diff --git a/Sources/XCTSpeziAccount/XCUIApplication+AccountValues.swift b/Sources/XCTSpeziAccount/XCUIApplication+AccountValues.swift index c7f9a831c..6d293b12e 100644 --- a/Sources/XCTSpeziAccount/XCUIApplication+AccountValues.swift +++ b/Sources/XCTSpeziAccount/XCUIApplication+AccountValues.swift @@ -35,12 +35,19 @@ extension XCUIApplication { buttons["Add Date of Birth"].tap() } - XCTAssertTrue(datePickers.firstMatch.waitForExistence(timeout: 2.0), "Failed to find date of birth picker") - datePickers.firstMatch.tap() + let datePicker = datePickers.firstMatch + XCTAssertTrue(datePicker.waitForExistence(timeout: 4.0), "Failed to find date of birth picker") + if datePicker.buttons.firstMatch.wait(for: \.isHittable, toEqual: true, timeout: 2.0) { + datePicker.buttons.firstMatch.tap() + } else { + XCTAssertTrue(datePicker.wait(for: \.isHittable, toEqual: true, timeout: 4.0), "Failed to find hittable date of birth picker") + datePicker.tap() + } // navigate to previous month and select the first date - XCTAssertTrue(datePickers.buttons["Previous Month"].waitForExistence(timeout: 2.0), "Couldn't find 'Previous Month' button") - datePickers.buttons["Previous Month"].tap() + let previousMonthButton = datePickers.buttons["Previous Month"] + XCTAssertTrue(previousMonthButton.wait(for: \.isHittable, toEqual: true, timeout: 4.0), "Couldn't find hittable 'Previous Month' button") + previousMonthButton.tap() // Tap the first button that contains "Friday" in its label let fridayButton = buttons.containing(NSPredicate(format: "label CONTAINS[c] %@", "Friday")).firstMatch diff --git a/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md b/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md index b43edecda..d3b61729d 100644 --- a/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md +++ b/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md @@ -16,8 +16,8 @@ SPDX-License-Identifier: MIT ### Notification Authorization -- ``XCTest/XCUIApplication/NotificationAuthorizationAction`` -- ``XCTest/XCUIApplication/confirmNotificationAuthorization(action:timeout:requireAlertToAppear:)`` +- ``XCUIAutomation/XCUIApplication/NotificationAuthorizationAction`` +- ``XCUIAutomation/XCUIApplication/confirmNotificationAuthorization(action:timeout:requireAlertToAppear:)`` ### Notification Requests -- ``XCTest/XCUIApplication/assertNotificationDetails(identifier:title:subtitle:body:category:thread:sound:interruption:type:nextTrigger:nextTriggerExistenceTimeout:)`` +- ``XCUIAutomation/XCUIApplication/assertNotificationDetails(identifier:title:subtitle:body:category:thread:sound:interruption:type:nextTrigger:nextTriggerExistenceTimeout:)`` diff --git a/Sources/XCTestExtensions/CONTRIBUTORS.md b/Sources/XCTestExtensions/CONTRIBUTORS.md index 5644845d1..32b1cbf8e 100644 --- a/Sources/XCTestExtensions/CONTRIBUTORS.md +++ b/Sources/XCTestExtensions/CONTRIBUTORS.md @@ -8,8 +8,7 @@ SPDX-License-Identifier: MIT --> -XCTestExtensions contributors -==================== +# XCTestExtensions contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) * [Andreas Bauer](https://github.com/bauer-andreas) diff --git a/Tests/ByteCodingTests/ByteCodableTests.swift b/Tests/ByteCodingTests/ByteCodableTests.swift index 4b72f2747..e3e0c8cf5 100644 --- a/Tests/ByteCodingTests/ByteCodableTests.swift +++ b/Tests/ByteCodingTests/ByteCodableTests.swift @@ -46,7 +46,7 @@ struct ByteCodableTests { #expect(value == 3333) // BYTE CODABLE - let stringData = try #require("Hello World".data(using: .utf8)) + let stringData = Data("Hello World".utf8) let stringValue = try #require(String(data: stringData)) #expect(stringValue == "Hello World") } @@ -75,7 +75,7 @@ struct ByteCodableTests { @Test("String") func testString() throws { - let data = try #require("Hello World".data(using: .utf8)) + let data = Data("Hello World".utf8) try testIdentity(of: String.self, from: data) var empty = ByteBuffer() diff --git a/Tests/HealthKitOnFHIRTests/TimeZoneTests.swift b/Tests/HealthKitOnFHIRTests/TimeZoneTests.swift index ff7638694..aed763e59 100644 --- a/Tests/HealthKitOnFHIRTests/TimeZoneTests.swift +++ b/Tests/HealthKitOnFHIRTests/TimeZoneTests.swift @@ -277,13 +277,6 @@ struct TimeZoneTests { // swiftlint:disable:this type_body_length let startTimestamp = try #require(period.start?.value?.description) let endTimestamp = try #require(period.end?.value?.description) - let currentTimeZone = TimeZone.current - let totalMinutes = currentTimeZone.secondsFromGMT(for: startDate) / 60 - let hours = abs(totalMinutes / 60) - let minutes = abs(totalMinutes % 60) - let sign = totalMinutes >= 0 ? "+" : "-" - let expectedOffsetString = String(format: "%@%02d:%02d", sign, hours, minutes) - #expect(startTimestamp.starts(with: "2024-12-01T09:00:00"), "Start timestamp should begin with correct date and time") #expect(endTimestamp.starts(with: "2024-12-01T10:45:00"), "End timestamp should begin with correct date and time") #expect(try #require(period.start?.value).asNSDate() == startDate) @@ -314,13 +307,6 @@ struct TimeZoneTests { // swiftlint:disable:this type_body_length let timestamp = try #require(dateTime.value?.description) - let currentTimeZone = TimeZone.current - let totalMinutes = currentTimeZone.secondsFromGMT(for: startDate) / 60 - let hours = abs(totalMinutes / 60) - let minutes = abs(totalMinutes % 60) - let sign = totalMinutes >= 0 ? "+" : "-" - let expectedOffsetString = String(format: "%@%02d:%02d", sign, hours, minutes) - #expect(timestamp.starts(with: "2024-12-01T09:00:00"), "Timestamp should begin with correct date and time") #expect(try #require(dateTime.value).asNSDate() == startDate) } diff --git a/Tests/SpeziAccessGuardTests/SpeziAccessGuardTests.swift b/Tests/SpeziAccessGuardTests/SpeziAccessGuardTests.swift index e97a57aa5..e65155845 100644 --- a/Tests/SpeziAccessGuardTests/SpeziAccessGuardTests.swift +++ b/Tests/SpeziAccessGuardTests/SpeziAccessGuardTests.swift @@ -6,11 +6,88 @@ // SPDX-License-Identifier: MIT // +import Foundation +import Observation @testable import SpeziAccessGuard +import SwiftUI import Testing +#if os(iOS) +@Test("AccessGuards observes scene lifecycle notifications") +@MainActor +@available(iOS 17, *) +func accessGuardsObservesSceneLifecycleNotifications() async { + let accessGuards = AccessGuards { + TestLifecycleAccessGuard() + } + let model = accessGuards.model(for: AccessGuardIdentifier.notificationLifecycleTest) + accessGuards.configure() -@Test("Spezi Access Guard Works") -func speziAccessGuardWorks() throws { - #expect(true) + let beforeBackground = Date() + NotificationCenter.default.post(name: UIScene.didEnterBackgroundNotification, object: nil) + await Task.yield() + + #expect(model.didEnterBackgroundCount == 1) + #expect(accessGuards.lastEnteredBackground >= beforeBackground) + + NotificationCenter.default.post(name: UIScene.willEnterForegroundNotification, object: nil) + await Task.yield() + + #expect(model.willEnterForegroundCount == 1) + #expect(model.lastEnteredBackground == accessGuards.lastEnteredBackground) +} + + +@available(iOS 17, *) +private struct TestLifecycleAccessGuard: _AccessGuardConfig { + let id: AccessGuardIdentifier = .notificationLifecycleTest + let timeout: Duration = .seconds(1) + + // swiftlint:disable:next identifier_name + func _makeUnlockView(model: TestLifecycleAccessGuardModel) -> EmptyView { + EmptyView() + } +} + + +@available(iOS 17, *) +@Observable +@MainActor +private final class TestLifecycleAccessGuardModel: _AnyAccessGuardModel { + typealias UnlockInput = Void + typealias UnlockResult = Void + + let config: TestLifecycleAccessGuard + private(set) var isLocked = false + private(set) var didEnterBackgroundCount = 0 + private(set) var willEnterForegroundCount = 0 + private(set) var lastEnteredBackground: Date? + + init(config: TestLifecycleAccessGuard, context: AccessGuards) { + self.config = config + } + + func lock() { + isLocked = true + } + + func unlock(_ input: Void) async throws { + isLocked = false + } + + func didEnterBackground() { + didEnterBackgroundCount += 1 + } + + func willEnterForeground(lastEnteredBackground: Date) { + willEnterForegroundCount += 1 + self.lastEnteredBackground = lastEnteredBackground + } +} + + +@available(iOS 17, *) +extension AccessGuardIdentifier where AccessGuard == TestLifecycleAccessGuard { + fileprivate static let notificationLifecycleTest = Self(value: "edu.stanford.spezi.accessGuard.lifecycleTest") } +#endif diff --git a/Tests/SpeziFHIRTests/FHIRAttachment/TextContentExtractorTests.swift b/Tests/SpeziFHIRTests/FHIRAttachment/TextContentExtractorTests.swift index 5d6243118..79a8dccc6 100644 --- a/Tests/SpeziFHIRTests/FHIRAttachment/TextContentExtractorTests.swift +++ b/Tests/SpeziFHIRTests/FHIRAttachment/TextContentExtractorTests.swift @@ -18,7 +18,7 @@ struct TextContentExtractorTests { @Test("Successfully extracts content from text data") func testTextExtraction() throws { - let textData = "Welcome to SpeziFHIR".data(using: .utf8) ?? Data() + let textData = Data("Welcome to SpeziFHIR".utf8) let content = try textExtractor.extractContent(from: textData) #expect(content == "Welcome to SpeziFHIR") diff --git a/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift b/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift index c1fabc96b..785b30492 100644 --- a/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift +++ b/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift @@ -20,11 +20,17 @@ struct AnyAsyncSequenceTests { ) -> AsyncStream where T: Strideable, T.Stride: SignedInteger { let (stream, continuation) = AsyncStream.makeStream(of: T.self) Task { - for element in range { - try await Task.sleep(for: interval) - continuation.yield(element) + defer { + continuation.finish() + } + do { + for element in range { + try await Task.sleep(for: interval) + continuation.yield(element) + } + } catch { + return } - continuation.finish() } return stream } diff --git a/Tests/SpeziFoundationTests/RWLockTests.swift b/Tests/SpeziFoundationTests/RWLockTests.swift index 3ba7737ac..24fa29758 100644 --- a/Tests/SpeziFoundationTests/RWLockTests.swift +++ b/Tests/SpeziFoundationTests/RWLockTests.swift @@ -46,7 +46,7 @@ final class RWLockTests: XCTestCase { } Task.detached { - try await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(100)) lock.withWriteLock { expectation2.fulfill() } @@ -68,7 +68,7 @@ final class RWLockTests: XCTestCase { } Task.detached { - try await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(100)) lock.withReadLock { expectation2.fulfill() } @@ -161,7 +161,7 @@ final class RWLockTests: XCTestCase { } Task.detached { - try await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(100)) lock.withWriteLock { expectation2.fulfill() } @@ -183,7 +183,7 @@ final class RWLockTests: XCTestCase { } Task.detached { - try await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(100)) lock.withReadLock { expectation2.fulfill() } diff --git a/Tests/SpeziHealthKitTests/HKUnitTests.swift b/Tests/SpeziHealthKitTests/HKUnitTests.swift index cf326eca2..3e8be1dfc 100644 --- a/Tests/SpeziHealthKitTests/HKUnitTests.swift +++ b/Tests/SpeziHealthKitTests/HKUnitTests.swift @@ -960,8 +960,8 @@ extension HKUnitTests { #expect(try HKUnitA.parse(HKUnitA.smallCalorie().unitString) == HKUnitA.smallCalorie()) #expect(try HKUnitB.parse(HKUnitB.smallCalorie().unitString) == HKUnitB.smallCalorie()) - #expect(try HKUnitA.parse(HKUnitA.calorie().unitString) == HKUnitA.calorie()) - #expect(try HKUnitB.parse(HKUnitB.calorie().unitString) == HKUnitB.calorie()) + #expect(try HKUnitA.parse("cal") == HKUnitA.smallCalorie()) + #expect(try HKUnitB.parse("cal") == HKUnitB.smallCalorie()) // mol expectFailsToParse("mol<>") diff --git a/Tests/SpeziHealthKitTests/SampleTypesTests.swift b/Tests/SpeziHealthKitTests/SampleTypesTests.swift index cce2c5a12..f6ef32d3f 100644 --- a/Tests/SpeziHealthKitTests/SampleTypesTests.swift +++ b/Tests/SpeziHealthKitTests/SampleTypesTests.swift @@ -165,11 +165,9 @@ struct SampleTypesTests { @Test func sampleTypeSwitching() { let sampleType = SampleTypeProxy(.heartburn) - switch sampleType { - case .category(.heartburn): - #expect(Bool(true)) - default: + guard case .category(.heartburn) = sampleType else { Issue.record("Pattern matching failed.") + return } } } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Array.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Array.swift index 0e6a342cc..67e193472 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Array.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Array.swift @@ -56,7 +56,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Array Parameters") func testLLMFunctionArrayParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestArray(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+CustomTypes.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+CustomTypes.swift index a6b45d803..0fe468cf5 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+CustomTypes.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+CustomTypes.swift @@ -78,7 +78,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Custom Type Parameters") func testLLMFunctionCustomParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestCustom(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Dictionary.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Dictionary.swift index 5e450ea9c..716657dff 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Dictionary.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Dictionary.swift @@ -52,7 +52,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Dictionary Parameters") func testLLMFunctionDictionaryParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestDictionary() } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Enum.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Enum.swift index e29d7de6f..cc404b1a0 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Enum.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+Enum.swift @@ -61,7 +61,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Enum Parameters") func testLLMFunctionEnumParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestEnum(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+InvalidParameters.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+InvalidParameters.swift index fec0d079d..a54096e53 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+InvalidParameters.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+InvalidParameters.swift @@ -43,7 +43,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Invalid Parameters") func testLLMFunctionInvalidParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestInvalid(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+OptionalTypes.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+OptionalTypes.swift index e7f57def7..28fcaa323 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+OptionalTypes.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests+OptionalTypes.swift @@ -79,7 +79,7 @@ extension LLMOpenAIFunctionCallingParameterDSLTests { @Test("Test Optional Parameters") func testLLMFunctionOptionalParameters() async throws { // swiftlint:disable:this function_body_length let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestOptional(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests.swift b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests.swift index 90433164f..6d5506951 100644 --- a/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests.swift +++ b/Tests/SpeziLLMTests/LLMOpenAIFunctionCallingParameterDSLTests.swift @@ -57,7 +57,7 @@ struct LLMOpenAIFunctionCallingParameterDSLTests { // swiftlint:disable:this typ @Test("Test Primitive Type Parameters") func testLLMFunctionPrimitiveParameters() async throws { let llm = LLMOpenAISchema( - parameters: .init(modelType: "gpt-4o") + parameters: .init(modelType: .gpt4o) ) { LLMFunctionTestPrimitive(someInitArg: "testArg") } diff --git a/Tests/SpeziLLMTests/OpenAIInferenceTests/LLMOpenAIMockedInferenceTests+Setup.swift b/Tests/SpeziLLMTests/OpenAIInferenceTests/LLMOpenAIMockedInferenceTests+Setup.swift index 8b123a4c1..d4a9e59ae 100644 --- a/Tests/SpeziLLMTests/OpenAIInferenceTests/LLMOpenAIMockedInferenceTests+Setup.swift +++ b/Tests/SpeziLLMTests/OpenAIInferenceTests/LLMOpenAIMockedInferenceTests+Setup.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import Foundation import GeneratedOpenAIClient import OpenAPIRuntime @testable import Spezi @@ -42,51 +43,59 @@ extension LLMOpenAIMockedInferenceTests { /// Helper struct for building mocked streaming responses from the OpenAI API. struct ChatResponseBuilder { + private enum EncodingError: Error { + case invalidUTF8 + } + private static let responseChunkId = UUID().uuidString private static let responseTimestamp = Int(Date().timeIntervalSince1970) private let createMockChatResponse: (String) throws -> String = { message in - String(decoding: try JSONEncoder().encode( - Components.Schemas.CreateChatCompletionStreamResponse( - id: Self.responseChunkId, - choices: ([ - .init( - delta: .init(content: message, role: .assistant), - logprobs: .none, - finish_reason: nil, - index: 0 - ) - ]), - created: Self.responseTimestamp, - model: "spezi-mock", - object: .chat_period_completion_period_chunk - ) - ), as: UTF8.self) + try Self.utf8String(from: .init( + id: Self.responseChunkId, + choices: ([ + .init( + delta: .init(content: message, role: .assistant), + logprobs: .none, + finish_reason: nil, + index: 0 + ) + ]), + created: Self.responseTimestamp, + model: "spezi-mock", + object: .chat_period_completion_period_chunk + )) } private let createMockFunctionCallResponse: (String, String) throws -> String = { name, arguments in - String(decoding: try JSONEncoder().encode( - Components.Schemas.CreateChatCompletionStreamResponse( - id: Self.responseChunkId, - choices: ([ - .init( - delta: .init( - content: .none, - tool_calls: [.init(index: 0, id: UUID().uuidString, function: .init(name: name, arguments: arguments))] - ), - logprobs: .none, - finish_reason: nil, - index: 0 - ) - ]), - created: Self.responseTimestamp, - model: "spezi-mock", - object: .chat_period_completion_period_chunk - ) - ), as: UTF8.self) + try Self.utf8String(from: .init( + id: Self.responseChunkId, + choices: ([ + .init( + delta: .init( + content: .none, + tool_calls: [.init(index: 0, id: UUID().uuidString, function: .init(name: name, arguments: arguments))] + ), + logprobs: .none, + finish_reason: nil, + index: 0 + ) + ]), + created: Self.responseTimestamp, + model: "spezi-mock", + object: .chat_period_completion_period_chunk + )) } private var data: [String] = [] + + private static func utf8String(from response: Components.Schemas.CreateChatCompletionStreamResponse) throws -> String { + let data = try JSONEncoder().encode(response) + guard let string = String(data: data, encoding: .utf8) else { + throw EncodingError.invalidUTF8 + } + return string + } /// Appends a standard assistant text message to the response. /// - Parameter text: The message content to append. diff --git a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift index 9d7e83d76..8963e9349 100644 --- a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift +++ b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift @@ -117,7 +117,7 @@ final class NotificationsTests: XCTestCase { try await Task.sleep(for: .milliseconds(500)) // allow dispatch of Task above - let data = try XCTUnwrap("Hello World".data(using: .utf8)) + let data = Data("Hello World".utf8) #if os(iOS) || os(visionOS) || os(tvOS) delegate.application(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: data) diff --git a/Tests/SpeziSchedulerTests/ScheduleTests.swift b/Tests/SpeziSchedulerTests/ScheduleTests.swift index a31a96174..fea07aada 100644 --- a/Tests/SpeziSchedulerTests/ScheduleTests.swift +++ b/Tests/SpeziSchedulerTests/ScheduleTests.swift @@ -8,7 +8,6 @@ @testable import SpeziScheduler import XCTest -import XCTSpezi final class ScheduleTests: XCTestCase { diff --git a/Tests/SpeziSchedulerTests/SchedulerTests.swift b/Tests/SpeziSchedulerTests/SchedulerTests.swift index 980a78ad9..24cf93742 100644 --- a/Tests/SpeziSchedulerTests/SchedulerTests.swift +++ b/Tests/SpeziSchedulerTests/SchedulerTests.swift @@ -31,8 +31,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length /// The test should use ``Scheduler/deleteAllVersions(ofTask:)`` API. case viaId } - - + + @Test func scheduler() throws { // test simple scheduler initialization test @@ -187,14 +187,14 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length try module.deleteAllVersions(ofTask: "test-task") } - + @Test func nonTrivialTaskContextCoding() throws { let module = Scheduler(persistence: .inMemory) withDependencyResolution { module } - + let value = NonTrivialTaskContext( field0: .random(in: 0..<100), field1: .random(in: 0..<100), @@ -207,7 +207,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length field8: .random(in: 0..<100), field9: .random(in: 0..<100) ) - + let createTask = { try module.createOrUpdateTask( id: #function, @@ -219,12 +219,12 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length } ) } - + #expect(try createTask().didChange) #expect(try !createTask().didChange) } - - + + @Test func fetchingEventsAfterCompletion() async throws { let todayRange = Date.today.. Task { try module.createOrUpdateTask(id: id, title: "", instructions: "", schedule: schedule, completionPolicy: .anytime).task } - + let task = try addTask("task", schedule: .daily(hour: 0, minute: 0, startingAt: .now)) - + do { let events = try module.queryEvents(forTaskWithId: "task", in: Calendar.current.rangeOfDay(for: .now)) #expect(events.count == 1) try #require(events.first).complete() } - + // update the task (this will create a new version) let task2 = try addTask("task", schedule: .daily(hour: 23, minute: 59, second: 59, startingAt: .now)) #expect(task2 == task.nextVersion) #expect(task2.previousVersion == task) - + do { let events = try module.queryEvents(forTaskWithId: "task", in: Calendar.current.rangeOfDay(for: .now)) #expect(events.count == 1) try #require(events.first).complete() } - + try module.deleteAllVersions(of: task2) - + #expect(try module.queryAllTasks().isEmpty) } - - + + @Test func deleteTaskSingleVersionNoOutcomes() async throws { let cal = Calendar.current @@ -309,7 +309,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + @discardableResult func addTask(_ id: String, startingAt startDate: Date) throws -> Task { let (task, didChange) = try module.createOrUpdateTask( @@ -323,16 +323,16 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didChange) return task } - + let task = try addTask("task", startingAt: cal.startOfMonth(for: .now)) - + try module.deleteTasks(task) - + #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) } - - + + @Test func deleteTaskSingleVersionSomeOutcomes() async throws { let cal = Calendar.current @@ -341,7 +341,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + @discardableResult func addTask(_ id: String, startingAt startDate: Date) throws -> Task { let (task, didChange) = try module.createOrUpdateTask( @@ -355,23 +355,23 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didChange) return task } - + let task = try addTask("task", startingAt: cal.startOfMonth(for: .now)) - + for event in try module.queryEvents(for: cal.rangeOfMonth(for: .now)) { try event.complete() } - + #expect(try module.queryAllTasks() == [task]) #expect(try module.queryAllOutcomes().count == cal.numberOfDaysInMonth(for: .now)) - + try module.deleteTasks(task) - + #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) } - - + + @Test(arguments: DeleteAllTaskVersionsApproach.allCases) func deleteTaskMultipleVersionsNoOutcomes(deleteAllVersionsApproach: DeleteAllTaskVersionsApproach) async throws { let cal = Calendar.current @@ -380,7 +380,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + @discardableResult func addTask(_ id: String, startingAt startDate: Date) throws -> Task { let (task, didChange) = try module.createOrUpdateTask( @@ -394,13 +394,13 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didChange) return task } - + let taskV1 = try addTask("task", startingAt: cal.startOfMonth(for: .now)) let taskV2 = try addTask("task", startingAt: cal.startOfNextMonth(for: .now)) - + #expect(try Set(module.queryAllTasks()) == [taskV1, taskV2]) #expect(try module.queryAllOutcomes().isEmpty) - + switch deleteAllVersionsApproach { case .viaFirst: try module.deleteTasks(taskV1) @@ -412,8 +412,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) } - - + + @Test(arguments: DeleteAllTaskVersionsApproach.allCases) func deleteTaskMultipleVersionsSomeOutcomes(deleteAllVersionsApproach: DeleteAllTaskVersionsApproach) async throws { let cal = Calendar.current @@ -422,7 +422,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + @discardableResult func addTask(_ id: String, title: String, startingAt startDate: Date) throws -> Task { let (task, didChange) = try module.createOrUpdateTask( @@ -436,20 +436,20 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didChange) return task } - + let taskV1 = try addTask("task", title: "V1", startingAt: cal.startOfMonth(for: .now)) for event in try module.queryEvents(for: cal.rangeOfMonth(for: .now)) { try event.complete() } - + let taskV2 = try addTask("task", title: "V2", startingAt: cal.startOfNextMonth(for: .now)) for event in try module.queryEvents(for: cal.rangeOfMonth(for: cal.startOfNextMonth(for: .now))) { try event.complete() } - + #expect(try Set(module.queryAllTasks()) == [taskV1, taskV2]) #expect(try module.queryAllOutcomes().count == cal.numberOfDaysInMonth(for: .now) + cal.numberOfDaysInMonth(for: cal.startOfNextMonth(for: .now))) // swiftlint:disable:this line_length - + try module.deleteTasks(taskV1) switch deleteAllVersionsApproach { case .viaFirst: @@ -462,8 +462,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) } - - + + @Test(arguments: DeleteAllTaskVersionsApproach.allCases) func deleteTask(deleteAllVersionsApproach: DeleteAllTaskVersionsApproach) async throws { let cal = Calendar.current @@ -472,7 +472,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + @discardableResult func addTask(_ id: String, startingAt startDate: Date) throws -> Task { let (task, didChange) = try module.createOrUpdateTask( @@ -486,9 +486,9 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didChange) return task } - + try addTask("task", startingAt: cal.startOfMonth(for: .now)) - + for idx in 0..<12 { let task = try #require(module.queryAllTasks().max { $1.effectiveFrom > $0.effectiveFrom }) let timeRange = cal.rangeOfMonth(for: task.schedule.start) @@ -506,9 +506,9 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length ) try addTask("task", startingAt: cal.startOfNextMonth(for: task.schedule.start)) } - + let allTasks = try module.queryAllTasks().sorted(using: KeyPathComparator(\.effectiveFrom)) - + switch deleteAllVersionsApproach { case .viaFirst: try module.deleteTasks(try #require(allTasks.first)) @@ -520,8 +520,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) } - - + + // Ensures that the state of the Scheduler's underlying ModelContext is correct when performing multiple operations within a single // run loop iteration, i.e. before the context is saved. // See also: FB17583572 and FB18429335. @@ -532,7 +532,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + let (task1A, didCreateTask1A) = try module.createOrUpdateTask( id: "task1", title: "", @@ -542,7 +542,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length ) #expect(didCreateTask1A) #expect(try module.queryTasks(for: Calendar.current.rangeOfDay(for: .today)) == [task1A]) - + let (task1B, didCreateTask1B) = try module.createOrUpdateTask( id: "task1", title: "", @@ -552,11 +552,11 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(didCreateTask1B) #expect(try module.queryTasks(for: Calendar.current.rangeOfDay(for: .today)).count == 2) #expect(try module.queryTasks(for: Calendar.current.rangeOfDay(for: .today)) == [task1A, task1B]) - + let context = try module.context #expect(context.hasChanges) #expect(try context.fetchCount(FetchDescriptor()) == 2) - + #expect(try context.fetchCount(FetchDescriptor()) == 2) #expect(context.hasChanges) try context.save() @@ -577,7 +577,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(try context.fetchCount(FetchDescriptor()) == 0) #expect(try context.fetchCount(FetchDescriptor()) == 0) } - + // regression test around a bug where the context save would take place too late // and the notification scheduling would end up accessing an old state of the context, and crash. // was likely in part caused by using `ModelContext.delete(model:where:)` instead of `ModelContext.delete(_:)`. @@ -599,7 +599,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length try scheduler.deleteAllVersions(of: task) try #expect(scheduler.queryTasks(for: allTime).isEmpty) } - + @Test func hourlyTask() throws { let cal = Calendar.current @@ -630,7 +630,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func hourlyTask12HourInterval() throws { let cal = Calendar.current @@ -672,7 +672,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func monthlyTask() throws { let cal = Calendar.current @@ -716,7 +716,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func monthlyTask3MonthInterval() throws { let cal = Calendar.current @@ -753,7 +753,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func yearlyTask() throws { let cal = Calendar.current @@ -785,7 +785,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func yearlyTask3YearInterval() throws { let cal = Calendar.current @@ -819,7 +819,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func iOS26Migration() throws { let fm = FileManager.default // swiftlint:disable:this identifier_name @@ -862,8 +862,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length ) #expect(String(localized: task3.instructions) == "Task 3") } - - + + @Test func userInfoPersistance() throws { struct TaskContextKey: TaskStorageKey, _UserInfoKey { @@ -935,21 +935,21 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length static let identifier = "tKey2" static let coding = UserStorageCoding.json } - + // Per-key inner encodings (driven by each key's `coding`; independent of the container's `Codable`). let innerInt = Data(#"{"value":52}"#.utf8) let innerString = Data(#"{"value":"hello"}"#.utf8) let rawUserInfo = ["tKey1": innerInt, "tKey2": innerString] - + // The canonical, frozen wire format: a single keyed container `{ "userInfo": <[String: Data]> }`, with the // `Data` values base64-encoded. Changing this string means changing the on-disk format -- which would make // existing persisted `Task`/`Outcome` `userInfo` undecodable, i.e. a breaking migration. let golden = #"{"userInfo":{"tKey1":"\#(innerInt.base64EncodedString())","tKey2":"\#(innerString.base64EncodedString())"}}"# - + let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] let decoder = JSONDecoder() - + // b) Format lock: the current implementation encodes to exactly the canonical format. var storage = UserInfoStorage() var cache = UserInfoStorage.RepositoryCache() @@ -957,12 +957,12 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length try storage.set(StringKey.self, value: "hello", cache: &cache) #expect(storage.userInfo == rawUserInfo) #expect(String(decoding: try encoder.encode(storage), as: UTF8.self) == golden) - + // a) Backward compatibility: bytes produced by the previous (`RawRepresentable`-based) implementation still // decode -- and round-trip the typed values -- with the current implementation. let legacyEncoded = try encoder.encode(LegacyUserInfoStorage(rawValue: rawUserInfo)) #expect(String(decoding: legacyEncoded, as: UTF8.self) == golden) // the previous impl emitted these exact bytes - + for persisted in [legacyEncoded, Data(golden.utf8)] { // legacy-produced bytes, and a frozen literal blob let decoded = try decoder.decode(UserInfoStorage.self, from: persisted) var decodedCache = UserInfoStorage.RepositoryCache() @@ -970,7 +970,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(try decoded.get(IntKey.self, cache: &decodedCache) == 52) #expect(try decoded.get(StringKey.self, cache: &decodedCache) == "hello") } - + // The same compatibility must hold through a binary property list (SwiftData persists via a plist-backed store). let plistEncoder = PropertyListEncoder() plistEncoder.outputFormat = .binary @@ -1018,7 +1018,7 @@ extension LegacyUserInfoStorage: RawRepresentable { var rawValue: [String: Data] { userInfo } - + init(rawValue: [String: Data]) { self.userInfo = rawValue } diff --git a/Tests/SpeziSchedulerTests/Utils/ExampleTaskKey.swift b/Tests/SpeziSchedulerTests/Utils/ExampleTaskKey.swift index 8643385ae..28055f504 100644 --- a/Tests/SpeziSchedulerTests/Utils/ExampleTaskKey.swift +++ b/Tests/SpeziSchedulerTests/Utils/ExampleTaskKey.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +import Foundation +import SpeziFoundation import SpeziScheduler diff --git a/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift b/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift index bd0ad1a11..e962e34bd 100644 --- a/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift +++ b/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift @@ -10,8 +10,8 @@ import SpeziScheduler @_spi(TestingSupport) @testable import SpeziSchedulerUI +import SpeziTesting import XCTest -import XCTSpezi final class SchedulerSampleDataTests: XCTestCase { diff --git a/Tests/SpeziStorageTests/LocalStorageTests.swift b/Tests/SpeziStorageTests/LocalStorageTests.swift index 8343c8b79..4e648262b 100644 --- a/Tests/SpeziStorageTests/LocalStorageTests.swift +++ b/Tests/SpeziStorageTests/LocalStorageTests.swift @@ -7,8 +7,8 @@ // @testable import SpeziLocalStorage +import SpeziTesting import XCTest -import XCTSpezi private struct Letter: Codable, Equatable { diff --git a/Tests/SpeziStudyTests/StudyDefinitionTests.swift b/Tests/SpeziStudyTests/StudyDefinitionTests.swift index 92798ab21..64514bc39 100644 --- a/Tests/SpeziStudyTests/StudyDefinitionTests.swift +++ b/Tests/SpeziStudyTests/StudyDefinitionTests.swift @@ -31,7 +31,7 @@ struct StudyDefinitionTests { let input1 = try JSONEncoder().encode(Self.testStudyBundle) #expect(try StudyDefinition.schemaVersion(of: input1, using: JSONDecoder()) == StudyDefinition.schemaVersion) - let input2 = try #require(#"{"schemaVersion":"1.2.3", "glorb": "florb"}"#.data(using: .utf8)) + let input2 = Data(#"{"schemaVersion":"1.2.3", "glorb": "florb"}"#.utf8) #expect(try StudyDefinition.schemaVersion(of: input2, using: JSONDecoder()) == Version(1, 2, 3)) } diff --git a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift index ef04d547e..e511503fb 100644 --- a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift +++ b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift @@ -109,7 +109,7 @@ struct NotificationsTests { async let registration = action() try await Task.sleep(for: .milliseconds(750)) // allow dispatch of Task above - let data = try #require("Hello World".data(using: .utf8)) + let data = Data("Hello World".utf8) #if os(iOS) || os(visionOS) || os(tvOS) delegate.application(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: data) diff --git a/Tests/SpeziTests/ModuleTests/ModuleTests.swift b/Tests/SpeziTests/ModuleTests/ModuleTests.swift index 191ace310..8ea057992 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleTests.swift @@ -7,6 +7,9 @@ // @_spi(APISupport) @testable import Spezi +#if canImport(Observation) && canImport(SwiftUI) +import Observation +#endif import SpeziTesting #if canImport(SwiftUI) import SwiftUI @@ -55,19 +58,35 @@ struct ModuleTests { #expect(modules.contains(where: { $0 is TestModule })) } +#if canImport(Observation) && canImport(SwiftUI) + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test("Storage Mutations Remain Observable") + func storageMutationsRemainObservable() async { + let spezi = Spezi(standard: DefaultStandard(), modules: []) + let observation = TestExpectation() + + withObservationTracking { + _ = spezi.modules + } onChange: { + observation.fulfill() + } + + spezi.loadModule(TestModule()) + await observation.fulfillment(within: .seconds(1)) + } +#endif + #if canImport(SwiftUI) @Test("Preview Modifier") func previewModifier() async throws { // manually patch environment variable for running within Xcode preview window setenv(ProcessInfo.xcodeRunningForPreviewKey, "1", 1) - try await confirmation { confirmation in - _ = try #require( - Text("Spezi") - .previewWith { - TestModule(confirmation: confirmation) - } - ) + await confirmation { confirmation in + _ = Text("Spezi") + .previewWith { + TestModule(confirmation: confirmation) + } } unsetenv(ProcessInfo.xcodeRunningForPreviewKey) diff --git a/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/ButtonTestView.swift b/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/ButtonTestView.swift index e266399e2..5c6f87427 100644 --- a/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/ButtonTestView.swift +++ b/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/ButtonTestView.swift @@ -124,6 +124,37 @@ struct ButtonTestView: View { } +struct AsyncButtonDebounceTestView: View { + @State private var fastCompleted = false + @State private var slowCompleted = false + @State private var fastViewState: ViewState = .idle + @State private var slowViewState: ViewState = .idle + + var body: some View { + Form { + Section("Fast Action") { + AsyncButton("Fast Debounced Action", state: $fastViewState) { + try await Task.sleep(for: .milliseconds(100)) + fastCompleted = true + } + .asyncButtonProcessingStyle(.listRow) + Text("Fast Completed: \(fastCompleted.description)") + } + + Section("Slow Action") { + AsyncButton("Slow Debounced Action", state: $slowViewState) { + try await Task.sleep(for: .seconds(5)) + slowCompleted = true + } + .asyncButtonProcessingStyle(.listRow) + Text("Slow Completed: \(slowCompleted.description)") + } + } + .environment(\.processingDebounceDuration, .seconds(2)) + } +} + + #if DEBUG struct AsyncButtonTestView_Previews: PreviewProvider { static var previews: some View { diff --git a/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift b/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 6a67db876..826f68a78 100644 --- a/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/SpeziViewsTests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -27,6 +27,7 @@ enum SpeziViewsTests: String, TestAppTests { case defaultErrorDescription = "Default Error Description" case anyLocalizableError = "AnyLocalizableError" case button = "Buttons" + case asyncButtonDebounce = "Async Button Debounce" case listRow = "List Row" case managedViewUpdate = "Managed View Update" case caseIterablePicker = "Picker" @@ -100,6 +101,8 @@ enum SpeziViewsTests: String, TestAppTests { AnyLocalizableErrorTestView() case .button: ButtonTestView() + case .asyncButtonDebounce: + AsyncButtonDebounceTestView() case .listRow: List { ListRow(verbatim: "Hello") { diff --git a/Tests/SpeziViewsTests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift b/Tests/SpeziViewsTests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift index 8052e4a32..0770e44e1 100644 --- a/Tests/SpeziViewsTests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift +++ b/Tests/SpeziViewsTests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift @@ -16,7 +16,7 @@ final class ViewsTests: XCTestCase { try super.setUpWithError() continueAfterFailure = false } - + @MainActor func testGeometryReader() throws { let app = XCUIApplication() @@ -30,7 +30,7 @@ final class ViewsTests: XCTestCase { XCTAssert(app.staticTexts["300.000000"].exists) XCTAssert(app.staticTexts["200.000000"].exists) } - + @MainActor func testLabel() throws { #if os(macOS) @@ -115,7 +115,44 @@ final class ViewsTests: XCTestCase { XCTAssert(app.staticTexts["Captured Hello World"].waitForExistence(timeout: 0.5)) } - + + @MainActor + func testAsyncButtonDebounce() throws { + let app = XCUIApplication() + app.launch() + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + app.open(target: "SpeziViews") + app.collectionViews.firstMatch.swipeUp() + + let debounceTest = tappableElement(named: "Async Button Debounce", in: app) + debounceTest.tap() + + let fastAction = tappableElement(named: "Fast Debounced Action", in: app) + fastAction.tap() + XCTAssert(app.staticTexts["Fast Completed: true"].waitForExistence(timeout: 1)) + sleep(for: .seconds(3)) + XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 1)) + + let slowAction = tappableElement(named: "Slow Debounced Action", in: app) + slowAction.tap() + XCTAssert(app.activityIndicators.firstMatch.waitForExistence(timeout: 4)) + XCTAssert(app.staticTexts["Slow Completed: true"].waitForExistence(timeout: 7)) + XCTAssert(app.activityIndicators.firstMatch.waitForNonExistence(timeout: 4)) + } + + @MainActor + private func tappableElement(named name: String, in app: XCUIApplication, timeout: TimeInterval = 2) -> XCUIElement { + let button = app.buttons[name] + if button.waitForExistence(timeout: timeout) { + return button + } + + let element = app.descendants(matching: .any)[name] + XCTAssertTrue(element.waitForExistence(timeout: timeout), "Unable to find \(name) in the debounce test fixture.") + return element + } + @MainActor func testAsyncButtonInToolbar() throws { let app = XCUIApplication() diff --git a/packages.toml b/packages.toml index 2dbc6de8a..5367ab023 100644 --- a/packages.toml +++ b/packages.toml @@ -211,6 +211,9 @@ platforms = ["iOS", "macOS", "macCatalyst", "watchOS", "visionOS", "tvOS"] targets = ["XCTRuntimeAssertions", "RuntimeAssertions", "RuntimeAssertionsTesting"] tests = ["XCTRuntimeAssertionsTests", "RuntimeAssertionsTests"] self-hosted-ci = ["ui", "unit"] +# This package's self-hosted jobs run run-package-tests.sh's tomllib path (Python 3.11+); pin them +# to a self-hosted runner carrying the `python3.11+` label (older self-hosted machines have < 3.11). +extra_runner_labels = ["python3.11+"] [XCTestExtensions] platforms = ["iOS", "macOS", "watchOS", "visionOS"]