expo-orb 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -1
- package/android/build.gradle +2 -0
- package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +25 -0
- package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +610 -0
- package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +108 -0
- package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +51 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +177 -0
- package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +144 -0
- package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
- package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
- package/build/ExpoBreathingExercise.types.d.ts +48 -0
- package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
- package/build/ExpoBreathingExercise.types.js +2 -0
- package/build/ExpoBreathingExercise.types.js.map +1 -0
- package/build/ExpoBreathingExerciseModule.d.ts +16 -0
- package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseModule.js +52 -0
- package/build/ExpoBreathingExerciseModule.js.map +1 -0
- package/build/ExpoBreathingExerciseView.d.ts +4 -0
- package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
- package/build/ExpoBreathingExerciseView.js +7 -0
- package/build/ExpoBreathingExerciseView.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/expo-module.config.json +2 -2
- package/ios/Breathing/BreathingConfiguration.swift +57 -0
- package/ios/Breathing/BreathingExerciseView.swift +451 -0
- package/ios/Breathing/BreathingParticlesView.swift +81 -0
- package/ios/Breathing/BreathingSharedState.swift +84 -0
- package/ios/Breathing/BreathingTextCue.swift +14 -0
- package/ios/Breathing/MorphingBlobView.swift +242 -0
- package/ios/Breathing/ProgressRingView.swift +27 -0
- package/ios/ExpoBreathingExerciseModule.swift +182 -0
- package/ios/ExpoBreathingExerciseView.swift +124 -0
- package/package.json +8 -5
- package/src/ExpoBreathingExercise.types.ts +50 -0
- package/src/ExpoBreathingExerciseModule.ts +67 -0
- package/src/ExpoBreathingExerciseView.tsx +11 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum BreathPhase: String {
|
|
4
|
+
case inhale
|
|
5
|
+
case holdIn
|
|
6
|
+
case exhale
|
|
7
|
+
case holdOut
|
|
8
|
+
case idle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public enum BreathingExerciseState {
|
|
12
|
+
case stopped
|
|
13
|
+
case running
|
|
14
|
+
case paused
|
|
15
|
+
case complete
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public final class BreathingSharedState {
|
|
19
|
+
public static let shared = BreathingSharedState()
|
|
20
|
+
|
|
21
|
+
// Pattern configuration
|
|
22
|
+
var phases: [BreathPhaseConfig] = []
|
|
23
|
+
var totalCycles: Int? = nil // nil = infinite
|
|
24
|
+
|
|
25
|
+
// Current state
|
|
26
|
+
var state: BreathingExerciseState = .stopped
|
|
27
|
+
var currentPhaseIndex: Int = 0
|
|
28
|
+
var currentCycle: Int = 0
|
|
29
|
+
var phaseStartTime: Date = Date()
|
|
30
|
+
var pauseTime: Date? = nil
|
|
31
|
+
|
|
32
|
+
// Animation values (read by TimelineView each frame)
|
|
33
|
+
var currentScale: Double = 1.0
|
|
34
|
+
var targetScale: Double = 1.0
|
|
35
|
+
var startScale: Double = 1.0 // Scale at start of current phase
|
|
36
|
+
var lastScaleUpdate: Date = Date()
|
|
37
|
+
var currentPhase: BreathPhase = .idle
|
|
38
|
+
var currentLabel: String = ""
|
|
39
|
+
var phaseProgress: Double = 0.0 // 0-1 progress through current phase
|
|
40
|
+
|
|
41
|
+
// Wobble animation
|
|
42
|
+
var wobbleIntensity: Double = 1.0
|
|
43
|
+
var wobbleOffsets: [Double] = []
|
|
44
|
+
var wobbleTargets: [Double] = []
|
|
45
|
+
var lastWobbleUpdate: Date = Date()
|
|
46
|
+
var lastWobbleTargetUpdate: Date = Date()
|
|
47
|
+
|
|
48
|
+
// Exercise tracking
|
|
49
|
+
var exerciseStartTime: Date = Date()
|
|
50
|
+
var totalDuration: Double = 0.0
|
|
51
|
+
|
|
52
|
+
// Callbacks
|
|
53
|
+
var onPhaseChange: ((BreathPhase, String, Int, Int) -> Void)? = nil
|
|
54
|
+
var onExerciseComplete: ((Int, Double) -> Void)? = nil
|
|
55
|
+
|
|
56
|
+
private init() {}
|
|
57
|
+
|
|
58
|
+
func reset() {
|
|
59
|
+
state = .stopped
|
|
60
|
+
currentPhaseIndex = 0
|
|
61
|
+
currentCycle = 0
|
|
62
|
+
currentScale = 1.0
|
|
63
|
+
targetScale = 1.0
|
|
64
|
+
currentPhase = .idle
|
|
65
|
+
currentLabel = ""
|
|
66
|
+
phaseProgress = 0.0
|
|
67
|
+
wobbleIntensity = 1.0
|
|
68
|
+
pauseTime = nil
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public struct BreathPhaseConfig {
|
|
73
|
+
let phase: BreathPhase
|
|
74
|
+
let duration: Double // seconds
|
|
75
|
+
let targetScale: Double
|
|
76
|
+
let label: String
|
|
77
|
+
|
|
78
|
+
init(phase: BreathPhase, duration: Double, targetScale: Double, label: String) {
|
|
79
|
+
self.phase = phase
|
|
80
|
+
self.duration = duration
|
|
81
|
+
self.targetScale = targetScale
|
|
82
|
+
self.label = label
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct BreathingTextCue: View {
|
|
4
|
+
let text: String
|
|
5
|
+
let color: Color
|
|
6
|
+
|
|
7
|
+
var body: some View {
|
|
8
|
+
Text(text)
|
|
9
|
+
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
|
10
|
+
.foregroundColor(color)
|
|
11
|
+
.shadow(color: Color.black.opacity(0.5), radius: 8, x: 0, y: 2)
|
|
12
|
+
.animation(.easeInOut(duration: 0.3), value: text)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct MorphingBlobView: View {
|
|
4
|
+
let baseRadius: CGFloat
|
|
5
|
+
let pointCount: Int
|
|
6
|
+
let offsets: [Double]
|
|
7
|
+
let colors: [Color]
|
|
8
|
+
let innerColor: Color
|
|
9
|
+
let showInnerBlob: Bool
|
|
10
|
+
var elapsedTime: Double = 0
|
|
11
|
+
|
|
12
|
+
var body: some View {
|
|
13
|
+
GeometryReader { geometry in
|
|
14
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
15
|
+
let center = CGPoint(x: size / 2, y: size / 2)
|
|
16
|
+
let effectiveRadius = baseRadius * size / 2
|
|
17
|
+
|
|
18
|
+
ZStack {
|
|
19
|
+
// Main blob with linear gradient fill (like OrbView)
|
|
20
|
+
MorphingBlobShape(
|
|
21
|
+
center: center,
|
|
22
|
+
baseRadius: effectiveRadius,
|
|
23
|
+
pointCount: pointCount,
|
|
24
|
+
offsets: offsets
|
|
25
|
+
)
|
|
26
|
+
.fill(
|
|
27
|
+
LinearGradient(
|
|
28
|
+
colors: colors,
|
|
29
|
+
startPoint: .bottom,
|
|
30
|
+
endPoint: .top
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Inner glow overlay (like OrbView's realisticInnerGlows)
|
|
35
|
+
MorphingBlobShape(
|
|
36
|
+
center: center,
|
|
37
|
+
baseRadius: effectiveRadius,
|
|
38
|
+
pointCount: pointCount,
|
|
39
|
+
offsets: offsets
|
|
40
|
+
)
|
|
41
|
+
.stroke(
|
|
42
|
+
LinearGradient(
|
|
43
|
+
colors: [.white.opacity(0.6), .clear],
|
|
44
|
+
startPoint: .bottom,
|
|
45
|
+
endPoint: .top
|
|
46
|
+
),
|
|
47
|
+
lineWidth: 3
|
|
48
|
+
)
|
|
49
|
+
.blur(radius: 4)
|
|
50
|
+
.blendMode(.plusLighter)
|
|
51
|
+
|
|
52
|
+
// Inner animated blob like OrbView's WavyBlobView
|
|
53
|
+
if showInnerBlob {
|
|
54
|
+
InnerWavyBlob(
|
|
55
|
+
center: center,
|
|
56
|
+
baseRadius: effectiveRadius * 0.55,
|
|
57
|
+
color: innerColor,
|
|
58
|
+
elapsedTime: elapsedTime
|
|
59
|
+
)
|
|
60
|
+
.blur(radius: 10)
|
|
61
|
+
.blendMode(.plusLighter)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Inner blob with time-based animation like WavyBlobView
|
|
69
|
+
struct InnerWavyBlob: View {
|
|
70
|
+
let center: CGPoint
|
|
71
|
+
let baseRadius: CGFloat
|
|
72
|
+
let color: Color
|
|
73
|
+
let elapsedTime: Double
|
|
74
|
+
|
|
75
|
+
private let pointCount = 6
|
|
76
|
+
private let loopDuration: Double = 4.0
|
|
77
|
+
|
|
78
|
+
var body: some View {
|
|
79
|
+
Canvas { context, size in
|
|
80
|
+
let angle = (elapsedTime.truncatingRemainder(dividingBy: loopDuration) / loopDuration) * 2 * .pi
|
|
81
|
+
|
|
82
|
+
var path = Path()
|
|
83
|
+
|
|
84
|
+
// Generate points with time-based smooth movement
|
|
85
|
+
var points: [CGPoint] = []
|
|
86
|
+
for i in 0..<pointCount {
|
|
87
|
+
let baseAngle = (Double(i) / Double(pointCount)) * 2 * .pi
|
|
88
|
+
let phaseOffset = Double(i) * .pi / 3
|
|
89
|
+
|
|
90
|
+
// Smooth oscillating offsets
|
|
91
|
+
let radiusOffset = sin(angle * 2 + phaseOffset) * 0.15 + cos(angle * 1.5 + phaseOffset * 0.7) * 0.1
|
|
92
|
+
let angleOffset = cos(angle + phaseOffset) * 0.1
|
|
93
|
+
|
|
94
|
+
let r = baseRadius * (1.0 + radiusOffset)
|
|
95
|
+
let a = baseAngle + angleOffset
|
|
96
|
+
|
|
97
|
+
points.append(CGPoint(
|
|
98
|
+
x: center.x + cos(a) * r,
|
|
99
|
+
y: center.y + sin(a) * r
|
|
100
|
+
))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Start path
|
|
104
|
+
path.move(to: points[0])
|
|
105
|
+
|
|
106
|
+
// Create smooth curves between points
|
|
107
|
+
let handleLength = baseRadius * 0.5
|
|
108
|
+
|
|
109
|
+
for i in 0..<pointCount {
|
|
110
|
+
let current = points[i]
|
|
111
|
+
let next = points[(i + 1) % pointCount]
|
|
112
|
+
|
|
113
|
+
let currentAngle = atan2(current.y - center.y, current.x - center.x)
|
|
114
|
+
let nextAngle = atan2(next.y - center.y, next.x - center.x)
|
|
115
|
+
|
|
116
|
+
let control1 = CGPoint(
|
|
117
|
+
x: current.x + cos(currentAngle + .pi / 2) * handleLength,
|
|
118
|
+
y: current.y + sin(currentAngle + .pi / 2) * handleLength
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
let control2 = CGPoint(
|
|
122
|
+
x: next.x + cos(nextAngle - .pi / 2) * handleLength,
|
|
123
|
+
y: next.y + sin(nextAngle - .pi / 2) * handleLength
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
path.addCurve(to: next, control1: control1, control2: control2)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
path.closeSubpath()
|
|
130
|
+
context.fill(path, with: .color(color))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
struct MorphingBlobShape: Shape {
|
|
136
|
+
var center: CGPoint
|
|
137
|
+
var baseRadius: CGFloat
|
|
138
|
+
var pointCount: Int
|
|
139
|
+
var offsets: [Double]
|
|
140
|
+
|
|
141
|
+
var animatableData: AnimatableVector {
|
|
142
|
+
get { AnimatableVector(values: offsets) }
|
|
143
|
+
set { offsets = newValue.values }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func path(in rect: CGRect) -> Path {
|
|
147
|
+
var path = Path()
|
|
148
|
+
|
|
149
|
+
guard pointCount >= 3 else { return path }
|
|
150
|
+
|
|
151
|
+
// Generate points around the circle with offsets
|
|
152
|
+
var points: [CGPoint] = []
|
|
153
|
+
for i in 0..<pointCount {
|
|
154
|
+
let angle = (Double(i) / Double(pointCount)) * 2 * .pi - .pi / 2
|
|
155
|
+
let offset = i < offsets.count ? offsets[i] : 0
|
|
156
|
+
let radius = baseRadius * (1.0 + offset)
|
|
157
|
+
|
|
158
|
+
points.append(CGPoint(
|
|
159
|
+
x: center.x + CGFloat(cos(angle)) * radius,
|
|
160
|
+
y: center.y + CGFloat(sin(angle)) * radius
|
|
161
|
+
))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tangent coefficient for smooth cubic bezier curves
|
|
165
|
+
let tangentCoeff = CGFloat((4.0 / 3.0) * tan(.pi / (2.0 * Double(pointCount))))
|
|
166
|
+
|
|
167
|
+
// Start at first point
|
|
168
|
+
path.move(to: points[0])
|
|
169
|
+
|
|
170
|
+
// Draw cubic bezier curves between each pair of points
|
|
171
|
+
for i in 0..<pointCount {
|
|
172
|
+
let current = points[i]
|
|
173
|
+
let next = points[(i + 1) % pointCount]
|
|
174
|
+
|
|
175
|
+
let currentAngle = (Double(i) / Double(pointCount)) * 2 * .pi - .pi / 2
|
|
176
|
+
let nextAngle = (Double((i + 1) % pointCount) / Double(pointCount)) * 2 * .pi - .pi / 2
|
|
177
|
+
|
|
178
|
+
let currentOffset = i < offsets.count ? offsets[i] : 0
|
|
179
|
+
let currentRadius = baseRadius * CGFloat(1.0 + currentOffset)
|
|
180
|
+
let currentTangentLength = currentRadius * tangentCoeff
|
|
181
|
+
|
|
182
|
+
let nextOffset = (i + 1) % pointCount < offsets.count ? offsets[(i + 1) % pointCount] : 0
|
|
183
|
+
let nextRadius = baseRadius * CGFloat(1.0 + nextOffset)
|
|
184
|
+
let nextTangentLength = nextRadius * tangentCoeff
|
|
185
|
+
|
|
186
|
+
let control1 = CGPoint(
|
|
187
|
+
x: current.x + CGFloat(cos(currentAngle + .pi / 2)) * currentTangentLength,
|
|
188
|
+
y: current.y + CGFloat(sin(currentAngle + .pi / 2)) * currentTangentLength
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
let control2 = CGPoint(
|
|
192
|
+
x: next.x + CGFloat(cos(nextAngle - .pi / 2)) * nextTangentLength,
|
|
193
|
+
y: next.y + CGFloat(sin(nextAngle - .pi / 2)) * nextTangentLength
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
path.addCurve(to: next, control1: control1, control2: control2)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
path.closeSubpath()
|
|
200
|
+
return path
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
struct AnimatableVector: VectorArithmetic {
|
|
205
|
+
var values: [Double]
|
|
206
|
+
|
|
207
|
+
static var zero: AnimatableVector {
|
|
208
|
+
AnimatableVector(values: [])
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
static func + (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector {
|
|
212
|
+
let maxCount = max(lhs.values.count, rhs.values.count)
|
|
213
|
+
var result = [Double](repeating: 0, count: maxCount)
|
|
214
|
+
for i in 0..<maxCount {
|
|
215
|
+
let l = i < lhs.values.count ? lhs.values[i] : 0
|
|
216
|
+
let r = i < rhs.values.count ? rhs.values[i] : 0
|
|
217
|
+
result[i] = l + r
|
|
218
|
+
}
|
|
219
|
+
return AnimatableVector(values: result)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static func - (lhs: AnimatableVector, rhs: AnimatableVector) -> AnimatableVector {
|
|
223
|
+
let maxCount = max(lhs.values.count, rhs.values.count)
|
|
224
|
+
var result = [Double](repeating: 0, count: maxCount)
|
|
225
|
+
for i in 0..<maxCount {
|
|
226
|
+
let l = i < lhs.values.count ? lhs.values[i] : 0
|
|
227
|
+
let r = i < rhs.values.count ? rhs.values[i] : 0
|
|
228
|
+
result[i] = l - r
|
|
229
|
+
}
|
|
230
|
+
return AnimatableVector(values: result)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
mutating func scale(by rhs: Double) {
|
|
234
|
+
for i in 0..<values.count {
|
|
235
|
+
values[i] *= rhs
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
var magnitudeSquared: Double {
|
|
240
|
+
values.reduce(0) { $0 + $1 * $1 }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct ProgressRingView: View {
|
|
4
|
+
let progress: Double
|
|
5
|
+
let color: Color
|
|
6
|
+
let lineWidth: CGFloat
|
|
7
|
+
let size: CGFloat
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
ZStack {
|
|
11
|
+
// Background ring
|
|
12
|
+
Circle()
|
|
13
|
+
.stroke(color.opacity(0.2), lineWidth: lineWidth)
|
|
14
|
+
.frame(width: size, height: size)
|
|
15
|
+
|
|
16
|
+
// Progress arc
|
|
17
|
+
Circle()
|
|
18
|
+
.trim(from: 0, to: CGFloat(min(max(progress, 0), 1)))
|
|
19
|
+
.stroke(
|
|
20
|
+
color,
|
|
21
|
+
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
|
22
|
+
)
|
|
23
|
+
.frame(width: size, height: size)
|
|
24
|
+
.rotationEffect(.degrees(-90))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
public class ExpoBreathingExerciseModule: Module {
|
|
5
|
+
public func definition() -> ModuleDefinition {
|
|
6
|
+
Name("ExpoBreathingExercise")
|
|
7
|
+
|
|
8
|
+
Events("onPhaseChange", "onExerciseComplete")
|
|
9
|
+
|
|
10
|
+
Function("startBreathingExercise") { (pattern: [String: Any]) in
|
|
11
|
+
DispatchQueue.main.async {
|
|
12
|
+
self.startExercise(pattern: pattern)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Function("stopBreathingExercise") {
|
|
17
|
+
DispatchQueue.main.async {
|
|
18
|
+
BreathingSharedState.shared.reset()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Function("pauseBreathingExercise") {
|
|
23
|
+
DispatchQueue.main.async {
|
|
24
|
+
let state = BreathingSharedState.shared
|
|
25
|
+
if state.state == .running {
|
|
26
|
+
state.state = .paused
|
|
27
|
+
state.pauseTime = Date()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Function("resumeBreathingExercise") {
|
|
33
|
+
DispatchQueue.main.async {
|
|
34
|
+
let state = BreathingSharedState.shared
|
|
35
|
+
if state.state == .paused, let pauseTime = state.pauseTime {
|
|
36
|
+
let pauseDuration = Date().timeIntervalSince(pauseTime)
|
|
37
|
+
state.phaseStartTime = state.phaseStartTime.addingTimeInterval(pauseDuration)
|
|
38
|
+
state.exerciseStartTime = state.exerciseStartTime.addingTimeInterval(pauseDuration)
|
|
39
|
+
state.state = .running
|
|
40
|
+
state.pauseTime = nil
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
View(ExpoBreathingExerciseView.self) {
|
|
46
|
+
Events("onPhaseChange", "onExerciseComplete")
|
|
47
|
+
|
|
48
|
+
Prop("blobColors") { (view: ExpoBreathingExerciseView, colors: [UIColor]) in
|
|
49
|
+
view.updateProps { $0.blobColors = colors }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Prop("innerBlobColor") { (view: ExpoBreathingExerciseView, color: UIColor) in
|
|
53
|
+
view.updateProps { $0.innerBlobColor = color }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Prop("glowColor") { (view: ExpoBreathingExerciseView, color: UIColor) in
|
|
57
|
+
view.updateProps { $0.glowColor = color }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Prop("particleColor") { (view: ExpoBreathingExerciseView, color: UIColor) in
|
|
61
|
+
view.updateProps { $0.particleColor = color }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Prop("progressRingColor") { (view: ExpoBreathingExerciseView, color: UIColor) in
|
|
65
|
+
view.updateProps { $0.progressRingColor = color }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
Prop("textColor") { (view: ExpoBreathingExerciseView, color: UIColor) in
|
|
69
|
+
view.updateProps { $0.textColor = color }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
Prop("showProgressRing") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
73
|
+
view.updateProps { $0.showProgressRing = value }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Prop("showTextCue") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
77
|
+
view.updateProps { $0.showTextCue = value }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Prop("showInnerBlob") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
81
|
+
view.updateProps { $0.showInnerBlob = value }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Prop("showShadow") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
85
|
+
view.updateProps { $0.showShadow = value }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Prop("showParticles") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
89
|
+
view.updateProps { $0.showParticles = value }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Prop("showWavyBlobs") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
93
|
+
view.updateProps { $0.showWavyBlobs = value }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Prop("showGlowEffects") { (view: ExpoBreathingExerciseView, value: Bool) in
|
|
97
|
+
view.updateProps { $0.showGlowEffects = value }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Prop("pointCount") { (view: ExpoBreathingExerciseView, value: Int) in
|
|
101
|
+
view.updateProps { $0.pointCount = value }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
Prop("wobbleIntensity") { (view: ExpoBreathingExerciseView, value: Double) in
|
|
105
|
+
view.updateProps { $0.wobbleIntensity = value }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func startExercise(pattern: [String: Any]) {
|
|
111
|
+
let state = BreathingSharedState.shared
|
|
112
|
+
state.reset()
|
|
113
|
+
|
|
114
|
+
// Parse phases
|
|
115
|
+
guard let phasesArray = pattern["phases"] as? [[String: Any]] else {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
var phases: [BreathPhaseConfig] = []
|
|
120
|
+
for phaseDict in phasesArray {
|
|
121
|
+
guard let phaseString = phaseDict["phase"] as? String,
|
|
122
|
+
let duration = phaseDict["duration"] as? Double,
|
|
123
|
+
let targetScale = phaseDict["targetScale"] as? Double,
|
|
124
|
+
let label = phaseDict["label"] as? String else {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let phase: BreathPhase
|
|
129
|
+
switch phaseString {
|
|
130
|
+
case "inhale": phase = .inhale
|
|
131
|
+
case "holdIn": phase = .holdIn
|
|
132
|
+
case "exhale": phase = .exhale
|
|
133
|
+
case "holdOut": phase = .holdOut
|
|
134
|
+
default: continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
phases.append(BreathPhaseConfig(
|
|
138
|
+
phase: phase,
|
|
139
|
+
duration: duration / 1000.0, // Convert ms to seconds
|
|
140
|
+
targetScale: targetScale,
|
|
141
|
+
label: label
|
|
142
|
+
))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
guard !phases.isEmpty else { return }
|
|
146
|
+
|
|
147
|
+
// Parse cycles
|
|
148
|
+
if let cycles = pattern["cycles"] as? Int {
|
|
149
|
+
state.totalCycles = cycles
|
|
150
|
+
} else {
|
|
151
|
+
state.totalCycles = nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Setup state
|
|
155
|
+
state.phases = phases
|
|
156
|
+
state.currentPhaseIndex = 0
|
|
157
|
+
state.currentCycle = 0
|
|
158
|
+
state.phaseStartTime = Date()
|
|
159
|
+
state.exerciseStartTime = Date()
|
|
160
|
+
|
|
161
|
+
// Set initial phase values
|
|
162
|
+
let firstPhase = phases[0]
|
|
163
|
+
state.currentPhase = firstPhase.phase
|
|
164
|
+
state.currentLabel = firstPhase.label
|
|
165
|
+
state.startScale = 1.0 // Start scale for first phase
|
|
166
|
+
state.targetScale = firstPhase.targetScale
|
|
167
|
+
state.currentScale = 1.0 // Start at base scale
|
|
168
|
+
state.phaseProgress = 0
|
|
169
|
+
|
|
170
|
+
// Set initial wobble intensity
|
|
171
|
+
switch firstPhase.phase {
|
|
172
|
+
case .inhale, .exhale:
|
|
173
|
+
state.wobbleIntensity = 1.0
|
|
174
|
+
case .holdIn, .holdOut:
|
|
175
|
+
state.wobbleIntensity = 0.3
|
|
176
|
+
case .idle:
|
|
177
|
+
state.wobbleIntensity = 0.5
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
state.state = .running
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
private final class BreathingConfigurationModel: ObservableObject {
|
|
5
|
+
@Published var configuration: BreathingConfiguration
|
|
6
|
+
|
|
7
|
+
init(configuration: BreathingConfiguration) {
|
|
8
|
+
self.configuration = configuration
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private struct BreathingContainerView: View {
|
|
13
|
+
@ObservedObject var model: BreathingConfigurationModel
|
|
14
|
+
|
|
15
|
+
var body: some View {
|
|
16
|
+
BreathingExerciseView(configuration: model.configuration)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct BreathingProps {
|
|
21
|
+
private static let defaultBlobColors: [UIColor] = [
|
|
22
|
+
UIColor(red: 0.4, green: 0.7, blue: 0.9, alpha: 1.0),
|
|
23
|
+
UIColor(red: 0.3, green: 0.5, blue: 0.8, alpha: 1.0),
|
|
24
|
+
UIColor(red: 0.5, green: 0.3, blue: 0.7, alpha: 1.0)
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
var blobColors: [UIColor] = BreathingProps.defaultBlobColors
|
|
28
|
+
var innerBlobColor: UIColor = UIColor.white.withAlphaComponent(0.3)
|
|
29
|
+
var glowColor: UIColor = .white
|
|
30
|
+
var particleColor: UIColor = .white
|
|
31
|
+
var progressRingColor: UIColor = UIColor.white.withAlphaComponent(0.5)
|
|
32
|
+
var textColor: UIColor = .white
|
|
33
|
+
var showProgressRing: Bool = true
|
|
34
|
+
var showTextCue: Bool = true
|
|
35
|
+
var showInnerBlob: Bool = true
|
|
36
|
+
var showShadow: Bool = true
|
|
37
|
+
var showParticles: Bool = true
|
|
38
|
+
var showWavyBlobs: Bool = true
|
|
39
|
+
var showGlowEffects: Bool = true
|
|
40
|
+
var pointCount: Int = 8
|
|
41
|
+
var wobbleIntensity: Double = 1.0
|
|
42
|
+
|
|
43
|
+
func makeConfiguration() -> BreathingConfiguration {
|
|
44
|
+
let resolvedBlobColors = blobColors.count >= 2
|
|
45
|
+
? blobColors
|
|
46
|
+
: BreathingProps.defaultBlobColors
|
|
47
|
+
|
|
48
|
+
return BreathingConfiguration(
|
|
49
|
+
blobColors: resolvedBlobColors.map { Color(uiColor: $0) },
|
|
50
|
+
innerBlobColor: Color(uiColor: innerBlobColor),
|
|
51
|
+
glowColor: Color(uiColor: glowColor),
|
|
52
|
+
particleColor: Color(uiColor: particleColor),
|
|
53
|
+
progressRingColor: Color(uiColor: progressRingColor),
|
|
54
|
+
textColor: Color(uiColor: textColor),
|
|
55
|
+
showProgressRing: showProgressRing,
|
|
56
|
+
showTextCue: showTextCue,
|
|
57
|
+
showInnerBlob: showInnerBlob,
|
|
58
|
+
showShadow: showShadow,
|
|
59
|
+
showParticles: showParticles,
|
|
60
|
+
showWavyBlobs: showWavyBlobs,
|
|
61
|
+
showGlowEffects: showGlowEffects,
|
|
62
|
+
pointCount: max(6, pointCount),
|
|
63
|
+
wobbleIntensity: max(0, min(1, wobbleIntensity))
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class ExpoBreathingExerciseView: ExpoView {
|
|
69
|
+
private var props = BreathingProps()
|
|
70
|
+
private let model: BreathingConfigurationModel
|
|
71
|
+
private let hostingController: UIHostingController<BreathingContainerView>
|
|
72
|
+
|
|
73
|
+
private let onPhaseChange = EventDispatcher()
|
|
74
|
+
private let onExerciseComplete = EventDispatcher()
|
|
75
|
+
|
|
76
|
+
required init(appContext: AppContext? = nil) {
|
|
77
|
+
let initialConfig = BreathingConfiguration()
|
|
78
|
+
let model = BreathingConfigurationModel(configuration: initialConfig)
|
|
79
|
+
self.model = model
|
|
80
|
+
self.hostingController = UIHostingController(rootView: BreathingContainerView(model: model))
|
|
81
|
+
|
|
82
|
+
super.init(appContext: appContext)
|
|
83
|
+
|
|
84
|
+
clipsToBounds = false
|
|
85
|
+
hostingController.view.backgroundColor = .clear
|
|
86
|
+
hostingController.view.clipsToBounds = false
|
|
87
|
+
hostingController.view.isOpaque = false
|
|
88
|
+
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
89
|
+
addSubview(hostingController.view)
|
|
90
|
+
|
|
91
|
+
NSLayoutConstraint.activate([
|
|
92
|
+
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
93
|
+
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
94
|
+
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
|
95
|
+
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
// Setup callbacks
|
|
99
|
+
setupCallbacks()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private func setupCallbacks() {
|
|
103
|
+
BreathingSharedState.shared.onPhaseChange = { [weak self] phase, label, phaseIndex, cycle in
|
|
104
|
+
self?.onPhaseChange([
|
|
105
|
+
"phase": phase.rawValue,
|
|
106
|
+
"label": label,
|
|
107
|
+
"phaseIndex": phaseIndex,
|
|
108
|
+
"cycle": cycle
|
|
109
|
+
])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
BreathingSharedState.shared.onExerciseComplete = { [weak self] totalCycles, totalDuration in
|
|
113
|
+
self?.onExerciseComplete([
|
|
114
|
+
"totalCycles": totalCycles,
|
|
115
|
+
"totalDuration": totalDuration
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func updateProps(_ update: (inout BreathingProps) -> Void) {
|
|
121
|
+
update(&props)
|
|
122
|
+
model.configuration = props.makeConfiguration()
|
|
123
|
+
}
|
|
124
|
+
}
|