Skip to content

Commit

Permalink
Add Daily Stepcount Goal UI
Browse files Browse the repository at this point in the history
  • Loading branch information
PSchmiedmayer committed Mar 25, 2024
1 parent 926bc02 commit 1da6645
Show file tree
Hide file tree
Showing 10 changed files with 456 additions and 64 deletions.
12 changes: 12 additions & 0 deletions StudyApplication.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
2F49B7762980407C00BCB272 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F49B7752980407B00BCB272 /* Spezi */; };
2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */; };
2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */; };
2F6B41652BB0B6DF00A6ADA2 /* StudyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */; };
2F6B41682BB0B6E300A6ADA2 /* StudyHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */; };
2F6B416A2BB0FE0500A6ADA2 /* Gauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */; };
2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; };
2FB099AF2A875DF100B20952 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099AE2A875DF100B20952 /* FirebaseAuth */; };
2FB099B12A875DF100B20952 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B02A875DF100B20952 /* FirebaseFirestore */; };
Expand Down Expand Up @@ -126,6 +129,9 @@
2F3AF7352B9D9B8400340B4F /* Peripheral Artery Questionnaire (PAQ).json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Peripheral Artery Questionnaire (PAQ).json"; sourceTree = "<group>"; };
2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = "<group>"; };
2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyImage.swift; sourceTree = "<group>"; };
2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyHeader.swift; sourceTree = "<group>"; };
2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gauge.swift; sourceTree = "<group>"; };
2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
2FAEC07F297F583900C11C42 /* StudyApplication.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = StudyApplication.entitlements; sourceTree = "<group>"; };
2FBF116F2BAE918B0084DC05 /* DailyStepCountGoalModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyStepCountGoalModule.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -260,6 +266,8 @@
isa = PBXGroup;
children = (
2FC5FBB12B35153000F13E59 /* StudyView.swift */,
2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */,
2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */,
2FC5FBCC2B357F3F00F13E59 /* StudiesView.swift */,
);
path = Views;
Expand Down Expand Up @@ -291,6 +299,7 @@
children = (
2FBF116F2BAE918B0084DC05 /* DailyStepCountGoalModule.swift */,
2FBF11852BB098660084DC05 /* DailyStepCountGoal.swift */,
2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */,
);
path = DailyStepCountGoal;
sourceTree = "<group>";
Expand Down Expand Up @@ -744,6 +753,7 @@
2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */,
2FBF11862BB098660084DC05 /* DailyStepCountGoal.swift in Sources */,
2FC5FBA32B3511E400F13E59 /* StudyOnboardingMechanism.swift in Sources */,
2F6B41652BB0B6DF00A6ADA2 /* StudyImage.swift in Sources */,
2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */,
2FC975A82978F11A00BA99FE /* MainView.swift in Sources */,
2FE5DC4E29EDD7FA004B9AB4 /* ScheduleView.swift in Sources */,
Expand All @@ -765,6 +775,7 @@
2FC5FBA52B3511EA00F13E59 /* InviationCodeStudyOnboardingMechanismError.swift in Sources */,
2F2168AF2BA4F3A7004603AE /* Home.swift in Sources */,
2FC5FBA72B3511F400F13E59 /* InviationCodeStudyOnboardingMechanism.swift in Sources */,
2F6B416A2BB0FE0500A6ADA2 /* Gauge.swift in Sources */,
2FC5FB942B34F55B00F13E59 /* StudyModule.swift in Sources */,
2FC5FB8C2B34F1CA00F13E59 /* Organization.swift in Sources */,
2FD08B652B9DC2E500AC7523 /* FirebaseFunctionsConfiguration.swift in Sources */,
Expand All @@ -780,6 +791,7 @@
653A2551283387FE005D4D48 /* StudyApplication.swift in Sources */,
2FC5FBAF2B3513DD00F13E59 /* StudyError.swift in Sources */,
2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */,
2F6B41682BB0B6E300A6ADA2 /* StudyHeader.swift in Sources */,
2FC5FBB52B351FF800F13E59 /* StudyOnboardingFlow.swift in Sources */,
2FC5FB892B34F09500F13E59 /* Study.swift in Sources */,
2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,119 @@ import SwiftUI


