Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,21 @@ jobs:
destination: ${{ matrix.platform.destination }}
resultBundle: ${{ format('TestApp-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }}
artifactname: ${{ format('TestApp-{0}-{1}.xcresult', matrix.platform.name, matrix.config) }}
package_tests_linux:
name: Build and Test Swift Package Linux (${{ matrix.config }})
uses: StanfordBDHG/.github/.github/workflows/swift-test.yml@v2
strategy:
matrix:
config: [Debug, Release]
fail-fast: false
with:
artifact_name: ${{ format('SpeziStudy-Package-Linux-{0}.lcov', matrix.config) }}
uploadcoveragereport:
name: Upload Coverage Report
needs: [package_tests, ui_tests]
needs: [package_tests, ui_tests, package_tests_linux]
uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: SpeziStudy-*.xcresult TestApp-*.xcresult
coveragereports_lcov: SpeziStudy-Package-Linux-*.lcov
secrets:
token: ${{ secrets.CODECOV_TOKEN }}
143 changes: 89 additions & 54 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,100 @@ let package = Package(
.watchOS(.v11),
.visionOS(.v2)
],
products: [
.library(name: "SpeziStudy", targets: ["SpeziStudy"]),
products: products(),
dependencies: dependencies() + swiftLintPackage(),
targets: targets()
)


func products() -> [Product] {
var products: [Product] = [
.library(name: "SpeziStudyDefinition", targets: ["SpeziStudyDefinition"])
],
dependencies: [
]
#if canImport(Darwin)
products.append(.library(name: "SpeziStudy", targets: ["SpeziStudy"]))
#endif
return products
}

func dependencies() -> [PackageDescription.Package.Dependency] {
var dependencies: [PackageDescription.Package.Dependency] = [
.package(url: "https://github.com/apple/FHIRModels.git", from: "0.8.0"),
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.7.2"),
.package(url: "https://github.com/StanfordSpezi/SpeziHealthKit.git", from: "1.3.2"),
.package(url: "https://github.com/StanfordSpezi/SpeziScheduler.git", from: "1.2.14"),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage.git", from: "2.1.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.7.3"),
.package(url: "https://github.com/StanfordSpezi/SpeziHealthKit.git", from: "1.4.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziScheduler.git", from: "1.2.18"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2")
]
#if canImport(Darwin)
dependencies.append(contentsOf: [
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.10.1"),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage.git", from: "2.1.0"),
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.1")
] + swiftLintPackage(),
targets: [
.target(
name: "SpeziStudyDefinition",
dependencies: [
.product(name: "ModelsR4", package: "FHIRModels"),
.product(name: "SpeziHealthKit", package: "SpeziHealthKit"),
.product(name: "SpeziHealthKitBulkExport", package: "SpeziHealthKit"),
.product(name: "SpeziFoundation", package: "SpeziFoundation"),
.product(name: "SpeziLocalization", package: "SpeziFoundation"),
.product(name: "SpeziScheduler", package: "SpeziScheduler"),
.product(name: "DequeModule", package: "swift-collections")
],
resources: [.process("Resources")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
),
.target(
name: "SpeziStudy",
dependencies: [
.target(name: "SpeziStudyDefinition"),
.product(name: "Spezi", package: "Spezi"),
.product(name: "ModelsR4", package: "FHIRModels"),
.product(name: "SpeziHealthKit", package: "SpeziHealthKit"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage"),
.product(name: "SpeziScheduler", package: "SpeziScheduler"),
.product(name: "SpeziSchedulerUI", package: "SpeziScheduler"),
.product(name: "Algorithms", package: "swift-algorithms")
],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
),
.testTarget(
name: "SpeziStudyTests",
dependencies: [
.target(name: "SpeziStudy"),
.target(name: "SpeziStudyDefinition"),
.product(name: "SpeziTesting", package: "Spezi"),
.product(name: "ModelsR4", package: "FHIRModels")
],
resources: [.process("Resources/questionnaires"), .copy("Resources/assets")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
)
])
#endif
return dependencies
}

func targets() -> [Target] { // swiftlint:disable:this function_body_length
var targets: [Target] = []

let speziStudyDefinitionDependencies: [Target.Dependency] = [
.product(name: "ModelsR4", package: "FHIRModels"),
.product(name: "SpeziHealthKit", package: "SpeziHealthKit"),
.product(name: "SpeziHealthKitBulkExport", package: "SpeziHealthKit"),
.product(name: "SpeziFoundation", package: "SpeziFoundation"),
.product(name: "SpeziLocalization", package: "SpeziFoundation"),
.product(name: "SpeziScheduler", package: "SpeziScheduler"),
.product(name: "DequeModule", package: "swift-collections"),
.product(name: "Logging", package: "swift-log")
]
)
targets.append(.target(
name: "SpeziStudyDefinition",
dependencies: speziStudyDefinitionDependencies,
resources: [.process("Resources")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
))

#if canImport(Darwin)
targets.append(.target(
name: "SpeziStudy",
dependencies: [
.target(name: "SpeziStudyDefinition"),
.product(name: "Spezi", package: "Spezi"),
.product(name: "ModelsR4", package: "FHIRModels"),
.product(name: "SpeziHealthKit", package: "SpeziHealthKit"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage"),
.product(name: "SpeziScheduler", package: "SpeziScheduler"),
.product(name: "SpeziSchedulerUI", package: "SpeziScheduler"),
.product(name: "Algorithms", package: "swift-algorithms")
],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
))
#endif

var speziStudyTestsDependencies: [Target.Dependency] = [
.target(name: "SpeziStudyDefinition"),
.product(name: "ModelsR4", package: "FHIRModels")
]
#if canImport(Darwin)
speziStudyTestsDependencies.append(contentsOf: [
.target(name: "SpeziStudy"),
.product(name: "SpeziTesting", package: "Spezi")
])
#endif
targets.append(.testTarget(
name: "SpeziStudyTests",
dependencies: speziStudyTestsDependencies,
resources: [.process("Resources/questionnaires"), .copy("Resources/assets")],
swiftSettings: [.enableUpcomingFeature("ExistentialAny")],
plugins: [] + swiftLintPlugin()
))

return targets
}


func swiftLintPlugin() -> [Target.PluginUsage] {
Expand Down
6 changes: 3 additions & 3 deletions Sources/SpeziStudy/Study Manager/StudyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ extension StudyManager {
$0.id.starts(with: prefix) && !activeTaskIds.contains($0.id)
})) ?? []
for orphanedTask in orphanedTasks {
logger.notice("Deleting orphaned Task for study '\(study.metadata.title)' (\(study.id)): \(orphanedTask)")
logger.notice("Deleting orphaned Task for study '\(study.metadata.title[.enUS] ?? "")' (\(study.id)): \(orphanedTask)")
try scheduler.deleteAllVersions(of: orphanedTask)
}
}
Expand Down Expand Up @@ -464,7 +464,7 @@ extension StudyManager {

/// Unenroll from a study.
public func unenroll(from enrollment: StudyEnrollment) async throws {
logger.notice("Unenrolling from study '\(enrollment.studyId)' (\(enrollment.studyBundle?.studyDefinition.metadata.title ?? "n/a"))")
logger.notice("Unenrolling from study '\(enrollment.studyId)' (\(enrollment.studyBundle?.studyDefinition.metadata.title[.enUS] ?? ""))")
do {
// Delete all Tasks associated with this study.
// Note that we do this by simply fetching & deleting all Tasks with a matching prefix,
Expand Down Expand Up @@ -644,7 +644,7 @@ extension StudyManager {
extension StudyManager {
private func handleStudyLifecycleEvent(_ event: StudyLifecycleEvent, for studyBundle: StudyBundle, at date: Date) {
logger.notice(
"Handling study lifecycle event '\(String(describing: event))' for study \(studyBundle.id) (\(studyBundle.studyDefinition.metadata.title))"
"Handling study lifecycle event '\(String(describing: event))' for study \(studyBundle.id) (\(studyBundle.studyDefinition.metadata.title[.enUS] ?? ""))"
)
let cal = preferredLocale.calendar
for enrollment in studyEnrollments where enrollment.studyId == studyBundle.id {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

#if canImport(Darwin)
import Foundation


Expand Down Expand Up @@ -42,3 +43,4 @@ extension StudyDefinition.CustomActiveTaskComponent {
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ extension StudyDefinition {
}


#if canImport(Darwin)
extension TimedWalkingTestConfiguration {
private static let spellOutNumberFormatter: NumberFormatter = {
let fmt = NumberFormatter()
Expand Down Expand Up @@ -86,3 +87,4 @@ extension TimedWalkingTestConfiguration {
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ extension StudyBundle {
try fileManager.createDirectory(at: bundleUrl, withIntermediateDirectories: true)
do {
let data = try JSONEncoder().encode(definition)
try data.write(to: bundleUrl.appendingPathComponent("definition", conformingTo: .json))
let definitionUrl = bundleUrl.appendingPathComponent("definition.json")
try data.write(to: definitionUrl)
}
for input in files {
let url: URL
Expand Down
9 changes: 8 additions & 1 deletion Sources/SpeziStudyDefinition/StudyBundle/StudyBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@

import Foundation
import class ModelsR4.Questionnaire
#if canImport(OSLog)
import OSLog
#else
import Logging
#endif
import SpeziFoundation
import SpeziLocalization
#if canImport(UniformTypeIdentifiers)
import UniformTypeIdentifiers


Expand All @@ -21,6 +26,7 @@ extension UTType {
public static let speziStudyBundle = UTType(filenameExtension: "spezistudybundle", conformingTo: .package)!
// swiftlint:disable:previous force_unwrapping
}
#endif


/// A handle for working with a Study Definition bundle.
Expand Down Expand Up @@ -77,7 +83,8 @@ public struct StudyBundle: Identifiable, Sendable {
try Self.assertIsStudyBundleUrl(bundleUrl)
self.bundleUrl = bundleUrl
do {
let data = try Data(contentsOf: bundleUrl.appendingPathComponent("definition", conformingTo: .json))
let definitionUrl = bundleUrl.appendingPathComponent("definition.json")
let data = try Data(contentsOf: definitionUrl)
self.studyDefinition = try JSONDecoder().decode(
StudyDefinition.self,
from: data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,12 @@ extension StudyDefinition {
fileRefToCheck = component.fileRef
case .questionnaire(let component):
fileRefToCheck = component.fileRef
case .healthDataCollection, .timedWalkingTest, .customActiveTask:
case .healthDataCollection, .timedWalkingTest:
fileRefToCheck = nil
#if canImport(Darwin)
case .customActiveTask:
fileRefToCheck = nil
#endif
}
if let fileRefToCheck, studyBundle.resolve(fileRefToCheck, in: Locale(identifier: "en_US")) == nil {
result.issues.insert(.unableToFindFileRef(fileRefToCheck, component))
Expand Down
10 changes: 9 additions & 1 deletion Sources/SpeziStudyDefinition/StudyDefinition+Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ extension StudyDefinition {
case healthDataCollection(HealthDataCollectionComponent)
/// A component that prompts the participant to perform a Timed Walking Test.
case timedWalkingTest(TimedWalkingTestComponent)
#if canImport(Darwin)
/// A component that prompts the participant to perform a custom Active Task.
case customActiveTask(CustomActiveTaskComponent)
#endif
Comment on lines +48 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this Darwin-only? wouldn't that break all studies that use active tasks, when running on linux?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that CustomActiveTaskComponent essentially is a composition of two LocalizedStringResources


/// The components `id`, uniquely identifying it within the ``StudyDefinition``.
public var id: UUID {
Expand All @@ -59,16 +61,22 @@ extension StudyDefinition {
component.id
case .timedWalkingTest(let component):
component.id
#if canImport(Darwin)
case .customActiveTask(let component):
component.id
#endif
}
}

/// The Component's kind
public var kind: Kind {
switch self {
case .informational, .questionnaire, .timedWalkingTest, .customActiveTask:
case .informational, .questionnaire, .timedWalkingTest:
.userInteractive
#if canImport(Darwin)
case .customActiveTask:
.userInteractive
#endif
case .healthDataCollection:
.internal
}
Expand Down
18 changes: 9 additions & 9 deletions Sources/SpeziStudyDefinition/StudyDefinition+Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//

import Foundation

import SpeziLocalization

extension StudyDefinition {
/// Study Metadata
Expand All @@ -23,18 +23,18 @@ extension StudyDefinition {
/// The study's user-visible title.
///
/// E.g., "MyHeart Counts"
public var title: String
public var title: LocalizationsDictionary<String>
/// The study's user-visible short title
///
/// E.g., "MHC"
public var shortTitle: String
public var shortTitle: LocalizationsDictionary<String>
/// Icon that will be used for this study.
public var icon: Icon?
/// Long-form explanation of and/or introduction to the study.
/// Is presented to the user
public var explanationText: String
public var explanationText: LocalizationsDictionary<String>
/// Text that is presented to the user when they eg browse a list of studies they can enroll in
public var shortExplanationText: String
public var shortExplanationText: LocalizationsDictionary<String>

/// Other studies this study depends on.
///
Expand All @@ -53,11 +53,11 @@ extension StudyDefinition {
/// Creates a new `Metadata` object.
public init(
id: UUID,
title: String,
shortTitle: String = "",
title: LocalizationsDictionary<String>,
shortTitle: LocalizationsDictionary<String> = .init([.enUS: ""]),
icon: Icon? = nil, // swiftlint:disable:this function_default_parameter_at_end
explanationText: String,
shortExplanationText: String,
explanationText: LocalizationsDictionary<String>,
shortExplanationText: LocalizationsDictionary<String>,
studyDependency: StudyDefinition.ID? = nil, // swiftlint:disable:this function_default_parameter_at_end
participationCriterion: ParticipationCriterion,
enrollmentConditions: EnrollmentConditions,
Expand Down
Loading
Loading