diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index ae2702284..bbf931837 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/.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 5af53b8d4..d9e1a6cbc 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,14 +4,16 @@ # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT -# +# -# The whitelist_rules configuration also includes rules that are enabled by default to provide a good overview of all rules. +# The only_rules configuration also includes rules that are enabled by default to provide a good overview of all rules. only_rules: # All Images that provide context should have an accessibility label. Purely decorative images can be hidden from accessibility. - 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. @@ -378,6 +400,7 @@ deployment_target: # Availability checks or attributes shouldn’t be using olde excluded: # paths to ignore during linting. Takes precedence over `included`. - .incoming # temporary during monorepo construction - .build + - .build-codex - .swiftpm - .xcodebuild - .derivedData diff --git a/Package.swift b/Package.swift index 3e9ca326a..38c66cad0 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"), @@ -177,7 +255,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 +263,7 @@ products += [ // MARK: SpeziScheduler .library(name: "SpeziSchedulerUI", targets: ["SpeziSchedulerUI"]), // MARK: SpeziStudy - .library(name: "SpeziStudy", targets: ["SpeziStudy"]), + .library(name: "SpeziStudy", targets: ["SpeziStudy"]) ] #endif @@ -198,13 +276,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 +288,7 @@ var targets: [Target] = [ dependencies: [ .product(name: "Antlr4", package: "antlr4") ], - exclude: [ - "ANTLUtils" - ], + exclude: targetExcludes("FHIRPathParser", additional: ["ANTLUtils"]), plugins: [] + defaultPlugins ), .target( @@ -226,6 +296,7 @@ var targets: [Target] = [ dependencies: [ .product(name: "ModelsR4", package: "FHIRModels") ], + exclude: targetExcludes("FHIRQuestionnaires"), resources: [ .process("Resources") ], @@ -274,13 +345,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 +360,7 @@ var targets: [Target] = [ .target(name: "HealthKitOnFHIR"), .target(name: "SpeziFoundation") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("HealthKitOnFHIRTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -324,12 +387,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 +396,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 +407,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 +418,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziTesting"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -391,9 +443,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,13 +459,7 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .target(name: "SpeziFoundation") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziAccessGuard"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -429,9 +473,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 +509,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 +524,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .target(name: "XCTestExtensions") ], + exclude: targetExcludes("XCTSpeziAccount"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -498,6 +536,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .product(name: "PhoneNumberKit", package: "PhoneNumberKit") ], + exclude: targetExcludes("SpeziAccountPhoneNumbers"), resources: [ .process("Resources") ], @@ -516,9 +555,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 +588,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 +601,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .target(name: "SpeziNumerics") ], + exclude: targetExcludes("SpeziBluetoothServices"), plugins: [] + defaultPlugins ), .executableTarget( @@ -579,6 +611,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetoothServices"), .target(name: "ByteCoding") ], + exclude: targetExcludes("TestPeripheral"), plugins: [] + defaultPlugins ), .testTarget( @@ -587,9 +620,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziBluetoothTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), .testTarget( @@ -612,12 +643,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 +657,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziChat") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziChatTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), // MARK: SpeziConsent @@ -648,13 +672,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 +689,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,12 +706,7 @@ var targets: [Target] = [ .target(name: "SpeziViews"), .target(name: "SpeziPersonalInfo") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziContact"), swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ], @@ -706,9 +717,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziContact") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziContactTests", additional: ["UITests"]), swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ], @@ -725,12 +734,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 +745,7 @@ var targets: [Target] = [ .target(name: "SpeziValidation"), .target(name: "SpeziBluetooth") ], + exclude: targetExcludes("SpeziDevicesUI"), resources: [ .process("Resources") ], @@ -753,6 +758,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], + exclude: targetExcludes("SpeziOmron"), resources: [ .process("Resources") ], @@ -767,9 +773,7 @@ var targets: [Target] = [ .target(name: "SpeziBluetooth"), .target(name: "SpeziBluetoothServices") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziDevicesTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), .testTarget( @@ -791,12 +795,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 +819,7 @@ var targets: [Target] = [ .target(name: "SpeziFHIR"), .product(name: "ModelsR4", package: "FHIRModels") ], + exclude: targetExcludes("SpeziFHIRMockPatients"), resources: [ .process("Resources") ], @@ -836,9 +836,7 @@ var targets: [Target] = [ .target(name: "HealthKitOnFHIR"), .target(name: "SpeziHealthKit") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFHIRTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -847,12 +845,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 +854,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .target(name: "SpeziNumerics") ], + exclude: targetExcludes("EDFFormat"), swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") ], @@ -880,12 +874,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 +889,7 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .product(name: "FirebaseAuth", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseAccount"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -911,6 +901,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .product(name: "FirebaseFirestore", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseConfiguration"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -924,6 +915,7 @@ var targets: [Target] = [ .product(name: "FirebaseFirestore", package: "firebase-ios-sdk"), .product(name: "Atomics", package: "swift-atomics") ], + exclude: targetExcludes("SpeziFirestore"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -936,6 +928,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .product(name: "FirebaseStorage", package: "firebase-ios-sdk") ], + exclude: targetExcludes("SpeziFirebaseStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -949,6 +942,7 @@ var targets: [Target] = [ .target(name: "SpeziAccount"), .target(name: "SpeziFirestore") ], + exclude: targetExcludes("SpeziFirebaseAccountStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -961,9 +955,7 @@ var targets: [Target] = [ .target(name: "SpeziFirebaseConfiguration"), .target(name: "SpeziFirestore") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFirebaseTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1024,13 +1016,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 +1036,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .product(name: "Algorithms", package: "swift-algorithms") ], + exclude: targetExcludes("SpeziLocalization"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1062,9 +1049,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "RuntimeAssertionsTesting") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziFoundationTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1076,6 +1061,7 @@ var targets: [Target] = [ .target(name: "SpeziLocalization"), .target(name: "SpeziFoundation") ], + exclude: testTargetExcludes("SpeziLocalizationTests"), resources: [ .process("Resources") ], @@ -1094,17 +1080,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 +1102,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 +1115,7 @@ var targets: [Target] = [ .target(name: "SpeziHealthKit"), .target(name: "SpeziFoundation") ], + exclude: targetExcludes("SpeziHealthKitUI"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1148,9 +1131,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 +1148,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") ], @@ -1193,6 +1168,7 @@ var targets: [Target] = [ .product(name: "Transformers", package: "swift-transformers", condition: .when(traits: [mlxTrait])), .product(name: "MLXLLM", package: "mlx-swift-examples", condition: .when(traits: [mlxTrait])) ], + exclude: targetExcludes("SpeziLLMLocal"), resources: [ .process("Resources") ], @@ -1209,6 +1185,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 +1207,7 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMOpenAI"), resources: [ .process("Resources") ], @@ -1252,6 +1230,7 @@ var targets: [Target] = [ .target(name: "SpeziKeychainStorage"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMOpenAIRealtime"), resources: [ .process("Resources") ], @@ -1266,6 +1245,7 @@ var targets: [Target] = [ .target(name: "SpeziLLMOpenAI"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLLMAnthropic"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1277,6 +1257,7 @@ var targets: [Target] = [ .target(name: "SpeziLLMOpenAI"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLLMGemini"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1292,6 +1273,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "SpeziOnboarding") ], + exclude: targetExcludes("SpeziLLMFog"), resources: [ .process("Resources") ], @@ -1308,14 +1290,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 +1312,7 @@ var targets: [Target] = [ .target(name: "SpeziLLM"), .target(name: "SpeziLLMOpenAI") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLLMTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1347,13 +1324,7 @@ var targets: [Target] = [ dependencies: [ .product(name: "SwiftPackageList", package: "swift-package-list") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md", - "REUSE.toml" - ], + exclude: targetExcludes("SpeziLicense"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1365,9 +1336,7 @@ var targets: [Target] = [ .target(name: "SpeziLicense"), .target(name: "Spezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLicenseTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1379,12 +1348,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziLocation"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1395,9 +1359,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziLocation") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziLocationTests", additional: ["UITests"]), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1406,12 +1368,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 +1377,7 @@ var targets: [Target] = [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio") ], + exclude: targetExcludes("ByteCoding"), plugins: [] + defaultPlugins ), .target( @@ -1428,6 +1386,7 @@ var targets: [Target] = [ .target(name: "ByteCoding"), .product(name: "NIOCore", package: "swift-nio") ], + exclude: targetExcludes("SpeziNumerics"), plugins: [] + defaultPlugins ), .target( @@ -1435,6 +1394,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "ByteCoding") ], + exclude: targetExcludes("ByteCodingTesting"), plugins: [] + defaultPlugins ), .target( @@ -1442,6 +1402,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "ByteCoding") ], + exclude: targetExcludes("XCTByteCoding"), plugins: [] + defaultPlugins ), .testTarget( @@ -1468,12 +1429,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], - exclude: [ - "CONTRIBUTORS.md", - "LICENSE.md", - "LICENSES", - "README.md" - ], + exclude: targetExcludes("SpeziNotifications"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1484,6 +1440,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziNotifications") ], + exclude: targetExcludes("XCTSpeziNotifications"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1495,6 +1452,7 @@ var targets: [Target] = [ .target(name: "SpeziNotifications"), .target(name: "SpeziViews") ], + exclude: targetExcludes("XCTSpeziNotificationsUI"), resources: [ .process("Resources") ], @@ -1510,9 +1468,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "XCTSpezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziNotificationsTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1525,13 +1481,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 +1495,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziOnboarding") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziOnboardingTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1562,13 +1510,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 +1540,7 @@ var targets: [Target] = [ .product(name: "Algorithms", package: "swift-algorithms"), .target(name: "SpeziFoundation") ], + exclude: targetExcludes("SpeziQuestionnaireFHIR"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InternalImportsByDefault") @@ -1639,9 +1582,7 @@ var targets: [Target] = [ .target(name: "FHIRModelsExtensions"), .target(name: "FHIRQuestionnaires") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziQuestionnaireTests", additional: ["UITests"]), resources: [ .process("Resources") ], @@ -1668,13 +1609,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 +1623,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 +1635,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 +1649,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziSpeechRecognizer"), plugins: [] + defaultPlugins ), .target( @@ -1733,6 +1657,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "Spezi") ], + exclude: targetExcludes("SpeziSpeechSynthesizer"), plugins: [] + defaultPlugins ), .testTarget( @@ -1741,20 +1666,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 +1681,7 @@ var targets: [Target] = [ .target(name: "Spezi"), .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("SpeziKeychainStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1775,6 +1694,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .target(name: "SpeziKeychainStorage") ], + exclude: targetExcludes("SpeziLocalStorage"), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1786,9 +1706,7 @@ var targets: [Target] = [ .target(name: "SpeziLocalStorage"), .target(name: "XCTSpezi") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziStorageTests", additional: ["UITests"]), swiftSettings: [ .enableUpcomingFeature("ExistentialAny") ], @@ -1807,6 +1725,7 @@ var targets: [Target] = [ .product(name: "DequeModule", package: "swift-collections"), .product(name: "Logging", package: "swift-log") ], + exclude: targetExcludes("SpeziStudyDefinition"), resources: [ .process("Resources") ], @@ -1830,9 +1749,7 @@ var targets: [Target] = [ #endif return deps }(), - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("SpeziStudyTests", additional: ["UITests"]), resources: [ .process("Resources/questionnaires"), .copy("Resources/assets") @@ -1851,13 +1768,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 +1779,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "SpeziViews") ], + exclude: targetExcludes("SpeziPersonalInfo"), resources: [ .process("Resources") ], @@ -1880,6 +1792,7 @@ var targets: [Target] = [ .target(name: "SpeziFoundation"), .product(name: "OrderedCollections", package: "swift-collections") ], + exclude: targetExcludes("SpeziValidation"), resources: [ .process("Resources") ], @@ -1892,20 +1805,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 +1822,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 +1839,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("RuntimeAssertionsTesting"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1947,6 +1850,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "RuntimeAssertions") ], + exclude: targetExcludes("XCTRuntimeAssertions"), swiftSettings: [ .swiftLanguageMode(.v5) ], @@ -1976,16 +1880,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 +1893,7 @@ var targets: [Target] = [ dependencies: [ .target(name: "XCTestExtensions") ], - exclude: [ - "UITests" - ], + exclude: testTargetExcludes("XCTestExtensionsTests", additional: ["UITests"]), plugins: [] + defaultPlugins ), ] @@ -2022,6 +1920,7 @@ targets += [ .target(name: "SpeziScheduler"), .target(name: "SpeziViews") ], + exclude: targetExcludes("SpeziSchedulerUI"), resources: [ .process("Resources") ], @@ -2042,9 +1941,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 +1956,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 +1981,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 +2008,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..0b922ee5b 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -46,9 +46,10 @@ 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 = [ + ".spi.yml", "Tests/TestPlans/**", ".swiftpm/xcode/xcshareddata/xcschemes/Spezi-Tests.xcscheme" ] diff --git a/Scripts/build-documentation.sh b/Scripts/build-documentation.sh new file mode 100755 index 000000000..66117750f --- /dev/null +++ b/Scripts/build-documentation.sh @@ -0,0 +1,171 @@ +#!/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_prefixes = ( + f"{repo}/.build/", + f"{repo}/.derivedData", +) + +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 line.startswith(ignored_prefixes): + 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/check-package-traits.py b/Scripts/check-package-traits.py index a91e9ae2d..cd791b456 100755 --- a/Scripts/check-package-traits.py +++ b/Scripts/check-package-traits.py @@ -65,6 +65,7 @@ def swiftpm_environment() -> dict[str, str]: env = os.environ.copy() env.setdefault("CLANG_MODULE_CACHE_PATH", str(ROOT / ".build" / "clang-module-cache")) env.setdefault("TMPDIR", str(ROOT / ".build" / "tmp")) + env.setdefault("SPEZI_EXCLUDE_DOCC_CATALOGS", "1") Path(env["CLANG_MODULE_CACHE_PATH"]).mkdir(parents=True, exist_ok=True) Path(env["TMPDIR"]).mkdir(parents=True, exist_ok=True) return env diff --git a/Scripts/cleanup-generated-artifacts.sh b/Scripts/cleanup-generated-artifacts.sh index 8ebe0df3f..fffd9fc58 100755 --- a/Scripts/cleanup-generated-artifacts.sh +++ b/Scripts/cleanup-generated-artifacts.sh @@ -21,6 +21,8 @@ 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 {} + diff --git a/Scripts/run-package-tests.sh b/Scripts/run-package-tests.sh index 51da5896e..5b2bb4f5f 100755 --- a/Scripts/run-package-tests.sh +++ b/Scripts/run-package-tests.sh @@ -26,6 +26,8 @@ if [ -n "${RUNNER_TEMP:-}" ]; then DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$RUNNER_TEMP/spezi-derivedData-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$}" fi DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-.derivedData}" +export SPEZI_EXCLUDE_DOCC_CATALOGS="${SPEZI_EXCLUDE_DOCC_CATALOGS:-1}" + enable_default_package_traits() { case "${SPEZI_ENABLE_DEFAULT_PACKAGE_TRAITS:-1}" in 0|false|FALSE|no|NO) 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/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 078c52ad0..f840a8297 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 b05229f18..c6e92c23b 100644 --- a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift +++ b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKCategoryValue+Coding.swift @@ -15,13 +15,16 @@ import ModelsR4 @available(macOS 13, *) protocol FHIRCodingConvertible { static var system: FHIRPrimitive { get } - + var code: String { get } var display: String? { get } - + init?(rawValue: Int) } +@available(macOS 13, *) +protocol FHIRCodingConvertibleHKEnum: FHIRCodingConvertible {} + @available(macOS 13, *) extension FHIRCodingConvertible { var asCoding: Coding { @@ -42,9 +45,6 @@ extension FHIRCodingConvertible where Self: RawRepresentable, RawValue == Int { } -@available(macOS 13, *) -protocol FHIRCodingConvertibleHKEnum: FHIRCodingConvertible {} - @available(macOS 13, *) extension FHIRCodingConvertibleHKEnum { static var system: FHIRPrimitive { diff --git a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKElectrocardiogram+Observation.swift index 81df89d02..4a39faa3e 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`. @available(iOS 17, macOS 14, watchOS 10, *) public func observation( symptoms: Symptoms, diff --git a/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift b/Sources/HealthKitOnFHIR/HealthKit Extensions/HKSample+ResourceProxy.swift index b41fb10db..f5e1bc1bf 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:)``. @available(iOS 17, macOS 14, watchOS 10, *) public func resource( withMapping mapping: HKSampleMapping = .default, @@ -77,8 +77,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`. @available(iOS 17, macOS 14, watchOS 10, *) public func mapIntoResourceProxies( using mapping: HKSampleMapping = .default, @@ -97,8 +97,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`. @available(iOS 17, macOS 14, watchOS 10, *) public func compactMapIntoResourceProxies( using mapping: HKSampleMapping = .default, 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/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 2e550c8b2..7dafcae42 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -101,7 +101,24 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length /// 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 +#if canImport(SwiftUI) + // Work around https://github.com/swiftlang/swift/issues/81962 with manually tracked backing storage. + @ObservationIgnored nonisolated(unsafe) private var _storage: SpeziStorage + nonisolated var storage: SpeziStorage { + get { + access(keyPath: \.storage) + return _storage + } + set { + withMutation(keyPath: \.storage) { + _storage = newValue + } + } + } +#else + // Writes are isolated to @MainActor; reads are nonisolated. + nonisolated(unsafe) var storage: SpeziStorage +#endif #if canImport(SwiftUI) /// Key is either a UUID for `@Modifier` or `@Model` property wrappers, or a `ModuleReference` for `EnvironmentAccessible` modifiers. @@ -198,7 +215,11 @@ public final class Spezi: Sendable { // swiftlint:disable:this type_body_length storage: consuming SpeziStorage = SpeziStorage() ) { self.standard = standard +#if canImport(SwiftUI) + self._storage = consume storage +#else self.storage = consume storage +#endif do { try self.loadModules(modules, ownership: .spezi) diff --git a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift index f419a7ba9..77324d29d 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 433fd0029..50a928711 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 17, *) @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 17, *) 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 b3f3f45ee..73c38c0b5 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(macOS, unavailable) @available(watchOS, unavailable) @available(iOS 17, macOS 14, *) @@ -66,13 +66,13 @@ public struct AccountOverview: View { /// A close button is shown that calls the `dismiss` action. case showCloseButton } - - + + /// How an account operation (i.e., logout or deletion) via the ``AccountOverview`` should be handled. public enum AccountOperationHandler { /// The operation should be handled normally via SpeziAccount. case `default` - + /// The operation should be handled via a custom closure. /// /// - parameter labels: The labels that should be used for UI related to the operation. @@ -82,22 +82,22 @@ public struct AccountOverview: View { _ handler: @Sendable () async throws -> Void ) } - - + + /// Defines the behavior of logging out of the account. public enum AccountLogoutBehavior: AccountOverviewDestructiveAccountOperation { /// Account logout is not available. case disabled /// Account logout is available. case enabled(AccountOperationHandler) - + typealias ExtraSections = AdditionalSections - + /// The default behavior, where logout is available and uses the SpeziAccount-defined labels and handler. public static var enabled: Self { .enabled(.default) } - + var labels: AccountOverviewOperationLabels { switch self { case .disabled, .enabled(.default): @@ -106,7 +106,7 @@ public struct AccountOverview: View { labels } } - + var handler: AccountOperationHandler? { switch self { case .disabled: @@ -116,8 +116,8 @@ public struct AccountOverview: View { } } } - - + + /// Defines the behavior of deleting the account. public enum AccountDeletionBehavior: AccountOverviewDestructiveAccountOperation { /// Account deletion is not available. @@ -126,19 +126,19 @@ public struct AccountOverview: View { case inEditMode(AccountOperationHandler) /// Show the delete button below the logout button. case belowLogout(AccountOperationHandler) - + typealias ExtraSections = AdditionalSections - + /// When entering the edit mode, the logout button turns into a delete account button. public static var inEditMode: Self { .inEditMode(.default) } - + /// Show the delete button below the logout button. public static var belowLogout: Self { .belowLogout(.default) } - + /// The labels that should be used for account-deletion-related UI elements. /// Exists to allow user customization. var labels: AccountOverviewOperationLabels { @@ -149,7 +149,7 @@ public struct AccountOverview: View { labels } } - + var handler: AccountOperationHandler? { switch self { case .disabled: @@ -159,8 +159,8 @@ public struct AccountOverview: View { } } } - - + + private let closeBehavior: CloseBehavior private let logoutBehavior: AccountLogoutBehavior private let deletionBehavior: AccountDeletionBehavior @@ -196,25 +196,46 @@ public struct AccountOverview: View { } } } - - + + /// 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. /// - 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 c2d785d0f..f5d2ec9b3 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 24c693837..e048896aa 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 5f18af57e..069a3f4d6 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 84137938e..8e64f442a 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 6197acc5a..3ffdad90c 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 f6503834a..a172a3e2d 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 f8072ae33..64c760071 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 1849d7628..545769189 100644 --- a/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift +++ b/Sources/SpeziAccountPhoneNumbers/PhoneVerificationConstraint.swift @@ -15,18 +15,18 @@ 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. @available(iOS 17, macOS 14, *) 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/AccessorySetupKit.swift b/Sources/SpeziBluetooth/AccessorySetupKit/AccessorySetupKit.swift index b0b820779..f197e8a3e 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/Model/BluetoothManagerStorage.swift b/Sources/SpeziBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index 50899c452..21c92aa0e 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 600d62fb1..23b0d9981 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/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 c31b916b7..cf3eb364e 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 682dad54c..c30694659 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 b846794fa..9c4bef604 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 17, macOS 14, visionOS 1, *) @Observable @MainActor @@ -160,8 +159,6 @@ public final class ConsentDocument: Sendable { /// - ``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 a14e126e6..d0c633806 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 1eb14dde4..8111a6abc 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 ed0b506bf..747a6f46b 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/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 ad74abb29..e87ce7458 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 8c5bd6aef..2a81ccbd5 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 d0db6a0f3..07514cc43 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 162d9a094..723c34f83 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 aedf07202..b0da25e48 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 7a8b5723a..b899edff3 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(macOS 13.0, *) diff --git a/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift b/Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift index da2952439..82d8fbd30 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.swift b/Sources/SpeziHealthKit/HealthKit.swift index 79aa38324..d64e34c42 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 e442857f9..bb62a363d 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 da07adb10..8b2dd3e43 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(macOS 13.0, *) 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/SampleType.swift b/Sources/SpeziHealthKit/Sample Types/SampleType.swift index 7b7b73675..bcfa6f3ef 100644 --- a/Sources/SpeziHealthKit/Sample Types/SampleType.swift +++ b/Sources/SpeziHealthKit/Sample Types/SampleType.swift @@ -165,7 +165,6 @@ extension SampleType { /// Creates a new quantity sample type. /// Use this initializer only if the sample type you want to work with isn't already defined by SpeziHealthKit. /// - parameter identifier: The sample type's underlying `HKQuantityTypeIdentifier` - /// - parameter displayTitle: The localized string which should be used when displaying this sample type's title in a user-visible context. /// - parameter displayUnit: The unit which should be used when displaying values of this quantity type to the user. /// - parameter expectedValuesRange: If applicable, the expected range the individual sample values will most likely fall into. /// Providing this information allows some components to optimize how they display data belonging to this sample type. @@ -198,7 +197,6 @@ extension SampleType { /// Creates a new correlation sample type. /// Use this initializer only if the sample type you want to work with isn't already defined by SpeziHealthKit. /// - parameter identifier: The sample type's underlying `HKCorrelationTypeIdentifier` - /// - parameter displayTitle: The localized string which should be used when displaying this sample type's title in a user-visible context. /// - parameter associatedQuantityTypes: The sample type's associated quantity sample types. E.g.: for the blood pressure correlation type, the associated quantity types would be systolic and siastolic blood pressure. @inlinable public static func correlation( _ identifier: HKCorrelationTypeIdentifier, @@ -220,7 +218,6 @@ extension SampleType { /// Creates a new category sample type. /// Use this initializer only if the sample type you want to work with isn't already defined by SpeziHealthKit. /// - parameter identifier: The sample type's underlying `HKCategoryTypeIdentifier` - /// - parameter displayTitle: The localized string which should be used when displaying this sample type's title in a user-visible context. @inlinable public static func category( _ identifier: HKCategoryTypeIdentifier ) -> SampleType { @@ -239,7 +236,6 @@ extension SampleType { /// Creates a new clinical record sample type. /// Use this initializer only if the sample type you want to work with isn't already defined by SpeziHealthKit. /// - parameter identifier: The sample type's underlying `HKClinicalTypeIdentifier` - /// - parameter displayTitle: The localized string which should be used when displaying this sample type's title in a user-visible context. @available(watchOS, unavailable) @inlinable public static func clinical( _ identifier: HKClinicalTypeIdentifier diff --git a/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift b/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift index 7cecb088d..8151dcc3f 100644 --- a/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift +++ b/Sources/SpeziHealthKitBulkExport/BulkExportSession.swift @@ -101,11 +101,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 34d01f762..ce2ae0672 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 2298d066c..27ad318b5 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 fe31a54aa..7b128d0f9 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 bc1517885..cc591b520 100644 --- a/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift +++ b/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift @@ -42,6 +42,12 @@ import SwiftUI /// ``` @available(iOS 16, macOS 13, tvOS 16, watchOS 9, visionOS 1, *) 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 719c537f0..aa38bd51b 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 6a8f6a95e..c334f88c7 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 86bd8eeac..250cf00b5 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 7e5fdfd78..4d8ff6770 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 960baeb65..c0a53b6ed 100644 --- a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift +++ b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher+TimeIntervalFetching.swift @@ -28,7 +28,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( @@ -84,6 +84,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 b2e6a107f..6a4da8b36 100644 --- a/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift +++ b/Sources/SpeziSensorKit/Sensor Reader/AnchoredFetcher.swift @@ -26,7 +26,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, @@ -47,7 +47,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, @@ -64,7 +64,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 5fdbecfd0..4b02ca001 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 17, *) @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 1cb37a140..b7e6b7263 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 5e79f39d3..a32eb2773 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 094468f5b..52a53380d 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 34e9fdb2d..bd189f50f 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 04d85c82b..a9617d7b0 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 001626ed1..cbfeecf29 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 16, macOS 13, macCatalyst 16, watchOS 9, visionOS 1, *) 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 5cc3f37d2..ad1a5dde2 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 417c3e63a..bdcd76872 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 @MainActor @available(iOS 16, macOS 13, tvOS 16, watchOS 9, visionOS 1, *) public struct AsyncButton: View { - private enum GroupResult { - case debounce - case result(Result) - } - private enum AsyncButtonState { case idle case disabled @@ -265,10 +266,32 @@ 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) + return .debounce(showProcessing: !Task.isCancelled) } group.addTask { do { @@ -281,43 +304,23 @@ public struct AsyncButton: View { fatalError("Unexpected TaskGroup state.") } if case .result = first { - group.cancelAll() // cancel the debounce + group.cancelAll() + } + if case .debounce(showProcessing: true) = first { + withAnimation(.easeOut(duration: 0.2)) { + buttonState = .disabledAndProcessing + } } guard let second = await group.next() else { fatalError("Unexpected TaskGroup state.") } switch (first, second) { - case (let .result(result), .debounce), (.debounce, let .result(result)): + case (let .result(result), .debounce(_)), (.debounce(_), let .result(result)): return result - case (.debounce, .debounce), (.result, .result): + case (.debounce(_), .debounce(_)), (.result, .result): 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 ca817fdf4..527840d5a 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 71e3e07a5..0caa9c680 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 310705986..5242c81d9 100644 --- a/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift +++ b/Sources/SpeziViews/Views/Text/MarkdownView+ImageProviders.swift @@ -37,6 +37,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 7a7f3ec6c..9bc4464d6 100644 --- a/Sources/SpeziViews/Views/Text/MarkdownView.swift +++ b/Sources/SpeziViews/Views/Text/MarkdownView.swift @@ -151,7 +151,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), @@ -161,6 +161,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 { @@ -203,7 +217,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), @@ -212,6 +226,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 0962e5bb5..9683ec384 100644 --- a/Sources/XCTHealthKit/XCTest+HealthRecord.swift +++ b/Sources/XCTHealthKit/XCTest+HealthRecord.swift @@ -91,7 +91,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..b4b6dbf5d 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:nextTriggerPredicate: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 11942565a..12c0bb404 100644 --- a/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift +++ b/Tests/SpeziFoundationTests/AnyAsyncSequenceTests.swift @@ -21,11 +21,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 0e7fa89e3..3b7ae081c 100644 --- a/Tests/SpeziFoundationTests/RWLockTests.swift +++ b/Tests/SpeziFoundationTests/RWLockTests.swift @@ -47,7 +47,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() } @@ -70,7 +70,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() } @@ -164,7 +164,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() } @@ -187,7 +187,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/SpeziLicenseTests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tests/SpeziLicenseTests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9fbbd017f..754108d05 100644 --- a/Tests/SpeziLicenseTests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tests/SpeziLicenseTests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -175,10 +175,10 @@ { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/PhoneNumberKit", + "location" : "https://github.com/PhoneNumberKit/PhoneNumberKit", "state" : { - "revision" : "169ab10234347fb19b37441f2867ace896a284b0", - "version" : "4.3.0" + "revision" : "ab06a8333394f4a4fb6eecca447dae0aa06c1eca", + "version" : "5.0.4" } }, { diff --git a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift index 36e4f8467..5edb2aaef 100644 --- a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift +++ b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift @@ -135,7 +135,7 @@ final class NotificationsTests: XCTestCase { let action = module.registerRemoteNotifications - let data = try XCTUnwrap("Hello World".data(using: .utf8)) + let data = Data("Hello World".utf8) async let registrationCallback: Void = Self.deliverSuccessfulRegistration(using: delegate, data: data) let deviceToken = try await action() diff --git a/Tests/SpeziSchedulerTests/ScheduleTests.swift b/Tests/SpeziSchedulerTests/ScheduleTests.swift index bd79373d4..96abccec6 100644 --- a/Tests/SpeziSchedulerTests/ScheduleTests.swift +++ b/Tests/SpeziSchedulerTests/ScheduleTests.swift @@ -8,7 +8,6 @@ @testable import SpeziScheduler import XCTest -import XCTSpezi @available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) diff --git a/Tests/SpeziSchedulerTests/SchedulerTests.swift b/Tests/SpeziSchedulerTests/SchedulerTests.swift index e894f3134..d545e9fbd 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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -199,7 +199,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length try module.deleteAllVersions(ofTask: "test-task") } - + @Test func nonTrivialTaskContextCoding() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -210,7 +210,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length withDependencyResolution { module } - + let value = NonTrivialTaskContext( field0: .random(in: 0..<100), field1: .random(in: 0..<100), @@ -223,7 +223,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, @@ -235,12 +235,12 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length } ) } - + #expect(try createTask().didChange) #expect(try !createTask().didChange) } - - + + @Test func fetchingEventsAfterCompletion() async throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -252,19 +252,19 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length withDependencyResolution { module } - + try module.eraseDatabase() #expect(try module.queryAllTasks().isEmpty) #expect(try module.queryAllOutcomes().isEmpty) #expect(try module.queryEvents(for: todayRange).isEmpty) - + let task = try module.createOrUpdateTask( id: "test-task", title: "Test Task", instructions: "", schedule: .daily(hour: 0, minute: 0, startingAt: .now) ).task - + let events = try module.queryEvents(for: todayRange) #expect(events.allSatisfy { todayRange.contains($0.occurrence.start) }) #expect(events.count == 1) @@ -282,8 +282,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length }) } } - - + + @Test func deleteAllVersions() async throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -295,36 +295,36 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + func addTask(_ id: String, schedule: Schedule) throws -> 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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -337,7 +337,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( @@ -351,16 +351,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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -373,7 +373,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( @@ -387,23 +387,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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -416,7 +416,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( @@ -430,13 +430,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) @@ -448,8 +448,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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -462,7 +462,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( @@ -476,20 +476,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: @@ -502,8 +502,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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -516,7 +516,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( @@ -530,9 +530,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) @@ -550,9 +550,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)) @@ -564,8 +564,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. @@ -580,7 +580,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length module } try module.eraseDatabase() - + let (task1A, didCreateTask1A) = try module.createOrUpdateTask( id: "task1", title: "", @@ -590,7 +590,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: "", @@ -600,11 +600,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() @@ -625,7 +625,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(_:)`. @@ -651,7 +651,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 { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -686,7 +686,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func hourlyTask12HourInterval() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -732,7 +732,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func monthlyTask() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -780,7 +780,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func monthlyTask3MonthInterval() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -821,7 +821,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func yearlyTask() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -857,7 +857,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func yearlyTask3YearInterval() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -895,7 +895,7 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length #expect(event.occurrence.start == expectedDate) } } - + @Test func iOS26Migration() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -942,8 +942,8 @@ struct SchedulerTests { // swiftlint:disable:this type_body_length ) #expect(String(localized: task3.instructions) == "Task 3") } - - + + @Test func userInfoPersistance() throws { guard #available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) else { @@ -1019,21 +1019,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() @@ -1041,12 +1041,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() @@ -1054,7 +1054,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 @@ -1103,7 +1103,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 49e76577b..dbce6cd47 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 5fa9bf970..e607b5960 100644 --- a/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift +++ b/Tests/SpeziSchedulerUITests/SchedulerSampleDataTests.swift @@ -6,12 +6,13 @@ // SPDX-License-Identifier: MIT // +import SpeziFoundation @_spi(TestingSupport) import SpeziScheduler @_spi(TestingSupport) @testable import SpeziSchedulerUI +import SpeziTesting import XCTest -import XCTSpezi @available(iOS 18, macOS 15, watchOS 11, visionOS 2, *) @@ -21,7 +22,7 @@ final class SchedulerSampleDataTests: XCTestCase { let container = try SchedulerSampleData.makeSharedContext() let scheduler = Scheduler(persistence: .testingContainer(container)) - withDependencyResolution { + SpeziTesting.withDependencyResolution { scheduler } diff --git a/Tests/SpeziStorageTests/LocalStorageTests.swift b/Tests/SpeziStorageTests/LocalStorageTests.swift index c7e76cdd6..ae3b9ea40 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/SpeziStudyTests/StudyManagerTests.swift b/Tests/SpeziStudyTests/StudyManagerTests.swift index 6a4cb9b65..dd29b331b 100644 --- a/Tests/SpeziStudyTests/StudyManagerTests.swift +++ b/Tests/SpeziStudyTests/StudyManagerTests.swift @@ -67,9 +67,10 @@ final class StudyManagerTests { private static let welcomeArticleComponentId = UUID() private static let sixMinuteWalkTestComponentId = UUID() private static let twelveMinuteRunTestComponentId = UUID() - + private let studyBundle: StudyBundle - + + // swiftlint:disable:next function_body_length init() throws { let testStudy = StudyDefinition( studyRevision: 0, @@ -146,8 +147,8 @@ final class StudyManagerTests { ] ) } - - + + @Test func enrollment() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -163,15 +164,15 @@ final class StudyManagerTests { let next4Weeks = try cal.startOfDay(for: .now)..<#require(cal.date(byAdding: .weekOfYear, value: 4, to: cal.startOfDay(for: .now))) #expect(try scheduler.queryAllTasks().isEmpty) #expect(try scheduler.queryEvents(for: next4Weeks).isEmpty) - + try await studyManager.enroll(in: studyBundle) - + #expect(studyManager.studyEnrollments.count == 1) let enrollment = try #require(studyManager.studyEnrollments.first) #expect(enrollment.studyId == studyBundle.id) #expect(enrollment.studyId == studyBundle.studyDefinition.id) #expect(try #require(enrollment.studyBundle).studyDefinition == studyBundle.studyDefinition) - + #expect(try scheduler.queryAllTasks().count == 3) #expect(try scheduler.queryEvents(for: cal.rangeOfDay(for: .now)).mapIntoSet { String(localized: $0.task.title) } == [ "Welcome to our Study!", "Six-Minute Walk Test" @@ -180,8 +181,8 @@ final class StudyManagerTests { String(localized: $0.task.title) } == ["12-Minute Run Test"]) } - - + + @Test func retroactiveEnrollment() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -197,7 +198,7 @@ final class StudyManagerTests { let next4Weeks = try cal.startOfDay(for: .now)..<#require(cal.date(byAdding: .weekOfYear, value: 4, to: cal.startOfDay(for: .now))) #expect(try scheduler.queryAllTasks().isEmpty) #expect(try scheduler.queryEvents(for: next4Weeks).isEmpty) - + try await studyManager.enroll(in: studyBundle, enrollmentDate: cal.startOfPrevDay(for: .now)) #expect(studyManager.studyEnrollments.count == 1) let enrollment = try #require(studyManager.studyEnrollments.first) @@ -212,8 +213,8 @@ final class StudyManagerTests { String(localized: $0.task.title) } == ["Six-Minute Walk Test"]) } - - + + @Test func orphanTaskHandling() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -226,7 +227,7 @@ final class StudyManagerTests { studyManager } try await studyManager.enroll(in: studyBundle) - + #expect(studyManager.studyEnrollments.count == 1) let enrollment = try #require(studyManager.studyEnrollments.first) #expect(enrollment.studyId == studyBundle.id) @@ -236,12 +237,12 @@ final class StudyManagerTests { studyManager.modelContext.delete(enrollment) try #expect(studyManager.scheduler.queryTasks(for: allTime).count == 3) try studyManager.removeOrphanedTasks() - + try await _Concurrency.Task.sleep(for: .seconds(0.2)) try #expect(studyManager.scheduler.queryTasks(for: allTime).isEmpty) } - - + + @Test func orphanStudyBundleHandling() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -254,7 +255,7 @@ final class StudyManagerTests { studyManager } try await studyManager.enroll(in: studyBundle) - + #expect(studyManager.studyEnrollments.count == 1) let enrollment = try #require(studyManager.studyEnrollments.first) #expect(enrollment.studyId == studyBundle.id) @@ -267,16 +268,16 @@ final class StudyManagerTests { try studyManager.removeOrphanedStudyBundles() #expect(try !fileManager.contents(of: StudyManager.studyBundlesDirectory).contains(enrollment.studyBundleUrl)) } - - + + @Test func localeMatching() throws { #expect(LocalizationKey(language: .english, region: .unitedStates).score(against: Locale(identifier: "en_US"), using: .default) == 1) #expect(LocalizationKey(language: .spanish, region: .unitedStates).score(against: Locale(identifier: "es_US"), using: .default) == 1) #expect(LocalizationKey(language: .german, region: .unitedStates).score(against: Locale(identifier: "es_US"), using: .default) == 0.75) } - - + + /// Tests that the StudyManager properly updates itself when the preferred locale changes. @Test func localeUpdate() async throws { @@ -295,7 +296,7 @@ final class StudyManagerTests { try await studyManager.enroll(in: studyBundle) #expect(studyManager.studyEnrollments.count == 1) let enrollment = try #require(studyManager.studyEnrollments.first) - + do { let tasks = try scheduler.queryAllTasks() #expect(tasks.count == 3) @@ -334,20 +335,20 @@ final class StudyManagerTests { } try await studyManager.unenroll(from: enrollment) } - - + + @Test func localeUtils() { let locale1 = Locale(language: .english, region: .germany) #expect(locale1.language == .english) #expect(locale1.region == .germany) - + let locale2 = Locale(language: .spanish, region: .antarctica) #expect(locale2.language == .spanish) #expect(locale2.region == .antarctica) } - - + + @Test func schedules() throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -358,7 +359,7 @@ final class StudyManagerTests { cal.timeZone = .losAngeles let enrollmentDate = try #require(cal.date(from: .init(year: 2025, month: 7, day: 31))) #expect(cal.component(.weekday, from: enrollmentDate) == 5) - + let schedule1: Schedule = .fromRepeated( .repeated(.daily(hour: 0, minute: 0)), in: cal, @@ -399,9 +400,10 @@ final class StudyManagerTests { #expect(try #require(nextOccurrence(schedule5)) == #require(cal.date(from: .init(year: 2025, month: 8, day: 2)))) #expect(try #require(nextOccurrence(schedule6)) == #require(cal.date(from: .init(year: 2025, month: 8, day: 5)))) } - - + + @Test + // swiftlint:disable:next function_body_length func taskVersionDeduplication() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { return @@ -417,7 +419,7 @@ final class StudyManagerTests { let initialTasks = try scheduler.queryAllTasks() #expect(initialTasks.count == 3) #expect(initialTasks.allSatisfy { $0.previousVersion == nil && $0.nextVersion == nil }) - + // pick the 12-min run, which has its first occurrence tomorrow (per the test study bundle's `offset: 1 day`). // this lets us complete an event in a time window that any of V1/V2/V3 can be responsible for, as long as // their `effectiveFrom` dates are still today. @@ -425,7 +427,7 @@ final class StudyManagerTests { let originalStudyContext = try #require(v1.studyContext) let originalAction = try #require(v1.studyScheduledTaskAction) #expect(v1.outcomes.isEmpty) - + // we manufacture duplicates by mutating each version's userInfo to add a marker that the next // -createOrUpdateTask call won't reproduce. that forces a new version (userInfo differs) while keeping // `studyContext` and `studyScheduledTaskAction` equal across versions -- which is exactly the shape that @@ -465,7 +467,7 @@ final class StudyManagerTests { #expect(v3.firstVersion == v1) #expect(v1.latestVersion == v3) #expect(try scheduler.queryAllTasks().count == 5) // 2 untouched tasks + the 3 versions of the 12-min run - + // complete an event on V3 so we have an outcome that the dedup logic must reassign to V1. let nextDayRange = cal.rangeOfDay(for: cal.startOfNextDay(for: .now)) let v3Events = try scheduler.queryEvents(for: v3, in: nextDayRange) @@ -476,9 +478,9 @@ final class StudyManagerTests { #expect(v3.outcomes.contains { $0.id == outcomeId }) #expect(v1.outcomes.isEmpty) #expect(v2.outcomes.isEmpty) - + try studyManager.fixTaskContextAndDuplicateVersions() - + // after the migration: only the 3 original tasks remain (V2 and V3 of the run task got merged into V1), // V1's chain is collapsed, and the outcome we created on V3 was reassigned to V1. let postTasks = try scheduler.queryAllTasks() @@ -496,8 +498,8 @@ final class StudyManagerTests { #expect(task.nextVersion == nil) } } - - + + @Test func taskContextMigration() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { @@ -513,13 +515,13 @@ final class StudyManagerTests { let enrollment = try #require(studyManager.studyEnrollments.first) let allTasks = try scheduler.queryAllTasks() #expect(allTasks.count == 3) - + // capture the original UUID-based study contexts so we can verify the migration restores them. let originalContexts: [Task.ID: Task.Context.StudyContext] = allTasks.reduce(into: [:]) { dict, task in dict[task.id] = task.studyContext } #expect(originalContexts.count == 3) - + // simulate the legacy on-disk state: rewrite each task so the shared "studyContext" storage slot holds // the old `PersistentIdentifier`-keyed encoding rather than the new UUID-keyed one. // both @Property declarations target the same storage identifier, so the latter write wins, but the @@ -540,9 +542,9 @@ final class StudyManagerTests { #expect(task.studyContextOld != nil) } try scheduler.context.save() - + try studyManager.fixTaskContextAndDuplicateVersions() - + // after the migration: every task has its UUID-based studyContext set (matching the pre-simulation values), // with the legacy enrollment id (PersistentIdentifier) swapped out for the enrollment's UUID id, // and the legacy studyContextOld cleared. @@ -558,8 +560,8 @@ final class StudyManagerTests { #expect(newContext.enrollmentId == enrollment.id) } } - - + + /// Strong end-to-end equivalence test for the destructive dedup migration. /// /// Builds a realistic production-shaped history: a run of three equal duplicate versions (V1 -> V2 -> V3), each @@ -571,6 +573,7 @@ final class StudyManagerTests { /// - leaves every other task untouched, and /// - is idempotent (a second run changes nothing). @Test + // swiftlint:disable:next function_body_length func taskVersionDeduplicationPreservesObservableState() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { return @@ -586,7 +589,7 @@ final class StudyManagerTests { let rangeEnd = try #require(cal.date(byAdding: .day, value: 14, to: cal.startOfDay(for: .now))) let wideRange = enrollmentDate.. Set { Set(try scheduler.queryEvents(for: wideRange).map { event in EventSnapshot( @@ -609,14 +612,14 @@ final class StudyManagerTests { ) }) } - + // the recurring 6-minute walk task (daily, interval 2) gives us several occurrences to spread versions/outcomes across. let runTask = try #require(try scheduler.queryAllTasks().first { $0.studyContext?.componentId == Self.sixMinuteWalkTestComponentId }) let runTaskId = runTask.id let origContext = try #require(runTask.studyContext) let origAction = try #require(runTask.studyScheduledTaskAction) let origEffectiveFrom = runTask.firstVersion.effectiveFrom - + func runEventsSorted() throws -> [Event] { try scheduler.queryEvents(for: wideRange, predicate: #Predicate { $0.id == runTaskId }) } @@ -652,11 +655,11 @@ final class StudyManagerTests { } ) } - + // capture the first four occurrence start dates (all owned by V1 at this point). let occStarts = try runEventsSorted().prefix(4).map(\.occurrence.start) try #require(occStarts.count == 4) - + // build V1 -> V2 -> V3, completing a distinct occurrence in each version's responsibility window. let o1 = try completeEvent(at: occStarts[0]) // owned by V1 try makeEqualNextVersion(marker: "m1", effectiveFrom: try #require(cal.date(byAdding: .second, value: 1, to: occStarts[0]))) @@ -664,20 +667,20 @@ final class StudyManagerTests { try makeEqualNextVersion(marker: "m2", effectiveFrom: try #require(cal.date(byAdding: .second, value: 1, to: occStarts[1]))) let o3 = try completeEvent(at: occStarts[2]) // owned by V3 // occStarts[3] is intentionally left incomplete, to prove incomplete events also survive unchanged. - + // precondition: the three outcomes really are scattered across three distinct version objects. let runOutcomes = try scheduler.queryAllOutcomes().filter { $0.task.id == runTaskId } try #require(runOutcomes.count == 3) try #require(Set(runOutcomes.map { ObjectIdentifier($0.task) }).count == 3) try #require(try scheduler.queryAllTasks().filter { $0.id == runTaskId }.count == 3) - + let eventsBefore = try snapshotEvents() let outcomesBefore = try snapshotOutcomes() let orderedEventsBefore = try scheduler.queryEvents(for: wideRange).count let totalTasksBefore = try scheduler.queryAllTasks().count - + try studyManager.fixTaskContextAndDuplicateVersions() - + // 1. the observable event stream is byte-for-byte identical (occurrences, titles, completion, outcome identity). #expect(try snapshotEvents() == eventsBefore) #expect(try scheduler.queryEvents(for: wideRange).count == orderedEventsBefore) @@ -696,18 +699,19 @@ final class StudyManagerTests { #expect(survivingOutcomeIds == expectedOutcomeIds) #expect(surviving.studyContext == origContext) #expect(surviving.studyScheduledTaskAction == origAction) - + // 4. idempotency: a second run is a complete no-op. try studyManager.fixTaskContextAndDuplicateVersions() #expect(try snapshotEvents() == eventsBefore) #expect(try snapshotOutcomes() == outcomesBefore) #expect(try scheduler.queryAllTasks().count == totalTasksBefore - 2) } - - + + /// Guards the other direction: the dedup must NEVER merge versions that genuinely differ (here, by title). /// Over-merging would silently delete a version the app still renders distinctly -- i.e. data loss. @Test + // swiftlint:disable:next function_body_length func taskVersionDeduplicationKeepsGenuinelyDistinctVersions() async throws { guard #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) else { return @@ -723,7 +727,7 @@ final class StudyManagerTests { let rangeEnd = try #require(cal.date(byAdding: .day, value: 14, to: cal.startOfDay(for: .now))) let wideRange = enrollmentDate.. Set { Set(try scheduler.queryEvents(for: wideRange).map { event in EventSnapshot( @@ -736,12 +740,12 @@ final class StudyManagerTests { ) }) } - + let runTask = try #require(try scheduler.queryAllTasks().first { $0.studyContext?.componentId == Self.sixMinuteWalkTestComponentId }) let runTaskId = runTask.id let origContext = try #require(runTask.studyContext) let origAction = try #require(runTask.studyScheduledTaskAction) - + // V2 differs from V1 by an observable property (title) -> subsumes() must return false -> no merge. _ = try scheduler.createOrUpdateTask( id: runTaskId, @@ -762,19 +766,19 @@ final class StudyManagerTests { } ) try #require(try scheduler.queryAllTasks().filter { $0.id == runTaskId }.count == 2) - + let eventsBefore = try snapshotEvents() let totalTasksBefore = try scheduler.queryAllTasks().count - + try studyManager.fixTaskContextAndDuplicateVersions() - + // nothing merged, nothing deleted, observable stream unchanged (V1's window keeps its title, V2's window keeps the new one). #expect(try scheduler.queryAllTasks().count == totalTasksBefore) #expect(try scheduler.queryAllTasks().filter { $0.id == runTaskId }.count == 2) #expect(try snapshotEvents() == eventsBefore) } - - + + deinit { try? FileManager.default.removeItem(at: studyBundle.bundleUrl) if #available(iOS 18, macOS 15, macCatalyst 18, watchOS 11, visionOS 2, *) { diff --git a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift index 15b48e3ad..4e8e3fb4e 100644 --- a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift +++ b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift @@ -106,7 +106,7 @@ struct NotificationsTests { let action = module.registerRemoteNotifications - let data = try #require("Hello World".data(using: .utf8)) + let data = Data("Hello World".utf8) async let registrationCallback: Void = deliverSuccessfulRegistration(using: delegate, data: data) _ = try await action() 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 d81767841..d8ea08675 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()