struct DailyStepCountGoal: View {
enum Constants {
static let stepCountGoalIncrement = 500
}

let study: Study
@Environment(DailyStepCountGoalModule.self) var dailyStepCountGoalModule


var body: some View {
Text("Today's step count: \(dailyStepCountGoalModule.todayStepCount)")
VStack {
todayStepCountSection
Divider()
goalSection
}
}

Check warning on line 27 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift#L21-L27

Added lines #L21 - L27 were not covered by tests


@ViewBuilder private var todayStepCountSection: some View {
ZStack {
Gauge(progress: Double(dailyStepCountGoalModule.todayStepCount) / Double(dailyStepCountGoalModule.stepCountGoal))
.frame(maxHeight: 256)
VStack {
Text("\(dailyStepCountGoalModule.todayStepCount)")
.font(.largeTitle.bold().monospacedDigit())
.minimumScaleFactor(0.3)
.contentTransition(.numericText())
.animation(.easeOut, value: dailyStepCountGoalModule.todayStepCount)
Text("Steps Today")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 20)
}
.padding()
.padding(.horizontal, 32)
}

Check warning on line 49 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift#L30-L49

Added lines #L30 - L49 were not covered by tests

@ViewBuilder private var goalSection: some View {
HStack(alignment: .firstTextBaseline) {
Spacer()
decreaseDailyStepCountGoalButton
VStack {
Text("\(dailyStepCountGoalModule.stepCountGoal)")
.font(.title.bold().monospacedDigit())
.contentTransition(.numericText())
.animation(.easeOut, value: dailyStepCountGoalModule.stepCountGoal)
Text("Daily Step Count Goal")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(minWidth: 150)
.accessibilityLabel(Text("Daily Step Count Goal: \(dailyStepCountGoalModule.stepCountGoal)"))
increaseDailyStepCountGoalButton
Spacer()
}
.padding(.top)
}

Check warning on line 71 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift#L51-L71

Added lines #L51 - L71 were not covered by tests

@ViewBuilder private var decreaseDailyStepCountGoalButton: some View {
Button(
action: {
guard dailyStepCountGoalModule.stepCountGoal > 3 * Constants.stepCountGoalIncrement else {
dailyStepCountGoalModule.stepCountGoal = 2 * Constants.stepCountGoalIncrement
print("Decrease GUARD ...")
return
}
dailyStepCountGoalModule.stepCountGoal -= Constants.stepCountGoalIncrement
print("Decrease ...")
},
label: {
Image(systemName: "minus.circle")
.accessibilityLabel(Text("Decrease Step Count Goal by \(Constants.stepCountGoalIncrement)"))
}
)
.font(.title)
.foregroundStyle(Color.accentColor)
.buttonStyle(.borderless)
}

Check warning on line 92 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift#L73-L92

Added lines #L73 - L92 were not covered by tests

@ViewBuilder private var increaseDailyStepCountGoalButton: some View {
Button(
action: {
dailyStepCountGoalModule.stepCountGoal += Constants.stepCountGoalIncrement
print("Increase ...")
},
label: {
Image(systemName: "plus.circle")
.accessibilityLabel(Text("Increase Step Count Goal by \(Constants.stepCountGoalIncrement)"))
}
)
.font(.title)
.foregroundStyle(Color.accentColor)
.buttonStyle(.borderless)
}

Check warning on line 108 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoal.swift#L94-L108

Added lines #L94 - L108 were not covered by tests
}


#Preview {
let studyModule = StudyModule()

return DailyStepCountGoal(study: studyModule.studies[0])
.previewWith {
return List {
StudyApplicationListCard {
DailyStepCountGoal(study: studyModule.studies[0])
}
}
.studyApplicationList()
.previewWith(standard: StudyApplicationStandard()) {
DailyStepCountGoalModule()
studyModule
}
.task {
try? await studyModule.enrollInStudy(study: studyModule.studies[0])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,48 @@ import HealthKit
import OSLog
import Spezi
import SpeziHealthKit
import SpeziLocalStorage


@Observable
class DailyStepCountGoalModule: Module, EnvironmentAccessible, DefaultInitializable {
private let logger = Logger(subsystem: "edu.stanford.spezi.studyapplication", category: "TodayStepCount")
private let healthStore = HKHealthStore()

@ObservationIgnored @Dependency var localStorage: LocalStorage
@ObservationIgnored @Dependency var healthKit: HealthKit

private var queryTask: Task<Void, Error>?
private var dayChangedTask: Task<Void, Never>?
private(set) var todayStepCount: Int = 0
var stepCountGoal: Int = 10_000 {
didSet {
do {
try localStorage.store(stepCountGoal, storageKey: StorageKeys.dailyStepCountGoal)
} catch {
logger.error("Could not store enrolled studies.")
}
}
}


required init() { }


func configure() {
do {
if ProcessInfo.processInfo.isPreviewSimulator {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.todayStepCount += Int.random(in: 20...42)
}

Check warning on line 46 in StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoalModule.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoalModule.swift#L44-L46

Added lines #L44 - L46 were not covered by tests
} else {
#warning("We need to store the step count goal in Firebase and observe changes in the study app.")
self.stepCountGoal = try localStorage.read(storageKey: StorageKeys.dailyStepCountGoal)
}
} catch {
logger.info("Could not retrieve existing step count goal.")
}

// We use observation tracking to observe healthKit.authorized.
// If the value is false, the query is not executed and the observation tracking will let us know if the value changed.
withContinousObservation(of: self.healthKit.authorized) { authorized in
Expand Down
160 changes: 160 additions & 0 deletions StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


private struct GaugeRing: Shape {
private(set) var progress: CGFloat


var animatableData: CGFloat {
get {
progress
}
set {
progress = newValue
}

Check warning on line 22 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L17-L22

Added lines #L17 - L22 were not covered by tests
}


init(progress: CGFloat) {
self.progress = progress
}

Check warning on line 28 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L26-L28

Added lines #L26 - L28 were not covered by tests


func path(in rect: CGRect) -> Path {
Path { path in
path.addArc(
center: CGPoint(x: rect.width / 2, y: rect.height / 2),
radius: min(rect.width, rect.height) / 2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: progress * 360.0),
clockwise: false
)
}
}

Check warning on line 41 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L31-L41

Added lines #L31 - L41 were not covered by tests
}

struct Gauge: View {
let lineWidth: Double
let shaddowRadius: Double
let gradient: Gradient
let backgroundColor: Color
let progress: Double

@State private var size: CGSize = .zero

Check warning on line 51 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L51

Added line #L51 was not covered by tests


private var shaddowOffset: CGPoint {
let angle = Angle(degrees: progress * 360.0).radians
return CGPoint(x: cos(angle) * shaddowRadius * 2.0, y: sin(angle) * shaddowRadius * 2.0)
}

Check warning on line 57 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L54-L57

Added lines #L54 - L57 were not covered by tests

private var radius: Double {
(size.height / 2) - (lineWidth / 2)
}

Check warning on line 61 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L59-L61

Added lines #L59 - L61 were not covered by tests

var body: some View {
ZStack { // swiftlint:disable:this closure_body_length
GeometryReader { proxy in
Circle()
.stroke(backgroundColor, lineWidth: lineWidth)
.padding(lineWidth / 2)
.onAppear {
self.size = proxy.size
}
}
GaugeRing(progress: progress)
.stroke(
AngularGradient(
gradient: gradient,
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(progress * 360.0)
),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.frame(width: size.width, height: size.height)
.padding(lineWidth / 2)
if progress < 1.0 - (lineWidth / (2 * radius * .pi)) {
Circle()
.frame(width: lineWidth)
.foregroundColor(gradient.stops.first?.color)
.offset(y: -(size.height / 2))
} else {
let shaddowOffset = shaddowOffset
Circle()
.frame(width: lineWidth)
.foregroundColor(gradient.stops.last?.color)
.offset(y: -(size.height / 2))
.rotationEffect(Angle.degrees(360 * Double(progress)))
.shadow(
color: .black.opacity(0.2),
radius: shaddowRadius,
x: shaddowOffset.x,
y: shaddowOffset.y
)
}
}
.aspectRatio(1.0, contentMode: .fit)
.animation(.easeOut, value: progress)
}

Check warning on line 108 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L63-L108

Added lines #L63 - L108 were not covered by tests


init(
progress: Double,
gradient: Gradient,
backgroundColor: Color,
lineWidth: Double = 20,
shaddowRadius: Double = 4
) {
self.progress = progress
self.lineWidth = lineWidth
self.shaddowRadius = shaddowRadius
self.gradient = gradient
self.backgroundColor = backgroundColor
}

Check warning on line 123 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L117-L123

Added lines #L117 - L123 were not covered by tests

init(
progress: Double,
color: Color = .accentColor,
lineWidth: Double = 20
) {
self.progress = progress
self.lineWidth = lineWidth
self.shaddowRadius = lineWidth / 5
self.gradient = Gradient(colors: [color, color])
self.backgroundColor = color.opacity(0.2)
}

Check warning on line 135 in StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift

View check run for this annotation

Codecov / codecov/patch

StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift#L129-L135

Added lines #L129 - L135 were not covered by tests
}


#Preview {
Gauge(progress: 0.981, color: .red)
.padding()
}

#Preview("Animation") {
struct PreviewWrapper: View {
@State var progress = 0.0

var body: some View {
Gauge(progress: progress)
.task {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
progress += Double.random(in: 0.0...0.05)
}
}
}
}

return PreviewWrapper()
.padding()
}
Loading

0 comments on commit 1da6645

Please sign in to comment.